diff --git a/Architecture.md b/Architecture.md index 0fd2a9f..fc95ce0 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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 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 `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 → Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM) → keys.get_key_code() modified nav keys → CSI 1; + enter/tab/backspace/space, modified, while the app + asked for modifyOtherKeys → CSI 27;;~ ctrl → control byte; alt → ESC prefix plain → _KEY_MAP / _APP_MODE_KEY_MAP → 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 lets apps enable 2026 on a generic `TERM`. - **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 ; ; ~`. + 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** diff --git a/README.md b/README.md index 8f7eee9..996f638 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ takes `cmd`, `cwd`, `env` and `title`: { "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 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 @@ -122,7 +124,9 @@ terminal protocols — see `Architecture.md`. Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004), 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 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; diff --git a/tests/test_emulator.py b/tests/test_emulator.py index a520b56..863077b 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -54,10 +54,18 @@ def run_case(char_by_char): # and nothing leaked to the screen 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() + assert screen.modify_other_keys == 0, tag 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 + 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 proc, screen, stream = make() diff --git a/tests/test_imports.py b/tests/test_imports.py index cd43dd7..f7d3188 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -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 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") # scheme manager produces scopes and rules diff --git a/tests/test_render.py b/tests/test_render.py index 1097949..651d5dc 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -519,10 +519,26 @@ assert not terminal._trackpad_engaged assert terminal.view.viewport == (0, ANCHOR - extent), "browsing must not snap" assert displace(-30) == "", "still browsing" 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 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 stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049h") terminal._sync_wheel_flag() @@ -537,7 +553,7 @@ tick() assert not terminal._trackpad_running 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 @@ -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)") +# 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 # replaced drag_select, which is what sublime focuses the group from class FakeWindow: diff --git a/vterm/emulator.py b/vterm/emulator.py index b914792..26c4300 100644 --- a/vterm/emulator.py +++ b/vterm/emulator.py @@ -184,6 +184,7 @@ class VtScreen(pyte.Screen): super().reset() self.cursor = Cursor(0, 0) self.cursor_style = 0 + self.modify_other_keys = 0 self.history.clear() 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 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): """Resize the screen, pushing scrolled-off lines into scrollback. @@ -617,7 +627,8 @@ class VtScreen(pyte.Screen): def report_kitty_keyboard(self): """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") def handle_osc(self, code, payload, terminator): @@ -912,7 +923,9 @@ class VtStream(pyte.Stream): listener.report_xtversion() elif final == "c": 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 if prefix: # kitty pop ('