Compare commits
5 Commits
e150591763
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5564f4aa | ||
|
|
9656faa385 | ||
|
|
c2a0c75359 | ||
|
|
e6aa754762 | ||
|
|
76927a85ea |
326
Architecture.md
326
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. |
|
||||
@@ -73,10 +52,11 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
||||
`Char` carrying the extra `linefeed` field.
|
||||
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
||||
generated scheme file and the mapping from colour combination to scope name.
|
||||
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
|
||||
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
|
||||
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
|
||||
`_lastrow` are that view's render state.
|
||||
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
|
||||
`TextCommand` instance whenever any plugin loads, so the view's render state
|
||||
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
||||
bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
|
||||
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -100,298 +80,6 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
||||
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
|
||||
`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) ───────────────
|
||||
│ _wake.wait(IDLE_TICK) idle: park until the reader has output,
|
||||
│ then SETTLE, so the writes of one app
|
||||
│ update are drawn as a single frame
|
||||
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
||||
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
||||
│ mutates buffer, fills screen.dirty
|
||||
│ in_sync_frame()? mid ?2026 frame → skip this tick
|
||||
│ is_visible()? hidden tab → draw nothing this tick
|
||||
│ was_resized()? (checked once a second) → resize pty
|
||||
│ is_render_due()? unfocused → at most one frame per
|
||||
│ UNFOCUSED_RENDER_PERIOD
|
||||
│ _need_to_render()? dirty rows, a pending clear, or a
|
||||
│ moved cursor
|
||||
│ view.run_command("vterm_render")
|
||||
│ _sync_wheel_flag() publishes the mousemap gate
|
||||
│
|
||||
└─ UI thread ──────────────────────────────────────────────────
|
||||
VtermRenderCommand.run(edit)
|
||||
update_lines() → drain screen.history above `offset`,
|
||||
then each dirty screen row
|
||||
update_line() → segment_buffer_line() → row-cache check
|
||||
→ view.replace() + add_regions()
|
||||
sync_alt_fold()
|
||||
follow-scroll gate → trim, focus_cursor()*, request_scroll()
|
||||
sync_cursor_style(), set_name()*, dirty.clear()
|
||||
* focused terminals only
|
||||
```
|
||||
|
||||
**Rendering off focus.** A terminal only touches its view the way the user would while it
|
||||
holds focus. Sublime dispatches view events application-wide, and packages hang global
|
||||
state off them: Vintage's `InputStateTracker.on_selection_modified` resets one module-level
|
||||
`g_input_state` for every view in every window, so a caret this plugin moved at 33 fps
|
||||
erased the half-typed `d` of a `dd` in whatever file the user was editing. Sublime drops a
|
||||
pending key sequence over the same kind of traffic, which took `gg` and `ciw` with it.
|
||||
|
||||
Three states, from `Terminal.focused` (set by `on_activated` / `on_deactivated`) and
|
||||
`Terminal.is_visible()` (polled once a second, because selecting another tab in an
|
||||
unfocused group raises no event for this view):
|
||||
|
||||
| state | text | caret, `sel` | tab name | follow-scroll | draw rate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| focused | yes | yes | yes | yes | 33 fps |
|
||||
| visible, unfocused | yes | no | no | yes | 15 fps |
|
||||
| hidden | no | no | no | no | none |
|
||||
|
||||
A frame costs a command dispatch and a dozen UI round trips whatever changed — two
|
||||
`rowcol(size())`, `folded_regions()`, `viewport_position()`, the `trim_trailing_spaces` scan,
|
||||
then `layout_extent()` / `text_to_layout()` / `set_viewport_position()` — so the rate a
|
||||
terminal is drawn at is what the editor pays for it, and only a focused one is worth 33 fps.
|
||||
|
||||
The renderer tick itself stays at `RENDER_PERIOD` in every state: `feed_data()` runs inside it,
|
||||
and throttling that would cap the parser at `FEED_LIMIT` per tick while the reader keeps filling
|
||||
an unbounded `chunks`. Only the drawing is rate-limited. So the emulator is always current and
|
||||
one render on activation catches the view up. An unfocused terminal is left with an empty selection —
|
||||
nothing a render edit can shift, so no selection event of ours escapes — unless the user
|
||||
selected text to copy, which is kept.
|
||||
|
||||
**The view/offset model.** The view holds real text. Rows `0 .. offset-1` are scrollback;
|
||||
rows `offset .. offset + screen.lines - 1` are the live screen. `terminal.offset` is the
|
||||
view row of screen row 0. When the emulator retires lines into `screen.history`, the
|
||||
renderer drains them, advances `offset` by that many rows, and writes them at their now
|
||||
permanent view rows. **Once a line leaves `screen.history`, its only copy is the view
|
||||
text** — which is why the alternate screen *folds* the scrollback rather than deleting it.
|
||||
|
||||
**The row cache.** pyte's `dirty` set is a repaint *hint*, not a diff: scrolling one line
|
||||
marks every row dirty, and a TUI repaints whole frames. `update_line` compares
|
||||
`(segments, lf)` against `_row_cache` and returns early when unchanged, which is what
|
||||
keeps a full-screen repaint from costing hundreds of cross-process calls.
|
||||
|
||||
**The follow-scroll gate.** `following()` compares the viewport against
|
||||
`terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled
|
||||
up by hand, and trimming, caret placement and scrolling are all skipped so their position
|
||||
survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate
|
||||
the same way, and scrolling down clamps to the anchor `bottom_y` returns, which re-opens
|
||||
it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate
|
||||
and one Down re-opens it.
|
||||
|
||||
### How colour is actually drawn
|
||||
|
||||
`view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a
|
||||
colour. The colours must already exist in the view's colour scheme. So vterm generates
|
||||
the colour scheme at runtime:
|
||||
|
||||
1. `segment_buffer_line` splits a screen row into runs of identical attributes.
|
||||
2. For each non-default run, `scheme_manager.scope(fg, bg, style)` returns a scope name
|
||||
such as `vterm.red.default.b`. On a **miss** it creates the rule and schedules a
|
||||
write; the hot path is a single lock-free dict read.
|
||||
3. `colorize_line` calls `add_regions` once per distinct scope in the row, with keys
|
||||
`vterm#N` — monotonic rather than per-row, because `trim_history` renumbers rows and
|
||||
per-row keys would collide. `colored_lines[row]` remembers them so they can be erased.
|
||||
Rows that scroll off the screen are queued by `archive_rows` and merged by
|
||||
`flush_chunk` onto shared `vterm@chunk@scope` keys (one per scope per `CHUNK` rows
|
||||
of scrollback), their per-row keys erased — every key is a separate pass in Sublime's
|
||||
redraw, and a long coloured session used to accumulate ~15k of them. The merge waits
|
||||
for the chunk to close: `add_regions` replaces a key's whole set, so merging every
|
||||
frame re-sent everything the chunk held so far, which is quadratic in `CHUNK` when
|
||||
output arrives a few rows at a time. The open chunk's rows keep their per-row keys
|
||||
until then, so the live count is scopes x chunks plus at most one chunk's worth.
|
||||
Chunk ordinals are view row + `_trimmed`, so `trim_history` renumbering cannot move
|
||||
a row between chunks; trimming settles the open chunk first, then erases whole
|
||||
chunks' keys and compacts the boundary chunk.
|
||||
4. `_write_file` serializes every rule into
|
||||
`Packages/User/vterm/vterm.hidden-color-scheme`, the path the view's `color_scheme`
|
||||
setting points at. **Each write makes Sublime reload the scheme and restyle every
|
||||
view**, so writes are debounced 250 ms onto the async thread.
|
||||
|
||||
Details worth knowing before touching `scheme.py`:
|
||||
|
||||
- Rules are **seeded** at build time for all 16 foregrounds (plain and bold) so the very
|
||||
first frame never references a scope the file lacks.
|
||||
- If a rule's resolved background equals the global background *exactly*, it is nudged by
|
||||
one (`next_color`) — `add_regions` inverts foreground and background when they match.
|
||||
- Colours are resolved when a rule is added, so a palette change cannot append: `_build`
|
||||
rebuilds the whole table.
|
||||
- Truecolor SGR is quantized into the 256 palette (`quantize_256`), which bounds the rule
|
||||
table: every distinct pair would otherwise be a permanent rule and another file write.
|
||||
- `plugin_loaded` writes the file **synchronously**, because a restored tab's
|
||||
`color_scheme` is read during startup.
|
||||
|
||||
### The alternate screen
|
||||
|
||||
While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so
|
||||
the view shows the app's screen alone. The fold is re-checked every frame, not just on
|
||||
transitions, because the screen's first row is rewritten on every region scroll and an
|
||||
edit at the fold boundary can silently break a fold. Entering the alt screen collapses the
|
||||
layout, so that transition also zeroes `terminal.viewport_y`; a stale anchor would hold
|
||||
the follow-scroll gate shut. A fold merely repaired keeps the anchor, or the boundary row
|
||||
apps rewrite on every scroll would reset it every frame. The emulator side
|
||||
(`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its
|
||||
frames into scrollback.
|
||||
|
||||
### Input: a key press reaches the app
|
||||
|
||||
Almost every key, **including every printable character**, is bound to `vterm_keypress`.
|
||||
That is deliberate: if printables were left to Sublime, an app that does not echo (vim in
|
||||
normal mode) would leave the typed character sitting in the view.
|
||||
|
||||
```
|
||||
keymap binding (context: setting.vterm_view)
|
||||
→ VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…)
|
||||
→ view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page
|
||||
and Up/Down a row; stops here, never on the alt screen
|
||||
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
|
||||
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
|
||||
enter/tab/backspace/space, modified, while the app
|
||||
asked for modifyOtherKeys → CSI 27;<mod>;<code>~
|
||||
ctrl → control byte; alt → ESC prefix
|
||||
plain → _KEY_MAP / _APP_MODE_KEY_MAP
|
||||
→ Terminal.send_string(normalized=False)
|
||||
→ pty write ≤4096 bytes inline, larger goes through a queue
|
||||
```
|
||||
|
||||
**IME and composed input** cannot work this way — there is no key event for "the IME
|
||||
committed 日本語"; the text arrives as Sublime's own `insert` command. `on_modified`
|
||||
catches that: it reconstructs what was inserted from the caret positions (not from
|
||||
`args["characters"]`, which reports the typed keys rather than the committed
|
||||
composition), forwards it as an unbracketed paste, and `soft_undo`s any other command
|
||||
that modified the view. Text the emulator did not produce must never remain in the view.
|
||||
|
||||
**Copy and paste** are rerouted by `on_text_command`: `copy` → `vterm_copy` (which strips
|
||||
the zero-width `CONTINUATION` sentinel so soft-wrapped lines re-join), `paste` →
|
||||
`vterm_paste`, and `undo` → `vterm_noop`. Pastes are wrapped in `ESC[200~` / `ESC[201~`
|
||||
when the app enabled bracketed paste. In the other direction, OSC 52 from the app reaches
|
||||
the system clipboard via a hop to the UI thread.
|
||||
|
||||
### Mouse
|
||||
|
||||
Once per tick, `_sync_wheel_flag` publishes `vterm_view.wheel_captured` = "the app
|
||||
reports the mouse, or owns the alternate screen". The mousemap's binding is gated on it,
|
||||
which produces three cases:
|
||||
|
||||
1. **App tracks the mouse** → `vterm_scroll` maps the event to a screen cell and sends
|
||||
three SGR wheel reports, *one per write* — pi matches the SGR form against a whole
|
||||
stdin chunk and drops anything longer.
|
||||
2. **Alternate screen, no tracking** → three arrow keys per notch (alternate scroll, as
|
||||
kitty/iTerm2/xterm do). This is what scrolls a fullscreen UI before it enables mouse
|
||||
reporting.
|
||||
3. **Neither** → the binding does not match at all and Sublime scrolls the view over the
|
||||
real scrollback text.
|
||||
|
||||
**The trackpad** fires none of that: on macOS it never reaches a mousemap binding
|
||||
(sublime_text#1838) and dispatches no command, so a plugin cannot see it at all. Its only
|
||||
in-API trace is the viewport moving, and reading a gesture that way means letting the view
|
||||
move before pinning it back — visibly, at every poll. vterm does not try: the trackpad is
|
||||
converted outside the plugin, by a utility such as LinearMouse set to discrete scrolling
|
||||
for Sublime Text, and arrives as ordinary wheel notches on the path above. Nothing in the
|
||||
plugin is trackpad-aware, and the viewport is never moved to measure input.
|
||||
|
||||
Clicks: `VtermMouseEventListener` intercepts `drag_select` only when the app tracks the
|
||||
mouse, and only the plain single-button form, so shift-click and add-selection stay
|
||||
native. The first click on an unfocused tab only focuses it — we replaced the command
|
||||
that normally does that — and subsequent clicks send a press/release pair.
|
||||
|
||||
**The middle button** pastes the view's own selection into the app, as X11 does, and
|
||||
never touches the clipboard. The binding is a `press_command`, not a `command`: Sublime's
|
||||
own `button3` on macOS is a column `drag_select`, which would clear the selection on
|
||||
press before a release command could read it. Shadowing it costs middle-drag column
|
||||
select inside terminal tabs; the context makes the binding fall through everywhere else.
|
||||
It is not gated on mouse reporting — an empty selection is already a no-op, and the alt
|
||||
screen rarely holds one.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- **Tab closed** → `on_pre_close` → `Terminal.kill()`: unsubscribe, terminate the child,
|
||||
drop the registry entry.
|
||||
- **Process exits** → reader gets `EOFError`, sets `alive = False`; the renderer drains
|
||||
the last output, then posts `vterm_cleanup` to the UI thread. Cleanup latches
|
||||
`vterm_view.finished`, renders a final frame, kills the terminal, and either closes the
|
||||
tab (`auto_close`) or appends `[process exited with code N]`.
|
||||
- **Plugin host restart** → the registry is gone but the view survives with its settings
|
||||
and text. `on_activated` detects a view marked `vterm_view` with no terminal behind it
|
||||
and shows a "disconnected" status: the tab has to be closed and reopened.
|
||||
|
||||
## Threading
|
||||
|
||||
| Thread | Runs | Touches |
|
||||
|---|---|---|
|
||||
| UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, clipboard | Sublime API; small pty writes go out here too |
|
||||
| reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state |
|
||||
| renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator |
|
||||
| paste drain | transient; empties the 4096-byte chunk queue | pty only |
|
||||
| ST async worker | `SchemeManager._flush` | the scheme file |
|
||||
|
||||
The emulator needs no lock because exactly one thread mutates it. The chunk deque needs
|
||||
none either: `append` and `popleft` are atomic under the GIL. This matters — the renderer
|
||||
used to hold a lock across `stream.feed` **and** the whole render command, so the reader
|
||||
could not drain the pty for the duration of a frame and the child blocked in `write()`.
|
||||
Backpressure now lives in `FEED_LIMIT` instead: at most 256 KiB is parsed per tick, so a
|
||||
huge burst still renders intermediate frames while the reader keeps draining.
|
||||
|
||||
`SchemeManager` does keep a lock, because `scope()` is called from the renderer while
|
||||
`reload()` and `_flush()` run on other threads. Its read fast path skips the lock; the
|
||||
invariant that makes that safe is that `_names` must never outlive `rules` — `_build`
|
||||
replaces both together.
|
||||
|
||||
## Sublime settings
|
||||
|
||||
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
|
||||
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
|
||||
`scroll_modifier`, `wheel_lines`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
|
||||
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
|
||||
into a scroll of the view — a page, a row — instead of a key for the app. `wheel_lines`
|
||||
is how many scroll lines one wheel notch sends the app (3, as iTerm2 delivers per notch;
|
||||
a trackpad converted to notches scrolls that much faster, so lower it). `terminal` and
|
||||
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project
|
||||
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
|
||||
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
|
||||
pi and Claude Code repaint by erasing
|
||||
saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the
|
||||
palette and restyles open terminals in place.
|
||||
|
||||
**Stamped on every terminal view** by `vterm_initialize_view`: `vterm_view` (the marker
|
||||
every context and hook keys off), `is_widget`, `color_scheme` (the generated scheme),
|
||||
`syntax` (the empty one), `scroll_past_end` (off — the view never scrolls below the last
|
||||
line), `scroll_context_lines`, and a list of disables — `gutter`, `highlight_line`,
|
||||
`word_wrap`, `auto_complete`, `draw_white_space`, `draw_indent_guides`, `spell_check`,
|
||||
`translate_tabs_to_spaces`, `command_mode` (which
|
||||
defuses Vintage mode inside a terminal). The view is also set scratch, so closing it never
|
||||
prompts to save.
|
||||
|
||||
**Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the
|
||||
Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and
|
||||
`block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block).
|
||||
|
||||
**Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in
|
||||
Sublime's own process — a plugin `on_query_context` would cost a round trip on every
|
||||
keystroke in every view. Mousemaps do **not** evaluate `setting.` contexts the same way,
|
||||
so the single wheel context still goes through `on_query_context`, which answers that one
|
||||
key and returns `None` for everything else.
|
||||
|
||||
---
|
||||
|
||||
|
||||
29
README.md
29
README.md
@@ -11,21 +11,25 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
||||
|
||||
### With Package Control
|
||||
|
||||
vterm is not on the default channel, so add its repository first. Open
|
||||
**Preferences → Package Settings → Package Control → Settings** and put the URL
|
||||
below in the `repositories` array of the right-hand (User) pane:
|
||||
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
|
||||
{
|
||||
"repositories": [
|
||||
"https://raw.githubusercontent.com/vaclavt/vterm/main/repository.json"
|
||||
"channels": [
|
||||
"https://packagecontrol.io/channel_v3.json",
|
||||
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Then run **Package Control: Install Package** from the Command Palette and pick
|
||||
**vterm**. Upgrades arrive through **Package Control: Upgrade Package** like any
|
||||
other package.
|
||||
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
|
||||
|
||||
@@ -100,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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
|
||||
assert os.path.exists(scheme_file), \
|
||||
"plugin_loaded must leave the colour scheme on disk"
|
||||
"plugin_loaded must leave {}'s colour scheme on disk".format(name)
|
||||
|
||||
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
||||
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
||||
|
||||
@@ -12,11 +12,13 @@ from vterm.emulator import VtScreen, VtStream
|
||||
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
|
||||
from vterm.render import (
|
||||
CHUNK,
|
||||
RenderState,
|
||||
VtermRenderCommand,
|
||||
focus_cursor,
|
||||
following,
|
||||
scroll_to_cursor,
|
||||
)
|
||||
from vterm.scheme import get_scheme_manager
|
||||
from vterm.terminal import Terminal
|
||||
from vterm.utils import SETTINGS_FILE
|
||||
|
||||
@@ -157,7 +159,7 @@ class FakeView:
|
||||
|
||||
def make_terminal(rows=10, cols=20, scrollback=None):
|
||||
# real __init__ for the plain attributes; process/screen stand in for start()
|
||||
terminal = Terminal(FakeView())
|
||||
terminal = Terminal(FakeView(), get_scheme_manager("default"))
|
||||
if scrollback is not None:
|
||||
terminal.scrollback_size = scrollback
|
||||
terminal.process = FakeProcess()
|
||||
@@ -165,9 +167,14 @@ def make_terminal(rows=10, cols=20, scrollback=None):
|
||||
return terminal, VtStream(terminal.screen)
|
||||
|
||||
|
||||
def make_command(view):
|
||||
# the real __init__, so the fake cannot drift from the command's attributes
|
||||
return VtermRenderCommand(view)
|
||||
def make_command(terminal):
|
||||
# the real __init__, so the fake cannot drift from the command's attributes;
|
||||
# run() itself sets _scheme, but these tests call its helpers directly
|
||||
command = VtermRenderCommand(terminal.view)
|
||||
command._scheme = terminal.scheme
|
||||
terminal.render_state = RenderState()
|
||||
command.state = terminal.render_state
|
||||
return command
|
||||
|
||||
|
||||
# an app on the alternate screen must not have its view trimmed away: the
|
||||
@@ -178,7 +185,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, \
|
||||
@@ -188,7 +195,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
|
||||
@@ -199,7 +206,7 @@ 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
|
||||
@@ -209,7 +216,7 @@ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||
terminal, stream = make_terminal(rows=4)
|
||||
stream.feed("\x1b[?1049h")
|
||||
terminal.view = FakeView("top\nrow\n\n")
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
before = terminal.view.text
|
||||
command.trim_trailing_spaces(None, terminal)
|
||||
assert terminal.view.text == before, \
|
||||
@@ -218,7 +225,7 @@ assert terminal.view.text == before, \
|
||||
# on the primary screen the blank tail below the cursor still goes
|
||||
terminal, stream = make_terminal(rows=4)
|
||||
terminal.view = FakeView("top\nrow\n\n")
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
command.trim_trailing_spaces(None, terminal)
|
||||
assert terminal.view.text == "top\nrow", terminal.view.text
|
||||
|
||||
@@ -261,7 +268,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
||||
# batches the runs that share a scope into one add_regions call
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
||||
command.update_lines(None, terminal)
|
||||
|
||||
@@ -278,7 +285,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
|
||||
assert len(painted) == 2, painted
|
||||
|
||||
# keys are unique per view and recorded so decolorize_line can erase them
|
||||
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
||||
assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
|
||||
"colored_lines must track exactly the keys that were added"
|
||||
command.decolorize_line(0)
|
||||
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||
@@ -286,12 +293,35 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||
|
||||
|
||||
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
|
||||
# bookkeeping lives on the terminal so a rebuilt command still erases what the
|
||||
# old one painted, and its keys never collide with keys still in use
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal)
|
||||
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
|
||||
command.update_lines(None, terminal)
|
||||
terminal.screen.dirty.clear()
|
||||
assert terminal.view.regions, "the red row must be painted"
|
||||
# a reload: new command, same terminal
|
||||
reloaded = VtermRenderCommand(terminal.view)
|
||||
reloaded._scheme = terminal.scheme
|
||||
reloaded.state = terminal.render_state
|
||||
stream.feed("\x1b[H\x1b[2Kgone")
|
||||
reloaded.update_lines(None, terminal)
|
||||
assert terminal.view.regions == {}, \
|
||||
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
|
||||
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
|
||||
|
||||
print("reload tests passed (render state survives a rebuilt command)")
|
||||
|
||||
|
||||
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||
# the whole session, and merging every frame would re-send the whole chunk
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
for i in range(300):
|
||||
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
||||
command.update_lines(None, terminal)
|
||||
@@ -310,14 +340,14 @@ assert len(row_keys) == 300 - CHUNK, \
|
||||
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
||||
terminal, stream = make_terminal(rows=3, scrollback=30)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||
command.update_lines(None, terminal)
|
||||
terminal.screen.dirty.clear()
|
||||
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
||||
assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
|
||||
command.trim_history(None, terminal)
|
||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||
assert 0 not in command._archived_chunks
|
||||
assert 0 not in command.state.archived_chunks
|
||||
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
||||
|
||||
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
||||
@@ -332,7 +362,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
|
||||
Terminal._terminals[id(terminal)] = terminal
|
||||
terminal.view.id = lambda: id(terminal)
|
||||
terminal.default_title = "zsh"
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
stream.feed("[vterm] %")
|
||||
command.run(None)
|
||||
assert all(r.empty() for r in terminal.view.sel()), \
|
||||
@@ -359,10 +389,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
|
||||
print("render diff tests passed (unchanged dirty rows skipped)")
|
||||
|
||||
# the generated scheme must define a selection colour, or sublime uses blue
|
||||
from vterm.scheme import blend, scheme_manager
|
||||
scheme_manager.background = "#303841"
|
||||
scheme_manager.foreground = "#d8dee9"
|
||||
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
|
||||
from vterm.scheme import blend
|
||||
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
|
||||
|
||||
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
||||
|
||||
@@ -370,7 +398,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)
|
||||
@@ -621,7 +649,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
||||
terminal.offset = 3
|
||||
terminal.viewport_y = 400
|
||||
command = make_command(terminal.view)
|
||||
command = make_command(terminal)
|
||||
fold_end = terminal.view.text_point(3, 0) - 1
|
||||
|
||||
stream.feed("\x1b[?1049h")
|
||||
@@ -649,7 +677,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
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
"min_columns": 20,
|
||||
"max_columns": 500,
|
||||
|
||||
// colour palette: "default", "homebrew", "pro", "slate", or null to
|
||||
// follow the active Sublime colour scheme. Applies to open tabs too.
|
||||
// colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
|
||||
// to follow the active Sublime colour scheme. Default for terminals opened
|
||||
// from now on; vterm: Select Theme retints the focused terminal and sets this.
|
||||
"theme": null,
|
||||
|
||||
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
122
vterm/render.py
122
vterm/render.py
@@ -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
|
||||
|
||||
@@ -117,32 +116,49 @@ def ensure_position(view, edit, row, col=0):
|
||||
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
||||
|
||||
|
||||
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
"""Draws the screen into the view. Sublime keeps one instance per view, so
|
||||
the caches below are that view's render state."""
|
||||
class RenderState:
|
||||
"""A view's render bookkeeping, kept on its Terminal.
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Set up this view's render caches."""
|
||||
super().__init__(*args, **kwargs)
|
||||
Sublime rebuilds every TextCommand instance of every view whenever any
|
||||
plugin module loads. State held on the command is lost then: the overlays
|
||||
already in the view become untracked, so a row rewrite no longer erases
|
||||
them, and the key counter restarts and hands out keys still in use.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Empty bookkeeping, for a view that has nothing painted yet."""
|
||||
# view row -> region keys painted on it
|
||||
self.colored_lines = {}
|
||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||
# repaint hint, so most dirty rows are identical frame to frame
|
||||
self._row_cache = {}
|
||||
self._key_counter = 0
|
||||
self.row_cache = {}
|
||||
self.key_counter = 0
|
||||
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||
# rows trimmed so far, so they survive trim_history renumbering
|
||||
self._archived_chunks = {}
|
||||
self.archived_chunks = {}
|
||||
# rows waiting for their chunk to close, and the chunk they belong to
|
||||
self._open_rows = []
|
||||
self._open_chunk = None
|
||||
self._trimmed = 0
|
||||
self.open_rows = []
|
||||
self.open_chunk = None
|
||||
self.trimmed = 0
|
||||
|
||||
|
||||
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
"""Draws the screen into the view. Holds nothing a frame depends on: the
|
||||
view's bookkeeping is `terminal.render_state` (see RenderState)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Per-instance scratch only; the render state is bound in run()."""
|
||||
super().__init__(*args, **kwargs)
|
||||
# the terminal's current SchemeManager and RenderState; set at the top of each run()
|
||||
self._scheme = None
|
||||
self.state = None
|
||||
self._lastrow = 0
|
||||
self._applied_cursor_style = None
|
||||
|
||||
def next_key(self):
|
||||
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
||||
self._key_counter += 1
|
||||
return "vterm#{}".format(self._key_counter)
|
||||
self.state.key_counter += 1
|
||||
return "vterm#{}".format(self.state.key_counter)
|
||||
|
||||
def ensure_row(self, edit, row):
|
||||
"""Row-existence check for the hot path, tracking the last row in python"""
|
||||
@@ -156,6 +172,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
terminal = Terminal.from_id(view.id())
|
||||
if not terminal:
|
||||
return
|
||||
self._scheme = terminal.scheme
|
||||
if terminal.render_state is None:
|
||||
terminal.render_state = RenderState()
|
||||
self.state = terminal.render_state
|
||||
|
||||
screen = terminal.screen
|
||||
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||
@@ -254,16 +274,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
screen = terminal.screen
|
||||
|
||||
view.replace(edit, sublime.Region(0, view.size()), "")
|
||||
for line in list(self.colored_lines):
|
||||
for line in list(self.state.colored_lines):
|
||||
self.decolorize_line(line)
|
||||
for chunk, scopes in self._archived_chunks.items():
|
||||
for chunk, scopes in self.state.archived_chunks.items():
|
||||
for scope in scopes:
|
||||
view.erase_regions(chunk_key(chunk, scope))
|
||||
self._archived_chunks.clear()
|
||||
self._open_rows = []
|
||||
self._open_chunk = None
|
||||
self._trimmed = 0
|
||||
self._row_cache.clear()
|
||||
self.state.archived_chunks.clear()
|
||||
self.state.open_rows = []
|
||||
self.state.open_chunk = None
|
||||
self.state.trimmed = 0
|
||||
self.state.row_cache.clear()
|
||||
terminal.offset = 0
|
||||
screen.history.clear()
|
||||
if wipe_screen:
|
||||
@@ -310,17 +330,18 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
||||
# back identical, and rewriting one costs an edit plus its region churn
|
||||
rendered = (segments, lf)
|
||||
if self._row_cache.get(line) == rendered:
|
||||
if self.state.row_cache.get(line) == rendered:
|
||||
return
|
||||
self._row_cache[line] = rendered
|
||||
self.state.row_cache[line] = rendered
|
||||
|
||||
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:
|
||||
@@ -340,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
def colorize_line(self, line, line_start, by_scope):
|
||||
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||
view = self.view
|
||||
keys = self.colored_lines.setdefault(line, []) if by_scope else None
|
||||
keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
|
||||
for scope, spans in by_scope.items():
|
||||
key = self.next_key()
|
||||
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
||||
@@ -348,21 +369,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
|
||||
def decolorize_line(self, line):
|
||||
"""Drop a row's colour overlays before it is rewritten or erased."""
|
||||
if line in self.colored_lines:
|
||||
for key in self.colored_lines[line]:
|
||||
if line in self.state.colored_lines:
|
||||
for key in self.state.colored_lines[line]:
|
||||
self.view.erase_regions(key)
|
||||
del self.colored_lines[line]
|
||||
del self.state.colored_lines[line]
|
||||
|
||||
def archive_rows(self, rows):
|
||||
"""Queue rows that left the screen for their chunk's merge."""
|
||||
for row in rows:
|
||||
if row not in self.colored_lines:
|
||||
if row not in self.state.colored_lines:
|
||||
continue
|
||||
chunk = (row + self._trimmed) // CHUNK
|
||||
if chunk != self._open_chunk:
|
||||
chunk = (row + self.state.trimmed) // CHUNK
|
||||
if chunk != self.state.open_chunk:
|
||||
self.flush_chunk()
|
||||
self._open_chunk = chunk
|
||||
self._open_rows.append(row)
|
||||
self.state.open_chunk = chunk
|
||||
self.state.open_rows.append(row)
|
||||
|
||||
def flush_chunk(self):
|
||||
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||
@@ -373,28 +394,29 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
whole set, so merging every frame would be quadratic in CHUNK.
|
||||
"""
|
||||
view = self.view
|
||||
rows, chunk = self._open_rows, self._open_chunk
|
||||
self._open_rows = []
|
||||
rows, chunk = self.state.open_rows, self.state.open_chunk
|
||||
self.state.open_rows = []
|
||||
if not rows:
|
||||
return
|
||||
|
||||
grouped = {}
|
||||
stale = []
|
||||
scope_of = self._scheme.scope
|
||||
for row in rows:
|
||||
keys = self.colored_lines.pop(row, None)
|
||||
keys = self.state.colored_lines.pop(row, None)
|
||||
if not keys:
|
||||
continue
|
||||
stale += keys
|
||||
# the segments that were painted, straight from the row cache
|
||||
segments = self._row_cache[row][0]
|
||||
segments = self.state.row_cache[row][0]
|
||||
start = view.line(view.text_point(row, 0)).begin()
|
||||
for _, a, b, fg, bg, style in segments:
|
||||
if is_default_run(fg, bg, style):
|
||||
continue
|
||||
scope = scheme_manager.scope(fg, bg, style)
|
||||
scope = scope_of(fg, bg, style)
|
||||
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||
|
||||
scopes = self._archived_chunks.setdefault(chunk, set())
|
||||
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||
for scope, regions in grouped.items():
|
||||
key = chunk_key(chunk, scope)
|
||||
if scope in scopes:
|
||||
@@ -427,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
while row > cursor_row:
|
||||
line_region = view.line(view.text_point(row, 0))
|
||||
text = view.substr(line_region)
|
||||
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
|
||||
if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
|
||||
row = row - 1
|
||||
else:
|
||||
break
|
||||
@@ -441,7 +463,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
||||
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
||||
view.erase(edit, trailing_region)
|
||||
self._row_cache.pop(row, None)
|
||||
self.state.row_cache.pop(row, None)
|
||||
|
||||
def trim_history(self, edit, terminal):
|
||||
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
||||
@@ -455,8 +477,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
||||
for line in range(m):
|
||||
self.decolorize_line(line)
|
||||
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
|
||||
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
|
||||
self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
|
||||
self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
|
||||
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
||||
view.erase(edit, top_region)
|
||||
terminal.offset -= m
|
||||
@@ -464,12 +486,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
|
||||
# chunks wholly above the cut go; the boundary chunk drops the
|
||||
# regions the erase collapsed to empty
|
||||
self._trimmed += m
|
||||
cut = self._trimmed // CHUNK
|
||||
for chunk in [c for c in self._archived_chunks if c < cut]:
|
||||
for scope in self._archived_chunks.pop(chunk):
|
||||
self.state.trimmed += m
|
||||
cut = self.state.trimmed // CHUNK
|
||||
for chunk in [c for c in self.state.archived_chunks if c < cut]:
|
||||
for scope in self.state.archived_chunks.pop(chunk):
|
||||
view.erase_regions(chunk_key(chunk, scope))
|
||||
for scope in self._archived_chunks.get(cut, ()):
|
||||
for scope in self.state.archived_chunks.get(cut, ()):
|
||||
key = chunk_key(cut, scope)
|
||||
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
||||
|
||||
@@ -478,7 +500,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
for line in view.lines(tail_region):
|
||||
row = view.rowcol(line.begin())[0]
|
||||
self.decolorize_line(row)
|
||||
self._row_cache.pop(row, None)
|
||||
self.state.row_cache.pop(row, None)
|
||||
view.erase(edit, tail_region)
|
||||
|
||||
|
||||
|
||||
129
vterm/scheme.py
129
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,89 +65,46 @@ def _is_dark(color_text):
|
||||
|
||||
|
||||
class SchemeManager:
|
||||
"""Owns the generated scheme file and the set of known scopes."""
|
||||
"""Owns one theme's generated scheme file and set of known scopes."""
|
||||
|
||||
def __init__(self):
|
||||
"""Start on the built-in palette; initialize() picks up the real one."""
|
||||
def __init__(self, theme_name):
|
||||
"""State only; build() resolves the palette and writes the file."""
|
||||
self.theme_name = theme_name
|
||||
self.file_name = "vterm-{}.hidden-color-scheme".format(theme_name or "system")
|
||||
self.path = "Packages/{}/{}".format(SCHEME_DIR_NAME, self.file_name)
|
||||
self.lock = threading.Lock()
|
||||
self.rules = {}
|
||||
# key -> scope name; the render thread reads it without the lock, so it
|
||||
# must never outlive self.rules (cleared together in _build)
|
||||
# must never outlive self.rules (cleared together in build)
|
||||
self._names = {}
|
||||
self.foreground = "#ffffff"
|
||||
self.background = "#000000"
|
||||
self._editor_fg = "#ffffff"
|
||||
self._editor_bg = "#000000"
|
||||
self.ansi = dict(DEFAULT_ANSI)
|
||||
self._write_pending = False
|
||||
self._initialized = False
|
||||
self._dir = None
|
||||
self._host_view = None
|
||||
self._listeners = []
|
||||
|
||||
def initialize(self, view):
|
||||
"""Pick up the palette, seed the rules, write the file. Main thread only"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._host_view = view
|
||||
|
||||
settings().add_on_change("vterm.theme", self.reload)
|
||||
self._build()
|
||||
|
||||
def ensure_file(self):
|
||||
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
|
||||
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
|
||||
return
|
||||
with self.lock:
|
||||
self._build()
|
||||
|
||||
def add_listener(self, callback):
|
||||
"""Called after every palette rebuild"""
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback):
|
||||
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
|
||||
if callback in self._listeners:
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def reload(self):
|
||||
"""Rebuild every rule for a new palette, then notify the terminals"""
|
||||
if not self._initialized:
|
||||
# no scheme file to maintain yet; initialize() will read the setting
|
||||
return
|
||||
with self.lock:
|
||||
self._build()
|
||||
for callback in list(self._listeners):
|
||||
try:
|
||||
callback()
|
||||
except Exception:
|
||||
LOG.warning("scheme listener failed", exc_info=True)
|
||||
|
||||
def _build(self):
|
||||
def build(self, host_view=None):
|
||||
"""Resolve the palette and regenerate every rule, then write the file.
|
||||
|
||||
Rules cannot be appended after a palette change: each one stores resolved
|
||||
colours, so they all have to be rebuilt. Caller holds the lock.
|
||||
colours, so they all have to be rebuilt. Callable again later (e.g. to
|
||||
re-sample the "No theme" palette from the editor's current colours).
|
||||
"""
|
||||
with self.lock:
|
||||
self._names = {}
|
||||
self.rules = {}
|
||||
self.ansi = dict(DEFAULT_ANSI)
|
||||
|
||||
theme = THEMES.get(settings().get("theme"))
|
||||
theme = THEMES.get(self.theme_name)
|
||||
|
||||
if theme:
|
||||
self.foreground = theme["foreground"]
|
||||
self.background = theme["background"]
|
||||
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
||||
else:
|
||||
# captured values outlive the view they came from
|
||||
if self._host_view is not None and self._host_view.is_valid():
|
||||
style = self._host_view.style()
|
||||
self._editor_fg = style.get("foreground", self._editor_fg)
|
||||
self._editor_bg = style.get("background", self._editor_bg)
|
||||
self.foreground = self._editor_fg
|
||||
self.background = self._editor_bg
|
||||
elif host_view is not None and host_view.is_valid():
|
||||
style = host_view.style()
|
||||
self.foreground = style.get("foreground", self.foreground)
|
||||
self.background = style.get("background", self.background)
|
||||
|
||||
# user overrides sit on top of whichever palette was chosen
|
||||
user_ansi = settings().get("ansi_colors", {})
|
||||
@@ -228,6 +181,10 @@ class SchemeManager:
|
||||
rule["font_style"] = " ".join(font_style)
|
||||
self.rules[(fg, bg, style)] = rule
|
||||
|
||||
def file_path(self):
|
||||
"""Filesystem path of the generated scheme file."""
|
||||
return os.path.join(self._directory(), self.file_name)
|
||||
|
||||
def _directory(self):
|
||||
"""Path of the generated scheme's directory, created on first use."""
|
||||
if self._dir is None:
|
||||
@@ -258,11 +215,49 @@ class SchemeManager:
|
||||
},
|
||||
"rules": list(self.rules.values()),
|
||||
}
|
||||
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(self.file_path(), "w", encoding="utf-8") as f:
|
||||
# compact: sublime reparses the whole file on every write
|
||||
json.dump(scheme, f, separators=(",", ":"))
|
||||
LOG.debug("scheme written: rules=%s", len(self.rules))
|
||||
|
||||
|
||||
scheme_manager = SchemeManager()
|
||||
# one manager per theme name, built once and kept for the plugin host's life: the
|
||||
# built-in theme set is small and fixed, so there is nothing to garbage-collect
|
||||
_managers = {}
|
||||
|
||||
|
||||
def get_scheme_manager(theme_name, host_view=None):
|
||||
"""The manager for a theme name (None for "follow the Sublime colour scheme"),
|
||||
building and caching it on first use.
|
||||
|
||||
The None entry is rebuilt whenever the editor's colours have changed since: it
|
||||
is a live sample of them, not a static palette, so every caller handing a
|
||||
terminal a "no theme" scheme gets one that is actually current.
|
||||
"""
|
||||
manager = _managers.get(theme_name)
|
||||
if manager is None:
|
||||
manager = SchemeManager(theme_name)
|
||||
_managers[theme_name] = manager
|
||||
manager.build(host_view)
|
||||
elif theme_name is None and host_view is not None and host_view.is_valid():
|
||||
style = host_view.style()
|
||||
if (style.get("foreground"), style.get("background")) != (manager.foreground, manager.background):
|
||||
manager.build(host_view)
|
||||
return manager
|
||||
|
||||
|
||||
def build_startup_schemes(host_view=None):
|
||||
"""Make sure every theme's colour scheme is on disk before Sublime reads a
|
||||
restored tab's color_scheme at startup.
|
||||
|
||||
A named theme is static, so an existing file is left alone - rebuilding it
|
||||
would just rewrite the same bytes, and each write makes sublime reload the
|
||||
scheme and restyle every view using it. None always rebuilds, same as
|
||||
get_scheme_manager.
|
||||
"""
|
||||
for name in ALL_THEME_NAMES:
|
||||
if name is None:
|
||||
get_scheme_manager(None, host_view=host_view)
|
||||
continue
|
||||
if not os.path.exists(SchemeManager(name).file_path()):
|
||||
get_scheme_manager(name)
|
||||
|
||||
@@ -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__)
|
||||
@@ -98,9 +97,11 @@ class Terminal:
|
||||
|
||||
_terminals = {}
|
||||
|
||||
def __init__(self, view):
|
||||
def __init__(self, view, scheme):
|
||||
"""State only; the pty and the threads come up in start()."""
|
||||
self.view = view
|
||||
self.scheme = scheme
|
||||
self.render_state = None
|
||||
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||
self.alive = True
|
||||
self.default_title = ""
|
||||
@@ -198,12 +199,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()
|
||||
|
||||
@@ -211,18 +211,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
|
||||
self._wake.set()
|
||||
scheme_manager.remove_listener(self.apply_scheme)
|
||||
try:
|
||||
self.process.terminate()
|
||||
except Exception:
|
||||
|
||||
@@ -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