8 Commits
v1.1.0 ... main

Author SHA1 Message Date
Vaclav Tvrdik
2abbc503cf End DCS/OSC strings at any ESC, default the external terminal to Terminal.app
A string with no ST swallowed every later sequence, RIS included, and
Clear Screen could not recover the tab. Any ESC now ends it, as in xterm,
and the sequence it starts is dispatched. The shipped setting needed Kitty.

Also: REP, SCOSC/SCORC, DECOM-relative CPR, `:` subparams and more DECRQM
modes answered; new rows inserted in one edit; controls stripped from
pastes; wide-char mouse columns.
2026-11-01 20:23:08 +01:00
Vaclav Tvrdik
93253aa564 fix opening kitty settings 2026-10-25 11:14:39 +01:00
Vaclav Tvrdik
2715ecddbb Report a failed spawn, drop the paste wrapper and the unbound close command, sync docs
- vterm_open shows an error dialog when the command cannot start,
  instead of a tab that silently vanishes
- on_text_command hands paste straight to vterm_paste_text;
  VtermPasteCommand only forwarded
- VtermCloseCommand was bound nowhere; closing the tab already kills
  the process
- wheel_lines fallback matches the shipped 1; README said three
- Architecture.md: per-theme scheme files and managers, command table
2026-10-25 10:41:29 +01:00
Vaclav Tvrdik
a556dab638 Drop OSC 52 clipboard writes, force-kill on close, bound the reader, find the ST libs for tests
- OSC 52 is ignored: any output, cat'd or remote, could plant a command
  on the clipboard to paste
- kill(): terminate(force=True), so a child ignoring HUP and INT does
  not outlive its tab with the pty open
- reader pauses once 64 unparsed reads wait, so a large dump blocks the
  child at the pty instead of being buffered whole in memory
- tests/_bootstrap.py: add every Lib/python3* directory; Package Control
  puts the libraries under the active plugin host's own directory
2026-10-25 10:40:25 +01:00
Vaclav Tvrdik
e478f41f7c Fix text loss after zero-width chars, grey 248 quantization, scrollback cap, no-theme sampling, shipped terminal default
- draw(): a zero-width non-combining char (VS16, ZWJ) or a stray control
  byte dropped the rest of the run; skip the char instead
- quantize_256: grey 248 mapped to slot 256 and aborted the SGR
- trim_history: the cap now counts rows above the screen, so a value
  below the screen height (0: no scrollback) no longer cuts the screen
- "No theme" sampled the focused terminal's own colours; sample a
  non-terminal view instead
- vterm.sublime-settings: Apple Terminal is the default again, kitty
  commented out, as the README says
2026-10-25 10:39:13 +01:00
Vaclav Tvrdik
7b536a7efa Keep scrollback under a full-screen scroll region 2026-10-20 11:00:04 +02:00
Vaclav Tvrdik
40dac967e2 selection now without command modifier key 2026-10-16 14:34:50 +02:00
Vaclav Tvrdik
8f5564f4aa Keep render state on the Terminal; a plugin reload rebuilds the command
Sublime recreates every TextCommand instance whenever any plugin loads,
which dropped colored_lines and restarted the region key counter: old
overlays were never erased and keys were reused. RenderState now lives on
terminal.render_state, which survives reloads.
2026-10-12 15:39:29 +02:00
16 changed files with 576 additions and 233 deletions

View File

@@ -11,7 +11,7 @@
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. | | `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
| `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. | | `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). | | `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. | | `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup. |
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. | | `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
| `vterm/mouse.py` | Wheel and click handling. | | `vterm/mouse.py` | Wheel and click handling. |
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. | | `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
@@ -21,14 +21,15 @@
| File | Role | | File | Role |
|---|---| |---|---|
| `vterm.sublime-settings` | Defaults for the eleven plugin settings. | | `vterm.sublime-settings` | Defaults for the twelve plugin settings. |
| `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. | | `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. | | `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.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. | | `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
| `dependencies.json` | Package Control libraries. | | `dependencies.json` | Package Control libraries. |
Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`. Generated at runtime, not in the repo: `Packages/User/vterm/vterm-<theme>.hidden-color-scheme`,
one per theme (`vterm-system` for "no theme").
## Classes ## Classes
@@ -50,12 +51,13 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
into the next one. Parity is enforced by a differential test (`run_fast_path_cases`). into the next one. Parity is enforced by a differential test (`run_fast_path_cases`).
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a - **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
`Char` carrying the extra `linefeed` field. `Char` carrying the extra `linefeed` field.
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the - **`SchemeManager`** (`scheme.py`) — one per theme name, cached by `get_scheme_manager`.
generated scheme file and the mapping from colour combination to scope name. Owns the 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
@@ -67,17 +69,15 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings | | `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
| `vterm_render` | `VtermRenderCommand` | renderer thread | | `vterm_render` | `VtermRenderCommand` | renderer thread |
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal | | `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture | | `vterm_copy` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
| `vterm_paste_selection` | `commands.py` | mousemap, middle button | | `vterm_paste_selection` | `commands.py` | mousemap, middle button |
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette | | `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` | | `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit | | `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_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`).
--- ---
@@ -100,7 +100,8 @@ are marked ✅.
truecolor, `ESC[0m` reset. This is what TUIs emit most. truecolor, `ESC[0m` reset. This is what TUIs emit most.
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings - **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
foreground/background, `OSC 52` reads or writes the system clipboard. foreground/background, `OSC 52` reads or writes the system clipboard (vterm
ignores it: untrusted output must not plant a paste).
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command - **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and (`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
discards the payload, so an image never leaks into the text. discards the payload, so an image never leaks into the text.

View File

@@ -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

View File

@@ -79,15 +79,16 @@ 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
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe app one scroll line; `wheel_lines` raises that (iTerm2 sends 3) when the wheel
scrolls too far. While an app owns the feels slow. While an app owns the
alternate screen the scrollback above it is folded away, kitty-style, and alternate screen the scrollback above it is folded away, kitty-style, and
unfolds when the app leaves it. unfolds when the app leaves it.
@@ -125,7 +126,8 @@ terminal protocols — see `Architecture.md`.
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
probe, and OSC 10/11 colour queries; OSC 52 goes to the Sublime clipboard. probe, and OSC 10/11 colour queries; OSC 52 (clipboard write) is ignored,
since any output could plant a command to paste.
Combining characters are merged as NFC, because Sublime draws decomposed Combining characters are merged as NFC, because Sublime draws decomposed
sequences badly. sequences badly.
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz. - `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
@@ -149,15 +151,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
@@ -207,6 +210,6 @@ python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-b
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the `harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
emulator with no Sublime involved, and prints the queries answered plus the emulator with no Sublime involved, and prints the queries answered plus the
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
`Lib/python38` (pure Python, so any Python 3 runs them). `Lib/python3*` (pure Python, so any Python 3 runs them).
Code style is in `CODE.md`, working agreements in `CLAUDE.md`. Code style is in `CODE.md`, working agreements in `CLAUDE.md`.

View File

@@ -296,11 +296,12 @@ def drive(feed, need_to_render, render, data, tick):
def run_vterm(data, tick): def run_vterm(data, tick):
from vterm.emulator import VtScreen, VtStream from vterm.emulator import VtScreen, VtStream
from vterm.render import VtermRenderCommand from vterm.render import VtermRenderCommand
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal from vterm.terminal import Terminal
counter = collections.Counter() counter = collections.Counter()
view = CountingView(counter) view = CountingView(counter)
terminal = Terminal(view) terminal = Terminal(view, get_scheme_manager("default"))
terminal.scrollback_size = SCROLLBACK terminal.scrollback_size = SCROLLBACK
terminal.process = FakeProcess() terminal.process = FakeProcess()
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK) terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)

View File

@@ -12,22 +12,20 @@ from .vterm.commands import (
VtermClearScreenCommand, VtermClearScreenCommand,
VtermClearUndoStackCommand, VtermClearUndoStackCommand,
VtermCleanupCommand, VtermCleanupCommand,
VtermCloseCommand,
VtermCopyCommand, VtermCopyCommand,
VtermInitializeViewCommand, VtermInitializeViewCommand,
VtermKeypressCommand, VtermKeypressCommand,
VtermNoopCommand, VtermNoopCommand,
VtermOpenCommand, VtermOpenCommand,
VtermOpenTerminalProjectFolderCommand, VtermOpenTerminalProjectFolderCommand,
VtermPasteCommand,
VtermPasteSelectionCommand, VtermPasteSelectionCommand,
VtermPasteTextCommand, VtermPasteTextCommand,
VtermSelectThemeCommand, VtermSelectThemeCommand,
) )
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

View File

@@ -3,14 +3,16 @@
They run as plain scripts (see README), so each imports this first to get the They run as plain scripts (see README), so each imports this first to get the
sublime stubs and the Package Control libraries on the path. sublime stubs and the Package Control libraries on the path.
""" """
import glob
import os import os
import sys import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ST_LIB = os.path.expanduser( # Package Control puts the libraries under the plugin host's own directory
"~/Library/Application Support/Sublime Text/Lib/python38") ST_LIBS = sorted(glob.glob(os.path.expanduser(
"~/Library/Application Support/Sublime Text/Lib/python3*")))
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")): for path in [ROOT] + ST_LIBS + [os.path.join(ROOT, "tests", "stubs")]:
if path not in sys.path: if path not in sys.path:
sys.path.insert(0, path) sys.path.insert(0, path)

View File

@@ -4,7 +4,7 @@ import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess from _bootstrap import FakeProcess
import sublime import sublime
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text from vterm.commands import VtermPasteSelectionCommand, VtermPasteTextCommand, VtermSelectThemeCommand, selection_text
from vterm.emulator import VtScreen from vterm.emulator import VtScreen
from vterm.render import CONTINUATION from vterm.render import CONTINUATION
from vterm.scheme import get_scheme_manager from vterm.scheme import get_scheme_manager
@@ -25,6 +25,7 @@ class FakeTerminalView:
self._id = FakeTerminalView._next_id self._id = FakeTerminalView._next_id
FakeTerminalView._next_id += 1 FakeTerminalView._next_id += 1
self._settings = sublime.Settings() self._settings = sublime.Settings()
self._settings.set("vterm_view", True)
def id(self): def id(self):
return self._id return self._id
@@ -36,7 +37,24 @@ class FakeTerminalView:
return True return True
def style(self): def style(self):
return {} # a terminal view reports its own theme's colours, not the editor's
return {"foreground": "#f2f2f2", "background": "#000000"}
class FakeEditorView:
"""An ordinary file view, the one the "No theme" palette is sampled from."""
def settings(self):
return sublime.Settings()
def is_valid(self):
return True
def style(self):
return {"foreground": "#111111", "background": "#eeeeee"}
editor = FakeEditorView()
class FakeWindow: class FakeWindow:
@@ -46,6 +64,9 @@ class FakeWindow:
def active_view(self): def active_view(self):
return self._view return self._view
def views(self):
return [self._view, editor]
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None): def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done, panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight) selected_index=selected_index, on_highlight=on_highlight)
@@ -105,6 +126,12 @@ command = open_picker(fake_terminal("slate"))
panel["on_done"](0) panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme") assert settings.get("theme") is None, settings.get("theme")
# ... and samples the editor's colours, not the themed terminal's own: that
# would retint every "no theme" terminal with the theme being left
system = get_scheme_manager(None)
assert (system.foreground, system.background) == ("#111111", "#eeeeee"), \
(system.foreground, system.background)
print("theme picker tests passed ({} themes offered)".format(len(THEMES))) print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
@@ -218,3 +245,17 @@ view = FakeView("abcdef", [sublime.Region(2, 2)])
assert paste(view) == [], view.commands assert paste(view) == [], view.commands
print("selection tests passed (sentinel stripping, paste, empty no-op)") print("selection tests passed (sentinel stripping, paste, empty no-op)")
# a paste is a trust boundary: C0 controls bar tab/LF/CR go, so a pasted
# ESC[201~ cannot close the bracket and type the rest into the app
terminal = fake_terminal("default")
terminal.process = terminal.screen._process
terminal.screen.set_mode(2004, private=True)
terminal.view.run_command = lambda name, args=None: None
command = VtermPasteTextCommand.__new__(VtermPasteTextCommand)
command.view = terminal.view
command.run(None, "a\x1b[201~b\x00\tc\nd")
assert terminal.process.written == "\x1b[200~a[201~b\tc\rd\x1b[201~", repr(terminal.process.written)
print("paste tests passed (controls stripped, bracket kept whole)")

View File

