2 Commits

Author SHA1 Message Date
Vaclav Tvrdik
7b536a7efa Keep scrollback under a full-screen scroll region 2026-10-20 11:00:04 +02:00
Vaclav Tvrdik
40dac967e2 selection now without command modifier key 2026-10-16 14:34:50 +02:00
10 changed files with 97 additions and 48 deletions

View File

@@ -75,10 +75,9 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit | | `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
| `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding | | `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding |
| `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal | | `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal |
| `vterm_scroll` / `vterm_mouse_click` | `mouse.py` | mousemap, `drag_select` interception | | `vterm_scroll` / `vterm_mouse_press` / `vterm_mouse_release` | `mouse.py` | mousemap |
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and One application-wide `EventListener`: `VtermCoreEventListener` (`listeners.py`).
`VtermMouseEventListener` (`mouse.py`).
--- ---

View File

@@ -11,6 +11,13 @@
"args": {"up": false}, "args": {"up": false},
"context": [{"key": "vterm_view.wheel_captured", "operand": true}] "context": [{"key": "vterm_view.wheel_captured", "operand": true}]
}, },
{
// press reports the click, release reports the drag
"button": "button1",
"press_command": "vterm_mouse_press",
"command": "vterm_mouse_release",
"context": [{"key": "vterm_view.mouse_reporting", "operand": true}]
},
{ {
// press_command: sublime's own button3 binding is a column drag_select, // press_command: sublime's own button3 binding is a column drag_select,
// which would clear the selection before a release command could read it // which would clear the selection before a release command could read it

View File

@@ -79,10 +79,11 @@ 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 bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The
middle button pastes the current selection straight into the app, X11-style, middle button pastes the current selection straight into the app, X11-style,
without going near the clipboard; it does nothing when nothing is selected. The without going near the clipboard; it does nothing when nothing is selected. The
wheel scrolls the view normally; while the app tracks the mouse the wheel and wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks
left click go to it instead, and on the alternate screen without tracking each and drags go to it instead (Claude Code's own mouse selection and copy work as in
wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is kitty), and on the alternate screen without tracking each wheel tick sends arrow
what scrolls Claude Code's fullscreen UI before it enables mouse reporting). 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): set Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
for Sublime Text and it arrives as wheel notches like any other. A notch sends the for Sublime Text and it arrives as wheel notches like any other. A notch sends the
@@ -149,15 +150,16 @@ terminal protocols — see `Architecture.md`.
into the view: an app that does not echo (vim in normal mode) would otherwise into the view: an app that does not echo (vim in normal mode) would otherwise
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, drag) when the app enables
tracking; native selection and scrolling otherwise. The trackpad is not tracking; native selection and scrolling otherwise. The trackpad is not
handled: it reaches no mousemap on macOS and is converted to wheel notches handled: it reaches no mousemap on macOS and is converted to wheel notches
outside the plugin. outside the plugin.
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 button are reported; a
drag, no right button; the middle button pastes the selection instead of being drag arrives as one motion at release, since Sublime raises none; no right
reported), focus events (1004), button; the middle button pastes the selection instead of being reported),
focus events (1004),
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR — unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
block/bar and blink/steady via the view's caret settings; underline falls back block/bar and blink/steady via the view's caret settings; underline falls back

View File

@@ -26,8 +26,8 @@ from .vterm.commands import (
) )
from .vterm.listeners import VtermCoreEventListener from .vterm.listeners import VtermCoreEventListener
from .vterm.mouse import ( from .vterm.mouse import (
VtermMouseClickCommand, VtermMousePressCommand,
VtermMouseEventListener, VtermMouseReleaseCommand,
VtermScrollCommand, VtermScrollCommand,
) )
from .vterm.scheme import build_startup_schemes from .vterm.scheme import build_startup_schemes

View File

@@ -141,6 +141,11 @@ def run_case(char_by_char):
assert len(screen.history) == 1, (tag, len(screen.history)) assert len(screen.history) == 1, (tag, len(screen.history))
assert screen.history[0][0].data == "a", tag assert screen.history[0][0].data == "a", tag
# an explicit full-screen scroll region still feeds scrollback
proc, screen, stream = make(10, 3)
feed(stream, "\x1b[1;3ra\r\nb\r\nc\r\nd", char_by_char)
assert len(screen.history) == 1, (tag, len(screen.history))
# ED 3 clears history and fires callback # ED 3 clears history and fires callback
cleared = [] cleared = []
proc = FakeProcess() proc = FakeProcess()

View File

@@ -9,7 +9,7 @@ from _bootstrap import FakeProcess
import sublime import sublime
from vterm.commands import VtermKeypressCommand 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 VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
from vterm.render import ( from vterm.render import (
CHUNK, CHUNK,
RenderState, RenderState,
@@ -626,19 +626,31 @@ terminal.view.window_to_text = lambda xy: 0
window = FakeWindow(active=object()) window = FakeWindow(active=object())
terminal.view.window = lambda: window terminal.view.window = lambda: window
stream.feed("\x1b[?1002h\x1b[?1006h") stream.feed("\x1b[?1002h\x1b[?1006h")
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand) mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
click.view = terminal.view mouse_press.view = terminal.view
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
mouse_release.view = terminal.view
event = {"x": 0, "y": 0, "button": 1} event = {"x": 0, "y": 0, "button": 1}
click.run(None, event) mouse_press.run(None, event)
assert window.active_view() is terminal.view, "click did not focus the tab" assert window.active_view() is terminal.view, "click did not focus the tab"
mouse_release.run(None, event)
assert terminal.process.written == "", repr(terminal.process.written) assert terminal.process.written == "", repr(terminal.process.written)
# once focused, the same click reports as before # once focused, press and release at one cell report a click
click.run(None, event) mouse_press.run(None, event)
mouse_release.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written) assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
print("click focus tests passed (first click focuses only, then reports)") # a release elsewhere reports one drag motion first
terminal.process.writes.clear()
terminal.view.text = "r0 \nr1 \nr2 \nr3 \nr4 \n"
mouse_press.run(None, event)
terminal.view.window_to_text = lambda xy: terminal.view.text_point(2, 4)
mouse_release.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<32;5;3M\x1b[<0;5;3m", repr(terminal.process.written)
print("click tests passed (first click focuses only; press, drag and release report)")
# entering the alt screen folds the scrollback away (kitty hides it); leaving # entering the alt screen folds the scrollback away (kitty hides it); leaving

View File

@@ -442,7 +442,9 @@ class VtScreen(pyte.Screen):
def index(self): def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback.""" """Line feed; at the bottom of the primary screen the top row goes to scrollback."""
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and self.margins is None: # a region spanning the whole screen (CSI 1;<lines> r) is no region
full = self.margins is None or self.margins == (0, self.lines - 1)
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and full:
self.push_lines_into_history(1) self.push_lines_into_history(1)
super().index() super().index()

View File

@@ -88,7 +88,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
"""Answer the contexts the mousemap needs""" """Answer the contexts the mousemap needs"""
# keymap contexts are native "setting.x"; mousemaps do not evaluate # keymap contexts are native "setting.x"; mousemaps do not evaluate
# those the same way, so their flags still need the hook # those the same way, so their flags still need the hook
if key not in ("vterm_view", "vterm_view.wheel_captured"): if key not in ("vterm_view", "vterm_view.wheel_captured", "vterm_view.mouse_reporting"):
return None return None
value = view.settings().get(key, False) value = view.settings().get(key, False)
if operator == sublime.OP_EQUAL: if operator == sublime.OP_EQUAL:

View File

@@ -13,6 +13,8 @@ from .utils import settings
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction # SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64 WHEEL_UP = 64
WHEEL_DOWN = 65 WHEEL_DOWN = 65
# button 0 with the "moved while held" bit
MOTION = 32
def send_scroll(terminal, up, lines, cell=None): def send_scroll(terminal, up, lines, cell=None):
@@ -36,7 +38,7 @@ def _event_to_cell(view, terminal, event, clamp=False):
"""Map a mouse event to 1-based (col, row) on the live screen, or None. """Map a mouse event to 1-based (col, row) on the live screen, or None.
Clamping pulls a point above or below the screen onto the nearest row: the Clamping pulls a point above or below the screen onto the nearest row: the
wheel still belongs to the app there, a click does not.""" wheel and a release still belong to the app there, a press does not."""
pt = view.window_to_text((event["x"], event["y"])) pt = view.window_to_text((event["x"], event["y"]))
row, col = view.rowcol(pt) row, col = view.rowcol(pt)
row -= terminal.offset row -= terminal.offset
@@ -48,46 +50,60 @@ def _event_to_cell(view, terminal, event, clamp=False):
return (min(col, screen.columns - 1) + 1, row + 1) return (min(col, screen.columns - 1) + 1, row + 1)
class VtermMouseEventListener(sublime_plugin.EventListener): class VtermMousePressCommand(sublime_plugin.TextCommand):
"""Hands button-1 clicks to the app, but only while it tracks the mouse.""" """Bound to button 1 in the mousemap while the app tracks the mouse."""
def on_text_command(self, view, name, args):
"""Replace drag_select with a mouse report; other click variants stay native."""
terminal = Terminal.from_id(view.id())
if not terminal:
return
if not terminal.screen.mouse_reporting_enabled():
# leave selection, caret and scrolling to sublime
return
if name == "drag_select" and args and args.get("event", {}).get("button") == 1 and len(args) == 1:
return ("vterm_mouse_click", {"event": args["event"]})
class VtermMouseClickCommand(sublime_plugin.TextCommand):
"""Reports a click to the app, or focuses the tab if it was not active."""
def run(self, edit, event): def run(self, edit, event):
"""Focus the tab on the first click, report press and release after that.""" """Focus an inactive tab; else report the press and remember its cell."""
terminal = Terminal.from_id(self.view.id()) terminal = Terminal.from_id(self.view.id())
if not terminal: if not terminal:
return return
terminal.mouse_press = None # a release may not come: the context can drop mid-drag
window = self.view.window() window = self.view.window()
if window and window.active_view() != self.view: if window and window.active_view() != self.view:
# we replaced drag_select, which is what focuses the group; this # the mousemap binding displaces drag_select, which is what focuses
# click only focuses, as clicking an unfocused terminal window does # the group; this click only focuses, as in a terminal window
window.focus_view(self.view) window.focus_view(self.view)
return return
cell = _event_to_cell(self.view, terminal, event) cell = _event_to_cell(self.view, terminal, event)
if cell is None: if cell is None:
return return
terminal.mouse_press = cell
terminal.send_mouse(0, cell[0], cell[1], pressed=True) terminal.send_mouse(0, cell[0], cell[1], pressed=True)
terminal.send_mouse(0, cell[0], cell[1], pressed=False) # native drag_select only previews the selection while dragging; the
# app gets the real one at release
self.view.run_command("drag_select", {"event": event})
def want_event(self): def want_event(self):
"""Ask sublime for the click coordinates.""" """Ask sublime for the click coordinates."""
return True return True
class VtermMouseReleaseCommand(sublime_plugin.TextCommand):
"""Reports the release, after one drag motion when the button came up elsewhere.
Sublime raises no motion events, so a drag reaches the app as a single
move to the release cell; an app's selection then spans press to release.
The native preview selection goes, leaving the app's own highlight."""
def run(self, edit, event):
"""Report motion and release for the pending press, if any."""
terminal = Terminal.from_id(self.view.id())
if not terminal or terminal.mouse_press is None:
return
cell = _event_to_cell(self.view, terminal, event, clamp=True)
if cell != terminal.mouse_press:
terminal.send_mouse(MOTION, cell[0], cell[1], pressed=True)
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
terminal.mouse_press = None
self.view.sel().clear()
self.view.run_command("vterm_show_cursor", {"scroll": False})
def want_event(self):
"""Ask sublime for the release coordinates."""
return True
class VtermScrollCommand(sublime_plugin.TextCommand): class VtermScrollCommand(sublime_plugin.TextCommand):
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen.""" """Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""

View File

@@ -121,6 +121,8 @@ class Terminal:
# guards the queue/flag handoff between send_string and the drain thread # guards the queue/flag handoff between send_string and the drain thread
self._send_lock = threading.Lock() self._send_lock = threading.Lock()
self._wheel_captured = False self._wheel_captured = False
self._mouse_reporting = False
self.mouse_press = None # cell of the pending button-1 press, for the release report
self._scroll_pending = False self._scroll_pending = False
self.pending_clear = False self.pending_clear = False
self.pending_clear_screen = False self.pending_clear_screen = False
@@ -329,7 +331,7 @@ class Terminal:
if frames >= UNDO_CLEAR_FRAMES: if frames >= UNDO_CLEAR_FRAMES:
frames = 0 frames = 0
self.view.run_command("vterm_clear_undo_stack") self.view.run_command("vterm_clear_undo_stack")
self._sync_wheel_flag() self._sync_mouse_flags()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops") LOG.debug("[renderer] stops")
break break
@@ -425,12 +427,16 @@ class Terminal:
continue continue
self.process.write(string) self.process.write(string)
def _sync_wheel_flag(self): def _sync_mouse_flags(self):
"""Publish whether the app owns the wheel, for the mousemap context.""" """Publish whether the app owns the wheel and the mouse, for the mousemap contexts."""
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode reporting = self.screen.mouse_reporting_enabled()
captured = reporting or self.screen.alternate_buffer_mode
if captured != self._wheel_captured: if captured != self._wheel_captured:
self._wheel_captured = captured self._wheel_captured = captured
self.view.settings().set("vterm_view.wheel_captured", captured) self.view.settings().set("vterm_view.wheel_captured", captured)
if reporting != self._mouse_reporting:
self._mouse_reporting = reporting
self.view.settings().set("vterm_view.mouse_reporting", reporting)
def send_focus(self, focused): def send_focus(self, focused):
"""Report focus gain or loss, when the app asked for it (?1004).""" """Report focus gain or loss, when the app asked for it (?1004)."""