diff --git a/Architecture.md b/Architecture.md index 319284f..234d73a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -42,7 +42,7 @@ Constraints it works under: | File | Role | |---|---| -| `vterm.sublime-settings` | Defaults for the ten plugin settings. | +| `vterm.sublime-settings` | Defaults for the eleven plugin settings. | | `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. | | `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. | | `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. | @@ -186,10 +186,11 @@ marks every row dirty, and a TUI repaints whole frames. `update_line` compares `(segments, lf)` against `_row_cache` and returns early when unchanged, which is what keeps a full-screen repaint from costing hundreds of cross-process calls. -**The follow-scroll gate.** `terminal.viewport_y` records where the last auto-scroll left -the viewport. If the current viewport is *above* that, the user scrolled up by hand, and -trimming, caret placement and scrolling are all skipped so their position survives the -next frame. +**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. ### How colour is actually drawn @@ -252,6 +253,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 → 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 @@ -330,8 +333,10 @@ replaces both together. ## Sublime settings **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()` -handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, `min_columns`, -`max_columns`, `theme`, `ansi_colors`, `terminal`, `parameters`, `debug`. `terminal` and +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 `parameters` name the external terminal spawned by *vterm: Open Terminal in Project Folder* (`%CWD%` in `parameters` becomes the project folder). `honor_erase_scrollback` is off, so ED 3 blanks the screen without dropping the scrollback: pi and Claude Code repaint by erasing diff --git a/README.md b/README.md index 31b2233..897bda6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Command palette: - **vterm: Open Terminal** — login shell in a tab (cwd = first project folder, else the active file's directory, else `~`) - **vterm: Open Terminal in Project Folder** — opens the external terminal from - the settings (kitty by default) in the project folder of the active file + the settings (Apple Terminal by default) in the project folder of the active file - **vterm: Clear Screen** (`cmd+shift+k`) — empties the tab, its screen and all scrollback, then asks the app to redraw, so the tab looks freshly started - **vterm: Select Theme** — pick a colour palette, previewing each as you move @@ -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). Copy/paste use the +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 @@ -68,11 +70,12 @@ zle -N zle-line-init `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`, `min_columns` / `max_columns`, +`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 kitty), `debug` (verbose logging -to the ST console), plus the colours: +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 diff --git a/tests/test_render.py b/tests/test_render.py index 57f8763..4ebc008 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -7,14 +7,21 @@ import _bootstrap # sets sys.path for the imports below from _bootstrap import FakeProcess import sublime +from vterm.commands import VtermKeypressCommand from vterm.emulator import VtScreen, VtStream from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand from vterm.render import ( CHUNK, VtermRenderCommand, focus_cursor, + following, + scroll_to_cursor, ) from vterm.terminal import Terminal +from vterm.utils import SETTINGS_FILE + + +LINE_HEIGHT = 20 class FakeSelection(list): @@ -35,6 +42,8 @@ class FakeView: self.commands = [] self.folds = [] self._name = "" + self.viewport = (0, 0) + self.viewport_rows = 5 def size(self): return len(self.text) @@ -113,10 +122,23 @@ class FakeView: return sublime.load_settings("fake") def viewport_position(self): - return (0, 0) + return self.viewport + + def set_viewport_position(self, xy, animate=True): + self.viewport = xy def line_height(self): - return 20 + return LINE_HEIGHT + + # one layout row per line; the viewport shows viewport_rows of them + def layout_extent(self): + return (0, (self.text.count("\n") + 1) * LINE_HEIGHT) + + def viewport_extent(self): + return (0, self.viewport_rows * LINE_HEIGHT) + + def text_to_layout(self, point): + return (0, self.rowcol(point)[0] * LINE_HEIGHT) def name(self): return self._name @@ -399,6 +421,79 @@ assert terminal.process.written == "", repr(terminal.process.written) print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)") +# 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 +terminal, stream = make_terminal(rows=8, cols=20) +Terminal._terminals[id(terminal)] = terminal +terminal.view.id = lambda: id(terminal) +terminal.view.text = "\n".join("row {}".format(i) for i in range(32)) +terminal.view.viewport_rows = terminal.screen.lines +terminal.offset = 24 +keypress = VtermKeypressCommand.__new__(VtermKeypressCommand) +keypress.view = terminal.view +plugin_settings = sublime.load_settings(SETTINGS_FILE) +PAGE = terminal.screen.lines * LINE_HEIGHT +anchor = 24 * LINE_HEIGHT + + +def press(**kwargs): + terminal.process.written = "" + keypress.run(None, **kwargs) + return terminal.view.viewport_position()[1] + + +scroll_to_cursor(terminal.view, terminal) +assert following(terminal.view, terminal) + +y = press(key="pageup", shift=True) +assert y == anchor - PAGE, y +assert terminal.process.written == "", repr(terminal.process.written) +# output must not yank the viewport back while the user reads +assert not following(terminal.view, terminal) + +# never above the top, never below the anchor +for _ in range(3): + y = press(key="pageup", shift=True) +assert y == 0, y +for _ in range(5): + y = press(key="pagedown", shift=True) +assert y == anchor, y +# paging back onto the anchor re-opens the gate +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) +press(key="pageup", ctrl=True) +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" + +# the alt screen is folded away: nothing to page, so the app keeps the key +stream.feed("\x1b[?1049h") +press(key="pageup", shift=True) +assert terminal.process.written == "\x1b[5;2~", 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") +y = press(key="pageup", ctrl=True) +assert y == anchor - PAGE, y +press(key="pageup", shift=True) +assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) + +plugin_settings.set("page_scroll_modifier", None) +press(key="pageup", shift=True) +assert terminal.process.written == "\x1b[5;2~", 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") + +print("page scroll tests passed (one screen per press, clamped to the anchor, configurable modifier)") + + # 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.sublime-settings b/vterm.sublime-settings index 25b9e4e..a48f5fb 100644 --- a/vterm.sublime-settings +++ b/vterm.sublime-settings @@ -10,6 +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", + "min_columns": 20, "max_columns": 500, @@ -23,12 +27,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 999867a..62ea63d 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -6,7 +6,7 @@ import subprocess import sublime import sublime_plugin -from .render import CONTINUATION +from .render import CONTINUATION, scroll_page from .scheme import SCHEME_PATH, scheme_manager from .terminal import Terminal from .themes import THEMES @@ -143,11 +143,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): + 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 is + folded and has no scroll range, 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: + return False + modifier = settings().get("page_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")) + return True + class VtermCopyCommand(sublime_plugin.TextCommand): """Copies the selection, re-joining rows the emulator soft-wrapped.""" diff --git a/vterm/render.py b/vterm/render.py index ef1c914..7c4a5ff 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -172,10 +172,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand): # drop the artefact; focus_cursor re-places the caret view.sel().clear() - # follow-scroll gate: viewport_y is where the last auto-scroll left the - # view, so a smaller current position means the user scrolled up. Then - # trimming, the caret and the scroll are all skipped, keeping their place - if terminal.viewport_y < view.viewport_position()[1] + view.line_height(): + # all skipped once the user scrolls up, so their place survives the frame + if following(view, terminal): self.trim_trailing_spaces(edit, terminal) self.trim_history(edit, terminal) if focused: @@ -511,18 +509,42 @@ def focus_cursor(view, edit, terminal): sel.add(sublime.Region(pt, pt)) -def scroll_to_cursor(view, terminal): - """Follow the output: pin the viewport to the bottom of the live screen.""" - terminal._scroll_pending = False +def following(view, terminal): + """Whether the viewport still sits where the last auto-scroll left it; above + that means the user scrolled up by hand.""" + return terminal.viewport_y < view.viewport_position()[1] + view.line_height() + + +def bottom_y(view, terminal): + """Viewport y that pins the bottom of the live screen to the bottom of the view.""" # layout_extent is maintained incrementally; text_to_layout(size()) forced # a whole-buffer layout on every call viewport_y = view.layout_extent()[1] - view.viewport_extent()[1] offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1] - y = max(offset_y, viewport_y) + return max(offset_y, viewport_y) + + +def scroll_to_cursor(view, terminal): + """Follow the output: pin the viewport to the bottom of the live screen.""" + terminal._scroll_pending = False + y = bottom_y(view, terminal) terminal.viewport_y = y view.set_viewport_position((0, y), False) +def scroll_page(view, terminal, up): + """Move the viewport one screen 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() + 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) + + def request_scroll(view, terminal): """Coalesce follow-scrolls: at most one queued callback per terminal.""" if not terminal._scroll_pending: