Shift+Up, Shift+Down added

This commit is contained in:
Vaclav Tvrdik
2026-09-09 15:32:30 +02:00
parent fb598227d1
commit 301f1f8114
6 changed files with 132 additions and 79 deletions

View File

@@ -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;<mod><final>
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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)