This commit is contained in:
Vaclav Tvrdik
2026-09-09 21:42:37 +02:00
parent 301f1f8114
commit 00bab25d5d
9 changed files with 134 additions and 11 deletions

View File

@@ -200,6 +200,14 @@ the same way, and scrolling down clamps to the anchor `bottom_y` returns, which
it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate
and one Down re-opens it. and one Down re-opens it.
**Pinning waits for rest.** Two pollers put the viewport back on the anchor: the renderer
tick, for a viewport dragged into sublime's past-end room on a terminal too idle to draw
a frame, and the trackpad poller, when a browsing gesture returns to the anchor. Both
compare the viewport against the position they saw one tick earlier and act only when the
two agree. macOS keeps scrolling after the fingers lift, and a hard
`set_viewport_position` against that momentum used to yank the view back thirty times a
second for as long as the glide lasted — a visible shake at the bottom of every scroll.
### How colour is actually drawn ### How colour is actually drawn
`view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a `view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a
@@ -269,6 +277,8 @@ keymap binding (context: setting.vterm_view)
and Up/Down a row; stops here, never on the alt screen and Up/Down a row; stops here, never on the alt screen
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM) → Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final> → keys.get_key_code() modified nav keys → CSI 1;<mod><final>
enter/tab/backspace/space, modified, while the app
asked for modifyOtherKeys → CSI 27;<mod>;<code>~
ctrl → control byte; alt → ESC prefix ctrl → control byte; alt → ESC prefix
plain → _KEY_MAP / _APP_MODE_KEY_MAP plain → _KEY_MAP / _APP_MODE_KEY_MAP
→ Terminal.send_string(normalized=False) → Terminal.send_string(normalized=False)
@@ -446,7 +456,13 @@ how an app discovers what the terminal supports.
- **XTVERSION** ✅ (`CSI > q`) — the terminal's name and version. Naming ourselves is what - **XTVERSION** ✅ (`CSI > q`) — the terminal's name and version. Naming ourselves is what
lets apps enable 2026 on a generic `TERM`. lets apps enable 2026 on a generic `TERM`.
- **Kitty keyboard protocol** — a richer key encoding with release events. vterm answers - **Kitty keyboard protocol** — a richer key encoding with release events. vterm answers
the probe with "unsupported", so apps fall back to the legacy encodings it does send. the probe (`CSI ? u`) with zero flags, so apps fall back to something else — for pi that
is modifyOtherKeys, which it then enables unprompted.
- **modifyOtherKeys** ✅ (`CSI > 4 ; n m`) — xterm's pre-kitty answer to the same problem:
keys whose modified form has no legacy encoding are sent as `CSI 27 ; <mod> ; <code> ~`.
While an app has it on, vterm uses that form for modified enter, tab, backspace and space —
Shift+Enter is `CSI 27;2;13~`, since a bare `ESC CR` is indistinguishable from Alt+Enter.
Ctrl+letters keep their control bytes, so Ctrl+C stays `\x03`.
**Other terms** **Other terms**

View File

