Skip to content

Track DECSET/DECRST 1007 (Alternate Scroll Mode) - #633

Merged
migueldeicaza merged 2 commits into
migueldeicaza:mainfrom
jizhi0v0:feature/decset-1007-alternate-scroll
Aug 18, 2026
Merged

Track DECSET/DECRST 1007 (Alternate Scroll Mode)#633
migueldeicaza merged 2 commits into
migueldeicaza:mainfrom
jizhi0v0:feature/decset-1007-alternate-scroll

Conversation

@jizhi0v0

Copy link
Copy Markdown
Contributor

What

Private mode 1007 is xterm's Alternate Scroll Mode, which ctlseqs documents as corresponding to the alternateScroll resource: while the alternate screen is active and the application has not enabled mouse tracking, the terminal translates wheel input into cursor up/down keys, so full-screen programs that do not read the mouse (less, vim without mouse=a) still respond to the scroll wheel.

SwiftTerm currently drops 1007 into the Unhandled DEC Private Mode Set/Reset branch. That has two consequences for an embedder that implements the translation (as SwiftTerm's own macOS view effectively does today, unconditionally): an application has no way to turn it off, and DECRQM cannot answer a query about it even though the neighbouring mouse modes 1000–1006 all can.

What this does

Tracks the state only, exposed as Terminal.alternateScrollMode. Translating wheel events stays with the host view, consistent with how the other mouse-related state is handled. Wired into DECSET, DECRST, DECRQM and RIS, mirroring how 1004 is done.

One choice worth a second opinion

The default here is true, matching Ghostty (src/terminal/modes.zig marks mouse_alternate_scroll default true), while xterm's own alternateScroll resource defaults to false. true keeps the wheel working out of the box in less/vim, which is what users of macOS terminals tend to expect, and it preserves SwiftTerm's current behaviour for hosts that already translate unconditionally. If you would rather follow the reference implementation, flipping it to false is a one-line change — happy to do that.

Tests

Tests/SwiftTermTests/AlternateScrollModeTests.swift: default value, DECSET/DECRST toggling, DECRQM reporting 1$y/2$y, RIS restoring the default, and independence from alt-screen / mouse-tracking state.

Full suite: 704 tests in 62 suites, all passing.

Private mode 1007 is xterm's Alternate Scroll Mode, which corresponds to its
alternateScroll resource: while the alternate screen is active and the
application has not enabled mouse tracking, the terminal translates wheel
input into cursor up/down keys, so full-screen programs that do not read the
mouse (less, vim without mouse=a) still respond to scrolling.

SwiftTerm currently drops 1007 into the "Unhandled DEC Private Mode" branch,
so an embedder that implements this translation has no way to let an
application turn it off, and DECRQM cannot answer a query about it.

This only tracks the state and exposes it as Terminal.alternateScrollMode;
translating wheel events stays with the host view, as with the other mouse
state SwiftTerm tracks. The mode is wired into DECSET, DECRST, DECRQM and
RIS, matching how 1004 is handled.

One deliberate choice worth a second opinion: the default here is true,
matching Ghostty (src/terminal/modes.zig marks mouse_alternate_scroll
default true), while xterm's own alternateScroll resource defaults to false.
True keeps the wheel working out of the box in less/vim, which is what
users on macOS terminals tend to expect, but I am happy to flip it to false
to match xterm if you would rather follow the reference implementation.
@migueldeicaza

Copy link
Copy Markdown
Owner

What can I do to experiment with this with different terminal emulators?

@jizhi0v0

Copy link
Copy Markdown
Contributor Author

Here's a way to poke at it directly.

What each emulator thinks the mode is. DECRQM (CSI ? 1007 $ p) answers with CSI ? 1007 ; Ps $ y, where Ps is 0 = not recognized, 1 = set, 2 = reset, 3/4 = permanently set/reset (ctlseqs). Worth querying 1004 and 1006 alongside as controls, so a silent 1007 can be told apart from an emulator that simply doesn't answer DECRQM:

old=$(stty -g); stty raw -echo min 0 time 3
printf '\033[?1007$p'; dd bs=32 count=1 2>/dev/null | cat -v; stty "$old"

On this Mac:

emulator 1007 1004 1006
Terminal.app no reply no reply no reply
Ghostty 1.3.1 1 (set) 2 2
SwiftTerm (this PR's base) 0 (not recognized) 2 2

Terminal.app doesn't answer DECRQM at all, so it can't tell us anything here. Ghostty reports the mode as set out of the box. SwiftTerm answers for the neighbouring modes but not for 1007, which is what this PR changes.

What the mode actually does. This puts the terminal on the alternate screen with mouse tracking off, prints whatever arrives, and toggles 1007 with t (q quits):

old=$(stty -g); trap 'stty "$old"; printf "\033[?1049l"' EXIT
printf '\033[?1049h\033[?1000l\033[?1002l\033[?1003l\033[?1006l'
stty raw -echo min 0 time 1; on=1
while :; do c=$(dd bs=64 count=1 2>/dev/null); [ -z "$c" ] && continue
  case "$c" in q*) break;; t*) [ $on -eq 1 ] && { printf '\033[?1007l'; on=0; } || { printf '\033[?1007h'; on=1; }; continue;; esac
  printf 'got: %s\r\n' "$(printf '%s' "$c" | cat -v)"; done