@@ -83,13 +83,11 @@ def run_case(char_by_char):
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written) assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
assert screen.buffer[0][0].data == "y", tag assert screen.buffer[0][0].data == "y", tag
# OSC 52 clipboard (base64 "hi") # OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
got = [] # not plant a paste
proc = FakeProcess() proc, screen, stream = make()
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append) feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
stream = VtStream(screen) assert screen.buffer[0][0].data == "z" and proc.written == "", tag
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
assert got == ["hi"], (tag, got)
# bracketed paste and sync modes tracked # bracketed paste and sync modes tracked
proc, screen, stream = make() proc, screen, stream = make()
@@ -119,6 +117,21 @@ def run_case(char_by_char):
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg) assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
# every grey lands inside the palette; 248 used to map to slot 256 and
# abort the whole SGR
from vterm.emulator import quantize_256
assert all(0 <= quantize_256(v, v, v) <= 255 for v in range(256))
proc, screen, stream = make()
feed(stream, "\x1b[38;2;248;248;248mX", char_by_char)
assert screen.buffer[0][0].fg == "ffffff", (tag, screen.buffer[0][0].fg)
# a zero-width non-combining char (VS16, ZWJ) or a stray control byte is
# dropped; the text after it must survive
proc, screen, stream = make()
feed(stream, "⚠️ warn \U0001F468‍\U0001F4BB ab\x01cd", char_by_char)
row = "".join(screen.buffer[0][x].data for x in range(20)).rstrip()
assert row == "⚠ warn \U0001F468\U0001F4BB abcd", (tag, repr(row))
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field # SGR 5/25 (blink): ignored, not a crash — Char has no blink field
proc, screen, stream = make() proc, screen, stream = make()
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char) feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
@@ -141,6 +154,11 @@ def run_case(char_by_char):
assert len(screen.history) == 1, (tag, len(screen.history)) assert len(screen.history) == 1, (tag, len(screen.history))
assert screen.history[0][0].data == "a", tag 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 # ED 3 clears history and fires callback
cleared = [] cleared = []
proc = FakeProcess() proc = FakeProcess()
@@ -310,7 +328,7 @@ GAUNTLET = (
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r" "\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
"\x1b[38:5:100mcolon\x1b[m\r\n" "\x1b[38:5:100mcolon\x1b[m\r\n"
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n" "\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
"wide: 漢字 \x1b[7m漢\x1b[m\r\n" "wide: 漢字 \x1b[7m漢\x1b[m ⚠️ after\r\n"
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n" "\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
) )
@@ -436,8 +454,67 @@ def run_clear_cases():
print("all clear-screen tests passed") print("all clear-screen tests passed")
def run_parser_edge_cases():
"""Sequences the stock collector misread: intermediates, `:` subparams, REP, marks."""
for char_by_char in (False, True):
tag = "char-by-char" if char_by_char else "chunked"
# DECSTR (`reset` sends it): `!` is an intermediate, the `p` must not be drawn
proc, screen, stream = make(20, 4)
feed(stream, "\x1b[!pX", char_by_char)
assert screen.display[0].rstrip() == "X", (tag, repr(screen.display[0]))
# `4:3` is curly underline, not SGR 43 (yellow background)
proc, screen, stream = make(20, 4)
feed(stream, "\x1b[4:3mX\x1b[38:2:1:2:3mY", char_by_char)
cell = screen.buffer[0][0]
assert (cell.bg, cell.underscore) == ("default", True), (tag, cell)
assert screen.buffer[0][1].data == "Y", (tag, screen.buffer[0][1])
# REP repeats the last printable; ncurses emits it for runs
proc, screen, stream = make(20, 4)
feed(stream, "a\x1b[3b\x1b[31mb\x1b[b", char_by_char)
assert screen.display[0].rstrip() == "aaaabb", (tag, repr(screen.display[0]))
assert screen.buffer[0][5].fg == "red", (tag, screen.buffer[0][5])
# a Thai vowel sign has combining class 0 but is still a mark: merged, not dropped
proc, screen, stream = make(20, 4)
feed(stream, "\u0e01\u0e31x", char_by_char)
assert screen.buffer[0][0].data == "\u0e01\u0e31", (tag, screen.buffer[0][0])
assert screen.buffer[0][1].data == "x", (tag, screen.buffer[0][1])
# DECRQM: the modes we honour answer set/reset, never "unrecognized"
proc, screen, stream = make()
feed(stream, "\x1b[?2031$p\x1b[?1h\x1b[?1$p\x1b[4$p\x1b[20$p", char_by_char)
assert proc.written == "\x1b[?2031;2$y\x1b[?1;1$y\x1b[4;2$y\x1b[20;2$y", (tag, proc.written)
# RIS on the alternate screen lands back on a cleared primary screen
proc, screen, stream = make(20, 4)
feed(stream, "primary\x1b[?1049halt\x1bc", char_by_char)
assert not screen.alternate_buffer_mode and not screen.savepoints, tag
assert screen.display[0].strip() == "", (tag, repr(screen.display[0]))
feed(stream, "\r\n" * 4, char_by_char)
assert len(screen.history) == 1, (tag, len(screen.history))
# SCOSC/SCORC, and CPR relative to the region under DECOM
proc, screen, stream = make(20, 4)
feed(stream, "ab\x1b[s\x1b[3;3H\x1b[uX\x1b[2;3r\x1b[?6h\x1b[6n", char_by_char)
assert screen.display[0].rstrip() == "abX", (tag, repr(screen.display[0]))
assert proc.written == "\x1b[1;1R", (tag, proc.written)
# a string with no terminator ends at the next ESC, as in xterm; a stray
# ESC P from a binary must not swallow the rest of the session
proc, screen, stream = make(20, 4)
feed(stream, "\x1bPgarbage\x1bcX\x1b]0;title\x1b[1mY", char_by_char)
assert screen.display[0].rstrip() == "XY", (tag, repr(screen.display[0]))
assert screen.title == "title" and screen.buffer[0][1].bold, (tag, screen.title)
print("all parser edge-case tests passed")
run_case(char_by_char=False) run_case(char_by_char=False)
run_case(char_by_char=True) run_case(char_by_char=True)
run_parser_edge_cases()
run_clear_cases() run_clear_cases()
run_resize_cases() run_resize_cases()
run_private_csi_cases() run_private_csi_cases()

View File