@@ -41,7 +41,9 @@ takes `cmd`, `cwd`, `env` and `title`:
{ "caption": "vterm: pi", "command": "vterm_open", "args": {"cmd": ["pi"], "title": "pi"} } { "caption": "vterm: pi", "command": "vterm_open", "args": {"cmd": ["pi"], "title": "pi"} }
``` ```
Keys: Shift+Enter sends `ESC CR` (newline in both agents). Shift+PgUp/PgDn page Keys: Shift+Enter sends `ESC CR`, or `CSI 27;2;13~` while the app has xterm
modifyOtherKeys on — pi turns it on and reads a bare `ESC CR` as Alt+Enter,
which submits instead of breaking the line. Shift+PgUp/PgDn page
the view over the scrollback and Shift+Up/Down move it one line, instead of the view over the scrollback and Shift+Up/Down move it one line, instead of
reaching the app, as xterm and kitty do — `scroll_modifier` moves that to Ctrl, reaching the app, as xterm and kitty do — `scroll_modifier` moves that to Ctrl,
or turns it off. Copy/paste use the normal Sublime keys, and pastes are or turns it off. Copy/paste use the normal Sublime keys, and pastes are
@@ -122,7 +124,9 @@ terminal protocols — see `Architecture.md`.
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004), Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
drag, no right or middle button), focus events (1004), cursor styles (DECSCUSR — drag, no right or middle button), focus events (1004),
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
block/bar and blink/steady via the view's caret settings; underline falls back block/bar and blink/steady via the view's caret settings; underline falls back
to bar, 0 is the default blinking block), scroll regions, autowrap, to bar, 0 is the default blinking block), scroll regions, autowrap,
50 000 lines of scrollback that ED 3 does not erase (agents repaint with it; 50 000 lines of scrollback that ED 3 does not erase (agents repaint with it;

View File

@@ -54,10 +54,18 @@ def run_case(char_by_char):
# and nothing leaked to the screen # and nothing leaked to the screen
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0]) assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
# modifyOtherKeys must not corrupt SGR state # modifyOtherKeys: the level is tracked (pi enables it and reads shift+enter
# no other way), and the sequence must not corrupt SGR state
proc, screen, stream = make() proc, screen, stream = make()
assert screen.modify_other_keys == 0, tag
feed(stream, "\x1b[>4;2mhello", char_by_char) feed(stream, "\x1b[>4;2mhello", char_by_char)
assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys)
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
feed(stream, "\x1b[>4;0m", char_by_char)
assert screen.modify_other_keys == 0, (tag, screen.modify_other_keys)
# another resource, and the bare form, leave it alone
feed(stream, "\x1b[>4;2m\x1b[>1;2m\x1b[>m", char_by_char)
assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys)
# OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped # OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped
proc, screen, stream = make() proc, screen, stream = make()

View File

@@ -52,6 +52,15 @@ for char in string.ascii_letters + string.digits + string.punctuation:
assert produced == char, "{!r} would send {!r}".format(char, produced) assert produced == char, "{!r} would send {!r}".format(char, produced)
assert get_key_code(key="space") == " ", "space must send a space" assert get_key_code(key="space") == " ", "space must send a space"
# legacy encodings by default; while the app has modifyOtherKeys on, the keys
# whose modified form is ambiguous take the CSI 27 form instead. pi reads a
# bare ESC CR as alt+enter and submits on it
assert get_key_code(key="enter", shift=True) == "\x1b\r"
assert get_key_code(key="enter", shift=True, modify_other_keys=2) == "\x1b[27;2;13~"
assert get_key_code(key="space", ctrl=True, modify_other_keys=2) == "\x1b[27;5;32~"
assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys stay legacy"
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
load_sublime_json("Default (OSX).sublime-mousemap") load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules # scheme manager produces scopes and rules

View File

