diff --git a/Architecture.md b/Architecture.md index 0dab940..f1e585a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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, diff --git a/Default (OSX).sublime-mousemap b/Default (OSX).sublime-mousemap index 1e443e4..0128956 100644 --- a/Default (OSX).sublime-mousemap +++ b/Default (OSX).sublime-mousemap @@ -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}] } ] diff --git a/README.md b/README.md index 2ed7eb0..8325c08 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Users/vaclavt/.pi/agent/skills/brave-search b/Users/vaclavt/.pi/agent/skills/brave-search deleted file mode 100644 index 5f24fc3..0000000 --- a/Users/vaclavt/.pi/agent/skills/brave-search +++ /dev/null @@ -1 +0,0 @@ -/Users/vaclavt/.pi/agent/skills/pi-skills/brave-search diff --git a/main.py b/main.py index a559ccd..9abacc0 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ from .vterm.commands import ( VtermOpenCommand, VtermOpenTerminalProjectFolderCommand, VtermPasteCommand, + VtermPasteSelectionCommand, VtermPasteTextCommand, VtermSelectThemeCommand, ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 0595bda..896e183 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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)") diff --git a/vterm/commands.py b/vterm/commands.py index ece7d3d..b86ed1e 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -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""" diff --git a/vterm/listeners.py b/vterm/listeners.py index 2827d4c..f2ce832 100644 --- a/vterm/listeners.py +++ b/vterm/listeners.py @@ -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: