Compare commits
14 Commits
2a8fffb9c6
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9656faa385 | ||
|
|
c2a0c75359 | ||
|
|
e6aa754762 | ||
|
|
76927a85ea | ||
|
|
e150591763 | ||
|
|
27f1140737 | ||
|
|
d371d35fb3 | ||
|
|
119d4124f0 | ||
|
|
c8caecdc45 | ||
|
|
6e93ae2a5b | ||
|
|
f37da42a74 | ||
|
|
ca0d76cf5b | ||
|
|
01a88f7b19 | ||
|
|
28c9c398a1 |
312
Architecture.md
312
Architecture.md
@@ -1,24 +1,3 @@
|
||||
# vterm architecture
|
||||
|
||||
How the plugin is put together, for someone about to change it. `README.md` is the
|
||||
user-facing overview; this is the developer reference. Code style lives in `CODE.md`.
|
||||
|
||||
## What it is
|
||||
|
||||
A terminal emulator that runs inside an ordinary Sublime Text tab. There is no custom
|
||||
drawing surface: the emulator's screen is written into the view as **real text**, and
|
||||
colours are region overlays. That single decision explains most of what follows.
|
||||
|
||||
Constraints it works under:
|
||||
|
||||
- **Sublime Text 4**, build 4149+ (`View.folded_regions`), plugin host **Python 3.8**
|
||||
(`.python-version`). The host is a separate process, so every Sublime API call from
|
||||
plugin code is a cross-process round trip — call *count* is what costs.
|
||||
- **macOS only.** The pty layer is posix and the mousemap is `Default (OSX)`.
|
||||
- Third-party code comes from Package Control libraries declared in `dependencies.json`:
|
||||
`pyte` (emulation), `ptyprocess` (pty), `wcwidth` (character widths).
|
||||
- Editing plugin sources needs a **Sublime restart**: submodules are deliberately not
|
||||
purged on reload, because rebuilding the terminal registry would orphan open tabs.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -30,8 +9,8 @@ Constraints it works under:
|
||||
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
|
||||
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
|
||||
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
||||
| `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. |
|
||||
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). |
|
||||
| `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
|
||||
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
|
||||
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
|
||||
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
||||
| `vterm/mouse.py` | Wheel and click handling. |
|
||||
@@ -100,293 +79,6 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
||||
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
|
||||
`VtermMouseEventListener` (`mouse.py`).
|
||||
|
||||
## Data flows
|
||||
|
||||
### Opening a terminal
|
||||
|
||||
`vterm_open` → resolve `cmd` (default `$SHELL -l`) and `cwd` (first project folder →
|
||||
active file's directory → `~`) → `scheme_manager.initialize()` → `window.new_file()` →
|
||||
`vterm_initialize_view` stamps the view settings → `Terminal(view)` → `Terminal.start()`:
|
||||
register in `_terminals`, compute the size from the viewport, spawn the pty, build the
|
||||
`VtScreen`/`VtStream`, subscribe to palette changes, start the two threads. All on the UI
|
||||
thread; a failure closes the view again.
|
||||
|
||||
### Output: a pty byte becomes a visible character
|
||||
|
||||
```
|
||||
child writes
|
||||
│
|
||||
├─ reader thread ──────────────────────────────────────────────
|
||||
│ process.read(8192) blocking read on the pty master
|
||||
│ self._decoder.decode(raw) incremental UTF-8: a sequence split
|
||||
│ across reads must not be corrupted
|
||||
│ chunks.append(text) collections.deque, no lock
|
||||
│
|
||||
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
||||
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
||||
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
||||
│ mutates buffer, fills screen.dirty
|
||||
│ in_sync_frame()? mid ?2026 frame → skip this tick
|
||||
│ is_visible()? hidden tab → draw nothing this tick
|
||||
│ was_resized()? (checked once a second) → resize pty
|
||||
│ is_render_due()? unfocused → at most one frame per
|
||||
│ UNFOCUSED_RENDER_PERIOD
|
||||
│ _need_to_render()? dirty rows, a pending clear, or a
|
||||
│ moved cursor
|
||||
│ view.run_command("vterm_render")
|
||||
│ _sync_wheel_flag() publishes the mousemap gate
|
||||
│
|
||||
└─ UI thread ──────────────────────────────────────────────────
|
||||
VtermRenderCommand.run(edit)
|
||||
update_lines() → drain screen.history above `offset`,
|
||||
then each dirty screen row
|
||||
update_line() → segment_buffer_line() → row-cache check
|
||||
→ view.replace() + add_regions()
|
||||
sync_alt_fold()
|
||||
follow-scroll gate → trim, focus_cursor()*, request_scroll()
|
||||
sync_cursor_style(), set_name()*, dirty.clear()
|
||||
* focused terminals only
|
||||
```
|
||||
|
||||
**Rendering off focus.** A terminal only touches its view the way the user would while it
|
||||
holds focus. Sublime dispatches view events application-wide, and packages hang global
|
||||
state off them: Vintage's `InputStateTracker.on_selection_modified` resets one module-level
|
||||
`g_input_state` for every view in every window, so a caret this plugin moved at 33 fps
|
||||
erased the half-typed `d` of a `dd` in whatever file the user was editing. Sublime drops a
|
||||
pending key sequence over the same kind of traffic, which took `gg` and `ciw` with it.
|
||||
|
||||
Three states, from `Terminal.focused` (set by `on_activated` / `on_deactivated`) and
|
||||
`Terminal.is_visible()` (polled once a second, because selecting another tab in an
|
||||
unfocused group raises no event for this view):
|
||||
|
||||
| state | text | caret, `sel` | tab name | follow-scroll | draw rate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| focused | yes | yes | yes | yes | 33 fps |
|
||||
| visible, unfocused | yes | no | no | yes | 15 fps |
|
||||
| hidden | no | no | no | no | none |
|
||||
|
||||
A frame costs a command dispatch and a dozen UI round trips whatever changed — two
|
||||
`rowcol(size())`, `folded_regions()`, `viewport_position()`, the `trim_trailing_spaces` scan,
|
||||
then `layout_extent()` / `text_to_layout()` / `set_viewport_position()` — so the rate a
|
||||
terminal is drawn at is what the editor pays for it, and only a focused one is worth 33 fps.
|
||||
|
||||
The renderer tick itself stays at `RENDER_PERIOD` in every state: `feed_data()` runs inside it,
|
||||
and throttling that would cap the parser at `FEED_LIMIT` per tick while the reader keeps filling
|
||||
an unbounded `chunks`. Only the drawing is rate-limited. So the emulator is always current and
|
||||
one render on activation catches the view up. An unfocused terminal is left with an empty selection —
|
||||
nothing a render edit can shift, so no selection event of ours escapes — unless the user
|
||||
selected text to copy, which is kept.
|
||||
|
||||
**The view/offset model.** The view holds real text. Rows `0 .. offset-1` are scrollback;
|
||||
rows `offset .. offset + screen.lines - 1` are the live screen. `terminal.offset` is the
|
||||
view row of screen row 0. When the emulator retires lines into `screen.history`, the
|
||||
renderer drains them, advances `offset` by that many rows, and writes them at their now
|
||||
permanent view rows. **Once a line leaves `screen.history`, its only copy is the view
|
||||
text** — which is why the alternate screen *folds* the scrollback rather than deleting it.
|
||||
|
||||
**The row cache.** pyte's `dirty` set is a repaint *hint*, not a diff: scrolling one line
|
||||
marks every row dirty, and a TUI repaints whole frames. `update_line` compares
|
||||
`(segments, lf)` against `_row_cache` and returns early when unchanged, which is what
|
||||
keeps a full-screen repaint from costing hundreds of cross-process calls.
|
||||
|
||||
**The follow-scroll gate.** `following()` compares the viewport against
|
||||
`terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled
|
||||
up by hand, and trimming, caret placement and scrolling are all skipped so their position
|
||||
survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate
|
||||
the same way, and scrolling down clamps to the anchor `bottom_y` returns, which re-opens
|
||||
it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate
|
||||
and one Down re-opens it.
|
||||
|
||||
### How colour is actually drawn
|
||||
|
||||
`view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a
|
||||
colour. The colours must already exist in the view's colour scheme. So vterm generates
|
||||
the colour scheme at runtime:
|
||||
|
||||
1. `segment_buffer_line` splits a screen row into runs of identical attributes.
|
||||
2. For each non-default run, `scheme_manager.scope(fg, bg, style)` returns a scope name
|
||||
such as `vterm.red.default.b`. On a **miss** it creates the rule and schedules a
|
||||
write; the hot path is a single lock-free dict read.
|
||||
3. `colorize_line` calls `add_regions` once per distinct scope in the row, with keys
|
||||
`vterm#N` — monotonic rather than per-row, because `trim_history` renumbers rows and
|
||||
per-row keys would collide. `colored_lines[row]` remembers them so they can be erased.
|
||||
Rows that scroll off the screen are queued by `archive_rows` and merged by
|
||||
`flush_chunk` onto shared `vterm@chunk@scope` keys (one per scope per `CHUNK` rows
|
||||
of scrollback), their per-row keys erased — every key is a separate pass in Sublime's
|
||||
redraw, and a long coloured session used to accumulate ~15k of them. The merge waits
|
||||
for the chunk to close: `add_regions` replaces a key's whole set, so merging every
|
||||
frame re-sent everything the chunk held so far, which is quadratic in `CHUNK` when
|
||||
output arrives a few rows at a time. The open chunk's rows keep their per-row keys
|
||||
until then, so the live count is scopes x chunks plus at most one chunk's worth.
|
||||
Chunk ordinals are view row + `_trimmed`, so `trim_history` renumbering cannot move
|
||||
a row between chunks; trimming settles the open chunk first, then erases whole
|
||||
chunks' keys and compacts the boundary chunk.
|
||||
4. `_write_file` serializes every rule into
|
||||
`Packages/User/vterm/vterm.hidden-color-scheme`, the path the view's `color_scheme`
|
||||
setting points at. **Each write makes Sublime reload the scheme and restyle every
|
||||
view**, so writes are debounced 250 ms onto the async thread.
|
||||
|
||||
Details worth knowing before touching `scheme.py`:
|
||||
|
||||
- Rules are **seeded** at build time for all 16 foregrounds (plain and bold) so the very
|
||||
first frame never references a scope the file lacks.
|
||||
- If a rule's resolved background equals the global background *exactly*, it is nudged by
|
||||
one (`next_color`) — `add_regions` inverts foreground and background when they match.
|
||||
- Colours are resolved when a rule is added, so a palette change cannot append: `_build`
|
||||
rebuilds the whole table.
|
||||
- Truecolor SGR is quantized into the 256 palette (`quantize_256`), which bounds the rule
|
||||
table: every distinct pair would otherwise be a permanent rule and another file write.
|
||||
- `plugin_loaded` writes the file **synchronously**, because a restored tab's
|
||||
`color_scheme` is read during startup.
|
||||
|
||||
### The alternate screen
|
||||
|
||||
While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so
|
||||
the view shows the app's screen alone. The fold is re-checked every frame, not just on
|
||||
transitions, because the screen's first row is rewritten on every region scroll and an
|
||||
edit at the fold boundary can silently break a fold. Entering the alt screen collapses the
|
||||
layout, so that transition also zeroes `terminal.viewport_y`; a stale anchor would hold
|
||||
the follow-scroll gate shut. A fold merely repaired keeps the anchor, or the boundary row
|
||||
apps rewrite on every scroll would reset it every frame. The emulator side
|
||||
(`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its
|
||||
frames into scrollback.
|
||||
|
||||
### Input: a key press reaches the app
|
||||
|
||||
Almost every key, **including every printable character**, is bound to `vterm_keypress`.
|
||||
That is deliberate: if printables were left to Sublime, an app that does not echo (vim in
|
||||
normal mode) would leave the typed character sitting in the view.
|
||||
|
||||
```
|
||||
keymap binding (context: setting.vterm_view)
|
||||
→ VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…)
|
||||
→ view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page
|
||||
and Up/Down a row; stops here, never on the alt screen
|
||||
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
|
||||
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
|
||||
enter/tab/backspace/space, modified, while the app
|
||||
asked for modifyOtherKeys → CSI 27;<mod>;<code>~
|
||||
ctrl → control byte; alt → ESC prefix
|
||||
plain → _KEY_MAP / _APP_MODE_KEY_MAP
|
||||
→ Terminal.send_string(normalized=False)
|
||||
→ pty write ≤4096 bytes inline, larger goes through a queue
|
||||
```
|
||||
|
||||
**IME and composed input** cannot work this way — there is no key event for "the IME
|
||||
committed 日本語"; the text arrives as Sublime's own `insert` command. `on_modified`
|
||||
catches that: it reconstructs what was inserted from the caret positions (not from
|
||||
`args["characters"]`, which reports the typed keys rather than the committed
|
||||
composition), forwards it as an unbracketed paste, and `soft_undo`s any other command
|
||||
that modified the view. Text the emulator did not produce must never remain in the view.
|
||||
|
||||
**Copy and paste** are rerouted by `on_text_command`: `copy` → `vterm_copy` (which strips
|
||||
the zero-width `CONTINUATION` sentinel so soft-wrapped lines re-join), `paste` →
|
||||
`vterm_paste`, and `undo` → `vterm_noop`. Pastes are wrapped in `ESC[200~` / `ESC[201~`
|
||||
when the app enabled bracketed paste. In the other direction, OSC 52 from the app reaches
|
||||
the system clipboard via a hop to the UI thread.
|
||||
|
||||
### Mouse
|
||||
|
||||
Once per tick, `_sync_wheel_flag` publishes `vterm_view.wheel_captured` = "the app
|
||||
reports the mouse, or owns the alternate screen". The mousemap's binding is gated on it,
|
||||
which produces three cases:
|
||||
|
||||
1. **App tracks the mouse** → `vterm_scroll` maps the event to a screen cell and sends
|
||||
three SGR wheel reports, *one per write* — pi matches the SGR form against a whole
|
||||
stdin chunk and drops anything longer.
|
||||
2. **Alternate screen, no tracking** → three arrow keys per notch (alternate scroll, as
|
||||
kitty/iTerm2/xterm do). This is what scrolls a fullscreen UI before it enables mouse
|
||||
reporting.
|
||||
3. **Neither** → the binding does not match at all and Sublime scrolls the view over the
|
||||
real scrollback text.
|
||||
|
||||
**The trackpad** fires none of that: on macOS it never reaches a mousemap binding
|
||||
(sublime_text#1838) and dispatches no command, so a plugin cannot see it at all. Its only
|
||||
in-API trace is the viewport moving, and reading a gesture that way means letting the view
|
||||
move before pinning it back — visibly, at every poll. vterm does not try: the trackpad is
|
||||
converted outside the plugin, by a utility such as LinearMouse set to discrete scrolling
|
||||
for Sublime Text, and arrives as ordinary wheel notches on the path above. Nothing in the
|
||||
plugin is trackpad-aware, and the viewport is never moved to measure input.
|
||||
|
||||
Clicks: `VtermMouseEventListener` intercepts `drag_select` only when the app tracks the
|
||||
mouse, and only the plain single-button form, so shift-click and add-selection stay
|
||||
native. The first click on an unfocused tab only focuses it — we replaced the command
|
||||
that normally does that — and subsequent clicks send a press/release pair.
|
||||
|
||||
**The middle button** pastes the view's own selection into the app, as X11 does, and
|
||||
never touches the clipboard. The binding is a `press_command`, not a `command`: Sublime's
|
||||
own `button3` on macOS is a column `drag_select`, which would clear the selection on
|
||||
press before a release command could read it. Shadowing it costs middle-drag column
|
||||
select inside terminal tabs; the context makes the binding fall through everywhere else.
|
||||
It is not gated on mouse reporting — an empty selection is already a no-op, and the alt
|
||||
screen rarely holds one.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- **Tab closed** → `on_pre_close` → `Terminal.kill()`: unsubscribe, terminate the child,
|
||||
drop the registry entry.
|
||||
- **Process exits** → reader gets `EOFError`, sets `alive = False`; the renderer drains
|
||||
the last output, then posts `vterm_cleanup` to the UI thread. Cleanup latches
|
||||
`vterm_view.finished`, renders a final frame, kills the terminal, and either closes the
|
||||
tab (`auto_close`) or appends `[process exited with code N]`.
|
||||
- **Plugin host restart** → the registry is gone but the view survives with its settings
|
||||
and text. `on_activated` detects a view marked `vterm_view` with no terminal behind it
|
||||
and shows a "disconnected" status: the tab has to be closed and reopened.
|
||||
|
||||
## Threading
|
||||
|
||||
| Thread | Runs | Touches |
|
||||
|---|---|---|
|
||||
| UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, clipboard | Sublime API; small pty writes go out here too |
|
||||
| reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state |
|
||||
| renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator |
|
||||
| paste drain | transient; empties the 4096-byte chunk queue | pty only |
|
||||
| ST async worker | `SchemeManager._flush` | the scheme file |
|
||||
|
||||
The emulator needs no lock because exactly one thread mutates it. The chunk deque needs
|
||||
none either: `append` and `popleft` are atomic under the GIL. This matters — the renderer
|
||||
used to hold a lock across `stream.feed` **and** the whole render command, so the reader
|
||||
could not drain the pty for the duration of a frame and the child blocked in `write()`.
|
||||
Backpressure now lives in `FEED_LIMIT` instead: at most 256 KiB is parsed per tick, so a
|
||||
huge burst still renders intermediate frames while the reader keeps draining.
|
||||
|
||||
`SchemeManager` does keep a lock, because `scope()` is called from the renderer while
|
||||
`reload()` and `_flush()` run on other threads. Its read fast path skips the lock; the
|
||||
invariant that makes that safe is that `_names` must never outlive `rules` — `_build`
|
||||
replaces both together.
|
||||
|
||||
## Sublime settings
|
||||
|
||||
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
|
||||
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
|
||||
`scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
|
||||
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
|
||||
into a scroll of the view — a page, a row — instead of a key for the app. `terminal` and
|
||||
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project
|
||||
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
|
||||
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
|
||||
pi and Claude Code repaint by erasing
|
||||
saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the
|
||||
palette and restyles open terminals in place.
|
||||
|
||||
**Stamped on every terminal view** by `vterm_initialize_view`: `vterm_view` (the marker
|
||||
every context and hook keys off), `is_widget`, `color_scheme` (the generated scheme),
|
||||
`syntax` (the empty one), `scroll_past_end` (off — the view never scrolls below the last
|
||||
line), `scroll_context_lines`, and a list of disables — `gutter`, `highlight_line`,
|
||||
`word_wrap`, `auto_complete`, `draw_white_space`, `draw_indent_guides`, `spell_check`,
|
||||
`translate_tabs_to_spaces`, `command_mode` (which
|
||||
defuses Vintage mode inside a terminal). The view is also set scratch, so closing it never
|
||||
prompts to save.
|
||||
|
||||
**Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the
|
||||
Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and
|
||||
`block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block).
|
||||
|
||||
**Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in
|
||||
Sublime's own process — a plugin `on_query_context` would cost a round trip on every
|
||||
keystroke in every view. Mousemaps do **not** evaluate `setting.` contexts the same way,
|
||||
so the single wheel context still goes through `on_query_context`, which answers that one
|
||||
key and returns `None` for everything else.
|
||||
|
||||
---
|
||||
|
||||
|
||||
63
README.md
63
README.md
@@ -9,17 +9,46 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
||||
|
||||
## Install
|
||||
|
||||
Symlink this folder into Sublime packages as `vterm`:
|
||||
### With Package Control
|
||||
|
||||
```sh
|
||||
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
||||
vterm is distributed through the shared channel at
|
||||
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the
|
||||
default one. Open **Preferences → Package Settings → Package Control →
|
||||
Settings** and put the channel URL in the `channels` array of the right-hand
|
||||
(User) pane:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": [
|
||||
"https://packagecontrol.io/channel_v3.json",
|
||||
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared
|
||||
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was
|
||||
never installed), run **Package Control: Satisfy Libraries** and restart Sublime.
|
||||
Package Control garbage-collects libraries no package requires, so if imports
|
||||
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
|
||||
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
|
||||
hides every default package. Then run **Package Control: Install Package**
|
||||
from the Command Palette and pick **vterm**. Upgrades arrive through
|
||||
**Package Control: Upgrade Package** like any other package.
|
||||
|
||||
### From source
|
||||
|
||||
Clone the repository into the Sublime Text `Packages` directory as `vterm`:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/vaclavt/vterm.git \
|
||||
"$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
||||
```
|
||||
|
||||
Use one method or the other, not both: an unpacked `Packages/vterm` directory
|
||||
overrides the `vterm.sublime-package` that Package Control installs.
|
||||
|
||||
Either way, dependencies (pyte, ptyprocess, wcwidth) are Package Control
|
||||
libraries declared in `dependencies.json`. If they are not yet in
|
||||
`Lib/python38` (e.g. Terminus was never installed), run **Package Control:
|
||||
Satisfy Libraries** and restart Sublime. Package Control garbage-collects
|
||||
libraries no package requires, so if imports ever fail after uninstalling
|
||||
another package, re-run *Satisfy Libraries*.
|
||||
|
||||
## Use
|
||||
|
||||
@@ -56,7 +85,9 @@ wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
|
||||
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
|
||||
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
||||
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
||||
for Sublime Text and it arrives as wheel notches like any other. While an app owns the
|
||||
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
||||
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
|
||||
scrolls too far. While an app owns the
|
||||
alternate screen the scrollback above it is folded away, kitty-style, and
|
||||
unfolds when the app leaves it.
|
||||
|
||||
@@ -73,11 +104,12 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
|
||||
colours:
|
||||
|
||||
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
|
||||
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
|
||||
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
|
||||
no restart needed.
|
||||
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
|
||||
default for terminals opened from now on. *vterm: Select Theme* retints the
|
||||
focused terminal immediately and sets this; other already-open terminals keep
|
||||
whatever theme they had.
|
||||
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
|
||||
applied on top of the theme.
|
||||
applied on top of the theme. Takes effect after a Sublime restart.
|
||||
|
||||
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
|
||||
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
|
||||
@@ -154,7 +186,8 @@ instead), image protocols, Windows, panels.
|
||||
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
|
||||
plugin (sublime_text#1838), so set a utility such as
|
||||
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for
|
||||
Sublime Text. It then arrives as wheel notches and scrolls the app. Without
|
||||
Sublime Text. It then arrives as wheel notches and scrolls the app, three
|
||||
lines a notch unless `wheel_lines` says otherwise. Without
|
||||
one, the trackpad does nothing in a full-screen app; the mouse wheel,
|
||||
`shift+pageup/pagedown` and `shift+up/down` work regardless.
|
||||
|
||||
@@ -167,6 +200,8 @@ python3 tests/test_render.py # render layer on a fake view: trimming, select
|
||||
python3 tests/test_commands.py # theme picker, live restyle, generated scheme
|
||||
python3 tests/harness_live.py CMD SECONDS [--send TEXT] # run a real app in the emulator, dump the screen
|
||||
python3 tools/gen_keymap.py # regenerate Default.sublime-keymap
|
||||
python3 benchmarks/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
|
||||
python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-benches of draw
|
||||
```
|
||||
|
||||
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
||||
|
||||
438
benchmarks/bench_vs_terminus.py
Normal file
438
benchmarks/bench_vs_terminus.py
Normal 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
129
benchmarks/profiling.py
Normal 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
14
main.py
@@ -5,6 +5,8 @@ registry would orphan open terminal tabs.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import sublime
|
||||
|
||||
# imported for sublime's command discovery, not called here
|
||||
from .vterm.commands import (
|
||||
VtermClearScreenCommand,
|
||||
@@ -28,7 +30,7 @@ from .vterm.mouse import (
|
||||
VtermMouseEventListener,
|
||||
VtermScrollCommand,
|
||||
)
|
||||
from .vterm.scheme import scheme_manager
|
||||
from .vterm.scheme import build_startup_schemes
|
||||
from .vterm.utils import settings
|
||||
from .vterm.render import (
|
||||
VtermRenderCommand,
|
||||
@@ -37,9 +39,13 @@ from .vterm.render import (
|
||||
|
||||
|
||||
def plugin_loaded():
|
||||
"""Write the colour scheme and set the log level, once per plugin host."""
|
||||
# synchronous: a restored tab's color_scheme is read during startup
|
||||
scheme_manager.ensure_file()
|
||||
"""Write any missing theme's colour scheme and set the log level, once per
|
||||
plugin host: a restored tab's color_scheme is read during startup and may
|
||||
name any of them.
|
||||
"""
|
||||
window = sublime.active_window()
|
||||
view = window.active_view() if window else None
|
||||
build_startup_schemes(view)
|
||||
|
||||
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
|
||||
logging.basicConfig()
|
||||
|
||||
21
repository.json
Normal file
21
repository.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -87,3 +87,7 @@ def expand_variables(value, variables):
|
||||
|
||||
def version():
|
||||
return "4200"
|
||||
|
||||
|
||||
def active_window():
|
||||
return None
|
||||
|
||||
@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
|
||||
|
||||
import sublime
|
||||
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
|
||||
from vterm.emulator import VtScreen
|
||||
from vterm.render import CONTINUATION
|
||||
from vterm.scheme import get_scheme_manager
|
||||
from vterm.terminal import Terminal
|
||||
from vterm.utils import SETTINGS_FILE
|
||||
from vterm.themes import THEMES
|
||||
|
||||
@@ -13,72 +16,113 @@ settings = sublime.load_settings(SETTINGS_FILE)
|
||||
panel = {}
|
||||
|
||||
|
||||
class FakeTerminalView:
|
||||
"""Just enough view for a Terminal to be rethemed: an id and its own settings."""
|
||||
|
||||
_next_id = 1
|
||||
|
||||
def __init__(self):
|
||||
self._id = FakeTerminalView._next_id
|
||||
FakeTerminalView._next_id += 1
|
||||
self._settings = sublime.Settings()
|
||||
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
def settings(self):
|
||||
return self._settings
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
def style(self):
|
||||
return {}
|
||||
|
||||
|
||||
class FakeWindow:
|
||||
def __init__(self, view):
|
||||
self._view = view
|
||||
|
||||
def active_view(self):
|
||||
return self._view
|
||||
|
||||
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
||||
panel.update(items=items, on_done=on_done,
|
||||
selected_index=selected_index, on_highlight=on_highlight)
|
||||
|
||||
|
||||
def open_picker(current):
|
||||
settings.set("theme", current)
|
||||
command = VtermSelectThemeCommand(FakeWindow())
|
||||
def fake_terminal(theme_name):
|
||||
"""A terminal frozen to a theme, registered the way a real one is."""
|
||||
view = FakeTerminalView()
|
||||
terminal = Terminal(view, get_scheme_manager(theme_name))
|
||||
terminal.screen = VtScreen(20, 3, process=FakeProcess())
|
||||
Terminal._terminals[view.id()] = terminal
|
||||
return terminal
|
||||
|
||||
|
||||
def open_picker(terminal):
|
||||
command = VtermSelectThemeCommand(FakeWindow(terminal.view))
|
||||
command.run()
|
||||
return command
|
||||
|
||||
|
||||
# every theme is offered, with "no theme" first
|
||||
command = open_picker(None)
|
||||
command = open_picker(fake_terminal(None))
|
||||
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
||||
assert panel["selected_index"] == 0
|
||||
|
||||
# the configured theme starts highlighted
|
||||
command = open_picker("pro")
|
||||
# the focused terminal's own theme starts highlighted, not the global default
|
||||
settings.set("theme", "homebrew")
|
||||
terminal = fake_terminal("pro")
|
||||
command = open_picker(terminal)
|
||||
assert panel["selected_index"] == command.choices.index("pro")
|
||||
|
||||
# highlighting previews but must not persist
|
||||
# highlighting previews the focused terminal alone, leaving the global default and
|
||||
# every other terminal untouched
|
||||
other = fake_terminal("default")
|
||||
saves = len(sublime._saved)
|
||||
panel["on_highlight"](command.choices.index("slate"))
|
||||
assert settings.get("theme") == "slate", settings.get("theme")
|
||||
assert terminal.scheme.theme_name == "slate", terminal.scheme.theme_name
|
||||
assert terminal.view.settings().get("color_scheme") == terminal.scheme.path
|
||||
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
|
||||
assert settings.get("theme") == "homebrew", "preview must not touch the global default"
|
||||
assert len(sublime._saved) == saves, "preview must not save settings"
|
||||
|
||||
# cancelling restores the value from before the panel opened
|
||||
# cancelling restores the focused terminal's theme from before the panel opened
|
||||
panel["on_done"](-1)
|
||||
assert settings.get("theme") == "pro", settings.get("theme")
|
||||
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
|
||||
|
||||
# confirming persists
|
||||
command = open_picker("pro")
|
||||
# confirming retheme the focused terminal and persists the default for new terminals
|
||||
command = open_picker(terminal)
|
||||
panel["on_done"](command.choices.index("homebrew"))
|
||||
assert terminal.scheme.theme_name == "homebrew", terminal.scheme.theme_name
|
||||
assert settings.get("theme") == "homebrew", settings.get("theme")
|
||||
assert sublime._saved[-1] == SETTINGS_FILE
|
||||
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
|
||||
|
||||
# "no theme" persists as null rather than a name
|
||||
command = open_picker("slate")
|
||||
command = open_picker(fake_terminal("slate"))
|
||||
panel["on_done"](0)
|
||||
assert settings.get("theme") is None, settings.get("theme")
|
||||
|
||||
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
||||
|
||||
|
||||
# a palette change must reach terminals that are already running: the screen
|
||||
# answers OSC 10/11 and DSR 996 from these values
|
||||
from vterm.emulator import VtScreen, VtStream
|
||||
from vterm.scheme import scheme_manager
|
||||
from vterm.terminal import Terminal
|
||||
|
||||
# a palette change must reach the screen it was applied to: it answers OSC 10/11
|
||||
# and DSR 996 from these values
|
||||
from vterm.emulator import VtStream
|
||||
|
||||
recorder = FakeProcess()
|
||||
# real __init__ (view unused here); process/screen stand in for start()
|
||||
terminal = Terminal(None)
|
||||
terminal = fake_terminal("default")
|
||||
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
|
||||
bg_color="ffffff", is_dark=False)
|
||||
terminal.process = recorder
|
||||
stream = VtStream(terminal.screen)
|
||||
|
||||
scheme_manager.foreground = "#eeeeee"
|
||||
scheme_manager.background = "#111111"
|
||||
terminal.scheme = get_scheme_manager("slate")
|
||||
terminal.apply_scheme()
|
||||
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
|
||||
assert terminal.screen.theme_is_dark is True
|
||||
assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
|
||||
assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
|
||||
|
||||
# without mode 2031 the app is not interrupted
|
||||
assert recorder.written == "", repr(recorder.written)
|
||||
@@ -92,37 +136,30 @@ assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
|
||||
# and the emulator answers a colour query with the new palette
|
||||
recorder.written = ""
|
||||
stream.feed("\x1b]11;?\x07")
|
||||
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
|
||||
bg = terminal.screen.default_bg_color
|
||||
expected = "\x1b]11;rgb:{0}{0}/{1}{1}/{2}{2}\x07".format(bg[0:2], bg[2:4], bg[4:6])
|
||||
assert recorder.written == expected, repr(recorder.written)
|
||||
|
||||
print("live theme tests passed (running screen adopts palette, app notified)")
|
||||
|
||||
|
||||
# the scheme file must exist before any restored tab's color_scheme is loaded at
|
||||
# startup, and building twice must not duplicate the seeded rules
|
||||
# startup, and building a theme twice must not duplicate its seeded rules
|
||||
import os
|
||||
from vterm.scheme import SCHEME_FILE_NAME
|
||||
|
||||
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
scheme = get_scheme_manager("pro")
|
||||
path = os.path.join(scheme._directory(), scheme.file_name)
|
||||
assert os.path.exists(path), "get_scheme_manager did not write the scheme"
|
||||
|
||||
scheme_manager.ensure_file()
|
||||
assert os.path.exists(path), "ensure_file did not write the scheme"
|
||||
assert not scheme_manager._initialized, \
|
||||
"ensure_file must leave the palette open to refinement by initialize()"
|
||||
# the cached instance is reused, not rebuilt
|
||||
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
|
||||
|
||||
# a second call is a no-op: same file, untouched
|
||||
stamp = os.path.getmtime(path)
|
||||
scheme_manager.ensure_file()
|
||||
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
|
||||
|
||||
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
|
||||
with scheme_manager.lock:
|
||||
scheme_manager._build()
|
||||
first = len(scheme_manager.rules)
|
||||
scheme_manager._build()
|
||||
assert len(scheme_manager.rules) == first, \
|
||||
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
|
||||
# build is idempotent
|
||||
with scheme.lock:
|
||||
first = len(scheme.rules)
|
||||
scheme.build()
|
||||
assert len(scheme.rules) == first, \
|
||||
"rules duplicated: {} -> {}".format(first, len(scheme.rules))
|
||||
|
||||
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
|
||||
|
||||
|
||||
@@ -63,10 +63,9 @@ assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys
|
||||
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
|
||||
load_sublime_json("Default (OSX).sublime-mousemap")
|
||||
|
||||
# scheme manager produces scopes and rules
|
||||
import vterm.scheme as scheme_module
|
||||
from vterm.scheme import scheme_manager
|
||||
scope = scheme_manager.scope("ff0000", "default", "b")
|
||||
# a theme's scheme manager produces scopes and rules
|
||||
from vterm.scheme import get_scheme_manager
|
||||
scope = get_scheme_manager("default").scope("ff0000", "default", "b")
|
||||
assert scope == "vterm.ff0000.default.b", scope
|
||||
|
||||
# every command referenced by the palette and the keymap must exist, and the
|
||||
@@ -89,7 +88,7 @@ for entry in load_sublime_json("vterm.sublime-commands") + keymap:
|
||||
|
||||
# every pending_* name mentioned in the modules must be a real Terminal
|
||||
# attribute, so a renamed flag cannot leave a writer pointing at nothing
|
||||
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
|
||||
flags = {f for f in vars(Terminal(None, None)) if f.startswith("pending_")}
|
||||
assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
|
||||
for module in (commands, render, terminal_module):
|
||||
with open(module.__file__, encoding="utf-8") as f:
|
||||
@@ -114,21 +113,28 @@ for name, theme in THEMES.items():
|
||||
configured = load_sublime_json("vterm.sublime-settings").get("theme")
|
||||
assert configured is None or configured in THEMES, configured
|
||||
|
||||
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
|
||||
# color_scheme during startup, before any terminal opens
|
||||
# plugin_loaded must create every theme's colour scheme: sublime loads a restored
|
||||
# tab's color_scheme during startup, before any terminal opens, and it may name
|
||||
# any of them
|
||||
# import main.py the way sublime does - as a module of the package directory -
|
||||
# so its relative imports and plugin_loaded body are actually executed
|
||||
from vterm.scheme import SchemeManager
|
||||
from vterm.themes import ALL_THEME_NAMES
|
||||
|
||||
scheme_files = [SchemeManager(name).file_path() for name in ALL_THEME_NAMES]
|
||||
for scheme_file in scheme_files:
|
||||
if os.path.exists(scheme_file):
|
||||
os.remove(scheme_file)
|
||||
|
||||
pkg = types.ModuleType("vterm")
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules["vterm"] = pkg
|
||||
main = importlib.import_module("vterm.main")
|
||||
|
||||
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
|
||||
if os.path.exists(scheme_file):
|
||||
os.remove(scheme_file)
|
||||
main.plugin_loaded()
|
||||
assert os.path.exists(scheme_file), \
|
||||
"plugin_loaded must leave the colour scheme on disk"
|
||||
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
|
||||
assert os.path.exists(scheme_file), \
|
||||
"plugin_loaded must leave {}'s colour scheme on disk".format(name)
|
||||
|
||||
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
||||
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
||||
|
||||
@@ -17,6 +17,7 @@ from vterm.render import (
|
||||
following,
|
||||
scroll_to_cursor,
|
||||
)
|
||||
from vterm.scheme import get_scheme_manager
|
||||
from vterm.terminal import Terminal
|
||||
from vterm.utils import SETTINGS_FILE
|
||||
|
||||
@@ -125,7 +126,9 @@ class FakeView:
|
||||
return self.viewport
|
||||
|
||||
def set_viewport_position(self, xy, animate=True):
|
||||
self.viewport = xy
|
||||
# scroll_past_end is off, so sublime clamps to the layout
|
||||
maxy = max(0, self.layout_extent()[1] - self.viewport_extent()[1])
|
||||
self.viewport = (xy[0], max(0, min(xy[1], maxy)))
|
||||
|
||||
def line_height(self):
|
||||
return LINE_HEIGHT
|
||||
@@ -155,7 +158,7 @@ class FakeView:
|
||||
|
||||
def make_terminal(rows=10, cols=20, scrollback=None):
|
||||
# real __init__ for the plain attributes; process/screen stand in for start()
|
||||
terminal = Terminal(FakeView())
|
||||
terminal = Terminal(FakeView(), get_scheme_manager("default"))
|
||||
if scrollback is not None:
|
||||
terminal.scrollback_size = scrollback
|
||||
terminal.process = FakeProcess()
|
||||
@@ -163,9 +166,12 @@ def make_terminal(rows=10, cols=20, scrollback=None):
|
||||
return terminal, VtStream(terminal.screen)
|
||||
|
||||
|
||||
def make_command(view):
|
||||
# the real __init__, so the fake cannot drift from the command's attributes
|
||||
return VtermRenderCommand(view)
|
||||
def make_command(terminal):
|
||||
# the real __init__, so the fake cannot drift from the command's attributes;
|
||||
# run() itself sets _scheme, but these tests call its helpers directly
|
||||
command = VtermRenderCommand(terminal.view)
|
||||
command._scheme = terminal.scheme
|
||||
return command
|
||||
|
||||
|
||||
# an app on the alternate screen must not have its view trimmed away: the
|
||||
@@ -176,7 +182,7 @@ assert terminal.screen.alternate_buffer_mode
|
||||
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
|
||||
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
before = terminal.view.text
|
||||
command.trim_history(None, terminal)
|
||||
assert terminal.view.text == before, \
|
||||
@@ -186,7 +192,7 @@ assert terminal.view.text == before, \
|
||||
terminal, stream = make_terminal(rows=5, scrollback=20)
|
||||
terminal.offset = 40
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
command.trim_history(None, terminal)
|
||||
rows_left = terminal.view.text.count("\n") + 1
|
||||
assert rows_left <= 20, rows_left
|
||||
@@ -197,11 +203,29 @@ assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
||||
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
||||
terminal.offset = 3
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
command.trim_history(None, terminal)
|
||||
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||
terminal.view.text
|
||||
|
||||
# nor may its blank rows be trimmed: an app that blanks and refills its last
|
||||
# row (vim's showcmd corner) would resize the view under a pinned viewport
|
||||
terminal, stream = make_terminal(rows=4)
|
||||
stream.feed("\x1b[?1049h")
|
||||
terminal.view = FakeView("top\nrow\n\n")
|
||||
command = make_command(terminal)
|
||||
before = terminal.view.text
|
||||
command.trim_trailing_spaces(None, terminal)
|
||||
assert terminal.view.text == before, \
|
||||
"alt screen blank tail was trimmed: {!r}".format(terminal.view.text)
|
||||
|
||||
# on the primary screen the blank tail below the cursor still goes
|
||||
terminal, stream = make_terminal(rows=4)
|
||||
terminal.view = FakeView("top\nrow\n\n")
|
||||
command = make_command(terminal)
|
||||
command.trim_trailing_spaces(None, terminal)
|
||||
assert terminal.view.text == "top\nrow", terminal.view.text
|
||||
|
||||
print("render tests passed (alt screen keeps its view, cap honoured)")
|
||||
|
||||
|
||||
@@ -241,7 +265,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
||||
# batches the runs that share a scope into one add_regions call
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
||||
command.update_lines(None, terminal)
|
||||
|
||||
@@ -271,7 +295,7 @@ print("colorize tests passed (arithmetic offsets, scope batching, key bookkeepin
|
||||
# the whole session, and merging every frame would re-send the whole chunk
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
for i in range(300):
|
||||
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
||||
command.update_lines(None, terminal)
|
||||
@@ -290,7 +314,7 @@ assert len(row_keys) == 300 - CHUNK, \
|
||||
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
||||
terminal, stream = make_terminal(rows=3, scrollback=30)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||
command.update_lines(None, terminal)
|
||||
terminal.screen.dirty.clear()
|
||||
@@ -312,7 +336,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
|
||||
Terminal._terminals[id(terminal)] = terminal
|
||||
terminal.view.id = lambda: id(terminal)
|
||||
terminal.default_title = "zsh"
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("[vterm] %")
|
||||
command.run(None)
|
||||
assert all(r.empty() for r in terminal.view.sel()), \
|
||||
@@ -339,10 +363,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
|
||||
print("render diff tests passed (unchanged dirty rows skipped)")
|
||||
|
||||
# the generated scheme must define a selection colour, or sublime uses blue
|
||||
from vterm.scheme import blend, scheme_manager
|
||||
scheme_manager.background = "#303841"
|
||||
scheme_manager.foreground = "#d8dee9"
|
||||
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
|
||||
from vterm.scheme import blend
|
||||
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
|
||||
|
||||
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
||||
|
||||
@@ -350,7 +372,7 @@ print("selection artefact tests passed (fresh prompt unselected, user selection
|
||||
# DECSCUSR drives the caret shape through view settings; before any app asks,
|
||||
# and for 0, the caret is the terminal default: a blinking block
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
settings = terminal.view.settings()
|
||||
|
||||
command.sync_cursor_style(terminal)
|
||||
@@ -522,6 +544,23 @@ plugin_settings.set("scroll_modifier", "shift")
|
||||
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
|
||||
|
||||
|
||||
# an app that repaints its block shorter than the screen (pi on every SIGWINCH)
|
||||
# leaves the live screen shorter than the viewport once the blank tail is
|
||||
# trimmed. the anchor must stay where sublime can actually put the viewport, or
|
||||
# the follow gate closes for good and the output is never followed again
|
||||
short, _ = make_terminal(rows=8, cols=20)
|
||||
short.view.viewport_rows = short.screen.lines
|
||||
# 24 scrollback rows, then a live screen holding 3 rows instead of 8
|
||||
short.view.text = "\n".join("row {}".format(i) for i in range(27))
|
||||
short.offset = 24
|
||||
scroll_to_cursor(short.view, short)
|
||||
assert short.viewport_y == short.view.viewport_position()[1], \
|
||||
(short.viewport_y, short.view.viewport_position()[1])
|
||||
assert following(short.view, short), "follow gate latched shut on a short screen"
|
||||
|
||||
print("short screen tests passed (the anchor stays reachable, so the view keeps following)")
|
||||
|
||||
|
||||
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
||||
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
||||
# bare ESC CR reads as alt+enter
|
||||
@@ -584,7 +623,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
||||
terminal.offset = 3
|
||||
terminal.viewport_y = 400
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
fold_end = terminal.view.text_point(3, 0) - 1
|
||||
|
||||
stream.feed("\x1b[?1049h")
|
||||
@@ -612,7 +651,7 @@ assert terminal.view.folds == [], terminal.view.folds
|
||||
# without scrollback there is nothing to fold
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("\x1b[?1049h")
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [], terminal.view.folds
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
// null sends the keys to the app
|
||||
"scroll_modifier": "shift",
|
||||
|
||||
// scroll lines sent to the app per wheel notch (iTerm2 sends 3).
|
||||
// A trackpad via LinearMouse makes many notches per swipe; 1 is calmer.
|
||||
"wheel_lines": 1,
|
||||
|
||||
"min_columns": 20,
|
||||
"max_columns": 500,
|
||||
|
||||
// colour palette: "default", "homebrew", "pro", "slate", or null to
|
||||
// follow the active Sublime colour scheme. Applies to open tabs too.
|
||||
// colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
|
||||
// to follow the active Sublime colour scheme. Default for terminals opened
|
||||
// from now on; vterm: Select Theme retints the focused terminal and sets this.
|
||||
"theme": null,
|
||||
|
||||
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
|
||||
|
||||
@@ -7,9 +7,9 @@ import sublime
|
||||
import sublime_plugin
|
||||
|
||||
from .render import CONTINUATION, scroll_view
|
||||
from .scheme import SCHEME_PATH, scheme_manager
|
||||
from .scheme import get_scheme_manager
|
||||
from .terminal import Terminal
|
||||
from .themes import THEMES
|
||||
from .themes import ALL_THEME_NAMES, THEMES
|
||||
from .utils import SETTINGS_FILE, settings
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@@ -43,12 +43,12 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
||||
cwd = os.path.expanduser("~")
|
||||
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
||||
|
||||
scheme_manager.initialize(window.active_view())
|
||||
scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view())
|
||||
|
||||
view = window.new_file()
|
||||
view.run_command("vterm_initialize_view")
|
||||
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
|
||||
|
||||
terminal = Terminal(view)
|
||||
terminal = Terminal(view, scheme)
|
||||
try:
|
||||
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
|
||||
except Exception:
|
||||
@@ -106,7 +106,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
|
||||
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
||||
"""Stamps a fresh view with the settings that make it a terminal."""
|
||||
|
||||
def run(self, _):
|
||||
def run(self, _, color_scheme):
|
||||
"""Apply the view settings; the pty starts only after this."""
|
||||
view = self.view
|
||||
view_settings = view.settings()
|
||||
@@ -125,7 +125,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
||||
view_settings.set("draw_indent_guides", False)
|
||||
view_settings.set("scroll_past_end", False)
|
||||
view_settings.set("scroll_context_lines", 0)
|
||||
view_settings.set("color_scheme", SCHEME_PATH)
|
||||
view_settings.set("color_scheme", color_scheme)
|
||||
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
|
||||
view_settings.set("spell_check", False)
|
||||
view_settings.set("detect_indentation", False)
|
||||
@@ -225,13 +225,19 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
|
||||
|
||||
|
||||
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
||||
"""Pick a colour palette, previewing each as it is highlighted"""
|
||||
"""Retheme the focused terminal, previewing each choice as it is highlighted.
|
||||
|
||||
The chosen theme also becomes the default for terminals opened from now on;
|
||||
it does not touch any other already-open terminal.
|
||||
"""
|
||||
|
||||
def run(self):
|
||||
"""Show the palette picker, starting on the configured theme."""
|
||||
self.settings = settings()
|
||||
self.original = self.settings.get("theme")
|
||||
self.choices = [None] + sorted(THEMES)
|
||||
"""Show the palette picker, starting on the focused terminal's own theme."""
|
||||
self.terminal = self._focused_terminal()
|
||||
if not self.terminal:
|
||||
return
|
||||
self.original = self.terminal.scheme.theme_name
|
||||
self.choices = ALL_THEME_NAMES
|
||||
|
||||
items = [["No theme", "follow the Sublime color scheme"]]
|
||||
for name in self.choices[1:]:
|
||||
@@ -241,16 +247,36 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
||||
current = self.choices.index(self.original) if self.original in self.choices else 0
|
||||
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
|
||||
|
||||
def is_enabled(self):
|
||||
"""Greys the palette entry out when the focused view is not a terminal."""
|
||||
return self._focused_terminal() is not None
|
||||
|
||||
def _focused_terminal(self):
|
||||
"""The terminal in the focused view, if any."""
|
||||
view = self.window.active_view()
|
||||
return Terminal.from_id(view.id()) if view else None
|
||||
|
||||
def _apply(self, name):
|
||||
"""Retheme the focused terminal alone."""
|
||||
scheme = get_scheme_manager(name, host_view=self.terminal.view)
|
||||
self.terminal.retheme(scheme)
|
||||
|
||||
def _preview(self, index):
|
||||
"""Apply a palette while it is highlighted, without saving it."""
|
||||
self.settings.set("theme", self.choices[index])
|
||||
"""Apply a palette to the focused terminal while it is highlighted."""
|
||||
self._apply(self.choices[index])
|
||||
|
||||
def _done(self, index):
|
||||
"""Persist the choice, or restore the original if the panel was cancelled."""
|
||||
"""On cancel, restore the focused terminal's original theme.
|
||||
|
||||
On confirm, keep the focused terminal on its new theme and persist the
|
||||
choice as the default for terminals opened from now on.
|
||||
"""
|
||||
if index == -1:
|
||||
self.settings.set("theme", self.original)
|
||||
self._apply(self.original)
|
||||
return
|
||||
self.settings.set("theme", self.choices[index])
|
||||
name = self.choices[index]
|
||||
self._apply(name)
|
||||
settings().set("theme", name)
|
||||
sublime.save_settings(SETTINGS_FILE)
|
||||
|
||||
|
||||
@@ -264,6 +290,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
|
||||
return
|
||||
# the renderer thread owns the screen; it applies this on its next tick
|
||||
terminal.pending_clear_screen = True
|
||||
terminal.wake()
|
||||
self.view.run_command("vterm_clear_undo_stack")
|
||||
|
||||
def is_enabled(self):
|
||||
|
||||
@@ -93,6 +93,9 @@ def quantize_256(r, g, b):
|
||||
return 16 + 36 * level(r) + 6 * level(g) + level(b)
|
||||
|
||||
|
||||
# a run of these is width-1 cells only: no wcwidth, no wide/combining cases
|
||||
PLAIN_RUN = re.compile("[ -~]+")
|
||||
|
||||
# linefeed marks an autowrap (soft wrap), so copy can re-join the halves
|
||||
Char = namedtuple(
|
||||
"Char",
|
||||
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
|
||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
||||
self._sgr_cache = {}
|
||||
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run)
|
||||
# one char -> Char cache per attribute set; attrs recur constantly in
|
||||
# coloured output, so the caches are kept rather than rebuilt on a change
|
||||
self._char_caches = {}
|
||||
self._char_cache = {}
|
||||
self._cache_attrs = None
|
||||
self.history = deque(maxlen=history)
|
||||
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
|
||||
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||
self._sgr_cache.clear()
|
||||
self._char_caches.clear()
|
||||
self._cache_attrs = None
|
||||
super().reset()
|
||||
self.cursor = Cursor(0, 0)
|
||||
self.cursor_style = 0
|
||||
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
|
||||
cursor = self.cursor
|
||||
buffer = self.buffer
|
||||
columns = self.columns
|
||||
# per-run cache: cells differ only by their data while attrs are unchanged
|
||||
if cursor.attrs is not self._cache_attrs:
|
||||
self._cache_attrs = cursor.attrs
|
||||
self._char_cache = {}
|
||||
attrs = cursor.attrs
|
||||
# cells differ only by their data within one attribute set; the cache is
|
||||
# picked per set, not discarded, or colour changes would thrash it
|
||||
if attrs is not self._cache_attrs:
|
||||
self._cache_attrs = attrs
|
||||
cache = self._char_caches.get(attrs)
|
||||
if cache is None:
|
||||
if len(self._char_caches) > 256:
|
||||
self._char_caches.clear()
|
||||
cache = self._char_caches[attrs] = {}
|
||||
self._char_cache = cache
|
||||
cache = self._char_cache
|
||||
|
||||
if autowrap and not insert_mode and PLAIN_RUN.fullmatch(data):
|
||||
# every cell is width 1, so wraps are pure arithmetic: fill whole
|
||||
# rows with C-speed dict.update instead of looping per char
|
||||
for c in set(data):
|
||||
if c not in cache:
|
||||
cache[c] = attrs._replace(data=c)
|
||||
get = cache.__getitem__
|
||||
dirty = self.dirty
|
||||
pos = 0
|
||||
length = len(data)
|
||||
while pos < length:
|
||||
x = cursor.x
|
||||
if x == columns:
|
||||
line = buffer[cursor.y]
|
||||
last = line[columns - 1]
|
||||
line[columns - 1] = last._replace(linefeed=True)
|
||||
dirty.add(cursor.y)
|
||||
self.carriage_return()
|
||||
self.linefeed()
|
||||
x = 0
|
||||
line = buffer[cursor.y]
|
||||
take = min(columns - x, length - pos)
|
||||
end = pos + take
|
||||
line.update(zip(range(x, x + take), map(get, data[pos:end])))
|
||||
dirty.add(cursor.y)
|
||||
cursor.x = x + take
|
||||
pos = end
|
||||
return
|
||||
|
||||
line = buffer[cursor.y]
|
||||
|
||||
for char in data:
|
||||
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
|
||||
self.insert_characters(char_width)
|
||||
cell = cache.get(char)
|
||||
if cell is None:
|
||||
cell = cache[char] = cursor.attrs._replace(data=char)
|
||||
cell = cache[char] = attrs._replace(data=char)
|
||||
line[cursor.x] = cell
|
||||
if char_width == 2 and cursor.x + 1 < columns:
|
||||
filler = cache.get("")
|
||||
if filler is None:
|
||||
filler = cache[""] = cursor.attrs._replace(data="")
|
||||
filler = cache[""] = attrs._replace(data="")
|
||||
line[cursor.x + 1] = filler
|
||||
# a combining mark belongs to the cell before it, which may be the
|
||||
# last column of the previous row
|
||||
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
|
||||
break
|
||||
|
||||
if char_width > 0:
|
||||
self.cursor.x = min(self.cursor.x + char_width, self.columns)
|
||||
x = cursor.x + char_width
|
||||
cursor.x = x if x < columns else columns
|
||||
|
||||
self.dirty.add(self.cursor.y)
|
||||
self.dirty.add(cursor.y)
|
||||
|
||||
def index(self):
|
||||
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||
|
||||
@@ -105,8 +105,7 @@ def _modifier_code(ctrl, alt, shift):
|
||||
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
|
||||
|
||||
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
||||
modify_other_keys=0):
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
|
||||
"""Escape sequence for a key name and its modifiers, honouring the app's active modes"""
|
||||
key_lo = key.lower() if len(key) > 1 else key
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||
# background — via the renderer tick: a render here would race the
|
||||
# renderer thread, which owns the emulator
|
||||
terminal.pending_render = True
|
||||
terminal.wake()
|
||||
view.run_command("vterm_show_cursor", {"scroll": False})
|
||||
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
|
||||
# the plugin host restarted; this tab's terminal is unreachable
|
||||
|
||||
@@ -8,14 +8,12 @@ notches and take the path below.
|
||||
import sublime_plugin
|
||||
|
||||
from .terminal import Terminal
|
||||
from .utils import settings
|
||||
|
||||
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
||||
WHEEL_UP = 64
|
||||
WHEEL_DOWN = 65
|
||||
|
||||
# lines per wheel tick, as iTerm2 delivers per notch
|
||||
WHEEL_LINES = 3
|
||||
|
||||
|
||||
def send_scroll(terminal, up, lines, cell=None):
|
||||
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
||||
@@ -105,7 +103,8 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
|
||||
cell = None
|
||||
if terminal.screen.mouse_reporting_enabled() and event:
|
||||
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
||||
send_scroll(terminal, up, WHEEL_LINES, cell)
|
||||
lines = max(0, int(settings().get("wheel_lines", 3)))
|
||||
send_scroll(terminal, up, lines, cell)
|
||||
|
||||
def want_event(self):
|
||||
"""Ask sublime for the wheel coordinates."""
|
||||
|
||||
@@ -15,7 +15,6 @@ from wcwidth import wcswidth
|
||||
# per-cell call in segment_buffer_line
|
||||
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
||||
|
||||
from .scheme import scheme_manager
|
||||
from .terminal import Terminal
|
||||
from .utils import rev_wcwidth
|
||||
|
||||
@@ -124,6 +123,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Set up this view's render caches."""
|
||||
super().__init__(*args, **kwargs)
|
||||
# the terminal's current SchemeManager; set at the top of each run()
|
||||
self._scheme = None
|
||||
self.colored_lines = {}
|
||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||
# repaint hint, so most dirty rows are identical frame to frame
|
||||
@@ -156,6 +157,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
terminal = Terminal.from_id(view.id())
|
||||
if not terminal:
|
||||
return
|
||||
self._scheme = terminal.scheme
|
||||
|
||||
screen = terminal.screen
|
||||
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||
@@ -316,11 +318,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
|
||||
by_scope = {}
|
||||
needed = 0
|
||||
scope_of = self._scheme.scope
|
||||
for _, start, end, fg, bg, style in segments:
|
||||
if is_default_run(fg, bg, style):
|
||||
continue
|
||||
needed = max(needed, end)
|
||||
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
|
||||
by_scope.setdefault(scope_of(fg, bg, style), []).append((start, end))
|
||||
|
||||
text = "".join(s[0] for s in segments)
|
||||
if lf:
|
||||
@@ -380,6 +383,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
|
||||
grouped = {}
|
||||
stale = []
|
||||
scope_of = self._scheme.scope
|
||||
for row in rows:
|
||||
keys = self.colored_lines.pop(row, None)
|
||||
if not keys:
|
||||
@@ -391,7 +395,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
for _, a, b, fg, bg, style in segments:
|
||||
if is_default_run(fg, bg, style):
|
||||
continue
|
||||
scope = scheme_manager.scope(fg, bg, style)
|
||||
scope = scope_of(fg, bg, style)
|
||||
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||
|
||||
scopes = self._archived_chunks.setdefault(chunk, set())
|
||||
@@ -410,9 +414,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
|
||||
Without this the view keeps a full screen of blank rows below a short
|
||||
prompt, and the scrollbar reflects them.
|
||||
|
||||
Both are skipped on the alternate screen, where the app owns every row:
|
||||
one that blanks and refills - vim's showcmd corner, on every keypress -
|
||||
would resize the view under a viewport pinned to its bottom, and the
|
||||
whole screen shifts by the part of a row the viewport leaves over.
|
||||
"""
|
||||
view = self.view
|
||||
screen = terminal.screen
|
||||
if screen.alternate_buffer_mode:
|
||||
return
|
||||
cursor = screen.cursor
|
||||
cursor_row = terminal.offset + cursor.y
|
||||
lastrow = view.rowcol(view.size())[0]
|
||||
@@ -513,11 +524,10 @@ def following(view, terminal):
|
||||
|
||||
|
||||
def bottom_y(view, terminal):
|
||||
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
|
||||
"""Viewport y that pins the bottom of the view to the bottom of the viewport."""
|
||||
|
||||
# layout_extent is maintained incrementally, unlike text_to_layout(size())
|
||||
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
|
||||
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
|
||||
return max(offset_y, viewport_y)
|
||||
return max(0, view.layout_extent()[1] - view.viewport_extent()[1])
|
||||
|
||||
|
||||
def scroll_to_cursor(view, terminal):
|
||||
|
||||
167
vterm/scheme.py
167
vterm/scheme.py
@@ -8,16 +8,12 @@ import sublime
|
||||
|
||||
from .emulator import ANSI_COLORS
|
||||
from .utils import settings
|
||||
from .themes import THEMES
|
||||
from .themes import ALL_THEME_NAMES, THEMES
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# written under Packages/User so it survives a package reinstall
|
||||
SCHEME_DIR_NAME = "User/vterm"
|
||||
# "hidden-" keeps the generated scheme out of the scheme picker
|
||||
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
|
||||
# the value terminal views get as their color_scheme setting
|
||||
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
|
||||
|
||||
# each write makes sublime reload the scheme and restyle every view
|
||||
WRITE_DEBOUNCE_MS = 250
|
||||
@@ -69,104 +65,61 @@ def _is_dark(color_text):
|
||||
|
||||
|
||||
class SchemeManager:
|
||||
"""Owns the generated scheme file and the set of known scopes."""
|
||||
"""Owns one theme's generated scheme file and set of known scopes."""
|
||||
|
||||
def __init__(self):
|
||||
"""Start on the built-in palette; initialize() picks up the real one."""
|
||||
def __init__(self, theme_name):
|
||||
"""State only; build() resolves the palette and writes the file."""
|
||||
self.theme_name = theme_name
|
||||
self.file_name = "vterm-{}.hidden-color-scheme".format(theme_name or "system")
|
||||
self.path = "Packages/{}/{}".format(SCHEME_DIR_NAME, self.file_name)
|
||||
self.lock = threading.Lock()
|
||||
self.rules = {}
|
||||
# key -> scope name; the render thread reads it without the lock, so it
|
||||
# must never outlive self.rules (cleared together in _build)
|
||||
# must never outlive self.rules (cleared together in build)
|
||||
self._names = {}
|
||||
self.foreground = "#ffffff"
|
||||
self.background = "#000000"
|
||||
self._editor_fg = "#ffffff"
|
||||
self._editor_bg = "#000000"
|
||||
self.ansi = dict(DEFAULT_ANSI)
|
||||
self._write_pending = False
|
||||
self._initialized = False
|
||||
self._dir = None
|
||||
self._host_view = None
|
||||
self._listeners = []
|
||||
|
||||
def initialize(self, view):
|
||||
"""Pick up the palette, seed the rules, write the file. Main thread only"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._host_view = view
|
||||
|
||||
settings().add_on_change("vterm.theme", self.reload)
|
||||
self._build()
|
||||
|
||||
def ensure_file(self):
|
||||
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
|
||||
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
|
||||
return
|
||||
with self.lock:
|
||||
self._build()
|
||||
|
||||
def add_listener(self, callback):
|
||||
"""Called after every palette rebuild"""
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback):
|
||||
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
|
||||
if callback in self._listeners:
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def reload(self):
|
||||
"""Rebuild every rule for a new palette, then notify the terminals"""
|
||||
if not self._initialized:
|
||||
# no scheme file to maintain yet; initialize() will read the setting
|
||||
return
|
||||
with self.lock:
|
||||
self._build()
|
||||
for callback in list(self._listeners):
|
||||
try:
|
||||
callback()
|
||||
except Exception:
|
||||
LOG.warning("scheme listener failed", exc_info=True)
|
||||
|
||||
def _build(self):
|
||||
def build(self, host_view=None):
|
||||
"""Resolve the palette and regenerate every rule, then write the file.
|
||||
|
||||
Rules cannot be appended after a palette change: each one stores resolved
|
||||
colours, so they all have to be rebuilt. Caller holds the lock.
|
||||
colours, so they all have to be rebuilt. Callable again later (e.g. to
|
||||
re-sample the "No theme" palette from the editor's current colours).
|
||||
"""
|
||||
self._names = {}
|
||||
self.rules = {}
|
||||
self.ansi = dict(DEFAULT_ANSI)
|
||||
with self.lock:
|
||||
self._names = {}
|
||||
self.rules = {}
|
||||
self.ansi = dict(DEFAULT_ANSI)
|
||||
|
||||
theme = THEMES.get(settings().get("theme"))
|
||||
theme = THEMES.get(self.theme_name)
|
||||
|
||||
if theme:
|
||||
self.foreground = theme["foreground"]
|
||||
self.background = theme["background"]
|
||||
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
||||
else:
|
||||
# captured values outlive the view they came from
|
||||
if self._host_view is not None and self._host_view.is_valid():
|
||||
style = self._host_view.style()
|
||||
self._editor_fg = style.get("foreground", self._editor_fg)
|
||||
self._editor_bg = style.get("background", self._editor_bg)
|
||||
self.foreground = self._editor_fg
|
||||
self.background = self._editor_bg
|
||||
if theme:
|
||||
self.foreground = theme["foreground"]
|
||||
self.background = theme["background"]
|
||||
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
||||
elif host_view is not None and host_view.is_valid():
|
||||
style = host_view.style()
|
||||
self.foreground = style.get("foreground", self.foreground)
|
||||
self.background = style.get("background", self.background)
|
||||
|
||||
# user overrides sit on top of whichever palette was chosen
|
||||
user_ansi = settings().get("ansi_colors", {})
|
||||
for name in ANSI_COLORS:
|
||||
if name in user_ansi:
|
||||
self.ansi[name] = user_ansi[name]
|
||||
# user overrides sit on top of whichever palette was chosen
|
||||
user_ansi = settings().get("ansi_colors", {})
|
||||
for name in ANSI_COLORS:
|
||||
if name in user_ansi:
|
||||
self.ansi[name] = user_ansi[name]
|
||||
|
||||
# seeded so the first frame does not reference scopes the file lacks
|
||||
for fg in ANSI_COLORS:
|
||||
self._add_rule(fg, "default", "")
|
||||
self._add_rule(fg, "default", "b")
|
||||
self._add_rule("default", "default", "b")
|
||||
self._add_rule("reverse_default", "reverse_default", "")
|
||||
# seeded so the first frame does not reference scopes the file lacks
|
||||
for fg in ANSI_COLORS:
|
||||
self._add_rule(fg, "default", "")
|
||||
self._add_rule(fg, "default", "b")
|
||||
self._add_rule("default", "default", "b")
|
||||
self._add_rule("reverse_default", "reverse_default", "")
|
||||
|
||||
self._write_file()
|
||||
self._write_file()
|
||||
|
||||
def is_dark(self):
|
||||
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
|
||||
@@ -228,6 +181,10 @@ class SchemeManager:
|
||||
rule["font_style"] = " ".join(font_style)
|
||||
self.rules[(fg, bg, style)] = rule
|
||||
|
||||
def file_path(self):
|
||||
"""Filesystem path of the generated scheme file."""
|
||||
return os.path.join(self._directory(), self.file_name)
|
||||
|
||||
def _directory(self):
|
||||
"""Path of the generated scheme's directory, created on first use."""
|
||||
if self._dir is None:
|
||||
@@ -258,11 +215,49 @@ class SchemeManager:
|
||||
},
|
||||
"rules": list(self.rules.values()),
|
||||
}
|
||||
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(self.file_path(), "w", encoding="utf-8") as f:
|
||||
# compact: sublime reparses the whole file on every write
|
||||
json.dump(scheme, f, separators=(",", ":"))
|
||||
LOG.debug("scheme written: rules=%s", len(self.rules))
|
||||
|
||||
|
||||
scheme_manager = SchemeManager()
|
||||
# one manager per theme name, built once and kept for the plugin host's life: the
|
||||
# built-in theme set is small and fixed, so there is nothing to garbage-collect
|
||||
_managers = {}
|
||||
|
||||
|
||||
def get_scheme_manager(theme_name, host_view=None):
|
||||
"""The manager for a theme name (None for "follow the Sublime colour scheme"),
|
||||
building and caching it on first use.
|
||||
|
||||
The None entry is rebuilt whenever the editor's colours have changed since: it
|
||||
is a live sample of them, not a static palette, so every caller handing a
|
||||
terminal a "no theme" scheme gets one that is actually current.
|
||||
"""
|
||||
manager = _managers.get(theme_name)
|
||||
if manager is None:
|
||||
manager = SchemeManager(theme_name)
|
||||
_managers[theme_name] = manager
|
||||
manager.build(host_view)
|
||||
elif theme_name is None and host_view is not None and host_view.is_valid():
|
||||
style = host_view.style()
|
||||
if (style.get("foreground"), style.get("background")) != (manager.foreground, manager.background):
|
||||
manager.build(host_view)
|
||||
return manager
|
||||
|
||||
|
||||
def build_startup_schemes(host_view=None):
|
||||
"""Make sure every theme's colour scheme is on disk before Sublime reads a
|
||||
restored tab's color_scheme at startup.
|
||||
|
||||
A named theme is static, so an existing file is left alone - rebuilding it
|
||||
would just rewrite the same bytes, and each write makes sublime reload the
|
||||
scheme and restyle every view using it. None always rebuilds, same as
|
||||
get_scheme_manager.
|
||||
"""
|
||||
for name in ALL_THEME_NAMES:
|
||||
if name is None:
|
||||
get_scheme_manager(None, host_view=host_view)
|
||||
continue
|
||||
if not os.path.exists(SchemeManager(name).file_path()):
|
||||
get_scheme_manager(name)
|
||||
|
||||
@@ -13,7 +13,6 @@ from pyte import modes as mo
|
||||
|
||||
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
|
||||
from .keys import get_key_code
|
||||
from .scheme import scheme_manager
|
||||
from .utils import intermission, responsive, settings
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@@ -24,12 +23,24 @@ RENDER_PERIOD = 0.03
|
||||
# draw rate for a visible terminal the user is not typing in, ~15 fps
|
||||
UNFOCUSED_RENDER_PERIOD = 0.075
|
||||
|
||||
# max chars parsed per tick, so a giant burst still renders intermediate frames
|
||||
FEED_LIMIT = 256 * 1024
|
||||
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||
FEED_LIMIT = 48 * 1024
|
||||
|
||||
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||
REPAINT_NUDGE_DELAY = 0.25
|
||||
|
||||
# extra wait once a tick finds nothing pending, on top of RENDER_PERIOD.
|
||||
IDLE_TICK = 0.2
|
||||
|
||||
# pause before the first frame after an idle park, so the writes of one app
|
||||
# update land together: vim splits every keypress into two writes a fraction of
|
||||
# a millisecond apart, and drawing between them flashes its showcmd echo
|
||||
SETTLE = 0.005
|
||||
|
||||
# frames between undo-stack clears: every render edit lands on the view's undo
|
||||
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
||||
UNDO_CLEAR_FRAMES = 100
|
||||
|
||||
|
||||
def view_size(view, default=None):
|
||||
"""Terminal (rows, columns) that fit the view's viewport."""
|
||||
@@ -86,9 +97,10 @@ class Terminal:
|
||||
|
||||
_terminals = {}
|
||||
|
||||
def __init__(self, view):
|
||||
def __init__(self, view, scheme):
|
||||
"""State only; the pty and the threads come up in start()."""
|
||||
self.view = view
|
||||
self.scheme = scheme
|
||||
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||
self.alive = True
|
||||
self.default_title = ""
|
||||
@@ -116,6 +128,12 @@ class Terminal:
|
||||
self.pending_render = False
|
||||
# a UTF-8 sequence split across read chunks must not be dropped
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
# set by the reader (and the UI thread) to end the renderer's idle park
|
||||
self._wake = threading.Event()
|
||||
|
||||
def wake(self):
|
||||
"""Cut short the renderer's idle wait, for a flag set from another thread"""
|
||||
self._wake.set()
|
||||
|
||||
@classmethod
|
||||
def from_id(cls, vid):
|
||||
@@ -180,12 +198,11 @@ class Terminal:
|
||||
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
||||
clear_callback=self._clear_callback,
|
||||
clipboard_callback=self._clipboard_callback,
|
||||
fg_color=scheme_manager.foreground.lstrip("#"),
|
||||
bg_color=scheme_manager.background.lstrip("#"),
|
||||
is_dark=scheme_manager.is_dark(),
|
||||
fg_color=self.scheme.foreground.lstrip("#"),
|
||||
bg_color=self.scheme.background.lstrip("#"),
|
||||
is_dark=self.scheme.is_dark(),
|
||||
)
|
||||
self.stream = VtStream(self.screen)
|
||||
scheme_manager.add_listener(self.apply_scheme)
|
||||
|
||||
self._start_rendering()
|
||||
|
||||
@@ -193,17 +210,25 @@ class Terminal:
|
||||
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
|
||||
# the screen answers OSC 10/11 and DSR 996 from these values
|
||||
screen = self.screen
|
||||
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
|
||||
screen.default_bg_color = scheme_manager.background.lstrip("#")
|
||||
screen.theme_is_dark = scheme_manager.is_dark()
|
||||
screen.default_fg_color = self.scheme.foreground.lstrip("#")
|
||||
screen.default_bg_color = self.scheme.background.lstrip("#")
|
||||
screen.theme_is_dark = self.scheme.is_dark()
|
||||
if screen.theme_notify_enabled():
|
||||
self.send_string(screen.theme_report(), normalized=False)
|
||||
|
||||
def retheme(self, scheme):
|
||||
"""Switch to a different scheme: vterm: Select Theme, scoped to this terminal."""
|
||||
if scheme is self.scheme:
|
||||
return
|
||||
self.scheme = scheme
|
||||
self.view.settings().set("color_scheme", scheme.path)
|
||||
self.apply_scheme()
|
||||
|
||||
def kill(self):
|
||||
"""Terminate the child and drop this terminal from the registry."""
|
||||
LOG.debug("[kill] terminating process")
|
||||
self.alive = False
|
||||
scheme_manager.remove_listener(self.apply_scheme)
|
||||
self._wake.set()
|
||||
try:
|
||||
self.process.terminate()
|
||||
except Exception:
|
||||
@@ -254,11 +279,13 @@ class Terminal:
|
||||
except EOFError:
|
||||
break
|
||||
chunks.append(self._decoder.decode(raw))
|
||||
self._wake.set()
|
||||
if done[0] or not self.is_hosted():
|
||||
LOG.debug("[reader] stops")
|
||||
break
|
||||
self.alive = False
|
||||
done[0] = True
|
||||
self._wake.set()
|
||||
|
||||
def feed_data():
|
||||
"""Parse up to FEED_LIMIT of pending output into the screen."""
|
||||
@@ -278,11 +305,13 @@ class Terminal:
|
||||
|
||||
def renderer():
|
||||
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
||||
frames = 0
|
||||
while True:
|
||||
# a hidden tab is not drawn at all: the screen keeps the state
|
||||
# and one render on unhide catches the view up. The tick stays at
|
||||
# full rate either way, or the parser would fall behind the pty
|
||||
# and one render on unhide catches the view up.
|
||||
visible = self.is_visible()
|
||||
|
||||
self._wake.clear()
|
||||
with intermission(period=RENDER_PERIOD):
|
||||
feed_data()
|
||||
if self.screen.in_sync_frame():
|
||||
@@ -295,11 +324,26 @@ class Terminal:
|
||||
# due first: _need_to_render consumes the cursor move it reports
|
||||
if self.is_render_due() and self._need_to_render():
|
||||
self.view.run_command("vterm_render")
|
||||
frames += 1
|
||||
if frames >= UNDO_CLEAR_FRAMES:
|
||||
frames = 0
|
||||
self.view.run_command("vterm_clear_undo_stack")
|
||||
self._sync_wheel_flag()
|
||||
if done[0] or not self.is_hosted():
|
||||
LOG.debug("[renderer] stops")
|
||||
break
|
||||
|
||||
# nothing left to do: park until woken, instead of spinning at
|
||||
# RENDER_PERIOD. A sync frame or a teardown skipped this by
|
||||
# leaving the block above, and keeps its own cadence.
|
||||
# dirty rows only count while visible: nothing clears them for a
|
||||
# hidden tab, and on_activated wakes us with pending_render
|
||||
if not (
|
||||
chunks or self.pending_render or self.pending_clear or self.pending_clear_screen or (visible and self.screen.dirty)
|
||||
):
|
||||
if self._wake.wait(IDLE_TICK):
|
||||
time.sleep(SETTLE)
|
||||
|
||||
while chunks:
|
||||
feed_data()
|
||||
done[0] = True
|
||||
|
||||
@@ -50,4 +50,27 @@ THEMES = {
|
||||
"light_cyan": "#8cdfe0",
|
||||
"light_white": "#e0e0e0",
|
||||
},
|
||||
"red-alert": {
|
||||
"foreground": "#ffffff",
|
||||
"background": "#762423",
|
||||
"black": "#000000",
|
||||
"red": "#d62e4e",
|
||||
"green": "#71be6b",
|
||||
"brown": "#beb86b",
|
||||
"blue": "#489bee",
|
||||
"magenta": "#e979d7",
|
||||
"cyan": "#6bbeb8",
|
||||
"white": "#d6d6d6",
|
||||
"light_black": "#262626",
|
||||
"light_red": "#e02553",
|
||||
"light_green": "#aff08c",
|
||||
"light_brown": "#dfddb7",
|
||||
"light_blue": "#65aaf1",
|
||||
"light_magenta": "#ddb7df",
|
||||
"light_cyan": "#b7dfdd",
|
||||
"light_white": "#ffffff",
|
||||
},
|
||||
}
|
||||
|
||||
# every selectable theme, including None for "follow the active Sublime colour scheme"
|
||||
ALL_THEME_NAMES = [None] + sorted(THEMES)
|
||||
|
||||
Reference in New Issue
Block a user