@@ -519,10 +519,26 @@ assert not terminal._trackpad_engaged
assert terminal.view.viewport == (0, ANCHOR - extent), "browsing must not snap" assert terminal.view.viewport == (0, ANCHOR - extent), "browsing must not snap"
assert displace(-30) == "", "still browsing" assert displace(-30) == "", "still browsing"
terminal.view.viewport = (0, ANCHOR) terminal.view.viewport = (0, ANCHOR)
tick() # back at the anchor: re-engage tick() # back at the anchor, but still gliding: nothing may pin it yet
assert not terminal._trackpad_engaged
tick() # at rest on the anchor: re-engage
assert terminal._trackpad_engaged assert terminal._trackpad_engaged
assert displace(20) == "\x1b[<65;1;3M" assert displace(20) == "\x1b[<65;1;3M"
# momentum carrying past the anchor must glide unpinned — pinning it mid-glide
# is the shake — and be corrected in one step once it stops, without a report
assert displace(-extent) == ""
assert not terminal._trackpad_engaged
terminal.process.written = ""
terminal.view.viewport = (0, ANCHOR + 40)
tick()
assert terminal.view.viewport == (0, ANCHOR + 40), "gliding must not snap"
assert not terminal._trackpad_engaged
tick()
assert terminal._trackpad_engaged
assert terminal.view.viewport == (0, ANCHOR), terminal.view.viewport
assert terminal.process.written == "", repr(terminal.process.written)
# on the alt screen without tracking the same displacement turns into arrows # on the alt screen without tracking the same displacement turns into arrows
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049h") stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049h")
terminal._sync_wheel_flag() terminal._sync_wheel_flag()
@@ -537,7 +553,7 @@ tick()
assert not terminal._trackpad_running assert not terminal._trackpad_running
assert sublime._timeouts == [], sublime._timeouts assert sublime._timeouts == [], sublime._timeouts
print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, poller unhooks)") print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, momentum settles, poller unhooks)")
# with the configured modifier PgUp/PgDn page the view and Up/Down move it one # with the configured modifier PgUp/PgDn page the view and Up/Down move it one
@@ -641,6 +657,25 @@ plugin_settings.set("scroll_modifier", "shift")
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)") print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
# form once the app turned modifyOtherKeys on. pi enables it (its kitty probe
# gets no flags from us) and reads a bare ESC CR as alt+enter, which submits
press(key="enter", shift=True)
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
stream.feed("\x1b[>4;2m")
press(key="enter", shift=True)
assert terminal.process.written == "\x1b[27;2;13~", repr(terminal.process.written)
press(key="enter")
assert terminal.process.written == "\r", "unmodified enter still submits"
press(key="c", ctrl=True)
assert terminal.process.written == "\x03", "ctrl+c stays a control byte"
stream.feed("\x1b[>4;0m")
press(key="enter", shift=True)
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
print("modifyOtherKeys tests passed (shift+enter as CSI 27 while the app asks for it)")
# a click in an unfocused group must focus the tab instead of reporting: we # a click in an unfocused group must focus the tab instead of reporting: we
# replaced drag_select, which is what sublime focuses the group from # replaced drag_select, which is what sublime focuses the group from
class FakeWindow: class FakeWindow:

View File

