11 Commits

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
Vaclav Tvrdik
9656faa385 Simplify per-terminal theme code; note ansi_colors needs restart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:42:20 +02:00
Vaclav Tvrdik
c2a0c75359 select theme applies only to current vterm and newly opened vterms 2026-10-02 12:29:32 +02:00
Vaclav Tvrdik
e6aa754762 red-alert theme added 2026-10-02 11:45:09 +02:00
20 changed files with 898 additions and 757 deletions

View File

@@ -1,24 +1,3 @@
# vterm architecture
How the plugin is put together, for someone about to change it. `README.md` is the
user-facing overview; this is the developer reference. Code style lives in `CODE.md`.
## What it is
A terminal emulator that runs inside an ordinary Sublime Text tab. There is no custom
drawing surface: the emulator's screen is written into the view as **real text**, and
colours are region overlays. That single decision explains most of what follows.
Constraints it works under:
- **Sublime Text 4**, build 4149+ (`View.folded_regions`), plugin host **Python 3.8**
(`.python-version`). The host is a separate process, so every Sublime API call from
plugin code is a cross-process round trip — call *count* is what costs.
- **macOS only.** The pty layer is posix and the mousemap is `Default (OSX)`.
- Third-party code comes from Package Control libraries declared in `dependencies.json`:
`pyte` (emulation), `ptyprocess` (pty), `wcwidth` (character widths).
- Editing plugin sources needs a **Sublime restart**: submodules are deliberately not
purged on reload, because rebuilding the terminal registry would orphan open tabs.
## Files
@@ -30,9 +9,9 @@ Constraints it works under:
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
| `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. |
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). |
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
| `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/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/mouse.py` | Wheel and click handling. |
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
@@ -42,14 +21,15 @@ Constraints it works under:
| 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-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. |
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
| `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
@@ -71,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`).
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
`Char` carrying the extra `linefeed` field.
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
generated scheme file and the mapping from colour combination to scope name.
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
`_lastrow` are that view's render state.
- **`SchemeManager`** (`scheme.py`) — one per theme name, cached by `get_scheme_manager`.
Owns the generated scheme file and the mapping from colour combination to scope name.
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
`TextCommand` instance whenever any plugin loads, so the view's render state
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
### Commands
@@ -88,310 +69,16 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
| `vterm_render` | `VtermRenderCommand` | renderer thread |
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
| `vterm_copy` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
| `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding |
| `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
`VtermMouseEventListener` (`mouse.py`).
One application-wide `EventListener`: `VtermCoreEventListener` (`listeners.py`).
## Data flows
### Opening a terminal
`vterm_open` → resolve `cmd` (default `$SHELL -l`) and `cwd` (first project folder →
active file's directory → `~`) → `scheme_manager.initialize()` → `window.new_file()` →
`vterm_initialize_view` stamps the view settings → `Terminal(view)` → `Terminal.start()`:
register in `_terminals`, compute the size from the viewport, spawn the pty, build the
`VtScreen`/`VtStream`, subscribe to palette changes, start the two threads. All on the UI
thread; a failure closes the view again.
### Output: a pty byte becomes a visible character
```
child writes
│
├─ reader thread ──────────────────────────────────────────────
│ process.read(8192) blocking read on the pty master
│ self._decoder.decode(raw) incremental UTF-8: a sequence split
│ across reads must not be corrupted
│ chunks.append(text) collections.deque, no lock
│
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
│ _wake.wait(IDLE_TICK) idle: park until the reader has output,
│ then SETTLE, so the writes of one app
│ update are drawn as a single frame
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
│ stream.feed(joined) → VtStream parser → VtScreen.draw
│ mutates buffer, fills screen.dirty
│ in_sync_frame()? mid ?2026 frame → skip this tick
│ is_visible()? hidden tab → draw nothing this tick
│ was_resized()? (checked once a second) → resize pty
│ is_render_due()? unfocused → at most one frame per
│ UNFOCUSED_RENDER_PERIOD
│ _need_to_render()? dirty rows, a pending clear, or a
│ moved cursor
│ view.run_command("vterm_render")
│ _sync_wheel_flag() publishes the mousemap gate
│
└─ UI thread ──────────────────────────────────────────────────
VtermRenderCommand.run(edit)
update_lines() → drain screen.history above `offset`,
then each dirty screen row
update_line() → segment_buffer_line() → row-cache check
→ view.replace() + add_regions()
sync_alt_fold()
follow-scroll gate → trim, focus_cursor()*, request_scroll()
sync_cursor_style(), set_name()*, dirty.clear()
* focused terminals only
```
**Rendering off focus.** A terminal only touches its view the way the user would while it
holds focus. Sublime dispatches view events application-wide, and packages hang global
state off them: Vintage's `InputStateTracker.on_selection_modified` resets one module-level
`g_input_state` for every view in every window, so a caret this plugin moved at 33 fps
erased the half-typed `d` of a `dd` in whatever file the user was editing. Sublime drops a
pending key sequence over the same kind of traffic, which took `gg` and `ciw` with it.
Three states, from `Terminal.focused` (set by `on_activated` / `on_deactivated`) and
`Terminal.is_visible()` (polled once a second, because selecting another tab in an
unfocused group raises no event for this view):
| state | text | caret, `sel` | tab name | follow-scroll | draw rate |
| --- | --- | --- | --- | --- | --- |
| focused | yes | yes | yes | yes | 33 fps |
| visible, unfocused | yes | no | no | yes | 15 fps |
| hidden | no | no | no | no | none |
A frame costs a command dispatch and a dozen UI round trips whatever changed — two
`rowcol(size())`, `folded_regions()`, `viewport_position()`, the `trim_trailing_spaces` scan,
then `layout_extent()` / `text_to_layout()` / `set_viewport_position()` — so the rate a
terminal is drawn at is what the editor pays for it, and only a focused one is worth 33 fps.
The renderer tick itself stays at `RENDER_PERIOD` in every state: `feed_data()` runs inside it,
and throttling that would cap the parser at `FEED_LIMIT` per tick while the reader keeps filling
an unbounded `chunks`. Only the drawing is rate-limited. So the emulator is always current and
one render on activation catches the view up. An unfocused terminal is left with an empty selection —
nothing a render edit can shift, so no selection event of ours escapes — unless the user
selected text to copy, which is kept.
**The view/offset model.** The view holds real text. Rows `0 .. offset-1` are scrollback;
rows `offset .. offset + screen.lines - 1` are the live screen. `terminal.offset` is the
view row of screen row 0. When the emulator retires lines into `screen.history`, the
renderer drains them, advances `offset` by that many rows, and writes them at their now
permanent view rows. **Once a line leaves `screen.history`, its only copy is the view
text** — which is why the alternate screen *folds* the scrollback rather than deleting it.
**The row cache.** pyte's `dirty` set is a repaint *hint*, not a diff: scrolling one line
marks every row dirty, and a TUI repaints whole frames. `update_line` compares
`(segments, lf)` against `_row_cache` and returns early when unchanged, which is what
keeps a full-screen repaint from costing hundreds of cross-process calls.
**The follow-scroll gate.** `following()` compares the viewport against
`terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled
up by hand, and trimming, caret placement and scrolling are all skipped so their position
survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate
the same way, and scrolling down clamps to the anchor `bottom_y` returns, which re-opens
it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate
and one Down re-opens it.
### How colour is actually drawn
`view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a
colour. The colours must already exist in the view's colour scheme. So vterm generates
the colour scheme at runtime:
1. `segment_buffer_line` splits a screen row into runs of identical attributes.
2. For each non-default run, `scheme_manager.scope(fg, bg, style)` returns a scope name
such as `vterm.red.default.b`. On a **miss** it creates the rule and schedules a
write; the hot path is a single lock-free dict read.
3. `colorize_line` calls `add_regions` once per distinct scope in the row, with keys
`vterm#N` — monotonic rather than per-row, because `trim_history` renumbers rows and
per-row keys would collide. `colored_lines[row]` remembers them so they can be erased.
Rows that scroll off the screen are queued by `archive_rows` and merged by
`flush_chunk` onto shared `vterm@chunk@scope` keys (one per scope per `CHUNK` rows
of scrollback), their per-row keys erased — every key is a separate pass in Sublime's
redraw, and a long coloured session used to accumulate ~15k of them. The merge waits
for the chunk to close: `add_regions` replaces a key's whole set, so merging every
frame re-sent everything the chunk held so far, which is quadratic in `CHUNK` when
output arrives a few rows at a time. The open chunk's rows keep their per-row keys
until then, so the live count is scopes x chunks plus at most one chunk's worth.
Chunk ordinals are view row + `_trimmed`, so `trim_history` renumbering cannot move
a row between chunks; trimming settles the open chunk first, then erases whole
chunks' keys and compacts the boundary chunk.
4. `_write_file` serializes every rule into
`Packages/User/vterm/vterm.hidden-color-scheme`, the path the view's `color_scheme`
setting points at. **Each write makes Sublime reload the scheme and restyle every
view**, so writes are debounced 250 ms onto the async thread.
Details worth knowing before touching `scheme.py`:
- Rules are **seeded** at build time for all 16 foregrounds (plain and bold) so the very
first frame never references a scope the file lacks.
- If a rule's resolved background equals the global background *exactly*, it is nudged by
one (`next_color`) — `add_regions` inverts foreground and background when they match.
- Colours are resolved when a rule is added, so a palette change cannot append: `_build`
rebuilds the whole table.
- Truecolor SGR is quantized into the 256 palette (`quantize_256`), which bounds the rule
table: every distinct pair would otherwise be a permanent rule and another file write.
- `plugin_loaded` writes the file **synchronously**, because a restored tab's
`color_scheme` is read during startup.
### The alternate screen
While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so
the view shows the app's screen alone. The fold is re-checked every frame, not just on
transitions, because the screen's first row is rewritten on every region scroll and an
edit at the fold boundary can silently break a fold. Entering the alt screen collapses the
layout, so that transition also zeroes `terminal.viewport_y`; a stale anchor would hold
the follow-scroll gate shut. A fold merely repaired keeps the anchor, or the boundary row
apps rewrite on every scroll would reset it every frame. The emulator side
(`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its
frames into scrollback.
### Input: a key press reaches the app
Almost every key, **including every printable character**, is bound to `vterm_keypress`.
That is deliberate: if printables were left to Sublime, an app that does not echo (vim in
normal mode) would leave the typed character sitting in the view.
```
keymap binding (context: setting.vterm_view)
→ VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…)
→ view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page
and Up/Down a row; stops here, never on the alt screen
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
enter/tab/backspace/space, modified, while the app
asked for modifyOtherKeys → CSI 27;<mod>;<code>~
ctrl → control byte; alt → ESC prefix
plain → _KEY_MAP / _APP_MODE_KEY_MAP
→ Terminal.send_string(normalized=False)
→ pty write ≤4096 bytes inline, larger goes through a queue
```
**IME and composed input** cannot work this way — there is no key event for "the IME
committed 日本語"; the text arrives as Sublime's own `insert` command. `on_modified`
catches that: it reconstructs what was inserted from the caret positions (not from
`args["characters"]`, which reports the typed keys rather than the committed
composition), forwards it as an unbracketed paste, and `soft_undo`s any other command
that modified the view. Text the emulator did not produce must never remain in the view.
**Copy and paste** are rerouted by `on_text_command`: `copy` → `vterm_copy` (which strips
the zero-width `CONTINUATION` sentinel so soft-wrapped lines re-join), `paste` →
`vterm_paste`, and `undo` → `vterm_noop`. Pastes are wrapped in `ESC[200~` / `ESC[201~`
when the app enabled bracketed paste. In the other direction, OSC 52 from the app reaches
the system clipboard via a hop to the UI thread.
### Mouse
Once per tick, `_sync_wheel_flag` publishes `vterm_view.wheel_captured` = "the app
reports the mouse, or owns the alternate screen". The mousemap's binding is gated on it,
which produces three cases:
1. **App tracks the mouse** → `vterm_scroll` maps the event to a screen cell and sends
three SGR wheel reports, *one per write* — pi matches the SGR form against a whole
stdin chunk and drops anything longer.
2. **Alternate screen, no tracking** → three arrow keys per notch (alternate scroll, as
kitty/iTerm2/xterm do). This is what scrolls a fullscreen UI before it enables mouse
reporting.
3. **Neither** → the binding does not match at all and Sublime scrolls the view over the
real scrollback text.
**The trackpad** fires none of that: on macOS it never reaches a mousemap binding
(sublime_text#1838) and dispatches no command, so a plugin cannot see it at all. Its only
in-API trace is the viewport moving, and reading a gesture that way means letting the view
move before pinning it back — visibly, at every poll. vterm does not try: the trackpad is
converted outside the plugin, by a utility such as LinearMouse set to discrete scrolling
for Sublime Text, and arrives as ordinary wheel notches on the path above. Nothing in the
plugin is trackpad-aware, and the viewport is never moved to measure input.
Clicks: `VtermMouseEventListener` intercepts `drag_select` only when the app tracks the
mouse, and only the plain single-button form, so shift-click and add-selection stay
native. The first click on an unfocused tab only focuses it — we replaced the command
that normally does that — and subsequent clicks send a press/release pair.
**The middle button** pastes the view's own selection into the app, as X11 does, and
never touches the clipboard. The binding is a `press_command`, not a `command`: Sublime's
own `button3` on macOS is a column `drag_select`, which would clear the selection on
press before a release command could read it. Shadowing it costs middle-drag column
select inside terminal tabs; the context makes the binding fall through everywhere else.
It is not gated on mouse reporting — an empty selection is already a no-op, and the alt
screen rarely holds one.
### Lifecycle
- **Tab closed** → `on_pre_close` → `Terminal.kill()`: unsubscribe, terminate the child,
drop the registry entry.
- **Process exits** → reader gets `EOFError`, sets `alive = False`; the renderer drains
the last output, then posts `vterm_cleanup` to the UI thread. Cleanup latches
`vterm_view.finished`, renders a final frame, kills the terminal, and either closes the
tab (`auto_close`) or appends `[process exited with code N]`.
- **Plugin host restart** → the registry is gone but the view survives with its settings
and text. `on_activated` detects a view marked `vterm_view` with no terminal behind it
and shows a "disconnected" status: the tab has to be closed and reopened.
## Threading
| Thread | Runs | Touches |
|---|---|---|
| UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, clipboard | Sublime API; small pty writes go out here too |
| reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state |
| renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator |
| paste drain | transient; empties the 4096-byte chunk queue | pty only |
| ST async worker | `SchemeManager._flush` | the scheme file |
The emulator needs no lock because exactly one thread mutates it. The chunk deque needs
none either: `append` and `popleft` are atomic under the GIL. This matters — the renderer
used to hold a lock across `stream.feed` **and** the whole render command, so the reader
could not drain the pty for the duration of a frame and the child blocked in `write()`.
Backpressure now lives in `FEED_LIMIT` instead: at most 256 KiB is parsed per tick, so a
huge burst still renders intermediate frames while the reader keeps draining.
`SchemeManager` does keep a lock, because `scope()` is called from the renderer while
`reload()` and `_flush()` run on other threads. Its read fast path skips the lock; the
invariant that makes that safe is that `_names` must never outlive `rules` — `_build`
replaces both together.
## Sublime settings
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
`scroll_modifier`, `wheel_lines`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
into a scroll of the view — a page, a row — instead of a key for the app. `wheel_lines`
is how many scroll lines one wheel notch sends the app (3, as iTerm2 delivers per notch;
a trackpad converted to notches scrolls that much faster, so lower it). `terminal` and
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
pi and Claude Code repaint by erasing
saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the
palette and restyles open terminals in place.
**Stamped on every terminal view** by `vterm_initialize_view`: `vterm_view` (the marker
every context and hook keys off), `is_widget`, `color_scheme` (the generated scheme),
`syntax` (the empty one), `scroll_past_end` (off — the view never scrolls below the last
line), `scroll_context_lines`, and a list of disables — `gutter`, `highlight_line`,
`word_wrap`, `auto_complete`, `draw_white_space`, `draw_indent_guides`, `spell_check`,
`translate_tabs_to_spaces`, `command_mode` (which
defuses Vintage mode inside a terminal). The view is also set scratch, so closing it never
prompts to save.
**Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the
Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and
`block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block).
**Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in
Sublime's own process — a plugin `on_query_context` would cost a round trip on every
keystroke in every view. Mousemaps do **not** evaluate `setting.` contexts the same way,
so the single wheel context still goes through `on_query_context`, which answers that one
key and returns `None` for everything else.
---
@@ -413,7 +100,8 @@ are marked ✅.
truecolor, `ESC[0m` reset. This is what TUIs emit most.
- **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
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
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
discards the payload, so an image never leaks into the text.

View File

@@ -11,6 +11,13 @@
"args": {"up": false},
"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,
// 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
middle button pastes the current selection straight into the app, X11-style,
without going near the clipboard; it does nothing when nothing is selected. The
wheel scrolls the view normally; while the app tracks the mouse the wheel and
left click go to it instead, and on the alternate screen without tracking each
wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks
and drags go to it instead (Claude Code's own mouse selection and copy work as in
kitty), and on the alternate screen without tracking each wheel tick sends arrow
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
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
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
scrolls too far. While an app owns the
app one scroll line; `wheel_lines` raises that (iTerm2 sends 3) when the wheel
feels slow. While an app owns the
alternate screen the scrollback above it is folded away, kitty-style, and
unfolds when the app leaves it.
@@ -104,11 +105,12 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
no restart needed.
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
default for terminals opened from now on. *vterm: Select Theme* retints the
focused terminal immediately and sets this; other already-open terminals keep
whatever theme they had.
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
applied on top of the theme.
applied on top of the theme. Takes effect after a Sublime restart.
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
@@ -124,7 +126,8 @@ terminal protocols — see `Architecture.md`.
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
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
sequences badly.
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
@@ -148,15 +151,16 @@ terminal protocols — see `Architecture.md`.
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
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
handled: it reaches no mousemap on macOS and is converted to wheel notches
outside the plugin.
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
drag, no right button; the middle button pastes the selection instead of being
reported), focus events (1004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left button are reported; a
drag arrives as one motion at release, since Sublime raises none; no right
button; the middle button pastes the selection instead of being reported),
focus events (1004),
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
block/bar and blink/steady via the view's caret settings; underline falls back
@@ -206,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
emulator with no Sublime involved, and prints the queries answered plus the
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`.

View File

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

20
main.py
View File

@@ -5,30 +5,30 @@ registry would orphan open terminal tabs.
"""
import logging
import sublime
# imported for sublime's command discovery, not called here
from .vterm.commands import (
VtermClearScreenCommand,
VtermClearUndoStackCommand,
VtermCleanupCommand,
VtermCloseCommand,
VtermCopyCommand,
VtermInitializeViewCommand,
VtermKeypressCommand,
VtermNoopCommand,
VtermOpenCommand,
VtermOpenTerminalProjectFolderCommand,
VtermPasteCommand,
VtermPasteSelectionCommand,
VtermPasteTextCommand,
VtermSelectThemeCommand,
)
from .vterm.listeners import VtermCoreEventListener
from .vterm.mouse import (
VtermMouseClickCommand,
VtermMouseEventListener,
VtermMousePressCommand,
VtermMouseReleaseCommand,
VtermScrollCommand,
)
from .vterm.scheme import scheme_manager
from .vterm.scheme import build_startup_schemes
from .vterm.utils import settings
from .vterm.render import (
VtermRenderCommand,
@@ -37,9 +37,13 @@ from .vterm.render import (
def plugin_loaded():
"""Write the colour scheme and set the log level, once per plugin host."""
# synchronous: a restored tab's color_scheme is read during startup
scheme_manager.ensure_file()
"""Write any missing theme's colour scheme and set the log level, once per
plugin host: a restored tab's color_scheme is read during startup and may
name any of them.
"""
window = sublime.active_window()
view = window.active_view() if window else None
build_startup_schemes(view)
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
logging.basicConfig()

View File

@@ -3,14 +3,16 @@
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.
"""
import glob
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ST_LIB = os.path.expanduser(
"~/Library/Application Support/Sublime Text/Lib/python38")
# Package Control puts the libraries under the plugin host's own directory
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:
sys.path.insert(0, path)

View File

@@ -87,3 +87,7 @@ def expand_variables(value, variables):
def version():
return "4200"
def active_window():
return None

View File

@@ -4,8 +4,11 @@ import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
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.render import CONTINUATION
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
@@ -13,72 +16,140 @@ settings = sublime.load_settings(SETTINGS_FILE)
panel = {}
class FakeTerminalView:
"""Just enough view for a Terminal to be rethemed: an id and its own settings."""
_next_id = 1
def __init__(self):
self._id = FakeTerminalView._next_id
FakeTerminalView._next_id += 1
self._settings = sublime.Settings()
self._settings.set("vterm_view", True)
def id(self):
return self._id
def settings(self):
return self._settings
def is_valid(self):
return True
def style(self):
# 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:
def __init__(self, view):
self._view = view
def active_view(self):
return self._view
def views(self):
return [self._view, editor]
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight)
def open_picker(current):
settings.set("theme", current)
command = VtermSelectThemeCommand(FakeWindow())
def fake_terminal(theme_name):
"""A terminal frozen to a theme, registered the way a real one is."""
view = FakeTerminalView()
terminal = Terminal(view, get_scheme_manager(theme_name))
terminal.screen = VtScreen(20, 3, process=FakeProcess())
Terminal._terminals[view.id()] = terminal
return terminal
def open_picker(terminal):
command = VtermSelectThemeCommand(FakeWindow(terminal.view))
command.run()
return command
# every theme is offered, with "no theme" first
command = open_picker(None)
command = open_picker(fake_terminal(None))
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0
# the configured theme starts highlighted
command = open_picker("pro")
# the focused terminal's own theme starts highlighted, not the global default
settings.set("theme", "homebrew")
terminal = fake_terminal("pro")
command = open_picker(terminal)
assert panel["selected_index"] == command.choices.index("pro")
# highlighting previews but must not persist
# highlighting previews the focused terminal alone, leaving the global default and
# every other terminal untouched
other = fake_terminal("default")
saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate"))
assert settings.get("theme") == "slate", settings.get("theme")
assert terminal.scheme.theme_name == "slate", terminal.scheme.theme_name
assert terminal.view.settings().get("color_scheme") == terminal.scheme.path
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
assert settings.get("theme") == "homebrew", "preview must not touch the global default"
assert len(sublime._saved) == saves, "preview must not save settings"
# cancelling restores the value from before the panel opened
# cancelling restores the focused terminal's theme from before the panel opened
panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme")
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
# confirming persists
command = open_picker("pro")
# confirming retheme the focused terminal and persists the default for new terminals
command = open_picker(terminal)
panel["on_done"](command.choices.index("homebrew"))
assert terminal.scheme.theme_name == "homebrew", terminal.scheme.theme_name
assert settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
# "no theme" persists as null rather than a name
command = open_picker("slate")
command = open_picker(fake_terminal("slate"))
panel["on_done"](0)
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)))
# a palette change must reach terminals that are already running: the screen
# answers OSC 10/11 and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
# a palette change must reach the screen it was applied to: it answers OSC 10/11
# and DSR 996 from these values
from vterm.emulator import VtStream
recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start()
terminal = Terminal(None)
terminal = fake_terminal("default")
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False)
terminal.process = recorder
stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee"
scheme_manager.background = "#111111"
terminal.scheme = get_scheme_manager("slate")
terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
assert terminal.screen.theme_is_dark is True
assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
# without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written)
@@ -92,37 +163,30 @@ assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
# and the emulator answers a colour query with the new palette
recorder.written = ""
stream.feed("\x1b]11;?\x07")
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
bg = terminal.screen.default_bg_color
expected = "\x1b]11;rgb:{0}{0}/{1}{1}/{2}{2}\x07".format(bg[0:2], bg[2:4], bg[4:6])
assert recorder.written == expected, repr(recorder.written)
print("live theme tests passed (running screen adopts palette, app notified)")
# the scheme file must exist before any restored tab's color_scheme is loaded at
# startup, and building twice must not duplicate the seeded rules
# startup, and building a theme twice must not duplicate its seeded rules
import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
if os.path.exists(path):
os.remove(path)
scheme = get_scheme_manager("pro")
path = os.path.join(scheme._directory(), scheme.file_name)
assert os.path.exists(path), "get_scheme_manager did not write the scheme"
scheme_manager.ensure_file()
assert os.path.exists(path), "ensure_file did not write the scheme"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# the cached instance is reused, not rebuilt
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
# a second call is a no-op: same file, untouched
stamp = os.path.getmtime(path)
scheme_manager.ensure_file()
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
with scheme_manager.lock:
scheme_manager._build()
first = len(scheme_manager.rules)
scheme_manager._build()
assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
# build is idempotent
with scheme.lock:
first = len(scheme.rules)
scheme.build()
assert len(scheme.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
@@ -181,3 +245,17 @@ view = FakeView("abcdef", [sublime.Region(2, 2)])
assert paste(view) == [], view.commands
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 screen.buffer[0][0].data == "y", tag
# OSC 52 clipboard (base64 "hi")
got = []
proc = FakeProcess()
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
stream = VtStream(screen)
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
assert got == ["hi"], (tag, got)
# OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
# not plant a paste
proc, screen, stream = make()
feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
assert screen.buffer[0][0].data == "z" and proc.written == "", tag
# bracketed paste and sync modes tracked
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][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
proc, screen, stream = make()
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 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
cleared = []
proc = FakeProcess()
@@ -310,7 +328,7 @@ GAUNTLET = (
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
"\x1b[38:5:100mcolon\x1b[m\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"
)
@@ -436,8 +454,67 @@ def run_clear_cases():
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=True)
run_parser_edge_cases()
run_clear_cases()
run_resize_cases()
run_private_csi_cases()

View File

@@ -63,10 +63,9 @@ assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules
import vterm.scheme as scheme_module
from vterm.scheme import scheme_manager
scope = scheme_manager.scope("ff0000", "default", "b")
# a theme's scheme manager produces scopes and rules
from vterm.scheme import get_scheme_manager
scope = get_scheme_manager("default").scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the
@@ -89,7 +88,7 @@ for entry in load_sublime_json("vterm.sublime-commands") + keymap:
# every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
flags = {f for f in vars(Terminal(None, None)) if f.startswith("pending_")}
assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f:
@@ -114,21 +113,28 @@ for name, theme in THEMES.items():
configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
# color_scheme during startup, before any terminal opens
# plugin_loaded must create every theme's colour scheme: sublime loads a restored
# tab's color_scheme during startup, before any terminal opens, and it may name
# any of them
# import main.py the way sublime does - as a module of the package directory -
# so its relative imports and plugin_loaded body are actually executed
from vterm.scheme import SchemeManager
from vterm.themes import ALL_THEME_NAMES
scheme_files = [SchemeManager(name).file_path() for name in ALL_THEME_NAMES]
for scheme_file in scheme_files:
if os.path.exists(scheme_file):
os.remove(scheme_file)
pkg = types.ModuleType("vterm")
pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main")
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
if os.path.exists(scheme_file):
os.remove(scheme_file)
main.plugin_loaded()
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
"plugin_loaded must leave {}'s colour scheme on disk".format(name)
print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES)))

View File

@@ -9,14 +9,16 @@ from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermKeypressCommand
from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
from vterm.render import (
CHUNK,
RenderState,
VtermRenderCommand,
focus_cursor,
following,
scroll_to_cursor,
)
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
@@ -157,7 +159,7 @@ class FakeView:
def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start()
terminal = Terminal(FakeView())
terminal = Terminal(FakeView(), get_scheme_manager("default"))
if scrollback is not None:
terminal.scrollback_size = scrollback
terminal.process = FakeProcess()
@@ -165,9 +167,14 @@ def make_terminal(rows=10, cols=20, scrollback=None):
return terminal, VtStream(terminal.screen)
def make_command(view):
# the real __init__, so the fake cannot drift from the command's attributes
return VtermRenderCommand(view)
def make_command(terminal):
# the real __init__, so the fake cannot drift from the command's attributes;
# run() itself sets _scheme, but these tests call its helpers directly
command = VtermRenderCommand(terminal.view)
command._scheme = terminal.scheme
terminal.render_state = RenderState()
command.state = terminal.render_state
return command
# an app on the alternate screen must not have its view trimmed away: the
@@ -178,28 +185,41 @@ assert terminal.screen.alternate_buffer_mode
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
command = make_command(terminal.view)
command = make_command(terminal)
before = terminal.view.text
command.trim_history(None, terminal)
assert terminal.view.text == before, \
"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.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal.view)
command = make_command(terminal)
command.trim_history(None, terminal)
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 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
terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
command = make_command(terminal.view)
command = make_command(terminal)
command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal.view.text
@@ -209,7 +229,7 @@ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal, stream = make_terminal(rows=4)
stream.feed("\x1b[?1049h")
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
command = make_command(terminal)
before = terminal.view.text
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == before, \
@@ -218,7 +238,7 @@ assert terminal.view.text == before, \
# on the primary screen the blank tail below the cursor still goes
terminal, stream = make_terminal(rows=4)
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
command = make_command(terminal)
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == "top\nrow", terminal.view.text
@@ -261,7 +281,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
# batches the runs that share a scope into one add_regions call
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
command.update_lines(None, terminal)
@@ -278,7 +298,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
assert len(painted) == 2, painted
# 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"
command.decolorize_line(0)
assert terminal.view.regions == {}, "decolorize must erase every key it added"
@@ -286,12 +306,35 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
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,
# 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
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
command = make_command(terminal)
for i in range(300):
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
command.update_lines(None, terminal)
@@ -310,14 +353,20 @@ assert len(row_keys) == 300 - CHUNK, \
# trimming settles the open chunk, then erases the keys of chunks above the cut
terminal, stream = make_terminal(rows=3, scrollback=30)
terminal.view = FakeView()
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
command.update_lines(None, terminal)
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)
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"
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
@@ -332,7 +381,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.default_title = "zsh"
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("[vterm] %")
command.run(None)
assert all(r.empty() for r in terminal.view.sel()), \
@@ -359,10 +408,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
print("render diff tests passed (unchanged dirty rows skipped)")
# the generated scheme must define a selection colour, or sublime uses blue
from vterm.scheme import blend, scheme_manager
scheme_manager.background = "#303841"
scheme_manager.foreground = "#d8dee9"
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
from vterm.scheme import blend
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
@@ -370,7 +417,7 @@ print("selection artefact tests passed (fresh prompt unselected, user selection
# DECSCUSR drives the caret shape through view settings; before any app asks,
# and for 0, the caret is the terminal default: a blinking block
terminal, stream = make_terminal(rows=3, cols=20)
command = make_command(terminal.view)
command = make_command(terminal)
settings = terminal.view.settings()
command.sync_cursor_style(terminal)
@@ -414,6 +461,8 @@ Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
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")
scroll.run(None, up=True)
@@ -598,19 +647,39 @@ terminal.view.window_to_text = lambda xy: 0
window = FakeWindow(active=object())
terminal.view.window = lambda: window
stream.feed("\x1b[?1002h\x1b[?1006h")
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
click.view = terminal.view
mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
mouse_press.view = terminal.view
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
mouse_release.view = terminal.view
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"
mouse_release.run(None, event)
assert terminal.process.written == "", repr(terminal.process.written)
# once focused, the same click reports as before
click.run(None, event)
# once focused, press and release at one cell report a click
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)
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
@@ -621,7 +690,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
terminal.offset = 3
terminal.viewport_y = 400
command = make_command(terminal.view)
command = make_command(terminal)
fold_end = terminal.view.text_point(3, 0) - 1
stream.feed("\x1b[?1049h")
@@ -649,7 +718,7 @@ assert terminal.view.folds == [], terminal.view.folds
# without scrollback there is nothing to fold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
@@ -696,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]]
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

@@ -22,8 +22,9 @@
"min_columns": 20,
"max_columns": 500,
// colour palette: "default", "homebrew", "pro", "slate", or null to
// follow the active Sublime colour scheme. Applies to open tabs too.
// colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
// to follow the active Sublime colour scheme. Default for terminals opened
// from now on; vterm: Select Theme retints the focused terminal and sets this.
"theme": null,
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
@@ -32,12 +33,12 @@
// External terminal for "vterm: Open Terminal in Project Folder".
// %CWD% in parameters is replaced with the project folder.
// "terminal": "/usr/bin/open",
// "parameters": ["-a", "Terminal", "%CWD%"],
"terminal": "/usr/bin/open",
"parameters": ["-a", "Terminal", "%CWD%"],
// kitty instead of Apple Terminal:
"terminal": "/opt/homebrew/bin/kitty",
"parameters": ["-d", "%CWD%"],
// "terminal": "/usr/bin/open",
// "parameters": ["-a", "Kitty", "%CWD%"],
"debug": false
}

View File

@@ -7,9 +7,9 @@ import sublime
import sublime_plugin
from .render import CONTINUATION, scroll_view
from .scheme import SCHEME_PATH, scheme_manager
from .scheme import get_scheme_manager
from .terminal import Terminal
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__)
@@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__)
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):
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
@@ -43,17 +49,18 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
cwd = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables())
scheme_manager.initialize(window.active_view())
scheme = get_scheme_manager(settings().get("theme"), host_view=editor_view(window))
view = window.new_file()
view.run_command("vterm_initialize_view")
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
terminal = Terminal(view)
terminal = Terminal(view, scheme)
try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception:
LOG.exception("failed to start terminal: cmd=%s", cmd)
view.close()
sublime.error_message("vterm: could not start {}".format(" ".join(cmd)))
return
window.focus_view(view)
@@ -106,7 +113,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""Stamps a fresh view with the settings that make it a terminal."""
def run(self, _):
def run(self, _, color_scheme):
"""Apply the view settings; the pty starts only after this."""
view = self.view
view_settings = view.settings()
@@ -125,7 +132,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", False)
view_settings.set("scroll_context_lines", 0)
view_settings.set("color_scheme", SCHEME_PATH)
view_settings.set("color_scheme", color_scheme)
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
view_settings.set("spell_check", False)
view_settings.set("detect_indentation", False)
@@ -183,14 +190,6 @@ class VtermCopyCommand(sublime_plugin.TextCommand):
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):
"""Sends arbitrary text to the app, bracketed when it asked for that."""
@@ -225,13 +224,19 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
"""Pick a colour palette, previewing each as it is highlighted"""
"""Retheme the focused terminal, previewing each choice as it is highlighted.
The chosen theme also becomes the default for terminals opened from now on;
it does not touch any other already-open terminal.
"""
def run(self):
"""Show the palette picker, starting on the configured theme."""
self.settings = settings()
self.original = self.settings.get("theme")
self.choices = [None] + sorted(THEMES)
"""Show the palette picker, starting on the focused terminal's own theme."""
self.terminal = self._focused_terminal()
if not self.terminal:
return
self.original = self.terminal.scheme.theme_name
self.choices = ALL_THEME_NAMES
items = [["No theme", "follow the Sublime color scheme"]]
for name in self.choices[1:]:
@@ -241,16 +246,36 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
current = self.choices.index(self.original) if self.original in self.choices else 0
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
def is_enabled(self):
"""Greys the palette entry out when the focused view is not a terminal."""
return self._focused_terminal() is not None
def _focused_terminal(self):
"""The terminal in the focused view, if any."""
view = self.window.active_view()
return Terminal.from_id(view.id()) if view else None
def _apply(self, name):
"""Retheme the focused terminal alone."""
scheme = get_scheme_manager(name, host_view=editor_view(self.window))
self.terminal.retheme(scheme)
def _preview(self, index):
"""Apply a palette while it is highlighted, without saving it."""
self.settings.set("theme", self.choices[index])
"""Apply a palette to the focused terminal while it is highlighted."""
self._apply(self.choices[index])
def _done(self, index):
"""Persist the choice, or restore the original if the panel was cancelled."""
"""On cancel, restore the focused terminal's original theme.
On confirm, keep the focused terminal on its new theme and persist the
choice as the default for terminals opened from now on.
"""
if index == -1:
self.settings.set("theme", self.original)
self._apply(self.original)
return
self.settings.set("theme", self.choices[index])
name = self.choices[index]
self._apply(name)
settings().set("theme", name)
sublime.save_settings(SETTINGS_FILE)
@@ -302,20 +327,6 @@ class VtermCleanupCommand(sublime_plugin.TextCommand):
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):
"""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 """
import base64
import logging
import re
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)}
# 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.
# 1: DECCKM, application cursor keys (arrows send SS3)
@@ -83,7 +82,8 @@ def quantize_256(r, g, b):
if r == g == b:
if r < 8:
return 16
if r > 248:
# 238 is the top of the grey ramp (255); above the midpoint, white
if r > 246:
return 231
return 232 + (r - 8) // 10
@@ -118,7 +118,7 @@ class Cursor:
class VtScreen(pyte.Screen):
"""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.
"""
@@ -136,7 +136,6 @@ class VtScreen(pyte.Screen):
history=5000,
erase_scrollback=True,
clear_callback=lambda: None,
clipboard_callback=lambda text: None,
fg_color="ffffff",
bg_color="000000",
is_dark=True,
@@ -145,7 +144,6 @@ class VtScreen(pyte.Screen):
self._process = process
self._erase_scrollback = erase_scrollback
self._clear_callback = clear_callback
self._clipboard_callback = clipboard_callback
self.primary_buffer = {}
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
@@ -155,6 +153,8 @@ class VtScreen(pyte.Screen):
self._char_caches = {}
self._char_cache = {}
self._cache_attrs = None
# last printable drawn, for REP
self._last_char = ""
self.history = deque(maxlen=history)
self.alternate_buffer_mode = False
self._sync_frame_start = 0
@@ -167,11 +167,16 @@ class VtScreen(pyte.Screen):
def reset(self):
"""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._sgr_cache.clear()
self._char_caches.clear()
self._cache_attrs = None
self._last_char = ""
super().reset()
self.savepoints = []
self.cursor = Cursor(0, 0)
self.cursor_style = 0
self.modify_other_keys = 0
@@ -378,6 +383,7 @@ class VtScreen(pyte.Screen):
dirty.add(cursor.y)
cursor.x = x + take
pos = end
self._last_char = data[-1]
return
line = buffer[cursor.y]
@@ -402,6 +408,7 @@ class VtScreen(pyte.Screen):
if cell is None:
cell = cache[char] = attrs._replace(data=char)
line[cursor.x] = cell
self._last_char = char
if char_width == 2 and cursor.x + 1 < columns:
filler = cache.get("")
if filler is None:
@@ -409,7 +416,10 @@ class VtScreen(pyte.Screen):
line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the
# 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
pos = None
last = None
@@ -432,7 +442,8 @@ class VtScreen(pyte.Screen):
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
self.dirty.add(pos[0])
else:
break
# other zero-width (VS16, ZWJ) or control: drop it, keep the rest
continue
if char_width > 0:
x = cursor.x + char_width
@@ -440,9 +451,16 @@ class VtScreen(pyte.Screen):
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):
"""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)
super().index()
@@ -614,6 +632,8 @@ class VtScreen(pyte.Screen):
elif mode == 6:
x = min(self.cursor.x, self.columns - 1) + 1
y = self.cursor.y + 1
if mo.DECOM in self.mode and self.margins:
y -= self.margins.top
marker = "?" if private else ""
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
elif mode == 996 and private:
@@ -631,7 +651,10 @@ class VtScreen(pyte.Screen):
def report_mode_status(self, mode, private):
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
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
if mode in REPORTABLE_MODES:
value = 1 if (mode << 5) in self.mode else 2
@@ -646,7 +669,7 @@ class VtScreen(pyte.Screen):
self.write_process_input("\x1b[?0u")
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):
self.set_title(payload)
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])
# answer with the terminator asked with: meli only ends an OSC at ST
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
elif code == 52:
parts = payload.split(";", 1)
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
# other OSC codes are intentionally dropped, 52 (clipboard write) among
# them: any output, cat'd or remote, could plant a command to paste
# 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["S"] = "scroll_up"
self.csi["T"] = "scroll_down"
self.csi["b"] = "repeat_character"
self.csi["s"] = "save_cursor"
self.csi["u"] = "restore_cursor"
self._carry = ""
# param-string -> parsed tuple; SGR strings repeat across a whole frame
self._params_cache = {}
@@ -751,7 +771,8 @@ class VtStream(pyte.Stream):
raw = match.group(1)
params = cache.get(raw)
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:
cache.clear()
cache[raw] = params
@@ -810,24 +831,33 @@ class VtStream(pyte.Stream):
escape_dispatch = create_dispatcher(self.escape)
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:
char = yield True
char = (yield True) if pending is None else ESC
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
if char == ESC:
if pending is None:
char = yield None
else:
char, pending = pending, None
if char == "[":
char = CSI_C1
elif char == "]":
char = OSC_C1
elif char in "PX^_":
# consume the payload so it never reaches the screen
prev = ""
while True:
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
prev = c
continue
else:
if char == "#":
@@ -853,13 +883,15 @@ class VtStream(pyte.Stream):
private = False
prefix = ""
intermediate = ""
# inside a `:` subparameter list, which is dropped (4:3 must not read as 43)
sub = False
while True:
char = yield None
if char == "?":
private = True
elif char in "><=":
prefix = char
elif char in " $":
elif " " <= char <= "/":
intermediate = char
elif char in ALLOWED_IN_CSI:
basic_dispatch[char]()
@@ -867,14 +899,16 @@ class VtStream(pyte.Stream):
draw(char)
break
elif char.isdigit():
if not sub:
current += char
elif char == ":":
# SGR subparameters, which we do not support
pass
sub = True
elif char == ";":
params.append(min(int(current or 0), 9999))
current = ""
sub = False
else:
if current or params:
params.append(min(int(current or 0), 9999))
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
break
@@ -890,18 +924,19 @@ class VtStream(pyte.Stream):
break
payload = ""
if char == ";" or (char not in OSC_TERMINATORS and char != ESC):
while True:
if char == ";":
char = yield None
while char not in OSC_TERMINATORS:
if char == ESC:
char += yield None
if char in OSC_TERMINATORS:
char = yield None
if char != "\\":
pending = char
# char holds the whole terminator, ST for a cut string
char = ctrl.ST_C0
break
if len(payload) < MAX_OSC:
payload += char
elif char == ESC:
# take the '\' of ST, so char holds the whole terminator
char += yield None
char = yield None
try:
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"""
# keymap contexts are native "setting.x"; mousemaps do not evaluate
# 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
value = view.settings().get(key, False)
if operator == sublime.OP_EQUAL:
@@ -104,6 +104,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
if name == "copy":
return ("vterm_copy", None)
elif name in ("paste", "paste_and_indent"):
return ("vterm_paste", None)
return ("vterm_paste_text", {"text": sublime.get_clipboard()})
elif name == "undo":
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
notches and take the path below.
"""
import sublime
import sublime_plugin
from wcwidth import wcswidth
from .terminal import Terminal
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
WHEEL_UP = 64
WHEEL_DOWN = 65
# button 0 with the "moved while held" bit
MOTION = 32
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.
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"]))
row, col = view.rowcol(pt)
col = wcswidth(view.substr(sublime.Region(pt - col, pt)))
row -= terminal.offset
screen = terminal.screen
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)
class VtermMouseEventListener(sublime_plugin.EventListener):
"""Hands button-1 clicks to the app, but only while it 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."""
class VtermMousePressCommand(sublime_plugin.TextCommand):
"""Bound to button 1 in the mousemap while the app tracks the mouse."""
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())
if not terminal:
return
terminal.mouse_press = None # a release may not come: the context can drop mid-drag
window = self.view.window()
if window and window.active_view() != self.view:
# we replaced drag_select, which is what focuses the group; this
# click only focuses, as clicking an unfocused terminal window does
# the mousemap binding displaces drag_select, which is what focuses
# the group; this click only focuses, as in a terminal window
window.focus_view(self.view)
return
cell = _event_to_cell(self.view, terminal, event)
if cell is None:
return
terminal.mouse_press = cell
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):
"""Ask sublime for the click coordinates."""
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):
"""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
if terminal.screen.mouse_reporting_enabled() and event:
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)
def want_event(self):

View File

@@ -1,11 +1,13 @@
""" Rendering the emulator screen into the Sublime view
The view holds real text: scrollback above `terminal.offset`, the live screen
window from `offset` on. Dirty rows are whole-line replaced; colors are
add_regions overlays with scopes served by the dynamic scheme. The ST caret
window from `offset` on. Dirty rows are whole-line replaced, and rows the view
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.
"""
import math
from bisect import bisect_left
from functools import lru_cache
import sublime
@@ -15,7 +17,6 @@ from wcwidth import wcswidth
# per-cell call in segment_buffer_line
cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal
from .utils import rev_wcwidth
@@ -89,8 +90,10 @@ def segment_buffer_line(buffer_line):
# an orphaned wide-char filler (its first half was overwritten) still
# occupies a column; render a space so later spans stay aligned
run.append(char.data or " ")
column += 1
data = char.data
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:
yield _run(run, start, column, attrs)
@@ -117,32 +120,49 @@ def ensure_position(view, edit, row, col=0):
view.insert(edit, line_region.end(), " " * (col - lastcol))
class VtermRenderCommand(sublime_plugin.TextCommand):
"""Draws the screen into the view. Sublime keeps one instance per view, so
the caches below are that view's render state."""
class RenderState:
"""A view's render bookkeeping, kept on its Terminal.
def __init__(self, *args, **kwargs):
"""Set up this view's render caches."""
super().__init__(*args, **kwargs)
Sublime rebuilds every TextCommand instance of every view whenever any
plugin module loads. State held on the command is lost then: the overlays
already in the view become untracked, so a row rewrite no longer erases
them, and the key counter restarts and hands out keys still in use.
"""
def __init__(self):
"""Empty bookkeeping, for a view that has nothing painted yet."""
# view row -> region keys painted on it
self.colored_lines = {}
# view row -> (segments, lf) as last rendered; the dirty set is only a
# repaint hint, so most dirty rows are identical frame to frame
self._row_cache = {}
self._key_counter = 0
self.row_cache = {}
self.key_counter = 0
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
# 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
self._open_rows = []
self._open_chunk = None
self._trimmed = 0
self.open_rows = []
self.open_chunk = None
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._applied_cursor_style = None
def next_key(self):
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
self._key_counter += 1
return "vterm#{}".format(self._key_counter)
self.state.key_counter += 1
return "vterm#{}".format(self.state.key_counter)
def ensure_row(self, edit, row):
"""Row-existence check for the hot path, tracking the last row in python"""
@@ -156,6 +176,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(view.id())
if not terminal:
return
self._scheme = terminal.scheme
if terminal.render_state is None:
terminal.render_state = RenderState()
self.state = terminal.render_state
screen = terminal.screen
# the caret and the tab name are the user's; an unfocused terminal leaves
@@ -254,16 +278,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
screen = terminal.screen
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)
for chunk, scopes in self._archived_chunks.items():
for chunk, scopes in self.state.archived_chunks.items():
for scope in scopes:
view.erase_regions(chunk_key(chunk, scope))
self._archived_chunks.clear()
self._open_rows = []
self._open_chunk = None
self._trimmed = 0
self._row_cache.clear()
self.state.archived_chunks.clear()
self.state.open_rows = []
self.state.open_chunk = None
self.state.trimmed = 0
self.state.row_cache.clear()
terminal.offset = 0
screen.history.clear()
if wipe_screen:
@@ -284,23 +308,33 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# valid for this method only; the trim paths erase rows
self._lastrow = self.view.rowcol(self.view.size())[0]
if dirty_lines:
# history pops newest-first, so it fills the rows below `offset` backwards
history = screen.history
popped = len(history)
terminal.offset += popped
offset = terminal.offset
first = offset - popped
retired = list(history)
history.clear()
for line in range(popped):
buffer_line = history.pop()
self.update_line(edit, offset - line - 1, buffer_line, columns)
# rows the view already holds are rewritten one by one; the rest
# are new and go in as one insert, after those edits moved the end
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
# it, so spans measured mid-loop would be stale
self.archive_rows(range(offset - popped, offset))
# after the edits: spans measured mid-loop would be stale
self.archive_rows(range(first, offset))
for line in dirty_lines:
buffer_line = screen.buffer[line]
self.update_line(edit, line + offset, buffer_line, columns)
# screen rows past the view's end go in as one insert too
start = self._lastrow + 1 - offset
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):
"""Rewrite one view row from a screen row, unless it is unchanged"""
@@ -310,25 +344,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# 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
rendered = (segments, lf)
if self._row_cache.get(line) == rendered:
if self.state.row_cache.get(line) == rendered:
return
self._row_cache[line] = rendered
by_scope = {}
needed = 0
for _, start, end, fg, bg, style in segments:
if is_default_run(fg, bg, style):
continue
needed = max(needed, end)
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
text = "".join(s[0] for s in segments)
if lf:
text += CONTINUATION
text = text.rstrip()
if len(text) < needed:
# pad only to the last coloured run, in the same edit as the text
text += " " * (needed - len(text))
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))
@@ -337,10 +356,77 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# 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 = {}
needed = 0
scope_of = self._scheme.scope
for _, start, end, fg, bg, style in segments:
if is_default_run(fg, bg, style):
continue
needed = max(needed, end)
by_scope.setdefault(scope_of(fg, bg, style), []).append((start, end))
text = "".join(s[0] for s in segments)
if lf:
text += CONTINUATION
text = text.rstrip()
if len(text) < needed:
# pad only to the last coloured run, in the same edit as the text
text += " " * (needed - len(text))
return text, by_scope
def append_rows(self, edit, buffer_lines, columns, scrollback):
"""Add rows below the view's end in one insert.
Scrollback rows are never rewritten, so their colours go straight onto
the chunk keys with no per-row key for flush_chunk to merge. Screen
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):
"""Lay the colour overlays for one row, one add_regions per scope."""
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():
key = self.next_key()
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
@@ -348,21 +434,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def decolorize_line(self, line):
"""Drop a row's colour overlays before it is rewritten or erased."""
if line in self.colored_lines:
for key in self.colored_lines[line]:
if line in self.state.colored_lines:
for key in self.state.colored_lines[line]:
self.view.erase_regions(key)
del self.colored_lines[line]
del self.state.colored_lines[line]
def archive_rows(self, rows):
"""Queue rows that left the screen for their chunk's merge."""
for row in rows:
if row not in self.colored_lines:
if row not in self.state.colored_lines:
continue
chunk = (row + self._trimmed) // CHUNK
if chunk != self._open_chunk:
chunk = (row + self.state.trimmed) // CHUNK
if chunk != self.state.open_chunk:
self.flush_chunk()
self._open_chunk = chunk
self._open_rows.append(row)
self.state.open_chunk = chunk
self.state.open_rows.append(row)
def flush_chunk(self):
"""Merge the queued rows onto shared per-(scope, chunk) keys.
@@ -373,28 +459,29 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
whole set, so merging every frame would be quadratic in CHUNK.
"""
view = self.view
rows, chunk = self._open_rows, self._open_chunk
self._open_rows = []
rows, chunk = self.state.open_rows, self.state.open_chunk
self.state.open_rows = []
if not rows:
return
grouped = {}
stale = []
scope_of = self._scheme.scope
for row in rows:
keys = self.colored_lines.pop(row, None)
keys = self.state.colored_lines.pop(row, None)
if not keys:
continue
stale += keys
# 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()
for _, a, b, fg, bg, style in segments:
if is_default_run(fg, bg, style):
continue
scope = scheme_manager.scope(fg, bg, style)
scope = scope_of(fg, bg, style)
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():
key = chunk_key(chunk, scope)
if scope in scopes:
@@ -427,7 +514,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
while row > cursor_row:
line_region = view.line(view.text_point(row, 0))
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
else:
break
@@ -441,22 +528,23 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
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:
view.erase(edit, trailing_region)
self._row_cache.pop(row, None)
self.state.row_cache.pop(row, None)
def trim_history(self, edit, terminal):
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
view = self.view
screen = terminal.screen
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:
# queued rows are about to be renumbered or erased: merge them first
self.flush_chunk()
m = max(lastrow + 1 - n, math.ceil(n / 10))
for line in range(m):
self.decolorize_line(line)
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
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())
view.erase(edit, top_region)
terminal.offset -= m
@@ -464,12 +552,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# chunks wholly above the cut go; the boundary chunk drops the
# regions the erase collapsed to empty
self._trimmed += m
cut = self._trimmed // CHUNK
for chunk in [c for c in self._archived_chunks if c < cut]:
for scope in self._archived_chunks.pop(chunk):
self.state.trimmed += m
cut = self.state.trimmed // CHUNK
for chunk in [c for c in self.state.archived_chunks if c < cut]:
for scope in self.state.archived_chunks.pop(chunk):
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)
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
@@ -478,7 +566,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for line in view.lines(tail_region):
row = view.rowcol(line.begin())[0]
self.decolorize_line(row)
self._row_cache.pop(row, None)
self.state.row_cache.pop(row, None)
view.erase(edit, tail_region)

View File

@@ -8,16 +8,12 @@ import sublime
from .emulator import ANSI_COLORS
from .utils import settings
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm"
# "hidden-" keeps the generated scheme out of the scheme picker
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
# the value terminal views get as their color_scheme setting
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
# each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250
@@ -69,89 +65,46 @@ def _is_dark(color_text):
class SchemeManager:
"""Owns the generated scheme file and the set of known scopes."""
"""Owns one theme's generated scheme file and set of known scopes."""
def __init__(self):
"""Start on the built-in palette; initialize() picks up the real one."""
def __init__(self, theme_name):
"""State only; build() resolves the palette and writes the file."""
self.theme_name = theme_name
self.file_name = "vterm-{}.hidden-color-scheme".format(theme_name or "system")
self.path = "Packages/{}/{}".format(SCHEME_DIR_NAME, self.file_name)
self.lock = threading.Lock()
self.rules = {}
# key -> scope name; the render thread reads it without the lock, so it
# must never outlive self.rules (cleared together in _build)
# must never outlive self.rules (cleared together in build)
self._names = {}
self.foreground = "#ffffff"
self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False
self._initialized = False
self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view):
"""Pick up the palette, seed the rules, write the file. Main thread only"""
if self._initialized:
return
self._initialized = True
self._host_view = view
settings().add_on_change("vterm.theme", self.reload)
self._build()
def ensure_file(self):
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
return
with self.lock:
self._build()
def add_listener(self, callback):
"""Called after every palette rebuild"""
self._listeners.append(callback)
def remove_listener(self, callback):
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
if callback in self._listeners:
self._listeners.remove(callback)
def reload(self):
"""Rebuild every rule for a new palette, then notify the terminals"""
if not self._initialized:
# no scheme file to maintain yet; initialize() will read the setting
return
with self.lock:
self._build()
for callback in list(self._listeners):
try:
callback()
except Exception:
LOG.warning("scheme listener failed", exc_info=True)
def _build(self):
def build(self, host_view=None):
"""Resolve the palette and regenerate every rule, then write the file.
Rules cannot be appended after a palette change: each one stores resolved
colours, so they all have to be rebuilt. Caller holds the lock.
colours, so they all have to be rebuilt. Callable again later (e.g. to
re-sample the "No theme" palette from the editor's current colours).
"""
with self.lock:
self._names = {}
self.rules = {}
self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme"))
theme = THEMES.get(self.theme_name)
if theme:
self.foreground = theme["foreground"]
self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else:
# captured values outlive the view they came from
if self._host_view is not None and self._host_view.is_valid():
style = self._host_view.style()
self._editor_fg = style.get("foreground", self._editor_fg)
self._editor_bg = style.get("background", self._editor_bg)
self.foreground = self._editor_fg
self.background = self._editor_bg
elif host_view is not None and host_view.is_valid():
style = host_view.style()
self.foreground = style.get("foreground", self.foreground)
self.background = style.get("background", self.background)
# user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {})
@@ -228,6 +181,10 @@ class SchemeManager:
rule["font_style"] = " ".join(font_style)
self.rules[(fg, bg, style)] = rule
def file_path(self):
"""Filesystem path of the generated scheme file."""
return os.path.join(self._directory(), self.file_name)
def _directory(self):
"""Path of the generated scheme's directory, created on first use."""
if self._dir is None:
@@ -258,11 +215,49 @@ class SchemeManager:
},
"rules": list(self.rules.values()),
}
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
with open(path, "w", encoding="utf-8") as f:
with open(self.file_path(), "w", encoding="utf-8") as f:
# compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":"))
LOG.debug("scheme written: rules=%s", len(self.rules))
scheme_manager = SchemeManager()
# one manager per theme name, built once and kept for the plugin host's life: the
# built-in theme set is small and fixed, so there is nothing to garbage-collect
_managers = {}
def get_scheme_manager(theme_name, host_view=None):
"""The manager for a theme name (None for "follow the Sublime colour scheme"),
building and caching it on first use.
The None entry is rebuilt whenever the editor's colours have changed since: it
is a live sample of them, not a static palette, so every caller handing a
terminal a "no theme" scheme gets one that is actually current.
"""
manager = _managers.get(theme_name)
if manager is None:
manager = SchemeManager(theme_name)
_managers[theme_name] = manager
manager.build(host_view)
elif theme_name is None and host_view is not None and host_view.is_valid():
style = host_view.style()
if (style.get("foreground"), style.get("background")) != (manager.foreground, manager.background):
manager.build(host_view)
return manager
def build_startup_schemes(host_view=None):
"""Make sure every theme's colour scheme is on disk before Sublime reads a
restored tab's color_scheme at startup.
A named theme is static, so an existing file is left alone - rebuilding it
would just rewrite the same bytes, and each write makes sublime reload the
scheme and restyle every view using it. None always rebuilds, same as
get_scheme_manager.
"""
for name in ALL_THEME_NAMES:
if name is None:
get_scheme_manager(None, host_view=host_view)
continue
if not os.path.exists(SchemeManager(name).file_path()):
get_scheme_manager(name)

View File

@@ -2,6 +2,7 @@
import codecs
import logging
import os
import re
import threading
import time
from collections import deque
@@ -13,7 +14,6 @@ from pyte import modes as mo
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
from .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__)
@@ -27,6 +27,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames.
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
REPAINT_NUDGE_DELAY = 0.25
@@ -38,6 +41,10 @@ IDLE_TICK = 0.2
# a millisecond apart, and drawing between them flashes its showcmd echo
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
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
UNDO_CLEAR_FRAMES = 100
@@ -98,9 +105,11 @@ class Terminal:
_terminals = {}
def __init__(self, view):
def __init__(self, view, scheme):
"""State only; the pty and the threads come up in start()."""
self.view = view
self.scheme = scheme
self.render_state = None
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True
self.default_title = ""
@@ -120,6 +129,8 @@ class Terminal:
# guards the queue/flag handoff between send_string and the drain thread
self._send_lock = threading.Lock()
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.pending_clear = False
self.pending_clear_screen = False
@@ -197,13 +208,11 @@ class Terminal:
history=self.scrollback_size,
erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback,
fg_color=scheme_manager.foreground.lstrip("#"),
bg_color=scheme_manager.background.lstrip("#"),
is_dark=scheme_manager.is_dark(),
fg_color=self.scheme.foreground.lstrip("#"),
bg_color=self.scheme.background.lstrip("#"),
is_dark=self.scheme.is_dark(),
)
self.stream = VtStream(self.screen)
scheme_manager.add_listener(self.apply_scheme)
self._start_rendering()
@@ -211,20 +220,28 @@ class Terminal:
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
# the screen answers OSC 10/11 and DSR 996 from these values
screen = self.screen
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
screen.default_bg_color = scheme_manager.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark()
screen.default_fg_color = self.scheme.foreground.lstrip("#")
screen.default_bg_color = self.scheme.background.lstrip("#")
screen.theme_is_dark = self.scheme.is_dark()
if screen.theme_notify_enabled():
self.send_string(screen.theme_report(), normalized=False)
def retheme(self, scheme):
"""Switch to a different scheme: vterm: Select Theme, scoped to this terminal."""
if scheme is self.scheme:
return
self.scheme = scheme
self.view.settings().set("color_scheme", scheme.path)
self.apply_scheme()
def kill(self):
"""Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process")
self.alive = False
self._wake.set()
scheme_manager.remove_listener(self.apply_scheme)
try:
self.process.terminate()
# force: a child ignoring HUP and INT would otherwise outlive the tab
self.process.terminate(force=True)
except Exception:
LOG.warning("terminate failed", exc_info=True)
vid = self.view.id()
@@ -235,10 +252,6 @@ class Terminal:
"""Screen asked for a clear; the next render performs it on the UI thread."""
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):
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
screen = self.screen
@@ -274,6 +287,10 @@ class Terminal:
break
chunks.append(self._decoder.decode(raw))
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():
LOG.debug("[reader] stops")
break
@@ -322,7 +339,7 @@ class Terminal:
if frames >= UNDO_CLEAR_FRAMES:
frames = 0
self.view.run_command("vterm_clear_undo_stack")
self._sync_wheel_flag()
self._sync_mouse_flags()
if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops")
break
@@ -382,7 +399,7 @@ class Terminal:
# escape sequences go through untouched; typed and pasted text has its
# line endings canonicalised for the app's LNM setting
if normalized:
string = string.replace("\r\n", "\n")
string = CONTROL_CHARS.sub("", string).replace("\r\n", "\n")
if mo.LNM in self.screen.mode:
string = string.replace("\n", "\r\n")
else:
@@ -418,12 +435,16 @@ class Terminal:
continue
self.process.write(string)
def _sync_wheel_flag(self):
"""Publish whether the app owns the wheel, for the mousemap context."""
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
def _sync_mouse_flags(self):
"""Publish whether the app owns the wheel and the mouse, for the mousemap contexts."""
reporting = self.screen.mouse_reporting_enabled()
captured = reporting or self.screen.alternate_buffer_mode
if captured != self._wheel_captured:
self._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):
"""Report focus gain or loss, when the app asked for it (?1004)."""

View File

@@ -50,4 +50,27 @@ THEMES = {
"light_cyan": "#8cdfe0",
"light_white": "#e0e0e0",
},
"red-alert": {
"foreground": "#ffffff",
"background": "#762423",
"black": "#000000",
"red": "#d62e4e",
"green": "#71be6b",
"brown": "#beb86b",
"blue": "#489bee",
"magenta": "#e979d7",
"cyan": "#6bbeb8",
"white": "#d6d6d6",
"light_black": "#262626",
"light_red": "#e02553",
"light_green": "#aff08c",
"light_brown": "#dfddb7",
"light_blue": "#65aaf1",
"light_magenta": "#ddb7df",
"light_cyan": "#b7dfdd",
"light_white": "#ffffff",
},
}
# every selectable theme, including None for "follow the active Sublime colour scheme"
ALL_THEME_NAMES = [None] + sorted(THEMES)