Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93253aa564 | ||
|
|
2715ecddbb | ||
|
|
a556dab638 | ||
|
|
e478f41f7c | ||
|
|
7b536a7efa |
@@ -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,8 +51,8 @@ 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`) — the per-frame logic. Sublime rebuilds every
|
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
|
||||||
`TextCommand` instance whenever any plugin loads, so the view's render state
|
`TextCommand` instance whenever any plugin loads, so the view's render state
|
||||||
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
||||||
@@ -68,12 +69,11 @@ 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_press` / `vterm_mouse_release` | `mouse.py` | mousemap |
|
| `vterm_scroll` / `vterm_mouse_press` / `vterm_mouse_release` | `mouse.py` | mousemap |
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ 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.
|
||||||
|
|
||||||
@@ -126,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.
|
||||||
@@ -209,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`.
|
||||||
|
|||||||
2
main.py
2
main.py
@@ -12,14 +12,12 @@ 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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -191,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
|
||||||
@@ -442,6 +455,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)
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
// "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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -432,7 +430,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
|
||||||
@@ -442,7 +441,9 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -646,7 +647,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 +658,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -119,7 +119,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):
|
||||||
|
|||||||
@@ -470,7 +470,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
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()
|
||||||
|
|||||||
@@ -26,6 +26,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
|
||||||
|
|
||||||
@@ -200,7 +203,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(),
|
||||||
@@ -233,7 +235,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()
|
||||||
@@ -244,10 +247,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
|
||||||
@@ -283,6 +282,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
|
||||||
|
|||||||
Reference in New Issue
Block a user