@@ -184,6 +184,7 @@ class VtScreen(pyte.Screen):
super().reset() super().reset()
self.cursor = Cursor(0, 0) self.cursor = Cursor(0, 0)
self.cursor_style = 0 self.cursor_style = 0
self.modify_other_keys = 0
self.history.clear() self.history.clear()
self._clear_callback() self._clear_callback()
@@ -192,6 +193,15 @@ class VtScreen(pyte.Screen):
# DECSCUSR: 0 default, 1/2 block, 3/4 underline, 5/6 bar; odd = blinking # DECSCUSR: 0 default, 1/2 block, 3/4 underline, 5/6 bar; odd = blinking
self.cursor_style = style self.cursor_style = style
def set_modify_other_keys(self, resource=0, value=0):
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~.
pi turns this on whenever the kitty probe answers with no flags, and
reads shift+enter no other way.
"""
if resource == 4:
self.modify_other_keys = value
def resize(self, lines=None, columns=None): def resize(self, lines=None, columns=None):
"""Resize the screen, pushing scrolled-off lines into scrollback. """Resize the screen, pushing scrolled-off lines into scrollback.
@@ -617,7 +627,8 @@ class VtScreen(pyte.Screen):
def report_kitty_keyboard(self): def report_kitty_keyboard(self):
"""Answer the kitty keyboard protocol probe.""" """Answer the kitty keyboard protocol probe."""
# 0 = unsupported, so apps fall back to legacy key encodings # answering at all means "supported"; 0 flags means none are enabled,
# which is what steers pi onto its modifyOtherKeys fallback
self.write_process_input("\x1b[?0u") self.write_process_input("\x1b[?0u")
def handle_osc(self, code, payload, terminator): def handle_osc(self, code, payload, terminator):
@@ -912,7 +923,9 @@ class VtStream(pyte.Stream):
listener.report_xtversion() listener.report_xtversion()
elif final == "c": elif final == "c":
listener.report_secondary_device_attributes(*params) listener.report_secondary_device_attributes(*params)
# '>m' modifyOtherKeys, '>u' kitty push: ignore elif final == "m":
listener.set_modify_other_keys(*params)
# '>u' kitty push: ignore
return return
if prefix: if prefix:
# kitty pop ('<u'), DECKPAM-style ('='): ignore # kitty pop ('<u'), DECKPAM-style ('='): ignore

View File

@@ -94,19 +94,26 @@ _SHIFT_KEY_MAP = {
"enter": "\x1b\r", "enter": "\x1b\r",
} }
# keys whose modified form has no unambiguous legacy encoding: ESC CR reads as
# alt+enter, ctrl+space as NUL. An app that turned modifyOtherKeys on gets them
# as CSI 27 ; <mod> ; <code> ~ instead
_OTHER_KEYS = {"enter": 13, "tab": 9, "backspace": 127, "space": 32}
def _modifier_code(ctrl, alt, shift): def _modifier_code(ctrl, alt, shift):
"""xterm modifier parameter: 1 plus a bitmask of shift/alt/ctrl.""" """xterm modifier parameter: 1 plus a bitmask of shift/alt/ctrl."""
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0) return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False): def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
modify_other_keys=0):
"""Escape sequence for a key name and its modifiers. """Escape sequence for a key name and its modifiers.
Args: Args:
key: key name from the keymap, or a single literal character. key: key name from the keymap, or a single literal character.
application_mode: DECCKM is set, so cursor keys take their SS3 form. application_mode: DECCKM is set, so cursor keys take their SS3 form.
new_line_mode: LNM is set, so enter sends CR LF. new_line_mode: LNM is set, so enter sends CR LF.
modify_other_keys: the level the app asked for with CSI > 4 ; n m.
Returns: Returns:
str: the bytes to write to the pty. str: the bytes to write to the pty.
@@ -116,6 +123,9 @@ def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI: if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift)) return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
if modify_other_keys and (ctrl or alt or shift) and key_lo in _OTHER_KEYS:
return "\x1b[27;{};{}~".format(_modifier_code(ctrl, alt, shift), _OTHER_KEYS[key_lo])
if ctrl: if ctrl:
base = key_lo base = key_lo
if base in _CTRL_KEY_MAP: if base in _CTRL_KEY_MAP:

View File

@@ -126,6 +126,7 @@ def start_trackpad(terminal):
terminal._trackpad_running = True terminal._trackpad_running = True
terminal._trackpad_engaged = True terminal._trackpad_engaged = True
terminal._trackpad_baseline = None terminal._trackpad_baseline = None
terminal._trackpad_browse_y = None
sublime.set_timeout(lambda: _trackpad_tick(terminal), TRACKPAD_POLL_MS) sublime.set_timeout(lambda: _trackpad_tick(terminal), TRACKPAD_POLL_MS)
@@ -143,9 +144,16 @@ def _trackpad_tick(terminal):
terminal._trackpad_baseline = (anchor, extent) terminal._trackpad_baseline = (anchor, extent)
terminal._trackpad_acc = 0.0 terminal._trackpad_acc = 0.0
elif not terminal._trackpad_engaged: elif not terminal._trackpad_engaged:
if following(view, terminal): # re-engaging mid-glide harvests the momentum as a gesture and snaps
# the view back on every tick, which reads as shaking. Wait for two
# ticks to agree that it has come to rest, then correct the overshoot
# in one step — it was browsing, not a scroll the app should hear about
y = view.viewport_position()[1]
if y == terminal._trackpad_browse_y and following(view, terminal):
terminal._trackpad_engaged = True terminal._trackpad_engaged = True
terminal._trackpad_acc = 0.0 terminal._trackpad_acc = 0.0
view.set_viewport_position((0, anchor), False)
terminal._trackpad_browse_y = y
else: else:
dy = view.viewport_position()[1] - anchor dy = view.viewport_position()[1] - anchor
if dy == 0: if dy == 0:
@@ -155,6 +163,7 @@ def _trackpad_tick(terminal):
# scrollbar/minimap jump or page scroll, not a trackpad gesture: # scrollbar/minimap jump or page scroll, not a trackpad gesture:
# let the user browse until they return to the anchor # let the user browse until they return to the anchor
terminal._trackpad_engaged = False terminal._trackpad_engaged = False
terminal._trackpad_browse_y = None
else: else:
terminal._trackpad_acc += dy / view.line_height() terminal._trackpad_acc += dy / view.line_height()
lines = int(terminal._trackpad_acc) lines = int(terminal._trackpad_acc)

View File

@@ -110,11 +110,13 @@ class Terminal:
self._wheel_captured = False self._wheel_captured = False
self._scroll_pending = False self._scroll_pending = False
# trackpad poller (mouse.start_trackpad): reschedule guard, fractional # trackpad poller (mouse.start_trackpad): reschedule guard, fractional
# line remainder, harvesting vs. browsing, last seen (anchor, extent) # line remainder, harvesting vs. browsing, last seen (anchor, extent),
# viewport of the previous browsing tick
self._trackpad_running = False self._trackpad_running = False
self._trackpad_acc = 0.0 self._trackpad_acc = 0.0
self._trackpad_engaged = True self._trackpad_engaged = True
self._trackpad_baseline = None self._trackpad_baseline = None
self._trackpad_browse_y = None
self.pending_clear = False self.pending_clear = False
self.pending_clear_screen = False self.pending_clear_screen = False
# a forced frame (set on activation); rendering stays on the renderer # a forced frame (set on activation); rendering stays on the renderer
@@ -290,6 +292,7 @@ class Terminal:
def renderer(): def renderer():
"""Own the emulator: parse, then ask sublime to redraw, once per tick.""" """Own the emulator: parse, then ask sublime to redraw, once per tick."""
settled_y = None
while True: while True:
# a hidden tab is not drawn at all: the screen keeps the state # a hidden tab is not drawn at all: the screen keeps the state
# and one render on unhide catches the view up. The tick stays at # and one render on unhide catches the view up. The tick stays at
@@ -307,6 +310,21 @@ class Terminal:
# due first: _need_to_render consumes the cursor move it reports # due first: _need_to_render consumes the cursor move it reports
if self.is_render_due() and self._need_to_render(): if self.is_render_due() and self._need_to_render():
self.view.run_command("vterm_render") self.view.run_command("vterm_render")
# a frame can move the viewport: a sample from
# before it says nothing about coming to rest
settled_y = None
else:
# the wheel can drag the screen off the top into
# sublime's past-end room; a frame undoes that, and
# an idle terminal draws none. Pinning while the
# view is still gliding fights the trackpad's
# momentum, which reads as shaking, so wait for two
# ticks to agree that it has come to rest
y = self.view.viewport_position()[1]
if y == settled_y and self.viewport_y < y:
from .render import request_scroll # module-level import would cycle
request_scroll(self.view, self)
settled_y = y
self._sync_wheel_flag() self._sync_wheel_flag()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops") LOG.debug("[renderer] stops")
@@ -348,6 +366,7 @@ class Terminal:
"""Encode a key name plus modifiers for the app's current modes and send it.""" """Encode a key name plus modifiers for the app's current modes and send it."""
kwargs["application_mode"] = APP_CURSOR_MODE in self.screen.mode kwargs["application_mode"] = APP_CURSOR_MODE in self.screen.mode
kwargs["new_line_mode"] = mo.LNM in self.screen.mode kwargs["new_line_mode"] = mo.LNM in self.screen.mode
kwargs["modify_other_keys"] = self.screen.modify_other_keys
self.send_string(get_key_code(**kwargs), normalized=False) self.send_string(get_key_code(**kwargs), normalized=False)
def send_string(self, string, normalized=True): def send_string(self, string, normalized=True):