Compare commits
11 Commits
v1.0.0
...
93253aa564
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93253aa564 | ||
|
|
2715ecddbb | ||
|
|
a556dab638 | ||
|
|
e478f41f7c | ||
|
|
7b536a7efa | ||
|
|
40dac967e2 | ||
|
|
8f5564f4aa | ||
|
|
9656faa385 | ||
|
|
c2a0c75359 | ||
|
|
e6aa754762 | ||
|
|
76927a85ea |
348
Architecture.md
348
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
|
## Files
|
||||||
|
|
||||||
@@ -30,9 +9,9 @@ Constraints it works under:
|
|||||||
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
|
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
|
||||||
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
|
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
|
||||||
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
||||||
| `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. |
|
| `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
|
||||||
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). |
|
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
|
||||||
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
|
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup. |
|
||||||
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
||||||
| `vterm/mouse.py` | Wheel and click handling. |
|
| `vterm/mouse.py` | Wheel and click handling. |
|
||||||
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
|
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
|
||||||
@@ -42,14 +21,15 @@ Constraints it works under:
|
|||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `vterm.sublime-settings` | Defaults for the eleven plugin settings. |
|
| `vterm.sublime-settings` | Defaults for the twelve plugin settings. |
|
||||||
| `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. |
|
| `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. |
|
||||||
| `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. |
|
| `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. |
|
||||||
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
|
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
|
||||||
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
|
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
|
||||||
| `dependencies.json` | Package Control libraries. |
|
| `dependencies.json` | Package Control libraries. |
|
||||||
|
|
||||||
Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`.
|
Generated at runtime, not in the repo: `Packages/User/vterm/vterm-<theme>.hidden-color-scheme`,
|
||||||
|
one per theme (`vterm-system` for "no theme").
|
||||||
|
|
||||||
## Classes
|
## Classes
|
||||||
|
|
||||||
@@ -71,12 +51,13 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
|||||||
into the next one. Parity is enforced by a differential test (`run_fast_path_cases`).
|
into the next one. Parity is enforced by a differential test (`run_fast_path_cases`).
|
||||||
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
|
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
|
||||||
`Char` carrying the extra `linefeed` field.
|
`Char` carrying the extra `linefeed` field.
|
||||||
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
- **`SchemeManager`** (`scheme.py`) — one per theme name, cached by `get_scheme_manager`.
|
||||||
generated scheme file and the mapping from colour combination to scope name.
|
Owns the generated scheme file and the mapping from colour combination to scope name.
|
||||||
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
|
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
|
||||||
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
|
`TextCommand` instance whenever any plugin loads, so the view's render state
|
||||||
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
|
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
||||||
`_lastrow` are that view's render state.
|
bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
|
||||||
|
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -88,310 +69,16 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
|||||||
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
|
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
|
||||||
| `vterm_render` | `VtermRenderCommand` | renderer thread |
|
| `vterm_render` | `VtermRenderCommand` | renderer thread |
|
||||||
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
|
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
|
||||||
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
|
| `vterm_copy` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
|
||||||
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
|
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
|
||||||
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
|
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
|
||||||
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
|
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
|
||||||
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
|
| `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_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
|
One application-wide `EventListener`: `VtermCoreEventListener` (`listeners.py`).
|
||||||
`VtermMouseEventListener` (`mouse.py`).
|
|
||||||
|
|
||||||
## Data flows
|
|
||||||
|
|
||||||
### Opening a terminal
|
|
||||||
|
|
||||||
`vterm_open` → resolve `cmd` (default `$SHELL -l`) and `cwd` (first project folder →
|
|
||||||
active file's directory → `~`) → `scheme_manager.initialize()` → `window.new_file()` →
|
|
||||||
`vterm_initialize_view` stamps the view settings → `Terminal(view)` → `Terminal.start()`:
|
|
||||||
register in `_terminals`, compute the size from the viewport, spawn the pty, build the
|
|
||||||
`VtScreen`/`VtStream`, subscribe to palette changes, start the two threads. All on the UI
|
|
||||||
thread; a failure closes the view again.
|
|
||||||
|
|
||||||
### Output: a pty byte becomes a visible character
|
|
||||||
|
|
||||||
```
|
|
||||||
child writes
|
|
||||||
│
|
|
||||||
├─ reader thread ──────────────────────────────────────────────
|
|
||||||
│ process.read(8192) blocking read on the pty master
|
|
||||||
│ self._decoder.decode(raw) incremental UTF-8: a sequence split
|
|
||||||
│ across reads must not be corrupted
|
|
||||||
│ chunks.append(text) collections.deque, no lock
|
|
||||||
│
|
|
||||||
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
|
||||||
│ _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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -413,7 +100,8 @@ are marked ✅.
|
|||||||
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
||||||
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
|
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
|
||||||
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
|
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
|
||||||
foreground/background, `OSC 52` reads or writes the system clipboard.
|
foreground/background, `OSC 52` reads or writes the system clipboard (vterm
|
||||||
|
ignores it: untrusted output must not plant a paste).
|
||||||
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
||||||
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
||||||
discards the payload, so an image never leaks into the text.
|
discards the payload, so an image never leaks into the text.
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
"args": {"up": false},
|
"args": {"up": false},
|
||||||
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
|
"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,
|
// press_command: sublime's own button3 binding is a column drag_select,
|
||||||
// which would clear the selection before a release command could read it
|
// which would clear the selection before a release command could read it
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -11,21 +11,25 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
|||||||
|
|
||||||
### With Package Control
|
### With Package Control
|
||||||
|
|
||||||
vterm is not on the default channel, so add its repository first. Open
|
vterm is distributed through the shared channel at
|
||||||
**Preferences → Package Settings → Package Control → Settings** and put the URL
|
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the
|
||||||
below in the `repositories` array of the right-hand (User) pane:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"repositories": [
|
"channels": [
|
||||||
"https://raw.githubusercontent.com/vaclavt/vterm/main/repository.json"
|
"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
|
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
|
||||||
**vterm**. Upgrades arrive through **Package Control: Upgrade Package** like any
|
hides every default package. Then run **Package Control: Install Package**
|
||||||
other package.
|
from the Command Palette and pick **vterm**. Upgrades arrive through
|
||||||
|
**Package Control: Upgrade Package** like any other package.
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
@@ -75,15 +79,16 @@ 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
|
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,
|
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
|
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
|
wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks
|
||||||
left click go to it instead, and on the alternate screen without tracking each
|
and drags go to it instead (Claude Code's own mouse selection and copy work as in
|
||||||
wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
|
kitty), and on the alternate screen without tracking each wheel tick sends arrow
|
||||||
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
|
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
|
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
||||||
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
||||||
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
||||||
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
|
app one scroll line; `wheel_lines` raises that (iTerm2 sends 3) when the wheel
|
||||||
scrolls too far. While an app owns the
|
feels slow. While an app owns the
|
||||||
alternate screen the scrollback above it is folded away, kitty-style, and
|
alternate screen the scrollback above it is folded away, kitty-style, and
|
||||||
unfolds when the app leaves it.
|
unfolds when the app leaves it.
|
||||||
|
|
||||||
@@ -100,11 +105,12 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
|
|||||||
colours:
|
colours:
|
||||||
|
|
||||||
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
|
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
|
||||||
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
|
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
|
||||||
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
|
default for terminals opened from now on. *vterm: Select Theme* retints the
|
||||||
no restart needed.
|
focused terminal immediately and sets this; other already-open terminals keep
|
||||||
|
whatever theme they had.
|
||||||
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
|
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
|
||||||
applied on top of the theme.
|
applied on top of the theme. Takes effect after a Sublime restart.
|
||||||
|
|
||||||
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
|
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
|
||||||
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
|
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
|
||||||
@@ -120,7 +126,8 @@ terminal protocols — see `Architecture.md`.
|
|||||||
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
|
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
|
||||||
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
|
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
|
||||||
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
|
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
|
||||||
probe, and OSC 10/11 colour queries; OSC 52 goes to the Sublime clipboard.
|
probe, and OSC 10/11 colour queries; OSC 52 (clipboard write) is ignored,
|
||||||
|
since any output could plant a command to paste.
|
||||||
Combining characters are merged as NFC, because Sublime draws decomposed
|
Combining characters are merged as NFC, because Sublime draws decomposed
|
||||||
sequences badly.
|
sequences badly.
|
||||||
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
||||||
@@ -144,15 +151,16 @@ terminal protocols — see `Architecture.md`.
|
|||||||
into the view: an app that does not echo (vim in normal mode) would otherwise
|
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
|
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
|
||||||
fallback for IME and composed input.
|
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
|
tracking; native selection and scrolling otherwise. The trackpad is not
|
||||||
handled: it reaches no mousemap on macOS and is converted to wheel notches
|
handled: it reaches no mousemap on macOS and is converted to wheel notches
|
||||||
outside the plugin.
|
outside the plugin.
|
||||||
|
|
||||||
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
||||||
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
|
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left button are reported; a
|
||||||
drag, no right button; the middle button pastes the selection instead of being
|
drag arrives as one motion at release, since Sublime raises none; no right
|
||||||
reported), focus events (1004),
|
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
|
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
|
||||||
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
|
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
|
||||||
block/bar and blink/steady via the view's caret settings; underline falls back
|
block/bar and blink/steady via the view's caret settings; underline falls back
|
||||||
@@ -202,6 +210,6 @@ python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-b
|
|||||||
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
||||||
emulator with no Sublime involved, and prints the queries answered plus the
|
emulator with no Sublime involved, and prints the queries answered plus the
|
||||||
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
||||||
`Lib/python38` (pure Python, so any Python 3 runs them).
|
`Lib/python3*` (pure Python, so any Python 3 runs them).
|
||||||
|
|
||||||
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.
|
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.
|
||||||
|
|||||||
20
main.py
20
main.py
@@ -5,30 +5,30 @@ registry would orphan open terminal tabs.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
|
||||||
# imported for sublime's command discovery, not called here
|
# imported for sublime's command discovery, not called here
|
||||||
from .vterm.commands import (
|
from .vterm.commands import (
|
||||||
VtermClearScreenCommand,
|
VtermClearScreenCommand,
|
||||||
VtermClearUndoStackCommand,
|
VtermClearUndoStackCommand,
|
||||||
VtermCleanupCommand,
|
VtermCleanupCommand,
|
||||||
VtermCloseCommand,
|
|
||||||
VtermCopyCommand,
|
VtermCopyCommand,
|
||||||
VtermInitializeViewCommand,
|
VtermInitializeViewCommand,
|
||||||
VtermKeypressCommand,
|
VtermKeypressCommand,
|
||||||
VtermNoopCommand,
|
VtermNoopCommand,
|
||||||
VtermOpenCommand,
|
VtermOpenCommand,
|
||||||
VtermOpenTerminalProjectFolderCommand,
|
VtermOpenTerminalProjectFolderCommand,
|
||||||
VtermPasteCommand,
|
|
||||||
VtermPasteSelectionCommand,
|
VtermPasteSelectionCommand,
|
||||||
VtermPasteTextCommand,
|
VtermPasteTextCommand,
|
||||||
VtermSelectThemeCommand,
|
VtermSelectThemeCommand,
|
||||||
)
|
)
|
||||||
from .vterm.listeners import VtermCoreEventListener
|
from .vterm.listeners import VtermCoreEventListener
|
||||||
from .vterm.mouse import (
|
from .vterm.mouse import (
|
||||||
VtermMouseClickCommand,
|
VtermMousePressCommand,
|
||||||
VtermMouseEventListener,
|
VtermMouseReleaseCommand,
|
||||||
VtermScrollCommand,
|
VtermScrollCommand,
|
||||||
)
|
)
|
||||||
from .vterm.scheme import scheme_manager
|
from .vterm.scheme import build_startup_schemes
|
||||||
from .vterm.utils import settings
|
from .vterm.utils import settings
|
||||||
from .vterm.render import (
|
from .vterm.render import (
|
||||||
VtermRenderCommand,
|
VtermRenderCommand,
|
||||||
@@ -37,9 +37,13 @@ from .vterm.render import (
|
|||||||
|
|
||||||
|
|
||||||
def plugin_loaded():
|
def plugin_loaded():
|
||||||
"""Write the colour scheme and set the log level, once per plugin host."""
|
"""Write any missing theme's colour scheme and set the log level, once per
|
||||||
# synchronous: a restored tab's color_scheme is read during startup
|
plugin host: a restored tab's color_scheme is read during startup and may
|
||||||
scheme_manager.ensure_file()
|
name any of them.
|
||||||
|
"""
|
||||||
|
window = sublime.active_window()
|
||||||
|
view = window.active_view() if window else None
|
||||||
|
build_startup_schemes(view)
|
||||||
|
|
||||||
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
|
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
|
||||||
logging.basicConfig()
|
logging.basicConfig()
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
They run as plain scripts (see README), so each imports this first to get the
|
They run as plain scripts (see README), so each imports this first to get the
|
||||||
sublime stubs and the Package Control libraries on the path.
|
sublime stubs and the Package Control libraries on the path.
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
ST_LIB = os.path.expanduser(
|
# Package Control puts the libraries under the plugin host's own directory
|
||||||
"~/Library/Application Support/Sublime Text/Lib/python38")
|
ST_LIBS = sorted(glob.glob(os.path.expanduser(
|
||||||
|
"~/Library/Application Support/Sublime Text/Lib/python3*")))
|
||||||
|
|
||||||
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
|
for path in [ROOT] + ST_LIBS + [os.path.join(ROOT, "tests", "stubs")]:
|
||||||
if path not in sys.path:
|
if path not in sys.path:
|
||||||
sys.path.insert(0, path)
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,7 @@ def expand_variables(value, variables):
|
|||||||
|
|
||||||
def version():
|
def version():
|
||||||
return "4200"
|
return "4200"
|
||||||
|
|
||||||
|
|
||||||
|
def active_window():
|
||||||
|
return None
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
|
|||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
|
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
|
||||||
|
from vterm.emulator import VtScreen
|
||||||
from vterm.render import CONTINUATION
|
from vterm.render import CONTINUATION
|
||||||
|
from vterm.scheme import get_scheme_manager
|
||||||
|
from vterm.terminal import Terminal
|
||||||
from vterm.utils import SETTINGS_FILE
|
from vterm.utils import SETTINGS_FILE
|
||||||
from vterm.themes import THEMES
|
from vterm.themes import THEMES
|
||||||
|
|
||||||
@@ -13,72 +16,140 @@ settings = sublime.load_settings(SETTINGS_FILE)
|
|||||||
panel = {}
|
panel = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTerminalView:
|
||||||
|
"""Just enough view for a Terminal to be rethemed: an id and its own settings."""
|
||||||
|
|
||||||
|
_next_id = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._id = FakeTerminalView._next_id
|
||||||
|
FakeTerminalView._next_id += 1
|
||||||
|
self._settings = sublime.Settings()
|
||||||
|
self._settings.set("vterm_view", True)
|
||||||
|
|
||||||
|
def id(self):
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def style(self):
|
||||||
|
# a terminal view reports its own theme's colours, not the editor's
|
||||||
|
return {"foreground": "#f2f2f2", "background": "#000000"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEditorView:
|
||||||
|
"""An ordinary file view, the one the "No theme" palette is sampled from."""
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return sublime.Settings()
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def style(self):
|
||||||
|
return {"foreground": "#111111", "background": "#eeeeee"}
|
||||||
|
|
||||||
|
|
||||||
|
editor = FakeEditorView()
|
||||||
|
|
||||||
|
|
||||||
class FakeWindow:
|
class FakeWindow:
|
||||||
|
def __init__(self, view):
|
||||||
|
self._view = view
|
||||||
|
|
||||||
|
def active_view(self):
|
||||||
|
return self._view
|
||||||
|
|
||||||
|
def views(self):
|
||||||
|
return [self._view, editor]
|
||||||
|
|
||||||
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
||||||
panel.update(items=items, on_done=on_done,
|
panel.update(items=items, on_done=on_done,
|
||||||
selected_index=selected_index, on_highlight=on_highlight)
|
selected_index=selected_index, on_highlight=on_highlight)
|
||||||
|
|
||||||
|
|
||||||
def open_picker(current):
|
def fake_terminal(theme_name):
|
||||||
settings.set("theme", current)
|
"""A terminal frozen to a theme, registered the way a real one is."""
|
||||||
command = VtermSelectThemeCommand(FakeWindow())
|
view = FakeTerminalView()
|
||||||
|
terminal = Terminal(view, get_scheme_manager(theme_name))
|
||||||
|
terminal.screen = VtScreen(20, 3, process=FakeProcess())
|
||||||
|
Terminal._terminals[view.id()] = terminal
|
||||||
|
return terminal
|
||||||
|
|
||||||
|
|
||||||
|
def open_picker(terminal):
|
||||||
|
command = VtermSelectThemeCommand(FakeWindow(terminal.view))
|
||||||
command.run()
|
command.run()
|
||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
# every theme is offered, with "no theme" first
|
# every theme is offered, with "no theme" first
|
||||||
command = open_picker(None)
|
command = open_picker(fake_terminal(None))
|
||||||
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
||||||
assert panel["selected_index"] == 0
|
assert panel["selected_index"] == 0
|
||||||
|
|
||||||
# the configured theme starts highlighted
|
# the focused terminal's own theme starts highlighted, not the global default
|
||||||
command = open_picker("pro")
|
settings.set("theme", "homebrew")
|
||||||
|
terminal = fake_terminal("pro")
|
||||||
|
command = open_picker(terminal)
|
||||||
assert panel["selected_index"] == command.choices.index("pro")
|
assert panel["selected_index"] == command.choices.index("pro")
|
||||||
|
|
||||||
# highlighting previews but must not persist
|
# highlighting previews the focused terminal alone, leaving the global default and
|
||||||
|
# every other terminal untouched
|
||||||
|
other = fake_terminal("default")
|
||||||
saves = len(sublime._saved)
|
saves = len(sublime._saved)
|
||||||
panel["on_highlight"](command.choices.index("slate"))
|
panel["on_highlight"](command.choices.index("slate"))
|
||||||
assert settings.get("theme") == "slate", settings.get("theme")
|
assert terminal.scheme.theme_name == "slate", terminal.scheme.theme_name
|
||||||
|
assert terminal.view.settings().get("color_scheme") == terminal.scheme.path
|
||||||
|
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
|
||||||
|
assert settings.get("theme") == "homebrew", "preview must not touch the global default"
|
||||||
assert len(sublime._saved) == saves, "preview must not save settings"
|
assert len(sublime._saved) == saves, "preview must not save settings"
|
||||||
|
|
||||||
# cancelling restores the value from before the panel opened
|
# cancelling restores the focused terminal's theme from before the panel opened
|
||||||
panel["on_done"](-1)
|
panel["on_done"](-1)
|
||||||
assert settings.get("theme") == "pro", settings.get("theme")
|
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
|
||||||
|
|
||||||
# confirming persists
|
# confirming retheme the focused terminal and persists the default for new terminals
|
||||||
command = open_picker("pro")
|
command = open_picker(terminal)
|
||||||
panel["on_done"](command.choices.index("homebrew"))
|
panel["on_done"](command.choices.index("homebrew"))
|
||||||
|
assert terminal.scheme.theme_name == "homebrew", terminal.scheme.theme_name
|
||||||
assert settings.get("theme") == "homebrew", settings.get("theme")
|
assert settings.get("theme") == "homebrew", settings.get("theme")
|
||||||
assert sublime._saved[-1] == SETTINGS_FILE
|
assert sublime._saved[-1] == SETTINGS_FILE
|
||||||
|
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
|
||||||
|
|
||||||
# "no theme" persists as null rather than a name
|
# "no theme" persists as null rather than a name
|
||||||
command = open_picker("slate")
|
command = open_picker(fake_terminal("slate"))
|
||||||
panel["on_done"](0)
|
panel["on_done"](0)
|
||||||
assert settings.get("theme") is None, settings.get("theme")
|
assert settings.get("theme") is None, settings.get("theme")
|
||||||
|
|
||||||
|
# ... and samples the editor's colours, not the themed terminal's own: that
|
||||||
|
# would retint every "no theme" terminal with the theme being left
|
||||||
|
system = get_scheme_manager(None)
|
||||||
|
assert (system.foreground, system.background) == ("#111111", "#eeeeee"), \
|
||||||
|
(system.foreground, system.background)
|
||||||
|
|
||||||
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
||||||
|
|
||||||
|
|
||||||
# a palette change must reach terminals that are already running: the screen
|
# a palette change must reach the screen it was applied to: it answers OSC 10/11
|
||||||
# answers OSC 10/11 and DSR 996 from these values
|
# and DSR 996 from these values
|
||||||
from vterm.emulator import VtScreen, VtStream
|
from vterm.emulator import VtStream
|
||||||
from vterm.scheme import scheme_manager
|
|
||||||
from vterm.terminal import Terminal
|
|
||||||
|
|
||||||
|
|
||||||
recorder = FakeProcess()
|
recorder = FakeProcess()
|
||||||
# real __init__ (view unused here); process/screen stand in for start()
|
terminal = fake_terminal("default")
|
||||||
terminal = Terminal(None)
|
|
||||||
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
|
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
|
||||||
bg_color="ffffff", is_dark=False)
|
bg_color="ffffff", is_dark=False)
|
||||||
terminal.process = recorder
|
terminal.process = recorder
|
||||||
stream = VtStream(terminal.screen)
|
stream = VtStream(terminal.screen)
|
||||||
|
|
||||||
scheme_manager.foreground = "#eeeeee"
|
terminal.scheme = get_scheme_manager("slate")
|
||||||
scheme_manager.background = "#111111"
|
|
||||||
terminal.apply_scheme()
|
terminal.apply_scheme()
|
||||||
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
|
assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
|
||||||
assert terminal.screen.theme_is_dark is True
|
assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
|
||||||
|
|
||||||
# without mode 2031 the app is not interrupted
|
# without mode 2031 the app is not interrupted
|
||||||
assert recorder.written == "", repr(recorder.written)
|
assert recorder.written == "", repr(recorder.written)
|
||||||
@@ -92,37 +163,30 @@ assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
|
|||||||
# and the emulator answers a colour query with the new palette
|
# and the emulator answers a colour query with the new palette
|
||||||
recorder.written = ""
|
recorder.written = ""
|
||||||
stream.feed("\x1b]11;?\x07")
|
stream.feed("\x1b]11;?\x07")
|
||||||
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
|
bg = terminal.screen.default_bg_color
|
||||||
|
expected = "\x1b]11;rgb:{0}{0}/{1}{1}/{2}{2}\x07".format(bg[0:2], bg[2:4], bg[4:6])
|
||||||
|
assert recorder.written == expected, repr(recorder.written)
|
||||||
|
|
||||||
print("live theme tests passed (running screen adopts palette, app notified)")
|
print("live theme tests passed (running screen adopts palette, app notified)")
|
||||||
|
|
||||||
|
|
||||||
# the scheme file must exist before any restored tab's color_scheme is loaded at
|
# the scheme file must exist before any restored tab's color_scheme is loaded at
|
||||||
# startup, and building twice must not duplicate the seeded rules
|
# startup, and building a theme twice must not duplicate its seeded rules
|
||||||
import os
|
import os
|
||||||
from vterm.scheme import SCHEME_FILE_NAME
|
|
||||||
|
|
||||||
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
|
scheme = get_scheme_manager("pro")
|
||||||
if os.path.exists(path):
|
path = os.path.join(scheme._directory(), scheme.file_name)
|
||||||
os.remove(path)
|
assert os.path.exists(path), "get_scheme_manager did not write the scheme"
|
||||||
|
|
||||||
scheme_manager.ensure_file()
|
# the cached instance is reused, not rebuilt
|
||||||
assert os.path.exists(path), "ensure_file did not write the scheme"
|
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
|
||||||
assert not scheme_manager._initialized, \
|
|
||||||
"ensure_file must leave the palette open to refinement by initialize()"
|
|
||||||
|
|
||||||
# a second call is a no-op: same file, untouched
|
# build is idempotent
|
||||||
stamp = os.path.getmtime(path)
|
with scheme.lock:
|
||||||
scheme_manager.ensure_file()
|
first = len(scheme.rules)
|
||||||
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
|
scheme.build()
|
||||||
|
assert len(scheme.rules) == first, \
|
||||||
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
|
"rules duplicated: {} -> {}".format(first, len(scheme.rules))
|
||||||
with scheme_manager.lock:
|
|
||||||
scheme_manager._build()
|
|
||||||
first = len(scheme_manager.rules)
|
|
||||||
scheme_manager._build()
|
|
||||||
assert len(scheme_manager.rules) == first, \
|
|
||||||
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
|
|
||||||
|
|
||||||
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
|
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
|
||||||
|
|
||||||
|
|||||||
@@ -83,13 +83,11 @@ def run_case(char_by_char):
|
|||||||
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
||||||
assert screen.buffer[0][0].data == "y", tag
|
assert screen.buffer[0][0].data == "y", tag
|
||||||
|
|
||||||
# OSC 52 clipboard (base64 "hi")
|
# OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
|
||||||
got = []
|
# not plant a paste
|
||||||
proc = FakeProcess()
|
proc, screen, stream = make()
|
||||||
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
|
feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
|
||||||
stream = VtStream(screen)
|
assert screen.buffer[0][0].data == "z" and proc.written == "", tag
|
||||||
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
|
|
||||||
assert got == ["hi"], (tag, got)
|
|
||||||
|
|
||||||
# bracketed paste and sync modes tracked
|
# bracketed paste and sync modes tracked
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
@@ -119,6 +117,21 @@ def run_case(char_by_char):
|
|||||||
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
|
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
|
||||||
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
|
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
|
||||||
|
|
||||||
|
# every grey lands inside the palette; 248 used to map to slot 256 and
|
||||||
|
# abort the whole SGR
|
||||||
|
from vterm.emulator import quantize_256
|
||||||
|
assert all(0 <= quantize_256(v, v, v) <= 255 for v in range(256))
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[38;2;248;248;248mX", char_by_char)
|
||||||
|
assert screen.buffer[0][0].fg == "ffffff", (tag, screen.buffer[0][0].fg)
|
||||||
|
|
||||||
|
# a zero-width non-combining char (VS16, ZWJ) or a stray control byte is
|
||||||
|
# dropped; the text after it must survive
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "⚠️ warn \U0001F468\U0001F4BB ab\x01cd", char_by_char)
|
||||||
|
row = "".join(screen.buffer[0][x].data for x in range(20)).rstrip()
|
||||||
|
assert row == "⚠ warn \U0001F468\U0001F4BB abcd", (tag, repr(row))
|
||||||
|
|
||||||
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
|
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
|
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
|
||||||
@@ -141,6 +154,11 @@ def run_case(char_by_char):
|
|||||||
assert len(screen.history) == 1, (tag, len(screen.history))
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
assert screen.history[0][0].data == "a", tag
|
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
|
# ED 3 clears history and fires callback
|
||||||
cleared = []
|
cleared = []
|
||||||
proc = FakeProcess()
|
proc = FakeProcess()
|
||||||
@@ -310,7 +328,7 @@ GAUNTLET = (
|
|||||||
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
|
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
|
||||||
"\x1b[38:5:100mcolon\x1b[m\r\n"
|
"\x1b[38:5:100mcolon\x1b[m\r\n"
|
||||||
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
|
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
|
||||||
"wide: 漢字 \x1b[7m漢\x1b[m\r\n"
|
"wide: 漢字 \x1b[7m漢\x1b[m ⚠️ after\r\n"
|
||||||
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
|
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys
|
|||||||
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
|
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
|
||||||
load_sublime_json("Default (OSX).sublime-mousemap")
|
load_sublime_json("Default (OSX).sublime-mousemap")
|
||||||
|
|
||||||
# scheme manager produces scopes and rules
|
# a theme's scheme manager produces scopes and rules
|
||||||
import vterm.scheme as scheme_module
|
from vterm.scheme import get_scheme_manager
|
||||||
from vterm.scheme import scheme_manager
|
scope = get_scheme_manager("default").scope("ff0000", "default", "b")
|
||||||
scope = scheme_manager.scope("ff0000", "default", "b")
|
|
||||||
assert scope == "vterm.ff0000.default.b", scope
|
assert scope == "vterm.ff0000.default.b", scope
|
||||||
|
|
||||||
# every command referenced by the palette and the keymap must exist, and the
|
# every command referenced by the palette and the keymap must exist, and the
|
||||||
@@ -89,7 +88,7 @@ for entry in load_sublime_json("vterm.sublime-commands") + keymap:
|
|||||||
|
|
||||||
# every pending_* name mentioned in the modules must be a real Terminal
|
# every pending_* name mentioned in the modules must be a real Terminal
|
||||||
# attribute, so a renamed flag cannot leave a writer pointing at nothing
|
# attribute, so a renamed flag cannot leave a writer pointing at nothing
|
||||||
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
|
flags = {f for f in vars(Terminal(None, None)) if f.startswith("pending_")}
|
||||||
assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
|
assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
|
||||||
for module in (commands, render, terminal_module):
|
for module in (commands, render, terminal_module):
|
||||||
with open(module.__file__, encoding="utf-8") as f:
|
with open(module.__file__, encoding="utf-8") as f:
|
||||||
@@ -114,21 +113,28 @@ for name, theme in THEMES.items():
|
|||||||
configured = load_sublime_json("vterm.sublime-settings").get("theme")
|
configured = load_sublime_json("vterm.sublime-settings").get("theme")
|
||||||
assert configured is None or configured in THEMES, configured
|
assert configured is None or configured in THEMES, configured
|
||||||
|
|
||||||
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
|
# plugin_loaded must create every theme's colour scheme: sublime loads a restored
|
||||||
# color_scheme during startup, before any terminal opens
|
# tab's color_scheme during startup, before any terminal opens, and it may name
|
||||||
|
# any of them
|
||||||
# import main.py the way sublime does - as a module of the package directory -
|
# import main.py the way sublime does - as a module of the package directory -
|
||||||
# so its relative imports and plugin_loaded body are actually executed
|
# so its relative imports and plugin_loaded body are actually executed
|
||||||
|
from vterm.scheme import SchemeManager
|
||||||
|
from vterm.themes import ALL_THEME_NAMES
|
||||||
|
|
||||||
|
scheme_files = [SchemeManager(name).file_path() for name in ALL_THEME_NAMES]
|
||||||
|
for scheme_file in scheme_files:
|
||||||
|
if os.path.exists(scheme_file):
|
||||||
|
os.remove(scheme_file)
|
||||||
|
|
||||||
pkg = types.ModuleType("vterm")
|
pkg = types.ModuleType("vterm")
|
||||||
pkg.__path__ = [ROOT]
|
pkg.__path__ = [ROOT]
|
||||||
sys.modules["vterm"] = pkg
|
sys.modules["vterm"] = pkg
|
||||||
main = importlib.import_module("vterm.main")
|
main = importlib.import_module("vterm.main")
|
||||||
|
|
||||||
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
|
|
||||||
if os.path.exists(scheme_file):
|
|
||||||
os.remove(scheme_file)
|
|
||||||
main.plugin_loaded()
|
main.plugin_loaded()
|
||||||
assert os.path.exists(scheme_file), \
|
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
|
||||||
"plugin_loaded must leave the colour scheme on disk"
|
assert os.path.exists(scheme_file), \
|
||||||
|
"plugin_loaded must leave {}'s colour scheme on disk".format(name)
|
||||||
|
|
||||||
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
||||||
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ from _bootstrap import FakeProcess
|
|||||||
import sublime
|
import sublime
|
||||||
from vterm.commands import VtermKeypressCommand
|
from vterm.commands import VtermKeypressCommand
|
||||||
from vterm.emulator import VtScreen, VtStream
|
from vterm.emulator import VtScreen, VtStream
|
||||||
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
|
from vterm.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
|
||||||
from vterm.render import (
|
from vterm.render import (
|
||||||
CHUNK,
|
CHUNK,
|
||||||
|
RenderState,
|
||||||
VtermRenderCommand,
|
VtermRenderCommand,
|
||||||
focus_cursor,
|
focus_cursor,
|
||||||
following,
|
following,
|
||||||
scroll_to_cursor,
|
scroll_to_cursor,
|
||||||
)
|
)
|
||||||
|
from vterm.scheme import get_scheme_manager
|
||||||
from vterm.terminal import Terminal
|
from vterm.terminal import Terminal
|
||||||
from vterm.utils import SETTINGS_FILE
|
from vterm.utils import SETTINGS_FILE
|
||||||
|
|
||||||
@@ -157,7 +159,7 @@ class FakeView:
|
|||||||
|
|
||||||
def make_terminal(rows=10, cols=20, scrollback=None):
|
def make_terminal(rows=10, cols=20, scrollback=None):
|
||||||
# real __init__ for the plain attributes; process/screen stand in for start()
|
# real __init__ for the plain attributes; process/screen stand in for start()
|
||||||
terminal = Terminal(FakeView())
|
terminal = Terminal(FakeView(), get_scheme_manager("default"))
|
||||||
if scrollback is not None:
|
if scrollback is not None:
|
||||||
terminal.scrollback_size = scrollback
|
terminal.scrollback_size = scrollback
|
||||||
terminal.process = FakeProcess()
|
terminal.process = FakeProcess()
|
||||||
@@ -165,9 +167,14 @@ def make_terminal(rows=10, cols=20, scrollback=None):
|
|||||||
return terminal, VtStream(terminal.screen)
|
return terminal, VtStream(terminal.screen)
|
||||||
|
|
||||||
|
|
||||||
def make_command(view):
|
def make_command(terminal):
|
||||||
# the real __init__, so the fake cannot drift from the command's attributes
|
# the real __init__, so the fake cannot drift from the command's attributes;
|
||||||
return VtermRenderCommand(view)
|
# run() itself sets _scheme, but these tests call its helpers directly
|
||||||
|
command = VtermRenderCommand(terminal.view)
|
||||||
|
command._scheme = terminal.scheme
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
command.state = terminal.render_state
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
# an app on the alternate screen must not have its view trimmed away: the
|
# an app on the alternate screen must not have its view trimmed away: the
|
||||||
@@ -178,28 +185,41 @@ assert terminal.screen.alternate_buffer_mode
|
|||||||
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
|
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
|
||||||
|
|
||||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
before = terminal.view.text
|
before = terminal.view.text
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
assert terminal.view.text == before, \
|
assert terminal.view.text == before, \
|
||||||
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
||||||
|
|
||||||
# the configured cap still applies on the primary screen
|
# the configured cap still applies on the primary screen: that many rows of
|
||||||
|
# scrollback above the screen
|
||||||
terminal, stream = make_terminal(rows=5, scrollback=20)
|
terminal, stream = make_terminal(rows=5, scrollback=20)
|
||||||
terminal.offset = 40
|
terminal.offset = 40
|
||||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
rows_left = terminal.view.text.count("\n") + 1
|
rows_left = terminal.view.text.count("\n") + 1
|
||||||
assert rows_left <= 20, rows_left
|
assert rows_left <= 25, rows_left
|
||||||
assert "row 39" in terminal.view.text, "newest rows must survive"
|
assert "row 39" in terminal.view.text, "newest rows must survive"
|
||||||
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
||||||
|
|
||||||
|
# a cap below the screen height (0: no scrollback) must never cut the screen
|
||||||
|
terminal, stream = make_terminal(rows=10, cols=20, scrollback=0)
|
||||||
|
command = make_command(terminal)
|
||||||
|
for i in range(3):
|
||||||
|
stream.feed("\r\n".join("row{}".format(i * 10 + j) for j in range(10)) + "\r\n")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
command.trim_trailing_spaces(None, terminal)
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert terminal.offset >= 0, terminal.offset
|
||||||
|
assert "row29" in terminal.view.text, terminal.view.text
|
||||||
|
|
||||||
# below the cap nothing is dropped
|
# below the cap nothing is dropped
|
||||||
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
||||||
terminal.offset = 3
|
terminal.offset = 3
|
||||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||||
terminal.view.text
|
terminal.view.text
|
||||||
@@ -209,7 +229,7 @@ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
|||||||
terminal, stream = make_terminal(rows=4)
|
terminal, stream = make_terminal(rows=4)
|
||||||
stream.feed("\x1b[?1049h")
|
stream.feed("\x1b[?1049h")
|
||||||
terminal.view = FakeView("top\nrow\n\n")
|
terminal.view = FakeView("top\nrow\n\n")
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
before = terminal.view.text
|
before = terminal.view.text
|
||||||
command.trim_trailing_spaces(None, terminal)
|
command.trim_trailing_spaces(None, terminal)
|
||||||
assert terminal.view.text == before, \
|
assert terminal.view.text == before, \
|
||||||
@@ -218,7 +238,7 @@ assert terminal.view.text == before, \
|
|||||||
# on the primary screen the blank tail below the cursor still goes
|
# on the primary screen the blank tail below the cursor still goes
|
||||||
terminal, stream = make_terminal(rows=4)
|
terminal, stream = make_terminal(rows=4)
|
||||||
terminal.view = FakeView("top\nrow\n\n")
|
terminal.view = FakeView("top\nrow\n\n")
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
command.trim_trailing_spaces(None, terminal)
|
command.trim_trailing_spaces(None, terminal)
|
||||||
assert terminal.view.text == "top\nrow", terminal.view.text
|
assert terminal.view.text == "top\nrow", terminal.view.text
|
||||||
|
|
||||||
@@ -261,7 +281,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
|||||||
# batches the runs that share a scope into one add_regions call
|
# batches the runs that share a scope into one add_regions call
|
||||||
terminal, stream = make_terminal(rows=3, cols=20)
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
terminal.view = FakeView()
|
terminal.view = FakeView()
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
|
|
||||||
@@ -278,7 +298,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
|
|||||||
assert len(painted) == 2, painted
|
assert len(painted) == 2, painted
|
||||||
|
|
||||||
# keys are unique per view and recorded so decolorize_line can erase them
|
# keys are unique per view and recorded so decolorize_line can erase them
|
||||||
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
|
||||||
"colored_lines must track exactly the keys that were added"
|
"colored_lines must track exactly the keys that were added"
|
||||||
command.decolorize_line(0)
|
command.decolorize_line(0)
|
||||||
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||||
@@ -286,12 +306,35 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
|||||||
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||||
|
|
||||||
|
|
||||||
|
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
|
||||||
|
# bookkeeping lives on the terminal so a rebuilt command still erases what the
|
||||||
|
# old one painted, and its keys never collide with keys still in use
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal)
|
||||||
|
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert terminal.view.regions, "the red row must be painted"
|
||||||
|
# a reload: new command, same terminal
|
||||||
|
reloaded = VtermRenderCommand(terminal.view)
|
||||||
|
reloaded._scheme = terminal.scheme
|
||||||
|
reloaded.state = terminal.render_state
|
||||||
|
stream.feed("\x1b[H\x1b[2Kgone")
|
||||||
|
reloaded.update_lines(None, terminal)
|
||||||
|
assert terminal.view.regions == {}, \
|
||||||
|
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
|
||||||
|
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
|
||||||
|
|
||||||
|
print("reload tests passed (render state survives a rebuilt command)")
|
||||||
|
|
||||||
|
|
||||||
# rows that leave the screen have their colours merged onto shared per-(scope,
|
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||||
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||||
# the whole session, and merging every frame would re-send the whole chunk
|
# the whole session, and merging every frame would re-send the whole chunk
|
||||||
terminal, stream = make_terminal(rows=3, cols=20)
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
terminal.view = FakeView()
|
terminal.view = FakeView()
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
for i in range(300):
|
for i in range(300):
|
||||||
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
@@ -310,14 +353,14 @@ assert len(row_keys) == 300 - CHUNK, \
|
|||||||
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
||||||
terminal, stream = make_terminal(rows=3, scrollback=30)
|
terminal, stream = make_terminal(rows=3, scrollback=30)
|
||||||
terminal.view = FakeView()
|
terminal.view = FakeView()
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
terminal.screen.dirty.clear()
|
terminal.screen.dirty.clear()
|
||||||
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||||
assert 0 not in command._archived_chunks
|
assert 0 not in command.state.archived_chunks
|
||||||
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
||||||
|
|
||||||
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
||||||
@@ -332,7 +375,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
|
|||||||
Terminal._terminals[id(terminal)] = terminal
|
Terminal._terminals[id(terminal)] = terminal
|
||||||
terminal.view.id = lambda: id(terminal)
|
terminal.view.id = lambda: id(terminal)
|
||||||
terminal.default_title = "zsh"
|
terminal.default_title = "zsh"
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
stream.feed("[vterm] %")
|
stream.feed("[vterm] %")
|
||||||
command.run(None)
|
command.run(None)
|
||||||
assert all(r.empty() for r in terminal.view.sel()), \
|
assert all(r.empty() for r in terminal.view.sel()), \
|
||||||
@@ -359,10 +402,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
|
|||||||
print("render diff tests passed (unchanged dirty rows skipped)")
|
print("render diff tests passed (unchanged dirty rows skipped)")
|
||||||
|
|
||||||
# the generated scheme must define a selection colour, or sublime uses blue
|
# the generated scheme must define a selection colour, or sublime uses blue
|
||||||
from vterm.scheme import blend, scheme_manager
|
from vterm.scheme import blend
|
||||||
scheme_manager.background = "#303841"
|
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
|
||||||
scheme_manager.foreground = "#d8dee9"
|
|
||||||
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
|
|
||||||
|
|
||||||
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
||||||
|
|
||||||
@@ -370,7 +411,7 @@ print("selection artefact tests passed (fresh prompt unselected, user selection
|
|||||||
# DECSCUSR drives the caret shape through view settings; before any app asks,
|
# DECSCUSR drives the caret shape through view settings; before any app asks,
|
||||||
# and for 0, the caret is the terminal default: a blinking block
|
# and for 0, the caret is the terminal default: a blinking block
|
||||||
terminal, stream = make_terminal(rows=3, cols=20)
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
settings = terminal.view.settings()
|
settings = terminal.view.settings()
|
||||||
|
|
||||||
command.sync_cursor_style(terminal)
|
command.sync_cursor_style(terminal)
|
||||||
@@ -414,6 +455,8 @@ Terminal._terminals[id(terminal)] = terminal
|
|||||||
terminal.view.id = lambda: id(terminal)
|
terminal.view.id = lambda: id(terminal)
|
||||||
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
||||||
scroll.view = terminal.view
|
scroll.view = terminal.view
|
||||||
|
# three per notch, so "one report per line" is visible
|
||||||
|
sublime.load_settings(SETTINGS_FILE).set("wheel_lines", 3)
|
||||||
|
|
||||||
stream.feed("\x1b[?1049h")
|
stream.feed("\x1b[?1049h")
|
||||||
scroll.run(None, up=True)
|
scroll.run(None, up=True)
|
||||||
@@ -598,19 +641,31 @@ terminal.view.window_to_text = lambda xy: 0
|
|||||||
window = FakeWindow(active=object())
|
window = FakeWindow(active=object())
|
||||||
terminal.view.window = lambda: window
|
terminal.view.window = lambda: window
|
||||||
stream.feed("\x1b[?1002h\x1b[?1006h")
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||||
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
|
mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
|
||||||
click.view = terminal.view
|
mouse_press.view = terminal.view
|
||||||
|
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
|
||||||
|
mouse_release.view = terminal.view
|
||||||
event = {"x": 0, "y": 0, "button": 1}
|
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"
|
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)
|
assert terminal.process.written == "", repr(terminal.process.written)
|
||||||
|
|
||||||
# once focused, the same click reports as before
|
# once focused, press and release at one cell report a click
|
||||||
click.run(None, event)
|
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)
|
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
|
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
||||||
@@ -621,7 +676,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
|
|||||||
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
||||||
terminal.offset = 3
|
terminal.offset = 3
|
||||||
terminal.viewport_y = 400
|
terminal.viewport_y = 400
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
fold_end = terminal.view.text_point(3, 0) - 1
|
fold_end = terminal.view.text_point(3, 0) - 1
|
||||||
|
|
||||||
stream.feed("\x1b[?1049h")
|
stream.feed("\x1b[?1049h")
|
||||||
@@ -649,7 +704,7 @@ assert terminal.view.folds == [], terminal.view.folds
|
|||||||
# without scrollback there is nothing to fold
|
# without scrollback there is nothing to fold
|
||||||
terminal, stream = make_terminal(rows=5, cols=20)
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
|
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
|
||||||
command = make_command(terminal.view)
|
command = make_command(terminal)
|
||||||
stream.feed("\x1b[?1049h")
|
stream.feed("\x1b[?1049h")
|
||||||
command.sync_alt_fold(terminal)
|
command.sync_alt_fold(terminal)
|
||||||
assert terminal.view.folds == [], terminal.view.folds
|
assert terminal.view.folds == [], terminal.view.folds
|
||||||
|
|||||||
@@ -22,8 +22,9 @@
|
|||||||
"min_columns": 20,
|
"min_columns": 20,
|
||||||
"max_columns": 500,
|
"max_columns": 500,
|
||||||
|
|
||||||
// colour palette: "default", "homebrew", "pro", "slate", or null to
|
// colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
|
||||||
// follow the active Sublime colour scheme. Applies to open tabs too.
|
// to follow the active Sublime colour scheme. Default for terminals opened
|
||||||
|
// from now on; vterm: Select Theme retints the focused terminal and sets this.
|
||||||
"theme": null,
|
"theme": null,
|
||||||
|
|
||||||
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
|
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
|
||||||
@@ -36,8 +37,8 @@
|
|||||||
// "parameters": ["-a", "Terminal", "%CWD%"],
|
// "parameters": ["-a", "Terminal", "%CWD%"],
|
||||||
|
|
||||||
// kitty instead of Apple Terminal:
|
// kitty instead of Apple Terminal:
|
||||||
"terminal": "/opt/homebrew/bin/kitty",
|
"terminal": "/usr/bin/open",
|
||||||
"parameters": ["-d", "%CWD%"],
|
"parameters": ["-a", "Kitty", "%CWD%"],
|
||||||
|
|
||||||
"debug": false
|
"debug": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import sublime
|
|||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from .render import CONTINUATION, scroll_view
|
from .render import CONTINUATION, scroll_view
|
||||||
from .scheme import SCHEME_PATH, scheme_manager
|
from .scheme import get_scheme_manager
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .themes import THEMES
|
from .themes import ALL_THEME_NAMES, THEMES
|
||||||
from .utils import SETTINGS_FILE, settings
|
from .utils import SETTINGS_FILE, settings
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
@@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__)
|
|||||||
SCROLL_KEYS = ("pageup", "pagedown", "up", "down")
|
SCROLL_KEYS = ("pageup", "pagedown", "up", "down")
|
||||||
|
|
||||||
|
|
||||||
|
def editor_view(window):
|
||||||
|
"""A view to sample the editor's colours from: never a terminal, whose
|
||||||
|
colours are its own theme's."""
|
||||||
|
return next((v for v in window.views() if not v.settings().get("vterm_view")), None)
|
||||||
|
|
||||||
|
|
||||||
class VtermOpenCommand(sublime_plugin.WindowCommand):
|
class VtermOpenCommand(sublime_plugin.WindowCommand):
|
||||||
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
||||||
|
|
||||||
@@ -43,17 +49,18 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
|||||||
cwd = os.path.expanduser("~")
|
cwd = os.path.expanduser("~")
|
||||||
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
||||||
|
|
||||||
scheme_manager.initialize(window.active_view())
|
scheme = get_scheme_manager(settings().get("theme"), host_view=editor_view(window))
|
||||||
|
|
||||||
view = window.new_file()
|
view = window.new_file()
|
||||||
view.run_command("vterm_initialize_view")
|
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
|
||||||
|
|
||||||
terminal = Terminal(view)
|
terminal = Terminal(view, scheme)
|
||||||
try:
|
try:
|
||||||
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
|
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("failed to start terminal: cmd=%s", cmd)
|
LOG.exception("failed to start terminal: cmd=%s", cmd)
|
||||||
view.close()
|
view.close()
|
||||||
|
sublime.error_message("vterm: could not start {}".format(" ".join(cmd)))
|
||||||
return
|
return
|
||||||
window.focus_view(view)
|
window.focus_view(view)
|
||||||
|
|
||||||
@@ -106,7 +113,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
|
|||||||
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
||||||
"""Stamps a fresh view with the settings that make it a terminal."""
|
"""Stamps a fresh view with the settings that make it a terminal."""
|
||||||
|
|
||||||
def run(self, _):
|
def run(self, _, color_scheme):
|
||||||
"""Apply the view settings; the pty starts only after this."""
|
"""Apply the view settings; the pty starts only after this."""
|
||||||
view = self.view
|
view = self.view
|
||||||
view_settings = view.settings()
|
view_settings = view.settings()
|
||||||
@@ -125,7 +132,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
|||||||
view_settings.set("draw_indent_guides", False)
|
view_settings.set("draw_indent_guides", False)
|
||||||
view_settings.set("scroll_past_end", False)
|
view_settings.set("scroll_past_end", False)
|
||||||
view_settings.set("scroll_context_lines", 0)
|
view_settings.set("scroll_context_lines", 0)
|
||||||
view_settings.set("color_scheme", SCHEME_PATH)
|
view_settings.set("color_scheme", color_scheme)
|
||||||
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
|
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
|
||||||
view_settings.set("spell_check", False)
|
view_settings.set("spell_check", False)
|
||||||
view_settings.set("detect_indentation", False)
|
view_settings.set("detect_indentation", False)
|
||||||
@@ -183,14 +190,6 @@ class VtermCopyCommand(sublime_plugin.TextCommand):
|
|||||||
sublime.set_clipboard(selection_text(self.view))
|
sublime.set_clipboard(selection_text(self.view))
|
||||||
|
|
||||||
|
|
||||||
class VtermPasteCommand(sublime_plugin.TextCommand):
|
|
||||||
"""Pastes the clipboard into the app; replaces sublime's own paste."""
|
|
||||||
|
|
||||||
def run(self, edit):
|
|
||||||
"""Hand the clipboard to vterm_paste_text."""
|
|
||||||
self.view.run_command("vterm_paste_text", {"text": sublime.get_clipboard()})
|
|
||||||
|
|
||||||
|
|
||||||
class VtermPasteTextCommand(sublime_plugin.TextCommand):
|
class VtermPasteTextCommand(sublime_plugin.TextCommand):
|
||||||
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
||||||
|
|
||||||
@@ -225,13 +224,19 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
|
|
||||||
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
||||||
"""Pick a colour palette, previewing each as it is highlighted"""
|
"""Retheme the focused terminal, previewing each choice as it is highlighted.
|
||||||
|
|
||||||
|
The chosen theme also becomes the default for terminals opened from now on;
|
||||||
|
it does not touch any other already-open terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Show the palette picker, starting on the configured theme."""
|
"""Show the palette picker, starting on the focused terminal's own theme."""
|
||||||
self.settings = settings()
|
self.terminal = self._focused_terminal()
|
||||||
self.original = self.settings.get("theme")
|
if not self.terminal:
|
||||||
self.choices = [None] + sorted(THEMES)
|
return
|
||||||
|
self.original = self.terminal.scheme.theme_name
|
||||||
|
self.choices = ALL_THEME_NAMES
|
||||||
|
|
||||||
items = [["No theme", "follow the Sublime color scheme"]]
|
items = [["No theme", "follow the Sublime color scheme"]]
|
||||||
for name in self.choices[1:]:
|
for name in self.choices[1:]:
|
||||||
@@ -241,16 +246,36 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
|||||||
current = self.choices.index(self.original) if self.original in self.choices else 0
|
current = self.choices.index(self.original) if self.original in self.choices else 0
|
||||||
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
|
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
|
||||||
|
|
||||||
|
def is_enabled(self):
|
||||||
|
"""Greys the palette entry out when the focused view is not a terminal."""
|
||||||
|
return self._focused_terminal() is not None
|
||||||
|
|
||||||
|
def _focused_terminal(self):
|
||||||
|
"""The terminal in the focused view, if any."""
|
||||||
|
view = self.window.active_view()
|
||||||
|
return Terminal.from_id(view.id()) if view else None
|
||||||
|
|
||||||
|
def _apply(self, name):
|
||||||
|
"""Retheme the focused terminal alone."""
|
||||||
|
scheme = get_scheme_manager(name, host_view=editor_view(self.window))
|
||||||
|
self.terminal.retheme(scheme)
|
||||||
|
|
||||||
def _preview(self, index):
|
def _preview(self, index):
|
||||||
"""Apply a palette while it is highlighted, without saving it."""
|
"""Apply a palette to the focused terminal while it is highlighted."""
|
||||||
self.settings.set("theme", self.choices[index])
|
self._apply(self.choices[index])
|
||||||
|
|
||||||
def _done(self, index):
|
def _done(self, index):
|
||||||
"""Persist the choice, or restore the original if the panel was cancelled."""
|
"""On cancel, restore the focused terminal's original theme.
|
||||||
|
|
||||||
|
On confirm, keep the focused terminal on its new theme and persist the
|
||||||
|
choice as the default for terminals opened from now on.
|
||||||
|
"""
|
||||||
if index == -1:
|
if index == -1:
|
||||||
self.settings.set("theme", self.original)
|
self._apply(self.original)
|
||||||
return
|
return
|
||||||
self.settings.set("theme", self.choices[index])
|
name = self.choices[index]
|
||||||
|
self._apply(name)
|
||||||
|
settings().set("theme", name)
|
||||||
sublime.save_settings(SETTINGS_FILE)
|
sublime.save_settings(SETTINGS_FILE)
|
||||||
|
|
||||||
|
|
||||||
@@ -302,20 +327,6 @@ class VtermCleanupCommand(sublime_plugin.TextCommand):
|
|||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
|
|
||||||
|
|
||||||
class VtermCloseCommand(sublime_plugin.TextCommand):
|
|
||||||
"""Kills the process and closes the tab. Registered but bound nowhere; for user keybindings."""
|
|
||||||
|
|
||||||
def run(self, _):
|
|
||||||
"""Kill the process, then close the view."""
|
|
||||||
view = self.view
|
|
||||||
if not view.settings().get("vterm_view"):
|
|
||||||
return
|
|
||||||
terminal = Terminal.from_id(view.id())
|
|
||||||
if terminal:
|
|
||||||
terminal.kill()
|
|
||||||
view.close()
|
|
||||||
|
|
||||||
|
|
||||||
class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
|
class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
|
||||||
"""Drops the undo history, so rendered output can never be undone into view."""
|
"""Drops the undo history, so rendered output can never be undone into view."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -83,7 +82,8 @@ def quantize_256(r, g, b):
|
|||||||
if r == g == b:
|
if r == g == b:
|
||||||
if r < 8:
|
if r < 8:
|
||||||
return 16
|
return 16
|
||||||
if r > 248:
|
# 238 is the top of the grey ramp (255); above the midpoint, white
|
||||||
|
if r > 246:
|
||||||
return 231
|
return 231
|
||||||
return 232 + (r - 8) // 10
|
return 232 + (r - 8) // 10
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ class Cursor:
|
|||||||
|
|
||||||
class VtScreen(pyte.Screen):
|
class VtScreen(pyte.Screen):
|
||||||
"""Screen with alternate buffer, scrollback, synchronized output,
|
"""Screen with alternate buffer, scrollback, synchronized output,
|
||||||
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
|
mouse/focus/paste modes, cursor styles, query replies and OSC titles/colours.
|
||||||
|
|
||||||
Mutated only by the renderer thread, so it carries no lock.
|
Mutated only by the renderer thread, so it carries no lock.
|
||||||
"""
|
"""
|
||||||
@@ -136,7 +136,6 @@ class VtScreen(pyte.Screen):
|
|||||||
history=5000,
|
history=5000,
|
||||||
erase_scrollback=True,
|
erase_scrollback=True,
|
||||||
clear_callback=lambda: None,
|
clear_callback=lambda: None,
|
||||||
clipboard_callback=lambda text: None,
|
|
||||||
fg_color="ffffff",
|
fg_color="ffffff",
|
||||||
bg_color="000000",
|
bg_color="000000",
|
||||||
is_dark=True,
|
is_dark=True,
|
||||||
@@ -145,7 +144,6 @@ class VtScreen(pyte.Screen):
|
|||||||
self._process = process
|
self._process = process
|
||||||
self._erase_scrollback = erase_scrollback
|
self._erase_scrollback = erase_scrollback
|
||||||
self._clear_callback = clear_callback
|
self._clear_callback = clear_callback
|
||||||
self._clipboard_callback = clipboard_callback
|
|
||||||
self.primary_buffer = {}
|
self.primary_buffer = {}
|
||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
||||||
@@ -432,7 +430,8 @@ class VtScreen(pyte.Screen):
|
|||||||
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
|
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
|
||||||
self.dirty.add(pos[0])
|
self.dirty.add(pos[0])
|
||||||
else:
|
else:
|
||||||
break
|
# other zero-width (VS16, ZWJ) or control: drop it, keep the rest
|
||||||
|
continue
|
||||||
|
|
||||||
if char_width > 0:
|
if char_width > 0:
|
||||||
x = cursor.x + char_width
|
x = cursor.x + char_width
|
||||||
@@ -442,7 +441,9 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
def index(self):
|
def index(self):
|
||||||
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||||
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)
|
self.push_lines_into_history(1)
|
||||||
super().index()
|
super().index()
|
||||||
|
|
||||||
@@ -646,7 +647,7 @@ class VtScreen(pyte.Screen):
|
|||||||
self.write_process_input("\x1b[?0u")
|
self.write_process_input("\x1b[?0u")
|
||||||
|
|
||||||
def handle_osc(self, code, payload, terminator):
|
def handle_osc(self, code, payload, terminator):
|
||||||
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard."""
|
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query."""
|
||||||
if code in (0, 2):
|
if code in (0, 2):
|
||||||
self.set_title(payload)
|
self.set_title(payload)
|
||||||
elif code == 1:
|
elif code == 1:
|
||||||
@@ -657,14 +658,8 @@ class VtScreen(pyte.Screen):
|
|||||||
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
|
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
|
||||||
# answer with the terminator asked with: meli only ends an OSC at ST
|
# answer with the terminator asked with: meli only ends an OSC at ST
|
||||||
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
||||||
elif code == 52:
|
# other OSC codes are intentionally dropped, 52 (clipboard write) among
|
||||||
parts = payload.split(";", 1)
|
# them: any output, cat'd or remote, could plant a command to paste
|
||||||
if len(parts) == 2 and parts[1] and parts[1] != "?":
|
|
||||||
try:
|
|
||||||
self._clipboard_callback(base64.b64decode(parts[1]).decode("utf-8", "replace"))
|
|
||||||
except ValueError:
|
|
||||||
LOG.warning("osc52: undecodable payload", exc_info=True)
|
|
||||||
# other OSC codes are intentionally dropped
|
|
||||||
|
|
||||||
|
|
||||||
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
"""Answer the contexts the mousemap needs"""
|
"""Answer the contexts the mousemap needs"""
|
||||||
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
||||||
# those the same way, so their flags still need the hook
|
# 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
|
return None
|
||||||
value = view.settings().get(key, False)
|
value = view.settings().get(key, False)
|
||||||
if operator == sublime.OP_EQUAL:
|
if operator == sublime.OP_EQUAL:
|
||||||
@@ -104,6 +104,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
if name == "copy":
|
if name == "copy":
|
||||||
return ("vterm_copy", None)
|
return ("vterm_copy", None)
|
||||||
elif name in ("paste", "paste_and_indent"):
|
elif name in ("paste", "paste_and_indent"):
|
||||||
return ("vterm_paste", None)
|
return ("vterm_paste_text", {"text": sublime.get_clipboard()})
|
||||||
elif name == "undo":
|
elif name == "undo":
|
||||||
return ("vterm_noop", None)
|
return ("vterm_noop", None)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from .utils import settings
|
|||||||
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
||||||
WHEEL_UP = 64
|
WHEEL_UP = 64
|
||||||
WHEEL_DOWN = 65
|
WHEEL_DOWN = 65
|
||||||
|
# button 0 with the "moved while held" bit
|
||||||
|
MOTION = 32
|
||||||
|
|
||||||
|
|
||||||
def send_scroll(terminal, up, lines, cell=None):
|
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.
|
"""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
|
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"]))
|
pt = view.window_to_text((event["x"], event["y"]))
|
||||||
row, col = view.rowcol(pt)
|
row, col = view.rowcol(pt)
|
||||||
row -= terminal.offset
|
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)
|
return (min(col, screen.columns - 1) + 1, row + 1)
|
||||||
|
|
||||||
|
|
||||||
class VtermMouseEventListener(sublime_plugin.EventListener):
|
class VtermMousePressCommand(sublime_plugin.TextCommand):
|
||||||
"""Hands button-1 clicks to the app, but only while it tracks the mouse."""
|
"""Bound to button 1 in the mousemap while the app 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."""
|
|
||||||
|
|
||||||
def run(self, edit, event):
|
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())
|
terminal = Terminal.from_id(self.view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
|
terminal.mouse_press = None # a release may not come: the context can drop mid-drag
|
||||||
window = self.view.window()
|
window = self.view.window()
|
||||||
if window and window.active_view() != self.view:
|
if window and window.active_view() != self.view:
|
||||||
# we replaced drag_select, which is what focuses the group; this
|
# the mousemap binding displaces drag_select, which is what focuses
|
||||||
# click only focuses, as clicking an unfocused terminal window does
|
# the group; this click only focuses, as in a terminal window
|
||||||
window.focus_view(self.view)
|
window.focus_view(self.view)
|
||||||
return
|
return
|
||||||
cell = _event_to_cell(self.view, terminal, event)
|
cell = _event_to_cell(self.view, terminal, event)
|
||||||
if cell is None:
|
if cell is None:
|
||||||
return
|
return
|
||||||
|
terminal.mouse_press = cell
|
||||||
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
|
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):
|
def want_event(self):
|
||||||
"""Ask sublime for the click coordinates."""
|
"""Ask sublime for the click coordinates."""
|
||||||
return True
|
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):
|
class VtermScrollCommand(sublime_plugin.TextCommand):
|
||||||
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
|
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
|
||||||
|
|
||||||
@@ -103,7 +119,7 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
|
|||||||
cell = None
|
cell = None
|
||||||
if terminal.screen.mouse_reporting_enabled() and event:
|
if terminal.screen.mouse_reporting_enabled() and event:
|
||||||
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
||||||
lines = max(0, int(settings().get("wheel_lines", 3)))
|
lines = max(0, int(settings().get("wheel_lines", 1)))
|
||||||
send_scroll(terminal, up, lines, cell)
|
send_scroll(terminal, up, lines, cell)
|
||||||
|
|
||||||
def want_event(self):
|
def want_event(self):
|
||||||
|
|||||||
125
vterm/render.py
125
vterm/render.py
@@ -15,7 +15,6 @@ from wcwidth import wcswidth
|
|||||||
# per-cell call in segment_buffer_line
|
# per-cell call in segment_buffer_line
|
||||||
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
||||||
|
|
||||||
from .scheme import scheme_manager
|
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .utils import rev_wcwidth
|
from .utils import rev_wcwidth
|
||||||
|
|
||||||
@@ -117,32 +116,49 @@ def ensure_position(view, edit, row, col=0):
|
|||||||
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
||||||
|
|
||||||
|
|
||||||
class VtermRenderCommand(sublime_plugin.TextCommand):
|
class RenderState:
|
||||||
"""Draws the screen into the view. Sublime keeps one instance per view, so
|
"""A view's render bookkeeping, kept on its Terminal.
|
||||||
the caches below are that view's render state."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
Sublime rebuilds every TextCommand instance of every view whenever any
|
||||||
"""Set up this view's render caches."""
|
plugin module loads. State held on the command is lost then: the overlays
|
||||||
super().__init__(*args, **kwargs)
|
already in the view become untracked, so a row rewrite no longer erases
|
||||||
|
them, and the key counter restarts and hands out keys still in use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Empty bookkeeping, for a view that has nothing painted yet."""
|
||||||
|
# view row -> region keys painted on it
|
||||||
self.colored_lines = {}
|
self.colored_lines = {}
|
||||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||||
# repaint hint, so most dirty rows are identical frame to frame
|
# repaint hint, so most dirty rows are identical frame to frame
|
||||||
self._row_cache = {}
|
self.row_cache = {}
|
||||||
self._key_counter = 0
|
self.key_counter = 0
|
||||||
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||||
# rows trimmed so far, so they survive trim_history renumbering
|
# rows trimmed so far, so they survive trim_history renumbering
|
||||||
self._archived_chunks = {}
|
self.archived_chunks = {}
|
||||||
# rows waiting for their chunk to close, and the chunk they belong to
|
# rows waiting for their chunk to close, and the chunk they belong to
|
||||||
self._open_rows = []
|
self.open_rows = []
|
||||||
self._open_chunk = None
|
self.open_chunk = None
|
||||||
self._trimmed = 0
|
self.trimmed = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Draws the screen into the view. Holds nothing a frame depends on: the
|
||||||
|
view's bookkeeping is `terminal.render_state` (see RenderState)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Per-instance scratch only; the render state is bound in run()."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# the terminal's current SchemeManager and RenderState; set at the top of each run()
|
||||||
|
self._scheme = None
|
||||||
|
self.state = None
|
||||||
self._lastrow = 0
|
self._lastrow = 0
|
||||||
self._applied_cursor_style = None
|
self._applied_cursor_style = None
|
||||||
|
|
||||||
def next_key(self):
|
def next_key(self):
|
||||||
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
||||||
self._key_counter += 1
|
self.state.key_counter += 1
|
||||||
return "vterm#{}".format(self._key_counter)
|
return "vterm#{}".format(self.state.key_counter)
|
||||||
|
|
||||||
def ensure_row(self, edit, row):
|
def ensure_row(self, edit, row):
|
||||||
"""Row-existence check for the hot path, tracking the last row in python"""
|
"""Row-existence check for the hot path, tracking the last row in python"""
|
||||||
@@ -156,6 +172,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
terminal = Terminal.from_id(view.id())
|
terminal = Terminal.from_id(view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
|
self._scheme = terminal.scheme
|
||||||
|
if terminal.render_state is None:
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
self.state = terminal.render_state
|
||||||
|
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
# the caret and the tab name are the user's; an unfocused terminal leaves
|
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||||
@@ -254,16 +274,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
|
|
||||||
view.replace(edit, sublime.Region(0, view.size()), "")
|
view.replace(edit, sublime.Region(0, view.size()), "")
|
||||||
for line in list(self.colored_lines):
|
for line in list(self.state.colored_lines):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
for chunk, scopes in self._archived_chunks.items():
|
for chunk, scopes in self.state.archived_chunks.items():
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
self._archived_chunks.clear()
|
self.state.archived_chunks.clear()
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
self._open_chunk = None
|
self.state.open_chunk = None
|
||||||
self._trimmed = 0
|
self.state.trimmed = 0
|
||||||
self._row_cache.clear()
|
self.state.row_cache.clear()
|
||||||
terminal.offset = 0
|
terminal.offset = 0
|
||||||
screen.history.clear()
|
screen.history.clear()
|
||||||
if wipe_screen:
|
if wipe_screen:
|
||||||
@@ -310,17 +330,18 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
||||||
# back identical, and rewriting one costs an edit plus its region churn
|
# back identical, and rewriting one costs an edit plus its region churn
|
||||||
rendered = (segments, lf)
|
rendered = (segments, lf)
|
||||||
if self._row_cache.get(line) == rendered:
|
if self.state.row_cache.get(line) == rendered:
|
||||||
return
|
return
|
||||||
self._row_cache[line] = rendered
|
self.state.row_cache[line] = rendered
|
||||||
|
|
||||||
by_scope = {}
|
by_scope = {}
|
||||||
needed = 0
|
needed = 0
|
||||||
|
scope_of = self._scheme.scope
|
||||||
for _, start, end, fg, bg, style in segments:
|
for _, start, end, fg, bg, style in segments:
|
||||||
if is_default_run(fg, bg, style):
|
if is_default_run(fg, bg, style):
|
||||||
continue
|
continue
|
||||||
needed = max(needed, end)
|
needed = max(needed, end)
|
||||||
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
|
by_scope.setdefault(scope_of(fg, bg, style), []).append((start, end))
|
||||||
|
|
||||||
text = "".join(s[0] for s in segments)
|
text = "".join(s[0] for s in segments)
|
||||||
if lf:
|
if lf:
|
||||||
@@ -340,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
def colorize_line(self, line, line_start, by_scope):
|
def colorize_line(self, line, line_start, by_scope):
|
||||||
"""Lay the colour overlays for one row, one add_regions per scope."""
|
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||||
view = self.view
|
view = self.view
|
||||||
keys = self.colored_lines.setdefault(line, []) if by_scope else None
|
keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
|
||||||
for scope, spans in by_scope.items():
|
for scope, spans in by_scope.items():
|
||||||
key = self.next_key()
|
key = self.next_key()
|
||||||
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
||||||
@@ -348,21 +369,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
def decolorize_line(self, line):
|
def decolorize_line(self, line):
|
||||||
"""Drop a row's colour overlays before it is rewritten or erased."""
|
"""Drop a row's colour overlays before it is rewritten or erased."""
|
||||||
if line in self.colored_lines:
|
if line in self.state.colored_lines:
|
||||||
for key in self.colored_lines[line]:
|
for key in self.state.colored_lines[line]:
|
||||||
self.view.erase_regions(key)
|
self.view.erase_regions(key)
|
||||||
del self.colored_lines[line]
|
del self.state.colored_lines[line]
|
||||||
|
|
||||||
def archive_rows(self, rows):
|
def archive_rows(self, rows):
|
||||||
"""Queue rows that left the screen for their chunk's merge."""
|
"""Queue rows that left the screen for their chunk's merge."""
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row not in self.colored_lines:
|
if row not in self.state.colored_lines:
|
||||||
continue
|
continue
|
||||||
chunk = (row + self._trimmed) // CHUNK
|
chunk = (row + self.state.trimmed) // CHUNK
|
||||||
if chunk != self._open_chunk:
|
if chunk != self.state.open_chunk:
|
||||||
self.flush_chunk()
|
self.flush_chunk()
|
||||||
self._open_chunk = chunk
|
self.state.open_chunk = chunk
|
||||||
self._open_rows.append(row)
|
self.state.open_rows.append(row)
|
||||||
|
|
||||||
def flush_chunk(self):
|
def flush_chunk(self):
|
||||||
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||||
@@ -373,28 +394,29 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
whole set, so merging every frame would be quadratic in CHUNK.
|
whole set, so merging every frame would be quadratic in CHUNK.
|
||||||
"""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
rows, chunk = self._open_rows, self._open_chunk
|
rows, chunk = self.state.open_rows, self.state.open_chunk
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
grouped = {}
|
grouped = {}
|
||||||
stale = []
|
stale = []
|
||||||
|
scope_of = self._scheme.scope
|
||||||
for row in rows:
|
for row in rows:
|
||||||
keys = self.colored_lines.pop(row, None)
|
keys = self.state.colored_lines.pop(row, None)
|
||||||
if not keys:
|
if not keys:
|
||||||
continue
|
continue
|
||||||
stale += keys
|
stale += keys
|
||||||
# the segments that were painted, straight from the row cache
|
# the segments that were painted, straight from the row cache
|
||||||
segments = self._row_cache[row][0]
|
segments = self.state.row_cache[row][0]
|
||||||
start = view.line(view.text_point(row, 0)).begin()
|
start = view.line(view.text_point(row, 0)).begin()
|
||||||
for _, a, b, fg, bg, style in segments:
|
for _, a, b, fg, bg, style in segments:
|
||||||
if is_default_run(fg, bg, style):
|
if is_default_run(fg, bg, style):
|
||||||
continue
|
continue
|
||||||
scope = scheme_manager.scope(fg, bg, style)
|
scope = scope_of(fg, bg, style)
|
||||||
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||||
|
|
||||||
scopes = self._archived_chunks.setdefault(chunk, set())
|
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||||
for scope, regions in grouped.items():
|
for scope, regions in grouped.items():
|
||||||
key = chunk_key(chunk, scope)
|
key = chunk_key(chunk, scope)
|
||||||
if scope in scopes:
|
if scope in scopes:
|
||||||
@@ -427,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
while row > cursor_row:
|
while row > cursor_row:
|
||||||
line_region = view.line(view.text_point(row, 0))
|
line_region = view.line(view.text_point(row, 0))
|
||||||
text = view.substr(line_region)
|
text = view.substr(line_region)
|
||||||
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
|
if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
|
||||||
row = row - 1
|
row = row - 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
@@ -441,22 +463,23 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
||||||
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
||||||
view.erase(edit, trailing_region)
|
view.erase(edit, trailing_region)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
|
|
||||||
def trim_history(self, edit, terminal):
|
def trim_history(self, edit, terminal):
|
||||||
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
||||||
view = self.view
|
view = self.view
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
lastrow = view.rowcol(view.size())[0]
|
lastrow = view.rowcol(view.size())[0]
|
||||||
n = terminal.scrollback_size
|
# the setting counts rows above the screen; 0 keeps the screen alone
|
||||||
|
n = terminal.scrollback_size + screen.lines
|
||||||
if lastrow + 1 > n:
|
if lastrow + 1 > n:
|
||||||
# queued rows are about to be renumbered or erased: merge them first
|
# queued rows are about to be renumbered or erased: merge them first
|
||||||
self.flush_chunk()
|
self.flush_chunk()
|
||||||
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
||||||
for line in range(m):
|
for line in range(m):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
|
self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
|
||||||
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
|
self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
|
||||||
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
||||||
view.erase(edit, top_region)
|
view.erase(edit, top_region)
|
||||||
terminal.offset -= m
|
terminal.offset -= m
|
||||||
@@ -464,12 +487,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
# chunks wholly above the cut go; the boundary chunk drops the
|
# chunks wholly above the cut go; the boundary chunk drops the
|
||||||
# regions the erase collapsed to empty
|
# regions the erase collapsed to empty
|
||||||
self._trimmed += m
|
self.state.trimmed += m
|
||||||
cut = self._trimmed // CHUNK
|
cut = self.state.trimmed // CHUNK
|
||||||
for chunk in [c for c in self._archived_chunks if c < cut]:
|
for chunk in [c for c in self.state.archived_chunks if c < cut]:
|
||||||
for scope in self._archived_chunks.pop(chunk):
|
for scope in self.state.archived_chunks.pop(chunk):
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
for scope in self._archived_chunks.get(cut, ()):
|
for scope in self.state.archived_chunks.get(cut, ()):
|
||||||
key = chunk_key(cut, scope)
|
key = chunk_key(cut, scope)
|
||||||
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
||||||
|
|
||||||
@@ -478,7 +501,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
for line in view.lines(tail_region):
|
for line in view.lines(tail_region):
|
||||||
row = view.rowcol(line.begin())[0]
|
row = view.rowcol(line.begin())[0]
|
||||||
self.decolorize_line(row)
|
self.decolorize_line(row)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
view.erase(edit, tail_region)
|
view.erase(edit, tail_region)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
167
vterm/scheme.py
167
vterm/scheme.py
@@ -8,16 +8,12 @@ import sublime
|
|||||||
|
|
||||||
from .emulator import ANSI_COLORS
|
from .emulator import ANSI_COLORS
|
||||||
from .utils import settings
|
from .utils import settings
|
||||||
from .themes import THEMES
|
from .themes import ALL_THEME_NAMES, THEMES
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
# written under Packages/User so it survives a package reinstall
|
# written under Packages/User so it survives a package reinstall
|
||||||
SCHEME_DIR_NAME = "User/vterm"
|
SCHEME_DIR_NAME = "User/vterm"
|
||||||
# "hidden-" keeps the generated scheme out of the scheme picker
|
|
||||||
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
|
|
||||||
# the value terminal views get as their color_scheme setting
|
|
||||||
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
|
|
||||||
|
|
||||||
# each write makes sublime reload the scheme and restyle every view
|
# each write makes sublime reload the scheme and restyle every view
|
||||||
WRITE_DEBOUNCE_MS = 250
|
WRITE_DEBOUNCE_MS = 250
|
||||||
@@ -69,104 +65,61 @@ def _is_dark(color_text):
|
|||||||
|
|
||||||
|
|
||||||
class SchemeManager:
|
class SchemeManager:
|
||||||
"""Owns the generated scheme file and the set of known scopes."""
|
"""Owns one theme's generated scheme file and set of known scopes."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, theme_name):
|
||||||
"""Start on the built-in palette; initialize() picks up the real one."""
|
"""State only; build() resolves the palette and writes the file."""
|
||||||
|
self.theme_name = theme_name
|
||||||
|
self.file_name = "vterm-{}.hidden-color-scheme".format(theme_name or "system")
|
||||||
|
self.path = "Packages/{}/{}".format(SCHEME_DIR_NAME, self.file_name)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.rules = {}
|
self.rules = {}
|
||||||
# key -> scope name; the render thread reads it without the lock, so it
|
# key -> scope name; the render thread reads it without the lock, so it
|
||||||
# must never outlive self.rules (cleared together in _build)
|
# must never outlive self.rules (cleared together in build)
|
||||||
self._names = {}
|
self._names = {}
|
||||||
self.foreground = "#ffffff"
|
self.foreground = "#ffffff"
|
||||||
self.background = "#000000"
|
self.background = "#000000"
|
||||||
self._editor_fg = "#ffffff"
|
|
||||||
self._editor_bg = "#000000"
|
|
||||||
self.ansi = dict(DEFAULT_ANSI)
|
self.ansi = dict(DEFAULT_ANSI)
|
||||||
self._write_pending = False
|
self._write_pending = False
|
||||||
self._initialized = False
|
|
||||||
self._dir = None
|
self._dir = None
|
||||||
self._host_view = None
|
|
||||||
self._listeners = []
|
|
||||||
|
|
||||||
def initialize(self, view):
|
def build(self, host_view=None):
|
||||||
"""Pick up the palette, seed the rules, write the file. Main thread only"""
|
|
||||||
if self._initialized:
|
|
||||||
return
|
|
||||||
self._initialized = True
|
|
||||||
self._host_view = view
|
|
||||||
|
|
||||||
settings().add_on_change("vterm.theme", self.reload)
|
|
||||||
self._build()
|
|
||||||
|
|
||||||
def ensure_file(self):
|
|
||||||
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
|
|
||||||
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
|
|
||||||
return
|
|
||||||
with self.lock:
|
|
||||||
self._build()
|
|
||||||
|
|
||||||
def add_listener(self, callback):
|
|
||||||
"""Called after every palette rebuild"""
|
|
||||||
self._listeners.append(callback)
|
|
||||||
|
|
||||||
def remove_listener(self, callback):
|
|
||||||
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
|
|
||||||
if callback in self._listeners:
|
|
||||||
self._listeners.remove(callback)
|
|
||||||
|
|
||||||
def reload(self):
|
|
||||||
"""Rebuild every rule for a new palette, then notify the terminals"""
|
|
||||||
if not self._initialized:
|
|
||||||
# no scheme file to maintain yet; initialize() will read the setting
|
|
||||||
return
|
|
||||||
with self.lock:
|
|
||||||
self._build()
|
|
||||||
for callback in list(self._listeners):
|
|
||||||
try:
|
|
||||||
callback()
|
|
||||||
except Exception:
|
|
||||||
LOG.warning("scheme listener failed", exc_info=True)
|
|
||||||
|
|
||||||
def _build(self):
|
|
||||||
"""Resolve the palette and regenerate every rule, then write the file.
|
"""Resolve the palette and regenerate every rule, then write the file.
|
||||||
|
|
||||||
Rules cannot be appended after a palette change: each one stores resolved
|
Rules cannot be appended after a palette change: each one stores resolved
|
||||||
colours, so they all have to be rebuilt. Caller holds the lock.
|
colours, so they all have to be rebuilt. Callable again later (e.g. to
|
||||||
|
re-sample the "No theme" palette from the editor's current colours).
|
||||||
"""
|
"""
|
||||||
self._names = {}
|
with self.lock:
|
||||||
self.rules = {}
|
self._names = {}
|
||||||
self.ansi = dict(DEFAULT_ANSI)
|
self.rules = {}
|
||||||
|
self.ansi = dict(DEFAULT_ANSI)
|
||||||
|
|
||||||
theme = THEMES.get(settings().get("theme"))
|
theme = THEMES.get(self.theme_name)
|
||||||
|
|
||||||
if theme:
|
if theme:
|
||||||
self.foreground = theme["foreground"]
|
self.foreground = theme["foreground"]
|
||||||
self.background = theme["background"]
|
self.background = theme["background"]
|
||||||
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
||||||
else:
|
elif host_view is not None and host_view.is_valid():
|
||||||
# captured values outlive the view they came from
|
style = host_view.style()
|
||||||
if self._host_view is not None and self._host_view.is_valid():
|
self.foreground = style.get("foreground", self.foreground)
|
||||||
style = self._host_view.style()
|
self.background = style.get("background", self.background)
|
||||||
self._editor_fg = style.get("foreground", self._editor_fg)
|
|
||||||
self._editor_bg = style.get("background", self._editor_bg)
|
|
||||||
self.foreground = self._editor_fg
|
|
||||||
self.background = self._editor_bg
|
|
||||||
|
|
||||||
# user overrides sit on top of whichever palette was chosen
|
# user overrides sit on top of whichever palette was chosen
|
||||||
user_ansi = settings().get("ansi_colors", {})
|
user_ansi = settings().get("ansi_colors", {})
|
||||||
for name in ANSI_COLORS:
|
for name in ANSI_COLORS:
|
||||||
if name in user_ansi:
|
if name in user_ansi:
|
||||||
self.ansi[name] = user_ansi[name]
|
self.ansi[name] = user_ansi[name]
|
||||||
|
|
||||||
# seeded so the first frame does not reference scopes the file lacks
|
# seeded so the first frame does not reference scopes the file lacks
|
||||||
for fg in ANSI_COLORS:
|
for fg in ANSI_COLORS:
|
||||||
self._add_rule(fg, "default", "")
|
self._add_rule(fg, "default", "")
|
||||||
self._add_rule(fg, "default", "b")
|
self._add_rule(fg, "default", "b")
|
||||||
self._add_rule("default", "default", "b")
|
self._add_rule("default", "default", "b")
|
||||||
self._add_rule("reverse_default", "reverse_default", "")
|
self._add_rule("reverse_default", "reverse_default", "")
|
||||||
|
|
||||||
self._write_file()
|
self._write_file()
|
||||||
|
|
||||||
def is_dark(self):
|
def is_dark(self):
|
||||||
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
|
"""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)
|
rule["font_style"] = " ".join(font_style)
|
||||||
self.rules[(fg, bg, style)] = rule
|
self.rules[(fg, bg, style)] = rule
|
||||||
|
|
||||||
|
def file_path(self):
|
||||||
|
"""Filesystem path of the generated scheme file."""
|
||||||
|
return os.path.join(self._directory(), self.file_name)
|
||||||
|
|
||||||
def _directory(self):
|
def _directory(self):
|
||||||
"""Path of the generated scheme's directory, created on first use."""
|
"""Path of the generated scheme's directory, created on first use."""
|
||||||
if self._dir is None:
|
if self._dir is None:
|
||||||
@@ -258,11 +215,49 @@ class SchemeManager:
|
|||||||
},
|
},
|
||||||
"rules": list(self.rules.values()),
|
"rules": list(self.rules.values()),
|
||||||
}
|
}
|
||||||
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
|
with open(self.file_path(), "w", encoding="utf-8") as f:
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
# compact: sublime reparses the whole file on every write
|
# compact: sublime reparses the whole file on every write
|
||||||
json.dump(scheme, f, separators=(",", ":"))
|
json.dump(scheme, f, separators=(",", ":"))
|
||||||
LOG.debug("scheme written: rules=%s", len(self.rules))
|
LOG.debug("scheme written: rules=%s", len(self.rules))
|
||||||
|
|
||||||
|
|
||||||
scheme_manager = SchemeManager()
|
# one manager per theme name, built once and kept for the plugin host's life: the
|
||||||
|
# built-in theme set is small and fixed, so there is nothing to garbage-collect
|
||||||
|
_managers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheme_manager(theme_name, host_view=None):
|
||||||
|
"""The manager for a theme name (None for "follow the Sublime colour scheme"),
|
||||||
|
building and caching it on first use.
|
||||||
|
|
||||||
|
The None entry is rebuilt whenever the editor's colours have changed since: it
|
||||||
|
is a live sample of them, not a static palette, so every caller handing a
|
||||||
|
terminal a "no theme" scheme gets one that is actually current.
|
||||||
|
"""
|
||||||
|
manager = _managers.get(theme_name)
|
||||||
|
if manager is None:
|
||||||
|
manager = SchemeManager(theme_name)
|
||||||
|
_managers[theme_name] = manager
|
||||||
|
manager.build(host_view)
|
||||||
|
elif theme_name is None and host_view is not None and host_view.is_valid():
|
||||||
|
style = host_view.style()
|
||||||
|
if (style.get("foreground"), style.get("background")) != (manager.foreground, manager.background):
|
||||||
|
manager.build(host_view)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def build_startup_schemes(host_view=None):
|
||||||
|
"""Make sure every theme's colour scheme is on disk before Sublime reads a
|
||||||
|
restored tab's color_scheme at startup.
|
||||||
|
|
||||||
|
A named theme is static, so an existing file is left alone - rebuilding it
|
||||||
|
would just rewrite the same bytes, and each write makes sublime reload the
|
||||||
|
scheme and restyle every view using it. None always rebuilds, same as
|
||||||
|
get_scheme_manager.
|
||||||
|
"""
|
||||||
|
for name in ALL_THEME_NAMES:
|
||||||
|
if name is None:
|
||||||
|
get_scheme_manager(None, host_view=host_view)
|
||||||
|
continue
|
||||||
|
if not os.path.exists(SchemeManager(name).file_path()):
|
||||||
|
get_scheme_manager(name)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from pyte import modes as mo
|
|||||||
|
|
||||||
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
|
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
|
||||||
from .keys import get_key_code
|
from .keys import get_key_code
|
||||||
from .scheme import scheme_manager
|
|
||||||
from .utils import intermission, responsive, settings
|
from .utils import intermission, responsive, settings
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
@@ -27,6 +26,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075
|
|||||||
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||||
FEED_LIMIT = 48 * 1024
|
FEED_LIMIT = 48 * 1024
|
||||||
|
|
||||||
|
# unparsed 8K reads the reader may hold before it pauses: ~10 ticks of parsing
|
||||||
|
PENDING_CHUNKS = 64
|
||||||
|
|
||||||
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||||
REPAINT_NUDGE_DELAY = 0.25
|
REPAINT_NUDGE_DELAY = 0.25
|
||||||
|
|
||||||
@@ -98,9 +100,11 @@ class Terminal:
|
|||||||
|
|
||||||
_terminals = {}
|
_terminals = {}
|
||||||
|
|
||||||
def __init__(self, view):
|
def __init__(self, view, scheme):
|
||||||
"""State only; the pty and the threads come up in start()."""
|
"""State only; the pty and the threads come up in start()."""
|
||||||
self.view = view
|
self.view = view
|
||||||
|
self.scheme = scheme
|
||||||
|
self.render_state = None
|
||||||
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||||
self.alive = True
|
self.alive = True
|
||||||
self.default_title = ""
|
self.default_title = ""
|
||||||
@@ -120,6 +124,8 @@ class Terminal:
|
|||||||
# guards the queue/flag handoff between send_string and the drain thread
|
# guards the queue/flag handoff between send_string and the drain thread
|
||||||
self._send_lock = threading.Lock()
|
self._send_lock = threading.Lock()
|
||||||
self._wheel_captured = False
|
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._scroll_pending = False
|
||||||
self.pending_clear = False
|
self.pending_clear = False
|
||||||
self.pending_clear_screen = False
|
self.pending_clear_screen = False
|
||||||
@@ -197,13 +203,11 @@ class Terminal:
|
|||||||
history=self.scrollback_size,
|
history=self.scrollback_size,
|
||||||
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
||||||
clear_callback=self._clear_callback,
|
clear_callback=self._clear_callback,
|
||||||
clipboard_callback=self._clipboard_callback,
|
fg_color=self.scheme.foreground.lstrip("#"),
|
||||||
fg_color=scheme_manager.foreground.lstrip("#"),
|
bg_color=self.scheme.background.lstrip("#"),
|
||||||
bg_color=scheme_manager.background.lstrip("#"),
|
is_dark=self.scheme.is_dark(),
|
||||||
is_dark=scheme_manager.is_dark(),
|
|
||||||
)
|
)
|
||||||
self.stream = VtStream(self.screen)
|
self.stream = VtStream(self.screen)
|
||||||
scheme_manager.add_listener(self.apply_scheme)
|
|
||||||
|
|
||||||
self._start_rendering()
|
self._start_rendering()
|
||||||
|
|
||||||
@@ -211,20 +215,28 @@ class Terminal:
|
|||||||
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
|
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
|
||||||
# the screen answers OSC 10/11 and DSR 996 from these values
|
# the screen answers OSC 10/11 and DSR 996 from these values
|
||||||
screen = self.screen
|
screen = self.screen
|
||||||
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
|
screen.default_fg_color = self.scheme.foreground.lstrip("#")
|
||||||
screen.default_bg_color = scheme_manager.background.lstrip("#")
|
screen.default_bg_color = self.scheme.background.lstrip("#")
|
||||||
screen.theme_is_dark = scheme_manager.is_dark()
|
screen.theme_is_dark = self.scheme.is_dark()
|
||||||
if screen.theme_notify_enabled():
|
if screen.theme_notify_enabled():
|
||||||
self.send_string(screen.theme_report(), normalized=False)
|
self.send_string(screen.theme_report(), normalized=False)
|
||||||
|
|
||||||
|
def retheme(self, scheme):
|
||||||
|
"""Switch to a different scheme: vterm: Select Theme, scoped to this terminal."""
|
||||||
|
if scheme is self.scheme:
|
||||||
|
return
|
||||||
|
self.scheme = scheme
|
||||||
|
self.view.settings().set("color_scheme", scheme.path)
|
||||||
|
self.apply_scheme()
|
||||||
|
|
||||||
def kill(self):
|
def kill(self):
|
||||||
"""Terminate the child and drop this terminal from the registry."""
|
"""Terminate the child and drop this terminal from the registry."""
|
||||||
LOG.debug("[kill] terminating process")
|
LOG.debug("[kill] terminating process")
|
||||||
self.alive = False
|
self.alive = False
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
scheme_manager.remove_listener(self.apply_scheme)
|
|
||||||
try:
|
try:
|
||||||
self.process.terminate()
|
# force: a child ignoring HUP and INT would otherwise outlive the tab
|
||||||
|
self.process.terminate(force=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("terminate failed", exc_info=True)
|
LOG.warning("terminate failed", exc_info=True)
|
||||||
vid = self.view.id()
|
vid = self.view.id()
|
||||||
@@ -235,10 +247,6 @@ class Terminal:
|
|||||||
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
||||||
self.pending_clear = True
|
self.pending_clear = True
|
||||||
|
|
||||||
def _clipboard_callback(self, text):
|
|
||||||
"""OSC 52 payload; the clipboard may only be touched on the UI thread."""
|
|
||||||
sublime.set_timeout(lambda: sublime.set_clipboard(text))
|
|
||||||
|
|
||||||
def _need_to_render(self):
|
def _need_to_render(self):
|
||||||
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
||||||
screen = self.screen
|
screen = self.screen
|
||||||
@@ -274,6 +282,10 @@ class Terminal:
|
|||||||
break
|
break
|
||||||
chunks.append(self._decoder.decode(raw))
|
chunks.append(self._decoder.decode(raw))
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
# backpressure: once this much waits unparsed, stop reading so
|
||||||
|
# the pty fills and the child blocks, as under a real terminal
|
||||||
|
while len(chunks) > PENDING_CHUNKS and not done[0] and self.is_hosted():
|
||||||
|
time.sleep(RENDER_PERIOD)
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[reader] stops")
|
LOG.debug("[reader] stops")
|
||||||
break
|
break
|
||||||
@@ -322,7 +334,7 @@ class Terminal:
|
|||||||
if frames >= UNDO_CLEAR_FRAMES:
|
if frames >= UNDO_CLEAR_FRAMES:
|
||||||
frames = 0
|
frames = 0
|
||||||
self.view.run_command("vterm_clear_undo_stack")
|
self.view.run_command("vterm_clear_undo_stack")
|
||||||
self._sync_wheel_flag()
|
self._sync_mouse_flags()
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[renderer] stops")
|
LOG.debug("[renderer] stops")
|
||||||
break
|
break
|
||||||
@@ -418,12 +430,16 @@ class Terminal:
|
|||||||
continue
|
continue
|
||||||
self.process.write(string)
|
self.process.write(string)
|
||||||
|
|
||||||
def _sync_wheel_flag(self):
|
def _sync_mouse_flags(self):
|
||||||
"""Publish whether the app owns the wheel, for the mousemap context."""
|
"""Publish whether the app owns the wheel and the mouse, for the mousemap contexts."""
|
||||||
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
|
reporting = self.screen.mouse_reporting_enabled()
|
||||||
|
captured = reporting or self.screen.alternate_buffer_mode
|
||||||
if captured != self._wheel_captured:
|
if captured != self._wheel_captured:
|
||||||
self._wheel_captured = captured
|
self._wheel_captured = captured
|
||||||
self.view.settings().set("vterm_view.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):
|
def send_focus(self, focused):
|
||||||
"""Report focus gain or loss, when the app asked for it (?1004)."""
|
"""Report focus gain or loss, when the app asked for it (?1004)."""
|
||||||
|
|||||||
@@ -50,4 +50,27 @@ THEMES = {
|
|||||||
"light_cyan": "#8cdfe0",
|
"light_cyan": "#8cdfe0",
|
||||||
"light_white": "#e0e0e0",
|
"light_white": "#e0e0e0",
|
||||||
},
|
},
|
||||||
|
"red-alert": {
|
||||||
|
"foreground": "#ffffff",
|
||||||
|
"background": "#762423",
|
||||||
|
"black": "#000000",
|
||||||
|
"red": "#d62e4e",
|
||||||
|
"green": "#71be6b",
|
||||||
|
"brown": "#beb86b",
|
||||||
|
"blue": "#489bee",
|
||||||
|
"magenta": "#e979d7",
|
||||||
|
"cyan": "#6bbeb8",
|
||||||
|
"white": "#d6d6d6",
|
||||||
|
"light_black": "#262626",
|
||||||
|
"light_red": "#e02553",
|
||||||
|
"light_green": "#aff08c",
|
||||||
|
"light_brown": "#dfddb7",
|
||||||
|
"light_blue": "#65aaf1",
|
||||||
|
"light_magenta": "#ddb7df",
|
||||||
|
"light_cyan": "#b7dfdd",
|
||||||
|
"light_white": "#ffffff",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# every selectable theme, including None for "follow the active Sublime colour scheme"
|
||||||
|
ALL_THEME_NAMES = [None] + sorted(THEMES)
|
||||||
|
|||||||
Reference in New Issue
Block a user