Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40dac967e2 | ||
|
|
8f5564f4aa |
@@ -52,10 +52,11 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
|||||||
`Char` carrying the extra `linefeed` field.
|
`Char` carrying the extra `linefeed` field.
|
||||||
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
||||||
generated scheme file and the mapping from colour combination to scope name.
|
generated scheme file and the mapping from colour combination to scope name.
|
||||||
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
|
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
|
||||||
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
|
`TextCommand` instance whenever any plugin loads, so the view's render state
|
||||||
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
|
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
||||||
`_lastrow` are that view's render state.
|
bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
|
||||||
|
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -74,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`).
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
4
main.py
4
main.py
@@ -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
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ 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,
|
||||||
VtermRenderCommand,
|
VtermRenderCommand,
|
||||||
focus_cursor,
|
focus_cursor,
|
||||||
following,
|
following,
|
||||||
@@ -171,6 +172,8 @@ def make_command(terminal):
|
|||||||
# run() itself sets _scheme, but these tests call its helpers directly
|
# run() itself sets _scheme, but these tests call its helpers directly
|
||||||
command = VtermRenderCommand(terminal.view)
|
command = VtermRenderCommand(terminal.view)
|
||||||
command._scheme = terminal.scheme
|
command._scheme = terminal.scheme
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
command.state = terminal.render_state
|
||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
@@ -282,7 +285,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
|
|||||||
assert len(painted) == 2, painted
|
assert len(painted) == 2, painted
|
||||||
|
|
||||||
# keys are unique per view and recorded so decolorize_line can erase them
|
# keys are unique per view and recorded so decolorize_line can erase them
|
||||||
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
|
||||||
"colored_lines must track exactly the keys that were added"
|
"colored_lines must track exactly the keys that were added"
|
||||||
command.decolorize_line(0)
|
command.decolorize_line(0)
|
||||||
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||||
@@ -290,6 +293,29 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
|||||||
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||||
|
|
||||||
|
|
||||||
|
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
|
||||||
|
# bookkeeping lives on the terminal so a rebuilt command still erases what the
|
||||||
|
# old one painted, and its keys never collide with keys still in use
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal)
|
||||||
|
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert terminal.view.regions, "the red row must be painted"
|
||||||
|
# a reload: new command, same terminal
|
||||||
|
reloaded = VtermRenderCommand(terminal.view)
|
||||||
|
reloaded._scheme = terminal.scheme
|
||||||
|
reloaded.state = terminal.render_state
|
||||||
|
stream.feed("\x1b[H\x1b[2Kgone")
|
||||||
|
reloaded.update_lines(None, terminal)
|
||||||
|
assert terminal.view.regions == {}, \
|
||||||
|
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
|
||||||
|
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
|
||||||
|
|
||||||
|
print("reload tests passed (render state survives a rebuilt command)")
|
||||||
|
|
||||||
|
|
||||||
# rows that leave the screen have their colours merged onto shared per-(scope,
|
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||||
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||||
# the whole session, and merging every frame would re-send the whole chunk
|
# the whole session, and merging every frame would re-send the whole chunk
|
||||||
@@ -318,10 +344,10 @@ command = make_command(terminal)
|
|||||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
terminal.screen.dirty.clear()
|
terminal.screen.dirty.clear()
|
||||||
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||||
assert 0 not in command._archived_chunks
|
assert 0 not in command.state.archived_chunks
|
||||||
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
||||||
|
|
||||||
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
||||||
@@ -600,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
116
vterm/render.py
116
vterm/render.py
@@ -116,34 +116,49 @@ def ensure_position(view, edit, row, col=0):
|
|||||||
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
||||||
|
|
||||||
|
|
||||||
class VtermRenderCommand(sublime_plugin.TextCommand):
|
class RenderState:
|
||||||
"""Draws the screen into the view. Sublime keeps one instance per view, so
|
"""A view's render bookkeeping, kept on its Terminal.
|
||||||
the caches below are that view's render state."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
Sublime rebuilds every TextCommand instance of every view whenever any
|
||||||
"""Set up this view's render caches."""
|
plugin module loads. State held on the command is lost then: the overlays
|
||||||
super().__init__(*args, **kwargs)
|
already in the view become untracked, so a row rewrite no longer erases
|
||||||
# the terminal's current SchemeManager; set at the top of each run()
|
them, and the key counter restarts and hands out keys still in use.
|
||||||
self._scheme = None
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Empty bookkeeping, for a view that has nothing painted yet."""
|
||||||
|
# view row -> region keys painted on it
|
||||||
self.colored_lines = {}
|
self.colored_lines = {}
|
||||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||||
# repaint hint, so most dirty rows are identical frame to frame
|
# repaint hint, so most dirty rows are identical frame to frame
|
||||||
self._row_cache = {}
|
self.row_cache = {}
|
||||||
self._key_counter = 0
|
self.key_counter = 0
|
||||||
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||||
# rows trimmed so far, so they survive trim_history renumbering
|
# rows trimmed so far, so they survive trim_history renumbering
|
||||||
self._archived_chunks = {}
|
self.archived_chunks = {}
|
||||||
# rows waiting for their chunk to close, and the chunk they belong to
|
# rows waiting for their chunk to close, and the chunk they belong to
|
||||||
self._open_rows = []
|
self.open_rows = []
|
||||||
self._open_chunk = None
|
self.open_chunk = None
|
||||||
self._trimmed = 0
|
self.trimmed = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Draws the screen into the view. Holds nothing a frame depends on: the
|
||||||
|
view's bookkeeping is `terminal.render_state` (see RenderState)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Per-instance scratch only; the render state is bound in run()."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# the terminal's current SchemeManager and RenderState; set at the top of each run()
|
||||||
|
self._scheme = None
|
||||||
|
self.state = None
|
||||||
self._lastrow = 0
|
self._lastrow = 0
|
||||||
self._applied_cursor_style = None
|
self._applied_cursor_style = None
|
||||||
|
|
||||||
def next_key(self):
|
def next_key(self):
|
||||||
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
||||||
self._key_counter += 1
|
self.state.key_counter += 1
|
||||||
return "vterm#{}".format(self._key_counter)
|
return "vterm#{}".format(self.state.key_counter)
|
||||||
|
|
||||||
def ensure_row(self, edit, row):
|
def ensure_row(self, edit, row):
|
||||||
"""Row-existence check for the hot path, tracking the last row in python"""
|
"""Row-existence check for the hot path, tracking the last row in python"""
|
||||||
@@ -158,6 +173,9 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
self._scheme = terminal.scheme
|
self._scheme = terminal.scheme
|
||||||
|
if terminal.render_state is None:
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
self.state = terminal.render_state
|
||||||
|
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
# the caret and the tab name are the user's; an unfocused terminal leaves
|
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||||
@@ -256,16 +274,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
|
|
||||||
view.replace(edit, sublime.Region(0, view.size()), "")
|
view.replace(edit, sublime.Region(0, view.size()), "")
|
||||||
for line in list(self.colored_lines):
|
for line in list(self.state.colored_lines):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
for chunk, scopes in self._archived_chunks.items():
|
for chunk, scopes in self.state.archived_chunks.items():
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
self._archived_chunks.clear()
|
self.state.archived_chunks.clear()
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
self._open_chunk = None
|
self.state.open_chunk = None
|
||||||
self._trimmed = 0
|
self.state.trimmed = 0
|
||||||
self._row_cache.clear()
|
self.state.row_cache.clear()
|
||||||
terminal.offset = 0
|
terminal.offset = 0
|
||||||
screen.history.clear()
|
screen.history.clear()
|
||||||
if wipe_screen:
|
if wipe_screen:
|
||||||
@@ -312,9 +330,9 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
||||||
# back identical, and rewriting one costs an edit plus its region churn
|
# back identical, and rewriting one costs an edit plus its region churn
|
||||||
rendered = (segments, lf)
|
rendered = (segments, lf)
|
||||||
if self._row_cache.get(line) == rendered:
|
if self.state.row_cache.get(line) == rendered:
|
||||||
return
|
return
|
||||||
self._row_cache[line] = rendered
|
self.state.row_cache[line] = rendered
|
||||||
|
|
||||||
by_scope = {}
|
by_scope = {}
|
||||||
needed = 0
|
needed = 0
|
||||||
@@ -343,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
def colorize_line(self, line, line_start, by_scope):
|
def colorize_line(self, line, line_start, by_scope):
|
||||||
"""Lay the colour overlays for one row, one add_regions per scope."""
|
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||||
view = self.view
|
view = self.view
|
||||||
keys = self.colored_lines.setdefault(line, []) if by_scope else None
|
keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
|
||||||
for scope, spans in by_scope.items():
|
for scope, spans in by_scope.items():
|
||||||
key = self.next_key()
|
key = self.next_key()
|
||||||
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
||||||
@@ -351,21 +369,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
def decolorize_line(self, line):
|
def decolorize_line(self, line):
|
||||||
"""Drop a row's colour overlays before it is rewritten or erased."""
|
"""Drop a row's colour overlays before it is rewritten or erased."""
|
||||||
if line in self.colored_lines:
|
if line in self.state.colored_lines:
|
||||||
for key in self.colored_lines[line]:
|
for key in self.state.colored_lines[line]:
|
||||||
self.view.erase_regions(key)
|
self.view.erase_regions(key)
|
||||||
del self.colored_lines[line]
|
del self.state.colored_lines[line]
|
||||||
|
|
||||||
def archive_rows(self, rows):
|
def archive_rows(self, rows):
|
||||||
"""Queue rows that left the screen for their chunk's merge."""
|
"""Queue rows that left the screen for their chunk's merge."""
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row not in self.colored_lines:
|
if row not in self.state.colored_lines:
|
||||||
continue
|
continue
|
||||||
chunk = (row + self._trimmed) // CHUNK
|
chunk = (row + self.state.trimmed) // CHUNK
|
||||||
if chunk != self._open_chunk:
|
if chunk != self.state.open_chunk:
|
||||||
self.flush_chunk()
|
self.flush_chunk()
|
||||||
self._open_chunk = chunk
|
self.state.open_chunk = chunk
|
||||||
self._open_rows.append(row)
|
self.state.open_rows.append(row)
|
||||||
|
|
||||||
def flush_chunk(self):
|
def flush_chunk(self):
|
||||||
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||||
@@ -376,8 +394,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
whole set, so merging every frame would be quadratic in CHUNK.
|
whole set, so merging every frame would be quadratic in CHUNK.
|
||||||
"""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
rows, chunk = self._open_rows, self._open_chunk
|
rows, chunk = self.state.open_rows, self.state.open_chunk
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -385,12 +403,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
stale = []
|
stale = []
|
||||||
scope_of = self._scheme.scope
|
scope_of = self._scheme.scope
|
||||||
for row in rows:
|
for row in rows:
|
||||||
keys = self.colored_lines.pop(row, None)
|
keys = self.state.colored_lines.pop(row, None)
|
||||||
if not keys:
|
if not keys:
|
||||||
continue
|
continue
|
||||||
stale += keys
|
stale += keys
|
||||||
# the segments that were painted, straight from the row cache
|
# the segments that were painted, straight from the row cache
|
||||||
segments = self._row_cache[row][0]
|
segments = self.state.row_cache[row][0]
|
||||||
start = view.line(view.text_point(row, 0)).begin()
|
start = view.line(view.text_point(row, 0)).begin()
|
||||||
for _, a, b, fg, bg, style in segments:
|
for _, a, b, fg, bg, style in segments:
|
||||||
if is_default_run(fg, bg, style):
|
if is_default_run(fg, bg, style):
|
||||||
@@ -398,7 +416,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
scope = scope_of(fg, bg, style)
|
scope = scope_of(fg, bg, style)
|
||||||
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||||
|
|
||||||
scopes = self._archived_chunks.setdefault(chunk, set())
|
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||||
for scope, regions in grouped.items():
|
for scope, regions in grouped.items():
|
||||||
key = chunk_key(chunk, scope)
|
key = chunk_key(chunk, scope)
|
||||||
if scope in scopes:
|
if scope in scopes:
|
||||||
@@ -431,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
while row > cursor_row:
|
while row > cursor_row:
|
||||||
line_region = view.line(view.text_point(row, 0))
|
line_region = view.line(view.text_point(row, 0))
|
||||||
text = view.substr(line_region)
|
text = view.substr(line_region)
|
||||||
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
|
if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
|
||||||
row = row - 1
|
row = row - 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
@@ -445,7 +463,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
||||||
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
||||||
view.erase(edit, trailing_region)
|
view.erase(edit, trailing_region)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
|
|
||||||
def trim_history(self, edit, terminal):
|
def trim_history(self, edit, terminal):
|
||||||
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
||||||
@@ -459,8 +477,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
||||||
for line in range(m):
|
for line in range(m):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
|
self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
|
||||||
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
|
self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
|
||||||
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
||||||
view.erase(edit, top_region)
|
view.erase(edit, top_region)
|
||||||
terminal.offset -= m
|
terminal.offset -= m
|
||||||
@@ -468,12 +486,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
# chunks wholly above the cut go; the boundary chunk drops the
|
# chunks wholly above the cut go; the boundary chunk drops the
|
||||||
# regions the erase collapsed to empty
|
# regions the erase collapsed to empty
|
||||||
self._trimmed += m
|
self.state.trimmed += m
|
||||||
cut = self._trimmed // CHUNK
|
cut = self.state.trimmed // CHUNK
|
||||||
for chunk in [c for c in self._archived_chunks if c < cut]:
|
for chunk in [c for c in self.state.archived_chunks if c < cut]:
|
||||||
for scope in self._archived_chunks.pop(chunk):
|
for scope in self.state.archived_chunks.pop(chunk):
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
for scope in self._archived_chunks.get(cut, ()):
|
for scope in self.state.archived_chunks.get(cut, ()):
|
||||||
key = chunk_key(cut, scope)
|
key = chunk_key(cut, scope)
|
||||||
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
||||||
|
|
||||||
@@ -482,7 +500,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
for line in view.lines(tail_region):
|
for line in view.lines(tail_region):
|
||||||
row = view.rowcol(line.begin())[0]
|
row = view.rowcol(line.begin())[0]
|
||||||
self.decolorize_line(row)
|
self.decolorize_line(row)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
view.erase(edit, tail_region)
|
view.erase(edit, tail_region)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class Terminal:
|
|||||||
"""State only; the pty and the threads come up in start()."""
|
"""State only; the pty and the threads come up in start()."""
|
||||||
self.view = view
|
self.view = view
|
||||||
self.scheme = scheme
|
self.scheme = scheme
|
||||||
|
self.render_state = None
|
||||||
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||||
self.alive = True
|
self.alive = True
|
||||||
self.default_title = ""
|
self.default_title = ""
|
||||||
@@ -120,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
|
||||||
@@ -328,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
|
||||||
@@ -424,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)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user