6 Commits

Author SHA1 Message Date
Vaclav Tvrdik
7b536a7efa Keep scrollback under a full-screen scroll region 2026-10-20 11:00:04 +02:00
Vaclav Tvrdik
40dac967e2 selection now without command modifier key 2026-10-16 14:34:50 +02:00
Vaclav Tvrdik
8f5564f4aa Keep render state on the Terminal; a plugin reload rebuilds the command
Sublime recreates every TextCommand instance whenever any plugin loads,
which dropped colored_lines and restarted the region key counter: old
overlays were never erased and keys were reused. RenderState now lives on
terminal.render_state, which survives reloads.
2026-10-12 15:39:29 +02:00
Vaclav Tvrdik
9656faa385 Simplify per-terminal theme code; note ansi_colors needs restart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:42:20 +02:00
Vaclav Tvrdik
c2a0c75359 select theme applies only to current vterm and newly opened vterms 2026-10-02 12:29:32 +02:00
Vaclav Tvrdik
e6aa754762 red-alert theme added 2026-10-02 11:45:09 +02:00
18 changed files with 515 additions and 622 deletions

View File

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

View File

@@ -11,6 +11,13 @@
"args": {"up": false},
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
},
{
// press reports the click, release reports the drag
"button": "button1",
"press_command": "vterm_mouse_press",
"command": "vterm_mouse_release",
"context": [{"key": "vterm_view.mouse_reporting", "operand": true}]
},
{
// press_command: sublime's own button3 binding is a column drag_select,
// which would clear the selection before a release command could read it

View File

@@ -79,10 +79,11 @@ or turns it off. Copy/paste use the normal Sublime keys, and pastes are
bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The
middle button pastes the current selection straight into the app, X11-style,
without going near the clipboard; it does nothing when nothing is selected. The
wheel scrolls the view normally; while the app tracks the mouse the wheel and
left click go to it instead, and on the alternate screen without tracking each
wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks
and drags go to it instead (Claude Code's own mouse selection and copy work as in
kitty), and on the alternate screen without tracking each wheel tick sends arrow
keys (alternate scroll, as kitty/iTerm2 do — this is what scrolls Claude Code's
fullscreen UI before it enables mouse reporting).
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
@@ -104,11 +105,12 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
no restart needed.
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
default for terminals opened from now on. *vterm: Select Theme* retints the
focused terminal immediately and sets this; other already-open terminals keep
whatever theme they had.
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
applied on top of the theme.
applied on top of the theme. Takes effect after a Sublime restart.
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
@@ -148,15 +150,16 @@ terminal protocols — see `Architecture.md`.
into the view: an app that does not echo (vim in normal mode) would otherwise
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
fallback for IME and composed input.
- `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables
- `vterm/mouse.py` — SGR mouse reporting (wheel, click, drag) when the app enables
tracking; native selection and scrolling otherwise. The trackpad is not
handled: it reaches no mousemap on macOS and is converted to wheel notches
outside the plugin.
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
drag, no right button; the middle button pastes the selection instead of being
reported), focus events (1004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left button are reported; a
drag arrives as one motion at release, since Sublime raises none; no right
button; the middle button pastes the selection instead of being reported),
focus events (1004),
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
block/bar and blink/steady via the view's caret settings; underline falls back

18
main.py
View File

@@ -5,6 +5,8 @@ registry would orphan open terminal tabs.
"""
import logging
import sublime
# imported for sublime's command discovery, not called here
from .vterm.commands import (
VtermClearScreenCommand,
@@ -24,11 +26,11 @@ from .vterm.commands import (
)
from .vterm.listeners import VtermCoreEventListener
from .vterm.mouse import (
VtermMouseClickCommand,
VtermMouseEventListener,
VtermMousePressCommand,
VtermMouseReleaseCommand,
VtermScrollCommand,
)
from .vterm.scheme import scheme_manager
from .vterm.scheme import build_startup_schemes
from .vterm.utils import settings
from .vterm.render import (
VtermRenderCommand,
@@ -37,9 +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()

View File

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

View File

@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
from vterm.emulator import VtScreen
from vterm.render import CONTINUATION
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
@@ -13,72 +16,113 @@ settings = sublime.load_settings(SETTINGS_FILE)
panel = {}
class FakeTerminalView:
"""Just enough view for a Terminal to be rethemed: an id and its own settings."""
_next_id = 1
def __init__(self):
self._id = FakeTerminalView._next_id
FakeTerminalView._next_id += 1
self._settings = sublime.Settings()
def id(self):
return self._id
def settings(self):
return self._settings
def is_valid(self):
return True
def style(self):
return {}
class FakeWindow:
def __init__(self, view):
self._view = view
def active_view(self):
return self._view
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight)
def open_picker(current):
settings.set("theme", current)
command = VtermSelectThemeCommand(FakeWindow())
def fake_terminal(theme_name):
"""A terminal frozen to a theme, registered the way a real one is."""
view = FakeTerminalView()
terminal = Terminal(view, get_scheme_manager(theme_name))
terminal.screen = VtScreen(20, 3, process=FakeProcess())
Terminal._terminals[view.id()] = terminal
return terminal
def open_picker(terminal):
command = VtermSelectThemeCommand(FakeWindow(terminal.view))
command.run()
return command
# every theme is offered, with "no theme" first
command = open_picker(None)
command = open_picker(fake_terminal(None))
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0
# the configured theme starts highlighted
command = open_picker("pro")
# the focused terminal's own theme starts highlighted, not the global default
settings.set("theme", "homebrew")
terminal = fake_terminal("pro")
command = open_picker(terminal)
assert panel["selected_index"] == command.choices.index("pro")
# highlighting previews but must not persist
# highlighting previews the focused terminal alone, leaving the global default and
# every other terminal untouched
other = fake_terminal("default")
saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate"))
assert settings.get("theme") == "slate", settings.get("theme")
assert terminal.scheme.theme_name == "slate", terminal.scheme.theme_name
assert terminal.view.settings().get("color_scheme") == terminal.scheme.path
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
assert settings.get("theme") == "homebrew", "preview must not touch the global default"
assert len(sublime._saved) == saves, "preview must not save settings"
# cancelling restores the value from before the panel opened
# cancelling restores the focused terminal's theme from before the panel opened
panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme")
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
# confirming persists
command = open_picker("pro")
# confirming retheme the focused terminal and persists the default for new terminals
command = open_picker(terminal)
panel["on_done"](command.choices.index("homebrew"))
assert terminal.scheme.theme_name == "homebrew", terminal.scheme.theme_name
assert settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
# "no theme" persists as null rather than a name
command = open_picker("slate")
command = open_picker(fake_terminal("slate"))
panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme")
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen
# answers OSC 10/11 and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
# a palette change must reach the screen it was applied to: it answers OSC 10/11
# and DSR 996 from these values
from vterm.emulator import VtStream
recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start()
terminal = Terminal(None)
terminal = fake_terminal("default")
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False)
terminal.process = recorder
stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee"
scheme_manager.background = "#111111"
terminal.scheme = get_scheme_manager("slate")
terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
assert terminal.screen.theme_is_dark is True
assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
# without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written)
@@ -92,37 +136,30 @@ assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
# and the emulator answers a colour query with the new palette
recorder.written = ""
stream.feed("\x1b]11;?\x07")
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
bg = terminal.screen.default_bg_color
expected = "\x1b]11;rgb:{0}{0}/{1}{1}/{2}{2}\x07".format(bg[0:2], bg[2:4], bg[4:6])
assert recorder.written == expected, repr(recorder.written)
print("live theme tests passed (running screen adopts palette, app notified)")
# the scheme file must exist before any restored tab's color_scheme is loaded at
# startup, and building twice must not duplicate the seeded rules
# startup, and building a theme twice must not duplicate its seeded rules
import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
if os.path.exists(path):
os.remove(path)
scheme = get_scheme_manager("pro")
path = os.path.join(scheme._directory(), scheme.file_name)
assert os.path.exists(path), "get_scheme_manager did not write the scheme"
scheme_manager.ensure_file()
assert os.path.exists(path), "ensure_file did not write the scheme"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# the cached instance is reused, not rebuilt
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
# a second call is a no-op: same file, untouched
stamp = os.path.getmtime(path)
scheme_manager.ensure_file()
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
with scheme_manager.lock:
scheme_manager._build()
first = len(scheme_manager.rules)
scheme_manager._build()
assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
# build is idempotent
with scheme.lock:
first = len(scheme.rules)
scheme.build()
assert len(scheme.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))

View File

@@ -141,6 +141,11 @@ def run_case(char_by_char):
assert len(screen.history) == 1, (tag, len(screen.history))
assert screen.history[0][0].data == "a", tag
# an explicit full-screen scroll region still feeds scrollback
proc, screen, stream = make(10, 3)
feed(stream, "\x1b[1;3ra\r\nb\r\nc\r\nd", char_by_char)
assert len(screen.history) == 1, (tag, len(screen.history))
# ED 3 clears history and fires callback
cleared = []
proc = FakeProcess()

View File

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

View File

@@ -9,14 +9,16 @@ from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermKeypressCommand
from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
from vterm.render import (
CHUNK,
RenderState,
VtermRenderCommand,
focus_cursor,
following,
scroll_to_cursor,
)
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
@@ -157,7 +159,7 @@ class FakeView:
def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start()
terminal = Terminal(FakeView())
terminal = Terminal(FakeView(), get_scheme_manager("default"))
if scrollback is not None:
terminal.scrollback_size = scrollback
terminal.process = FakeProcess()
@@ -165,9 +167,14 @@ def make_terminal(rows=10, cols=20, scrollback=None):
return terminal, VtStream(terminal.screen)
def make_command(view):
# the real __init__, so the fake cannot drift from the command's attributes
return VtermRenderCommand(view)
def make_command(terminal):
# the real __init__, so the fake cannot drift from the command's attributes;
# run() itself sets _scheme, but these tests call its helpers directly
command = VtermRenderCommand(terminal.view)
command._scheme = terminal.scheme
terminal.render_state = RenderState()
command.state = terminal.render_state
return command
# an app on the alternate screen must not have its view trimmed away: the
@@ -178,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)
@@ -598,19 +626,31 @@ terminal.view.window_to_text = lambda xy: 0
window = FakeWindow(active=object())
terminal.view.window = lambda: window
stream.feed("\x1b[?1002h\x1b[?1006h")
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
click.view = terminal.view
mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
mouse_press.view = terminal.view
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
mouse_release.view = terminal.view
event = {"x": 0, "y": 0, "button": 1}
click.run(None, event)
mouse_press.run(None, event)
assert window.active_view() is terminal.view, "click did not focus the tab"
mouse_release.run(None, event)
assert terminal.process.written == "", repr(terminal.process.written)
# once focused, the same click reports as before
click.run(None, event)
# once focused, press and release at one cell report a click
mouse_press.run(None, event)
mouse_release.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
print("click focus tests passed (first click focuses only, then reports)")
# a release elsewhere reports one drag motion first
terminal.process.writes.clear()
terminal.view.text = "r0 \nr1 \nr2 \nr3 \nr4 \n"
mouse_press.run(None, event)
terminal.view.window_to_text = lambda xy: terminal.view.text_point(2, 4)
mouse_release.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<32;5;3M\x1b[<0;5;3m", repr(terminal.process.written)
print("click tests passed (first click focuses only; press, drag and release report)")
# entering the alt screen folds the scrollback away (kitty hides it); leaving
@@ -621,7 +661,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 +689,7 @@ assert terminal.view.folds == [], terminal.view.folds
# without scrollback there is nothing to fold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds

View File

@@ -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"}

View File

@@ -7,9 +7,9 @@ import sublime
import sublime_plugin
from .render import CONTINUATION, scroll_view
from .scheme import SCHEME_PATH, scheme_manager
from .scheme import get_scheme_manager
from .terminal import Terminal
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__)
@@ -43,12 +43,12 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
cwd = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables())
scheme_manager.initialize(window.active_view())
scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view())
view = window.new_file()
view.run_command("vterm_initialize_view")
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
terminal = Terminal(view)
terminal = Terminal(view, scheme)
try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception:
@@ -106,7 +106,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""Stamps a fresh view with the settings that make it a terminal."""
def run(self, _):
def run(self, _, color_scheme):
"""Apply the view settings; the pty starts only after this."""
view = self.view
view_settings = view.settings()
@@ -125,7 +125,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", False)
view_settings.set("scroll_context_lines", 0)
view_settings.set("color_scheme", SCHEME_PATH)
view_settings.set("color_scheme", color_scheme)
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
view_settings.set("spell_check", False)
view_settings.set("detect_indentation", False)
@@ -225,13 +225,19 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
"""Pick a colour palette, previewing each as it is highlighted"""
"""Retheme the focused terminal, previewing each choice as it is highlighted.
The chosen theme also becomes the default for terminals opened from now on;
it does not touch any other already-open terminal.
"""
def run(self):
"""Show the palette picker, starting on the configured theme."""
self.settings = settings()
self.original = self.settings.get("theme")
self.choices = [None] + sorted(THEMES)
"""Show the palette picker, starting on the focused terminal's own theme."""
self.terminal = self._focused_terminal()
if not self.terminal:
return
self.original = self.terminal.scheme.theme_name
self.choices = ALL_THEME_NAMES
items = [["No theme", "follow the Sublime color scheme"]]
for name in self.choices[1:]:
@@ -241,16 +247,36 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
current = self.choices.index(self.original) if self.original in self.choices else 0
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
def is_enabled(self):
"""Greys the palette entry out when the focused view is not a terminal."""
return self._focused_terminal() is not None
def _focused_terminal(self):
"""The terminal in the focused view, if any."""
view = self.window.active_view()
return Terminal.from_id(view.id()) if view else None
def _apply(self, name):
"""Retheme the focused terminal alone."""
scheme = get_scheme_manager(name, host_view=self.terminal.view)
self.terminal.retheme(scheme)
def _preview(self, index):
"""Apply a palette while it is highlighted, without saving it."""
self.settings.set("theme", self.choices[index])
"""Apply a palette to the focused terminal while it is highlighted."""
self._apply(self.choices[index])
def _done(self, index):
"""Persist the choice, or restore the original if the panel was cancelled."""
"""On cancel, restore the focused terminal's original theme.
On confirm, keep the focused terminal on its new theme and persist the
choice as the default for terminals opened from now on.
"""
if index == -1:
self.settings.set("theme", self.original)
self._apply(self.original)
return
self.settings.set("theme", self.choices[index])
name = self.choices[index]
self._apply(name)
settings().set("theme", name)
sublime.save_settings(SETTINGS_FILE)

View File

@@ -442,7 +442,9 @@ class VtScreen(pyte.Screen):
def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and self.margins is None:
# a region spanning the whole screen (CSI 1;<lines> r) is no region
full = self.margins is None or self.margins == (0, self.lines - 1)
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and full:
self.push_lines_into_history(1)
super().index()

View File

@@ -88,7 +88,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
"""Answer the contexts the mousemap needs"""
# keymap contexts are native "setting.x"; mousemaps do not evaluate
# those the same way, so their flags still need the hook
if key not in ("vterm_view", "vterm_view.wheel_captured"):
if key not in ("vterm_view", "vterm_view.wheel_captured", "vterm_view.mouse_reporting"):
return None
value = view.settings().get(key, False)
if operator == sublime.OP_EQUAL:

View File

@@ -13,6 +13,8 @@ from .utils import settings
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64
WHEEL_DOWN = 65
# button 0 with the "moved while held" bit
MOTION = 32
def send_scroll(terminal, up, lines, cell=None):
@@ -36,7 +38,7 @@ def _event_to_cell(view, terminal, event, clamp=False):
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
Clamping pulls a point above or below the screen onto the nearest row: the
wheel still belongs to the app there, a click does not."""
wheel and a release still belong to the app there, a press does not."""
pt = view.window_to_text((event["x"], event["y"]))
row, col = view.rowcol(pt)
row -= terminal.offset
@@ -48,46 +50,60 @@ def _event_to_cell(view, terminal, event, clamp=False):
return (min(col, screen.columns - 1) + 1, row + 1)
class VtermMouseEventListener(sublime_plugin.EventListener):
"""Hands button-1 clicks to the app, but only while it tracks the mouse."""
def on_text_command(self, view, name, args):
"""Replace drag_select with a mouse report; other click variants stay native."""
terminal = Terminal.from_id(view.id())
if not terminal:
return
if not terminal.screen.mouse_reporting_enabled():
# leave selection, caret and scrolling to sublime
return
if name == "drag_select" and args and args.get("event", {}).get("button") == 1 and len(args) == 1:
return ("vterm_mouse_click", {"event": args["event"]})
class VtermMouseClickCommand(sublime_plugin.TextCommand):
"""Reports a click to the app, or focuses the tab if it was not active."""
class VtermMousePressCommand(sublime_plugin.TextCommand):
"""Bound to button 1 in the mousemap while the app tracks the mouse."""
def run(self, edit, event):
"""Focus the tab on the first click, report press and release after that."""
"""Focus an inactive tab; else report the press and remember its cell."""
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
terminal.mouse_press = None # a release may not come: the context can drop mid-drag
window = self.view.window()
if window and window.active_view() != self.view:
# we replaced drag_select, which is what focuses the group; this
# click only focuses, as clicking an unfocused terminal window does
# the mousemap binding displaces drag_select, which is what focuses
# the group; this click only focuses, as in a terminal window
window.focus_view(self.view)
return
cell = _event_to_cell(self.view, terminal, event)
if cell is None:
return
terminal.mouse_press = cell
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
# native drag_select only previews the selection while dragging; the
# app gets the real one at release
self.view.run_command("drag_select", {"event": event})
def want_event(self):
"""Ask sublime for the click coordinates."""
return True
class VtermMouseReleaseCommand(sublime_plugin.TextCommand):
"""Reports the release, after one drag motion when the button came up elsewhere.
Sublime raises no motion events, so a drag reaches the app as a single
move to the release cell; an app's selection then spans press to release.
The native preview selection goes, leaving the app's own highlight."""
def run(self, edit, event):
"""Report motion and release for the pending press, if any."""
terminal = Terminal.from_id(self.view.id())
if not terminal or terminal.mouse_press is None:
return
cell = _event_to_cell(self.view, terminal, event, clamp=True)
if cell != terminal.mouse_press:
terminal.send_mouse(MOTION, cell[0], cell[1], pressed=True)
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
terminal.mouse_press = None
self.view.sel().clear()
self.view.run_command("vterm_show_cursor", {"scroll": False})
def want_event(self):
"""Ask sublime for the release coordinates."""
return True
class VtermScrollCommand(sublime_plugin.TextCommand):
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""

View File

@@ -15,7 +15,6 @@ from wcwidth import wcswidth
# per-cell call in segment_buffer_line
cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal
from .utils import rev_wcwidth
@@ -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)

View File

@@ -8,16 +8,12 @@ import sublime
from .emulator import ANSI_COLORS
from .utils import settings
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm"
# "hidden-" keeps the generated scheme out of the scheme picker
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
# the value terminal views get as their color_scheme setting
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
# each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250
@@ -69,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)

View File

@@ -13,7 +13,6 @@ from pyte import modes as mo
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
from .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__)
@@ -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 = ""
@@ -120,6 +121,8 @@ class Terminal:
# guards the queue/flag handoff between send_string and the drain thread
self._send_lock = threading.Lock()
self._wheel_captured = False
self._mouse_reporting = False
self.mouse_press = None # cell of the pending button-1 press, for the release report
self._scroll_pending = False
self.pending_clear = False
self.pending_clear_screen = False
@@ -198,12 +201,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 +213,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:
@@ -322,7 +331,7 @@ class Terminal:
if frames >= UNDO_CLEAR_FRAMES:
frames = 0
self.view.run_command("vterm_clear_undo_stack")
self._sync_wheel_flag()
self._sync_mouse_flags()
if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops")
break
@@ -418,12 +427,16 @@ class Terminal:
continue
self.process.write(string)
def _sync_wheel_flag(self):
"""Publish whether the app owns the wheel, for the mousemap context."""
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
def _sync_mouse_flags(self):
"""Publish whether the app owns the wheel and the mouse, for the mousemap contexts."""
reporting = self.screen.mouse_reporting_enabled()
captured = reporting or self.screen.alternate_buffer_mode
if captured != self._wheel_captured:
self._wheel_captured = captured
self.view.settings().set("vterm_view.wheel_captured", captured)
if reporting != self._mouse_reporting:
self._mouse_reporting = reporting
self.view.settings().set("vterm_view.mouse_reporting", reporting)
def send_focus(self, focused):
"""Report focus gain or loss, when the app asked for it (?1004)."""

View File

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