Compare commits
2 Commits
v1.1.1
...
7b536a7efa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b536a7efa | ||
|
|
40dac967e2 |
@@ -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`).
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
18
README.md
18
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
|
||||
|
||||
4
main.py
4
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
|
||||
|
||||
@@ -141,6 +141,11 @@ def run_case(char_by_char):
|
||||
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||
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
|
||||
cleared = []
|
||||
proc = FakeProcess()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -442,7 +442,9 @@ class VtScreen(pyte.Screen):
|
||||
|
||||
def index(self):
|
||||
"""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)
|
||||
super().index()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user