Shift+PgUp, Shift+PgDn added
This commit is contained in:
@@ -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;<mod><final>
|
||||
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
|
||||
|
||||
13
README.md
13
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user