In Ghostty 1.3.1, scrolling with the mode set gives you ^[[A/^[[B; toggle it off with t and the wheel goes silent — verified by flipping it back and forth. In SwiftTerm today t changes nothing, the arrows come either way, since the mode isn't tracked. That's the gap this PR closes: the behaviour is already implemented, an application just has no way to turn it off.

On the default value I have no strong opinion and it's your call — Ghostty ships true (src/terminal/modes.zig, mouse_alternate_scroll), xterm's alternateScroll resource is false. I picked true because it leaves SwiftTerm's current behaviour unchanged for hosts that already translate the wheel unconditionally. Flipping it is one line.

@migueldeicaza

Copy link
Copy Markdown
Owner

Thanks, will try

@migueldeicaza

Copy link
Copy Markdown
Owner

ok, this changes the state management, but does not change the actual handling of the scroll wheel - I do have a system to test, but I suspect you might.

The issue is this:

} else if terminal.isDisplayBufferAlternate {
    for _ in 0..<magnitude {
        if scrollingUp {
            sendKeyUp()
        } else {
            sendKeyDown()
        }
    }
}

That code likely needs to check the new setting - so we are sending the events regardless of the setting.

I think the test needs to be update to include "&& terminal.alternateScrollMode"

Tracking the mode is not enough: TerminalView translated the wheel into
cursor keys whenever the alternate screen was active, so an application
could set or reset 1007 and see no difference. Gate that translation on
terminal.alternateScrollMode; with the mode reset the wheel produces
nothing, matching xterm (the alternate buffer has no scrollback to move
either).

The check sits before the delta accumulator rather than inside the
alternate-screen branch. Sub-line trackpad deltas return early at
`lines == 0`, so a check further down never sees them and their
remainder stays banked in the accumulator — five suppressed sub-line
events followed by DECSET 1007 then made the next event emit a cursor
key it had not earned. Deciding the route first also lets the existing
mouse-reporting condition be named once instead of twice.

Also documents 1007 in the DECRST comment list, which the previous
commit only added to the DECSET side.

Tests: the wheel emits a cursor key with the mode set and nothing with
it reset, and motion accumulated while suppressed is not spent once the
mode returns.
@jizhi0v0

Copy link
Copy Markdown
Contributor Author

You're right, and thanks — the view kept sending cursor keys either way, so an application could set or reset 1007 and see exactly nothing. Fixed in b00cc89.

One deliberate difference from what you sketched: the check sits a bit earlier instead of being &&-ed onto the else if. Sub-line trackpad deltas return at lines == 0 before they ever reach that branch, and by then the accumulator has already swallowed them — so with the check down there, scrolling in less while 1007 is off quietly banks the motion, and the first scroll after the app turns the mode back on jumps a line it never earned. Easy to reproduce: five sub-line events while suppressed, then CSI ?1007h, then one more event — that last one emits an arrow, where a fresh accumulator would send nothing. Deciding the route before touching the accumulator (and clearing it when we suppress) takes care of it, and it lets the mouse-reporting condition be named once instead of spelled out twice.

Two tests came along: the wheel emits a cursor key with the mode set and nothing with it reset, and motion accumulated while suppressed isn't spent once the mode comes back. Both fail without the fix. Full suite is at 704 passing.

The default is still true — happy to flip it to false to match xterm's alternateScroll if you'd prefer the reference behaviour, it's a one-liner.

@migueldeicaza

Copy link
Copy Markdown
Owner

Thank you!

@migueldeicaza
migueldeicaza merged commit 464df52 into migueldeicaza:main Aug 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants