mouse middle button click support added

This commit is contained in:
Vaclav Tvrdik
2026-09-10 09:49:16 +02:00
parent ab501b8cb5
commit d2b763a95a
8 changed files with 106 additions and 19 deletions

View File

@@ -46,7 +46,7 @@ Constraints it works under:
| `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`. |
| `Default (OSX).sublime-mousemap` | Two wheel bindings. |
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
| `dependencies.json` | Package Control libraries. |
Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`.
@@ -89,6 +89,7 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
| `vterm_render` | `VtermRenderCommand` | renderer thread |
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
@@ -312,6 +313,14 @@ mouse, and only the plain single-button form, so shift-click and add-selection s
native. The first click on an unfocused tab only focuses it — we replaced the command
that normally does that — and subsequent clicks send a press/release pair.
**The middle button** pastes the view's own selection into the app, as X11 does, and
never touches the clipboard. The binding is a `press_command`, not a `command`: Sublime's
own `button3` on macOS is a column `drag_select`, which would clear the selection on
press before a release command could read it. Shadowing it costs middle-drag column
select inside terminal tabs; the context makes the binding fall through everywhere else.
It is not gated on mouse reporting — an empty selection is already a no-op, and the alt
screen rarely holds one.
### Lifecycle
- **Tab closed** → `on_pre_close` → `Terminal.kill()`: unsubscribe, terminate the child,

View File

@@ -10,5 +10,12 @@
"command": "vterm_scroll",
"args": {"up": false},
"context": [{"key": "vterm_view.wheel_captured", "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
"button": "button3",
"press_command": "vterm_paste_selection",
"context": [{"key": "vterm_view", "operand": true}]
}
]

View File

@@ -48,6 +48,8 @@ the view over the scrollback and Shift+Up/Down move it one line, instead of
reaching the app, as xterm and kitty do — `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
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
@@ -122,7 +124,8 @@ terminal protocols — see `Architecture.md`.
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 or middle button), focus events (1004),
drag, 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

View File

@@ -1 +0,0 @@
/Users/vaclavt/.pi/agent/skills/pi-skills/brave-search

View File

@@ -18,6 +18,7 @@ from .vterm.commands import (
VtermOpenCommand,
VtermOpenTerminalProjectFolderCommand,
VtermPasteCommand,
VtermPasteSelectionCommand,
VtermPasteTextCommand,
VtermSelectThemeCommand,
)

View File

@@ -1,10 +1,11 @@
""" Theme picker flows: preview, cancel, confirm.
""" Theme picker flows and the selection-to-text helper.
Run with: python3 tests/test_commands.py """
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermSelectThemeCommand
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
from vterm.render import CONTINUATION
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
@@ -124,3 +125,59 @@ assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
class FakeView:
"""Just enough view for selection_text and the paste-selection command."""
def __init__(self, text, regions):
self.text = text
self.regions = list(regions)
self.commands = []
def substr(self, region):
return self.text[region.begin():region.end()]
def sel(self):
return self
def __iter__(self):
return iter(self.regions)
def clear(self):
self.regions = []
def run_command(self, name, args=None):
self.commands.append((name, args))
def paste(view):
command = VtermPasteSelectionCommand.__new__(VtermPasteSelectionCommand)
command.view = view
command.run(None)
return view.commands
# soft-wrapped rows re-join: the sentinel and the newline behind it both go
wrapped = "abc" + CONTINUATION + "\ndef"
view = FakeView(wrapped, [sublime.Region(0, len(wrapped))])
assert selection_text(view) == "abcdef", repr(selection_text(view))
# a trailing sentinel with no newline is still stripped
view = FakeView("abc" + CONTINUATION, [sublime.Region(0, 3 + len(CONTINUATION))])
assert selection_text(view) == "abc", repr(selection_text(view))
# multiple regions join with a newline
view = FakeView("abcdef", [sublime.Region(0, 3), sublime.Region(3, 6)])
assert selection_text(view) == "abc\ndef", repr(selection_text(view))
# the middle button pastes the selection and drops it, so the caret follows again
view = FakeView("abcdef", [sublime.Region(0, 3)])
assert paste(view) == [("vterm_paste_text", {"text": "abc"})], view.commands
assert list(view.sel()) == [], "selection must be cleared"
# nothing selected, nothing sent
view = FakeView("abcdef", [sublime.Region(2, 2)])
assert paste(view) == [], view.commands
print("selection tests passed (sentinel stripping, paste, empty no-op)")

View File

@@ -178,21 +178,20 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
return True
def selection_text(view):
"""The view's selection as one string, with soft-wrapped rows re-joined."""
text = "\n".join(view.substr(s) for s in view.sel())
return text.replace(CONTINUATION + "\n", "").replace(CONTINUATION, "")
class VtermCopyCommand(sublime_plugin.TextCommand):
"""Copies the selection, re-joining rows the emulator soft-wrapped."""
def run(self, edit):
"""Strip the soft-wrap sentinels, then copy as usual."""
view = self.view
if not view.settings().get("vterm_view"):
if not self.view.settings().get("vterm_view"):
return
text = "\n".join(view.substr(s) for s in view.sel())
# re-join soft-wrapped lines
text = text.replace(CONTINUATION + "\n", "")
text = text.replace(CONTINUATION, "")
sublime.set_clipboard(text)
sublime.set_clipboard(selection_text(self.view))
class VtermPasteCommand(sublime_plugin.TextCommand):
@@ -226,6 +225,19 @@ class VtermPasteTextCommand(sublime_plugin.TextCommand):
terminal.send_key(key="bracketed_paste_end")
class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
"""Sends the view's own selection to the app; the middle button, X11-style."""
def run(self, edit):
"""Bracketed like any paste, and the clipboard is never touched."""
text = selection_text(self.view)
if not text:
return
# as typing does, so the caret follows the terminal cursor again
self.view.sel().clear()
self.view.run_command("vterm_paste_text", {"text": text})
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
"""Pick a colour palette, previewing each as it is highlighted"""

View File

@@ -1,5 +1,4 @@
""" Event listeners: printable/IME input capture, command rerouting,
keymap context, focus reporting, lifecycle """
""" Event listeners: printable/IME input capture, command rerouting, keymap context, focus reporting, lifecycle """
import sublime
import sublime_plugin
@@ -87,10 +86,10 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
self._cursor[view.id()] = view.sel()[0].end()
def on_query_context(self, view, key, operator, operand, match_all):
"""Answer the one context the mousemap needs; keymap contexts are native."""
"""Answer the contexts the mousemap needs; keymap contexts are native."""
# keymap contexts are native "setting.x"; mousemaps do not evaluate
# those the same way, so the wheel flag still needs the hook
if key != "vterm_view.wheel_captured":
# those the same way, so their flags still need the hook
if key not in ("vterm_view", "vterm_view.wheel_captured"):
return None
value = view.settings().get(key, False)
if operator == sublime.OP_EQUAL: