diff --git a/Architecture.md b/Architecture.md index d9eff2b..0fd2a9f 100644 --- a/Architecture.md +++ b/Architecture.md @@ -34,7 +34,7 @@ Constraints it works under: | `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). | | `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. | | `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. | -| `vterm/mouse.py` | Wheel and click handling. | +| `vterm/mouse.py` | Wheel and click handling, and the trackpad poller. | | `vterm/keys.py` | Key name + modifiers → escape sequence tables. | | `vterm/utils.py` | `settings()`, the `responsive` throttle, the `intermission` pacer, `rev_wcwidth`. | @@ -74,8 +74,9 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the generated scheme file and the mapping from colour combination to scope name. - **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per - view**, so its `colored_lines`, `_row_cache`, `_key_counter` and `_lastrow` are that - view's render state. + view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk + bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and + `_lastrow` are that view's render state. ### Commands @@ -132,6 +133,8 @@ child writes │ _need_to_render()? dirty rows, a pending clear, or a │ moved cursor │ view.run_command("vterm_render") + │ _sync_wheel_flag() publishes the mousemap gate, and starts the + │ trackpad poller while the app owns the wheel │ └─ UI thread ────────────────────────────────────────────────── VtermRenderCommand.run(edit) @@ -180,6 +183,9 @@ view row of screen row 0. When the emulator retires lines into `screen.history`, renderer drains them, advances `offset` by that many rows, and writes them at their now permanent view rows. **Once a line leaves `screen.history`, its only copy is the view text** — which is why the alternate screen *folds* the scrollback rather than deleting it. +A fresh view is given one blank scrollback row before anything else: Sublime lays out +nothing above row 0, and the trackpad poller needs that slack to see an upward gesture at +all — on the alt screen no line ever retires to provide it. **The row cache.** pyte's `dirty` set is a repaint *hint*, not a diff: scrolling one line marks every row dirty, and a TUI repaints whole frames. `update_line` compares @@ -189,8 +195,10 @@ keeps a full-screen repaint from costing hundreds of cross-process calls. **The follow-scroll gate.** `following()` compares the viewport against `terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled up by hand, and trimming, caret placement and scrolling are all skipped so their position -survives the next frame. `scroll_page` (the PgUp/PgDn chord) closes the gate the same way, -and paging down clamps to the anchor `bottom_y` returns, which re-opens it. +survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate +the same way, and scrolling down clamps to the anchor `bottom_y` returns, which re-opens +it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate +and one Down re-opens it. ### How colour is actually drawn @@ -236,11 +244,15 @@ Details worth knowing before touching `scheme.py`: ### The alternate screen -While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away and -sets `scroll_past_end` to false, leaving the view with no scroll range — the app's own -scrolling is the only scrolling. The fold is re-checked every frame, not just on -transitions, because the screen's first row is rewritten on every region scroll and an -edit at the fold boundary can silently break a fold. The emulator side +While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so +the view shows the app's screen alone. The scroll range is deliberately left in place: the +trackpad poller pins the viewport and needs the slack to read a gesture. The fold is +re-checked every frame, not just on transitions, because the screen's first row is +rewritten on every region scroll and an edit at the fold boundary can silently break a +fold. Entering the alt screen collapses the layout, so that transition also zeroes +`terminal.viewport_y`; a stale anchor would hold the follow-scroll gate shut. A fold +merely repaired keeps the anchor, or the boundary row apps rewrite on every scroll would +thrash the poller. The emulator side (`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its frames into scrollback. @@ -253,8 +265,8 @@ normal mode) would leave the typed character sitting in the view. ``` keymap binding (context: setting.vterm_view) → VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…) - → page_scroll() PgUp/PgDn with page_scroll_modifier scrolls the view - and stops here; never on the alternate screen + → view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page + 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; ctrl → control byte; alt → ESC prefix @@ -291,6 +303,17 @@ which produces three cases: 3. **Neither** → the binding does not match at all and Sublime scrolls the view over the real scrollback text. +**The trackpad** fires none of that: on macOS it never reaches a mousemap binding +(sublime_text#1838), and its only trace is the viewport moving. So while the wheel flag is +up, `start_trackpad` runs a 24 ms timer chain, and any displacement from the follow anchor +is a gesture — whole lines of it go out through the same `send_scroll` as the wheel, the +fraction is carried into the next tick, and the viewport is pinned back to the anchor. A +displacement of a viewport or more is a scrollbar drag or a page scroll instead: the poller +disengages and lets the user browse until they return to the anchor. `_sync_wheel_flag` +restarts the chain within a frame or two of it stopping on a hidden tab or a capture flap, +and `scroll_to_cursor` yields while the poller owns the pin — apps repaint mid-gesture, and +re-pinning there would discard displacement not yet harvested. + Clicks: `VtermMouseEventListener` intercepts `drag_select` only when the app tracks the mouse, and only the plain single-button form, so shift-click and add-selection stay native. The first click on an unfocused tab only focuses it — we replaced the command @@ -312,7 +335,7 @@ that normally does that — and subsequent clicks send a press/release pair. | Thread | Runs | Touches | |---|---|---| -| UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, clipboard | Sublime API; small pty writes go out here too | +| UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, `_trackpad_tick`, clipboard | Sublime API; small pty writes go out here too | | reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state | | renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator | | paste drain | transient; empties the 4096-byte chunk queue | pty only | @@ -334,9 +357,9 @@ replaces both together. **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()` handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, -`page_scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`, -`parameters`, `debug`. `page_scroll_modifier` is the modifier that turns PgUp/PgDn into a -page scroll of the view instead of a key for the app. `terminal` and +`scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`, +`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down +into a scroll of the view — a page, a row — instead of a key for the app. `terminal` and `parameters` name the external terminal spawned by *vterm: Open Terminal in Project Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either: @@ -353,9 +376,8 @@ defuses Vintage mode inside a terminal). The view is also set scratch, so closin prompts to save. **Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the -Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), -`scroll_past_end` (toggled by the alt-screen fold), and `block_caret` / `caret_style` -(from DECSCUSR; style 0 is the terminal default, a blinking block). +Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and +`block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block). **Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in Sublime's own process — a plugin `on_query_context` would cost a round trip on every diff --git a/README.md b/README.md index 7e0d81e..8f7eee9 100644 --- a/README.md +++ b/README.md @@ -42,40 +42,33 @@ takes `cmd`, `cwd`, `env` and `title`: ``` Keys: Shift+Enter sends `ESC CR` (newline in both agents). Shift+PgUp/PgDn page -the view over the scrollback instead of reaching the app, as xterm and kitty do -— `page_scroll_modifier` moves that to Ctrl, or turns it off. Copy/paste use the -normal Sublime keys, and pastes are bracketed when the app asks for it. Copying -re-joins soft-wrapped lines. The wheel scrolls the view normally; while the app -tracks the mouse the wheel and left click go to it instead, and on the alternate -screen without tracking each wheel tick sends arrow keys (alternate scroll, as -kitty/iTerm2 do — this is what scrolls Claude Code's fullscreen UI before it -enables mouse reporting). While an app owns the alternate screen the scrollback -above it is folded away, kitty-style, and unfolds when the app leaves it. - -The caret follows DECSCUSR, so zsh vi-mode can show a block cursor in normal -mode. The shell must emit the sequence — add to `~/.zshrc`: - -```zsh -function zle-keymap-select zle-line-init { - case $KEYMAP in - vicmd) print -n '\e[1 q' ;; # blinking block - *) print -n '\e[5 q' ;; # blinking bar - esac -} -zle -N zle-keymap-select -zle -N zle-line-init -``` +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 +bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The +wheel scrolls the view normally; while the app tracks the mouse the wheel and +left click go to it instead, and on the alternate screen without tracking each +wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is +what scrolls Claude Code's fullscreen UI before it enables mouse reporting). +Sublime raises no wheel event at all for the trackpad (sublime_text#1838), so +while the app owns the wheel a poller reads the viewport movement instead, +sends it as scroll lines and pins the view back. A jump of a whole screen or +more — the scrollbar, the page keys — is left alone, so the scrollback is still +yours to browse until you come back to the bottom. While an app owns the +alternate screen the scrollback above it is folded away, kitty-style, and +unfolds when the app leaves it. ## Settings -`vterm.sublime-settings`: `auto_close` (close the tab when the process exits; off -leaves it open with a `[process exited with code N]` line), -`scrollback_history_size`, `page_scroll_modifier` (the modifier for the PgUp/PgDn -page scroll; `null` gives both keys to the app), `min_columns` / `max_columns`, -`terminal` / `parameters` (the external terminal for *vterm: Open Terminal in -Project Folder*; `%CWD%` in `parameters` is replaced with the project folder, -defaulting to Apple Terminal, with kitty commented out beside it), `debug` -(verbose logging to the ST console), plus the colours: +`vterm.sublime-settings`: `auto_close` (close the tab when the process exits; +off leaves it open with a `[process exited with code N]` line), +`scrollback_history_size`, `scroll_modifier` (the modifier that scrolls the +view with PgUp/PgDn and Up/Down; `null` gives all four keys to the app), +`min_columns` / `max_columns`, `terminal` / `parameters` (the external terminal +for *vterm: Open Terminal in Project Folder*; `%CWD%` in `parameters` is +replaced with the project folder, defaulting to Apple Terminal, with kitty +commented out beside it), `debug` (verbose logging to the ST console), plus the +colours: - `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`, `"slate"`, or `null` to follow the active Sublime colour scheme. Set it with @@ -123,7 +116,9 @@ terminal protocols — see `Architecture.md`. leave the character behind. `on_modified` in `vterm/listeners.py` remains the fallback for IME and composed input. - `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables - tracking; native selection and scrolling otherwise. + tracking; native selection and scrolling otherwise. The trackpad reaches no + mousemap on macOS, so while the app owns the wheel a 24 ms poller harvests + viewport displacement into the same reports. Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004), mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no @@ -151,6 +146,8 @@ instead), image protocols, Windows, panels. input, so close it and open a new one. - CJK and emoji rely on wcwidth agreeing with Sublime's font metrics; where they disagree the caret can drift. +- Trackpad scroll into an app is polled and rounded to whole lines, so it is + coarser than the wheel in a native terminal. ## Dev diff --git a/tests/test_render.py b/tests/test_render.py index 9f20a06..1097949 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -540,9 +540,9 @@ assert sublime._timeouts == [], sublime._timeouts print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, poller unhooks)") -# PgUp/PgDn with the configured modifier pages the view instead of reaching the -# app; every other combination is sent as before. 24 scrollback rows above an -# 8-row screen, viewport at the follow anchor +# with the configured modifier PgUp/PgDn page the view and Up/Down move it one +# row, instead of reaching the app; every other combination is sent as before. +# 24 scrollback rows above an 8-row screen, viewport at the follow anchor terminal, stream = make_terminal(rows=8, cols=20) Terminal._terminals[id(terminal)] = terminal terminal.view.id = lambda: id(terminal) @@ -581,6 +581,21 @@ assert y == anchor, y # paging back onto the anchor re-opens the gate assert following(terminal.view, terminal) +# the arrows move one row rather than a screen +y = press(key="up", shift=True) +assert y == anchor - LINE_HEIGHT, y +assert terminal.process.written == "", repr(terminal.process.written) +# one row up is the edge of the gate's one-line tolerance; it must still close +assert not following(terminal.view, terminal) + +for _ in range(30): + y = press(key="up", shift=True) +assert y == 0, y +for _ in range(30): + y = press(key="down", shift=True) +assert y == anchor, y +assert following(terminal.view, terminal) + # a bare or differently modified key still belongs to the app press(key="pageup") assert terminal.process.written == "\x1b[5~", repr(terminal.process.written) @@ -589,28 +604,41 @@ assert terminal.process.written == "\x1b[5;5~", repr(terminal.process.written) y = press(key="pageup", ctrl=True, shift=True) assert terminal.process.written == "\x1b[5;6~", repr(terminal.process.written) assert y == anchor, "the app's keys must not scroll" +press(key="up") +assert terminal.process.written == "\x1b[A", repr(terminal.process.written) +y = press(key="up", ctrl=True, shift=True) +assert terminal.process.written == "\x1b[1;6A", repr(terminal.process.written) +assert y == anchor, "the app's keys must not scroll" -# the alt screen is folded away: nothing to page, so the app keeps the key +# the alt screen is folded away: nothing to scroll, so the app keeps the keys stream.feed("\x1b[?1049h") press(key="pageup", shift=True) assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) +press(key="up", shift=True) +assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written) stream.feed("\x1b[?1049l") -# the modifier is configurable, and null hands both keys back to the app -plugin_settings.set("page_scroll_modifier", "ctrl") +# the modifier is configurable, and null hands every key back to the app +plugin_settings.set("scroll_modifier", "ctrl") y = press(key="pageup", ctrl=True) assert y == anchor - PAGE, y +y = press(key="up", ctrl=True) +assert y == anchor - PAGE - LINE_HEIGHT, y press(key="pageup", shift=True) assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) +press(key="up", shift=True) +assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written) -plugin_settings.set("page_scroll_modifier", None) +plugin_settings.set("scroll_modifier", None) press(key="pageup", shift=True) assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) +press(key="up", shift=True) +assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written) press(key="pagedown", ctrl=True) assert terminal.process.written == "\x1b[6;5~", repr(terminal.process.written) -plugin_settings.set("page_scroll_modifier", "shift") +plugin_settings.set("scroll_modifier", "shift") -print("page scroll tests passed (one screen 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)") # a click in an unfocused group must focus the tab instead of reporting: we diff --git a/vterm.sublime-settings b/vterm.sublime-settings index a48f5fb..09bc410 100644 --- a/vterm.sublime-settings +++ b/vterm.sublime-settings @@ -10,9 +10,10 @@ // cmd+shift+k is then the only way to empty the tab. "honor_erase_scrollback": false, - // modifier that turns PgUp/PgDn into a page scroll of the view: - // "shift", "ctrl", "alt" or a "+" combination; null sends both to the app - "page_scroll_modifier": "shift", + // modifier that turns PgUp/PgDn and Up/Down into a scroll of the view + // (a page / a line): "shift", "ctrl", "alt" or a "+" combination; + // null sends the keys to the app + "scroll_modifier": "shift", "min_columns": 20, "max_columns": 500, @@ -27,12 +28,12 @@ // External terminal for "vterm: Open Terminal in Project Folder". // %CWD% in parameters is replaced with the project folder. - "terminal": "/usr/bin/open", - "parameters": ["-a", "Terminal", "%CWD%"], + // "terminal": "/usr/bin/open", + // "parameters": ["-a", "Terminal", "%CWD%"], // kitty instead of Apple Terminal: - // "terminal": "/opt/homebrew/bin/kitty", - // "parameters": ["-d", "%CWD%"], + "terminal": "/opt/homebrew/bin/kitty", + "parameters": ["-d", "%CWD%"], "debug": false } diff --git a/vterm/commands.py b/vterm/commands.py index 204f707..f3426e7 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -6,7 +6,7 @@ import subprocess import sublime import sublime_plugin -from .render import CONTINUATION, scroll_page +from .render import CONTINUATION, scroll_view from .scheme import SCHEME_PATH, scheme_manager from .terminal import Terminal from .themes import THEMES @@ -14,6 +14,9 @@ from .utils import SETTINGS_FILE, settings LOG = logging.getLogger(__name__) +# nav keys the view may consume from the app, with the modifier configured +SCROLL_KEYS = ("pageup", "pagedown", "up", "down") + class VtermOpenCommand(sublime_plugin.WindowCommand): """Open a terminal in a new tab. `vterm_open`, the only entry point users bind.""" @@ -143,29 +146,31 @@ class VtermKeypressCommand(sublime_plugin.TextCommand): terminal = Terminal.from_id(self.view.id()) if not terminal or not terminal.alive: return - if self.page_scrolled(terminal, **kwargs): + if self.view_scrolled(terminal, **kwargs): return # drop any selection so the caret follows the terminal cursor self.view.sel().clear() self.view.run_command("vterm_show_cursor") terminal.send_key(**kwargs) - def page_scrolled(self, terminal, key=None, **mods): - """Page the view one screen over the scrollback, before vterm_show_cursor, - whose scroll would pull the viewport straight back down. The alt screen - has its scrollback folded away, so an app there keeps the key. + def view_scrolled(self, terminal, key=None, **mods): + """Scroll the view over the scrollback, before vterm_show_cursor, whose + scroll would pull the viewport straight back down. PgUp/PgDn move a + screen, Up/Down one line. The alt screen has its scrollback folded + away, so an app there keeps the key. Returns: bool: the view consumed the key; False sends it to the app. """ # the cheap tests first: this runs on every keystroke - if key not in ("pageup", "pagedown") or not mods or terminal.screen.alternate_buffer_mode: + if key not in SCROLL_KEYS or not mods or terminal.screen.alternate_buffer_mode: return False - modifier = settings().get("page_scroll_modifier", "shift") + modifier = settings().get("scroll_modifier", "shift") # exact match: ctrl+shift+pageup still belongs to the app if not modifier or {m for m, on in mods.items() if on} != set(modifier.split("+")): return False - scroll_page(self.view, terminal, up=(key == "pageup")) + lines = 1 if key in ("up", "down") else terminal.screen.lines + scroll_view(self.view, terminal, up=key in ("up", "pageup"), lines=lines) return True diff --git a/vterm/render.py b/vterm/render.py index 2d02f7c..192c049 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -554,14 +554,14 @@ def scroll_to_cursor(view, terminal): view.set_viewport_position((0, y), False) -def scroll_page(view, terminal, up): - """Move the viewport one screen over the scrollback. +def scroll_view(view, terminal, up, lines): + """Move the viewport `lines` rows over the scrollback. Not sublime's scroll_lines: it animates, and while the viewport is still within a line of where it started the follow-scroll gate reads that as "at the bottom" and pulls it back down. """ - delta = terminal.screen.lines * view.line_height() + delta = lines * view.line_height() y = view.viewport_position()[1] y = max(y - delta, 0) if up else min(y + delta, bottom_y(view, terminal)) view.set_viewport_position((0, y), False)