14 Commits

Author SHA1 Message Date
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
Vaclav Tvrdik
76927a85ea Point Package Control install at shared sublime-chanel channel
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:29:47 +02:00
Vaclav Tvrdik
e150591763 Add GitHub-based install instructions and Package Control repository.json
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:06:35 +02:00
Vaclav Tvrdik
27f1140737 Fix when pi started in vterm does not scroll to the end 2026-09-16 16:50:04 +02:00
Vaclav Tvrdik
d371d35fb3 configurable scrolling speed implemented 2026-09-16 16:14:42 +02:00
Vaclav Tvrdik
119d4124f0 bound the undo stack by clearing it every 100 frames 2026-09-11 23:18:48 +02:00
Vaclav Tvrdik
c8caecdc45 batch plain ascii runs in draw with a C-speed row fill 2026-09-11 23:18:25 +02:00
Vaclav Tvrdik
6e93ae2a5b benchmarking and profiling added 2026-09-11 12:33:57 +02:00
Vaclav Tvrdik
f37da42a74 fix for another vim glitch 2026-09-10 17:33:40 +02:00
Vaclav Tvrdik
ca0d76cf5b fix for vim glitch 2026-09-10 17:28:14 +02:00
Vaclav Tvrdik
01a88f7b19 halve the parse cost of coloured output and keep drawing during bursts 2026-09-10 13:03:34 +02:00
Vaclav Tvrdik
28c9c398a1 wake the renderer on pty output instead of polling at 33 fps 2026-09-10 11:23:09 +02:00
20 changed files with 1102 additions and 547 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,8 +9,8 @@ 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/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, close. |
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
| `vterm/mouse.py` | Wheel and click handling. |
@@ -100,293 +79,6 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
`VtermMouseEventListener` (`mouse.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) ───────────────
│ 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`, `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. `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.
---

View File

@@ -9,17 +9,46 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
## Install
Symlink this folder into Sublime packages as `vterm`:
### With Package Control
```sh
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
vterm is distributed through the shared channel at
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the
default one. Open **Preferences → Package Settings → Package Control →
Settings** and put the channel URL in the `channels` array of the right-hand
(User) pane:
```json
{
"channels": [
"https://packagecontrol.io/channel_v3.json",
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
]
}
```
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was
never installed), run **Package Control: Satisfy Libraries** and restart Sublime.
Package Control garbage-collects libraries no package requires, so if imports
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
hides every default package. Then run **Package Control: Install Package**
from the Command Palette and pick **vterm**. Upgrades arrive through
**Package Control: Upgrade Package** like any other package.
### From source
Clone the repository into the Sublime Text `Packages` directory as `vterm`:
```sh
git clone https://github.com/vaclavt/vterm.git \
"$HOME/Library/Application Support/Sublime Text/Packages/vterm"
```
Use one method or the other, not both: an unpacked `Packages/vterm` directory
overrides the `vterm.sublime-package` that Package Control installs.
Either way, dependencies (pyte, ptyprocess, wcwidth) are Package Control
libraries declared in `dependencies.json`. If they are not yet in
`Lib/python38` (e.g. Terminus was never installed), run **Package Control:
Satisfy Libraries** and restart Sublime. Package Control garbage-collects
libraries no package requires, so if imports ever fail after uninstalling
another package, re-run *Satisfy Libraries*.
## Use
@@ -56,7 +85,9 @@ 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. While an app owns the
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
scrolls too far. While an app owns the
alternate screen the scrollback above it is folded away, kitty-style, and
unfolds when the app leaves it.
@@ -73,11 +104,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
@@ -154,7 +186,8 @@ instead), image protocols, Windows, panels.
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
plugin (sublime_text#1838), so set a utility such as
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for
Sublime Text. It then arrives as wheel notches and scrolls the app. Without
Sublime Text. It then arrives as wheel notches and scrolls the app, three
lines a notch unless `wheel_lines` says otherwise. Without
one, the trackpad does nothing in a full-screen app; the mouse wheel,
`shift+pageup/pagedown` and `shift+up/down` work regardless.
@@ -167,6 +200,8 @@ python3 tests/test_render.py # render layer on a fake view: trimming, select
python3 tests/test_commands.py # theme picker, live restyle, generated scheme
python3 tests/harness_live.py CMD SECONDS [--send TEXT] # run a real app in the emulator, dump the screen
python3 tools/gen_keymap.py # regenerate Default.sublime-keymap
python3 benchmarks/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-benches of draw
```
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the

View File

@@ -0,0 +1,438 @@
""" Render-cost comparison between vterm and Terminus
Both plugins turn pty output into view text plus add_regions overlays, so what
they cost the editor is the number of Sublime calls a frame makes -- the plugin
host is a separate process (see Architecture.md). This drives both render
commands against a fake view that counts every call, on the same input, with
each plugin's own render gate deciding when a frame happens.
Terminus is read straight out of its .sublime-package by zipimport; it is
skipped when not installed. Parse times are real, call counts stand in for the
cross-process cost, which a fake view cannot show.
Run with: python3 benchmarks/bench_vs_terminus.py
"""
import collections
import os
import sys
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tests"))
import _bootstrap # noqa: F401 sets sys.path for the imports below
TERMINUS = os.path.expanduser(
"~/Library/Application Support/Sublime Text/Installed Packages/Terminus.sublime-package")
import sublime
ROWS, COLUMNS = 40, 100
SCROLLBACK = 10000
class FakeSelection(list):
def clear(self):
del self[:]
def add(self, region):
self.append(region)
class CountingView:
"""A view that answers with real line arithmetic and tallies every call."""
_ids = [0]
def __init__(self, counter):
CountingView._ids[0] += 1
self._id = CountingView._ids[0]
self.c = counter
self.text = ""
self.selection = FakeSelection()
self.regions = {}
self.folds = []
self._name = ""
self._settings = sublime.Settings()
# so a plugin that renders through a second command has it counted too
self.commands = {}
def _t(self, name):
self.c[name] += 1
def id(self):
return self._id
def size(self):
self._t("size")
return len(self.text)
def rowcol(self, point):
self._t("rowcol")
head = self.text[:point]
return (head.count("\n"), point - (head.rfind("\n") + 1))
def text_point(self, row, col):
self._t("text_point")
lines = self.text.split("\n")
start = sum(len(l) + 1 for l in lines[:row])
return start + min(col, len(lines[row]) if row < len(lines) else 0)
def _line(self, point):
start = self.text.rfind("\n", 0, point) + 1
end = self.text.find("\n", point)
return sublime.Region(start, len(self.text) if end == -1 else end)
def line(self, point):
self._t("line")
return self._line(point)
def full_line(self, point):
self._t("full_line")
region = self._line(point)
return sublime.Region(region.begin(), min(region.end() + 1, len(self.text)))
def lines(self, region):
self._t("lines")
out = []
point = region.begin()
while point <= region.end():
line = self._line(point)
out.append(line)
point = line.end() + 1
return out
def substr(self, region):
self._t("substr")
return self.text[region.begin():region.end()]
def erase(self, edit, region):
self._t("erase")
self.text = self.text[:region.begin()] + self.text[region.end():]
def replace(self, edit, region, value):
self._t("replace")
self.text = self.text[:region.begin()] + value + self.text[region.end():]
def insert(self, edit, point, value):
self._t("insert")
self.text = self.text[:point] + value + self.text[point:]
return len(value)
def fold(self, region):
self._t("fold")
self.folds.append((region.begin(), region.end()))
return True
def unfold(self, region):
self._t("unfold")
folded, self.folds = self.folds, []
return folded
def folded_regions(self):
self._t("folded_regions")
return [sublime.Region(a, b) for a, b in self.folds]
def add_regions(self, key, regions, scope="", *args, **kwargs):
self._t("add_regions")
# what crosses the process boundary is the regions, not just the call
self.c["regions.sent"] += len(regions)
self.c["regions.sent.merged" if "@" in key else "regions.sent.row"] += len(regions)
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
def get_regions(self, key):
self._t("get_regions")
_, spans = self.regions.get(key, ("", []))
self.c["regions.read"] += len(spans)
return [sublime.Region(a, b) for a, b in spans]
def erase_regions(self, key):
self._t("erase_regions")
self.regions.pop(key, None)
def settings(self):
self._t("settings")
return CountingSettings(self._settings, self.c)
def viewport_position(self):
self._t("viewport_position")
return (0, 0)
def viewport_extent(self):
self._t("viewport_extent")
return (800, ROWS * 20)
def layout_extent(self):
self._t("layout_extent")
return (800, len(self.text))
def text_to_layout(self, point):
self._t("text_to_layout")
return (0, point)
def set_viewport_position(self, position, animate=True):
self._t("set_viewport_position")
def line_height(self):
self._t("line_height")
return 20
def em_width(self):
self._t("em_width")
return 8
def name(self):
self._t("name")
return self._name
def set_name(self, name):
self._t("set_name")
self._name = name
def window(self):
self._t("window")
return object()
def sel(self):
self._t("sel")
return self.selection
def run_command(self, name, args=None):
self._t("run_command:" + name)
command = self.commands.get(name)
if command:
command.run(None, **(args or {}))
class CountingSettings:
"""Settings reads and writes are round trips too, and Terminus does one per run."""
def __init__(self, settings, counter):
self._settings = settings
self._c = counter
def get(self, key, default=None):
self._c["settings.get"] += 1
return self._settings.get(key, default)
def set(self, key, value):
self._c["settings.set"] += 1
self._settings[key] = value
def erase(self, key):
self._c["settings.erase"] += 1
self._settings.pop(key, None)
def has(self, key):
return key in self._settings
def add_on_change(self, *args):
pass
def clear_on_change(self, *args):
pass
class FakeProcess:
def __init__(self):
self.writes = []
def write(self, data):
self.writes.append(data)
# ------------------------------------------------------------------ workloads
def colour_flood(lines=2000, runs=6, palette=200):
"""Coloured output: `runs` SGR changes per line, drawn from `palette` colours."""
width = COLUMNS // runs
out = []
for i in range(lines):
parts = []
for r in range(runs):
parts.append("\x1b[38;5;{}m".format(16 + (i * runs + r) % palette))
parts.append(str((i + r) % 10) * width)
parts.append("\x1b[0m\r\n")
out.append("".join(parts))
return "".join(out)
def plain_flood(lines=2000):
return "".join("{:04d} ".format(i) + "x" * (COLUMNS - 5) + "\r\n" for i in range(lines))
def tui_repaint(frames=60):
"""A full-screen coloured app redrawing itself; one cell differs per frame."""
out = []
for f in range(frames):
parts = ["\x1b[H"]
for row in range(ROWS):
for r in range(4):
parts.append("\x1b[38;5;{}m".format(16 + (row * 4 + r) % 200))
parts.append(str((row + r) % 10) * (COLUMNS // 4))
parts.append("\x1b[0m")
if row < ROWS - 1:
parts.append("\r\n")
parts.append("\x1b[1;1H\x1b[38;5;99m{:04d}\x1b[0m".format(f))
out.append("".join(parts))
return "".join(out)
# ------------------------------------------------------------------- drivers
def drive(feed, need_to_render, render, data, tick):
"""Feed `data` in tick-sized reads, rendering whenever the plugin asks to."""
parse = 0.0
frames = 0
for i in range(0, len(data), tick):
start = time.perf_counter()
feed(data[i:i + tick])
parse += time.perf_counter() - start
if need_to_render():
render()
frames += 1
return parse, frames
def run_vterm(data, tick):
from vterm.emulator import VtScreen, VtStream
from vterm.render import VtermRenderCommand
from vterm.terminal import Terminal
counter = collections.Counter()
view = CountingView(counter)
terminal = Terminal(view)
terminal.scrollback_size = SCROLLBACK
terminal.process = FakeProcess()
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
Terminal._terminals[view.id()] = terminal
stream = VtStream(terminal.screen)
command = VtermRenderCommand(view)
def render():
counter["run_command:vterm_render"] += 1
command.run(None)
try:
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
finally:
del Terminal._terminals[view.id()]
return counter, parse, frames, len(view.regions)
def run_terminus(data, tick):
from terminus.ptty import TerminalScreen, TerminalStream
from terminus.render import TerminusRenderCommand, TerminusShowCursorCommand
from terminus.terminal import Terminal
counter = collections.Counter()
view = CountingView(counter)
terminal = Terminal(view)
terminal.detached = False
terminal.offset = 0
terminal.title = None
terminal.default_title = "Terminus"
terminal.screen = TerminalScreen(
COLUMNS, ROWS, process=FakeProcess(), clear_callback=lambda: None,
reset_callback=lambda: None, history=SCROLLBACK)
Terminal._terminals[view.id()] = terminal
stream = TerminalStream(terminal.screen)
command = TerminusRenderCommand(view)
command.scrollback_history_size = SCROLLBACK
view.commands["terminus_show_cursor"] = TerminusShowCursorCommand(view)
def render():
counter["run_command:terminus_render"] += 1
command.run(None)
# terminus clears dirty in its render loop, not in the command
terminal.screen.dirty.clear()
try:
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
finally:
del Terminal._terminals[view.id()]
return counter, parse, frames, len(view.regions)
# -------------------------------------------------------------------- report
def measure(runner, data, tick):
counter, parse, frames, keys = runner(data, tick)
calls = sum(v for k, v in counter.items() if not k.startswith("regions."))
return {
"parse": parse * 1000,
"frames": frames,
"calls": calls,
"per_frame": calls / max(frames, 1),
"sent": counter["regions.sent"],
"sent_row": counter["regions.sent.row"],
"sent_merged": counter["regions.sent.merged"],
"read": counter["regions.read"],
"keys": keys,
"counter": counter,
}
ROW_FORMAT = " {:<26}{:>14}{:>14}{:>10}"
def report(title, data, tick, both):
print("=" * 66)
print("{}\n {:.0f} KiB fed in {} B reads".format(title, len(data) / 1024, tick))
print("=" * 66)
ours = measure(run_vterm, data, tick)
theirs = measure(run_terminus, data, tick) if both else None
def line(label, key, fmt="{:.0f}"):
mine = fmt.format(ours[key])
if not theirs:
print(ROW_FORMAT.format(label, mine, "-", "-"))
return
other = fmt.format(theirs[key])
ratio = theirs[key] / ours[key] if ours[key] else 0
print(ROW_FORMAT.format(label, mine, other, "{:.1f}x".format(ratio)))
print(ROW_FORMAT.format("", "vterm", "Terminus", "ratio"))
line("parse ms", "parse", "{:.1f}")
line("frames", "frames")
line("sublime calls", "calls")
line("calls / frame", "per_frame", "{:.1f}")
line("regions sent", "sent")
line(" of them merges", "sent_merged")
line("regions read back", "read")
line("live region keys", "keys")
top = sorted(((v, k) for k, v in ours["counter"].items() if not k.startswith("regions.")),
reverse=True)[:6]
print(" vterm top: " + ", ".join("{}={}".format(k, v) for v, k in top))
if theirs:
top = sorted(((v, k) for k, v in theirs["counter"].items() if not k.startswith("regions.")),
reverse=True)[:6]
print(" Terminus top: " + ", ".join("{}={}".format(k, v) for v, k in top))
print()
def main():
from vterm.terminal import FEED_LIMIT
both = os.path.exists(TERMINUS)
if both:
sys.path.insert(0, TERMINUS)
else:
print("Terminus is not installed; reporting vterm alone.\n")
burst = FEED_LIMIT # the most one renderer tick parses during a flood
trickle = 512 # a few rows a frame, as a build log arrives
report("A. colour flood, 6 runs/line, 200 colours",
colour_flood(), burst, both)
report("B. colour flood, 6 runs/line, 8 colours",
colour_flood(palette=8), burst, both)
report("C. colour trickle, a few rows a frame",
colour_flood(lines=600), trickle, both)
report("D. plain flood, no colour",
plain_flood(), burst, both)
report("E. TUI repaint, 60 full-screen frames, one cell changes",
tui_repaint(), burst, both)
if __name__ == "__main__":
main()

129
benchmarks/profiling.py Normal file
View File

@@ -0,0 +1,129 @@
"""Profile the feed/parse path, then micro-bench the per-char steps of VtScreen.draw.
cProfile ranks the hotspots on the bench workloads; the micro loops put numbers
on the per-char candidates inside the draw path.
Run with: python3 benchmarks/profiling.py
"""
import cProfile
import os
import pstats
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "tests"))
import _bootstrap # noqa: F401 sets sys.path for the imports below
from bench_vs_terminus import colour_flood, plain_flood, tui_repaint # noqa: E402
from _bootstrap import FakeProcess # noqa: E402
from pyte.screens import wcwidth as pyte_wcwidth # noqa: E402 lru_cache'd
from wcwidth import wcwidth as raw_wcwidth # noqa: E402
from vterm.emulator import VtScreen, VtStream # noqa: E402
# ------------------------------------------------------------ feed hotspots
def build():
screen = VtScreen(100, 40, process=FakeProcess(), history=10000)
return VtStream(screen)
def run(name, data):
stream = build()
t0 = time.perf_counter()
for i in range(0, len(data), 32768):
stream.feed(data[i:i + 32768])
dt = (time.perf_counter() - t0) * 1000
print("{:<20} {:.1f} ms".format(name, dt))
def profiled(name, data):
stream = build()
pr = cProfile.Profile()
pr.enable()
for i in range(0, len(data), 32768):
stream.feed(data[i:i + 32768])
pr.disable()
s = pstats.Stats(pr)
print("\n==== {} ====".format(name))
s.sort_stats("cumulative").print_stats(18)
# ------------------------------------------- per-char micro-benches of draw
data = ("x" * 50 + "\x1b[38;5;100m") * 40000 # ~2M printable chars
def t(label, fn, n=3):
best = 1e9
for _ in range(n):
t0 = time.perf_counter()
fn()
best = min(best, time.perf_counter() - t0)
print("{:<42} {:8.3f} ms".format(label, best * 1000))
def loop_wcwidth_pyte():
for c in data:
w = pyte_wcwidth(c)
def loop_wcwidth_raw():
for c in data:
w = raw_wcwidth(c)
def loop_iter_only():
for c in data:
pass
def loop_min():
x = 0
for c in data:
x = min(x + 1, 80)
def loop_dict_get_set():
d = {}
line = {}
for i, c in enumerate(data):
v = d.get(c)
if v is None:
d[c] = c
line[i % 80] = c
def loop_min_vs_cmp():
x = 0
cols = 80
for c in data:
nx = x + 1
x = cols if nx > cols else nx
def main():
floods = {
"colour": colour_flood(),
"plain": plain_flood(),
"tui": tui_repaint(),
}
for n, d in floods.items():
run(n, d)
profiled("colour", colour_flood())
profiled("tui", tui_repaint())
print("==== per-char micro-benches ====")
t("iter only", loop_iter_only)
t("wcwidth (pyte lru_cache)", loop_wcwidth_pyte)
t("wcwidth (raw)", loop_wcwidth_raw)
t("min(x+w, cols) per char", loop_min)
t("min -> branch per char", loop_min_vs_cmp)
t("dict get/miss/set per char", loop_dict_get_set)
if __name__ == "__main__":
main()

14
main.py
View File

@@ -5,6 +5,8 @@ registry would orphan open terminal tabs.
"""
import logging
import sublime
# imported for sublime's command discovery, not called here
from .vterm.commands import (
VtermClearScreenCommand,
@@ -28,7 +30,7 @@ from .vterm.mouse import (
VtermMouseEventListener,
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 +39,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()

21
repository.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "sublime://packagecontrol.io/schemas/repository",
"schema_version": "4.0.0",
"packages": [
{
"name": "vterm",
"details": "https://github.com/vaclavt/vterm",
"description": "Minimalist terminal for Sublime Text 4, built to run TUI coding agents (pi, claude) in a tab",
"author": "vaclavt",
"labels": ["terminal", "shell", "pty"],
"releases": [
{
"sublime_text": ">=4149",
"platforms": ["osx"],
"python_versions": ["3.8"],
"tags": true
}
]
}
]
}

View File

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

View File

@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermPasteSelectionCommand, 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,113 @@ 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()
def id(self):
return self._id
def settings(self):
return self._settings
def is_valid(self):
return True
def style(self):
return {}
class FakeWindow:
def __init__(self, view):
self._view = view
def active_view(self):
return self._view
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")
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 +136,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))

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()
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
assert os.path.exists(scheme_file), \
"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

@@ -17,6 +17,7 @@ from vterm.render import (
following,
scroll_to_cursor,
)
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
@@ -125,7 +126,9 @@ class FakeView:
return self.viewport
def set_viewport_position(self, xy, animate=True):
self.viewport = xy
# scroll_past_end is off, so sublime clamps to the layout
maxy = max(0, self.layout_extent()[1] - self.viewport_extent()[1])
self.viewport = (xy[0], max(0, min(xy[1], maxy)))
def line_height(self):
return LINE_HEIGHT
@@ -155,7 +158,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()
@@ -163,9 +166,12 @@ 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
return command
# an app on the alternate screen must not have its view trimmed away: the
@@ -176,7 +182,7 @@ 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, \
@@ -186,7 +192,7 @@ assert terminal.view.text == before, \
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
@@ -197,11 +203,29 @@ assert "row 0" not in terminal.view.text, "oldest rows must be 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
# nor may its blank rows be trimmed: an app that blanks and refills its last
# row (vim's showcmd corner) would resize the view under a pinned viewport
terminal, stream = make_terminal(rows=4)
stream.feed("\x1b[?1049h")
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal)
before = terminal.view.text
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == before, \
"alt screen blank tail was trimmed: {!r}".format(terminal.view.text)
# 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)
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == "top\nrow", terminal.view.text
print("render tests passed (alt screen keeps its view, cap honoured)")
@@ -241,7 +265,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)
@@ -271,7 +295,7 @@ print("colorize tests passed (arithmetic offsets, scope batching, key bookkeepin
# 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)
@@ -290,7 +314,7 @@ 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()
@@ -312,7 +336,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()), \
@@ -339,10 +363,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)")
@@ -350,7 +372,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)
@@ -522,6 +544,23 @@ plugin_settings.set("scroll_modifier", "shift")
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
# an app that repaints its block shorter than the screen (pi on every SIGWINCH)
# leaves the live screen shorter than the viewport once the blank tail is
# trimmed. the anchor must stay where sublime can actually put the viewport, or
# the follow gate closes for good and the output is never followed again
short, _ = make_terminal(rows=8, cols=20)
short.view.viewport_rows = short.screen.lines
# 24 scrollback rows, then a live screen holding 3 rows instead of 8
short.view.text = "\n".join("row {}".format(i) for i in range(27))
short.offset = 24
scroll_to_cursor(short.view, short)
assert short.viewport_y == short.view.viewport_position()[1], \
(short.viewport_y, short.view.viewport_position()[1])
assert following(short.view, short), "follow gate latched shut on a short screen"
print("short screen tests passed (the anchor stays reachable, so the view keeps following)")
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
# form once the app turned modifyOtherKeys on, which it reaches for because a
# bare ESC CR reads as alt+enter
@@ -584,7 +623,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")
@@ -612,7 +651,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

View File

@@ -15,11 +15,16 @@
// null sends the keys to the app
"scroll_modifier": "shift",
// scroll lines sent to the app per wheel notch (iTerm2 sends 3).
// A trackpad via LinearMouse makes many notches per swipe; 1 is calmer.
"wheel_lines": 1,
"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"}

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__)
@@ -43,12 +43,12 @@ 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=window.active_view())
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:
@@ -106,7 +106,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 +125,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)
@@ -225,13 +225,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 +247,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=self.terminal.view)
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)
@@ -264,6 +290,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
return
# the renderer thread owns the screen; it applies this on its next tick
terminal.pending_clear_screen = True
terminal.wake()
self.view.run_command("vterm_clear_undo_stack")
def is_enabled(self):

View File

@@ -93,6 +93,9 @@ def quantize_256(r, g, b):
return 16 + 36 * level(r) + 6 * level(g) + level(b)
# a run of these is width-1 cells only: no wcwidth, no wide/combining cases
PLAIN_RUN = re.compile("[ -~]+")
# linefeed marks an autowrap (soft wrap), so copy can re-join the halves
Char = namedtuple(
"Char",
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
self._sgr_cache = {}
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run)
# one char -> Char cache per attribute set; attrs recur constantly in
# coloured output, so the caches are kept rather than rebuilt on a change
self._char_caches = {}
self._char_cache = {}
self._cache_attrs = None
self.history = deque(maxlen=history)
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
"""RIS: reset the screen along with the cursor, scrollback and caches"""
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
self._sgr_cache.clear()
self._char_caches.clear()
self._cache_attrs = None
super().reset()
self.cursor = Cursor(0, 0)
self.cursor_style = 0
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
cursor = self.cursor
buffer = self.buffer
columns = self.columns
# per-run cache: cells differ only by their data while attrs are unchanged
if cursor.attrs is not self._cache_attrs:
self._cache_attrs = cursor.attrs
self._char_cache = {}
attrs = cursor.attrs
# cells differ only by their data within one attribute set; the cache is
# picked per set, not discarded, or colour changes would thrash it
if attrs is not self._cache_attrs:
self._cache_attrs = attrs
cache = self._char_caches.get(attrs)
if cache is None:
if len(self._char_caches) > 256:
self._char_caches.clear()
cache = self._char_caches[attrs] = {}
self._char_cache = cache
cache = self._char_cache
if autowrap and not insert_mode and PLAIN_RUN.fullmatch(data):
# every cell is width 1, so wraps are pure arithmetic: fill whole
# rows with C-speed dict.update instead of looping per char
for c in set(data):
if c not in cache:
cache[c] = attrs._replace(data=c)
get = cache.__getitem__
dirty = self.dirty
pos = 0
length = len(data)
while pos < length:
x = cursor.x
if x == columns:
line = buffer[cursor.y]
last = line[columns - 1]
line[columns - 1] = last._replace(linefeed=True)
dirty.add(cursor.y)
self.carriage_return()
self.linefeed()
x = 0
line = buffer[cursor.y]
take = min(columns - x, length - pos)
end = pos + take
line.update(zip(range(x, x + take), map(get, data[pos:end])))
dirty.add(cursor.y)
cursor.x = x + take
pos = end
return
line = buffer[cursor.y]
for char in data:
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
self.insert_characters(char_width)
cell = cache.get(char)
if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char)
cell = cache[char] = attrs._replace(data=char)
line[cursor.x] = cell
if char_width == 2 and cursor.x + 1 < columns:
filler = cache.get("")
if filler is None:
filler = cache[""] = cursor.attrs._replace(data="")
filler = cache[""] = attrs._replace(data="")
line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the
# last column of the previous row
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
break
if char_width > 0:
self.cursor.x = min(self.cursor.x + char_width, self.columns)
x = cursor.x + char_width
cursor.x = x if x < columns else columns
self.dirty.add(self.cursor.y)
self.dirty.add(cursor.y)
def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""

View File

@@ -105,8 +105,7 @@ def _modifier_code(ctrl, alt, shift):
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
modify_other_keys=0):
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
"""Escape sequence for a key name and its modifiers, honouring the app's active modes"""
key_lo = key.lower() if len(key) > 1 else key

View File

@@ -27,6 +27,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
# background — via the renderer tick: a render here would race the
# renderer thread, which owns the emulator
terminal.pending_render = True
terminal.wake()
view.run_command("vterm_show_cursor", {"scroll": False})
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
# the plugin host restarted; this tab's terminal is unreachable

View File

@@ -8,14 +8,12 @@ notches and take the path below.
import sublime_plugin
from .terminal import Terminal
from .utils import settings
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64
WHEEL_DOWN = 65
# lines per wheel tick, as iTerm2 delivers per notch
WHEEL_LINES = 3
def send_scroll(terminal, up, lines, cell=None):
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
@@ -105,7 +103,8 @@ 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)
send_scroll(terminal, up, WHEEL_LINES, cell)
lines = max(0, int(settings().get("wheel_lines", 3)))
send_scroll(terminal, up, lines, cell)
def want_event(self):
"""Ask sublime for the wheel coordinates."""

