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/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/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/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/keys.py` | Key name + modifiers → escape sequence tables. |
| `vterm/utils.py` | `settings()`, the `responsive` throttle, the `intermission` pacer, `rev_wcwidth`. | | `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 - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
generated scheme file and the mapping from colour combination to scope name. generated scheme file and the mapping from colour combination to scope name.
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per - **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
view**, so its `colored_lines`, `_row_cache`, `_key_counter` and `_lastrow` are that view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
view's render state. bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
`_lastrow` are that view's render state.
### Commands ### Commands
@@ -132,6 +133,8 @@ child writes
│ _need_to_render()? dirty rows, a pending clear, or a │ _need_to_render()? dirty rows, a pending clear, or a
│ moved cursor │ moved cursor
│ view.run_command("vterm_render") │ 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 ────────────────────────────────────────────────── └─ UI thread ──────────────────────────────────────────────────
VtermRenderCommand.run(edit) 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 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 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. 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 **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 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 **The follow-scroll gate.** `following()` compares the viewport against
`terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled `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 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, survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate
and paging down clamps to the anchor `bottom_y` returns, which re-opens it. 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 ### How colour is actually drawn
@@ -236,11 +244,15 @@ Details worth knowing before touching `scheme.py`:
### The alternate screen ### The alternate screen
While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away and While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so
sets `scroll_past_end` to false, leaving the view with no scroll range — the app's own the view shows the app's screen alone. The scroll range is deliberately left in place: the
scrolling is the only scrolling. The fold is re-checked every frame, not just on trackpad poller pins the viewport and needs the slack to read a gesture. The fold is
transitions, because the screen's first row is rewritten on every region scroll and an re-checked every frame, not just on transitions, because the screen's first row is
edit at the fold boundary can silently break a fold. The emulator side 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 (`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its
frames into scrollback. frames into scrollback.
@@ -253,8 +265,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 → view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page
and stops here; never on the alternate screen and Up/Down a row; stops here, never on the alt 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
@@ -291,6 +303,17 @@ which produces three cases:
3. **Neither** → the binding does not match at all and Sublime scrolls the view over the 3. **Neither** → the binding does not match at all and Sublime scrolls the view over the
real scrollback text. 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 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 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 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 | | 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 | | reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state |
| renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator | | renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator |
| paste drain | transient; empties the 4096-byte chunk queue | pty only | | 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()` **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
`page_scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`, `scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
`parameters`, `debug`. `page_scroll_modifier` is the modifier that turns PgUp/PgDn into a `parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
page scroll of the view instead of a key for the app. `terminal` and 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 `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 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: 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. prompts to save.
**Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the **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), Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and
`scroll_past_end` (toggled by the alt-screen fold), and `block_caret` / `caret_style` `block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block).
(from DECSCUSR; style 0 is the terminal default, a blinking block).
**Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in **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 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 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 the view over the scrollback and Shift+Up/Down move it one line, instead of
— `page_scroll_modifier` moves that to Ctrl, or turns it off. Copy/paste use the reaching the app, as xterm and kitty do — `scroll_modifier` moves that to Ctrl,
normal Sublime keys, and pastes are bracketed when the app asks for it. Copying or turns it off. Copy/paste use the normal Sublime keys, and pastes are
re-joins soft-wrapped lines. The wheel scrolls the view normally; while the app bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The
tracks the mouse the wheel and left click go to it instead, and on the alternate wheel scrolls the view normally; while the app tracks the mouse the wheel and
screen without tracking each wheel tick sends arrow keys (alternate scroll, as left click go to it instead, and on the alternate screen without tracking each
kitty/iTerm2 do — this is what scrolls Claude Code's fullscreen UI before it wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
enables mouse reporting). While an app owns the alternate screen the scrollback what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
above it is folded away, kitty-style, and unfolds when the app leaves it. 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,
The caret follows DECSCUSR, so zsh vi-mode can show a block cursor in normal sends it as scroll lines and pins the view back. A jump of a whole screen or
mode. The shell must emit the sequence — add to `~/.zshrc`: 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
```zsh alternate screen the scrollback above it is folded away, kitty-style, and
function zle-keymap-select zle-line-init { unfolds when the app leaves it.
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
```
## Settings ## Settings
`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;
leaves it open with a `[process exited with code N]` line), off leaves it open with a `[process exited with code N]` line),
`scrollback_history_size`, `page_scroll_modifier` (the modifier for the PgUp/PgDn `scrollback_history_size`, `scroll_modifier` (the modifier that scrolls the
page scroll; `null` gives both keys to the app), `min_columns` / `max_columns`, view with PgUp/PgDn and Up/Down; `null` gives all four keys to the app),
`terminal` / `parameters` (the external terminal for *vterm: Open Terminal in `min_columns` / `max_columns`, `terminal` / `parameters` (the external terminal
Project Folder*; `%CWD%` in `parameters` is replaced with the project folder, for *vterm: Open Terminal in Project Folder*; `%CWD%` in `parameters` is
defaulting to Apple Terminal, with kitty commented out beside it), `debug` replaced with the project folder, defaulting to Apple Terminal, with kitty
(verbose logging to the ST console), plus the colours: commented out beside it), `debug` (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
@@ -123,7 +116,9 @@ terminal protocols — see `Architecture.md`.
leave the character behind. `on_modified` in `vterm/listeners.py` remains the leave the character behind. `on_modified` in `vterm/listeners.py` remains the
fallback for IME and composed input. fallback for IME and composed input.
- `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables - `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), Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no 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. input, so close it and open a new one.
- CJK and emoji rely on wcwidth agreeing with Sublime's font metrics; where they - CJK and emoji rely on wcwidth agreeing with Sublime's font metrics; where they
disagree the caret can drift. 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 ## Dev

View File

@@ -540,9 +540,9 @@ 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, poller unhooks)")
# PgUp/PgDn with the configured modifier pages the view instead of reaching the # with the configured modifier PgUp/PgDn page the view and Up/Down move it one
# app; every other combination is sent as before. 24 scrollback rows above an # row, instead of reaching the app; every other combination is sent as before.
# 8-row screen, viewport at the follow anchor # 24 scrollback rows above an 8-row screen, viewport at the follow anchor
terminal, stream = make_terminal(rows=8, cols=20) terminal, stream = make_terminal(rows=8, cols=20)
Terminal._terminals[id(terminal)] = terminal Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal) terminal.view.id = lambda: id(terminal)
@@ -581,6 +581,21 @@ assert y == anchor, y
# paging back onto the anchor re-opens the gate # paging back onto the anchor re-opens the gate
assert following(terminal.view, terminal) 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 # a bare or differently modified key still belongs to the app
press(key="pageup") press(key="pageup")
assert terminal.process.written == "\x1b[5~", repr(terminal.process.written) 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) y = press(key="pageup", ctrl=True, shift=True)
assert terminal.process.written == "\x1b[5;6~", repr(terminal.process.written) assert terminal.process.written == "\x1b[5;6~", repr(terminal.process.written)
assert y == anchor, "the app's keys must not scroll" 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") stream.feed("\x1b[?1049h")
press(key="pageup", shift=True) press(key="pageup", shift=True)
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) 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") stream.feed("\x1b[?1049l")
# the modifier is configurable, and null hands both keys back to the app # the modifier is configurable, and null hands every key back to the app
plugin_settings.set("page_scroll_modifier", "ctrl") plugin_settings.set("scroll_modifier", "ctrl")
y = press(key="pageup", ctrl=True) y = press(key="pageup", ctrl=True)
assert y == anchor - PAGE, y assert y == anchor - PAGE, y
y = press(key="up", ctrl=True)
assert y == anchor - PAGE - LINE_HEIGHT, y
press(key="pageup", shift=True) press(key="pageup", shift=True)
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) 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) press(key="pageup", shift=True)
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written) 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) press(key="pagedown", ctrl=True)
assert terminal.process.written == "\x1b[6;5~", repr(terminal.process.written) 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 # 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. // 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: // modifier that turns PgUp/PgDn and Up/Down into a scroll of the view
// "shift", "ctrl", "alt" or a "+" combination; null sends both to the app // (a page / a line): "shift", "ctrl", "alt" or a "+" combination;
"page_scroll_modifier": "shift", // null sends the keys to the app
"scroll_modifier": "shift",
"min_columns": 20, "min_columns": 20,
"max_columns": 500, "max_columns": 500,
@@ -27,12 +28,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, scroll_page from .render import CONTINUATION, scroll_view
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
@@ -14,6 +14,9 @@ from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__) 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): class VtermOpenCommand(sublime_plugin.WindowCommand):
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind.""" """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()) 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): if self.view_scrolled(terminal, **kwargs):
return 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): def view_scrolled(self, terminal, key=None, **mods):
"""Page the view one screen over the scrollback, before vterm_show_cursor, """Scroll the view over the scrollback, before vterm_show_cursor, whose
whose scroll would pull the viewport straight back down. The alt screen scroll would pull the viewport straight back down. PgUp/PgDn move a
has its scrollback folded away, so an app there keeps the key. screen, Up/Down one line. The alt screen has its scrollback folded
away, so an app there keeps the key.
Returns: Returns:
bool: the view consumed the key; False sends it to the app. bool: the view consumed the key; False sends it to the app.
""" """
# the cheap tests first: this runs on every keystroke # 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 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 # 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("+")): if not modifier or {m for m, on in mods.items() if on} != set(modifier.split("+")):
return False 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 return True

View File

@@ -554,14 +554,14 @@ def scroll_to_cursor(view, terminal):
view.set_viewport_position((0, y), False) view.set_viewport_position((0, y), False)
def scroll_page(view, terminal, up): def scroll_view(view, terminal, up, lines):
"""Move the viewport one screen over the scrollback. """Move the viewport `lines` rows over the scrollback.
Not sublime's scroll_lines: it animates, and while the viewport is still 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 within a line of where it started the follow-scroll gate reads that as "at
the bottom" and pulls it back down. 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 = view.viewport_position()[1]
y = max(y - delta, 0) if up else min(y + delta, bottom_y(view, terminal)) y = max(y - delta, 0) if up else min(y + delta, bottom_y(view, terminal))
view.set_viewport_position((0, y), False) view.set_viewport_position((0, y), False)