15 Commits

Author SHA1 Message Date
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
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 1202 additions and 601 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 ## 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/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/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/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/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`). | | `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. | | `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. | | `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
| `vterm/mouse.py` | Wheel and click handling. | | `vterm/mouse.py` | Wheel and click handling. |
@@ -73,10 +52,11 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
`Char` carrying the extra `linefeed` field. `Char` carrying the extra `linefeed` field.
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
generated scheme file and the mapping from colour combination to scope name. generated scheme file and the mapping from colour combination to scope name.
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per - **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk `TextCommand` instance whenever any plugin loads, so the view's render state
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and (`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
`_lastrow` are that view's render state. bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
### Commands ### Commands
@@ -100,293 +80,6 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
`VtermMouseEventListener` (`mouse.py`). `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 ## Install
Symlink this folder into Sublime packages as `vterm`: ### With Package Control
```sh vterm is distributed through the shared channel at
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm" [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 Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was hides every default package. Then run **Package Control: Install Package**
never installed), run **Package Control: Satisfy Libraries** and restart Sublime. from the Command Palette and pick **vterm**. Upgrades arrive through
Package Control garbage-collects libraries no package requires, so if imports **Package Control: Upgrade Package** like any other package.
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
### 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 ## 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). what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
for Sublime Text and it arrives as wheel notches like any other. 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 alternate screen the scrollback above it is folded away, kitty-style, and
unfolds when the app leaves it. unfolds when the app leaves it.
@@ -73,11 +104,12 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
colours: colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`, - `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with `"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
*vterm: Select Theme* or by hand; either way open terminals restyle in place, default for terminals opened from now on. *vterm: Select Theme* retints the
no restart needed. 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"}`, - `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 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 `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 - Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
plugin (sublime_text#1838), so set a utility such as plugin (sublime_text#1838), so set a utility such as
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for [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, one, the trackpad does nothing in a full-screen app; the mouse wheel,
`shift+pageup/pagedown` and `shift+up/down` work regardless. `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/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 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 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 `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 logging
import sublime
# imported for sublime's command discovery, not called here # imported for sublime's command discovery, not called here
from .vterm.commands import ( from .vterm.commands import (
VtermClearScreenCommand, VtermClearScreenCommand,
@@ -28,7 +30,7 @@ from .vterm.mouse import (
VtermMouseEventListener, VtermMouseEventListener,
VtermScrollCommand, VtermScrollCommand,
) )
from .vterm.scheme import scheme_manager from .vterm.scheme import build_startup_schemes
from .vterm.utils import settings from .vterm.utils import settings
from .vterm.render import ( from .vterm.render import (
VtermRenderCommand, VtermRenderCommand,
@@ -37,9 +39,13 @@ from .vterm.render import (
def plugin_loaded(): def plugin_loaded():
"""Write the colour scheme and set the log level, once per plugin host.""" """Write any missing theme's colour scheme and set the log level, once per
# synchronous: a restored tab's color_scheme is read during startup plugin host: a restored tab's color_scheme is read during startup and may
scheme_manager.ensure_file() 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 level = logging.DEBUG if settings().get("debug", False) else logging.INFO
logging.basicConfig() 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(): def version():
return "4200" return "4200"
def active_window():
return None

View File

@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
import sublime import sublime
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
from vterm.emulator import VtScreen
from vterm.render import CONTINUATION 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.utils import SETTINGS_FILE
from vterm.themes import THEMES from vterm.themes import THEMES
@@ -13,72 +16,113 @@ settings = sublime.load_settings(SETTINGS_FILE)
panel = {} 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: 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): def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done, panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight) selected_index=selected_index, on_highlight=on_highlight)
def open_picker(current): def fake_terminal(theme_name):
settings.set("theme", current) """A terminal frozen to a theme, registered the way a real one is."""
command = VtermSelectThemeCommand(FakeWindow()) 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() command.run()
return command return command
# every theme is offered, with "no theme" first # 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 [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0 assert panel["selected_index"] == 0
# the configured theme starts highlighted # the focused terminal's own theme starts highlighted, not the global default
command = open_picker("pro") settings.set("theme", "homebrew")
terminal = fake_terminal("pro")
command = open_picker(terminal)
assert panel["selected_index"] == command.choices.index("pro") 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) saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate")) 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" 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) panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme") assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
# confirming persists # confirming retheme the focused terminal and persists the default for new terminals
command = open_picker("pro") command = open_picker(terminal)
panel["on_done"](command.choices.index("homebrew")) 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 settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE 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 # "no theme" persists as null rather than a name
command = open_picker("slate") command = open_picker(fake_terminal("slate"))
panel["on_done"](0) panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme") assert settings.get("theme") is None, settings.get("theme")
print("theme picker tests passed ({} themes offered)".format(len(THEMES))) print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen # a palette change must reach the screen it was applied to: it answers OSC 10/11
# answers OSC 10/11 and DSR 996 from these values # and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream from vterm.emulator import VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
recorder = FakeProcess() recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start() terminal = fake_terminal("default")
terminal = Terminal(None)
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000", terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False) bg_color="ffffff", is_dark=False)
terminal.process = recorder terminal.process = recorder
stream = VtStream(terminal.screen) stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee" terminal.scheme = get_scheme_manager("slate")
scheme_manager.background = "#111111"
terminal.apply_scheme() terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
assert terminal.screen.theme_is_dark is True assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
# without mode 2031 the app is not interrupted # without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written) 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 # and the emulator answers a colour query with the new palette
recorder.written = "" recorder.written = ""
stream.feed("\x1b]11;?\x07") 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)") 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 # 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 import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME) scheme = get_scheme_manager("pro")
if os.path.exists(path): path = os.path.join(scheme._directory(), scheme.file_name)
os.remove(path) assert os.path.exists(path), "get_scheme_manager did not write the scheme"
scheme_manager.ensure_file() # the cached instance is reused, not rebuilt
assert os.path.exists(path), "ensure_file did not write the scheme" assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# a second call is a no-op: same file, untouched # build is idempotent
stamp = os.path.getmtime(path) with scheme.lock:
scheme_manager.ensure_file() first = len(scheme.rules)
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme" scheme.build()
assert len(scheme.rules) == first, \
# _build is idempotent, which is what makes the ensure_file + initialize pair safe "rules duplicated: {} -> {}".format(first, len(scheme.rules))
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))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first)) 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" 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") load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules # a theme's scheme manager produces scopes and rules
import vterm.scheme as scheme_module from vterm.scheme import get_scheme_manager
from vterm.scheme import scheme_manager scope = get_scheme_manager("default").scope("ff0000", "default", "b")
scope = scheme_manager.scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the # 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 # every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing # 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 assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
for module in (commands, render, terminal_module): for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f: 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") configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's # plugin_loaded must create every theme's colour scheme: sublime loads a restored
# color_scheme during startup, before any terminal opens # 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 - # 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 # 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 = types.ModuleType("vterm")
pkg.__path__ = [ROOT] pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main") 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() main.plugin_loaded()
assert os.path.exists(scheme_file), \ for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
"plugin_loaded must leave the colour scheme on disk" 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, " print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES))) "{} themes)".format(len(keymap), len(flags), len(THEMES)))

View File

@@ -12,11 +12,13 @@ from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.render import ( from vterm.render import (
CHUNK, CHUNK,
RenderState,
VtermRenderCommand, VtermRenderCommand,
focus_cursor, focus_cursor,
following, following,
scroll_to_cursor, scroll_to_cursor,
) )
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE from vterm.utils import SETTINGS_FILE
@@ -125,7 +127,9 @@ class FakeView:
return self.viewport return self.viewport
def set_viewport_position(self, xy, animate=True): 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): def line_height(self):
return LINE_HEIGHT return LINE_HEIGHT
@@ -155,7 +159,7 @@ class FakeView:
def make_terminal(rows=10, cols=20, scrollback=None): def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start() # 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: if scrollback is not None:
terminal.scrollback_size = scrollback terminal.scrollback_size = scrollback
terminal.process = FakeProcess() terminal.process = FakeProcess()
@@ -163,9 +167,14 @@ def make_terminal(rows=10, cols=20, scrollback=None):
return terminal, VtStream(terminal.screen) return terminal, VtStream(terminal.screen)
def make_command(view): def make_command(terminal):
# the real __init__, so the fake cannot drift from the command's attributes # the real __init__, so the fake cannot drift from the command's attributes;
return VtermRenderCommand(view) # 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 # an app on the alternate screen must not have its view trimmed away: the
@@ -176,7 +185,7 @@ assert terminal.screen.alternate_buffer_mode
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque" assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10))) 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 before = terminal.view.text
command.trim_history(None, terminal) command.trim_history(None, terminal)
assert terminal.view.text == before, \ assert terminal.view.text == before, \
@@ -186,7 +195,7 @@ assert terminal.view.text == before, \
terminal, stream = make_terminal(rows=5, scrollback=20) terminal, stream = make_terminal(rows=5, scrollback=20)
terminal.offset = 40 terminal.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40))) terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal.view) command = make_command(terminal)
command.trim_history(None, terminal) command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1 rows_left = terminal.view.text.count("\n") + 1
assert rows_left <= 20, rows_left assert rows_left <= 20, rows_left
@@ -197,11 +206,29 @@ assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
terminal, stream = make_terminal(rows=5, scrollback=5000) terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3 terminal.offset = 3
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8))) 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) command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
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)") print("render tests passed (alt screen keeps its view, cap honoured)")
@@ -241,7 +268,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
# batches the runs that share a scope into one add_regions call # batches the runs that share a scope into one add_regions call
terminal, stream = make_terminal(rows=3, cols=20) terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView() terminal.view = FakeView()
command = make_command(terminal.view) command = make_command(terminal)
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen") stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
command.update_lines(None, terminal) command.update_lines(None, terminal)
@@ -258,7 +285,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
assert len(painted) == 2, painted assert len(painted) == 2, painted
# keys are unique per view and recorded so decolorize_line can erase them # keys are unique per view and recorded so decolorize_line can erase them
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \ assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
"colored_lines must track exactly the keys that were added" "colored_lines must track exactly the keys that were added"
command.decolorize_line(0) command.decolorize_line(0)
assert terminal.view.regions == {}, "decolorize must erase every key it added" assert terminal.view.regions == {}, "decolorize must erase every key it added"
@@ -266,12 +293,35 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)") print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
# bookkeeping lives on the terminal so a rebuilt command still erases what the
# old one painted, and its keys never collide with keys still in use
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal)
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
assert terminal.view.regions, "the red row must be painted"
# a reload: new command, same terminal
reloaded = VtermRenderCommand(terminal.view)
reloaded._scheme = terminal.scheme
reloaded.state = terminal.render_state
stream.feed("\x1b[H\x1b[2Kgone")
reloaded.update_lines(None, terminal)
assert terminal.view.regions == {}, \
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
print("reload tests passed (render state survives a rebuilt command)")
# rows that leave the screen have their colours merged onto shared per-(scope, # rows that leave the screen have their colours merged onto shared per-(scope,
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for # chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
# the whole session, and merging every frame would re-send the whole chunk # the whole session, and merging every frame would re-send the whole chunk
terminal, stream = make_terminal(rows=3, cols=20) terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView() terminal.view = FakeView()
command = make_command(terminal.view) command = make_command(terminal)
for i in range(300): for i in range(300):
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i)) stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
command.update_lines(None, terminal) command.update_lines(None, terminal)
@@ -290,14 +340,14 @@ assert len(row_keys) == 300 - CHUNK, \
# trimming settles the open chunk, then erases the keys of chunks above the cut # trimming settles the open chunk, then erases the keys of chunks above the cut
terminal, stream = make_terminal(rows=3, scrollback=30) terminal, stream = make_terminal(rows=3, scrollback=30)
terminal.view = FakeView() 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))) stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
command.update_lines(None, terminal) command.update_lines(None, terminal)
terminal.screen.dirty.clear() terminal.screen.dirty.clear()
assert set(command._archived_chunks) == {0}, command._archived_chunks assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
command.trim_history(None, terminal) command.trim_history(None, terminal)
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind" assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
assert 0 not in command._archived_chunks assert 0 not in command.state.archived_chunks
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive" assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)") print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
@@ -312,7 +362,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
Terminal._terminals[id(terminal)] = terminal Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal) terminal.view.id = lambda: id(terminal)
terminal.default_title = "zsh" terminal.default_title = "zsh"
command = make_command(terminal.view) command = make_command(terminal)
stream.feed("[vterm] %") stream.feed("[vterm] %")
command.run(None) command.run(None)
assert all(r.empty() for r in terminal.view.sel()), \ assert all(r.empty() for r in terminal.view.sel()), \
@@ -339,10 +389,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
print("render diff tests passed (unchanged dirty rows skipped)") print("render diff tests passed (unchanged dirty rows skipped)")
# the generated scheme must define a selection colour, or sublime uses blue # the generated scheme must define a selection colour, or sublime uses blue
from vterm.scheme import blend, scheme_manager from vterm.scheme import blend
scheme_manager.background = "#303841" assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
scheme_manager.foreground = "#d8dee9"
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
print("selection artefact tests passed (fresh prompt unselected, user selection kept)") print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
@@ -350,7 +398,7 @@ print("selection artefact tests passed (fresh prompt unselected, user selection
# DECSCUSR drives the caret shape through view settings; before any app asks, # DECSCUSR drives the caret shape through view settings; before any app asks,
# and for 0, the caret is the terminal default: a blinking block # and for 0, the caret is the terminal default: a blinking block
terminal, stream = make_terminal(rows=3, cols=20) terminal, stream = make_terminal(rows=3, cols=20)
command = make_command(terminal.view) command = make_command(terminal)
settings = terminal.view.settings() settings = terminal.view.settings()
command.sync_cursor_style(terminal) command.sync_cursor_style(terminal)
@@ -522,6 +570,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)") 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 # 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 # form once the app turned modifyOtherKeys on, which it reaches for because a
# bare ESC CR reads as alt+enter # bare ESC CR reads as alt+enter
@@ -584,7 +649,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n") terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
terminal.offset = 3 terminal.offset = 3
terminal.viewport_y = 400 terminal.viewport_y = 400
command = make_command(terminal.view) command = make_command(terminal)
fold_end = terminal.view.text_point(3, 0) - 1 fold_end = terminal.view.text_point(3, 0) - 1
stream.feed("\x1b[?1049h") stream.feed("\x1b[?1049h")
@@ -612,7 +677,7 @@ assert terminal.view.folds == [], terminal.view.folds
# without scrollback there is nothing to fold # without scrollback there is nothing to fold
terminal, stream = make_terminal(rows=5, cols=20) terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n") terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view) command = make_command(terminal)
stream.feed("\x1b[?1049h") stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal) command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds assert terminal.view.folds == [], terminal.view.folds

View File

@@ -15,11 +15,16 @@
// null sends the keys to the app // null sends the keys to the app
"scroll_modifier": "shift", "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, "min_columns": 20,
"max_columns": 500, "max_columns": 500,
// colour palette: "default", "homebrew", "pro", "slate", or null to // colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
// follow the active Sublime colour scheme. Applies to open tabs too. // 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, "theme": null,
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"} // override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}

View File

@@ -7,9 +7,9 @@ import sublime
import sublime_plugin import sublime_plugin
from .render import CONTINUATION, scroll_view from .render import CONTINUATION, scroll_view
from .scheme import SCHEME_PATH, scheme_manager from .scheme import get_scheme_manager
from .terminal import Terminal from .terminal import Terminal
from .themes import THEMES from .themes import ALL_THEME_NAMES, THEMES
from .utils import SETTINGS_FILE, settings from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -43,12 +43,12 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
cwd = os.path.expanduser("~") cwd = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables()) cwd = sublime.expand_variables(cwd, window.extract_variables())
scheme_manager.initialize(window.active_view()) scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view())
view = window.new_file() 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: try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0])) terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception: except Exception:
@@ -106,7 +106,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
class VtermInitializeViewCommand(sublime_plugin.TextCommand): class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""Stamps a fresh view with the settings that make it a terminal.""" """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.""" """Apply the view settings; the pty starts only after this."""
view = self.view view = self.view
view_settings = view.settings() view_settings = view.settings()
@@ -125,7 +125,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
view_settings.set("draw_indent_guides", False) view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", False) view_settings.set("scroll_past_end", False)
view_settings.set("scroll_context_lines", 0) 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("syntax", "Packages/vterm/vterm.sublime-syntax")
view_settings.set("spell_check", False) view_settings.set("spell_check", False)
view_settings.set("detect_indentation", False) view_settings.set("detect_indentation", False)
@@ -225,13 +225,19 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
class VtermSelectThemeCommand(sublime_plugin.WindowCommand): 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): def run(self):
"""Show the palette picker, starting on the configured theme.""" """Show the palette picker, starting on the focused terminal's own theme."""
self.settings = settings() self.terminal = self._focused_terminal()
self.original = self.settings.get("theme") if not self.terminal:
self.choices = [None] + sorted(THEMES) return
self.original = self.terminal.scheme.theme_name
self.choices = ALL_THEME_NAMES
items = [["No theme", "follow the Sublime color scheme"]] items = [["No theme", "follow the Sublime color scheme"]]
for name in self.choices[1:]: 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 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) 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): def _preview(self, index):
"""Apply a palette while it is highlighted, without saving it.""" """Apply a palette to the focused terminal while it is highlighted."""
self.settings.set("theme", self.choices[index]) self._apply(self.choices[index])
def _done(self, 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: if index == -1:
self.settings.set("theme", self.original) self._apply(self.original)
return return
self.settings.set("theme", self.choices[index]) name = self.choices[index]
self._apply(name)
settings().set("theme", name)
sublime.save_settings(SETTINGS_FILE) sublime.save_settings(SETTINGS_FILE)
@@ -264,6 +290,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
return return
# the renderer thread owns the screen; it applies this on its next tick # the renderer thread owns the screen; it applies this on its next tick
terminal.pending_clear_screen = True terminal.pending_clear_screen = True
terminal.wake()
self.view.run_command("vterm_clear_undo_stack") self.view.run_command("vterm_clear_undo_stack")
def is_enabled(self): 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) 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 # linefeed marks an autowrap (soft wrap), so copy can re-join the halves
Char = namedtuple( Char = namedtuple(
"Char", "Char",
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params) # SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
self._sgr_cache = {} 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._char_cache = {}
self._cache_attrs = None self._cache_attrs = None
self.history = deque(maxlen=history) self.history = deque(maxlen=history)
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
"""RIS: reset the screen along with the cursor, scrollback and caches""" """RIS: reset the screen along with the cursor, scrollback and caches"""
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
self._sgr_cache.clear() self._sgr_cache.clear()
self._char_caches.clear()
self._cache_attrs = None
super().reset() super().reset()
self.cursor = Cursor(0, 0) self.cursor = Cursor(0, 0)
self.cursor_style = 0 self.cursor_style = 0
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
cursor = self.cursor cursor = self.cursor
buffer = self.buffer buffer = self.buffer
columns = self.columns columns = self.columns
# per-run cache: cells differ only by their data while attrs are unchanged attrs = cursor.attrs
if cursor.attrs is not self._cache_attrs: # cells differ only by their data within one attribute set; the cache is
self._cache_attrs = cursor.attrs # picked per set, not discarded, or colour changes would thrash it
self._char_cache = {} 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 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] line = buffer[cursor.y]
for char in data: for char in data:
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
self.insert_characters(char_width) self.insert_characters(char_width)
cell = cache.get(char) cell = cache.get(char)
if cell is None: if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char) cell = cache[char] = attrs._replace(data=char)
line[cursor.x] = cell line[cursor.x] = cell
if char_width == 2 and cursor.x + 1 < columns: if char_width == 2 and cursor.x + 1 < columns:
filler = cache.get("") filler = cache.get("")
if filler is None: if filler is None:
filler = cache[""] = cursor.attrs._replace(data="") filler = cache[""] = attrs._replace(data="")
line[cursor.x + 1] = filler line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the # a combining mark belongs to the cell before it, which may be the
# last column of the previous row # last column of the previous row
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
break break
if char_width > 0: 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): def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback.""" """Line feed; at the bottom of the primary screen the top row goes to scrollback."""

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) 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, def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
modify_other_keys=0):
"""Escape sequence for a key name and its modifiers, honouring the app's active modes""" """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 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 # background — via the renderer tick: a render here would race the
# renderer thread, which owns the emulator # renderer thread, which owns the emulator
terminal.pending_render = True terminal.pending_render = True
terminal.wake()
view.run_command("vterm_show_cursor", {"scroll": False}) view.run_command("vterm_show_cursor", {"scroll": False})
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"): elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
# the plugin host restarted; this tab's terminal is unreachable # 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 import sublime_plugin
from .terminal import Terminal from .terminal import Terminal
from .utils import settings
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction # SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64 WHEEL_UP = 64
WHEEL_DOWN = 65 WHEEL_DOWN = 65
# lines per wheel tick, as iTerm2 delivers per notch
WHEEL_LINES = 3
def send_scroll(terminal, up, lines, cell=None): def send_scroll(terminal, up, lines, cell=None):
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen. """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 cell = None
if terminal.screen.mouse_reporting_enabled() and event: if terminal.screen.mouse_reporting_enabled() and event:
cell = _event_to_cell(self.view, terminal, event, clamp=True) cell = _event_to_cell(self.view, terminal, event, clamp=True)
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): def want_event(self):
"""Ask sublime for the wheel coordinates.""" """Ask sublime for the wheel coordinates."""

View File

@@ -15,7 +15,6 @@ from wcwidth import wcswidth
# per-cell call in segment_buffer_line # per-cell call in segment_buffer_line
cell_width = lru_cache(maxsize=4096)(wcswidth) cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal from .terminal import Terminal
from .utils import rev_wcwidth from .utils import rev_wcwidth
@@ -117,32 +116,49 @@ def ensure_position(view, edit, row, col=0):
view.insert(edit, line_region.end(), " " * (col - lastcol)) view.insert(edit, line_region.end(), " " * (col - lastcol))
class VtermRenderCommand(sublime_plugin.TextCommand): class RenderState:
"""Draws the screen into the view. Sublime keeps one instance per view, so """A view's render bookkeeping, kept on its Terminal.
the caches below are that view's render state."""
def __init__(self, *args, **kwargs): Sublime rebuilds every TextCommand instance of every view whenever any
"""Set up this view's render caches.""" plugin module loads. State held on the command is lost then: the overlays
super().__init__(*args, **kwargs) already in the view become untracked, so a row rewrite no longer erases
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 = {} self.colored_lines = {}
# view row -> (segments, lf) as last rendered; the dirty set is only a # view row -> (segments, lf) as last rendered; the dirty set is only a
# repaint hint, so most dirty rows are identical frame to frame # repaint hint, so most dirty rows are identical frame to frame
self._row_cache = {} self.row_cache = {}
self._key_counter = 0 self.key_counter = 0
# chunk ordinal -> scopes it holds a key for; ordinals are view row + # chunk ordinal -> scopes it holds a key for; ordinals are view row +
# rows trimmed so far, so they survive trim_history renumbering # rows trimmed so far, so they survive trim_history renumbering
self._archived_chunks = {} self.archived_chunks = {}
# rows waiting for their chunk to close, and the chunk they belong to # rows waiting for their chunk to close, and the chunk they belong to
self._open_rows = [] self.open_rows = []
self._open_chunk = None self.open_chunk = None
self._trimmed = 0 self.trimmed = 0
class VtermRenderCommand(sublime_plugin.TextCommand):
"""Draws the screen into the view. Holds nothing a frame depends on: the
view's bookkeeping is `terminal.render_state` (see RenderState)."""
def __init__(self, *args, **kwargs):
"""Per-instance scratch only; the render state is bound in run()."""
super().__init__(*args, **kwargs)
# the terminal's current SchemeManager and RenderState; set at the top of each run()
self._scheme = None
self.state = None
self._lastrow = 0 self._lastrow = 0
self._applied_cursor_style = None self._applied_cursor_style = None
def next_key(self): def next_key(self):
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows""" """A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
self._key_counter += 1 self.state.key_counter += 1
return "vterm#{}".format(self._key_counter) return "vterm#{}".format(self.state.key_counter)
def ensure_row(self, edit, row): def ensure_row(self, edit, row):
"""Row-existence check for the hot path, tracking the last row in python""" """Row-existence check for the hot path, tracking the last row in python"""
@@ -156,6 +172,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(view.id()) terminal = Terminal.from_id(view.id())
if not terminal: if not terminal:
return return
self._scheme = terminal.scheme
if terminal.render_state is None:
terminal.render_state = RenderState()
self.state = terminal.render_state
screen = terminal.screen screen = terminal.screen
# the caret and the tab name are the user's; an unfocused terminal leaves # the caret and the tab name are the user's; an unfocused terminal leaves
@@ -254,16 +274,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
screen = terminal.screen screen = terminal.screen
view.replace(edit, sublime.Region(0, view.size()), "") view.replace(edit, sublime.Region(0, view.size()), "")
for line in list(self.colored_lines): for line in list(self.state.colored_lines):
self.decolorize_line(line) self.decolorize_line(line)
for chunk, scopes in self._archived_chunks.items(): for chunk, scopes in self.state.archived_chunks.items():
for scope in scopes: for scope in scopes:
view.erase_regions(chunk_key(chunk, scope)) view.erase_regions(chunk_key(chunk, scope))
self._archived_chunks.clear() self.state.archived_chunks.clear()
self._open_rows = [] self.state.open_rows = []
self._open_chunk = None self.state.open_chunk = None
self._trimmed = 0 self.state.trimmed = 0
self._row_cache.clear() self.state.row_cache.clear()
terminal.offset = 0 terminal.offset = 0
screen.history.clear() screen.history.clear()
if wipe_screen: if wipe_screen:
@@ -310,17 +330,18 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# the dirty set is a repaint hint, not a diff: most dirty rows come # the dirty set is a repaint hint, not a diff: most dirty rows come
# back identical, and rewriting one costs an edit plus its region churn # back identical, and rewriting one costs an edit plus its region churn
rendered = (segments, lf) rendered = (segments, lf)
if self._row_cache.get(line) == rendered: if self.state.row_cache.get(line) == rendered:
return return
self._row_cache[line] = rendered self.state.row_cache[line] = rendered
by_scope = {} by_scope = {}
needed = 0 needed = 0
scope_of = self._scheme.scope
for _, start, end, fg, bg, style in segments: for _, start, end, fg, bg, style in segments:
if is_default_run(fg, bg, style): if is_default_run(fg, bg, style):
continue continue
needed = max(needed, end) 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) text = "".join(s[0] for s in segments)
if lf: if lf:
@@ -340,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def colorize_line(self, line, line_start, by_scope): def colorize_line(self, line, line_start, by_scope):
"""Lay the colour overlays for one row, one add_regions per scope.""" """Lay the colour overlays for one row, one add_regions per scope."""
view = self.view view = self.view
keys = self.colored_lines.setdefault(line, []) if by_scope else None keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
for scope, spans in by_scope.items(): for scope, spans in by_scope.items():
key = self.next_key() key = self.next_key()
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope) view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
@@ -348,21 +369,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def decolorize_line(self, line): def decolorize_line(self, line):
"""Drop a row's colour overlays before it is rewritten or erased.""" """Drop a row's colour overlays before it is rewritten or erased."""
if line in self.colored_lines: if line in self.state.colored_lines:
for key in self.colored_lines[line]: for key in self.state.colored_lines[line]:
self.view.erase_regions(key) self.view.erase_regions(key)
del self.colored_lines[line] del self.state.colored_lines[line]
def archive_rows(self, rows): def archive_rows(self, rows):
"""Queue rows that left the screen for their chunk's merge.""" """Queue rows that left the screen for their chunk's merge."""
for row in rows: for row in rows:
if row not in self.colored_lines: if row not in self.state.colored_lines:
continue continue
chunk = (row + self._trimmed) // CHUNK chunk = (row + self.state.trimmed) // CHUNK
if chunk != self._open_chunk: if chunk != self.state.open_chunk:
self.flush_chunk() self.flush_chunk()
self._open_chunk = chunk self.state.open_chunk = chunk
self._open_rows.append(row) self.state.open_rows.append(row)
def flush_chunk(self): def flush_chunk(self):
"""Merge the queued rows onto shared per-(scope, chunk) keys. """Merge the queued rows onto shared per-(scope, chunk) keys.
@@ -373,28 +394,29 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
whole set, so merging every frame would be quadratic in CHUNK. whole set, so merging every frame would be quadratic in CHUNK.
""" """
view = self.view view = self.view
rows, chunk = self._open_rows, self._open_chunk rows, chunk = self.state.open_rows, self.state.open_chunk
self._open_rows = [] self.state.open_rows = []
if not rows: if not rows:
return return
grouped = {} grouped = {}
stale = [] stale = []
scope_of = self._scheme.scope
for row in rows: for row in rows:
keys = self.colored_lines.pop(row, None) keys = self.state.colored_lines.pop(row, None)
if not keys: if not keys:
continue continue
stale += keys stale += keys
# the segments that were painted, straight from the row cache # the segments that were painted, straight from the row cache
segments = self._row_cache[row][0] segments = self.state.row_cache[row][0]
start = view.line(view.text_point(row, 0)).begin() start = view.line(view.text_point(row, 0)).begin()
for _, a, b, fg, bg, style in segments: for _, a, b, fg, bg, style in segments:
if is_default_run(fg, bg, style): if is_default_run(fg, bg, style):
continue continue
scope = scheme_manager.scope(fg, bg, style) scope = scope_of(fg, bg, style)
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b)) grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
scopes = self._archived_chunks.setdefault(chunk, set()) scopes = self.state.archived_chunks.setdefault(chunk, set())
for scope, regions in grouped.items(): for scope, regions in grouped.items():
key = chunk_key(chunk, scope) key = chunk_key(chunk, scope)
if scope in scopes: if scope in scopes:
@@ -410,9 +432,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
Without this the view keeps a full screen of blank rows below a short Without this the view keeps a full screen of blank rows below a short
prompt, and the scrollbar reflects them. 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 view = self.view
screen = terminal.screen screen = terminal.screen
if screen.alternate_buffer_mode:
return
cursor = screen.cursor cursor = screen.cursor
cursor_row = terminal.offset + cursor.y cursor_row = terminal.offset + cursor.y
lastrow = view.rowcol(view.size())[0] lastrow = view.rowcol(view.size())[0]
@@ -420,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
while row > cursor_row: while row > cursor_row:
line_region = view.line(view.text_point(row, 0)) line_region = view.line(view.text_point(row, 0))
text = view.substr(line_region) text = view.substr(line_region)
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]): if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
row = row - 1 row = row - 1
else: else:
break break
@@ -434,7 +463,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end()) trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0: if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
view.erase(edit, trailing_region) view.erase(edit, trailing_region)
self._row_cache.pop(row, None) self.state.row_cache.pop(row, None)
def trim_history(self, edit, terminal): def trim_history(self, edit, terminal):
"""Hold the view to scrollback_size rows, and drop rows below the screen.""" """Hold the view to scrollback_size rows, and drop rows below the screen."""
@@ -448,8 +477,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
m = max(lastrow + 1 - n, math.ceil(n / 10)) m = max(lastrow + 1 - n, math.ceil(n / 10))
for line in range(m): for line in range(m):
self.decolorize_line(line) self.decolorize_line(line)
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()} self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m} self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end()) top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
view.erase(edit, top_region) view.erase(edit, top_region)
terminal.offset -= m terminal.offset -= m
@@ -457,12 +486,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# chunks wholly above the cut go; the boundary chunk drops the # chunks wholly above the cut go; the boundary chunk drops the
# regions the erase collapsed to empty # regions the erase collapsed to empty
self._trimmed += m self.state.trimmed += m
cut = self._trimmed // CHUNK cut = self.state.trimmed // CHUNK
for chunk in [c for c in self._archived_chunks if c < cut]: for chunk in [c for c in self.state.archived_chunks if c < cut]:
for scope in self._archived_chunks.pop(chunk): for scope in self.state.archived_chunks.pop(chunk):
view.erase_regions(chunk_key(chunk, scope)) view.erase_regions(chunk_key(chunk, scope))
for scope in self._archived_chunks.get(cut, ()): for scope in self.state.archived_chunks.get(cut, ()):
key = chunk_key(cut, scope) key = chunk_key(cut, scope)
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope) view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
@@ -471,7 +500,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for line in view.lines(tail_region): for line in view.lines(tail_region):
row = view.rowcol(line.begin())[0] row = view.rowcol(line.begin())[0]
self.decolorize_line(row) self.decolorize_line(row)
self._row_cache.pop(row, None) self.state.row_cache.pop(row, None)
view.erase(edit, tail_region) view.erase(edit, tail_region)
@@ -513,11 +542,10 @@ def following(view, terminal):
def bottom_y(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()) # layout_extent is maintained incrementally, unlike text_to_layout(size())
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1] return max(0, 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)
def scroll_to_cursor(view, terminal): def scroll_to_cursor(view, terminal):

View File

@@ -8,16 +8,12 @@ import sublime
from .emulator import ANSI_COLORS from .emulator import ANSI_COLORS
from .utils import settings from .utils import settings
from .themes import THEMES from .themes import ALL_THEME_NAMES, THEMES
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall # written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm" 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 # each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250 WRITE_DEBOUNCE_MS = 250
@@ -69,89 +65,46 @@ def _is_dark(color_text):
class SchemeManager: 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): def __init__(self, theme_name):
"""Start on the built-in palette; initialize() picks up the real one.""" """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.lock = threading.Lock()
self.rules = {} self.rules = {}
# key -> scope name; the render thread reads it without the lock, so it # 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._names = {}
self.foreground = "#ffffff" self.foreground = "#ffffff"
self.background = "#000000" self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI) self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False self._write_pending = False
self._initialized = False
self._dir = None self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view): def build(self, host_view=None):
"""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):
"""Resolve the palette and regenerate every rule, then write the file. """Resolve the palette and regenerate every rule, then write the file.
Rules cannot be appended after a palette change: each one stores resolved 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._names = {}
self.rules = {} self.rules = {}
self.ansi = dict(DEFAULT_ANSI) self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme")) theme = THEMES.get(self.theme_name)
if theme: if theme:
self.foreground = theme["foreground"] self.foreground = theme["foreground"]
self.background = theme["background"] self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme}) self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else: elif host_view is not None and host_view.is_valid():
# captured values outlive the view they came from style = host_view.style()
if self._host_view is not None and self._host_view.is_valid(): self.foreground = style.get("foreground", self.foreground)
style = self._host_view.style() self.background = style.get("background", self.background)
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
# user overrides sit on top of whichever palette was chosen # user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {}) user_ansi = settings().get("ansi_colors", {})
@@ -228,6 +181,10 @@ class SchemeManager:
rule["font_style"] = " ".join(font_style) rule["font_style"] = " ".join(font_style)
self.rules[(fg, bg, style)] = rule 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): def _directory(self):
"""Path of the generated scheme's directory, created on first use.""" """Path of the generated scheme's directory, created on first use."""
if self._dir is None: if self._dir is None:
@@ -258,11 +215,49 @@ class SchemeManager:
}, },
"rules": list(self.rules.values()), "rules": list(self.rules.values()),
} }
path = os.path.join(self._directory(), SCHEME_FILE_NAME) with open(self.file_path(), "w", encoding="utf-8") as f:
with open(path, "w", encoding="utf-8") as f:
# compact: sublime reparses the whole file on every write # compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":")) json.dump(scheme, f, separators=(",", ":"))
LOG.debug("scheme written: rules=%s", len(self.rules)) 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 .emulator import APP_CURSOR_MODE, VtScreen, VtStream
from .keys import get_key_code from .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__) 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 # draw rate for a visible terminal the user is not typing in, ~15 fps
UNFOCUSED_RENDER_PERIOD = 0.075 UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames # max chars parsed per tick, so a giant burst still renders intermediate frames.
FEED_LIMIT = 256 * 1024 FEED_LIMIT = 48 * 1024
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this # SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25 REPAINT_NUDGE_DELAY = 0.25
# 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): def view_size(view, default=None):
"""Terminal (rows, columns) that fit the view's viewport.""" """Terminal (rows, columns) that fit the view's viewport."""
@@ -86,9 +97,11 @@ class Terminal:
_terminals = {} _terminals = {}
def __init__(self, view): def __init__(self, view, scheme):
"""State only; the pty and the threads come up in start().""" """State only; the pty and the threads come up in start()."""
self.view = view self.view = view
self.scheme = scheme
self.render_state = None
# process.isalive() costs two waitpid syscalls; UI hooks read this instead # process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True self.alive = True
self.default_title = "" self.default_title = ""
@@ -116,6 +129,12 @@ class Terminal:
self.pending_render = False self.pending_render = False
# a UTF-8 sequence split across read chunks must not be dropped # a UTF-8 sequence split across read chunks must not be dropped
self._decoder = codecs.getincrementaldecoder("utf-8")("replace") 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 @classmethod
def from_id(cls, vid): def from_id(cls, vid):
@@ -180,12 +199,11 @@ class Terminal:
erase_scrollback=settings().get("honor_erase_scrollback", False), erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback, clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback, clipboard_callback=self._clipboard_callback,
fg_color=scheme_manager.foreground.lstrip("#"), fg_color=self.scheme.foreground.lstrip("#"),
bg_color=scheme_manager.background.lstrip("#"), bg_color=self.scheme.background.lstrip("#"),
is_dark=scheme_manager.is_dark(), is_dark=self.scheme.is_dark(),
) )
self.stream = VtStream(self.screen) self.stream = VtStream(self.screen)
scheme_manager.add_listener(self.apply_scheme)
self._start_rendering() self._start_rendering()
@@ -193,17 +211,25 @@ class Terminal:
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle""" """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 # the screen answers OSC 10/11 and DSR 996 from these values
screen = self.screen screen = self.screen
screen.default_fg_color = scheme_manager.foreground.lstrip("#") screen.default_fg_color = self.scheme.foreground.lstrip("#")
screen.default_bg_color = scheme_manager.background.lstrip("#") screen.default_bg_color = self.scheme.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark() screen.theme_is_dark = self.scheme.is_dark()
if screen.theme_notify_enabled(): if screen.theme_notify_enabled():
self.send_string(screen.theme_report(), normalized=False) 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): def kill(self):
"""Terminate the child and drop this terminal from the registry.""" """Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process") LOG.debug("[kill] terminating process")
self.alive = False self.alive = False
scheme_manager.remove_listener(self.apply_scheme) self._wake.set()
try: try:
self.process.terminate() self.process.terminate()
except Exception: except Exception:
@@ -254,11 +280,13 @@ class Terminal:
except EOFError: except EOFError:
break break
chunks.append(self._decoder.decode(raw)) chunks.append(self._decoder.decode(raw))
self._wake.set()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[reader] stops") LOG.debug("[reader] stops")
break break
self.alive = False self.alive = False
done[0] = True done[0] = True
self._wake.set()
def feed_data(): def feed_data():
"""Parse up to FEED_LIMIT of pending output into the screen.""" """Parse up to FEED_LIMIT of pending output into the screen."""
@@ -278,11 +306,13 @@ class Terminal:
def renderer(): def renderer():
"""Own the emulator: parse, then ask sublime to redraw, once per tick.""" """Own the emulator: parse, then ask sublime to redraw, once per tick."""
frames = 0
while True: while True:
# a hidden tab is not drawn at all: the screen keeps the state # 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 # and one render on unhide catches the view up.
# full rate either way, or the parser would fall behind the pty
visible = self.is_visible() visible = self.is_visible()
self._wake.clear()
with intermission(period=RENDER_PERIOD): with intermission(period=RENDER_PERIOD):
feed_data() feed_data()
if self.screen.in_sync_frame(): if self.screen.in_sync_frame():
@@ -295,11 +325,26 @@ class Terminal:
# due first: _need_to_render consumes the cursor move it reports # due first: _need_to_render consumes the cursor move it reports
if self.is_render_due() and self._need_to_render(): if self.is_render_due() and self._need_to_render():
self.view.run_command("vterm_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() self._sync_wheel_flag()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops") LOG.debug("[renderer] stops")
break break
# 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: while chunks:
feed_data() feed_data()
done[0] = True done[0] = True

View File

@@ -50,4 +50,27 @@ THEMES = {
"light_cyan": "#8cdfe0", "light_cyan": "#8cdfe0",
"light_white": "#e0e0e0", "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)