@@ -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
@@ -188,17 +191,30 @@ command.trim_history(None, terminal)
assert terminal.view.text == before, \ assert terminal.view.text == before, \
"alt screen view was trimmed: {!r}".format(terminal.view.text) "alt screen view was trimmed: {!r}".format(terminal.view.text)
# the configured cap still applies on the primary screen # the configured cap still applies on the primary screen: that many rows of
# scrollback above the screen
terminal, stream = make_terminal(rows=5, scrollback=20) terminal, stream = make_terminal(rows=5, scrollback=20)
terminal.offset = 40 terminal.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40))) terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal) command = make_command(terminal)
command.trim_history(None, terminal) command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1 rows_left = terminal.view.text.count("\n") + 1
assert rows_left <= 20, rows_left assert rows_left <= 25, rows_left
assert "row 39" in terminal.view.text, "newest rows must survive" assert "row 39" in terminal.view.text, "newest rows must survive"
assert "row 0" not in terminal.view.text, "oldest rows must be dropped" assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
# a cap below the screen height (0: no scrollback) must never cut the screen
terminal, stream = make_terminal(rows=10, cols=20, scrollback=0)
command = make_command(terminal)
for i in range(3):
stream.feed("\r\n".join("row{}".format(i * 10 + j) for j in range(10)) + "\r\n")
command.update_lines(None, terminal)
command.trim_trailing_spaces(None, terminal)
command.trim_history(None, terminal)
terminal.screen.dirty.clear()
assert terminal.offset >= 0, terminal.offset
assert "row29" in terminal.view.text, terminal.view.text
# below the cap nothing is dropped # below the cap nothing is dropped
terminal, stream = make_terminal(rows=5, scrollback=5000) terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3 terminal.offset = 3
@@ -282,7 +298,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 +306,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 +357,16 @@ 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 # rows new to the view went in as one insert, coloured straight onto their
# chunk keys; only row 0 and the two coloured screen rows have a key of their own
assert set(command.state.archived_chunks) == {0, 1}, command.state.archived_chunks
assert len(terminal.view.regions["vterm@1@vterm.red.default"][1]) == terminal.offset - CHUNK
row_keys = [k for k in terminal.view.regions if k.startswith("vterm#")]
assert len(row_keys) == 3, row_keys
assert terminal.view.text.split("\n")[:3] == ["r0", "r1", "r2"], terminal.view.text[:20]
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)")
@@ -416,6 +461,8 @@ Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal) terminal.view.id = lambda: id(terminal)
scroll = VtermScrollCommand.__new__(VtermScrollCommand) scroll = VtermScrollCommand.__new__(VtermScrollCommand)
scroll.view = terminal.view scroll.view = terminal.view
# three per notch, so "one report per line" is visible
sublime.load_settings(SETTINGS_FILE).set("wheel_lines", 3)
stream.feed("\x1b[?1049h") stream.feed("\x1b[?1049h")
scroll.run(None, up=True) scroll.run(None, up=True)
@@ -600,19 +647,39 @@ 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)
# the column is counted in cells: a wide char before the point takes two
terminal.process.writes.clear()
terminal.view.text = "\u65e5\u672cx \nr1 \nr2 \nr3 \nr4 \n"
terminal.view.window_to_text = lambda xy: terminal.view.text_point(0, 2)
mouse_press.run(None, event)
mouse_release.run(None, event)
assert terminal.process.written == "\x1b[<0;5;1M\x1b[<0;5;1m", repr(terminal.process.written)
print("click tests passed (first click focuses only; press, drag and release report; wide chars)")
# entering the alt screen folds the scrollback away (kitty hides it); leaving # entering the alt screen folds the scrollback away (kitty hides it); leaving
@@ -698,4 +765,12 @@ line[0] = Char("r", "red", "default", False, False, False, False, True)
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]] runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse)") # a cell can hold two code points (a mark NFC could not compose): the spans
# that follow are offsets into the row text, so they move by two
line = StaticDefaultDict(Char(" "))
line[0] = Char("q\u0301", "default", "default")
line[1] = Char("x", "blue", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("q\u0301", 0, 2, "default", "default", ""), ("x", 2, 3, "blue", "default", "")], runs
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse, two-code-point cell)")

View File

@@ -33,12 +33,12 @@
// External terminal for "vterm: Open Terminal in Project Folder". // External terminal for "vterm: Open Terminal in Project Folder".
// %CWD% in parameters is replaced with the project folder. // %CWD% in parameters is replaced with the project folder.
// "terminal": "/usr/bin/open", "terminal": "/usr/bin/open",
// "parameters": ["-a", "Terminal", "%CWD%"], "parameters": ["-a", "Terminal", "%CWD%"],
// kitty instead of Apple Terminal: // kitty instead of Apple Terminal:
"terminal": "/opt/homebrew/bin/kitty", // "terminal": "/usr/bin/open",
"parameters": ["-d", "%CWD%"], // "parameters": ["-a", "Kitty", "%CWD%"],
"debug": false "debug": false
} }

View File

@@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__)
SCROLL_KEYS = ("pageup", "pagedown", "up", "down") SCROLL_KEYS = ("pageup", "pagedown", "up", "down")
def editor_view(window):
"""A view to sample the editor's colours from: never a terminal, whose
colours are its own theme's."""
return next((v for v in window.views() if not v.settings().get("vterm_view")), None)
class VtermOpenCommand(sublime_plugin.WindowCommand): class VtermOpenCommand(sublime_plugin.WindowCommand):
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind.""" """Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
@@ -43,7 +49,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
cwd = os.path.expanduser("~") cwd = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables()) cwd = sublime.expand_variables(cwd, window.extract_variables())
scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view()) scheme = get_scheme_manager(settings().get("theme"), host_view=editor_view(window))
view = window.new_file() view = window.new_file()
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path}) view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
@@ -54,6 +60,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
except Exception: except Exception:
LOG.exception("failed to start terminal: cmd=%s", cmd) LOG.exception("failed to start terminal: cmd=%s", cmd)
view.close() view.close()
sublime.error_message("vterm: could not start {}".format(" ".join(cmd)))
return return
window.focus_view(view) window.focus_view(view)
@@ -183,14 +190,6 @@ class VtermCopyCommand(sublime_plugin.TextCommand):
sublime.set_clipboard(selection_text(self.view)) sublime.set_clipboard(selection_text(self.view))
class VtermPasteCommand(sublime_plugin.TextCommand):
"""Pastes the clipboard into the app; replaces sublime's own paste."""
def run(self, edit):
"""Hand the clipboard to vterm_paste_text."""
self.view.run_command("vterm_paste_text", {"text": sublime.get_clipboard()})
class VtermPasteTextCommand(sublime_plugin.TextCommand): class VtermPasteTextCommand(sublime_plugin.TextCommand):
"""Sends arbitrary text to the app, bracketed when it asked for that.""" """Sends arbitrary text to the app, bracketed when it asked for that."""
@@ -258,7 +257,7 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
def _apply(self, name): def _apply(self, name):
"""Retheme the focused terminal alone.""" """Retheme the focused terminal alone."""
scheme = get_scheme_manager(name, host_view=self.terminal.view) scheme = get_scheme_manager(name, host_view=editor_view(self.window))
self.terminal.retheme(scheme) self.terminal.retheme(scheme)
def _preview(self, index): def _preview(self, index):
@@ -328,20 +327,6 @@ class VtermCleanupCommand(sublime_plugin.TextCommand):
view.sel().clear() view.sel().clear()
class VtermCloseCommand(sublime_plugin.TextCommand):
"""Kills the process and closes the tab. Registered but bound nowhere; for user keybindings."""
def run(self, _):
"""Kill the process, then close the view."""
view = self.view
if not view.settings().get("vterm_view"):
return
terminal = Terminal.from_id(view.id())
if terminal:
terminal.kill()
view.close()
class VtermClearUndoStackCommand(sublime_plugin.TextCommand): class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
"""Drops the undo history, so rendered output can never be undone into view.""" """Drops the undo history, so rendered output can never be undone into view."""