View File

@@ -15,7 +15,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
@@ -124,6 +123,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def __init__(self, *args, **kwargs):
"""Set up this view's render caches."""
super().__init__(*args, **kwargs)
# the terminal's current SchemeManager; set at the top of each run()
self._scheme = None
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
@@ -156,6 +157,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(view.id())
if not terminal:
return
self._scheme = terminal.scheme
screen = terminal.screen
# the caret and the tab name are the user's; an unfocused terminal leaves
@@ -316,11 +318,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
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(scheme_manager.scope(fg, bg, style), []).append((start, end))
by_scope.setdefault(scope_of(fg, bg, style), []).append((start, end))
text = "".join(s[0] for s in segments)
if lf:
@@ -380,6 +383,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
grouped = {}
stale = []
scope_of = self._scheme.scope
for row in rows:
keys = self.colored_lines.pop(row, None)
if not keys:
@@ -391,7 +395,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
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())
@@ -410,9 +414,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
Without this the view keeps a full screen of blank rows below a short
prompt, and the scrollbar reflects them.
Both are skipped on the alternate screen, where the app owns every row:
one that blanks and refills - vim's showcmd corner, on every keypress -
would resize the view under a viewport pinned to its bottom, and the
whole screen shifts by the part of a row the viewport leaves over.
"""
view = self.view
screen = terminal.screen
if screen.alternate_buffer_mode:
return
cursor = screen.cursor
cursor_row = terminal.offset + cursor.y
lastrow = view.rowcol(view.size())[0]
@@ -513,11 +524,10 @@ def following(view, terminal):
def bottom_y(view, terminal):
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
"""Viewport y that pins the bottom of the view to the bottom of the viewport."""
# layout_extent is maintained incrementally, unlike text_to_layout(size())
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
return max(offset_y, viewport_y)
return max(0, view.layout_extent()[1] - view.viewport_extent()[1])
def scroll_to_cursor(view, terminal):

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

@@ -13,7 +13,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__)
@@ -24,12 +23,24 @@ RENDER_PERIOD = 0.03
# draw rate for a visible terminal the user is not typing in, ~15 fps
UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames
FEED_LIMIT = 256 * 1024
# max chars parsed per tick, so a giant burst still renders intermediate frames.
FEED_LIMIT = 48 * 1024
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25
# extra wait once a tick finds nothing pending, on top of RENDER_PERIOD.
IDLE_TICK = 0.2
# pause before the first frame after an idle park, so the writes of one app
# update land together: vim splits every keypress into two writes a fraction of
# a millisecond apart, and drawing between them flashes its showcmd echo
SETTLE = 0.005
# 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
def view_size(view, default=None):
"""Terminal (rows, columns) that fit the view's viewport."""
@@ -86,9 +97,10 @@ 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
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True
self.default_title = ""
@@ -116,6 +128,12 @@ class Terminal:
self.pending_render = False
# a UTF-8 sequence split across read chunks must not be dropped
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
# set by the reader (and the UI thread) to end the renderer's idle park
self._wake = threading.Event()
def wake(self):
"""Cut short the renderer's idle wait, for a flag set from another thread"""
self._wake.set()
@classmethod
def from_id(cls, vid):
@@ -180,12 +198,11 @@ class Terminal:
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()
@@ -193,17 +210,25 @@ 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
scheme_manager.remove_listener(self.apply_scheme)
self._wake.set()
try:
self.process.terminate()
except Exception:
@@ -254,11 +279,13 @@ class Terminal:
except EOFError:
break
chunks.append(self._decoder.decode(raw))
self._wake.set()
if done[0] or not self.is_hosted():
LOG.debug("[reader] stops")
break
self.alive = False
done[0] = True
self._wake.set()
def feed_data():
"""Parse up to FEED_LIMIT of pending output into the screen."""
@@ -278,11 +305,13 @@ class Terminal:
def renderer():
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
frames = 0
while True:
# a hidden tab is not drawn at all: the screen keeps the state
# and one render on unhide catches the view up. The tick stays at
# full rate either way, or the parser would fall behind the pty
# and one render on unhide catches the view up.
visible = self.is_visible()
self._wake.clear()
with intermission(period=RENDER_PERIOD):
feed_data()
if self.screen.in_sync_frame():
@@ -295,11 +324,26 @@ class Terminal:
# due first: _need_to_render consumes the cursor move it reports
if self.is_render_due() and self._need_to_render():
self.view.run_command("vterm_render")
frames += 1
if frames >= UNDO_CLEAR_FRAMES:
frames = 0
self.view.run_command("vterm_clear_undo_stack")
self._sync_wheel_flag()
if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops")
break
# nothing left to do: park until woken, instead of spinning at
# RENDER_PERIOD. A sync frame or a teardown skipped this by
# leaving the block above, and keeps its own cadence.
# dirty rows only count while visible: nothing clears them for a
# hidden tab, and on_activated wakes us with pending_render
if not (
chunks or self.pending_render or self.pending_clear or self.pending_clear_screen or (visible and self.screen.dirty)
):
if self._wake.wait(IDLE_TICK):
time.sleep(SETTLE)
while chunks:
feed_data()
done[0] = True

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)