Skip to content

Commit 9edde69

Browse files
committed
Track DECSET/DECRST 1007 (Alternate Scroll Mode)
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.
1 parent 1052996 commit 9edde69

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

Sources/SwiftTerm/Terminal.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,16 @@ open class Terminal {
476476
/// Indicates that the application has toggled bracketed paste mode, which means that when content is pasted into
477477
/// the terminal, the content will be wrapped in "ESC [ 200 ~" to start, and "ESC [ 201 ~" to end.
478478
public private(set) var bracketedPasteMode: Bool = false
479+
480+
/// Tracks DECSET/DECRST private mode 1007 (Alternate Scroll Mode, xterm's "alternateScroll" resource).
481+
/// When true and the alternate screen buffer is active without an application mouse-tracking mode enabled,
482+
/// hosts are expected to translate scroll wheel input into cursor up/down key sequences instead of scrolling,
483+
/// so that full-screen apps that do not read the mouse (e.g. `less`, `vim` without `mouse=a`) still respond
484+
/// to the scroll wheel. SwiftTerm only tracks the mode's state here; translating wheel events is left to the
485+
/// host view, which can read this property to decide how to route them.
486+
/// xterm's own default for this resource is false; we default to true here to match modern terminals
487+
/// (e.g. Ghostty) that enable it out of the box.
488+
public private(set) var alternateScrollMode: Bool = true
479489

480490
private var charset: [UInt8:String]? = nil
481491
private var gCharsets: [[UInt8:String]?] = [CharSets.defaultCharset, nil, nil, nil]
@@ -954,6 +964,7 @@ open class Terminal {
954964
setInsertMode(false)
955965
setWraparound(true)
956966
bracketedPasteMode = false
967+
alternateScrollMode = true
957968

958969
keyboardModeNormal = KeyboardModeState()
959970
keyboardModeAlt = KeyboardModeState()
@@ -4428,6 +4439,8 @@ open class Terminal {
44284439
res = mouseProtocol == .utf8 ? modeSet : modeReset
44294440
case 1006:
44304441
res = mouseProtocol == .sgr ? modeSet : modeReset
4442+
case 1007:
4443+
res = alternateScrollMode ? modeSet : modeReset
44314444
case 1015:
44324445
res = mouseProtocol == .urxvt ? modeSet : modeReset
44334446
case 1016:
@@ -5257,6 +5270,8 @@ open class Terminal {
52575270
mouseMode = .off
52585271
case 1004: // send focusin/focusout events
52595272
sendFocus = false
5273+
case 1007: // alternate scroll mode (xterm's alternateScroll resource)
5274+
alternateScrollMode = false
52605275
case 2500: // box drawing mirroring off
52615276
updateCurrentBidiState(property: .boxMirroring) { $0.boxMirroring = false }
52625277
case 2501: // autodetect off: use the SPD-selected direction
@@ -5356,6 +5371,8 @@ open class Terminal {
53565371
// Ps = 1 0 0 3 -> Use All Motion Mouse Tracking.
53575372
// Ps = 1 0 0 4 -> Send FocusIn/FocusOut events.
53585373
// Ps = 1 0 0 5 -> Enable Extended Mouse Mode.
5374+
// Ps = 1 0 0 7 -> Enable Alternate Scroll Mode, xterm. This
5375+
// corresponds to the alternateScroll resource.
53595376
// Ps = 1 0 1 0 -> Scroll to bottom on tty output (rxvt).
53605377
// Ps = 1 0 1 1 -> Scroll to bottom on key press (rxvt).
53615378
// Ps = 1 0 3 4 -> Interpret "meta" key, sets eighth bit.
@@ -5506,6 +5523,8 @@ open class Terminal {
55065523
// the application does not assume it is unfocused until the
55075524
// first real focus change.
55085525
sendFocusReport()
5526+
case 1007: // alternate scroll mode (xterm's alternateScroll resource)
5527+
alternateScrollMode = true
55095528
case 2500: // box drawing mirroring (terminal-wg)
55105529
updateCurrentBidiState(property: .boxMirroring) { $0.boxMirroring = true }
55115530
case 2501: // autodetect paragraph direction (terminal-wg)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//
2+
// AlternateScrollModeTests.swift
3+
//
4+
// DECSET/DECRST 1007: Alternate Scroll Mode (xterm's "alternateScroll" resource).
5+
// SwiftTerm only tracks the mode's boolean state on Terminal.alternateScrollMode;
6+
// translating scroll wheel events into cursor keys while the alt screen is active
7+
// is the host view's responsibility.
8+
//
9+
import Foundation
10+
import Testing
11+
12+
@testable import SwiftTerm
13+
14+
final class AlternateScrollModeTests: TerminalDelegate {
15+
var sent: [UInt8] = []
16+
17+
func send(source: Terminal, data: ArraySlice<UInt8>) {
18+
sent.append(contentsOf: data)
19+
}
20+
21+
func makeTerminal() -> Terminal {
22+
Terminal(delegate: self, options: TerminalOptions(cols: 80, rows: 25))
23+
}
24+
25+
@Test func defaultsToEnabled() {
26+
let terminal = makeTerminal()
27+
#expect(terminal.alternateScrollMode == true, "SwiftTerm defaults 1007 to on, matching modern terminals like Ghostty")
28+
}
29+
30+
@Test func decsetEnablesAndDecrstDisables() {
31+
let terminal = makeTerminal()
32+
33+
terminal.feed(text: "\u{1b}[?1007l")
34+
#expect(terminal.alternateScrollMode == false)
35+
36+
terminal.feed(text: "\u{1b}[?1007h")
37+
#expect(terminal.alternateScrollMode == true)
38+
39+
terminal.feed(text: "\u{1b}[?1007l")
40+
#expect(terminal.alternateScrollMode == false)
41+
}
42+
43+
@Test func fullResetRestoresDefault() {
44+
let terminal = makeTerminal()
45+
terminal.feed(text: "\u{1b}[?1007l")
46+
#expect(terminal.alternateScrollMode == false)
47+
48+
// RIS (Reset to Initial State) should restore the mode to its default (on).
49+
terminal.feed(text: "\u{1b}c")
50+
#expect(terminal.alternateScrollMode == true)
51+
}
52+
53+
@Test func modeIsIndependentOfAltScreenAndMouseTracking() {
54+
let terminal = makeTerminal()
55+
terminal.feed(text: "\u{1b}[?1007l")
56+
terminal.feed(text: "\u{1b}[?1049h") // enter alt screen
57+
#expect(terminal.alternateScrollMode == false, "entering the alt screen must not change 1007's own state")
58+
59+
terminal.feed(text: "\u{1b}[?1000h") // vt200 mouse tracking
60+
#expect(terminal.alternateScrollMode == false, "enabling mouse tracking must not implicitly change 1007's state")
61+
}
62+
63+
/// DECRQM (`CSI ? 1007 $ p`) must report the mode, the way the neighbouring
64+
/// mouse modes (1000-1006) already do — otherwise an application can set the
65+
/// mode but never find out what it currently is.
66+
@Test func decrqmReportsCurrentState() {
67+
let terminal = makeTerminal()
68+
69+
sent = []
70+
terminal.feed(text: "\u{1b}[?1007$p")
71+
#expect(String(decoding: sent, as: UTF8.self) == "\u{1b}[?1007;1$y", "set is reported as 1")
72+
73+
terminal.feed(text: "\u{1b}[?1007l")
74+
sent = []
75+
terminal.feed(text: "\u{1b}[?1007$p")
76+
#expect(String(decoding: sent, as: UTF8.self) == "\u{1b}[?1007;2$y", "reset is reported as 2")
77+
}
78+
}

0 commit comments

Comments
 (0)