View File

@@ -1,5 +1,4 @@
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """ """ Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
import base64
import logging import logging
import re import re
import time import time
@@ -41,7 +40,7 @@ FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)} BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5) # DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026) REPORTABLE_MODES = (1, mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026, 2031)
# DEC private modes, pre-shifted into pyte's internal representation. # DEC private modes, pre-shifted into pyte's internal representation.
# 1: DECCKM, application cursor keys (arrows send SS3) # 1: DECCKM, application cursor keys (arrows send SS3)
@@ -83,7 +82,8 @@ def quantize_256(r, g, b):
if r == g == b: if r == g == b:
if r < 8: if r < 8:
return 16 return 16
if r > 248: # 238 is the top of the grey ramp (255); above the midpoint, white
if r > 246:
return 231 return 231
return 232 + (r - 8) // 10 return 232 + (r - 8) // 10
@@ -118,7 +118,7 @@ class Cursor:
class VtScreen(pyte.Screen): class VtScreen(pyte.Screen):
"""Screen with alternate buffer, scrollback, synchronized output, """Screen with alternate buffer, scrollback, synchronized output,
mouse/focus/paste modes, cursor styles, query replies and OSC handling. mouse/focus/paste modes, cursor styles, query replies and OSC titles/colours.
Mutated only by the renderer thread, so it carries no lock. Mutated only by the renderer thread, so it carries no lock.
""" """
@@ -136,7 +136,6 @@ class VtScreen(pyte.Screen):
history=5000, history=5000,
erase_scrollback=True, erase_scrollback=True,
clear_callback=lambda: None, clear_callback=lambda: None,
clipboard_callback=lambda text: None,
fg_color="ffffff", fg_color="ffffff",
bg_color="000000", bg_color="000000",
is_dark=True, is_dark=True,
@@ -145,7 +144,6 @@ class VtScreen(pyte.Screen):
self._process = process self._process = process
self._erase_scrollback = erase_scrollback self._erase_scrollback = erase_scrollback
self._clear_callback = clear_callback self._clear_callback = clear_callback
self._clipboard_callback = clipboard_callback
self.primary_buffer = {} self.primary_buffer = {}
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params) # SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
@@ -155,6 +153,8 @@ class VtScreen(pyte.Screen):
self._char_caches = {} self._char_caches = {}
self._char_cache = {} self._char_cache = {}
self._cache_attrs = None self._cache_attrs = None
# last printable drawn, for REP
self._last_char = ""
self.history = deque(maxlen=history) self.history = deque(maxlen=history)
self.alternate_buffer_mode = False self.alternate_buffer_mode = False
self._sync_frame_start = 0 self._sync_frame_start = 0
@@ -167,11 +167,16 @@ class VtScreen(pyte.Screen):
def reset(self): def reset(self):
"""RIS: reset the screen along with the cursor, scrollback and caches""" """RIS: reset the screen along with the cursor, scrollback and caches"""
if self.alternate_buffer_mode:
# the primary screen is what a reset clears; leave the alt one behind
self.reset_mode(1049, private=True)
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
self._sgr_cache.clear() self._sgr_cache.clear()
self._char_caches.clear() self._char_caches.clear()
self._cache_attrs = None self._cache_attrs = None
self._last_char = ""
super().reset() super().reset()
self.savepoints = []
self.cursor = Cursor(0, 0) self.cursor = Cursor(0, 0)
self.cursor_style = 0 self.cursor_style = 0
self.modify_other_keys = 0 self.modify_other_keys = 0
@@ -378,6 +383,7 @@ class VtScreen(pyte.Screen):
dirty.add(cursor.y) dirty.add(cursor.y)
cursor.x = x + take cursor.x = x + take
pos = end pos = end
self._last_char = data[-1]
return return
line = buffer[cursor.y] line = buffer[cursor.y]
@@ -402,6 +408,7 @@ class VtScreen(pyte.Screen):
if cell is None: if cell is None:
cell = cache[char] = attrs._replace(data=char) cell = cache[char] = attrs._replace(data=char)
line[cursor.x] = cell line[cursor.x] = cell
self._last_char = char
if char_width == 2 and cursor.x + 1 < columns: if char_width == 2 and cursor.x + 1 < columns:
filler = cache.get("") filler = cache.get("")
if filler is None: if filler is None:
@@ -409,7 +416,10 @@ class VtScreen(pyte.Screen):
line[cursor.x + 1] = filler line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the # a combining mark belongs to the cell before it, which may be the
# last column of the previous row # last column of the previous row
elif char_width == 0 and unicodedata.combining(char): # a mark: by category, not combining(), which is 0 for Thai/Indic
# vowel signs. Variation selectors are marks too but stay dropped:
# merged, VS16 would make the cell width 2 and swallow its neighbour
elif char_width == 0 and unicodedata.category(char)[0] == "M" and not "\ufe00" <= char <= "\ufe0f":
# sublime renders decomposed combining chars poorly; merge as NFC # sublime renders decomposed combining chars poorly; merge as NFC
pos = None pos = None
last = None last = None
@@ -432,7 +442,8 @@ class VtScreen(pyte.Screen):
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized) self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
self.dirty.add(pos[0]) self.dirty.add(pos[0])
else: else:
break # other zero-width (VS16, ZWJ) or control: drop it, keep the rest
continue
if char_width > 0: if char_width > 0:
x = cursor.x + char_width x = cursor.x + char_width
@@ -440,9 +451,16 @@ class VtScreen(pyte.Screen):
self.dirty.add(cursor.y) self.dirty.add(cursor.y)
def repeat_character(self, count=None):
"""REP: draw the last printable again `count` times."""
if self._last_char:
self.draw(self._last_char * (count or 1))
def index(self): def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback.""" """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) self.push_lines_into_history(1)
super().index() super().index()
@@ -614,6 +632,8 @@ class VtScreen(pyte.Screen):
elif mode == 6: elif mode == 6:
x = min(self.cursor.x, self.columns - 1) + 1 x = min(self.cursor.x, self.columns - 1) + 1
y = self.cursor.y + 1 y = self.cursor.y + 1
if mo.DECOM in self.mode and self.margins:
y -= self.margins.top
marker = "?" if private else "" marker = "?" if private else ""
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x)) self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
elif mode == 996 and private: elif mode == 996 and private:
@@ -631,7 +651,10 @@ class VtScreen(pyte.Screen):
def report_mode_status(self, mode, private): def report_mode_status(self, mode, private):
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement.""" """DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
if not private: if not private:
self.write_process_input("\x1b[{};0$y".format(mode)) value = 0
if mode in (mo.IRM, mo.LNM):
value = 1 if mode in self.mode else 2
self.write_process_input("\x1b[{};{}$y".format(mode, value))
return return
if mode in REPORTABLE_MODES: if mode in REPORTABLE_MODES:
value = 1 if (mode << 5) in self.mode else 2 value = 1 if (mode << 5) in self.mode else 2
@@ -646,7 +669,7 @@ class VtScreen(pyte.Screen):
self.write_process_input("\x1b[?0u") self.write_process_input("\x1b[?0u")
def handle_osc(self, code, payload, terminator): def handle_osc(self, code, payload, terminator):
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard.""" """Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query."""
if code in (0, 2): if code in (0, 2):
self.set_title(payload) self.set_title(payload)
elif code == 1: elif code == 1:
@@ -657,14 +680,8 @@ class VtScreen(pyte.Screen):
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6]) rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
# answer with the terminator asked with: meli only ends an OSC at ST # answer with the terminator asked with: meli only ends an OSC at ST
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator)) self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
elif code == 52: # other OSC codes are intentionally dropped, 52 (clipboard write) among
parts = payload.split(";", 1) # them: any output, cat'd or remote, could plant a command to paste
if len(parts) == 2 and parts[1] and parts[1] != "?":
try:
self._clipboard_callback(base64.b64decode(parts[1]).decode("utf-8", "replace"))
except ValueError:
LOG.warning("osc52: undecodable payload", exc_info=True)
# other OSC codes are intentionally dropped
# a complete plain CSI: params then a final byte. No private marker, prefix or # a complete plain CSI: params then a final byte. No private marker, prefix or
@@ -688,6 +705,9 @@ class VtStream(pyte.Stream):
self.csi = dict(self.csi) self.csi = dict(self.csi)
self.csi["S"] = "scroll_up" self.csi["S"] = "scroll_up"
self.csi["T"] = "scroll_down" self.csi["T"] = "scroll_down"
self.csi["b"] = "repeat_character"
self.csi["s"] = "save_cursor"
self.csi["u"] = "restore_cursor"
self._carry = "" self._carry = ""
# param-string -> parsed tuple; SGR strings repeat across a whole frame # param-string -> parsed tuple; SGR strings repeat across a whole frame
self._params_cache = {} self._params_cache = {}
@@ -751,7 +771,8 @@ class VtStream(pyte.Stream):
raw = match.group(1) raw = match.group(1)
params = cache.get(raw) params = cache.get(raw)
if params is None: if params is None:
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";")) # no parameters is (), not (0,): save_cursor takes none
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";")) if raw else ()
if len(cache) > 4096: if len(cache) > 4096:
cache.clear() cache.clear()
cache[raw] = params cache[raw] = params
@@ -810,24 +831,33 @@ class VtStream(pyte.Stream):
escape_dispatch = create_dispatcher(self.escape) escape_dispatch = create_dispatcher(self.escape)
csi_dispatch = create_dispatcher(self.csi) csi_dispatch = create_dispatcher(self.csi)
# the byte after an ESC that cut a string short: any ESC ends a string,
# as in xterm, or one with no ST would swallow every later sequence
pending = None
while True: while True:
char = yield True char = (yield True) if pending is None else ESC
# ESC introducer: fold the 7-bit forms onto their C1 equivalents # ESC introducer: fold the 7-bit forms onto their C1 equivalents
if char == ESC: if char == ESC:
if pending is None:
char = yield None char = yield None
else:
char, pending = pending, None
if char == "[": if char == "[":
char = CSI_C1 char = CSI_C1
elif char == "]": elif char == "]":
char = OSC_C1 char = OSC_C1
elif char in "PX^_": elif char in "PX^_":
# consume the payload so it never reaches the screen # consume the payload so it never reaches the screen
prev = ""
while True: while True:
c = yield None c = yield None
if c == ctrl.ST_C1 or (prev == ESC and c == "\\"): if c == ESC:
c = yield None
if c != "\\":
pending = c
break
if c == ctrl.ST_C1:
break break
prev = c
continue continue
else: else:
if char == "#": if char == "#":
@@ -853,13 +883,15 @@ class VtStream(pyte.Stream):
private = False private = False
prefix = "" prefix = ""
intermediate = "" intermediate = ""
# inside a `:` subparameter list, which is dropped (4:3 must not read as 43)
sub = False
while True: while True:
char = yield None char = yield None
if char == "?": if char == "?":
private = True private = True
elif char in "><=": elif char in "><=":
prefix = char prefix = char
elif char in " $": elif " " <= char <= "/":
intermediate = char intermediate = char
elif char in ALLOWED_IN_CSI: elif char in ALLOWED_IN_CSI:
basic_dispatch[char]() basic_dispatch[char]()
@@ -867,14 +899,16 @@ class VtStream(pyte.Stream):
draw(char) draw(char)
break break
elif char.isdigit(): elif char.isdigit():
if not sub:
current += char current += char
elif char == ":": elif char == ":":
# SGR subparameters, which we do not support sub = True
pass
elif char == ";": elif char == ";":
params.append(min(int(current or 0), 9999)) params.append(min(int(current or 0), 9999))
current = "" current = ""
sub = False
else: else:
if current or params:
params.append(min(int(current or 0), 9999)) params.append(min(int(current or 0), 9999))
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate) self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
break break
@@ -890,18 +924,19 @@ class VtStream(pyte.Stream):
break break
payload = "" payload = ""
if char == ";" or (char not in OSC_TERMINATORS and char != ESC): if char == ";":
while True:
char = yield None char = yield None
while char not in OSC_TERMINATORS:
if char == ESC: if char == ESC:
char += yield None char = yield None
if char in OSC_TERMINATORS: if char != "\\":
pending = char
# char holds the whole terminator, ST for a cut string
char = ctrl.ST_C0
break break
if len(payload) < MAX_OSC: if len(payload) < MAX_OSC:
payload += char payload += char
elif char == ESC: char = yield None
# take the '\' of ST, so char holds the whole terminator
char += yield None
try: try:
listener.handle_osc(int(code or 0), payload, char) listener.handle_osc(int(code or 0), payload, char)

View File

@@ -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:
@@ -104,6 +104,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
if name == "copy": if name == "copy":
return ("vterm_copy", None) return ("vterm_copy", None)
elif name in ("paste", "paste_and_indent"): elif name in ("paste", "paste_and_indent"):
return ("vterm_paste", None) return ("vterm_paste_text", {"text": sublime.get_clipboard()})
elif name == "undo": elif name == "undo":
return ("vterm_noop", None) return ("vterm_noop", None)

View File

@@ -5,7 +5,9 @@ The trackpad is not handled here. On macOS it never reaches a mousemap binding
continuous scrolling into discrete wheel events; those arrive as ordinary wheel continuous scrolling into discrete wheel events; those arrive as ordinary wheel
notches and take the path below. notches and take the path below.
""" """
import sublime
import sublime_plugin import sublime_plugin
from wcwidth import wcswidth
from .terminal import Terminal from .terminal import Terminal
from .utils import settings from .utils import settings
@@ -13,6 +15,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,9 +40,10 @@ 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)
col = wcswidth(view.substr(sublime.Region(pt - col, pt)))
row -= terminal.offset row -= terminal.offset
screen = terminal.screen screen = terminal.screen
if row < 0 or row >= screen.lines: if row < 0 or row >= screen.lines:
@@ -48,46 +53,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."""
@@ -103,7 +122,7 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
cell = None cell = None
if terminal.screen.mouse_reporting_enabled() and event: if terminal.screen.mouse_reporting_enabled() and event:
cell = _event_to_cell(self.view, terminal, event, clamp=True) cell = _event_to_cell(self.view, terminal, event, clamp=True)
lines = max(0, int(settings().get("wheel_lines", 3))) lines = max(0, int(settings().get("wheel_lines", 1)))
send_scroll(terminal, up, lines, cell) send_scroll(terminal, up, lines, cell)
def want_event(self): def want_event(self):

View File

@@ -1,11 +1,13 @@
""" Rendering the emulator screen into the Sublime view """ Rendering the emulator screen into the Sublime view
The view holds real text: scrollback above `terminal.offset`, the live screen The view holds real text: scrollback above `terminal.offset`, the live screen
window from `offset` on. Dirty rows are whole-line replaced; colors are window from `offset` on. Dirty rows are whole-line replaced, and rows the view
add_regions overlays with scopes served by the dynamic scheme. The ST caret does not hold yet are appended in one insert; colors are add_regions overlays
with scopes served by the dynamic scheme. The ST caret
doubles as the terminal cursor. doubles as the terminal cursor.
""" """
import math import math
from bisect import bisect_left
from functools import lru_cache from functools import lru_cache
import sublime import sublime
@@ -88,8 +90,10 @@ def segment_buffer_line(buffer_line):
# an orphaned wide-char filler (its first half was overwritten) still # an orphaned wide-char filler (its first half was overwritten) still
# occupies a column; render a space so later spans stay aligned # occupies a column; render a space so later spans stay aligned
run.append(char.data or " ") data = char.data
column += 1 run.append(data or " ")
# a cell can hold more than one code point: a mark NFC could not compose
column += len(data) or 1
if attrs is not None: if attrs is not None:
yield _run(run, start, column, attrs) yield _run(run, start, column, attrs)
@@ -116,34 +120,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 +177,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 +278,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:
@@ -286,23 +308,33 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# valid for this method only; the trim paths erase rows # valid for this method only; the trim paths erase rows
self._lastrow = self.view.rowcol(self.view.size())[0] self._lastrow = self.view.rowcol(self.view.size())[0]
if dirty_lines: if dirty_lines:
# history pops newest-first, so it fills the rows below `offset` backwards
history = screen.history history = screen.history
popped = len(history) popped = len(history)
terminal.offset += popped terminal.offset += popped
offset = terminal.offset offset = terminal.offset
first = offset - popped
retired = list(history)
history.clear()
for line in range(popped): # rows the view already holds are rewritten one by one; the rest
buffer_line = history.pop() # are new and go in as one insert, after those edits moved the end
self.update_line(edit, offset - line - 1, buffer_line, columns) split = min(popped, self._lastrow + 1 - first)
for i in range(split):
self.update_line(edit, first + i, retired[i], columns)
if split < popped:
self.append_rows(edit, retired[split:], columns, scrollback=True)
# after the loop: a pop edits an earlier point than the one before # after the edits: spans measured mid-loop would be stale
# it, so spans measured mid-loop would be stale self.archive_rows(range(first, offset))
self.archive_rows(range(offset - popped, offset))
for line in dirty_lines: # screen rows past the view's end go in as one insert too
buffer_line = screen.buffer[line] start = self._lastrow + 1 - offset
self.update_line(edit, line + offset, buffer_line, columns) cut = bisect_left(dirty_lines, start)
for line in dirty_lines[:cut]:
self.update_line(edit, line + offset, screen.buffer[line], columns)
if cut < len(dirty_lines):
rows = range(start, dirty_lines[-1] + 1)
self.append_rows(edit, [screen.buffer[y] for y in rows], columns, scrollback=False)
def update_line(self, edit, line, buffer_line, columns): def update_line(self, edit, line, buffer_line, columns):
"""Rewrite one view row from a screen row, unless it is unchanged""" """Rewrite one view row from a screen row, unless it is unchanged"""
@@ -312,10 +344,20 @@ 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
text, by_scope = self.row_text(segments, lf)
self.ensure_row(edit, line)
line_region = view.line(view.text_point(line, 0))
self.decolorize_line(line)
view.replace(edit, line_region, text)
# replacing a line cannot move its start
self.colorize_line(line, line_region.begin(), by_scope)
def row_text(self, segments, lf):
"""The view text of a row and its colour spans by scope, as char offsets."""
by_scope = {} by_scope = {}
needed = 0 needed = 0
scope_of = self._scheme.scope scope_of = self._scheme.scope
@@ -332,18 +374,59 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
if len(text) < needed: if len(text) < needed:
# pad only to the last coloured run, in the same edit as the text # pad only to the last coloured run, in the same edit as the text
text += " " * (needed - len(text)) text += " " * (needed - len(text))
return text, by_scope
self.ensure_row(edit, line) def append_rows(self, edit, buffer_lines, columns, scrollback):
line_region = view.line(view.text_point(line, 0)) """Add rows below the view's end in one insert.
self.decolorize_line(line)
view.replace(edit, line_region, text) Scrollback rows are never rewritten, so their colours go straight onto
# replacing a line cannot move its start the chunk keys with no per-row key for flush_chunk to merge. Screen
self.colorize_line(line, line_region.begin(), by_scope) rows are coloured per row as usual; a trailing blank run the trim
already erased is still cached as rendered and is left out.
"""
view = self.view
rows = [(line, list(segment_buffer_line(buffer_line)), buffer_line[columns - 1].linefeed)
for line, buffer_line in enumerate(buffer_lines, self._lastrow + 1)]
if not scrollback:
while rows and self.state.row_cache.get(rows[-1][0]) == rows[-1][1:]:
rows.pop()
if not rows:
return
point = view.size() + 1
texts = []
# scrollback: (chunk, scope) -> regions; screen: (line, start, by_scope)
painted = {}
colored = []
trimmed = self.state.trimmed
for line, segments, lf in rows:
self.state.row_cache[line] = (segments, lf)
text, by_scope = self.row_text(segments, lf)
texts.append(text)
if scrollback:
chunk = (line + trimmed) // CHUNK
for scope, spans in by_scope.items():
painted.setdefault((chunk, scope), []).extend(sublime.Region(point + a, point + b) for a, b in spans)
elif by_scope:
colored.append((line, point, by_scope))
point += len(text) + 1
view.insert(edit, view.size(), "\n" + "\n".join(texts))
self._lastrow = rows[-1][0]
for (chunk, scope), regions in painted.items():
key = chunk_key(chunk, scope)
scopes = self.state.archived_chunks.setdefault(chunk, set())
if scope in scopes:
regions = view.get_regions(key) + regions
view.add_regions(key, regions, scope)
scopes.add(scope)
for line, start, by_scope in colored:
self.colorize_line(line, start, by_scope)
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 +434,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 +459,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 +468,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 +481,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 +514,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,22 +528,23 @@ 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."""
view = self.view view = self.view
screen = terminal.screen screen = terminal.screen
lastrow = view.rowcol(view.size())[0] lastrow = view.rowcol(view.size())[0]
n = terminal.scrollback_size # the setting counts rows above the screen; 0 keeps the screen alone
n = terminal.scrollback_size + screen.lines
if lastrow + 1 > n: if lastrow + 1 > n:
# queued rows are about to be renumbered or erased: merge them first # queued rows are about to be renumbered or erased: merge them first
self.flush_chunk() self.flush_chunk()
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 +552,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 +566,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)

View File

@@ -2,6 +2,7 @@
import codecs import codecs
import logging import logging
import os import os
import re
import threading import threading
import time import time
from collections import deque from collections import deque
@@ -26,6 +27,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames. # max chars parsed per tick, so a giant burst still renders intermediate frames.
FEED_LIMIT = 48 * 1024 FEED_LIMIT = 48 * 1024
# unparsed 8K reads the reader may hold before it pauses: ~10 ticks of parsing
PENDING_CHUNKS = 64
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this # SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25 REPAINT_NUDGE_DELAY = 0.25
@@ -37,6 +41,10 @@ IDLE_TICK = 0.2
# a millisecond apart, and drawing between them flashes its showcmd echo # a millisecond apart, and drawing between them flashes its showcmd echo
SETTLE = 0.005 SETTLE = 0.005
# C0 controls bar tab/LF/CR, stripped from text sent as typed: a pasted
# ESC[201~ would end the bracket early and type the rest into the app
CONTROL_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
# frames between undo-stack clears: every render edit lands on the view's undo # frames between undo-stack clears: every render edit lands on the view's undo
# stack, which only focus changes clear otherwise, and undo is swallowed anyway # stack, which only focus changes clear otherwise, and undo is swallowed anyway
UNDO_CLEAR_FRAMES = 100 UNDO_CLEAR_FRAMES = 100
@@ -101,6 +109,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 +129,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
@@ -197,7 +208,6 @@ class Terminal:
history=self.scrollback_size, history=self.scrollback_size,
erase_scrollback=settings().get("honor_erase_scrollback", False), erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback, clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback,
fg_color=self.scheme.foreground.lstrip("#"), fg_color=self.scheme.foreground.lstrip("#"),
bg_color=self.scheme.background.lstrip("#"), bg_color=self.scheme.background.lstrip("#"),
is_dark=self.scheme.is_dark(), is_dark=self.scheme.is_dark(),
@@ -230,7 +240,8 @@ class Terminal:
self.alive = False self.alive = False
self._wake.set() self._wake.set()
try: try:
self.process.terminate() # force: a child ignoring HUP and INT would otherwise outlive the tab
self.process.terminate(force=True)
except Exception: except Exception:
LOG.warning("terminate failed", exc_info=True) LOG.warning("terminate failed", exc_info=True)
vid = self.view.id() vid = self.view.id()
@@ -241,10 +252,6 @@ class Terminal:
"""Screen asked for a clear; the next render performs it on the UI thread.""" """Screen asked for a clear; the next render performs it on the UI thread."""
self.pending_clear = True self.pending_clear = True
def _clipboard_callback(self, text):
"""OSC 52 payload; the clipboard may only be touched on the UI thread."""
sublime.set_timeout(lambda: sublime.set_clipboard(text))
def _need_to_render(self): def _need_to_render(self):
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor.""" """Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
screen = self.screen screen = self.screen
@@ -280,6 +287,10 @@ class Terminal:
break break
chunks.append(self._decoder.decode(raw)) chunks.append(self._decoder.decode(raw))
self._wake.set() self._wake.set()
# backpressure: once this much waits unparsed, stop reading so
# the pty fills and the child blocks, as under a real terminal
while len(chunks) > PENDING_CHUNKS and not done[0] and self.is_hosted():
time.sleep(RENDER_PERIOD)
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[reader] stops") LOG.debug("[reader] stops")
break break
@@ -328,7 +339,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
@@ -388,7 +399,7 @@ class Terminal:
# escape sequences go through untouched; typed and pasted text has its # escape sequences go through untouched; typed and pasted text has its
# line endings canonicalised for the app's LNM setting # line endings canonicalised for the app's LNM setting
if normalized: if normalized:
string = string.replace("\r\n", "\n") string = CONTROL_CHARS.sub("", string).replace("\r\n", "\n")
if mo.LNM in self.screen.mode: if mo.LNM in self.screen.mode:
string = string.replace("\n", "\r\n") string = string.replace("\n", "\r\n")
else: else:
@@ -424,12 +435,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)."""