Shift+PgUp, Shift+PgDn added

This commit is contained in:
Vaclav Tvrdik
2026-09-08 12:08:39 +02:00
parent 23644cbdf5
commit f672bff6a0
6 changed files with 176 additions and 27 deletions

View File

@@ -42,7 +42,7 @@ Constraints it works under:
| File | Role | | 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-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. | | `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`. | | `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 `(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. 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 follow-scroll gate.** `following()` compares the viewport against
the viewport. If the current viewport is *above* that, the user scrolled up by hand, and `terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled
trimming, caret placement and scrolling are all skipped so their position survives the up by hand, and trimming, caret placement and scrolling are all skipped so their position
next frame. 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 ### 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) keymap binding (context: setting.vterm_view)
→ VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…) → 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) → 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>
ctrl → control byte; alt → ESC prefix ctrl → control byte; alt → ESC prefix
@@ -330,8 +333,10 @@ replaces both together.
## Sublime settings ## Sublime settings
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()` **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, `min_columns`, handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
`max_columns`, `theme`, `ansi_colors`, `terminal`, `parameters`, `debug`. `terminal` and `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 `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 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 blanks the screen without dropping the scrollback: pi and Claude Code repaint by erasing

View File

@@ -28,7 +28,7 @@ Command palette:
- **vterm: Open Terminal** — login shell in a tab (cwd = first project folder, - **vterm: Open Terminal** — login shell in a tab (cwd = first project folder,
else the active file's directory, else `~`) else the active file's directory, else `~`)
- **vterm: Open Terminal in Project Folder** — opens the external terminal from - **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 - **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 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 - **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"} } { "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 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 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 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 `vterm.sublime-settings`: `auto_close` (close the tab when the process exits; off
leaves it open with a `[process exited with code N]` line), 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 `terminal` / `parameters` (the external terminal for *vterm: Open Terminal in
Project Folder*; `%CWD%` in `parameters` is replaced with the project folder, Project Folder*; `%CWD%` in `parameters` is replaced with the project folder,
defaulting to kitty), `debug` (verbose logging defaulting to Apple Terminal, with kitty commented out beside it), `debug`
to the ST console), plus the colours: (verbose logging to the ST console), plus the colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`, - `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with `"slate"`, or `null` to follow the active Sublime colour scheme. Set it with

View File

@@ -7,14 +7,21 @@ import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess from _bootstrap import FakeProcess
import sublime import sublime
from vterm.commands import VtermKeypressCommand
from vterm.emulator import VtScreen, VtStream from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.render import ( from vterm.render import (
CHUNK, CHUNK,
VtermRenderCommand, VtermRenderCommand,
focus_cursor, focus_cursor,
following,
scroll_to_cursor,
) )
from vterm.terminal import Terminal from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
LINE_HEIGHT = 20
class FakeSelection(list): class FakeSelection(list):
@@ -35,6 +42,8 @@ class FakeView:
self.commands = [] self.commands = []
self.folds = [] self.folds = []
self._name = "" self._name = ""
self.viewport = (0, 0)
self.viewport_rows = 5
def size(self): def size(self):
return len(self.text) return len(self.text)
@@ -113,10 +122,23 @@ class FakeView:
return sublime.load_settings("fake") return sublime.load_settings("fake")
def viewport_position(self): 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): 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): def name(self):
return self._name 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)") 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 # 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

@@ -10,6 +10,10 @@
// cmd+shift+k is then the only way to empty the tab. // cmd+shift+k is then the only way to empty the tab.
"honor_erase_scrollback": false, "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, "min_columns": 20,
"max_columns": 500, "max_columns": 500,
@@ -23,12 +27,12 @@
// External terminal for "vterm: Open Terminal in Project Folder". // External terminal for "vterm: Open Terminal in Project Folder".
// %CWD% in parameters is replaced with the project folder. // %CWD% in parameters is replaced with the project folder.
// "terminal": "/usr/bin/open", "terminal": "/usr/bin/open",
// "parameters": ["-a", "Terminal", "%CWD%"], "parameters": ["-a", "Terminal", "%CWD%"],
// kitty instead of Apple Terminal: // kitty instead of Apple Terminal:
"terminal": "/opt/homebrew/bin/kitty", // "terminal": "/opt/homebrew/bin/kitty",
"parameters": ["-d", "%CWD%"], // "parameters": ["-d", "%CWD%"],
"debug": false "debug": false
} }

View File

@@ -6,7 +6,7 @@ import subprocess
import sublime import sublime
import sublime_plugin import sublime_plugin
from .render import CONTINUATION from .render import CONTINUATION, scroll_page
from .scheme import SCHEME_PATH, scheme_manager from .scheme import SCHEME_PATH, scheme_manager
from .terminal import Terminal from .terminal import Terminal
from .themes import THEMES from .themes import THEMES
@@ -143,11 +143,31 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(self.view.id()) terminal = Terminal.from_id(self.view.id())
if not terminal or not terminal.alive: if not terminal or not terminal.alive:
return return
if self.page_scrolled(terminal, **kwargs):
return
# drop any selection so the caret follows the terminal cursor # drop any selection so the caret follows the terminal cursor
self.view.sel().clear() self.view.sel().clear()
self.view.run_command("vterm_show_cursor") self.view.run_command("vterm_show_cursor")
terminal.send_key(**kwargs) 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): class VtermCopyCommand(sublime_plugin.TextCommand):
"""Copies the selection, re-joining rows the emulator soft-wrapped.""" """Copies the selection, re-joining rows the emulator soft-wrapped."""

View File

@@ -172,10 +172,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# drop the artefact; focus_cursor re-places the caret # drop the artefact; focus_cursor re-places the caret
view.sel().clear() view.sel().clear()
# follow-scroll gate: viewport_y is where the last auto-scroll left the # all skipped once the user scrolls up, so their place survives the frame
# view, so a smaller current position means the user scrolled up. Then if following(view, terminal):
# trimming, the caret and the scroll are all skipped, keeping their place
if terminal.viewport_y < view.viewport_position()[1] + view.line_height():
self.trim_trailing_spaces(edit, terminal) self.trim_trailing_spaces(edit, terminal)
self.trim_history(edit, terminal) self.trim_history(edit, terminal)
if focused: if focused:
@@ -511,18 +509,42 @@ def focus_cursor(view, edit, terminal):
sel.add(sublime.Region(pt, pt)) sel.add(sublime.Region(pt, pt))
def scroll_to_cursor(view, terminal): def following(view, terminal):
"""Follow the output: pin the viewport to the bottom of the live screen.""" """Whether the viewport still sits where the last auto-scroll left it; above
terminal._scroll_pending = False 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 # layout_extent is maintained incrementally; text_to_layout(size()) forced
# a whole-buffer layout on every call # a whole-buffer layout on every call
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1] viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[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 terminal.viewport_y = y
view.set_viewport_position((0, y), False) 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): def request_scroll(view, terminal):
"""Coalesce follow-scrolls: at most one queued callback per terminal.""" """Coalesce follow-scrolls: at most one queued callback per terminal."""
if not terminal._scroll_pending: if not terminal._scroll_pending: