From 40dac967e277b0ddc99eb804c91662aa0e94c778 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Fri, 16 Oct 2026 14:34:50 +0200 Subject: [PATCH] selection now without command modifier key --- Architecture.md | 5 ++- Default (OSX).sublime-mousemap | 7 ++++ README.md | 18 +++++----- main.py | 4 +-- tests/test_render.py | 26 +++++++++++---- vterm/listeners.py | 2 +- vterm/mouse.py | 60 +++++++++++++++++++++------------- vterm/terminal.py | 14 +++++--- 8 files changed, 89 insertions(+), 47 deletions(-) diff --git a/Architecture.md b/Architecture.md index 6d94cd6..ce5452c 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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_close` | `VtermCloseCommand` | nothing — available for a user keybinding | | `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 -`VtermMouseEventListener` (`mouse.py`). +One application-wide `EventListener`: `VtermCoreEventListener` (`listeners.py`). --- diff --git a/Default (OSX).sublime-mousemap b/Default (OSX).sublime-mousemap index 0128956..3f4c4af 100644 --- a/Default (OSX).sublime-mousemap +++ b/Default (OSX).sublime-mousemap @@ -11,6 +11,13 @@ "args": {"up": false}, "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, // which would clear the selection before a release command could read it diff --git a/README.md b/README.md index 2655701..bfe2ff7 100644 --- a/README.md +++ b/README.md @@ -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 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 -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). +wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks +and drags go to it instead (Claude Code's own mouse selection and copy work as in +kitty), 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): set 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 @@ -149,15 +150,16 @@ terminal protocols — see `Architecture.md`. 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 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 handled: it reaches no mousemap on macOS and is converted to wheel notches outside the plugin. Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004), -mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no -drag, no right button; the middle button pastes the selection instead of being -reported), focus events (1004), +mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left button are reported; a +drag arrives as one motion at release, since Sublime raises none; no right +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 unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR — block/bar and blink/steady via the view's caret settings; underline falls back diff --git a/main.py b/main.py index ce86753..8aad758 100644 --- a/main.py +++ b/main.py @@ -26,8 +26,8 @@ from .vterm.commands import ( ) from .vterm.listeners import VtermCoreEventListener from .vterm.mouse import ( - VtermMouseClickCommand, - VtermMouseEventListener, + VtermMousePressCommand, + VtermMouseReleaseCommand, VtermScrollCommand, ) from .vterm.scheme import build_startup_schemes diff --git a/tests/test_render.py b/tests/test_render.py index f096814..17ebcd4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -9,7 +9,7 @@ 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.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand from vterm.render import ( CHUNK, RenderState, @@ -626,19 +626,31 @@ terminal.view.window_to_text = lambda xy: 0 window = FakeWindow(active=object()) terminal.view.window = lambda: window stream.feed("\x1b[?1002h\x1b[?1006h") -click = VtermMouseClickCommand.__new__(VtermMouseClickCommand) -click.view = terminal.view +mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand) +mouse_press.view = terminal.view +mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand) +mouse_release.view = terminal.view 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" +mouse_release.run(None, event) assert terminal.process.written == "", repr(terminal.process.written) -# once focused, the same click reports as before -click.run(None, event) +# once focused, press and release at one cell report a click +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) -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 diff --git a/vterm/listeners.py b/vterm/listeners.py index e6acffe..6fc19d6 100644 --- a/vterm/listeners.py +++ b/vterm/listeners.py @@ -88,7 +88,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener): """Answer the contexts the mousemap needs""" # keymap contexts are native "setting.x"; mousemaps do not evaluate # 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 value = view.settings().get(key, False) if operator == sublime.OP_EQUAL: diff --git a/vterm/mouse.py b/vterm/mouse.py index 62e5fb1..ea8394f 100644 --- a/vterm/mouse.py +++ b/vterm/mouse.py @@ -13,6 +13,8 @@ from .utils import settings # SGR button numbers for the wheel; 64 is the "scroll" bit plus direction WHEEL_UP = 64 WHEEL_DOWN = 65 +# button 0 with the "moved while held" bit +MOTION = 32 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. 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"])) row, col = view.rowcol(pt) 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) -class VtermMouseEventListener(sublime_plugin.EventListener): - """Hands button-1 clicks to the app, but only while it 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.""" +class VtermMousePressCommand(sublime_plugin.TextCommand): + """Bound to button 1 in the mousemap while the app tracks the mouse.""" 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()) if not terminal: return + terminal.mouse_press = None # a release may not come: the context can drop mid-drag window = self.view.window() if window and window.active_view() != self.view: - # we replaced drag_select, which is what focuses the group; this - # click only focuses, as clicking an unfocused terminal window does + # the mousemap binding displaces drag_select, which is what focuses + # the group; this click only focuses, as in a terminal window window.focus_view(self.view) return cell = _event_to_cell(self.view, terminal, event) if cell is None: return + terminal.mouse_press = cell 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): """Ask sublime for the click coordinates.""" 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): """Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen.""" diff --git a/vterm/terminal.py b/vterm/terminal.py index 9763965..561ea08 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -121,6 +121,8 @@ class Terminal: # guards the queue/flag handoff between send_string and the drain thread self._send_lock = threading.Lock() 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.pending_clear = False self.pending_clear_screen = False @@ -329,7 +331,7 @@ class Terminal: if frames >= UNDO_CLEAR_FRAMES: frames = 0 self.view.run_command("vterm_clear_undo_stack") - self._sync_wheel_flag() + self._sync_mouse_flags() if done[0] or not self.is_hosted(): LOG.debug("[renderer] stops") break @@ -425,12 +427,16 @@ class Terminal: continue self.process.write(string) - def _sync_wheel_flag(self): - """Publish whether the app owns the wheel, for the mousemap context.""" - captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode + def _sync_mouse_flags(self): + """Publish whether the app owns the wheel and the mouse, for the mousemap contexts.""" + reporting = self.screen.mouse_reporting_enabled() + captured = reporting or self.screen.alternate_buffer_mode if captured != self._wheel_captured: self._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): """Report focus gain or loss, when the app asked for it (?1004)."""