initial import
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
vterm/__pycache__
|
||||||
|
*.sublime-workspace
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.8
|
||||||
434
Architecture.md
Normal file
434
Architecture.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### The package
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `main.py` | Entry point. Imports every command and listener class so Sublime discovers them, and defines `plugin_loaded()`. |
|
||||||
|
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
|
||||||
|
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
|
||||||
|
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
||||||
|
| `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. |
|
||||||
|
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). |
|
||||||
|
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
|
||||||
|
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
||||||
|
| `vterm/mouse.py` | Wheel and click handling. |
|
||||||
|
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
|
||||||
|
| `vterm/utils.py` | `settings()`, the `responsive` throttle, the `intermission` pacer, `rev_wcwidth`. |
|
||||||
|
|
||||||
|
### Sublime-facing files at the repo root
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `vterm.sublime-settings` | Defaults for the ten plugin settings. |
|
||||||
|
| `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. |
|
||||||
|
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
|
||||||
|
| `Default (OSX).sublime-mousemap` | Two wheel bindings. |
|
||||||
|
| `dependencies.json` | Package Control libraries. |
|
||||||
|
|
||||||
|
Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`.
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
- **`Terminal`** (`terminal.py`) — one per terminal tab. Owns the pty, the `VtScreen` and
|
||||||
|
`VtStream`, and the two threads. Instances live in the class-level registry
|
||||||
|
`Terminal._terminals`, keyed by **view id**; `Terminal.from_id(view.id())` is how every
|
||||||
|
command and listener finds its terminal, and it is a plain dict lookup, so it is the
|
||||||
|
cheapest possible gate to put at the top of a hook.
|
||||||
|
- **`TerminalPtyProcess`** (`terminal.py`) — `ptyprocess.PtyProcess` that accepts `str`.
|
||||||
|
- **`VtScreen`** (`emulator.py`) — `pyte.Screen` plus the alternate screen, scrollback,
|
||||||
|
synchronized output, mouse/focus/paste modes, cursor styles, query replies, OSC
|
||||||
|
handling. Mutated only by the renderer thread, so it carries no lock.
|
||||||
|
- **`VtStream`** (`emulator.py`) — `pyte.Stream` with a rewritten parser FSM. Stock pyte
|
||||||
|
drops what these agents rely on: `>`/`<`/`=` prefixed CSI, the DECRQM `$p` intermediate,
|
||||||
|
and OSC codes longer than one digit. Its `feed()` regex-matches complete plain CSI and
|
||||||
|
dispatches them directly (params memoized) — pyte resumes the parser coroutine once per
|
||||||
|
character, which dominated escape-heavy floods. OSC, charsets, private/prefixed CSI and
|
||||||
|
`:` subparams still take the coroutine, byte for byte; a read ending mid-CSI is carried
|
||||||
|
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
|
||||||
|
`Char` carrying the extra `linefeed` field.
|
||||||
|
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
||||||
|
generated scheme file and the mapping from colour combination to scope name.
|
||||||
|
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
|
||||||
|
view**, so its `colored_lines`, `_row_cache`, `_key_counter` and `_lastrow` are that
|
||||||
|
view's render state.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Class | Reached from |
|
||||||
|
|---|---|---|
|
||||||
|
| `vterm_open` | `VtermOpenCommand` | palette, user keybindings |
|
||||||
|
| `vterm_open_terminal_project_folder` | `VtermOpenTerminalProjectFolderCommand` | palette |
|
||||||
|
| `vterm_initialize_view` | `VtermInitializeViewCommand` | internal |
|
||||||
|
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
|
||||||
|
| `vterm_render` | `VtermRenderCommand` | renderer thread |
|
||||||
|
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
|
||||||
|
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
|
||||||
|
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
|
||||||
|
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
|
||||||
|
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
|
||||||
|
| `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding |
|
||||||
|
| `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal |
|
||||||
|
| `vterm_scroll` / `vterm_mouse_click` | `mouse.py` | mousemap, `drag_select` interception |
|
||||||
|
|
||||||
|
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
|
||||||
|
`VtermMouseEventListener` (`mouse.py`).
|
||||||
|
|
||||||
|
## Data flows
|
||||||
|
|
||||||
|
### Opening a terminal
|
||||||
|
|
||||||
|
`vterm_open` → resolve `cmd` (default `$SHELL -l`) and `cwd` (first project folder →
|
||||||
|
active file's directory → `~`) → `scheme_manager.initialize()` → `window.new_file()` →
|
||||||
|
`vterm_initialize_view` stamps the view settings → `Terminal(view)` → `Terminal.start()`:
|
||||||
|
register in `_terminals`, compute the size from the viewport, spawn the pty, build the
|
||||||
|
`VtScreen`/`VtStream`, subscribe to palette changes, start the two threads. All on the UI
|
||||||
|
thread; a failure closes the view again.
|
||||||
|
|
||||||
|
### Output: a pty byte becomes a visible character
|
||||||
|
|
||||||
|
```
|
||||||
|
child writes
|
||||||
|
│
|
||||||
|
├─ reader thread ──────────────────────────────────────────────
|
||||||
|
│ process.read(8192) blocking read on the pty master
|
||||||
|
│ self._decoder.decode(raw) incremental UTF-8: a sequence split
|
||||||
|
│ across reads must not be corrupted
|
||||||
|
│ chunks.append(text) collections.deque, no lock
|
||||||
|
│
|
||||||
|
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
||||||
|
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
||||||
|
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
||||||
|
│ mutates buffer, fills screen.dirty
|
||||||
|
│ in_sync_frame()? mid ?2026 frame → skip this tick
|
||||||
|
│ is_visible()? hidden tab → draw nothing this tick
|
||||||
|
│ was_resized()? (checked once a second) → resize pty
|
||||||
|
│ is_render_due()? unfocused → at most one frame per
|
||||||
|
│ UNFOCUSED_RENDER_PERIOD
|
||||||
|
│ _need_to_render()? dirty rows, a pending clear, or a
|
||||||
|
│ moved cursor
|
||||||
|
│ view.run_command("vterm_render")
|
||||||
|
│
|
||||||
|
└─ 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.** `terminal.viewport_y` records where the last auto-scroll left
|
||||||
|
the viewport. If the current viewport is *above* that, the user scrolled up by hand, and
|
||||||
|
trimming, caret placement and scrolling are all skipped so their position survives the
|
||||||
|
next frame.
|
||||||
|
|
||||||
|
### 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 and
|
||||||
|
sets `scroll_past_end` to false, leaving the view with no scroll range — the app's own
|
||||||
|
scrolling is the only scrolling. 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. 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=…)
|
||||||
|
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
|
||||||
|
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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`, `min_columns`,
|
||||||
|
`max_columns`, `theme`, `ansi_colors`, `debug`. `honor_erase_scrollback` is off, so ED 3
|
||||||
|
blanks the screen without dropping the scrollback: 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`, `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),
|
||||||
|
`scroll_past_end` (toggled by the alt-screen fold), 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal concepts, briefly
|
||||||
|
|
||||||
|
For anyone who has not worked with terminal protocols before. The ones vterm implements
|
||||||
|
are marked ✅.
|
||||||
|
|
||||||
|
**Control bytes** — single characters below 0x20 with an effect rather than a glyph: CR
|
||||||
|
(`\r`) return to column 0, LF (`\n`) next line, BS backspace, HT tab, BEL bell.
|
||||||
|
|
||||||
|
**Escape sequence** — `ESC` (0x1B) followed by more bytes. Everything below is one.
|
||||||
|
|
||||||
|
- **CSI** ✅ — Control Sequence Introducer, `ESC [`. The workhorse: `ESC[2J` clear screen,
|
||||||
|
`ESC[10;5H` move the cursor. Ends with a *final byte* that names the operation;
|
||||||
|
numbers in between are its parameters.
|
||||||
|
- **SGR** ✅ — Select Graphic Rendition, `CSI … m`. Sets colour and style: `ESC[1m` bold,
|
||||||
|
`ESC[31m` red foreground, `ESC[38;5;208m` a 256-palette colour, `ESC[38;2;r;g;bm`
|
||||||
|
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
||||||
|
- **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
|
||||||
|
foreground/background, `OSC 52` reads or writes the system clipboard.
|
||||||
|
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
||||||
|
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
||||||
|
discards the payload, so an image never leaks into the text.
|
||||||
|
- **SS3** ✅ — `ESC O`, a single shifted character. Cursor keys take this form in
|
||||||
|
application mode; F1-F4 use it too.
|
||||||
|
- **ST** — String Terminator, `ESC \`, which ends OSC/DCS/APC payloads (BEL also ends OSC).
|
||||||
|
|
||||||
|
**Modes** are switches. DEC *private* modes are set with `CSI ? n h` (DECSET) and cleared
|
||||||
|
with `CSI ? n l` (DECRST); **DECRQM** ✅ (`CSI ? n $p`) asks whether one is on, which is
|
||||||
|
how an app discovers what the terminal supports.
|
||||||
|
|
||||||
|
- **1049** ✅ alternate screen — a second, scrollback-less buffer for full-screen apps, so
|
||||||
|
leaving vim restores your shell exactly as it was.
|
||||||
|
- **2026** ✅ synchronized output — "I am mid-frame, do not draw yet"; what makes a
|
||||||
|
fullscreen UI update without tearing.
|
||||||
|
- **2004** ✅ bracketed paste — pasted text is wrapped in `ESC[200~`/`ESC[201~` so an app
|
||||||
|
can tell it apart from typing and not auto-indent it.
|
||||||
|
- **1000 / 1002 / 1003** ✅ mouse reporting — clicks / clicks with drag / all motion.
|
||||||
|
- **1006** ✅ SGR mouse encoding — the modern wire form, `ESC[<button;col;rowM`, without
|
||||||
|
the 223-column limit of the original.
|
||||||
|
- **1004** ✅ focus reporting — `ESC[I` on focus, `ESC[O` on blur.
|
||||||
|
- **DECCKM** ✅ — application cursor keys: arrows switch from `CSI A` to `SS3 A`.
|
||||||
|
- **DECAWM** ✅ — autowrap at the right margin.
|
||||||
|
- **DECSCNM** ✅ — reverse video for the whole screen.
|
||||||
|
- **DECSCUSR** ✅ (`CSI n SP q`) — caret shape: block, underline or bar, blinking or not.
|
||||||
|
- **DECSTBM** ✅ (`CSI t;b r`) — set the scrolling region to a band of rows.
|
||||||
|
- **DECKPAM** — application keypad mode, the numeric keypad's equivalent of DECCKM.
|
||||||
|
vterm ignores it.
|
||||||
|
- **LNM** ✅ — line feed/new line mode; decides whether Enter sends CR or CR LF.
|
||||||
|
|
||||||
|
**Queries** — the app asks, the terminal answers *on the input stream*, as if typed:
|
||||||
|
|
||||||
|
- **DA1 / DA2** ✅ (`CSI c`, `CSI > c`) — Device Attributes: what kind of terminal are you,
|
||||||
|
and what version. Claude Code waits on DA1 with no timeout, so failing to answer hangs it.
|
||||||
|
- **DSR / CPR** ✅ (`CSI n`, `CSI 6n`) — Device Status Report / Cursor Position Report:
|
||||||
|
"are you ok" and "where is the cursor". Private `DSR 996` reports whether the palette is
|
||||||
|
light or dark.
|
||||||
|
- **XTVERSION** ✅ (`CSI > q`) — the terminal's name and version. Naming ourselves is what
|
||||||
|
lets apps enable 2026 on a generic `TERM`.
|
||||||
|
- **Kitty keyboard protocol** — a richer key encoding with release events. vterm answers
|
||||||
|
the probe with "unsupported", so apps fall back to the legacy encodings it does send.
|
||||||
|
|
||||||
|
**Other terms**
|
||||||
|
|
||||||
|
- **pty** — pseudo-terminal: a kernel device pair. The child thinks its stdin/stdout is a
|
||||||
|
terminal; we hold the master end and read what it writes.
|
||||||
|
- **TIOCSWINSZ / SIGWINCH** ✅ — telling the pty its new size delivers SIGWINCH to the
|
||||||
|
child, which is how a resize reaches the app.
|
||||||
|
- **wcwidth** ✅ — how many columns a character occupies: 1 for ASCII, 2 for CJK and most
|
||||||
|
emoji, 0 for combining marks. Terminals are grids, so this has to agree with the font
|
||||||
|
or the caret drifts.
|
||||||
|
- **IME** — Input Method Editor: the OS layer that turns keystrokes into composed text
|
||||||
|
(pinyin → 中文, dead keys → accents). It commits text without a matching key event,
|
||||||
|
which is why `on_modified` exists.
|
||||||
|
- **TERM / COLORTERM** ✅ — environment variables describing the terminal's capabilities.
|
||||||
|
Children get `TERM=xterm-256color` and `COLORTERM=truecolor`.
|
||||||
37
CLAUDE.md
Normal file
37
CLAUDE.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
## 4. Writing Style, Code Style and Rules
|
||||||
|
When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
|
||||||
|
|
||||||
|
Coding rules can be found in CODE.md file
|
||||||
177
CODE.md
Normal file
177
CODE.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Code Style and Rules
|
||||||
|
|
||||||
|
## Python
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Use module-level loggers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use structured and parameterized logging:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOG.info("Export started: export_type=%s", export_type)
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
* Log important execution flow and state changes
|
||||||
|
* Log errors with enough context for debugging
|
||||||
|
* Use parameterized logging (`%s`) instead of f-strings
|
||||||
|
* Do not log secrets, passwords, tokens, personal data, or large payloads
|
||||||
|
* Use appropriate log levels:
|
||||||
|
* `debug` — detailed diagnostic information
|
||||||
|
* `info` — normal application flow
|
||||||
|
* `warning` — unexpected but recoverable situations
|
||||||
|
* `error` — failed operations
|
||||||
|
* `exception` — errors with stack traces
|
||||||
|
|
||||||
|
An `except` block that swallows a failure and continues must preserve the stack, with
|
||||||
|
`LOG.exception(...)` or `LOG.warning(..., exc_info=True)`. Logging only `str(exc)` loses
|
||||||
|
which failure mode fired and where.
|
||||||
|
|
||||||
|
Message form:
|
||||||
|
|
||||||
|
* `"<stage>.<step>: <what>"` for progress — `LOG.info("asset_render.symbols: generating symbol=%s", name)`
|
||||||
|
* `key=%s` pairs once a message carries more than one value; a single value may read as prose
|
||||||
|
* bracketed markers for lifecycle transitions — `[start]`, `[run]`, `[done]`, `[loading]`
|
||||||
|
|
||||||
|
`mmgen/cli.py` (pipeline start) is the reference for a multi-value message.
|
||||||
|
|
||||||
|
For function tracing, log only meaningful arguments and results when useful for debugging:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOG.debug("Creating export: design size=%s", len(design))
|
||||||
|
result = exporter.run()
|
||||||
|
LOG.debug("Export completed: items=%s", len(result))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Doc strings
|
||||||
|
|
||||||
|
**Module doc strings.** The first line is a concise summary of what the module is for:
|
||||||
|
|
||||||
|
```python
|
||||||
|
""" Simple minislot images generation """
|
||||||
|
```
|
||||||
|
|
||||||
|
Further paragraphs are allowed, and encouraged, only to record *why* the module exists or a
|
||||||
|
non-obvious constraint it encodes — a failure observed in practice, an external format
|
||||||
|
requirement, a decision a future reader would otherwise undo. Naming config keys, context
|
||||||
|
keys or sibling modules is fine when that is the constraint being explained. Do not restate
|
||||||
|
the module's API. See `mmgen/asset_design/framing.py` for a justified long one.
|
||||||
|
|
||||||
|
**Function and method doc strings** use this format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def update_columns_by_filter_cond(
|
||||||
|
table: Base,
|
||||||
|
filter_cond: Any,
|
||||||
|
columns: dict,
|
||||||
|
session: Optional[Session] = None,
|
||||||
|
) -> list[Base]:
|
||||||
|
"""
|
||||||
|
Update specified columns of all rows in the database matching a given filter condition.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table: The database table to update.
|
||||||
|
filter_cond: SQLAlchemy filter condition to select rows.
|
||||||
|
columns: A dictionary containing column names as keys and new values.
|
||||||
|
session: The database session to use.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Base]: A list of refreshed ORM instances reflecting the updates.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the table is not mapped.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
* Types belong in the signature, not in `Args:` — a doc string type is a second source of
|
||||||
|
truth that drifts, and pyright already checks the real one
|
||||||
|
* `Returns:` leads with the type, because there is no parameter name to carry it
|
||||||
|
* `Raises:` documents the exceptions a caller is expected to handle
|
||||||
|
* Omit `Args:`/`Returns:` on a private helper whose signature is self-evident; a one-line
|
||||||
|
summary is enough
|
||||||
|
* A `Step.run()` override needs no doc string when the class doc string says what the step
|
||||||
|
does — the contract is documented once on the base class in `mmgen/pipeline.py`
|
||||||
|
|
||||||
|
### Type hints
|
||||||
|
|
||||||
|
- when optional type use Optional. Eg. do not use "dict | None", use "Optional[dict]"
|
||||||
|
- annotate every parameter and return type; parameterize containers (`dict[str, int]`, not
|
||||||
|
bare `dict`) where the element types are known
|
||||||
|
- no suppression comments (`# type: ignore`, `# pyright: ignore`) — solve the typing
|
||||||
|
problem instead
|
||||||
|
|
||||||
|
### Code comments
|
||||||
|
|
||||||
|
Use comments only when they add non-obvious context.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
* Prefer clear naming and small functions over comments
|
||||||
|
* Comment:
|
||||||
|
* business logic
|
||||||
|
* important decisions
|
||||||
|
* workarounds or edge cases
|
||||||
|
* logical sections in long functions
|
||||||
|
* Keep comments short and explain **why**, not **what**
|
||||||
|
* Update comments when code changes
|
||||||
|
* Avoid comments that restate the code
|
||||||
|
* Don't add and don't remove comments to a block of code if you did not create it or modify it.
|
||||||
|
|
||||||
|
Form:
|
||||||
|
|
||||||
|
* A comment sits on its own line above the code it describes — never trailing
|
||||||
|
* No `# ---- section ----` banners
|
||||||
|
* A comment above a constant explains what the *value* means; it must not paraphrase the
|
||||||
|
identifier
|
||||||
|
|
||||||
|
### Errors and failures
|
||||||
|
|
||||||
|
* No custom exception classes
|
||||||
|
* `RuntimeError` for operational failures — missing context prerequisites, missing files,
|
||||||
|
unknown names, unsupported configs
|
||||||
|
* `ValueError` for value and domain validation — pydantic validators, pure helpers
|
||||||
|
* Guard-clause-then-raise at the top of `run()`, naming the stage that should have run first:
|
||||||
|
|
||||||
|
```python
|
||||||
|
section = ctx.design.get("symbols_section")
|
||||||
|
if section is None:
|
||||||
|
raise RuntimeError("symbols_section not in context; run the game_design stage first")
|
||||||
|
```
|
||||||
|
|
||||||
|
* Let failures propagate. `Pipeline.run` does not catch, so a failing step aborts the run
|
||||||
|
* Catch narrowly, and only to continue with a degraded result. Re-raise wrapped with
|
||||||
|
`from exc` (see `mmgen/shapes/registry.py`)
|
||||||
|
* Error message form: `"<context>: <problem>; <what to do>"`, listing the valid options where
|
||||||
|
there is a closed set. f-strings are correct here, unlike in logging — the message is built
|
||||||
|
once, at the point of failure
|
||||||
|
* Validate cheaply before expensive work, so a typo fails before any paid LLM call
|
||||||
|
(`prompts/registry.validate()`, `shapes/registry.validate()` run at stage head)
|
||||||
|
|
||||||
|
### Imports and paths
|
||||||
|
|
||||||
|
* Absolute imports only. No relative imports, no star imports
|
||||||
|
* Three groups, blank-line separated, alphabetized within group: stdlib → third-party →
|
||||||
|
`mmgen.*`
|
||||||
|
* `__init__.py` holds a doc string only — no re-exports. Import from the concrete module
|
||||||
|
* `pathlib` only, never `os.path`. Join with `/`, prefer `Path.read_text(...)`, and always
|
||||||
|
pass `encoding="utf-8"` explicitly
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
There is no unit-test suite. Correctness is checked by running the pipeline end to end.
|
||||||
|
|
||||||
|
If you add tests, put them in `tests/` with pytest and add pytest to the `dev` dependency
|
||||||
|
group.
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
`black` (line-length 140) is the formatter and `pyright` is the type checker. No linter is
|
||||||
|
configured. See the *Dev tools* section of `README.md` for the commands.
|
||||||
14
Default (OSX).sublime-mousemap
Normal file
14
Default (OSX).sublime-mousemap
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"button": "scroll_up",
|
||||||
|
"command": "vterm_scroll",
|
||||||
|
"args": {"up": true},
|
||||||
|
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"button": "scroll_down",
|
||||||
|
"command": "vterm_scroll",
|
||||||
|
"args": {"up": false},
|
||||||
|
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
|
||||||
|
}
|
||||||
|
]
|
||||||
248
Default.sublime-keymap
Normal file
248
Default.sublime-keymap
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
[
|
||||||
|
// generated by tools/gen_keymap.py - do not edit by hand
|
||||||
|
{"keys": ["a"], "command": "vterm_keypress", "args": {"key": "a"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["b"], "command": "vterm_keypress", "args": {"key": "b"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["c"], "command": "vterm_keypress", "args": {"key": "c"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["d"], "command": "vterm_keypress", "args": {"key": "d"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["e"], "command": "vterm_keypress", "args": {"key": "e"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f"], "command": "vterm_keypress", "args": {"key": "f"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["g"], "command": "vterm_keypress", "args": {"key": "g"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["h"], "command": "vterm_keypress", "args": {"key": "h"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["i"], "command": "vterm_keypress", "args": {"key": "i"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["j"], "command": "vterm_keypress", "args": {"key": "j"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["k"], "command": "vterm_keypress", "args": {"key": "k"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["l"], "command": "vterm_keypress", "args": {"key": "l"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["m"], "command": "vterm_keypress", "args": {"key": "m"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["n"], "command": "vterm_keypress", "args": {"key": "n"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["o"], "command": "vterm_keypress", "args": {"key": "o"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["p"], "command": "vterm_keypress", "args": {"key": "p"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["q"], "command": "vterm_keypress", "args": {"key": "q"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["r"], "command": "vterm_keypress", "args": {"key": "r"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["s"], "command": "vterm_keypress", "args": {"key": "s"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["t"], "command": "vterm_keypress", "args": {"key": "t"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["u"], "command": "vterm_keypress", "args": {"key": "u"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["v"], "command": "vterm_keypress", "args": {"key": "v"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["w"], "command": "vterm_keypress", "args": {"key": "w"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["x"], "command": "vterm_keypress", "args": {"key": "x"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["y"], "command": "vterm_keypress", "args": {"key": "y"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["z"], "command": "vterm_keypress", "args": {"key": "z"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["A"], "command": "vterm_keypress", "args": {"key": "A"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["B"], "command": "vterm_keypress", "args": {"key": "B"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["C"], "command": "vterm_keypress", "args": {"key": "C"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["D"], "command": "vterm_keypress", "args": {"key": "D"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["E"], "command": "vterm_keypress", "args": {"key": "E"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["F"], "command": "vterm_keypress", "args": {"key": "F"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["G"], "command": "vterm_keypress", "args": {"key": "G"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["H"], "command": "vterm_keypress", "args": {"key": "H"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["I"], "command": "vterm_keypress", "args": {"key": "I"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["J"], "command": "vterm_keypress", "args": {"key": "J"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["K"], "command": "vterm_keypress", "args": {"key": "K"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["L"], "command": "vterm_keypress", "args": {"key": "L"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["M"], "command": "vterm_keypress", "args": {"key": "M"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["N"], "command": "vterm_keypress", "args": {"key": "N"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["O"], "command": "vterm_keypress", "args": {"key": "O"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["P"], "command": "vterm_keypress", "args": {"key": "P"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["Q"], "command": "vterm_keypress", "args": {"key": "Q"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["R"], "command": "vterm_keypress", "args": {"key": "R"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["S"], "command": "vterm_keypress", "args": {"key": "S"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["T"], "command": "vterm_keypress", "args": {"key": "T"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["U"], "command": "vterm_keypress", "args": {"key": "U"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["V"], "command": "vterm_keypress", "args": {"key": "V"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["W"], "command": "vterm_keypress", "args": {"key": "W"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["X"], "command": "vterm_keypress", "args": {"key": "X"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["Y"], "command": "vterm_keypress", "args": {"key": "Y"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["Z"], "command": "vterm_keypress", "args": {"key": "Z"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["0"], "command": "vterm_keypress", "args": {"key": "0"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["1"], "command": "vterm_keypress", "args": {"key": "1"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["2"], "command": "vterm_keypress", "args": {"key": "2"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["3"], "command": "vterm_keypress", "args": {"key": "3"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["4"], "command": "vterm_keypress", "args": {"key": "4"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["5"], "command": "vterm_keypress", "args": {"key": "5"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["6"], "command": "vterm_keypress", "args": {"key": "6"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["7"], "command": "vterm_keypress", "args": {"key": "7"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["8"], "command": "vterm_keypress", "args": {"key": "8"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["9"], "command": "vterm_keypress", "args": {"key": "9"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["!"], "command": "vterm_keypress", "args": {"key": "!"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["\""], "command": "vterm_keypress", "args": {"key": "\""}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["#"], "command": "vterm_keypress", "args": {"key": "#"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["$"], "command": "vterm_keypress", "args": {"key": "$"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["%"], "command": "vterm_keypress", "args": {"key": "%"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["&"], "command": "vterm_keypress", "args": {"key": "&"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["'"], "command": "vterm_keypress", "args": {"key": "'"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["("], "command": "vterm_keypress", "args": {"key": "("}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": [")"], "command": "vterm_keypress", "args": {"key": ")"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["*"], "command": "vterm_keypress", "args": {"key": "*"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["+"], "command": "vterm_keypress", "args": {"key": "+"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": [","], "command": "vterm_keypress", "args": {"key": ","}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["-"], "command": "vterm_keypress", "args": {"key": "-"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["."], "command": "vterm_keypress", "args": {"key": "."}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["/"], "command": "vterm_keypress", "args": {"key": "/"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": [":"], "command": "vterm_keypress", "args": {"key": ":"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": [";"], "command": "vterm_keypress", "args": {"key": ";"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["<"], "command": "vterm_keypress", "args": {"key": "<"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["="], "command": "vterm_keypress", "args": {"key": "="}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": [">"], "command": "vterm_keypress", "args": {"key": ">"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["?"], "command": "vterm_keypress", "args": {"key": "?"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["@"], "command": "vterm_keypress", "args": {"key": "@"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["["], "command": "vterm_keypress", "args": {"key": "["}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["\\"], "command": "vterm_keypress", "args": {"key": "\\"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["]"], "command": "vterm_keypress", "args": {"key": "]"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["^"], "command": "vterm_keypress", "args": {"key": "^"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["_"], "command": "vterm_keypress", "args": {"key": "_"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["`"], "command": "vterm_keypress", "args": {"key": "`"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["{"], "command": "vterm_keypress", "args": {"key": "{"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["|"], "command": "vterm_keypress", "args": {"key": "|"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["}"], "command": "vterm_keypress", "args": {"key": "}"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["~"], "command": "vterm_keypress", "args": {"key": "~"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["space"], "command": "vterm_keypress", "args": {"key": "space"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["enter"], "command": "vterm_keypress", "args": {"key": "enter"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["tab"], "command": "vterm_keypress", "args": {"key": "tab"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["backspace"], "command": "vterm_keypress", "args": {"key": "backspace"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["up"], "command": "vterm_keypress", "args": {"key": "up"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["down"], "command": "vterm_keypress", "args": {"key": "down"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["right"], "command": "vterm_keypress", "args": {"key": "right"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["left"], "command": "vterm_keypress", "args": {"key": "left"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["home"], "command": "vterm_keypress", "args": {"key": "home"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["end"], "command": "vterm_keypress", "args": {"key": "end"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["delete"], "command": "vterm_keypress", "args": {"key": "delete"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["pageup"], "command": "vterm_keypress", "args": {"key": "pageup"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["escape"], "command": "vterm_keypress", "args": {"key": "escape"}, "context": [{"key": "setting.vterm_view"}, {"key": "setting.vterm_view.finished", "operator": "not_equal", "operand": true}, {"key": "overlay_visible", "operator": "equal", "operand": false}, {"key": "popup_visible", "operator": "equal", "operand": false}]},
|
||||||
|
{"keys": ["f1"], "command": "vterm_keypress", "args": {"key": "f1"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f2"], "command": "vterm_keypress", "args": {"key": "f2"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f3"], "command": "vterm_keypress", "args": {"key": "f3"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f4"], "command": "vterm_keypress", "args": {"key": "f4"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f5"], "command": "vterm_keypress", "args": {"key": "f5"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f6"], "command": "vterm_keypress", "args": {"key": "f6"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f7"], "command": "vterm_keypress", "args": {"key": "f7"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f8"], "command": "vterm_keypress", "args": {"key": "f8"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f9"], "command": "vterm_keypress", "args": {"key": "f9"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f10"], "command": "vterm_keypress", "args": {"key": "f10"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f11"], "command": "vterm_keypress", "args": {"key": "f11"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["f12"], "command": "vterm_keypress", "args": {"key": "f12"}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+up"], "command": "vterm_keypress", "args": {"key": "up", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+down"], "command": "vterm_keypress", "args": {"key": "down", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+right"], "command": "vterm_keypress", "args": {"key": "right", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+left"], "command": "vterm_keypress", "args": {"key": "left", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+home"], "command": "vterm_keypress", "args": {"key": "home", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+end"], "command": "vterm_keypress", "args": {"key": "end", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+delete"], "command": "vterm_keypress", "args": {"key": "delete", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+up"], "command": "vterm_keypress", "args": {"key": "up", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+down"], "command": "vterm_keypress", "args": {"key": "down", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+right"], "command": "vterm_keypress", "args": {"key": "right", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+left"], "command": "vterm_keypress", "args": {"key": "left", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+home"], "command": "vterm_keypress", "args": {"key": "home", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+end"], "command": "vterm_keypress", "args": {"key": "end", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+up"], "command": "vterm_keypress", "args": {"key": "up", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+down"], "command": "vterm_keypress", "args": {"key": "down", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+right"], "command": "vterm_keypress", "args": {"key": "right", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+left"], "command": "vterm_keypress", "args": {"key": "left", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+home"], "command": "vterm_keypress", "args": {"key": "home", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+end"], "command": "vterm_keypress", "args": {"key": "end", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+alt+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+enter"], "command": "vterm_keypress", "args": {"key": "enter", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+enter"], "command": "vterm_keypress", "args": {"key": "enter", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+enter"], "command": "vterm_keypress", "args": {"key": "enter", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+tab"], "command": "vterm_keypress", "args": {"key": "tab", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+tab"], "command": "vterm_keypress", "args": {"key": "tab", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["shift+tab"], "command": "vterm_keypress", "args": {"key": "tab", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+a"], "command": "vterm_keypress", "args": {"key": "a", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+a"], "command": "vterm_keypress", "args": {"key": "a", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+b"], "command": "vterm_keypress", "args": {"key": "b", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+b"], "command": "vterm_keypress", "args": {"key": "b", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+c"], "command": "vterm_keypress", "args": {"key": "c", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+c"], "command": "vterm_keypress", "args": {"key": "c", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+d"], "command": "vterm_keypress", "args": {"key": "d", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+d"], "command": "vterm_keypress", "args": {"key": "d", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+e"], "command": "vterm_keypress", "args": {"key": "e", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+e"], "command": "vterm_keypress", "args": {"key": "e", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+f"], "command": "vterm_keypress", "args": {"key": "f", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+f"], "command": "vterm_keypress", "args": {"key": "f", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+g"], "command": "vterm_keypress", "args": {"key": "g", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+g"], "command": "vterm_keypress", "args": {"key": "g", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+h"], "command": "vterm_keypress", "args": {"key": "h", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+h"], "command": "vterm_keypress", "args": {"key": "h", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+i"], "command": "vterm_keypress", "args": {"key": "i", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+i"], "command": "vterm_keypress", "args": {"key": "i", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+j"], "command": "vterm_keypress", "args": {"key": "j", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+j"], "command": "vterm_keypress", "args": {"key": "j", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+k"], "command": "vterm_keypress", "args": {"key": "k", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+k"], "command": "vterm_keypress", "args": {"key": "k", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+l"], "command": "vterm_keypress", "args": {"key": "l", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+l"], "command": "vterm_keypress", "args": {"key": "l", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+m"], "command": "vterm_keypress", "args": {"key": "m", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+m"], "command": "vterm_keypress", "args": {"key": "m", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+n"], "command": "vterm_keypress", "args": {"key": "n", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+n"], "command": "vterm_keypress", "args": {"key": "n", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+o"], "command": "vterm_keypress", "args": {"key": "o", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+o"], "command": "vterm_keypress", "args": {"key": "o", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+p"], "command": "vterm_keypress", "args": {"key": "p", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+p"], "command": "vterm_keypress", "args": {"key": "p", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+q"], "command": "vterm_keypress", "args": {"key": "q", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+q"], "command": "vterm_keypress", "args": {"key": "q", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+r"], "command": "vterm_keypress", "args": {"key": "r", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+r"], "command": "vterm_keypress", "args": {"key": "r", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+s"], "command": "vterm_keypress", "args": {"key": "s", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+s"], "command": "vterm_keypress", "args": {"key": "s", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+t"], "command": "vterm_keypress", "args": {"key": "t", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+t"], "command": "vterm_keypress", "args": {"key": "t", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+u"], "command": "vterm_keypress", "args": {"key": "u", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+u"], "command": "vterm_keypress", "args": {"key": "u", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+v"], "command": "vterm_keypress", "args": {"key": "v", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+v"], "command": "vterm_keypress", "args": {"key": "v", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+w"], "command": "vterm_keypress", "args": {"key": "w", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+w"], "command": "vterm_keypress", "args": {"key": "w", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+x"], "command": "vterm_keypress", "args": {"key": "x", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+x"], "command": "vterm_keypress", "args": {"key": "x", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+y"], "command": "vterm_keypress", "args": {"key": "y", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+y"], "command": "vterm_keypress", "args": {"key": "y", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+z"], "command": "vterm_keypress", "args": {"key": "z", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["alt+z"], "command": "vterm_keypress", "args": {"key": "z", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+`"], "command": "vterm_keypress", "args": {"key": "`", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+["], "command": "vterm_keypress", "args": {"key": "[", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+]"], "command": "vterm_keypress", "args": {"key": "]", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+\\"], "command": "vterm_keypress", "args": {"key": "\\", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+space"], "command": "vterm_keypress", "args": {"key": "space", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+_"], "command": "vterm_keypress", "args": {"key": "_", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+^"], "command": "vterm_keypress", "args": {"key": "^", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+/"], "command": "vterm_keypress", "args": {"key": "/", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["ctrl+@"], "command": "vterm_keypress", "args": {"key": "@", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
|
||||||
|
{"keys": ["super+shift+k"], "command": "vterm_clear_screen", "context": [{"key": "setting.vterm_view"}]}
|
||||||
|
]
|
||||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# vterm
|
||||||
|
|
||||||
|
Minimalist terminal for Sublime Text 4, built to run TUI coding agents
|
||||||
|
(`pi`, `claude`) in a tab. macOS only. No panels, no images,
|
||||||
|
no build-system integration.
|
||||||
|
|
||||||
|
Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
||||||
|
`View.folded_regions`) and Package Control.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Symlink this folder into Sublime packages as `vterm`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared
|
||||||
|
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was
|
||||||
|
never installed), run **Package Control: Satisfy Libraries** and restart Sublime.
|
||||||
|
Package Control garbage-collects libraries no package requires, so if imports
|
||||||
|
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
Command palette:
|
||||||
|
|
||||||
|
- **vterm: Open Terminal** — login shell in a tab (cwd = first project folder,
|
||||||
|
else the active file's directory, else `~`)
|
||||||
|
- **vterm: Open Terminal in Project Folder** — opens the external terminal from
|
||||||
|
the settings (kitty by default) in the project folder of the active file
|
||||||
|
- **vterm: Clear Screen** (`cmd+shift+k`) — empties the tab, its screen and all
|
||||||
|
scrollback, then asks the app to redraw, so the tab looks freshly started
|
||||||
|
- **vterm: Select Theme** — pick a colour palette, previewing each as you move
|
||||||
|
through the list; Esc restores what you had
|
||||||
|
|
||||||
|
To launch an agent directly, add your own palette entry or keybinding — `vterm_open`
|
||||||
|
takes `cmd`, `cwd`, `env` and `title`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "caption": "vterm: pi", "command": "vterm_open", "args": {"cmd": ["pi"], "title": "pi"} }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys: Shift+Enter sends `ESC CR` (newline in both agents). Copy/paste use the
|
||||||
|
normal Sublime keys, and pastes are bracketed when the app asks for it. Copying
|
||||||
|
re-joins soft-wrapped lines. The wheel scrolls the view normally; while the app
|
||||||
|
tracks the mouse the wheel and left click go to it instead, and on the alternate
|
||||||
|
screen without tracking each wheel tick sends arrow keys (alternate scroll, as
|
||||||
|
kitty/iTerm2 do — this is what scrolls Claude Code's fullscreen UI before it
|
||||||
|
enables mouse reporting). While an app owns the alternate screen the scrollback
|
||||||
|
above it is folded away, kitty-style, and unfolds when the app leaves it.
|
||||||
|
|
||||||
|
The caret follows DECSCUSR, so zsh vi-mode can show a block cursor in normal
|
||||||
|
mode. The shell must emit the sequence — add to `~/.zshrc`:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
function zle-keymap-select zle-line-init {
|
||||||
|
case $KEYMAP in
|
||||||
|
vicmd) print -n '\e[1 q' ;; # blinking block
|
||||||
|
*) print -n '\e[5 q' ;; # blinking bar
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
zle -N zle-keymap-select
|
||||||
|
zle -N zle-line-init
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
`vterm.sublime-settings`: `auto_close` (close the tab when the process exits; off
|
||||||
|
leaves it open with a `[process exited with code N]` line),
|
||||||
|
`scrollback_history_size`, `min_columns` / `max_columns`,
|
||||||
|
`terminal` / `parameters` (the external terminal for *vterm: Open Terminal in
|
||||||
|
Project Folder*; `%CWD%` in `parameters` is replaced with the project folder,
|
||||||
|
defaulting to kitty), `debug` (verbose logging
|
||||||
|
to the ST console), plus the colours:
|
||||||
|
|
||||||
|
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
|
||||||
|
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
|
||||||
|
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
|
||||||
|
no restart needed.
|
||||||
|
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
|
||||||
|
applied on top of the theme.
|
||||||
|
|
||||||
|
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
|
||||||
|
told unprompted when you change the theme mid-session.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
For the full picture — class inventory, data flows, threading, and a primer on the
|
||||||
|
terminal protocols — see `Architecture.md`.
|
||||||
|
|
||||||
|
- `vterm/emulator.py` — pyte subclasses. The parser is rewritten because stock
|
||||||
|
pyte swallows what these agents rely on: `>`/`<`/`=` prefixed CSI, DECRQM `$p`,
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
Combining characters are merged as NFC, because Sublime draws decomposed
|
||||||
|
sequences badly.
|
||||||
|
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
||||||
|
Rendering is held back inside a `?2026` frame, and the pty is resized from the
|
||||||
|
view size (TIOCSWINSZ → SIGWINCH). Reads go through an incremental UTF-8
|
||||||
|
decoder so a sequence split across chunks is not dropped. The child gets
|
||||||
|
`TERM=xterm-256color` and `COLORTERM=truecolor`, with `TERM_PROGRAM` stripped.
|
||||||
|
A forced repaint narrows the pty by one column for 250 ms — never ctrl+L, which
|
||||||
|
is `/clear` in Claude Code.
|
||||||
|
- `vterm/render.py` — scrollback and live screen are real view text: dirty rows
|
||||||
|
are whole-line replaced, colours are `add_regions` overlays, and the Sublime
|
||||||
|
caret doubles as the terminal cursor (DECTCEM turns it into a block, so an app
|
||||||
|
drawing its own lands on the same cell; a view with no caret at all loses the
|
||||||
|
space key and IME text).
|
||||||
|
- `vterm/scheme.py` + `vterm/themes.py` — colour scheme rules generated on demand
|
||||||
|
per (fg, bg, style) into `Packages/User/vterm/`, truecolor quantized into the
|
||||||
|
256 palette to bound the rule table, palette from the selected theme or your
|
||||||
|
editor scheme.
|
||||||
|
- `vterm/keys.py` + generated `Default.sublime-keymap` — every key including the
|
||||||
|
printable characters is bound to `vterm_keypress`, so nothing is ever inserted
|
||||||
|
into the view: an app that does not echo (vim in normal mode) would otherwise
|
||||||
|
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
|
||||||
|
fallback for IME and composed input.
|
||||||
|
- `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables
|
||||||
|
tracking; native selection and scrolling otherwise.
|
||||||
|
|
||||||
|
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
||||||
|
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
|
||||||
|
drag, no right or middle button), focus events (1004), cursor styles (DECSCUSR —
|
||||||
|
block/bar and blink/steady via the view's caret settings; underline falls back
|
||||||
|
to bar, 0 is the default blinking block), scroll regions, autowrap,
|
||||||
|
50 000 lines of scrollback that ED 3 does not erase (agents repaint with it;
|
||||||
|
`honor_erase_scrollback` restores the usual meaning),
|
||||||
|
256-colour and truecolor SGR (truecolor quantized to the 256 palette),
|
||||||
|
wcwidth-aware wide characters, OSC 0/2 titles,
|
||||||
|
DEC line drawing (`ESC(0`, so ncurses apps such as mutt or dialog draw real
|
||||||
|
borders even under a non-UTF-8 locale).
|
||||||
|
Deliberately absent: the kitty keyboard protocol (legacy encodings are sent
|
||||||
|
instead), image protocols, Windows, panels.
|
||||||
|
|
||||||
|
## Rough edges
|
||||||
|
|
||||||
|
- Streaming output is expensive: every scroll marks all rows dirty and each row is
|
||||||
|
rewritten over the plugin-host IPC boundary. A per-row cache skips rewriting rows
|
||||||
|
whose content did not change, but a genuinely changing full screen still costs
|
||||||
|
real CPU. Batching the colour regions across a frame is the known next fix.
|
||||||
|
- Editing `vterm/*.py` needs a **Sublime restart**. Submodules are deliberately
|
||||||
|
not purged on reload, because rebuilding the terminal registry silently orphans
|
||||||
|
open tabs; an orphaned tab shows a "disconnected" status and stops accepting
|
||||||
|
input, so close it and open a new one.
|
||||||
|
- CJK and emoji rely on wcwidth agreeing with Sublime's font metrics; where they
|
||||||
|
disagree the caret can drift.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/test_emulator.py # emulator, query responder, clear — chunked and char-by-char
|
||||||
|
python3 tests/test_imports.py # modules import, keymap parses, command/flag/theme wiring
|
||||||
|
python3 tests/test_render.py # render layer on a fake view: trimming, selection, colouring
|
||||||
|
python3 tests/test_commands.py # theme picker, live restyle, generated scheme
|
||||||
|
python3 tests/harness_live.py CMD SECONDS [--send TEXT] # run a real app in the emulator, dump the screen
|
||||||
|
python3 tools/gen_keymap.py # regenerate Default.sublime-keymap
|
||||||
|
python3 tools/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
||||||
|
`Lib/python38` (pure Python, so any Python 3 runs them).
|
||||||
|
|
||||||
|
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.
|
||||||
1
Users/vaclavt/.pi/agent/skills/brave-search
Normal file
1
Users/vaclavt/.pi/agent/skills/brave-search
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/Users/vaclavt/.pi/agent/skills/pi-skills/brave-search
|
||||||
9
dependencies.json
Normal file
9
dependencies.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"*": {
|
||||||
|
">3000": [
|
||||||
|
"ptyprocess",
|
||||||
|
"pyte",
|
||||||
|
"wcwidth"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
45
main.py
Normal file
45
main.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
""" vterm plugin entry point
|
||||||
|
|
||||||
|
Submodules are deliberately not purged on reload: rebuilding the terminal
|
||||||
|
registry would orphan open terminal tabs.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# imported for sublime's command discovery, not called here
|
||||||
|
from .vterm.commands import (
|
||||||
|
VtermClearScreenCommand,
|
||||||
|
VtermClearUndoStackCommand,
|
||||||
|
VtermCleanupCommand,
|
||||||
|
VtermCloseCommand,
|
||||||
|
VtermCopyCommand,
|
||||||
|
VtermInitializeViewCommand,
|
||||||
|
VtermKeypressCommand,
|
||||||
|
VtermNoopCommand,
|
||||||
|
VtermOpenCommand,
|
||||||
|
VtermOpenTerminalProjectFolderCommand,
|
||||||
|
VtermPasteCommand,
|
||||||
|
VtermPasteTextCommand,
|
||||||
|
VtermSelectThemeCommand,
|
||||||
|
)
|
||||||
|
from .vterm.listeners import VtermCoreEventListener
|
||||||
|
from .vterm.mouse import (
|
||||||
|
VtermMouseClickCommand,
|
||||||
|
VtermMouseEventListener,
|
||||||
|
VtermScrollCommand,
|
||||||
|
)
|
||||||
|
from .vterm.scheme import scheme_manager
|
||||||
|
from .vterm.utils import settings
|
||||||
|
from .vterm.render import (
|
||||||
|
VtermRenderCommand,
|
||||||
|
VtermShowCursorCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_loaded():
|
||||||
|
"""Write the colour scheme and set the log level, once per plugin host."""
|
||||||
|
# synchronous: a restored tab's color_scheme is read during startup
|
||||||
|
scheme_manager.ensure_file()
|
||||||
|
|
||||||
|
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
|
||||||
|
logging.basicConfig()
|
||||||
|
logging.getLogger("vterm").setLevel(level)
|
||||||
33
tests/_bootstrap.py
Normal file
33
tests/_bootstrap.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
""" sys.path setup and fakes shared by the test scripts
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ST_LIB = os.path.expanduser(
|
||||||
|
"~/Library/Application Support/Sublime Text/Lib/python38")
|
||||||
|
|
||||||
|
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
"""Captures what the emulator writes to the pty, one entry per write."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.writes.append(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def written(self):
|
||||||
|
return "".join(self.writes)
|
||||||
|
|
||||||
|
@written.setter
|
||||||
|
def written(self, value):
|
||||||
|
self.writes = [value] if value else []
|
||||||
89
tests/harness_live.py
Normal file
89
tests/harness_live.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
""" Live harness: run a real command inside VtScreen/VtStream without Sublime.
|
||||||
|
Usage: python3 tests/harness_live.py <cmd> [seconds] [--send <text> | --type <text>]
|
||||||
|
--send writes the text in one go; --type writes a character per write, as typing does. """
|
||||||
|
import codecs
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import _bootstrap # sets sys.path for the imports below
|
||||||
|
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from vterm.terminal import TerminalPtyProcess, agent_env
|
||||||
|
|
||||||
|
cmd = sys.argv[1:2] or ["/bin/zsh"]
|
||||||
|
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||||
|
send = None
|
||||||
|
if "--send" in sys.argv:
|
||||||
|
send = sys.argv[sys.argv.index("--send") + 1]
|
||||||
|
typed = None
|
||||||
|
if "--type" in sys.argv:
|
||||||
|
typed = sys.argv[sys.argv.index("--type") + 1]
|
||||||
|
|
||||||
|
# the same process and environment the plugin spawns
|
||||||
|
proc = TerminalPtyProcess.spawn(
|
||||||
|
cmd, cwd=os.getcwd(), env=agent_env(), dimensions=(30, 100))
|
||||||
|
|
||||||
|
replies = []
|
||||||
|
|
||||||
|
|
||||||
|
class Screen(VtScreen):
|
||||||
|
"""Records the query replies the emulator writes back to the app."""
|
||||||
|
|
||||||
|
def write_process_input(self, data):
|
||||||
|
replies.append(data)
|
||||||
|
super().write_process_input(data)
|
||||||
|
|
||||||
|
|
||||||
|
screen = Screen(100, 30, process=proc)
|
||||||
|
stream = VtStream(screen)
|
||||||
|
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||||
|
lock = threading.Lock()
|
||||||
|
stop = [False]
|
||||||
|
|
||||||
|
|
||||||
|
def reader():
|
||||||
|
while not stop[0]:
|
||||||
|
try:
|
||||||
|
raw = proc.read(4096)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
with lock:
|
||||||
|
stream.feed(decoder.decode(raw))
|
||||||
|
|
||||||
|
|
||||||
|
t = threading.Thread(target=reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
if send is not None:
|
||||||
|
time.sleep(2)
|
||||||
|
proc.write(send)
|
||||||
|
|
||||||
|
if typed is not None:
|
||||||
|
time.sleep(2)
|
||||||
|
# one write per character: the app frames its input per read, so a whole
|
||||||
|
# string in one write is not the same test as typing it
|
||||||
|
for char in typed:
|
||||||
|
proc.write(char)
|
||||||
|
time.sleep(0.04)
|
||||||
|
|
||||||
|
time.sleep(seconds)
|
||||||
|
stop[0] = True
|
||||||
|
try:
|
||||||
|
proc.terminate(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("=== queries answered ===")
|
||||||
|
for r in replies:
|
||||||
|
print(repr(r))
|
||||||
|
print("=== screen (non-empty rows) ===")
|
||||||
|
for y in range(screen.lines):
|
||||||
|
row = "".join(screen.buffer[y][x].data for x in range(screen.columns)).rstrip()
|
||||||
|
if row:
|
||||||
|
print("{:2}| {}".format(y, row))
|
||||||
|
print("=== modes: {} title: {!r} alt: {}".format(
|
||||||
|
sorted(m >> 5 for m in screen.mode if m >= 32), screen.title,
|
||||||
|
screen.alternate_buffer_mode))
|
||||||
89
tests/stubs/sublime.py
Normal file
89
tests/stubs/sublime.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
""" Minimal sublime API stub for offline tests """
|
||||||
|
|
||||||
|
|
||||||
|
class Region:
|
||||||
|
def __init__(self, a, b=None):
|
||||||
|
self.a = a
|
||||||
|
self.b = a if b is None else b
|
||||||
|
|
||||||
|
def begin(self):
|
||||||
|
return min(self.a, self.b)
|
||||||
|
|
||||||
|
def end(self):
|
||||||
|
return max(self.a, self.b)
|
||||||
|
|
||||||
|
def empty(self):
|
||||||
|
return self.a == self.b
|
||||||
|
|
||||||
|
|
||||||
|
OP_EQUAL = 0
|
||||||
|
OP_NOT_EQUAL = 1
|
||||||
|
|
||||||
|
_settings = {}
|
||||||
|
_clipboard = [""]
|
||||||
|
_timeouts = []
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(dict):
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, key, default)
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self[key] = value
|
||||||
|
|
||||||
|
def erase(self, key):
|
||||||
|
self.pop(key, None)
|
||||||
|
|
||||||
|
def has(self, key):
|
||||||
|
return key in self
|
||||||
|
|
||||||
|
def add_on_change(self, tag, callback):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear_on_change(self, tag):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(name):
|
||||||
|
return _settings.setdefault(name, Settings())
|
||||||
|
|
||||||
|
|
||||||
|
_saved = []
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(name):
|
||||||
|
_saved.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def set_timeout(fn, delay=0):
|
||||||
|
_timeouts.append(fn)
|
||||||
|
|
||||||
|
|
||||||
|
def set_timeout_async(fn, delay=0):
|
||||||
|
_timeouts.append(fn)
|
||||||
|
|
||||||
|
|
||||||
|
def run_timeouts():
|
||||||
|
while _timeouts:
|
||||||
|
_timeouts.pop(0)()
|
||||||
|
|
||||||
|
|
||||||
|
def set_clipboard(text):
|
||||||
|
_clipboard[0] = text
|
||||||
|
|
||||||
|
|
||||||
|
def get_clipboard():
|
||||||
|
return _clipboard[0]
|
||||||
|
|
||||||
|
|
||||||
|
def packages_path():
|
||||||
|
import tempfile
|
||||||
|
return tempfile.gettempdir()
|
||||||
|
|
||||||
|
|
||||||
|
def expand_variables(value, variables):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def version():
|
||||||
|
return "4200"
|
||||||
15
tests/stubs/sublime_plugin.py
Normal file
15
tests/stubs/sublime_plugin.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
""" Minimal sublime_plugin stub """
|
||||||
|
|
||||||
|
|
||||||
|
class TextCommand:
|
||||||
|
def __init__(self, view=None):
|
||||||
|
self.view = view
|
||||||
|
|
||||||
|
|
||||||
|
class WindowCommand:
|
||||||
|
def __init__(self, window=None):
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
|
||||||
|
class EventListener:
|
||||||
|
pass
|
||||||
126
tests/test_commands.py
Normal file
126
tests/test_commands.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
""" Theme picker flows: preview, cancel, confirm.
|
||||||
|
Run with: python3 tests/test_commands.py """
|
||||||
|
import _bootstrap # sets sys.path for the imports below
|
||||||
|
from _bootstrap import FakeProcess
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
from vterm.commands import VtermSelectThemeCommand
|
||||||
|
from vterm.utils import SETTINGS_FILE
|
||||||
|
from vterm.themes import THEMES
|
||||||
|
|
||||||
|
settings = sublime.load_settings(SETTINGS_FILE)
|
||||||
|
panel = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow:
|
||||||
|
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
||||||
|
panel.update(items=items, on_done=on_done,
|
||||||
|
selected_index=selected_index, on_highlight=on_highlight)
|
||||||
|
|
||||||
|
|
||||||
|
def open_picker(current):
|
||||||
|
settings.set("theme", current)
|
||||||
|
command = VtermSelectThemeCommand(FakeWindow())
|
||||||
|
command.run()
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
# every theme is offered, with "no theme" first
|
||||||
|
command = open_picker(None)
|
||||||
|
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
||||||
|
assert panel["selected_index"] == 0
|
||||||
|
|
||||||
|
# the configured theme starts highlighted
|
||||||
|
command = open_picker("pro")
|
||||||
|
assert panel["selected_index"] == command.choices.index("pro")
|
||||||
|
|
||||||
|
# highlighting previews but must not persist
|
||||||
|
saves = len(sublime._saved)
|
||||||
|
panel["on_highlight"](command.choices.index("slate"))
|
||||||
|
assert settings.get("theme") == "slate", settings.get("theme")
|
||||||
|
assert len(sublime._saved) == saves, "preview must not save settings"
|
||||||
|
|
||||||
|
# cancelling restores the value from before the panel opened
|
||||||
|
panel["on_done"](-1)
|
||||||
|
assert settings.get("theme") == "pro", settings.get("theme")
|
||||||
|
|
||||||
|
# confirming persists
|
||||||
|
command = open_picker("pro")
|
||||||
|
panel["on_done"](command.choices.index("homebrew"))
|
||||||
|
assert settings.get("theme") == "homebrew", settings.get("theme")
|
||||||
|
assert sublime._saved[-1] == SETTINGS_FILE
|
||||||
|
|
||||||
|
# "no theme" persists as null rather than a name
|
||||||
|
command = open_picker("slate")
|
||||||
|
panel["on_done"](0)
|
||||||
|
assert settings.get("theme") is None, settings.get("theme")
|
||||||
|
|
||||||
|
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
||||||
|
|
||||||
|
|
||||||
|
# a palette change must reach terminals that are already running: the screen
|
||||||
|
# answers OSC 10/11 and DSR 996 from these values
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from vterm.scheme import scheme_manager
|
||||||
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
|
|
||||||
|
recorder = FakeProcess()
|
||||||
|
# real __init__ (view unused here); process/screen stand in for start()
|
||||||
|
terminal = Terminal(None)
|
||||||
|
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
|
||||||
|
bg_color="ffffff", is_dark=False)
|
||||||
|
terminal.process = recorder
|
||||||
|
stream = VtStream(terminal.screen)
|
||||||
|
|
||||||
|
scheme_manager.foreground = "#eeeeee"
|
||||||
|
scheme_manager.background = "#111111"
|
||||||
|
terminal.apply_scheme()
|
||||||
|
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
|
||||||
|
assert terminal.screen.theme_is_dark is True
|
||||||
|
|
||||||
|
# without mode 2031 the app is not interrupted
|
||||||
|
assert recorder.written == "", repr(recorder.written)
|
||||||
|
|
||||||
|
# with it, the dark/light report is sent so the agent can restyle itself
|
||||||
|
stream.feed("\x1b[?2031h")
|
||||||
|
recorder.written = ""
|
||||||
|
terminal.apply_scheme()
|
||||||
|
assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
|
||||||
|
|
||||||
|
# and the emulator answers a colour query with the new palette
|
||||||
|
recorder.written = ""
|
||||||
|
stream.feed("\x1b]11;?\x07")
|
||||||
|
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
|
||||||
|
|
||||||
|
print("live theme tests passed (running screen adopts palette, app notified)")
|
||||||
|
|
||||||
|
|
||||||
|
# the scheme file must exist before any restored tab's color_scheme is loaded at
|
||||||
|
# startup, and building twice must not duplicate the seeded rules
|
||||||
|
import os
|
||||||
|
from vterm.scheme import SCHEME_FILE_NAME
|
||||||
|
|
||||||
|
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
scheme_manager.ensure_file()
|
||||||
|
assert os.path.exists(path), "ensure_file did not write the scheme"
|
||||||
|
assert not scheme_manager._initialized, \
|
||||||
|
"ensure_file must leave the palette open to refinement by initialize()"
|
||||||
|
|
||||||
|
# a second call is a no-op: same file, untouched
|
||||||
|
stamp = os.path.getmtime(path)
|
||||||
|
scheme_manager.ensure_file()
|
||||||
|
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
|
||||||
|
|
||||||
|
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
|
||||||
|
with scheme_manager.lock:
|
||||||
|
scheme_manager._build()
|
||||||
|
first = len(scheme_manager.rules)
|
||||||
|
scheme_manager._build()
|
||||||
|
assert len(scheme_manager.rules) == first, \
|
||||||
|
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
|
||||||
|
|
||||||
|
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
|
||||||
420
tests/test_emulator.py
Normal file
420
tests/test_emulator.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
""" Offline emulator tests: queries answered, modes tracked, parsing survives
|
||||||
|
chunk boundaries. Run with: python3 tests/test_emulator.py """
|
||||||
|
import _bootstrap # sets sys.path for the imports below
|
||||||
|
from _bootstrap import FakeProcess
|
||||||
|
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
|
||||||
|
|
||||||
|
def make(columns=80, lines=24):
|
||||||
|
proc = FakeProcess()
|
||||||
|
screen = VtScreen(columns, lines, process=proc)
|
||||||
|
stream = VtStream(screen)
|
||||||
|
return proc, screen, stream
|
||||||
|
|
||||||
|
|
||||||
|
def feed(stream, data, char_by_char=False):
|
||||||
|
if char_by_char:
|
||||||
|
for c in data:
|
||||||
|
stream.feed(c)
|
||||||
|
else:
|
||||||
|
stream.feed(data)
|
||||||
|
|
||||||
|
|
||||||
|
def run_case(char_by_char):
|
||||||
|
tag = "char-by-char" if char_by_char else "chunked"
|
||||||
|
|
||||||
|
# DA1 answered (Claude Code's untimed sentinel)
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[c", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
|
||||||
|
|
||||||
|
# CPR, plain and private form
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char)
|
||||||
|
assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written)
|
||||||
|
|
||||||
|
# XTVERSION then DECRQM 2026 (the Claude Code probe sequence)
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
|
||||||
|
assert proc.written == (
|
||||||
|
"\x1bP>|vterm 0.1\x1b\\" "\x1b[?62;22c"
|
||||||
|
"\x1b[?2026;2$y" "\x1b[?62;22c"), (tag, proc.written)
|
||||||
|
|
||||||
|
# DECRQM for DECAWM (7) and DECTCEM (25): both default-on, and the
|
||||||
|
# wire-number list must answer them as set, not as unrecognized
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?7$p\x1b[?25$p\x1b[?2026$p", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?7;1$y\x1b[?25;1$y\x1b[?2026;2$y", (tag, proc.written)
|
||||||
|
|
||||||
|
# kitty probe: push ignored, query answered 0, DA1 fence answered
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[>7u\x1b[?u\x1b[c\x1b[<u", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?0u\x1b[?62;22c", (tag, proc.written)
|
||||||
|
# and nothing leaked to the screen
|
||||||
|
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
||||||
|
|
||||||
|
# modifyOtherKeys must not corrupt SGR state
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
||||||
|
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
|
||||||
|
|
||||||
|
# OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped
|
||||||
|
proc, screen, stream = make()
|
||||||
|
screen.default_bg_color = "1a2b3c"
|
||||||
|
feed(stream, "\x1b]0;mytitle\x07\x1b]11;?\x07\x1b]133;A\x07x", char_by_char)
|
||||||
|
assert screen.title == "mytitle", (tag, screen.title)
|
||||||
|
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x07", (tag, proc.written)
|
||||||
|
assert screen.buffer[0][0].data == "x", tag
|
||||||
|
|
||||||
|
# OSC with ST terminator; a query asked with ST is answered with ST (meli)
|
||||||
|
proc, screen, stream = make()
|
||||||
|
screen.default_bg_color = "1a2b3c"
|
||||||
|
feed(stream, "\x1b]2;st-title\x1b\\\x1b]11;?\x1b\\y", char_by_char)
|
||||||
|
assert screen.title == "st-title", (tag, screen.title)
|
||||||
|
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
||||||
|
assert screen.buffer[0][0].data == "y", tag
|
||||||
|
|
||||||
|
# OSC 52 clipboard (base64 "hi")
|
||||||
|
got = []
|
||||||
|
proc = FakeProcess()
|
||||||
|
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
|
||||||
|
stream = VtStream(screen)
|
||||||
|
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
|
||||||
|
assert got == ["hi"], (tag, got)
|
||||||
|
|
||||||
|
# bracketed paste and sync modes tracked
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?2004h\x1b[?2026h", char_by_char)
|
||||||
|
assert screen.bracketed_paste_enabled(), tag
|
||||||
|
assert screen.in_sync_frame(), tag
|
||||||
|
feed(stream, "\x1b[?2026l\x1b[?2004l", char_by_char)
|
||||||
|
assert not screen.in_sync_frame(), tag
|
||||||
|
assert not screen.bracketed_paste_enabled(), tag
|
||||||
|
|
||||||
|
# mouse modes
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h", char_by_char)
|
||||||
|
assert screen.mouse_reporting_enabled(), tag
|
||||||
|
assert screen.focus_tracking_enabled(), tag
|
||||||
|
|
||||||
|
# alt screen switch and restore
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "primary\x1b[?1049h\x1b[2Jalt\x1b[?1049l", char_by_char)
|
||||||
|
row0 = "".join(screen.buffer[0][i].data for i in range(7))
|
||||||
|
assert row0 == "primary", (tag, row0)
|
||||||
|
assert not screen.alternate_buffer_mode, tag
|
||||||
|
|
||||||
|
# truecolor SGR is quantized into the 256 palette; 256-color as hex; bold tracked
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[1;38;2;255;0;10mX\x1b[0m\x1b[38;5;196mY", char_by_char)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
|
||||||
|
assert screen.buffer[0][0].fg == "red", (tag, screen.buffer[0][0])
|
||||||
|
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "Y", tag
|
||||||
|
|
||||||
|
# DECSTBM + SU/SD don't crash and scroll within margins
|
||||||
|
proc, screen, stream = make(10, 6)
|
||||||
|
feed(stream, "\x1b[2;5r\x1b[2S\x1b[1T\x1b[r", char_by_char)
|
||||||
|
assert screen.margins is None, tag
|
||||||
|
|
||||||
|
# DECSCUSR ignored, CSI t ignored, ?996n answered
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[2 q\x1b[16t\x1b[?996n", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?997;1n", (tag, proc.written)
|
||||||
|
|
||||||
|
# scrollback: lines pushed to history when scrolling at bottom
|
||||||
|
proc, screen, stream = make(10, 3)
|
||||||
|
feed(stream, "a\r\nb\r\nc\r\nd", char_by_char)
|
||||||
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
|
assert screen.history[0][0].data == "a", tag
|
||||||
|
|
||||||
|
# ED 3 clears history and fires callback
|
||||||
|
cleared = []
|
||||||
|
proc = FakeProcess()
|
||||||
|
screen = VtScreen(10, 3, process=proc, clear_callback=lambda: cleared.append(1))
|
||||||
|
stream = VtStream(screen)
|
||||||
|
feed(stream, "a\r\nb\r\nc\r\nd\x1b[3J", char_by_char)
|
||||||
|
assert len(screen.history) == 0 and cleared, tag
|
||||||
|
|
||||||
|
# DCS/APC payloads are consumed, not drawn
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1bP1$rpayload\x1b\\Z\x1b_Gapc\x1b\\W", char_by_char)
|
||||||
|
row0 = "".join(screen.buffer[0][i].data for i in range(2))
|
||||||
|
assert row0 == "ZW", (tag, row0)
|
||||||
|
|
||||||
|
# DEC special graphics: xterm-256color's smacs is ESC(0, so ncurses apps
|
||||||
|
# (mutt, dialog, less) draw borders with it when the locale is not UTF-8
|
||||||
|
proc, screen, stream = make(20, 3)
|
||||||
|
feed(stream, "\x1b(0lqqqk\x1b(Bplain", char_by_char)
|
||||||
|
row = "".join(screen.buffer[0][x].data for x in range(10))
|
||||||
|
assert row == "\u250c\u2500\u2500\u2500\u2510plain", (tag, repr(row))
|
||||||
|
|
||||||
|
# G1 designation plus SO/SI stays ignored in UTF-8 mode, so a stray 0x0e in
|
||||||
|
# decoded text cannot silently switch the charset
|
||||||
|
proc, screen, stream = make(20, 3)
|
||||||
|
feed(stream, "\x1b)0\x0eq\x0f", char_by_char)
|
||||||
|
assert screen.buffer[0][0].data == "q", (tag, screen.buffer[0][0].data)
|
||||||
|
|
||||||
|
# multi-byte text is unaffected by a graphics designation
|
||||||
|
proc, screen, stream = make(20, 3)
|
||||||
|
feed(stream, "\x1b(0\u00e9\u2500", char_by_char)
|
||||||
|
row = "".join(screen.buffer[0][x].data for x in range(2))
|
||||||
|
assert row == "\u00e9\u2500", (tag, repr(row))
|
||||||
|
|
||||||
|
# shift+enter esc-cr passthrough sanity: ESC CR fed back in draws nothing odd
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?25l\x1b[?25h", char_by_char)
|
||||||
|
assert not screen.cursor.hidden, tag
|
||||||
|
|
||||||
|
# DECSCUSR tracked; missing parameter means 0; reset restores the default
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[2 q", char_by_char)
|
||||||
|
assert screen.cursor_style == 2, (tag, screen.cursor_style)
|
||||||
|
feed(stream, "\x1b[ q", char_by_char)
|
||||||
|
assert screen.cursor_style == 0, (tag, screen.cursor_style)
|
||||||
|
feed(stream, "\x1b[6 q", char_by_char)
|
||||||
|
screen.reset()
|
||||||
|
assert screen.cursor_style == 0, (tag, screen.cursor_style)
|
||||||
|
# nothing leaked to the screen or the pty
|
||||||
|
assert screen.buffer[0][0].data == " " and proc.written == "", tag
|
||||||
|
|
||||||
|
print("all emulator tests passed ({})".format(tag))
|
||||||
|
|
||||||
|
|
||||||
|
def run_resize_cases():
|
||||||
|
"""Shrinking must drop cells past the new width, in both buffers: the
|
||||||
|
segmenter walks a line to its highest key, so leftovers keep rendering."""
|
||||||
|
proc, screen, stream = make(20, 3)
|
||||||
|
feed(stream, "ABCDEFGHIJKLMNOPQRST", False)
|
||||||
|
screen.resize(3, 10)
|
||||||
|
assert max(screen.buffer[0]) < 10, sorted(screen.buffer[0])
|
||||||
|
|
||||||
|
# the buffer parked by the alt screen is trimmed too, or switching back
|
||||||
|
# brings the stale cells with it
|
||||||
|
proc, screen, stream = make(20, 3)
|
||||||
|
feed(stream, "PRIMARYPRIMARYPRIMAR", False)
|
||||||
|
feed(stream, "\x1b[?1049h", False)
|
||||||
|
screen.resize(3, 8)
|
||||||
|
feed(stream, "\x1b[?1049l", False)
|
||||||
|
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
|
||||||
|
|
||||||
|
# mode 2031 is how a live theme change reaches the app
|
||||||
|
proc, screen, stream = make()
|
||||||
|
assert not screen.theme_notify_enabled()
|
||||||
|
feed(stream, "\x1b[?2031h", False)
|
||||||
|
assert screen.theme_notify_enabled()
|
||||||
|
|
||||||
|
print("all resize tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_private_csi_cases():
|
||||||
|
"""No private CSI may raise: an exception here used to kill the renderer
|
||||||
|
thread and freeze the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S)."""
|
||||||
|
for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@":
|
||||||
|
proc, screen, stream = make()
|
||||||
|
stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI
|
||||||
|
stream.feed("\x1b[?1" + final) # single-parameter
|
||||||
|
stream.feed("\x1b[?" + final) # bare
|
||||||
|
|
||||||
|
# the ones we do support still work
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1006h", False)
|
||||||
|
assert screen.mouse_reporting_enabled(), "private h must still set modes"
|
||||||
|
feed(stream, "\x1b[?1000l\x1b[?1002l", False)
|
||||||
|
assert not screen.mouse_reporting_enabled(), "private l must still reset modes"
|
||||||
|
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[5;10H\x1b[?6n", False)
|
||||||
|
assert proc.written == "\x1b[?5;10R", proc.written
|
||||||
|
|
||||||
|
# ...and an unsupported one is ignored rather than answered or dispatched
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?2;1;0S", False)
|
||||||
|
assert proc.written == "", "XTSMGRAPHICS must be ignored, not answered"
|
||||||
|
|
||||||
|
print("all private CSI tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_decscnm_cases():
|
||||||
|
"""A DECSCNM flash (vim's visual bell) must not outlive itself.
|
||||||
|
|
||||||
|
pyte points every line at the blank cell from inside set/reset_mode, so a
|
||||||
|
line kept a reverse default and painted the columns the app never wrote —
|
||||||
|
vim skips indentation with cursor-forward — as white blocks.
|
||||||
|
"""
|
||||||
|
from vterm.render import segment_buffer_line
|
||||||
|
|
||||||
|
# a gutter, then the indent skipped with CSI 8C, then the text: vim's shape
|
||||||
|
paint = "\x1b[1;1H\x1b[93m 47 \x1b[m\x1b[8C\x1b[96mreturn\x1b[m default"
|
||||||
|
for order in (paint + "\x1b[?5h\x1b[?5l", "\x1b[?5h\x1b[?5l" + paint):
|
||||||
|
proc, screen, stream = make(columns=60, lines=5)
|
||||||
|
feed(stream, order, False)
|
||||||
|
assert not screen.default_char.reverse, "the flash is over"
|
||||||
|
assert not screen.buffer[0].default.reverse, "line kept the flash's blank cell"
|
||||||
|
assert not screen.buffer[0][6].reverse, "untouched column left reverse"
|
||||||
|
painted = [s for s in segment_buffer_line(screen.buffer[0])
|
||||||
|
if not s[0].strip() and s[3] == "reverse_default"]
|
||||||
|
assert not painted, ("whitespace painted reverse", painted)
|
||||||
|
|
||||||
|
# while the flash is on, the untouched columns invert too
|
||||||
|
proc, screen, stream = make(columns=60, lines=5)
|
||||||
|
feed(stream, paint + "\x1b[?5h", False)
|
||||||
|
assert screen.buffer[0].default.reverse and screen.buffer[0][6].reverse, \
|
||||||
|
"DECSCNM must invert the whole screen, not only the written columns"
|
||||||
|
|
||||||
|
print("all DECSCNM tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
# everything the two paths must agree on: text, SGR in each form, empty and
|
||||||
|
# clamped params, cursor moves, private modes, prefixed CSI, DECRQM, OSC,
|
||||||
|
# scroll regions, controls inside a CSI, a colon subparameter, wide chars
|
||||||
|
GAUNTLET = (
|
||||||
|
"plain text\r\n"
|
||||||
|
"\x1b[1;31mbold red\x1b[m normal \x1b[38;5;190mpalette\x1b[0m\r\n"
|
||||||
|
"\x1b[38;2;10;200;30mtruecolor\x1b[49m\x1b[m\r\n"
|
||||||
|
"\x1b[H\x1b[2Kover\twritten\x1b[3;5Hmoved\x1b[999999C clamp\x1b[m\r\n"
|
||||||
|
"\x1b[?25l\x1b[?1049h\x1b[?25h\x1b[?1049l"
|
||||||
|
"\x1b[>q\x1b[?2026$p\x1b[5n\x1b[6n"
|
||||||
|
"\x1b]0;a title\x07\x1b]11;?\x07"
|
||||||
|
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
|
||||||
|
"\x1b[38:5:100mcolon\x1b[m\r\n"
|
||||||
|
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
|
||||||
|
"wide: 漢字 \x1b[7m漢\x1b[m\r\n"
|
||||||
|
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_fast_path_cases():
|
||||||
|
"""The feed() fast path must be indistinguishable from the parser."""
|
||||||
|
|
||||||
|
def state(screen):
|
||||||
|
rows = [
|
||||||
|
tuple(screen.buffer[y][x] for x in range(screen.columns))
|
||||||
|
for y in range(screen.lines)
|
||||||
|
]
|
||||||
|
c = screen.cursor
|
||||||
|
return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title)
|
||||||
|
|
||||||
|
def parser_only(blob):
|
||||||
|
"""The blob through the coroutine parser alone: pyte's original feed."""
|
||||||
|
proc, screen, stream = make(columns=30, lines=8)
|
||||||
|
import pyte
|
||||||
|
pyte.Stream.feed(stream, blob)
|
||||||
|
return proc, screen
|
||||||
|
|
||||||
|
ref_proc, ref_screen = parser_only(GAUNTLET)
|
||||||
|
reference = state(ref_screen)
|
||||||
|
# chunk sizes that split every sequence somewhere; 1 exercises pure carry
|
||||||
|
for size in (1, 2, 3, 7, 64, len(GAUNTLET)):
|
||||||
|
proc, screen, stream = make(columns=30, lines=8)
|
||||||
|
for i in range(0, len(GAUNTLET), size):
|
||||||
|
stream.feed(GAUNTLET[i : i + size])
|
||||||
|
assert state(screen) == reference, "fast path diverged at chunk size {}".format(size)
|
||||||
|
assert proc.written == ref_proc.written, (size, proc.written, ref_proc.written)
|
||||||
|
|
||||||
|
# a param run past the carry bound still parses, via the parser
|
||||||
|
proc, screen, stream = make()
|
||||||
|
stream.feed("\x1b[" + "1;" * 40)
|
||||||
|
stream.feed("31mX")
|
||||||
|
assert screen.buffer[0][0].fg == "red", screen.buffer[0][0]
|
||||||
|
|
||||||
|
# a carried partial does not linger: nothing dispatches until it completes
|
||||||
|
proc, screen, stream = make()
|
||||||
|
stream.feed("A\x1b[3")
|
||||||
|
assert screen.buffer[0][1].data == " ", "partial CSI must not be drawn"
|
||||||
|
stream.feed("1mB")
|
||||||
|
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "B"
|
||||||
|
|
||||||
|
print("all fast-path tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_malformed_csi_cases():
|
||||||
|
"""A malformed CSI must cost one sequence, not the chunk.
|
||||||
|
|
||||||
|
Excess params used to TypeError out of the dispatch; feed_data caught it
|
||||||
|
and dropped up to 256K of output. Triggered by cat'ing a binary file or a
|
||||||
|
corrupted stream. The SGR range guards the blink class the same way.
|
||||||
|
"""
|
||||||
|
for char_by_char in (False, True): # fast path / parser
|
||||||
|
for final in (chr(c) for c in range(0x40, 0x7F)):
|
||||||
|
for params in ("2;3", "0;0;0"):
|
||||||
|
proc, screen, stream = make(columns=20, lines=6)
|
||||||
|
feed(stream, "ab\x1b[{}{}cd".format(params, final), char_by_char)
|
||||||
|
row = "".join(screen.buffer[y][x].data for y in range(6) for x in range(20))
|
||||||
|
assert "c" in row and "d" in row, \
|
||||||
|
("output after a malformed CSI was lost", params, final, char_by_char)
|
||||||
|
for n in range(121):
|
||||||
|
proc, screen, stream = make(columns=20, lines=6)
|
||||||
|
feed(stream, "a\x1b[{}mb".format(n), char_by_char)
|
||||||
|
assert screen.buffer[0][1].data == "b", ("SGR {} ate the chunk".format(n), char_by_char)
|
||||||
|
|
||||||
|
print("all malformed-CSI tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_clear_cases():
|
||||||
|
"""Screen wipe and post-clear history suppression (vterm_clear_screen)."""
|
||||||
|
proc, screen, stream = make(10, 4)
|
||||||
|
feed(stream, "aaa\r\nbbb\r\nccc\r\nddd\r\neee", False)
|
||||||
|
assert len(screen.history) > 0
|
||||||
|
assert screen.buffer[0][0].data != " "
|
||||||
|
|
||||||
|
screen.wipe()
|
||||||
|
rows = ["".join(screen.buffer[y][x].data for x in range(screen.columns)).strip()
|
||||||
|
for y in range(screen.lines)]
|
||||||
|
assert rows == ["", "", "", ""], rows
|
||||||
|
assert (screen.cursor.x, screen.cursor.y) == (0, 0)
|
||||||
|
assert screen.dirty == set(range(4)), screen.dirty
|
||||||
|
|
||||||
|
# the app's repaint right after a clear must not refill scrollback
|
||||||
|
screen.history.clear()
|
||||||
|
feed(stream, "one\r\ntwo\r\nthree\r\nfour\r\nfive", False)
|
||||||
|
assert len(screen.history) == 0, len(screen.history)
|
||||||
|
|
||||||
|
# ... but scrollback works again once the window expires
|
||||||
|
screen._suppress_history_until = 0
|
||||||
|
feed(stream, "\r\nsix\r\nseven", False)
|
||||||
|
assert len(screen.history) > 0, "history suppressed for too long"
|
||||||
|
|
||||||
|
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
|
||||||
|
# and (as in pyte/Terminus) also blanks the screen. Both agents pair it
|
||||||
|
# with 2J and repaint, so the render side must not wipe or nudge again.
|
||||||
|
cleared = []
|
||||||
|
proc = FakeProcess()
|
||||||
|
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
|
||||||
|
stream = VtStream(screen)
|
||||||
|
feed(stream, "gone\r\nalso-gone\r\nmore\r\nlines\r\nx", False)
|
||||||
|
assert len(screen.history) > 0
|
||||||
|
feed(stream, "\x1b[3J", False)
|
||||||
|
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
|
||||||
|
|
||||||
|
# with erase_scrollback off, ED 3 still blanks the screen but the history
|
||||||
|
# and the view survive: agents repaint with it and would take both
|
||||||
|
cleared = []
|
||||||
|
proc = FakeProcess()
|
||||||
|
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
|
||||||
|
clear_callback=lambda: cleared.append(1))
|
||||||
|
stream = VtStream(screen)
|
||||||
|
feed(stream, "kept\r\nalso-kept\r\nmore\r\nlines\r\nx", False)
|
||||||
|
kept = len(screen.history)
|
||||||
|
assert kept > 0
|
||||||
|
del cleared[:] # the screen's own reset() fired one at construction
|
||||||
|
feed(stream, "\x1b[3J", False)
|
||||||
|
assert len(screen.history) == kept and not cleared, (len(screen.history), cleared)
|
||||||
|
assert screen.buffer[0][0].data == " ", screen.buffer[0][0]
|
||||||
|
|
||||||
|
print("all clear-screen tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
run_case(char_by_char=False)
|
||||||
|
run_case(char_by_char=True)
|
||||||
|
run_clear_cases()
|
||||||
|
run_resize_cases()
|
||||||
|
run_private_csi_cases()
|
||||||
|
run_decscnm_cases()
|
||||||
|
run_fast_path_cases()
|
||||||
|
run_malformed_csi_cases()
|
||||||
125
tests/test_imports.py
Normal file
125
tests/test_imports.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
""" Smoke test: every plugin module imports cleanly against the sublime stubs.
|
||||||
|
Run with: python3 tests/test_imports.py """
|
||||||
|
import _bootstrap # sets sys.path for the imports below
|
||||||
|
|
||||||
|
ROOT = _bootstrap.ROOT
|
||||||
|
|
||||||
|
import vterm.utils
|
||||||
|
import vterm.keys
|
||||||
|
import vterm.emulator
|
||||||
|
import vterm.scheme
|
||||||
|
import vterm.terminal
|
||||||
|
import vterm.render
|
||||||
|
import vterm.commands
|
||||||
|
import vterm.listeners
|
||||||
|
import vterm.mouse
|
||||||
|
|
||||||
|
# keymap and mousemap must parse the way sublime parses them: JSON with
|
||||||
|
# // line comments
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
def load_sublime_json(name):
|
||||||
|
with open(os.path.join(ROOT, name), encoding="utf-8") as f:
|
||||||
|
return json.loads(re.sub(r"^\s*//.*$", "", f.read(), flags=re.M))
|
||||||
|
|
||||||
|
|
||||||
|
keymap = load_sublime_json("Default.sublime-keymap")
|
||||||
|
assert len(keymap) > 100
|
||||||
|
|
||||||
|
# every printable must be bound, or sublime inserts it into the view and an app
|
||||||
|
# that does not echo (vim in normal mode) leaves the character behind. Each
|
||||||
|
# binding's key must also round-trip to that same character: the shift+<letter>
|
||||||
|
# form silently yields lowercase, which is why uppercase is bound literally.
|
||||||
|
import string # noqa
|
||||||
|
from vterm.keys import get_key_code
|
||||||
|
|
||||||
|
bound = {}
|
||||||
|
for entry in keymap:
|
||||||
|
combo = entry["keys"][0]
|
||||||
|
if len(combo) == 1 and entry["command"] == "vterm_keypress":
|
||||||
|
assert combo not in bound, "{} bound twice".format(combo)
|
||||||
|
bound[combo] = entry["args"]
|
||||||
|
|
||||||
|
for char in string.ascii_letters + string.digits + string.punctuation:
|
||||||
|
assert char in bound, "printable {!r} is not bound".format(char)
|
||||||
|
produced = get_key_code(**bound[char])
|
||||||
|
assert produced == char, "{!r} would send {!r}".format(char, produced)
|
||||||
|
|
||||||
|
assert get_key_code(key="space") == " ", "space must send a space"
|
||||||
|
load_sublime_json("Default (OSX).sublime-mousemap")
|
||||||
|
|
||||||
|
# scheme manager produces scopes and rules
|
||||||
|
import vterm.scheme as scheme_module
|
||||||
|
from vterm.scheme import scheme_manager
|
||||||
|
scope = scheme_manager.scope("ff0000", "default", "b")
|
||||||
|
assert scope == "vterm.ff0000.default.b", scope
|
||||||
|
|
||||||
|
# every command referenced by the palette and the keymap must exist, and the
|
||||||
|
# flags commands set must be the ones the renderer reads (a rename here is
|
||||||
|
# otherwise only caught at runtime)
|
||||||
|
import vterm.commands as commands
|
||||||
|
import vterm.render as render
|
||||||
|
import vterm.terminal as terminal_module
|
||||||
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
|
|
||||||
|
def command_class_name(name):
|
||||||
|
return "".join(part.title() for part in name.split("_")) + "Command"
|
||||||
|
|
||||||
|
|
||||||
|
for entry in load_sublime_json("vterm.sublime-commands") + keymap:
|
||||||
|
cls = command_class_name(entry["command"])
|
||||||
|
assert hasattr(commands, cls) or hasattr(render, cls), \
|
||||||
|
"{} -> missing {}".format(entry["command"], cls)
|
||||||
|
|
||||||
|
# every pending_* name mentioned in the modules must be a real Terminal
|
||||||
|
# attribute, so a renamed flag cannot leave a writer pointing at nothing
|
||||||
|
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
|
||||||
|
assert flags == {"pending_clear", "pending_clear_screen"}, flags
|
||||||
|
for module in (commands, render, terminal_module):
|
||||||
|
with open(module.__file__, encoding="utf-8") as f:
|
||||||
|
# (?<!_) skips the private _pending_to_send_string write-queue flag
|
||||||
|
for name in set(re.findall(r"(?<!_)pending_[a-z_]+", f.read())):
|
||||||
|
assert name in flags, "{}: {} is not a Terminal attribute".format(
|
||||||
|
os.path.basename(module.__file__), name)
|
||||||
|
|
||||||
|
# a theme is data, so a typo'd colour name would only show up as a silently
|
||||||
|
# unstyled scope at runtime
|
||||||
|
from vterm.emulator import ANSI_COLORS
|
||||||
|
from vterm.themes import THEMES
|
||||||
|
|
||||||
|
for name, theme in THEMES.items():
|
||||||
|
missing = (set(ANSI_COLORS) | {"foreground", "background"}) - set(theme)
|
||||||
|
assert not missing, "theme {}: missing {}".format(name, sorted(missing))
|
||||||
|
assert not set(theme) - (set(ANSI_COLORS) | {"foreground", "background"}), name
|
||||||
|
for key, value in theme.items():
|
||||||
|
assert re.fullmatch(r"#[0-9a-f]{6}", value), \
|
||||||
|
"theme {}: {} = {!r}".format(name, key, value)
|
||||||
|
|
||||||
|
configured = load_sublime_json("vterm.sublime-settings").get("theme")
|
||||||
|
assert configured is None or configured in THEMES, configured
|
||||||
|
|
||||||
|
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
|
||||||
|
# color_scheme during startup, before any terminal opens
|
||||||
|
# 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
|
||||||
|
pkg = types.ModuleType("vterm")
|
||||||
|
pkg.__path__ = [ROOT]
|
||||||
|
sys.modules["vterm"] = pkg
|
||||||
|
main = importlib.import_module("vterm.main")
|
||||||
|
|
||||||
|
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
|
||||||
|
if os.path.exists(scheme_file):
|
||||||
|
os.remove(scheme_file)
|
||||||
|
main.plugin_loaded()
|
||||||
|
assert os.path.exists(scheme_file), \
|
||||||
|
"plugin_loaded must leave the colour scheme on disk"
|
||||||
|
|
||||||
|
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
||||||
|
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
||||||
509
tests/test_render.py
Normal file
509
tests/test_render.py
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
""" Render-layer tests against a fake view.
|
||||||
|
|
||||||
|
The live harness exercises the emulator only, so regressions in render.py used
|
||||||
|
to reach Sublime unnoticed. Run with: python3 tests/test_render.py
|
||||||
|
"""
|
||||||
|
import _bootstrap # sets sys.path for the imports below
|
||||||
|
from _bootstrap import FakeProcess
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
|
||||||
|
from vterm.render import (
|
||||||
|
CHUNK,
|
||||||
|
VtermRenderCommand,
|
||||||
|
focus_cursor,
|
||||||
|
)
|
||||||
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSelection(list):
|
||||||
|
def clear(self):
|
||||||
|
del self[:]
|
||||||
|
|
||||||
|
def add(self, region):
|
||||||
|
self.append(region)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeView:
|
||||||
|
"""Enough of the view API for the trim paths, with real line arithmetic."""
|
||||||
|
|
||||||
|
def __init__(self, text=""):
|
||||||
|
self.text = text
|
||||||
|
self.selection = FakeSelection()
|
||||||
|
self.regions = {}
|
||||||
|
self.commands = []
|
||||||
|
self.folds = []
|
||||||
|
self._name = ""
|
||||||
|
|
||||||
|
def size(self):
|
||||||
|
return len(self.text)
|
||||||
|
|
||||||
|
def rowcol(self, point):
|
||||||
|
head = self.text[:point]
|
||||||
|
row = head.count("\n")
|
||||||
|
return (row, point - (head.rfind("\n") + 1))
|
||||||
|
|
||||||
|
def text_point(self, row, col):
|
||||||
|
lines = self.text.split("\n")
|
||||||
|
start = sum(len(l) + 1 for l in lines[:row])
|
||||||
|
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
||||||
|
|
||||||
|
def line(self, point):
|
||||||
|
start = self.text.rfind("\n", 0, point) + 1
|
||||||
|
end = self.text.find("\n", point)
|
||||||
|
return sublime.Region(start, len(self.text) if end == -1 else end)
|
||||||
|
|
||||||
|
def full_line(self, point):
|
||||||
|
region = self.line(point)
|
||||||
|
return sublime.Region(region.begin(),
|
||||||
|
min(region.end() + 1, len(self.text)))
|
||||||
|
|
||||||
|
def lines(self, region):
|
||||||
|
out = []
|
||||||
|
point = region.begin()
|
||||||
|
while point <= region.end():
|
||||||
|
line = self.line(point)
|
||||||
|
out.append(line)
|
||||||
|
point = line.end() + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
def substr(self, region):
|
||||||
|
return self.text[region.begin():region.end()]
|
||||||
|
|
||||||
|
def erase(self, edit, region):
|
||||||
|
self.text = self.text[:region.begin()] + self.text[region.end():]
|
||||||
|
|
||||||
|
def replace(self, edit, region, value):
|
||||||
|
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
||||||
|
# sublime maps a caret inside the replaced span onto the new text,
|
||||||
|
# which is where the artefact selection came from
|
||||||
|
for i, sel in enumerate(list(self.selection)):
|
||||||
|
if sel.empty() and region.begin() <= sel.begin() <= region.end():
|
||||||
|
self.selection[i] = sublime.Region(region.begin(),
|
||||||
|
region.begin() + len(value))
|
||||||
|
|
||||||
|
def insert(self, edit, point, value):
|
||||||
|
self.text = self.text[:point] + value + self.text[point:]
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
def fold(self, region):
|
||||||
|
self.folds.append((region.begin(), region.end()))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unfold(self, region):
|
||||||
|
unfolded = self.folds
|
||||||
|
self.folds = []
|
||||||
|
return unfolded
|
||||||
|
|
||||||
|
def folded_regions(self):
|
||||||
|
return [sublime.Region(b, e) for b, e in self.folds]
|
||||||
|
|
||||||
|
def erase_regions(self, key):
|
||||||
|
self.regions.pop(key, None)
|
||||||
|
|
||||||
|
def add_regions(self, key, regions, scope):
|
||||||
|
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
||||||
|
|
||||||
|
def get_regions(self, key):
|
||||||
|
_, pairs = self.regions.get(key, ("", []))
|
||||||
|
return [sublime.Region(a, b) for a, b in pairs]
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return sublime.load_settings("fake")
|
||||||
|
|
||||||
|
def viewport_position(self):
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def line_height(self):
|
||||||
|
return 20
|
||||||
|
|
||||||
|
def name(self):
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
def set_name(self, name):
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def run_command(self, name, args=None):
|
||||||
|
self.commands.append(name)
|
||||||
|
|
||||||
|
def sel(self):
|
||||||
|
return self.selection
|
||||||
|
|
||||||
|
|
||||||
|
def make_terminal(rows=10, cols=20, scrollback=None):
|
||||||
|
# real __init__ for the plain attributes; process/screen stand in for start()
|
||||||
|
terminal = Terminal(FakeView())
|
||||||
|
if scrollback is not None:
|
||||||
|
terminal.scrollback_size = scrollback
|
||||||
|
terminal.process = FakeProcess()
|
||||||
|
terminal.screen = VtScreen(cols, rows, process=terminal.process)
|
||||||
|
return terminal, VtStream(terminal.screen)
|
||||||
|
|
||||||
|
|
||||||
|
def make_command(view):
|
||||||
|
# the real __init__, so the fake cannot drift from the command's attributes
|
||||||
|
return VtermRenderCommand(view)
|
||||||
|
|
||||||
|
|
||||||
|
# an app on the alternate screen must not have its view trimmed away: the
|
||||||
|
# emulator's history deque is maxlen=0 there, so it cannot be the row cap
|
||||||
|
terminal, stream = make_terminal(rows=10)
|
||||||
|
stream.feed("\x1b[?1049h")
|
||||||
|
assert terminal.screen.alternate_buffer_mode
|
||||||
|
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
|
||||||
|
|
||||||
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
before = terminal.view.text
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
assert terminal.view.text == before, \
|
||||||
|
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
||||||
|
|
||||||
|
# the configured cap still applies on the primary screen
|
||||||
|
terminal, stream = make_terminal(rows=5, scrollback=20)
|
||||||
|
terminal.offset = 40
|
||||||
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
rows_left = terminal.view.text.count("\n") + 1
|
||||||
|
assert rows_left <= 20, rows_left
|
||||||
|
assert "row 39" in terminal.view.text, "newest rows must survive"
|
||||||
|
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
||||||
|
|
||||||
|
# below the cap nothing is dropped
|
||||||
|
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
||||||
|
terminal.offset = 3
|
||||||
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||||
|
terminal.view.text
|
||||||
|
|
||||||
|
print("render tests passed (alt screen keeps its view, cap honoured)")
|
||||||
|
|
||||||
|
|
||||||
|
# a selection the user made must survive app output: focus_cursor runs on every
|
||||||
|
# frame, and clearing it there made click-drag selection impossible to hold
|
||||||
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
|
terminal.view = FakeView("hello world\nsecond line\n")
|
||||||
|
stream.feed("x")
|
||||||
|
selection = terminal.view.sel()
|
||||||
|
selection.add(sublime.Region(0, 5)) # user selected "hello"
|
||||||
|
focus_cursor(terminal.view, None, terminal)
|
||||||
|
assert len(selection) == 1 and selection[0].begin() == 0 and selection[0].end() == 5, \
|
||||||
|
"user selection was clobbered by a render"
|
||||||
|
|
||||||
|
# an empty selection (a plain click, or none at all) still gets the caret placed
|
||||||
|
# at the terminal cursor
|
||||||
|
selection.clear()
|
||||||
|
focus_cursor(terminal.view, None, terminal)
|
||||||
|
assert len(selection) == 1 and selection[0].empty(), \
|
||||||
|
"caret must be placed when nothing is selected"
|
||||||
|
|
||||||
|
print("selection tests passed (user selection survives renders, caret still tracked)")
|
||||||
|
|
||||||
|
|
||||||
|
# an app that hides the cursor (pi hides it for good) still needs the caret:
|
||||||
|
# sublime drops a space keypress in a view that has no caret at all
|
||||||
|
selection.clear()
|
||||||
|
stream.feed("\x1b[?25l")
|
||||||
|
focus_cursor(terminal.view, None, terminal)
|
||||||
|
assert len(selection) == 1 and selection[0].empty(), \
|
||||||
|
"caret must survive DECTCEM, or the space key stops working"
|
||||||
|
|
||||||
|
print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
||||||
|
|
||||||
|
|
||||||
|
# colorize_line positions regions by arithmetic from the line start now, and
|
||||||
|
# batches the runs that share a scope into one add_regions call
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
|
||||||
|
painted = terminal.view.regions
|
||||||
|
scopes = sorted(scope for scope, _ in painted.values())
|
||||||
|
assert scopes == ["vterm.green.default", "vterm.red.default"], scopes
|
||||||
|
red = next(spans for scope, spans in painted.values() if scope.endswith("red.default"))
|
||||||
|
assert red == [(0, 3), (5, 8)], "two red runs must share one key: {}".format(red)
|
||||||
|
green = next(spans for scope, spans in painted.values() if scope.endswith("green.default"))
|
||||||
|
assert green == [(10, 15)], green
|
||||||
|
assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text)
|
||||||
|
|
||||||
|
# the default-on-default run between them gets no region at all
|
||||||
|
assert len(painted) == 2, painted
|
||||||
|
|
||||||
|
# keys are unique per view and recorded so decolorize_line can erase them
|
||||||
|
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
||||||
|
"colored_lines must track exactly the keys that were added"
|
||||||
|
command.decolorize_line(0)
|
||||||
|
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||||
|
|
||||||
|
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||||
|
|
||||||
|
|
||||||
|
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||||
|
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||||
|
# the whole session, and merging every frame would re-send the whole chunk
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
for i in range(300):
|
||||||
|
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
|
||||||
|
regions = terminal.view.regions
|
||||||
|
chunk = "vterm@0@vterm.red.default"
|
||||||
|
assert chunk in regions, sorted(regions)
|
||||||
|
_, spans = regions[chunk]
|
||||||
|
assert len(spans) == CHUNK, "the closed chunk merges its rows: {}".format(len(spans))
|
||||||
|
assert "vterm@1@vterm.red.default" not in regions, "an open chunk must not be sent yet"
|
||||||
|
row_keys = [k for k in regions if k.startswith("vterm#")]
|
||||||
|
assert len(row_keys) == 300 - CHUNK, \
|
||||||
|
"merged rows drop their per-row keys, the open chunk keeps them: {}".format(len(row_keys))
|
||||||
|
|
||||||
|
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
||||||
|
terminal, stream = make_terminal(rows=3, scrollback=30)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||||
|
assert 0 not in command._archived_chunks
|
||||||
|
assert "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)")
|
||||||
|
|
||||||
|
|
||||||
|
# a fresh terminal must not be left with the prompt selected: our own replace
|
||||||
|
# expands the caret across the inserted text, and sublime paints that with the
|
||||||
|
# selection colour (its default is pure blue)
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
|
||||||
|
Terminal._terminals[id(terminal)] = terminal
|
||||||
|
terminal.view.id = lambda: id(terminal)
|
||||||
|
terminal.default_title = "zsh"
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
stream.feed("[vterm] %")
|
||||||
|
command.run(None)
|
||||||
|
assert all(r.empty() for r in terminal.view.sel()), \
|
||||||
|
"prompt was left selected: {}".format([(r.begin(), r.end()) for r in terminal.view.sel()])
|
||||||
|
|
||||||
|
# but a selection the user already had is still respected
|
||||||
|
terminal.view.sel().clear()
|
||||||
|
terminal.view.sel().add(sublime.Region(2, 8))
|
||||||
|
stream.feed("\x1b[2Kmore output")
|
||||||
|
command.run(None)
|
||||||
|
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
|
||||||
|
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
|
||||||
|
|
||||||
|
# pyte's dirty is a repaint hint: rows whose content did not change must not
|
||||||
|
# be rewritten (each rewrite is a cross-process call and an on_modified)
|
||||||
|
rewritten = []
|
||||||
|
_replace = terminal.view.replace
|
||||||
|
terminal.view.replace = lambda edit, region, value: (rewritten.append(value), _replace(edit, region, value))[1]
|
||||||
|
terminal.screen.dirty.update(range(terminal.screen.lines))
|
||||||
|
command.run(None)
|
||||||
|
terminal.view.replace = _replace
|
||||||
|
# blank tail rows still churn via trim_trailing_spaces; content rows must not
|
||||||
|
assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}".format(rewritten)
|
||||||
|
print("render diff tests passed (unchanged dirty rows skipped)")
|
||||||
|
|
||||||
|
# the generated scheme must define a selection colour, or sublime uses blue
|
||||||
|
from vterm.scheme import blend, scheme_manager
|
||||||
|
scheme_manager.background = "#303841"
|
||||||
|
scheme_manager.foreground = "#d8dee9"
|
||||||
|
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
|
||||||
|
|
||||||
|
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
||||||
|
|
||||||
|
|
||||||
|
# DECSCUSR drives the caret shape through view settings; before any app asks,
|
||||||
|
# and for 0, the caret is the terminal default: a blinking block
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
settings = terminal.view.settings()
|
||||||
|
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||||
|
|
||||||
|
stream.feed("\x1b[2 q")
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
||||||
|
|
||||||
|
stream.feed("\x1b[5 q")
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is False, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||||
|
|
||||||
|
stream.feed("\x1b[0 q")
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||||
|
|
||||||
|
# a hidden cursor outranks DECSCUSR: the caret stays for typing, as a block on
|
||||||
|
# the cell the app draws its own cursor over
|
||||||
|
stream.feed("\x1b[5 q\x1b[?25l")
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
||||||
|
|
||||||
|
stream.feed("\x1b[?25h")
|
||||||
|
command.sync_cursor_style(terminal)
|
||||||
|
assert settings.get("block_caret") is False, settings.get("block_caret")
|
||||||
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||||
|
|
||||||
|
print("cursor style tests passed (DECSCUSR maps to caret settings, 0 is the default block)")
|
||||||
|
|
||||||
|
|
||||||
|
# wheel over an alt-screen app that does not track the mouse must fall back to
|
||||||
|
# arrow keys (alternate scroll); with tracking on, SGR reports as before
|
||||||
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
|
Terminal._terminals[id(terminal)] = terminal
|
||||||
|
terminal.view.id = lambda: id(terminal)
|
||||||
|
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
||||||
|
scroll.view = terminal.view
|
||||||
|
|
||||||
|
stream.feed("\x1b[?1049h")
|
||||||
|
scroll.run(None, up=True)
|
||||||
|
assert terminal.process.written == "\x1b[A" * 3, repr(terminal.process.written)
|
||||||
|
|
||||||
|
# application cursor mode switches the arrow encoding
|
||||||
|
terminal.process.written = ""
|
||||||
|
stream.feed("\x1b[?1h")
|
||||||
|
scroll.run(None, up=False)
|
||||||
|
assert terminal.process.written == "\x1bOB" * 3, repr(terminal.process.written)
|
||||||
|
|
||||||
|
# with mouse tracking on, the wheel is reported, not translated: one report per
|
||||||
|
# line, aimed mid-screen when there is no event to place it
|
||||||
|
terminal.process.written = ""
|
||||||
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||||
|
scroll.run(None, up=True)
|
||||||
|
assert terminal.process.writes == ["\x1b[<64;1;3M"] * 3, terminal.process.writes
|
||||||
|
|
||||||
|
# on the primary screen without tracking the command stays inert
|
||||||
|
terminal.process.written = ""
|
||||||
|
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049l")
|
||||||
|
scroll.run(None, up=True)
|
||||||
|
assert terminal.process.written == "", repr(terminal.process.written)
|
||||||
|
|
||||||
|
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
|
||||||
|
|
||||||
|
|
||||||
|
# a click in an unfocused group must focus the tab instead of reporting: we
|
||||||
|
# replaced drag_select, which is what sublime focuses the group from
|
||||||
|
class FakeWindow:
|
||||||
|
def __init__(self, active):
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def active_view(self):
|
||||||
|
return self.active
|
||||||
|
|
||||||
|
def focus_view(self, view):
|
||||||
|
self.active = view
|
||||||
|
|
||||||
|
|
||||||
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
|
Terminal._terminals[id(terminal)] = terminal
|
||||||
|
terminal.view.id = lambda: id(terminal)
|
||||||
|
terminal.view.window_to_text = lambda xy: 0
|
||||||
|
window = FakeWindow(active=object())
|
||||||
|
terminal.view.window = lambda: window
|
||||||
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||||
|
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
|
||||||
|
click.view = terminal.view
|
||||||
|
event = {"x": 0, "y": 0, "button": 1}
|
||||||
|
|
||||||
|
click.run(None, event)
|
||||||
|
assert window.active_view() is terminal.view, "click did not focus the tab"
|
||||||
|
assert terminal.process.written == "", repr(terminal.process.written)
|
||||||
|
|
||||||
|
# once focused, the same click reports as before
|
||||||
|
click.run(None, event)
|
||||||
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
|
||||||
|
|
||||||
|
print("click focus tests passed (first click focuses only, then reports)")
|
||||||
|
|
||||||
|
|
||||||
|
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
||||||
|
# unfolds it, and the fold resets the follow anchor so the gate re-engages.
|
||||||
|
# the fold ends one character before the screen's first row, out of reach of
|
||||||
|
# the per-frame replaces of that row
|
||||||
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
|
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
||||||
|
terminal.offset = 3
|
||||||
|
terminal.viewport_y = 400
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
fold_end = terminal.view.text_point(3, 0) - 1
|
||||||
|
|
||||||
|
terminal.view.settings().set("scroll_past_end", True)
|
||||||
|
|
||||||
|
stream.feed("\x1b[?1049h")
|
||||||
|
command.sync_alt_fold(terminal)
|
||||||
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||||
|
assert terminal.viewport_y == 0, terminal.viewport_y
|
||||||
|
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
|
||||||
|
|
||||||
|
# a second render leaves the fold alone
|
||||||
|
command.sync_alt_fold(terminal)
|
||||||
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||||
|
|
||||||
|
# a disturbed fold is re-asserted on the next render
|
||||||
|
terminal.view.folds = [(0, fold_end - 2)]
|
||||||
|
command.sync_alt_fold(terminal)
|
||||||
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||||
|
|
||||||
|
stream.feed("\x1b[?1049l")
|
||||||
|
command.sync_alt_fold(terminal)
|
||||||
|
assert terminal.view.folds == [], terminal.view.folds
|
||||||
|
assert terminal.view.settings().get("scroll_past_end") is True, "primary screen scrolls past end again"
|
||||||
|
|
||||||
|
# without scrollback there is nothing to fold, but the scroll lock still applies
|
||||||
|
terminal, stream = make_terminal(rows=5, cols=20)
|
||||||
|
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
stream.feed("\x1b[?1049h")
|
||||||
|
command.sync_alt_fold(terminal)
|
||||||
|
assert terminal.view.folds == [], terminal.view.folds
|
||||||
|
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
|
||||||
|
|
||||||
|
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
|
||||||
|
|
||||||
|
|
||||||
|
# a row whose first cells were never written: they read back as default cells
|
||||||
|
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
|
||||||
|
# run that follows keeps the right column
|
||||||
|
from pyte.screens import StaticDefaultDict
|
||||||
|
from vterm.emulator import Char
|
||||||
|
from vterm.render import segment_buffer_line
|
||||||
|
|
||||||
|
line = StaticDefaultDict(Char(" "))
|
||||||
|
line[3] = Char("X", "red", "default")
|
||||||
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
|
assert runs == [(" ", 0, 3, "default", "default", ""),
|
||||||
|
("X", 3, 4, "red", "default", "")], runs
|
||||||
|
|
||||||
|
# wide characters consume two columns but one character of text
|
||||||
|
line = StaticDefaultDict(Char(" "))
|
||||||
|
line[0] = Char("漢", "red", "default")
|
||||||
|
line[1] = Char("", "red", "default")
|
||||||
|
line[2] = Char("x", "blue", "default")
|
||||||
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
|
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
|
||||||
|
|
||||||
|
# reverse video swaps the pair, and default becomes reverse_default
|
||||||
|
line = StaticDefaultDict(Char(" "))
|
||||||
|
line[0] = Char("r", "red", "default", False, False, False, False, True)
|
||||||
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
|
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
||||||
|
|
||||||
|
print("segmenter tests passed (leading gap, wide char, reverse)")
|
||||||
69
tools/_micro_draw.py
Normal file
69
tools/_micro_draw.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Micro-bench the per-char steps of VtScreen.draw."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import sys
|
||||||
|
ST_LIB = os.path.expanduser("~/Library/Application Support/Sublime Text/Lib/python38")
|
||||||
|
sys.path.insert(0, ST_LIB)
|
||||||
|
sys.path.insert(0, "tests/stubs")
|
||||||
|
from pyte.screens import wcwidth as pyte_wcwidth # lru_cache'd
|
||||||
|
from wcwidth import wcwidth as raw_wcwidth
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
data = ("x" * 50 + "\x1b[38;5;100m") * 40000 # ~1.2M printable chars
|
||||||
|
|
||||||
|
|
||||||
|
def t(label, fn, n=3):
|
||||||
|
best = 1e9
|
||||||
|
for _ in range(n):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
fn()
|
||||||
|
best = min(best, time.perf_counter() - t0)
|
||||||
|
print("{:<42} {:8.3f} ms".format(label, best * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def loop_wcwidth_pyte():
|
||||||
|
for c in data:
|
||||||
|
w = pyte_wcwidth(c)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_wcwidth_raw():
|
||||||
|
for c in data:
|
||||||
|
w = raw_wcwidth(c)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_iter_only():
|
||||||
|
for c in data:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def loop_min():
|
||||||
|
x = 0
|
||||||
|
for c in data:
|
||||||
|
x = min(x + 1, 80)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_dict_get_set():
|
||||||
|
d = {}
|
||||||
|
line = {}
|
||||||
|
for i, c in enumerate(data):
|
||||||
|
v = d.get(c)
|
||||||
|
if v is None:
|
||||||
|
d[c] = c
|
||||||
|
line[i % 80] = c
|
||||||
|
|
||||||
|
|
||||||
|
def loop_min_vs_cmp():
|
||||||
|
x = 0
|
||||||
|
cols = 80
|
||||||
|
for c in data:
|
||||||
|
nx = x + 1
|
||||||
|
x = cols if nx > cols else nx
|
||||||
|
|
||||||
|
|
||||||
|
t("iter only", loop_iter_only)
|
||||||
|
t("wcwidth (pyte lru_cache)", loop_wcwidth_pyte)
|
||||||
|
t("wcwidth (raw)", loop_wcwidth_raw)
|
||||||
|
t("min(x+w, cols) per char", loop_min)
|
||||||
|
t("min -> branch per char", loop_min_vs_cmp)
|
||||||
|
t("dict get/miss/set per char", loop_dict_get_set)
|
||||||
50
tools/_prof_feed.py
Normal file
50
tools/_prof_feed.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""One-shot profile of the feed/parse path, to rank hotspots."""
|
||||||
|
import cProfile
|
||||||
|
import pstats
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "tools")
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
from tools.bench_vs_terminus import colour_flood, plain_flood, tui_repaint, _bootstrap # noqa
|
||||||
|
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from tests._bootstrap import FakeProcess # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
screen = VtScreen(100, 40, process=FakeProcess(), history=10000)
|
||||||
|
return VtStream(screen)
|
||||||
|
|
||||||
|
|
||||||
|
def run(name, data):
|
||||||
|
stream = build()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for i in range(0, len(data), 32768):
|
||||||
|
stream.feed(data[i:i + 32768])
|
||||||
|
dt = (time.perf_counter() - t0) * 1000
|
||||||
|
print("{:<20} {:.1f} ms".format(name, dt))
|
||||||
|
|
||||||
|
|
||||||
|
def profiled(name, data):
|
||||||
|
stream = build()
|
||||||
|
pr = cProfile.Profile()
|
||||||
|
pr.enable()
|
||||||
|
for i in range(0, len(data), 32768):
|
||||||
|
stream.feed(data[i:i + 32768])
|
||||||
|
pr.disable()
|
||||||
|
s = pstats.Stats(pr)
|
||||||
|
print("\n==== {} ====".format(name))
|
||||||
|
s.sort_stats("cumulative").print_stats(18)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
floods = {
|
||||||
|
"colour": colour_flood(),
|
||||||
|
"plain": plain_flood(),
|
||||||
|
"tui": tui_repaint(),
|
||||||
|
}
|
||||||
|
for n, d in floods.items():
|
||||||
|
run(n, d)
|
||||||
|
profiled("colour", colour_flood())
|
||||||
|
profiled("tui", tui_repaint())
|
||||||
436
tools/bench_vs_terminus.py
Normal file
436
tools/bench_vs_terminus.py
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
""" Render-cost comparison between vterm and Terminus
|
||||||
|
|
||||||
|
Both plugins turn pty output into view text plus add_regions overlays, so what
|
||||||
|
they cost the editor is the number of Sublime calls a frame makes -- the plugin
|
||||||
|
host is a separate process (see Architecture.md). This drives both render
|
||||||
|
commands against a fake view that counts every call, on the same input, with
|
||||||
|
each plugin's own render gate deciding when a frame happens.
|
||||||
|
|
||||||
|
Terminus is read straight out of its .sublime-package by zipimport; it is
|
||||||
|
skipped when not installed. Parse times are real, call counts stand in for the
|
||||||
|
cross-process cost, which a fake view cannot show.
|
||||||
|
|
||||||
|
Run with: python3 tools/bench_vs_terminus.py
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "tests"))
|
||||||
|
import _bootstrap # noqa: F401 sets sys.path for the imports below
|
||||||
|
|
||||||
|
TERMINUS = os.path.expanduser(
|
||||||
|
"~/Library/Application Support/Sublime Text/Installed Packages/Terminus.sublime-package")
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
|
||||||
|
ROWS, COLUMNS = 40, 100
|
||||||
|
SCROLLBACK = 10000
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSelection(list):
|
||||||
|
def clear(self):
|
||||||
|
del self[:]
|
||||||
|
|
||||||
|
def add(self, region):
|
||||||
|
self.append(region)
|
||||||
|
|
||||||
|
|
||||||
|
class CountingView:
|
||||||
|
"""A view that answers with real line arithmetic and tallies every call."""
|
||||||
|
|
||||||
|
_ids = [0]
|
||||||
|
|
||||||
|
def __init__(self, counter):
|
||||||
|
CountingView._ids[0] += 1
|
||||||
|
self._id = CountingView._ids[0]
|
||||||
|
self.c = counter
|
||||||
|
self.text = ""
|
||||||
|
self.selection = FakeSelection()
|
||||||
|
self.regions = {}
|
||||||
|
self.folds = []
|
||||||
|
self._name = ""
|
||||||
|
self._settings = sublime.Settings()
|
||||||
|
# so a plugin that renders through a second command has it counted too
|
||||||
|
self.commands = {}
|
||||||
|
|
||||||
|
def _t(self, name):
|
||||||
|
self.c[name] += 1
|
||||||
|
|
||||||
|
def id(self):
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def size(self):
|
||||||
|
self._t("size")
|
||||||
|
return len(self.text)
|
||||||
|
|
||||||
|
def rowcol(self, point):
|
||||||
|
self._t("rowcol")
|
||||||
|
head = self.text[:point]
|
||||||
|
return (head.count("\n"), point - (head.rfind("\n") + 1))
|
||||||
|
|
||||||
|
def text_point(self, row, col):
|
||||||
|
self._t("text_point")
|
||||||
|
lines = self.text.split("\n")
|
||||||
|
start = sum(len(l) + 1 for l in lines[:row])
|
||||||
|
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
||||||
|
|
||||||
|
def _line(self, point):
|
||||||
|
start = self.text.rfind("\n", 0, point) + 1
|
||||||
|
end = self.text.find("\n", point)
|
||||||
|
return sublime.Region(start, len(self.text) if end == -1 else end)
|
||||||
|
|
||||||
|
def line(self, point):
|
||||||
|
self._t("line")
|
||||||
|
return self._line(point)
|
||||||
|
|
||||||
|
def full_line(self, point):
|
||||||
|
self._t("full_line")
|
||||||
|
region = self._line(point)
|
||||||
|
return sublime.Region(region.begin(), min(region.end() + 1, len(self.text)))
|
||||||
|
|
||||||
|
def lines(self, region):
|
||||||
|
self._t("lines")
|
||||||
|
out = []
|
||||||
|
point = region.begin()
|
||||||
|
while point <= region.end():
|
||||||
|
line = self._line(point)
|
||||||
|
out.append(line)
|
||||||
|
point = line.end() + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
def substr(self, region):
|
||||||
|
self._t("substr")
|
||||||
|
return self.text[region.begin():region.end()]
|
||||||
|
|
||||||
|
def erase(self, edit, region):
|
||||||
|
self._t("erase")
|
||||||
|
self.text = self.text[:region.begin()] + self.text[region.end():]
|
||||||
|
|
||||||
|
def replace(self, edit, region, value):
|
||||||
|
self._t("replace")
|
||||||
|
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
||||||
|
|
||||||
|
def insert(self, edit, point, value):
|
||||||
|
self._t("insert")
|
||||||
|
self.text = self.text[:point] + value + self.text[point:]
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
def fold(self, region):
|
||||||
|
self._t("fold")
|
||||||
|
self.folds.append((region.begin(), region.end()))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unfold(self, region):
|
||||||
|
self._t("unfold")
|
||||||
|
folded, self.folds = self.folds, []
|
||||||
|
return folded
|
||||||
|
|
||||||
|
def folded_regions(self):
|
||||||
|
self._t("folded_regions")
|
||||||
|
return [sublime.Region(a, b) for a, b in self.folds]
|
||||||
|
|
||||||
|
def add_regions(self, key, regions, scope="", *args, **kwargs):
|
||||||
|
self._t("add_regions")
|
||||||
|
# what crosses the process boundary is the regions, not just the call
|
||||||
|
self.c["regions.sent"] += len(regions)
|
||||||
|
self.c["regions.sent.merged" if "@" in key else "regions.sent.row"] += len(regions)
|
||||||
|
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
||||||
|
|
||||||
|
def get_regions(self, key):
|
||||||
|
self._t("get_regions")
|
||||||
|
_, spans = self.regions.get(key, ("", []))
|
||||||
|
self.c["regions.read"] += len(spans)
|
||||||
|
return [sublime.Region(a, b) for a, b in spans]
|
||||||
|
|
||||||
|
def erase_regions(self, key):
|
||||||
|
self._t("erase_regions")
|
||||||
|
self.regions.pop(key, None)
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
self._t("settings")
|
||||||
|
return CountingSettings(self._settings, self.c)
|
||||||
|
|
||||||
|
def viewport_position(self):
|
||||||
|
self._t("viewport_position")
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def viewport_extent(self):
|
||||||
|
self._t("viewport_extent")
|
||||||
|
return (800, ROWS * 20)
|
||||||
|
|
||||||
|
def layout_extent(self):
|
||||||
|
self._t("layout_extent")
|
||||||
|
return (800, len(self.text))
|
||||||
|
|
||||||
|
def text_to_layout(self, point):
|
||||||
|
self._t("text_to_layout")
|
||||||
|
return (0, point)
|
||||||
|
|
||||||
|
def set_viewport_position(self, position, animate=True):
|
||||||
|
self._t("set_viewport_position")
|
||||||
|
|
||||||
|
def line_height(self):
|
||||||
|
self._t("line_height")
|
||||||
|
return 20
|
||||||
|
|
||||||
|
def em_width(self):
|
||||||
|
self._t("em_width")
|
||||||
|
return 8
|
||||||
|
|
||||||
|
def name(self):
|
||||||
|
self._t("name")
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
def set_name(self, name):
|
||||||
|
self._t("set_name")
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def window(self):
|
||||||
|
self._t("window")
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def sel(self):
|
||||||
|
self._t("sel")
|
||||||
|
return self.selection
|
||||||
|
|
||||||
|
def run_command(self, name, args=None):
|
||||||
|
self._t("run_command:" + name)
|
||||||
|
command = self.commands.get(name)
|
||||||
|
if command:
|
||||||
|
command.run(None, **(args or {}))
|
||||||
|
|
||||||
|
|
||||||
|
class CountingSettings:
|
||||||
|
"""Settings reads and writes are round trips too, and Terminus does one per run."""
|
||||||
|
|
||||||
|
def __init__(self, settings, counter):
|
||||||
|
self._settings = settings
|
||||||
|
self._c = counter
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
self._c["settings.get"] += 1
|
||||||
|
return self._settings.get(key, default)
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self._c["settings.set"] += 1
|
||||||
|
self._settings[key] = value
|
||||||
|
|
||||||
|
def erase(self, key):
|
||||||
|
self._c["settings.erase"] += 1
|
||||||
|
self._settings.pop(key, None)
|
||||||
|
|
||||||
|
def has(self, key):
|
||||||
|
return key in self._settings
|
||||||
|
|
||||||
|
def add_on_change(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear_on_change(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.writes.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ workloads
|
||||||
|
|
||||||
|
def colour_flood(lines=2000, runs=6, palette=200):
|
||||||
|
"""Coloured output: `runs` SGR changes per line, drawn from `palette` colours."""
|
||||||
|
width = COLUMNS // runs
|
||||||
|
out = []
|
||||||
|
for i in range(lines):
|
||||||
|
parts = []
|
||||||
|
for r in range(runs):
|
||||||
|
parts.append("\x1b[38;5;{}m".format(16 + (i * runs + r) % palette))
|
||||||
|
parts.append(str((i + r) % 10) * width)
|
||||||
|
parts.append("\x1b[0m\r\n")
|
||||||
|
out.append("".join(parts))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def plain_flood(lines=2000):
|
||||||
|
return "".join("{:04d} ".format(i) + "x" * (COLUMNS - 5) + "\r\n" for i in range(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def tui_repaint(frames=60):
|
||||||
|
"""A full-screen coloured app redrawing itself; one cell differs per frame."""
|
||||||
|
out = []
|
||||||
|
for f in range(frames):
|
||||||
|
parts = ["\x1b[H"]
|
||||||
|
for row in range(ROWS):
|
||||||
|
for r in range(4):
|
||||||
|
parts.append("\x1b[38;5;{}m".format(16 + (row * 4 + r) % 200))
|
||||||
|
parts.append(str((row + r) % 10) * (COLUMNS // 4))
|
||||||
|
parts.append("\x1b[0m")
|
||||||
|
if row < ROWS - 1:
|
||||||
|
parts.append("\r\n")
|
||||||
|
parts.append("\x1b[1;1H\x1b[38;5;99m{:04d}\x1b[0m".format(f))
|
||||||
|
out.append("".join(parts))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- drivers
|
||||||
|
|
||||||
|
def drive(feed, need_to_render, render, data, tick):
|
||||||
|
"""Feed `data` in tick-sized reads, rendering whenever the plugin asks to."""
|
||||||
|
parse = 0.0
|
||||||
|
frames = 0
|
||||||
|
for i in range(0, len(data), tick):
|
||||||
|
start = time.perf_counter()
|
||||||
|
feed(data[i:i + tick])
|
||||||
|
parse += time.perf_counter() - start
|
||||||
|
if need_to_render():
|
||||||
|
render()
|
||||||
|
frames += 1
|
||||||
|
return parse, frames
|
||||||
|
|
||||||
|
|
||||||
|
def run_vterm(data, tick):
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from vterm.render import VtermRenderCommand
|
||||||
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
|
counter = collections.Counter()
|
||||||
|
view = CountingView(counter)
|
||||||
|
terminal = Terminal(view)
|
||||||
|
terminal.scrollback_size = SCROLLBACK
|
||||||
|
terminal.process = FakeProcess()
|
||||||
|
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
||||||
|
Terminal._terminals[view.id()] = terminal
|
||||||
|
stream = VtStream(terminal.screen)
|
||||||
|
command = VtermRenderCommand(view)
|
||||||
|
|
||||||
|
def render():
|
||||||
|
counter["run_command:vterm_render"] += 1
|
||||||
|
command.run(None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
||||||
|
finally:
|
||||||
|
del Terminal._terminals[view.id()]
|
||||||
|
return counter, parse, frames, len(view.regions)
|
||||||
|
|
||||||
|
|
||||||
|
def run_terminus(data, tick):
|
||||||
|
from terminus.ptty import TerminalScreen, TerminalStream
|
||||||
|
from terminus.render import TerminusRenderCommand, TerminusShowCursorCommand
|
||||||
|
from terminus.terminal import Terminal
|
||||||
|
|
||||||
|
counter = collections.Counter()
|
||||||
|
view = CountingView(counter)
|
||||||
|
terminal = Terminal(view)
|
||||||
|
terminal.detached = False
|
||||||
|
terminal.offset = 0
|
||||||
|
terminal.title = None
|
||||||
|
terminal.default_title = "Terminus"
|
||||||
|
terminal.screen = TerminalScreen(
|
||||||
|
COLUMNS, ROWS, process=FakeProcess(), clear_callback=lambda: None,
|
||||||
|
reset_callback=lambda: None, history=SCROLLBACK)
|
||||||
|
Terminal._terminals[view.id()] = terminal
|
||||||
|
stream = TerminalStream(terminal.screen)
|
||||||
|
command = TerminusRenderCommand(view)
|
||||||
|
command.scrollback_history_size = SCROLLBACK
|
||||||
|
view.commands["terminus_show_cursor"] = TerminusShowCursorCommand(view)
|
||||||
|
|
||||||
|
def render():
|
||||||
|
counter["run_command:terminus_render"] += 1
|
||||||
|
command.run(None)
|
||||||
|
# terminus clears dirty in its render loop, not in the command
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
||||||
|
finally:
|
||||||
|
del Terminal._terminals[view.id()]
|
||||||
|
return counter, parse, frames, len(view.regions)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- report
|
||||||
|
|
||||||
|
def measure(runner, data, tick):
|
||||||
|
counter, parse, frames, keys = runner(data, tick)
|
||||||
|
calls = sum(v for k, v in counter.items() if not k.startswith("regions."))
|
||||||
|
return {
|
||||||
|
"parse": parse * 1000,
|
||||||
|
"frames": frames,
|
||||||
|
"calls": calls,
|
||||||
|
"per_frame": calls / max(frames, 1),
|
||||||
|
"sent": counter["regions.sent"],
|
||||||
|
"sent_row": counter["regions.sent.row"],
|
||||||
|
"sent_merged": counter["regions.sent.merged"],
|
||||||
|
"read": counter["regions.read"],
|
||||||
|
"keys": keys,
|
||||||
|
"counter": counter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ROW_FORMAT = " {:<26}{:>14}{:>14}{:>10}"
|
||||||
|
|
||||||
|
|
||||||
|
def report(title, data, tick, both):
|
||||||
|
print("=" * 66)
|
||||||
|
print("{}\n {:.0f} KiB fed in {} B reads".format(title, len(data) / 1024, tick))
|
||||||
|
print("=" * 66)
|
||||||
|
ours = measure(run_vterm, data, tick)
|
||||||
|
theirs = measure(run_terminus, data, tick) if both else None
|
||||||
|
|
||||||
|
def line(label, key, fmt="{:.0f}"):
|
||||||
|
mine = fmt.format(ours[key])
|
||||||
|
if not theirs:
|
||||||
|
print(ROW_FORMAT.format(label, mine, "-", "-"))
|
||||||
|
return
|
||||||
|
other = fmt.format(theirs[key])
|
||||||
|
ratio = theirs[key] / ours[key] if ours[key] else 0
|
||||||
|
print(ROW_FORMAT.format(label, mine, other, "{:.1f}x".format(ratio)))
|
||||||
|
|
||||||
|
print(ROW_FORMAT.format("", "vterm", "Terminus", "ratio"))
|
||||||
|
line("parse ms", "parse", "{:.1f}")
|
||||||
|
line("frames", "frames")
|
||||||
|
line("sublime calls", "calls")
|
||||||
|
line("calls / frame", "per_frame", "{:.1f}")
|
||||||
|
line("regions sent", "sent")
|
||||||
|
line(" of them merges", "sent_merged")
|
||||||
|
line("regions read back", "read")
|
||||||
|
line("live region keys", "keys")
|
||||||
|
top = sorted(((v, k) for k, v in ours["counter"].items() if not k.startswith("regions.")),
|
||||||
|
reverse=True)[:6]
|
||||||
|
print(" vterm top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
||||||
|
if theirs:
|
||||||
|
top = sorted(((v, k) for k, v in theirs["counter"].items() if not k.startswith("regions.")),
|
||||||
|
reverse=True)[:6]
|
||||||
|
print(" Terminus top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
both = os.path.exists(TERMINUS)
|
||||||
|
if both:
|
||||||
|
sys.path.insert(0, TERMINUS)
|
||||||
|
else:
|
||||||
|
print("Terminus is not installed; reporting vterm alone.\n")
|
||||||
|
|
||||||
|
burst = 32 * 1024 # what a 30 ms tick collects during a flood
|
||||||
|
trickle = 512 # a few rows a frame, as a build log arrives
|
||||||
|
|
||||||
|
report("A. colour flood, 6 runs/line, 200 colours",
|
||||||
|
colour_flood(), burst, both)
|
||||||
|
report("B. colour flood, 6 runs/line, 8 colours",
|
||||||
|
colour_flood(palette=8), burst, both)
|
||||||
|
report("C. colour trickle, a few rows a frame",
|
||||||
|
colour_flood(lines=600), trickle, both)
|
||||||
|
report("D. plain flood, no colour",
|
||||||
|
plain_flood(), burst, both)
|
||||||
|
report("E. TUI repaint, 60 full-screen frames, one cell changes",
|
||||||
|
tui_repaint(), burst, both)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
87
tools/gen_keymap.py
Normal file
87
tools/gen_keymap.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
""" Generate Default.sublime-keymap """
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from vterm.keys import _MODIFIABLE_CSI
|
||||||
|
|
||||||
|
# "setting.x" is evaluated natively by sublime; a plugin on_query_context would
|
||||||
|
# cost an IPC round trip on every keystroke in every view
|
||||||
|
CTX = [{"key": "setting.vterm_view"}]
|
||||||
|
ESCAPE_CTX = [
|
||||||
|
{"key": "setting.vterm_view"},
|
||||||
|
{"key": "setting.vterm_view.finished", "operator": "not_equal", "operand": True},
|
||||||
|
{"key": "overlay_visible", "operator": "equal", "operand": False},
|
||||||
|
{"key": "popup_visible", "operator": "equal", "operand": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
# derived from keys.py so a key added there cannot silently lack a binding
|
||||||
|
NAV_KEYS = list(_MODIFIABLE_CSI)
|
||||||
|
MODS = ["ctrl", "alt", "shift", "ctrl+shift", "alt+shift", "ctrl+alt"]
|
||||||
|
|
||||||
|
|
||||||
|
def binding(keys, key, context=None, **flags):
|
||||||
|
args = {"key": key}
|
||||||
|
args.update(flags)
|
||||||
|
return {
|
||||||
|
"keys": [keys],
|
||||||
|
"command": "vterm_keypress",
|
||||||
|
"args": args,
|
||||||
|
"context": context or CTX,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mod_flags(mod):
|
||||||
|
return {m: True for m in mod.split("+")}
|
||||||
|
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
# printable characters: bound like any other key so nothing is ever inserted into
|
||||||
|
# the view. Uppercase must be a literal ("A", not shift+a): get_key_code returns
|
||||||
|
# lowercase for the shift+ form. on_modified still covers IME and composed input.
|
||||||
|
for c in string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation:
|
||||||
|
entries.append(binding(c, c))
|
||||||
|
entries.append(binding("space", "space"))
|
||||||
|
|
||||||
|
for key in ["enter", "tab", "backspace"] + NAV_KEYS:
|
||||||
|
entries.append(binding(key, key))
|
||||||
|
entries.append(binding("escape", "escape", context=ESCAPE_CTX))
|
||||||
|
|
||||||
|
for i in range(1, 13):
|
||||||
|
entries.append(binding("f{}".format(i), "f{}".format(i)))
|
||||||
|
|
||||||
|
for mod in MODS:
|
||||||
|
for key in NAV_KEYS:
|
||||||
|
entries.append(binding("{}+{}".format(mod, key), key, **mod_flags(mod)))
|
||||||
|
|
||||||
|
for key in ["enter", "backspace", "tab"]:
|
||||||
|
for mod in ["ctrl", "alt", "shift"]:
|
||||||
|
entries.append(binding("{}+{}".format(mod, key), key, **mod_flags(mod)))
|
||||||
|
|
||||||
|
for c in "abcdefghijklmnopqrstuvwxyz":
|
||||||
|
entries.append(binding("ctrl+{}".format(c), c, ctrl=True))
|
||||||
|
entries.append(binding("alt+{}".format(c), c, alt=True))
|
||||||
|
|
||||||
|
for c in ["`", "[", "]", "\\", "space", "_", "^", "/", "@"]:
|
||||||
|
entries.append(binding("ctrl+{}".format(c if c != " " else "space"), c, ctrl=True))
|
||||||
|
|
||||||
|
# plugin-command bindings (everything above sends keys to the pty)
|
||||||
|
entries.append({
|
||||||
|
"keys": ["super+shift+k"],
|
||||||
|
"command": "vterm_clear_screen",
|
||||||
|
"context": CTX,
|
||||||
|
})
|
||||||
|
|
||||||
|
# one binding per line: indented JSON turns 150 bindings into 2000+ lines
|
||||||
|
lines = ["[", " // generated by tools/gen_keymap.py - do not edit by hand"]
|
||||||
|
lines += [" " + json.dumps(e, separators=(", ", ": ")) + "," for e in entries]
|
||||||
|
lines[-1] = lines[-1].rstrip(",")
|
||||||
|
lines.append("]")
|
||||||
|
|
||||||
|
path = pathlib.Path(__file__).parent.parent / "Default.sublime-keymap"
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
print("wrote {} bindings to {}".format(len(entries), path))
|
||||||
6
vterm.sublime-commands
Normal file
6
vterm.sublime-commands
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{ "caption": "vterm: Open Terminal", "command": "vterm_open" },
|
||||||
|
{ "caption": "vterm: Open Terminal in Project Folder", "command": "vterm_open_terminal_project_folder" },
|
||||||
|
{ "caption": "vterm: Clear Screen", "command": "vterm_clear_screen" },
|
||||||
|
{ "caption": "vterm: Select Theme", "command": "vterm_select_theme" }
|
||||||
|
]
|
||||||
29
vterm.sublime-project
Normal file
29
vterm.sublime-project
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"folders":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"path": "./",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"python_interpreter": "${project_path}/.venv/bin/python3",
|
||||||
|
},
|
||||||
|
"build_systems":
|
||||||
|
[
|
||||||
|
|
||||||
|
{
|
||||||
|
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
|
||||||
|
"name": "venv Python file",
|
||||||
|
"selector": "source.python",
|
||||||
|
"shell_cmd": "source ${project_path}/.venv/bin/activate && ${project_path}/.venv/bin/python3 -u \"$file\""
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"debugger_configurations": [
|
||||||
|
{
|
||||||
|
"name": "Run file",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${file}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
34
vterm.sublime-settings
Normal file
34
vterm.sublime-settings
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
// close the tab automatically when the process exits
|
||||||
|
"auto_close": true,
|
||||||
|
|
||||||
|
// number of scrollback lines kept in the view
|
||||||
|
"scrollback_history_size": 50000,
|
||||||
|
|
||||||
|
// ED 3 (erase saved lines) drops the scrollback, as a terminal should.
|
||||||
|
// Agents repaint with it and take your history with them, so it is off;
|
||||||
|
// cmd+shift+k is then the only way to empty the tab.
|
||||||
|
"honor_erase_scrollback": false,
|
||||||
|
|
||||||
|
"min_columns": 20,
|
||||||
|
"max_columns": 500,
|
||||||
|
|
||||||
|
// colour palette: "default", "homebrew", "pro", "slate", or null to
|
||||||
|
// follow the active Sublime colour scheme. Applies to open tabs too.
|
||||||
|
"theme": null,
|
||||||
|
|
||||||
|
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
|
||||||
|
// applied on top of the theme
|
||||||
|
"ansi_colors": {},
|
||||||
|
|
||||||
|
// External terminal for "vterm: Open Terminal in Project Folder".
|
||||||
|
// %CWD% in parameters is replaced with the project folder.
|
||||||
|
"terminal": "/opt/homebrew/bin/kitty",
|
||||||
|
"parameters": ["-d", "%CWD%"],
|
||||||
|
|
||||||
|
// Apple Terminal instead of kitty:
|
||||||
|
// "terminal": "/usr/bin/open",
|
||||||
|
// "parameters": ["-a", "Terminal", "%CWD%"],
|
||||||
|
|
||||||
|
"debug": false
|
||||||
|
}
|
||||||
7
vterm.sublime-syntax
Normal file
7
vterm.sublime-syntax
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
name: vterm
|
||||||
|
hidden: true
|
||||||
|
scope: text.vterm
|
||||||
|
contexts:
|
||||||
|
main: []
|
||||||
1
vterm/__init__.py
Normal file
1
vterm/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
""" vterm: minimalist terminal for Sublime Text """
|
||||||
296
vterm/commands.py
Normal file
296
vterm/commands.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
""" User-facing commands and the terminal view setup """
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
import sublime_plugin
|
||||||
|
|
||||||
|
from .render import CONTINUATION
|
||||||
|
from .scheme import SCHEME_PATH, scheme_manager
|
||||||
|
from .terminal import Terminal
|
||||||
|
from .themes import THEMES
|
||||||
|
from .utils import SETTINGS_FILE, settings
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermOpenCommand(sublime_plugin.WindowCommand):
|
||||||
|
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
||||||
|
|
||||||
|
def run(self, cmd=None, cwd=None, env=None, title=None):
|
||||||
|
"""Start `cmd` (default: a login shell) in a fresh view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cwd: defaults to the first project folder, else the active file's
|
||||||
|
directory, else the home directory.
|
||||||
|
env: extra environment on top of the child defaults.
|
||||||
|
title: tab name; the program name unless the app sets its own.
|
||||||
|
"""
|
||||||
|
window = self.window
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
shell = os.environ.get("SHELL", "/bin/zsh")
|
||||||
|
cmd = [shell, "-l"]
|
||||||
|
elif isinstance(cmd, str):
|
||||||
|
cmd = [cmd]
|
||||||
|
|
||||||
|
# the app may override the title via OSC 0/2 later
|
||||||
|
if not cwd:
|
||||||
|
folders = window.folders()
|
||||||
|
view = window.active_view()
|
||||||
|
if folders:
|
||||||
|
cwd = folders[0]
|
||||||
|
elif view and view.file_name():
|
||||||
|
cwd = os.path.dirname(view.file_name())
|
||||||
|
else:
|
||||||
|
cwd = os.path.expanduser("~")
|
||||||
|
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
||||||
|
|
||||||
|
scheme_manager.initialize(window.active_view())
|
||||||
|
|
||||||
|
view = window.new_file()
|
||||||
|
view.run_command("vterm_initialize_view")
|
||||||
|
|
||||||
|
terminal = Terminal(view)
|
||||||
|
try:
|
||||||
|
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("failed to start terminal: cmd=%s", cmd)
|
||||||
|
view.close()
|
||||||
|
return
|
||||||
|
window.focus_view(view)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
|
||||||
|
"""Open the external terminal in the project folder of the active file."""
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Resolve the folder, substitute %CWD%, spawn the configured terminal."""
|
||||||
|
view = self.window.active_view()
|
||||||
|
if view and view.file_name():
|
||||||
|
path = view.file_name()
|
||||||
|
folders = [f for f in self.window.folders() if path.find(f + os.sep) == 0]
|
||||||
|
cwd = folders[0] if folders else os.path.dirname(path)
|
||||||
|
elif self.window.folders():
|
||||||
|
cwd = self.window.folders()[0]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
s = settings()
|
||||||
|
terminal = s.get("terminal")
|
||||||
|
if not terminal:
|
||||||
|
sublime.error_message('vterm: set "terminal" in vterm.sublime-settings')
|
||||||
|
return
|
||||||
|
|
||||||
|
parameters = [p.replace("%CWD%", cwd) for p in s.get("parameters", [])]
|
||||||
|
try:
|
||||||
|
subprocess.Popen([terminal] + parameters, cwd=cwd)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception("failed to start external terminal: terminal=%s", terminal)
|
||||||
|
sublime.error_message(f"vterm: could not start {terminal}")
|
||||||
|
|
||||||
|
def is_visible(self):
|
||||||
|
"""Hide the palette entry when there is no project folder."""
|
||||||
|
return bool(self.window.folders())
|
||||||
|
|
||||||
|
|
||||||
|
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Stamps a fresh view with the settings that make it a terminal."""
|
||||||
|
|
||||||
|
def run(self, _):
|
||||||
|
"""Apply the view settings; the pty starts only after this."""
|
||||||
|
view = self.view
|
||||||
|
view_settings = view.settings()
|
||||||
|
view_settings.set("vterm_view", True)
|
||||||
|
view.set_scratch(True)
|
||||||
|
view.set_read_only(False)
|
||||||
|
view_settings.set("is_widget", True)
|
||||||
|
view_settings.set("gutter", False)
|
||||||
|
view_settings.set("highlight_line", False)
|
||||||
|
view_settings.set("auto_complete_commit_on_tab", False)
|
||||||
|
view_settings.set("draw_centered", False)
|
||||||
|
view_settings.set("word_wrap", False)
|
||||||
|
view_settings.set("auto_complete", False)
|
||||||
|
view_settings.set("draw_white_space", "none")
|
||||||
|
view_settings.set("draw_unicode_white_space", False)
|
||||||
|
view_settings.set("draw_indent_guides", False)
|
||||||
|
view_settings.set("scroll_past_end", True)
|
||||||
|
view_settings.set("scroll_context_lines", 0)
|
||||||
|
view_settings.set("color_scheme", SCHEME_PATH)
|
||||||
|
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
|
||||||
|
view_settings.set("spell_check", False)
|
||||||
|
view_settings.set("detect_indentation", False)
|
||||||
|
view_settings.set("translate_tabs_to_spaces", False)
|
||||||
|
view_settings.set("command_mode", False)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermKeypressCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Sends one key to the app. Every key binding in the generated keymap lands here."""
|
||||||
|
|
||||||
|
def run(self, _, **kwargs):
|
||||||
|
"""Args:
|
||||||
|
kwargs: key name plus modifier flags, straight from the keymap.
|
||||||
|
"""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal or not terminal.alive:
|
||||||
|
return
|
||||||
|
# drop any selection so the caret follows the terminal cursor
|
||||||
|
self.view.sel().clear()
|
||||||
|
self.view.run_command("vterm_show_cursor")
|
||||||
|
terminal.send_key(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermCopyCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Copies the selection, re-joining rows the emulator soft-wrapped."""
|
||||||
|
|
||||||
|
def run(self, edit):
|
||||||
|
"""Strip the soft-wrap sentinels, then copy as usual."""
|
||||||
|
view = self.view
|
||||||
|
if not view.settings().get("vterm_view"):
|
||||||
|
return
|
||||||
|
text = "\n".join(view.substr(s) for s in view.sel())
|
||||||
|
|
||||||
|
# re-join soft-wrapped lines
|
||||||
|
text = text.replace(CONTINUATION + "\n", "")
|
||||||
|
text = text.replace(CONTINUATION, "")
|
||||||
|
|
||||||
|
sublime.set_clipboard(text)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
||||||
|
|
||||||
|
def run(self, edit, text, bracketed=True):
|
||||||
|
"""Args:
|
||||||
|
bracketed: wrap in paste brackets, if the app enabled ?2004. IME
|
||||||
|
text comes through unbracketed — it was typed, not pasted.
|
||||||
|
"""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
|
||||||
|
bracketed = bracketed and terminal.screen.bracketed_paste_enabled()
|
||||||
|
if bracketed:
|
||||||
|
terminal.send_key(key="bracketed_paste_start")
|
||||||
|
|
||||||
|
self.view.run_command("vterm_show_cursor")
|
||||||
|
terminal.send_string(text)
|
||||||
|
|
||||||
|
if bracketed:
|
||||||
|
terminal.send_key(key="bracketed_paste_end")
|
||||||
|
|
||||||
|
|
||||||
|
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
||||||
|
"""Pick a colour palette, previewing each as it is highlighted"""
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Show the palette picker, starting on the configured theme."""
|
||||||
|
self.settings = settings()
|
||||||
|
self.original = self.settings.get("theme")
|
||||||
|
self.choices = [None] + sorted(THEMES)
|
||||||
|
|
||||||
|
items = [["No theme", "follow the Sublime color scheme"]]
|
||||||
|
for name in self.choices[1:]:
|
||||||
|
theme = THEMES[name]
|
||||||
|
items.append([name, f"{theme["foreground"]} on {theme["background"]}"])
|
||||||
|
|
||||||
|
current = self.choices.index(self.original) if self.original in self.choices else 0
|
||||||
|
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
|
||||||
|
|
||||||
|
def _preview(self, index):
|
||||||
|
"""Apply a palette while it is highlighted, without saving it."""
|
||||||
|
self.settings.set("theme", self.choices[index])
|
||||||
|
|
||||||
|
def _done(self, index):
|
||||||
|
"""Persist the choice, or restore the original if the panel was cancelled."""
|
||||||
|
if index == -1:
|
||||||
|
self.settings.set("theme", self.original)
|
||||||
|
return
|
||||||
|
self.settings.set("theme", self.choices[index])
|
||||||
|
sublime.save_settings(SETTINGS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermClearScreenCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Clear the view, the screen and all scrollback, then let the app redraw"""
|
||||||
|
|
||||||
|
def run(self, edit):
|
||||||
|
"""Flag the clear; the renderer applies it and asks the app to redraw."""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
# the renderer thread owns the screen; it applies this on its next tick
|
||||||
|
terminal.pending_clear_screen = True
|
||||||
|
self.view.run_command("vterm_clear_undo_stack")
|
||||||
|
|
||||||
|
def is_enabled(self):
|
||||||
|
"""Greys the palette entry out for views that are not terminals."""
|
||||||
|
return Terminal.from_id(self.view.id()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class VtermCleanupCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Finishes a terminal whose process exited: last frame, then close or annotate."""
|
||||||
|
|
||||||
|
def run(self, edit):
|
||||||
|
"""Render the last frame, then close the tab or leave the exit code behind."""
|
||||||
|
view = self.view
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
|
||||||
|
# latched: the flag also disables the escape binding for a dead tab
|
||||||
|
if view.settings().get("vterm_view.finished"):
|
||||||
|
return
|
||||||
|
view.settings().set("vterm_view.finished", True)
|
||||||
|
|
||||||
|
view.run_command("vterm_render")
|
||||||
|
terminal.kill()
|
||||||
|
|
||||||
|
if settings().get("auto_close", True):
|
||||||
|
view.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# reaps the child, which sets exitstatus
|
||||||
|
terminal.process.isalive()
|
||||||
|
exitstatus = terminal.process.exitstatus
|
||||||
|
if exitstatus is not None:
|
||||||
|
view.run_command("append", {"characters": f"\n[process exited with code {exitstatus}]"})
|
||||||
|
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):
|
||||||
|
"""Drops the undo history, so rendered output can never be undone into view."""
|
||||||
|
|
||||||
|
def run(self, _):
|
||||||
|
"""Deferred: clearing during a command would fight the edit in progress."""
|
||||||
|
sublime.set_timeout(self.view.clear_undo_stack)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermNoopCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Swallows a sublime command that must not reach a terminal view."""
|
||||||
|
|
||||||
|
def run(self, _):
|
||||||
|
"""Deliberately does nothing."""
|
||||||
|
pass
|
||||||
929
vterm/emulator.py
Normal file
929
vterm/emulator.py
Normal file
@@ -0,0 +1,929 @@
|
|||||||
|
""" Terminal emulation: pyte screen and a parser that handles prefixed CSI, DECRQM and multi-digit OSC """
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
from collections import defaultdict, deque, namedtuple
|
||||||
|
|
||||||
|
import pyte
|
||||||
|
from pyte import control as ctrl
|
||||||
|
from pyte import graphics as g
|
||||||
|
from pyte import modes as mo
|
||||||
|
from pyte.charsets import LAT1_MAP
|
||||||
|
from pyte.screens import Margins, StaticDefaultDict, wcwidth
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# pyte's colour names, in SGR 30-37 / 90-97 order; the index is the 256-palette entry
|
||||||
|
ANSI_COLORS = [
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"brown",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"light_black",
|
||||||
|
"light_red",
|
||||||
|
"light_green",
|
||||||
|
"light_brown",
|
||||||
|
"light_blue",
|
||||||
|
"light_magenta",
|
||||||
|
"light_cyan",
|
||||||
|
"light_white",
|
||||||
|
]
|
||||||
|
|
||||||
|
# SGR 90-97 / 100-107: the bright colours, which stock pyte does not map
|
||||||
|
FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
||||||
|
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
||||||
|
|
||||||
|
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
|
||||||
|
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
|
||||||
|
|
||||||
|
# DEC private modes, pre-shifted into pyte's internal representation.
|
||||||
|
# 2026: synchronized output, the app is mid-frame and we should not draw
|
||||||
|
SYNC_MODE = 2026 << 5
|
||||||
|
# 1049: alternate screen, a full-screen app owns the viewport
|
||||||
|
ALT_SCREEN_MODE = 1049 << 5
|
||||||
|
# 2004: wrap pasted text in ESC[200~ / ESC[201~ so the app can tell it from typing
|
||||||
|
BRACKETED_PASTE_MODE = 2004 << 5
|
||||||
|
# 1004: report focus gain/loss as ESC[I / ESC[O
|
||||||
|
FOCUS_MODE = 1004 << 5
|
||||||
|
# 1000 click only, 1002 click plus drag, 1003 any motion
|
||||||
|
MOUSE_MODES = (1000 << 5, 1002 << 5, 1003 << 5)
|
||||||
|
# 1006: SGR mouse encoding, the only wire form we emit
|
||||||
|
MOUSE_SGR_MODE = 1006 << 5
|
||||||
|
# 2031: the app wants to be told when the colour scheme changes
|
||||||
|
THEME_NOTIFY_MODE = 2031 << 5
|
||||||
|
|
||||||
|
# how long a ?2026 frame may suppress rendering before we draw anyway
|
||||||
|
SYNC_FRAME_TIMEOUT = 0.15
|
||||||
|
|
||||||
|
# must outlast Terminal.REPAINT_NUDGE_DELAY, which triggers the repaint
|
||||||
|
SUPPRESS_HISTORY_AFTER_CLEAR = 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_line(line):
|
||||||
|
"""copy.copy on a dict subclass takes the pickle path (~10x); rebuild instead"""
|
||||||
|
new = StaticDefaultDict(line.default)
|
||||||
|
dict.update(new, line)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_256(r, g, b):
|
||||||
|
"""Nearest xterm-256 index: grays to the ramp, colours to the 6x6x6 cube.
|
||||||
|
|
||||||
|
Truecolor is folded into the 256 palette so the colour-scheme rule table
|
||||||
|
stays bounded (each distinct combo becomes a scheme rule and a file write)."""
|
||||||
|
if r == g == b:
|
||||||
|
if r < 8:
|
||||||
|
return 16
|
||||||
|
if r > 248:
|
||||||
|
return 231
|
||||||
|
return 232 + (r - 8) // 10
|
||||||
|
|
||||||
|
def level(c):
|
||||||
|
return 0 if c < 48 else (1 if c < 115 else (c - 35) // 40)
|
||||||
|
|
||||||
|
return 16 + 36 * level(r) + 6 * level(g) + level(b)
|
||||||
|
|
||||||
|
|
||||||
|
# linefeed marks a hard wrap, so copy can re-join soft-wrapped lines
|
||||||
|
Char = namedtuple(
|
||||||
|
"Char",
|
||||||
|
["data", "fg", "bg", "bold", "italics", "underscore", "strikethrough", "reverse", "linefeed"],
|
||||||
|
defaults=("default", "default", False, False, False, False, False, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Cursor:
|
||||||
|
"""Cursor position and pending attributes.
|
||||||
|
|
||||||
|
Replaces pyte's own so a Char carrying the extra `linefeed` field can be
|
||||||
|
the default attribute set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("x", "y", "attrs", "hidden")
|
||||||
|
|
||||||
|
def __init__(self, x, y, attrs=Char(" ")):
|
||||||
|
"""Args:
|
||||||
|
attrs: the Char whose styling new text inherits.
|
||||||
|
"""
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.attrs = attrs
|
||||||
|
self.hidden = False
|
||||||
|
|
||||||
|
|
||||||
|
class VtScreen(pyte.Screen):
|
||||||
|
"""Screen with the terminal features these agents rely on.
|
||||||
|
|
||||||
|
Adds to pyte: the alternate screen, scrollback, synchronized output,
|
||||||
|
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
|
||||||
|
Mutated only by the renderer thread, so it carries no lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_char(self):
|
||||||
|
"""The blank cell an untouched column holds."""
|
||||||
|
# pyte's base property cannot be shadowed by an instance attribute;
|
||||||
|
# the value is maintained on reset and DECSCNM flips
|
||||||
|
return self._default_char
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
columns,
|
||||||
|
lines,
|
||||||
|
process,
|
||||||
|
history=5000,
|
||||||
|
erase_scrollback=True,
|
||||||
|
clear_callback=lambda: None,
|
||||||
|
clipboard_callback=lambda text: None,
|
||||||
|
fg_color="ffffff",
|
||||||
|
bg_color="000000",
|
||||||
|
is_dark=True,
|
||||||
|
):
|
||||||
|
"""Args:
|
||||||
|
process: written to directly when the app queries the terminal.
|
||||||
|
history: scrollback depth, in lines.
|
||||||
|
erase_scrollback: whether ED 3 drops the scrollback, as a terminal should.
|
||||||
|
clear_callback: the app cleared the screen; the view follows.
|
||||||
|
clipboard_callback: an OSC 52 payload for the system clipboard.
|
||||||
|
fg_color, bg_color, is_dark: palette facts reported back to the app.
|
||||||
|
"""
|
||||||
|
self._process = process
|
||||||
|
self._erase_scrollback = erase_scrollback
|
||||||
|
self._clear_callback = clear_callback
|
||||||
|
self._clipboard_callback = clipboard_callback
|
||||||
|
self.primary_buffer = {}
|
||||||
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
|
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
||||||
|
self._sgr_cache = {}
|
||||||
|
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run)
|
||||||
|
self._char_cache = {}
|
||||||
|
self._cache_attrs = None
|
||||||
|
self.history = deque(maxlen=history)
|
||||||
|
self.alternate_buffer_mode = False
|
||||||
|
self._sync_frame_start = 0
|
||||||
|
self._suppress_history_until = 0
|
||||||
|
# rgb hex (no '#'), answered to OSC 10/11 colour queries
|
||||||
|
self.default_fg_color = fg_color
|
||||||
|
self.default_bg_color = bg_color
|
||||||
|
self.theme_is_dark = is_dark
|
||||||
|
super().__init__(columns, lines)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""RIS: pyte's reset plus our cursor, scrollback and caches."""
|
||||||
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
|
self._sgr_cache.clear()
|
||||||
|
super().reset()
|
||||||
|
self.cursor = Cursor(0, 0)
|
||||||
|
self.cursor_style = 0
|
||||||
|
self.history.clear()
|
||||||
|
self._clear_callback()
|
||||||
|
|
||||||
|
def set_cursor_style(self, style):
|
||||||
|
"""Record the caret shape the app asked for; render maps it onto sublime."""
|
||||||
|
# DECSCUSR: 0 default, 1/2 block, 3/4 underline, 5/6 bar; odd = blinking
|
||||||
|
self.cursor_style = style
|
||||||
|
|
||||||
|
def resize(self, lines=None, columns=None):
|
||||||
|
"""Resize the screen, pushing scrolled-off lines into scrollback.
|
||||||
|
|
||||||
|
Unlike pyte's, shrinking preserves content: rows that no longer fit go
|
||||||
|
to history rather than being dropped.
|
||||||
|
"""
|
||||||
|
lines = lines or self.lines
|
||||||
|
columns = columns or self.columns
|
||||||
|
|
||||||
|
if lines == self.lines and columns == self.columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dirty.update(range(lines))
|
||||||
|
|
||||||
|
# shrinking: anything below the last non-empty row is free to lose,
|
||||||
|
# the rest scrolls into history
|
||||||
|
line_diff = self.lines - lines
|
||||||
|
if line_diff > 0:
|
||||||
|
bottom = self.first_non_empty_line_from_bottom()
|
||||||
|
num_empty_lines = self.lines - 1 - bottom
|
||||||
|
if line_diff > num_empty_lines:
|
||||||
|
line_diff = line_diff - num_empty_lines
|
||||||
|
self.push_lines_into_history(line_diff)
|
||||||
|
self.scroll_up(line_diff)
|
||||||
|
self.cursor.y -= line_diff
|
||||||
|
|
||||||
|
if columns < self.columns:
|
||||||
|
# leftovers past the new width would keep rendering
|
||||||
|
buffers = [self.buffer]
|
||||||
|
if self.primary_buffer.get("buffer") is not None:
|
||||||
|
buffers.append(self.primary_buffer["buffer"])
|
||||||
|
for buffer in buffers:
|
||||||
|
for line in buffer.values():
|
||||||
|
for x in range(columns, self.columns):
|
||||||
|
line.pop(x, None)
|
||||||
|
|
||||||
|
self.lines, self.columns = lines, columns
|
||||||
|
self.set_margins()
|
||||||
|
self.tabstops = set(range(8, self.columns, 8))
|
||||||
|
|
||||||
|
def set_mode(self, *modes, **kwargs):
|
||||||
|
"""Track DECSET, entering the alternate screen and opening ?2026 frames."""
|
||||||
|
super().set_mode(*modes, **kwargs)
|
||||||
|
self._sync_default_char()
|
||||||
|
if kwargs.get("private") and 2026 in modes:
|
||||||
|
self._sync_frame_start = time.time()
|
||||||
|
if ALT_SCREEN_MODE in self.mode and not self.alternate_buffer_mode:
|
||||||
|
self.alternate_buffer_mode = True
|
||||||
|
self.save_cursor()
|
||||||
|
self.switch_to_screen(alt=True)
|
||||||
|
|
||||||
|
def reset_mode(self, *modes, **kwargs):
|
||||||
|
"""Track DECRST, leaving the alternate screen and restoring the cursor."""
|
||||||
|
super().reset_mode(*modes, **kwargs)
|
||||||
|
self._sync_default_char()
|
||||||
|
if ALT_SCREEN_MODE not in self.mode and self.alternate_buffer_mode:
|
||||||
|
self.alternate_buffer_mode = False
|
||||||
|
self.switch_to_screen(alt=False)
|
||||||
|
self.restore_cursor()
|
||||||
|
|
||||||
|
def _sync_default_char(self):
|
||||||
|
"""Refresh the blank cell after a DECSCNM (reverse video) flip."""
|
||||||
|
reverse = mo.DECSCNM in self.mode
|
||||||
|
if reverse != self._default_char.reverse:
|
||||||
|
self._default_char = self._default_char._replace(reverse=reverse)
|
||||||
|
self._sgr_cache.clear()
|
||||||
|
# pyte points every line at the old blank cell from inside
|
||||||
|
# set/reset_mode, before this runs; a stale one paints the columns
|
||||||
|
# the app never wrote reverse for good
|
||||||
|
for line in self.buffer.values():
|
||||||
|
line.default = self._default_char
|
||||||
|
|
||||||
|
def switch_to_screen(self, alt=False):
|
||||||
|
"""Swap buffer, scrollback and cursor between the primary and alternate screens.
|
||||||
|
|
||||||
|
The alternate screen gets a maxlen=0 history: a full-screen app must not
|
||||||
|
push its frames into scrollback.
|
||||||
|
"""
|
||||||
|
if alt:
|
||||||
|
self.primary_buffer["buffer"] = self.buffer
|
||||||
|
self.primary_buffer["history"] = self.history
|
||||||
|
self.primary_buffer["cursor"] = self.cursor
|
||||||
|
self.buffer = defaultdict(lambda: StaticDefaultDict(self.default_char))
|
||||||
|
self.history = deque(maxlen=0)
|
||||||
|
self.cursor = Cursor(0, 0)
|
||||||
|
else:
|
||||||
|
self.buffer = self.primary_buffer["buffer"]
|
||||||
|
self.history = self.primary_buffer["history"]
|
||||||
|
self.cursor = self.primary_buffer["cursor"]
|
||||||
|
|
||||||
|
self.dirty.update(range(self.lines))
|
||||||
|
|
||||||
|
def wipe(self):
|
||||||
|
"""Blank the screen and home the cursor"""
|
||||||
|
for y in range(self.lines):
|
||||||
|
self.buffer[y].clear()
|
||||||
|
self.cursor.x = 0
|
||||||
|
self.cursor.y = 0
|
||||||
|
self.dirty.update(range(self.lines))
|
||||||
|
self._suppress_history_until = time.time() + SUPPRESS_HISTORY_AFTER_CLEAR
|
||||||
|
|
||||||
|
def in_sync_frame(self):
|
||||||
|
"""True while an app is mid ?2026 frame, so rendering should wait.
|
||||||
|
|
||||||
|
Bounded by SYNC_FRAME_TIMEOUT: an app that never closes its frame
|
||||||
|
must not freeze the view.
|
||||||
|
"""
|
||||||
|
return SYNC_MODE in self.mode and time.time() - self._sync_frame_start <= SYNC_FRAME_TIMEOUT
|
||||||
|
|
||||||
|
def mouse_reporting_enabled(self):
|
||||||
|
"""True when the app asked for mouse reports we can actually encode."""
|
||||||
|
return MOUSE_SGR_MODE in self.mode and any(m in self.mode for m in MOUSE_MODES)
|
||||||
|
|
||||||
|
def cursor_hidden(self):
|
||||||
|
"""True while the app has DECTCEM off (CSI ?25l).
|
||||||
|
|
||||||
|
Read from the mode set, not cursor.hidden: switch_to_screen builds a
|
||||||
|
fresh Cursor, which would flash the caret on every alt-screen entry.
|
||||||
|
"""
|
||||||
|
return mo.DECTCEM not in self.mode
|
||||||
|
|
||||||
|
def theme_notify_enabled(self):
|
||||||
|
"""True when the app subscribed to colour scheme changes (?2031)."""
|
||||||
|
return THEME_NOTIFY_MODE in self.mode
|
||||||
|
|
||||||
|
def focus_tracking_enabled(self):
|
||||||
|
"""True when the app wants focus reports (?1004)."""
|
||||||
|
return FOCUS_MODE in self.mode
|
||||||
|
|
||||||
|
def bracketed_paste_enabled(self):
|
||||||
|
"""True when pastes should be bracketed (?2004)."""
|
||||||
|
return BRACKETED_PASTE_MODE in self.mode
|
||||||
|
|
||||||
|
def draw(self, data):
|
||||||
|
"""Write text at the cursor, wcwidth-aware, marking hard wraps.
|
||||||
|
|
||||||
|
Differs from pyte: double-width cells claim two columns, combining marks
|
||||||
|
are merged into the preceding cell as NFC (sublime renders decomposed
|
||||||
|
sequences badly), and an autowrap sets `linefeed` on the last cell so
|
||||||
|
copy can tell a soft wrap from a real newline.
|
||||||
|
"""
|
||||||
|
if self.charset:
|
||||||
|
data = data.translate(self.g1_charset)
|
||||||
|
elif self.g0_charset is not LAT1_MAP:
|
||||||
|
data = data.translate(self.g0_charset)
|
||||||
|
|
||||||
|
autowrap = mo.DECAWM in self.mode
|
||||||
|
insert_mode = mo.IRM in self.mode
|
||||||
|
|
||||||
|
# hoisted: this loop runs per printable character of every burst
|
||||||
|
cursor = self.cursor
|
||||||
|
buffer = self.buffer
|
||||||
|
columns = self.columns
|
||||||
|
# cells only differ by their data while attrs are unchanged, so a
|
||||||
|
# per-run cache turns the namedtuple rebuild into a dict lookup
|
||||||
|
if cursor.attrs is not self._cache_attrs:
|
||||||
|
self._cache_attrs = cursor.attrs
|
||||||
|
self._char_cache = {}
|
||||||
|
cache = self._char_cache
|
||||||
|
line = buffer[cursor.y]
|
||||||
|
|
||||||
|
for char in data:
|
||||||
|
char_width = wcwidth(char)
|
||||||
|
if (cursor.x == columns and char_width >= 1) or (cursor.x == columns - 1 and char_width >= 2):
|
||||||
|
if autowrap:
|
||||||
|
last = line[columns - 1]
|
||||||
|
line[columns - 1] = last._replace(linefeed=True)
|
||||||
|
self.dirty.add(cursor.y)
|
||||||
|
self.carriage_return()
|
||||||
|
self.linefeed()
|
||||||
|
line = buffer[cursor.y]
|
||||||
|
elif char_width > 0:
|
||||||
|
cursor.x -= char_width
|
||||||
|
|
||||||
|
if char_width == 1:
|
||||||
|
if insert_mode:
|
||||||
|
self.insert_characters(char_width)
|
||||||
|
cell = cache.get(char)
|
||||||
|
if cell is None:
|
||||||
|
cell = cache[char] = cursor.attrs._replace(data=char)
|
||||||
|
line[cursor.x] = cell
|
||||||
|
elif char_width == 2:
|
||||||
|
if insert_mode:
|
||||||
|
self.insert_characters(char_width)
|
||||||
|
cell = cache.get(char)
|
||||||
|
if cell is None:
|
||||||
|
cell = cache[char] = cursor.attrs._replace(data=char)
|
||||||
|
line[cursor.x] = cell
|
||||||
|
if cursor.x + 1 < columns:
|
||||||
|
filler = cache.get("")
|
||||||
|
if filler is None:
|
||||||
|
filler = cache[""] = cursor.attrs._replace(data="")
|
||||||
|
line[cursor.x + 1] = filler
|
||||||
|
# a combining mark belongs to the cell before it, which may be the
|
||||||
|
# last column of the previous row
|
||||||
|
elif char_width == 0 and unicodedata.combining(char):
|
||||||
|
# sublime renders decomposed combining chars poorly; merge as NFC
|
||||||
|
pos = None
|
||||||
|
last = None
|
||||||
|
for row, col in [(self.cursor.y, self.cursor.x), (self.cursor.y - 1, self.columns)]:
|
||||||
|
if row < 0:
|
||||||
|
continue
|
||||||
|
row_line = self.buffer[row]
|
||||||
|
if col >= 2:
|
||||||
|
last = row_line[col - 2]
|
||||||
|
if wcswidth(last.data) >= 2:
|
||||||
|
pos = (row, col - 2)
|
||||||
|
break
|
||||||
|
if col >= 1:
|
||||||
|
last = row_line[col - 1]
|
||||||
|
pos = (row, col - 1)
|
||||||
|
break
|
||||||
|
|
||||||
|
if pos and last is not None:
|
||||||
|
normalized = unicodedata.normalize("NFC", last.data + char)
|
||||||
|
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
|
||||||
|
self.dirty.add(pos[0])
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if char_width > 0:
|
||||||
|
self.cursor.x = min(self.cursor.x + char_width, self.columns)
|
||||||
|
|
||||||
|
self.dirty.add(self.cursor.y)
|
||||||
|
|
||||||
|
def index(self):
|
||||||
|
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||||
|
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and self.margins is None:
|
||||||
|
self.push_lines_into_history(1)
|
||||||
|
super().index()
|
||||||
|
|
||||||
|
def erase_in_display(self, how=0, *args, **kwargs):
|
||||||
|
"""ED: erase part of the screen, keeping what scrolls away.
|
||||||
|
|
||||||
|
Unlike pyte's, a full clear (or a clear-to-end from home) pushes the
|
||||||
|
current screen into scrollback first, so `clear` does not lose history.
|
||||||
|
"""
|
||||||
|
if not self.alternate_buffer_mode and (how == 2 or (how == 0 and self.cursor.x == 0 and self.cursor.y == 0)):
|
||||||
|
self.push_lines_into_history()
|
||||||
|
|
||||||
|
if how == 0:
|
||||||
|
interval = range(self.cursor.y + 1, self.lines)
|
||||||
|
elif how == 1:
|
||||||
|
interval = range(self.cursor.y)
|
||||||
|
elif how == 2 or how == 3:
|
||||||
|
interval = range(self.lines)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dirty.update(interval)
|
||||||
|
for y in interval:
|
||||||
|
line = self.buffer[y]
|
||||||
|
for x in list(line):
|
||||||
|
if x < self.columns:
|
||||||
|
line[x] = self.cursor.attrs
|
||||||
|
else:
|
||||||
|
line.pop(x, None)
|
||||||
|
|
||||||
|
if how == 0 or how == 1:
|
||||||
|
self.erase_in_line(how)
|
||||||
|
|
||||||
|
if how == 3 and self._erase_scrollback:
|
||||||
|
self.history.clear()
|
||||||
|
self._clear_callback()
|
||||||
|
|
||||||
|
def select_graphic_rendition(self, *attrs, **kwargs):
|
||||||
|
"""Apply an SGR sequence to the cursor attributes.
|
||||||
|
|
||||||
|
Adds to pyte: bright (aixterm) colours, and truecolor folded into the
|
||||||
|
256 palette. Transitions are memoized — TUIs cycle through a handful of
|
||||||
|
sequences thousands of times per frame.
|
||||||
|
"""
|
||||||
|
if not attrs or attrs == (0,):
|
||||||
|
self.cursor.attrs = self._default_char
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = (self.cursor.attrs, attrs)
|
||||||
|
cached = self._sgr_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
self.cursor.attrs = cached
|
||||||
|
return
|
||||||
|
|
||||||
|
# index walk rather than pop(): 38/48 consume their own parameters
|
||||||
|
replace = {}
|
||||||
|
i = 0
|
||||||
|
end = len(attrs)
|
||||||
|
while i < end:
|
||||||
|
attr = attrs[i]
|
||||||
|
i += 1
|
||||||
|
if attr == 0:
|
||||||
|
replace.update(self._default_char._asdict())
|
||||||
|
elif attr in g.FG_ANSI:
|
||||||
|
replace["fg"] = g.FG_ANSI[attr]
|
||||||
|
elif attr in g.BG_ANSI:
|
||||||
|
replace["bg"] = g.BG_ANSI[attr]
|
||||||
|
elif attr in g.TEXT:
|
||||||
|
attr = g.TEXT[attr]
|
||||||
|
if attr[1:] != "blink":
|
||||||
|
replace[attr[1:]] = attr.startswith("+")
|
||||||
|
elif attr in FG_AIXTERM:
|
||||||
|
replace.update(fg=FG_AIXTERM[attr])
|
||||||
|
elif attr in BG_AIXTERM:
|
||||||
|
replace.update(bg=BG_AIXTERM[attr])
|
||||||
|
elif attr in (g.FG_256, g.BG_256):
|
||||||
|
key = "fg" if attr == g.FG_256 else "bg"
|
||||||
|
try:
|
||||||
|
n = attrs[i]
|
||||||
|
i += 1
|
||||||
|
if n == 5:
|
||||||
|
m = attrs[i]
|
||||||
|
i += 1
|
||||||
|
if m < 16:
|
||||||
|
replace[key] = ANSI_COLORS[m]
|
||||||
|
else:
|
||||||
|
replace[key] = g.FG_BG_256[m]
|
||||||
|
elif n == 2:
|
||||||
|
m = quantize_256(attrs[i], attrs[i + 1], attrs[i + 2])
|
||||||
|
i += 3
|
||||||
|
replace[key] = ANSI_COLORS[m] if m < 16 else g.FG_BG_256[m]
|
||||||
|
except IndexError:
|
||||||
|
break
|
||||||
|
|
||||||
|
result = self.cursor.attrs._replace(**replace)
|
||||||
|
if len(self._sgr_cache) > 1024:
|
||||||
|
self._sgr_cache.clear()
|
||||||
|
self._sgr_cache[cache_key] = result
|
||||||
|
self.cursor.attrs = result
|
||||||
|
|
||||||
|
def scroll_up(self, n=None):
|
||||||
|
"""CSI S: scroll the margin region up, blanking the rows it exposes."""
|
||||||
|
n = n or 1
|
||||||
|
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||||
|
for y in range(top, bottom + 1):
|
||||||
|
if y + n > bottom:
|
||||||
|
self.buffer[y].clear()
|
||||||
|
else:
|
||||||
|
self.buffer[y] = _copy_line(self.buffer[y + n])
|
||||||
|
self.dirty.update(range(self.lines))
|
||||||
|
|
||||||
|
def scroll_down(self, n=None):
|
||||||
|
"""CSI T: scroll the margin region down, blanking the rows it exposes."""
|
||||||
|
n = n or 1
|
||||||
|
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||||
|
for y in reversed(range(top, bottom + 1)):
|
||||||
|
if y - n < top:
|
||||||
|
self.buffer[y].clear()
|
||||||
|
else:
|
||||||
|
self.buffer[y] = _copy_line(self.buffer[y - n])
|
||||||
|
self.dirty.update(range(self.lines))
|
||||||
|
|
||||||
|
def first_non_empty_line_from_bottom(self):
|
||||||
|
"""Index of the lowest row holding text, or -1 when the screen is blank."""
|
||||||
|
for nz_line in reversed(range(self.lines)):
|
||||||
|
# .get: indexing the defaultdict would materialize every empty row
|
||||||
|
line = self.buffer.get(nz_line)
|
||||||
|
if line and any(c.data and not c.data.isspace() for c in line.values()):
|
||||||
|
return nz_line
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def push_lines_into_history(self, count=None):
|
||||||
|
"""Copy the top `count` rows into scrollback; all content rows by default.
|
||||||
|
|
||||||
|
Suppressed on the alternate screen, and briefly after a wipe so the
|
||||||
|
forced repaint does not land in scrollback twice.
|
||||||
|
"""
|
||||||
|
if self.alternate_buffer_mode:
|
||||||
|
return
|
||||||
|
if time.time() < self._suppress_history_until:
|
||||||
|
return
|
||||||
|
if count is None:
|
||||||
|
count = self.first_non_empty_line_from_bottom() + 1
|
||||||
|
self.history.extend(_copy_line(self.buffer[y]) for y in range(count))
|
||||||
|
|
||||||
|
# replies go to the pty, never to the screen
|
||||||
|
|
||||||
|
def write_process_input(self, data):
|
||||||
|
"""Send a reply to the app. Query answers go to the pty, never to the screen."""
|
||||||
|
self._process.write(data)
|
||||||
|
|
||||||
|
def report_device_attributes(self, mode=0, **kwargs):
|
||||||
|
"""DA1: claim to be a VT220 with ANSI colour. Claude Code waits on this with no timeout."""
|
||||||
|
if kwargs.get("private"):
|
||||||
|
return
|
||||||
|
if mode == 0:
|
||||||
|
self.write_process_input("\x1b[?62;22c")
|
||||||
|
|
||||||
|
def report_secondary_device_attributes(self, mode=0):
|
||||||
|
"""DA2: terminal type and version, as a VT220 would answer."""
|
||||||
|
self.write_process_input("\x1b[>1;10;0c")
|
||||||
|
|
||||||
|
def report_device_status(self, mode=0, **kwargs):
|
||||||
|
"""DSR: 5 device ok, 6 cursor position, private 996 the light/dark scheme."""
|
||||||
|
private = kwargs.get("private", False)
|
||||||
|
if mode == 5:
|
||||||
|
self.write_process_input("\x1b[0n")
|
||||||
|
elif mode == 6:
|
||||||
|
x = min(self.cursor.x, self.columns - 1) + 1
|
||||||
|
y = self.cursor.y + 1
|
||||||
|
marker = "?" if private else ""
|
||||||
|
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
|
||||||
|
elif mode == 996 and private:
|
||||||
|
# color-scheme report, pi asks with a 2s timeout
|
||||||
|
self.write_process_input("\x1b[?997;{}n".format(1 if self.theme_is_dark else 2))
|
||||||
|
|
||||||
|
def report_xtversion(self):
|
||||||
|
"""XTVERSION: naming ourselves is what unlocks ?2026 for apps on a generic TERM."""
|
||||||
|
self.write_process_input("\x1bP>|vterm 0.1\x1b\\")
|
||||||
|
|
||||||
|
def report_mode_status(self, mode, private):
|
||||||
|
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
|
||||||
|
if not private:
|
||||||
|
self.write_process_input("\x1b[{};0$y".format(mode))
|
||||||
|
return
|
||||||
|
if mode in REPORTABLE_MODES:
|
||||||
|
value = 1 if (mode << 5) in self.mode else 2
|
||||||
|
else:
|
||||||
|
value = 0
|
||||||
|
self.write_process_input("\x1b[?{};{}$y".format(mode, value))
|
||||||
|
|
||||||
|
def report_kitty_keyboard(self):
|
||||||
|
"""Answer the kitty keyboard protocol probe."""
|
||||||
|
# 0 = unsupported, so apps fall back to legacy key encodings
|
||||||
|
self.write_process_input("\x1b[?0u")
|
||||||
|
|
||||||
|
def handle_osc(self, code, payload, terminator):
|
||||||
|
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard."""
|
||||||
|
if code in (0, 2):
|
||||||
|
self.set_title(payload)
|
||||||
|
elif code == 1:
|
||||||
|
self.set_icon_name(payload)
|
||||||
|
elif code in (10, 11):
|
||||||
|
if payload == "?":
|
||||||
|
color = self.default_fg_color if code == 10 else self.default_bg_color
|
||||||
|
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
|
||||||
|
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
||||||
|
elif code == 52:
|
||||||
|
parts = payload.split(";", 1)
|
||||||
|
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
|
||||||
|
# intermediate — those forms, and sequences split across reads, take the parser
|
||||||
|
CSI_COMPLETE = re.compile("(?:\x1b\\[|\x9b)([0-9;]*)([@-~])")
|
||||||
|
# a read ending mid-CSI; held for the next read, as the parser would hold state.
|
||||||
|
# bounded so a run of digits with no final cannot buffer forever
|
||||||
|
CSI_PARTIAL = re.compile("(?:\x1b\\[?|\x9b)[0-9;]{0,32}$")
|
||||||
|
|
||||||
|
|
||||||
|
class VtStream(pyte.Stream):
|
||||||
|
"""pyte.Stream with a parser for prefixed CSI, DECRQM and multi-digit OSC
|
||||||
|
|
||||||
|
Stock pyte drops what these agents rely on: `>`/`<`/`=` prefixed CSI, the
|
||||||
|
DECRQM `$p` intermediate, and OSC codes longer than one digit.
|
||||||
|
|
||||||
|
feed() dispatches complete plain CSI and lone control characters directly:
|
||||||
|
pyte resumes the parser coroutine once per character, which was ~90% of the
|
||||||
|
cost of an escape-heavy flood.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Copy the CSI table before adding to it, so pyte's class-level one is untouched."""
|
||||||
|
self.csi = dict(self.csi)
|
||||||
|
self.csi["S"] = "scroll_up"
|
||||||
|
self.csi["T"] = "scroll_down"
|
||||||
|
self._carry = ""
|
||||||
|
# param-string -> parsed tuple; SGR strings repeat across a whole frame
|
||||||
|
self._params_cache = {}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def attach(self, screen):
|
||||||
|
"""Bind the fast dispatch tables when the listener is known."""
|
||||||
|
super().attach(screen)
|
||||||
|
# same construction as the parser's create_dispatcher, so an unknown
|
||||||
|
# final lands on listener.debug either way
|
||||||
|
self._fast_csi = defaultdict(
|
||||||
|
lambda: screen.debug,
|
||||||
|
{final: getattr(screen, name) for final, name in self.csi.items()},
|
||||||
|
)
|
||||||
|
# SI/SO always take the parser: it skips them under utf8, and utf8 can
|
||||||
|
# be toggled at runtime
|
||||||
|
self._fast_basic = {char: getattr(screen, name) for char, name in self.basic.items() if char not in (ctrl.SI, ctrl.SO)}
|
||||||
|
|
||||||
|
def feed(self, data):
|
||||||
|
"""pyte's feed with a ground-state fast path.
|
||||||
|
|
||||||
|
Plain text runs keep pyte's regex path. A complete unprefixed CSI is
|
||||||
|
matched whole and dispatched in one call, and basic controls (CR, LF,
|
||||||
|
tab...) in one lookup, instead of one coroutine resume per character.
|
||||||
|
Everything else — OSC, charsets, private/prefixed CSI, `:` subparams —
|
||||||
|
falls through to the parser, byte for byte as before. A read ending
|
||||||
|
mid-CSI is carried into the next read; the parser would hold it as
|
||||||
|
state, so nothing is dispatched either way until the sequence is whole.
|
||||||
|
"""
|
||||||
|
# the tail of the previous read ended mid-CSI; complete it first
|
||||||
|
if self._carry:
|
||||||
|
data = self._carry + data
|
||||||
|
self._carry = ""
|
||||||
|
|
||||||
|
# locals for the loop, which runs per text run / escape sequence
|
||||||
|
send = self._send_to_parser
|
||||||
|
draw = self.listener.draw
|
||||||
|
match_text = self._text_pattern.match
|
||||||
|
match_csi = CSI_COMPLETE.match
|
||||||
|
match_partial = CSI_PARTIAL.match
|
||||||
|
csi = self._fast_csi
|
||||||
|
basic = self._fast_basic
|
||||||
|
cache = self._params_cache
|
||||||
|
# True: the parser is at ground state, the fast paths may act.
|
||||||
|
# False: it is mid-sequence and owns every byte until it yields True
|
||||||
|
taking_plain_text = self._taking_plain_text
|
||||||
|
|
||||||
|
length = len(data)
|
||||||
|
offset = 0
|
||||||
|
while offset < length:
|
||||||
|
if taking_plain_text:
|
||||||
|
# printable run: hand it to draw() whole (pyte's own fast path)
|
||||||
|
match = match_text(data, offset)
|
||||||
|
if match:
|
||||||
|
start, offset = match.span()
|
||||||
|
draw(data[start:offset])
|
||||||
|
continue
|
||||||
|
char = data[offset]
|
||||||
|
if char == "\x1b" or char == "\x9b":
|
||||||
|
# complete plain CSI: dispatch here, skipping the parser
|
||||||
|
match = match_csi(data, offset)
|
||||||
|
if match:
|
||||||
|
# params memo: SGR strings repeat across a whole frame
|
||||||
|
raw = match.group(1)
|
||||||
|
params = cache.get(raw)
|
||||||
|
if params is None:
|
||||||
|
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";"))
|
||||||
|
if len(cache) > 4096:
|
||||||
|
cache.clear()
|
||||||
|
cache[raw] = params
|
||||||
|
try:
|
||||||
|
csi[match.group(2)](*params)
|
||||||
|
except TypeError:
|
||||||
|
# same guard as _dispatch_csi: excess params cost one sequence, not the chunk
|
||||||
|
LOG.debug("malformed CSI %s%s", params, match.group(2))
|
||||||
|
offset = match.end()
|
||||||
|
continue
|
||||||
|
# CSI cut off by the end of the read: hold it for the next
|
||||||
|
if match_partial(data, offset):
|
||||||
|
self._carry = data[offset:]
|
||||||
|
break
|
||||||
|
# some other escape (OSC, charset, private CSI...):
|
||||||
|
# fall through to the parser
|
||||||
|
elif char in basic:
|
||||||
|
# lone control character (CR, LF, tab, BEL...)
|
||||||
|
basic[char]()
|
||||||
|
offset += 1
|
||||||
|
continue
|
||||||
|
# this byte is the parser's; the else branch below sends it
|
||||||
|
taking_plain_text = False
|
||||||
|
else:
|
||||||
|
# parser mid-sequence: one byte at a time until it returns
|
||||||
|
# True, saying it is back at ground state
|
||||||
|
taking_plain_text = send(data[offset : offset + 1])
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
self._taking_plain_text = taking_plain_text
|
||||||
|
|
||||||
|
def _parser_fsm(self):
|
||||||
|
"""Coroutine pyte feeds one character at a time; `yield True` resumes bulk text.
|
||||||
|
|
||||||
|
A full copy of pyte's FSM rather than a hook, because the additions are
|
||||||
|
inside its inner loops. Locals are bound up front: this runs per byte of
|
||||||
|
every escape sequence.
|
||||||
|
"""
|
||||||
|
basic = self.basic
|
||||||
|
listener = self.listener
|
||||||
|
draw = listener.draw
|
||||||
|
debug = listener.debug
|
||||||
|
|
||||||
|
ESC, CSI_C1 = ctrl.ESC, ctrl.CSI_C1
|
||||||
|
OSC_C1 = ctrl.OSC_C1
|
||||||
|
NUL_OR_DEL = ctrl.NUL + ctrl.DEL
|
||||||
|
CAN_OR_SUB = ctrl.CAN + ctrl.SUB
|
||||||
|
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ctrl.VT, ctrl.FF, ctrl.CR])
|
||||||
|
OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL])
|
||||||
|
MAX_OSC = 65536
|
||||||
|
|
||||||
|
# unknown sequences fall through to listener.debug rather than raising
|
||||||
|
def create_dispatcher(mapping):
|
||||||
|
return defaultdict(lambda: debug, dict((event, getattr(listener, attr)) for event, attr in mapping.items()))
|
||||||
|
|
||||||
|
basic_dispatch = create_dispatcher(basic)
|
||||||
|
sharp_dispatch = create_dispatcher(self.sharp)
|
||||||
|
escape_dispatch = create_dispatcher(self.escape)
|
||||||
|
csi_dispatch = create_dispatcher(self.csi)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
char = yield True
|
||||||
|
|
||||||
|
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
|
||||||
|
if char == ESC:
|
||||||
|
char = yield None
|
||||||
|
if char == "[":
|
||||||
|
char = CSI_C1
|
||||||
|
elif char == "]":
|
||||||
|
char = OSC_C1
|
||||||
|
elif char in "PX^_":
|
||||||
|
# consume the payload so it never reaches the screen
|
||||||
|
prev = ""
|
||||||
|
while True:
|
||||||
|
c = yield None
|
||||||
|
if c == ctrl.ST_C1 or (prev == ESC and c == "\\"):
|
||||||
|
break
|
||||||
|
prev = c
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if char == "#":
|
||||||
|
sharp_dispatch[(yield None)]()
|
||||||
|
elif char == "%":
|
||||||
|
self.select_other_charset((yield None))
|
||||||
|
elif char in "()":
|
||||||
|
# honoured even in UTF-8 mode, which pyte skips: smacs is ESC(0
|
||||||
|
listener.define_charset((yield None), mode=char)
|
||||||
|
else:
|
||||||
|
escape_dispatch[char]()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char in basic:
|
||||||
|
if (char == ctrl.SI or char == ctrl.SO) and self.use_utf8:
|
||||||
|
continue
|
||||||
|
basic_dispatch[char]()
|
||||||
|
# CSI: collect private/prefix/intermediate markers and the numeric
|
||||||
|
# parameters until a final byte arrives
|
||||||
|
elif char == CSI_C1:
|
||||||
|
params = []
|
||||||
|
current = ""
|
||||||
|
private = False
|
||||||
|
prefix = ""
|
||||||
|
intermediate = ""
|
||||||
|
while True:
|
||||||
|
char = yield None
|
||||||
|
if char == "?":
|
||||||
|
private = True
|
||||||
|
elif char in "><=":
|
||||||
|
prefix = char
|
||||||
|
elif char in " $":
|
||||||
|
intermediate = char
|
||||||
|
elif char in ALLOWED_IN_CSI:
|
||||||
|
basic_dispatch[char]()
|
||||||
|
elif char in CAN_OR_SUB:
|
||||||
|
draw(char)
|
||||||
|
break
|
||||||
|
elif char.isdigit():
|
||||||
|
current += char
|
||||||
|
elif char == ":":
|
||||||
|
# SGR subparameters, which we do not support
|
||||||
|
pass
|
||||||
|
elif char == ";":
|
||||||
|
params.append(min(int(current or 0), 9999))
|
||||||
|
current = ""
|
||||||
|
else:
|
||||||
|
params.append(min(int(current or 0), 9999))
|
||||||
|
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
|
||||||
|
break
|
||||||
|
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
|
||||||
|
# ending at BEL or ST. pyte only accepts a single-digit code
|
||||||
|
elif char == OSC_C1:
|
||||||
|
code = ""
|
||||||
|
while True:
|
||||||
|
char = yield None
|
||||||
|
if char.isdigit() and len(code) < 8:
|
||||||
|
code += char
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload = ""
|
||||||
|
if char == ";" or (char not in OSC_TERMINATORS and char != ESC):
|
||||||
|
while True:
|
||||||
|
char = yield None
|
||||||
|
if char == ESC:
|
||||||
|
char += yield None
|
||||||
|
if char in OSC_TERMINATORS:
|
||||||
|
break
|
||||||
|
if len(payload) < MAX_OSC:
|
||||||
|
payload += char
|
||||||
|
elif char == ESC:
|
||||||
|
# take the '\' of ST, so char holds the whole terminator
|
||||||
|
char += yield None
|
||||||
|
|
||||||
|
try:
|
||||||
|
listener.handle_osc(int(code or 0), payload, char)
|
||||||
|
except Exception:
|
||||||
|
LOG.warning("osc handler failed: code=%s", code, exc_info=True)
|
||||||
|
elif char not in NUL_OR_DEL:
|
||||||
|
draw(char)
|
||||||
|
|
||||||
|
def _dispatch_csi(self, csi_dispatch, listener, final, params, private, prefix, intermediate):
|
||||||
|
"""Route a CSI by its intermediate, prefix and private marker.
|
||||||
|
|
||||||
|
The forms pyte has no table for are handled here; a private sequence we
|
||||||
|
do not implement is dropped rather than run in its non-private meaning.
|
||||||
|
"""
|
||||||
|
if intermediate == "$":
|
||||||
|
if final == "p":
|
||||||
|
listener.report_mode_status(params[0] if params else 0, private)
|
||||||
|
return
|
||||||
|
if intermediate == " ":
|
||||||
|
if final == "q":
|
||||||
|
listener.set_cursor_style(params[0] if params else 0)
|
||||||
|
return
|
||||||
|
if prefix == ">":
|
||||||
|
if final == "q":
|
||||||
|
listener.report_xtversion()
|
||||||
|
elif final == "c":
|
||||||
|
listener.report_secondary_device_attributes(*params)
|
||||||
|
# '>m' modifyOtherKeys, '>u' kitty push: ignore
|
||||||
|
return
|
||||||
|
if prefix:
|
||||||
|
# kitty pop ('<u'), DECKPAM-style ('='): ignore
|
||||||
|
return
|
||||||
|
if private:
|
||||||
|
if final == "u":
|
||||||
|
listener.report_kitty_keyboard()
|
||||||
|
elif final in ("h", "l"):
|
||||||
|
csi_dispatch[final](*params, private=True)
|
||||||
|
elif final in ("J", "K", "n", "c"):
|
||||||
|
csi_dispatch[final](params[0] if params else 0, private=True)
|
||||||
|
else:
|
||||||
|
# only these handlers understand the private form; passing it to
|
||||||
|
# the rest raises TypeError. aerc probes XTSMGRAPHICS (?S)
|
||||||
|
LOG.debug("ignoring private CSI ?%s%s", params, final)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
csi_dispatch[final](*params)
|
||||||
|
except TypeError:
|
||||||
|
LOG.debug("malformed CSI %s%s", params, final)
|
||||||
148
vterm/keys.py
Normal file
148
vterm/keys.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
""" Key-name to escape-sequence tables
|
||||||
|
|
||||||
|
The keymap binds key *names*; get_key_code turns a name plus its modifier flags
|
||||||
|
into the bytes an app expects. Legacy xterm encodings only — the kitty keyboard
|
||||||
|
protocol is deliberately not implemented.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# unmodified keys, and the paste brackets, in their default (non-application) form
|
||||||
|
_KEY_MAP = {
|
||||||
|
"enter": "\r",
|
||||||
|
"backspace": "\x7f",
|
||||||
|
"tab": "\t",
|
||||||
|
"space": " ",
|
||||||
|
"escape": "\x1b",
|
||||||
|
"up": "\x1b[A",
|
||||||
|
"down": "\x1b[B",
|
||||||
|
"right": "\x1b[C",
|
||||||
|
"left": "\x1b[D",
|
||||||
|
"home": "\x1b[H",
|
||||||
|
"end": "\x1b[F",
|
||||||
|
"pageup": "\x1b[5~",
|
||||||
|
"pagedown": "\x1b[6~",
|
||||||
|
"delete": "\x1b[3~",
|
||||||
|
"f1": "\x1bOP",
|
||||||
|
"f2": "\x1bOQ",
|
||||||
|
"f3": "\x1bOR",
|
||||||
|
"f4": "\x1bOS",
|
||||||
|
"f5": "\x1b[15~",
|
||||||
|
"f6": "\x1b[17~",
|
||||||
|
"f7": "\x1b[18~",
|
||||||
|
"f8": "\x1b[19~",
|
||||||
|
"f9": "\x1b[20~",
|
||||||
|
"f10": "\x1b[21~",
|
||||||
|
"f11": "\x1b[23~",
|
||||||
|
"f12": "\x1b[24~",
|
||||||
|
"bracketed_paste_start": "\x1b[200~",
|
||||||
|
"bracketed_paste_end": "\x1b[201~",
|
||||||
|
}
|
||||||
|
|
||||||
|
# DECCKM: cursor keys switch from CSI to SS3 while an app asks for application mode
|
||||||
|
_APP_MODE_KEY_MAP = {
|
||||||
|
"up": "\x1bOA",
|
||||||
|
"down": "\x1bOB",
|
||||||
|
"right": "\x1bOC",
|
||||||
|
"left": "\x1bOD",
|
||||||
|
"home": "\x1bOH",
|
||||||
|
"end": "\x1bOF",
|
||||||
|
}
|
||||||
|
|
||||||
|
# xterm modified keys: CSI 1;<mod><letter> / CSI <n>;<mod>~
|
||||||
|
_MODIFIABLE_CSI = {
|
||||||
|
"up": "\x1b[1;{}A",
|
||||||
|
"down": "\x1b[1;{}B",
|
||||||
|
"right": "\x1b[1;{}C",
|
||||||
|
"left": "\x1b[1;{}D",
|
||||||
|
"home": "\x1b[1;{}H",
|
||||||
|
"end": "\x1b[1;{}F",
|
||||||
|
"delete": "\x1b[3;{}~",
|
||||||
|
"pageup": "\x1b[5;{}~",
|
||||||
|
"pagedown": "\x1b[6;{}~",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ctrl+<key> for the keys whose control byte is not chr(letter - 'a' + 1)
|
||||||
|
_CTRL_KEY_MAP = {
|
||||||
|
"@": "\x00",
|
||||||
|
"`": "\x00",
|
||||||
|
"2": "\x00",
|
||||||
|
"[": "\x1b",
|
||||||
|
"{": "\x1b",
|
||||||
|
"3": "\x1b",
|
||||||
|
"\\": "\x1c",
|
||||||
|
"|": "\x1c",
|
||||||
|
"4": "\x1c",
|
||||||
|
"]": "\x1d",
|
||||||
|
"}": "\x1d",
|
||||||
|
"5": "\x1d",
|
||||||
|
"^": "\x1e",
|
||||||
|
"~": "\x1e",
|
||||||
|
"6": "\x1e",
|
||||||
|
"_": "\x1f",
|
||||||
|
"/": "\x1f",
|
||||||
|
"7": "\x1f",
|
||||||
|
"?": "\x7f",
|
||||||
|
"8": "\x7f",
|
||||||
|
"space": "\x00",
|
||||||
|
"enter": "\n",
|
||||||
|
"tab": "\t",
|
||||||
|
"backspace": "\x08",
|
||||||
|
}
|
||||||
|
|
||||||
|
# the two shifted keys with an encoding of their own; shift is bare text elsewhere
|
||||||
|
_SHIFT_KEY_MAP = {
|
||||||
|
"tab": "\x1b[Z",
|
||||||
|
"enter": "\x1b\r",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _modifier_code(ctrl, alt, shift):
|
||||||
|
"""xterm modifier parameter: 1 plus a bitmask of shift/alt/ctrl."""
|
||||||
|
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False):
|
||||||
|
"""Escape sequence for a key name and its modifiers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: key name from the keymap, or a single literal character.
|
||||||
|
application_mode: DECCKM is set, so cursor keys take their SS3 form.
|
||||||
|
new_line_mode: LNM is set, so enter sends CR LF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: the bytes to write to the pty.
|
||||||
|
"""
|
||||||
|
key_lo = key.lower() if len(key) > 1 else key
|
||||||
|
|
||||||
|
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
|
||||||
|
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
|
||||||
|
|
||||||
|
if ctrl:
|
||||||
|
base = key_lo
|
||||||
|
if base in _CTRL_KEY_MAP:
|
||||||
|
code = _CTRL_KEY_MAP[base]
|
||||||
|
elif len(base) == 1 and "a" <= base <= "z":
|
||||||
|
code = chr(ord(base) - ord("a") + 1)
|
||||||
|
else:
|
||||||
|
code = _plain(key, application_mode, new_line_mode)
|
||||||
|
return ("\x1b" + code) if alt else code
|
||||||
|
|
||||||
|
if alt:
|
||||||
|
return "\x1b" + _plain(key if len(key) == 1 else key_lo, application_mode, new_line_mode)
|
||||||
|
|
||||||
|
if shift:
|
||||||
|
if key_lo in _SHIFT_KEY_MAP:
|
||||||
|
return _SHIFT_KEY_MAP[key_lo]
|
||||||
|
return _KEY_MAP.get(key, key)
|
||||||
|
|
||||||
|
return _plain(key, application_mode, new_line_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(key, application_mode=False, new_line_mode=False):
|
||||||
|
"""Unmodified encoding, honouring DECCKM and LNM; unknown names pass through as text."""
|
||||||
|
if application_mode and key in _APP_MODE_KEY_MAP:
|
||||||
|
return _APP_MODE_KEY_MAP[key]
|
||||||
|
if new_line_mode and key == "enter":
|
||||||
|
return "\r\n"
|
||||||
|
if key in _KEY_MAP:
|
||||||
|
return _KEY_MAP[key]
|
||||||
|
return key
|
||||||
109
vterm/listeners.py
Normal file
109
vterm/listeners.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
""" Event listeners: printable/IME input capture, command rerouting,
|
||||||
|
keymap context, focus reporting, lifecycle """
|
||||||
|
import sublime
|
||||||
|
import sublime_plugin
|
||||||
|
|
||||||
|
from .terminal import Terminal
|
||||||
|
|
||||||
|
|
||||||
|
class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||||
|
"""View events a terminal has to answer: focus, input capture, teardown.
|
||||||
|
|
||||||
|
Application-wide, so every hook runs for ordinary files too and has to bail
|
||||||
|
out cheaply.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Track carets per view, for the insert-detection in on_modified."""
|
||||||
|
# view id -> last caret position, to work out what sublime just inserted
|
||||||
|
self._cursor = {}
|
||||||
|
|
||||||
|
def on_activated(self, view):
|
||||||
|
"""Report focus; warn if the plugin host restarted and left the tab orphaned."""
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if terminal:
|
||||||
|
terminal.focused = True
|
||||||
|
terminal.send_focus(True)
|
||||||
|
view.run_command("vterm_clear_undo_stack")
|
||||||
|
# catch up on the frames, title and caret skipped while in the background
|
||||||
|
view.run_command("vterm_render")
|
||||||
|
view.run_command("vterm_show_cursor", {"scroll": False})
|
||||||
|
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
|
||||||
|
# the plugin host restarted; this tab's terminal is unreachable
|
||||||
|
view.set_status("vterm", "vterm: disconnected — close this tab and open a new one")
|
||||||
|
|
||||||
|
def on_deactivated(self, view):
|
||||||
|
"""Report focus loss, and leave the view without a caret while it is in the background."""
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if terminal:
|
||||||
|
terminal.focused = False
|
||||||
|
terminal.send_focus(False)
|
||||||
|
if all(region.empty() for region in view.sel()):
|
||||||
|
view.sel().clear()
|
||||||
|
|
||||||
|
def on_pre_close(self, view):
|
||||||
|
"""Kill the process when its tab closes."""
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if terminal:
|
||||||
|
terminal.kill()
|
||||||
|
self._cursor.pop(view.id(), None)
|
||||||
|
|
||||||
|
def on_modified(self, view):
|
||||||
|
"""Forward text sublime inserted, and undo anything else that edits the view.
|
||||||
|
|
||||||
|
The keymap covers printable keys, but IME and composed input never
|
||||||
|
raise a key event — it arrives as an `insert` command. Text the emulator
|
||||||
|
did not produce must never stay in the view.
|
||||||
|
"""
|
||||||
|
# printables and IME text arrive as sublime inserts, not keypresses
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
command, args, _ = view.command_history(0)
|
||||||
|
if command.startswith("vterm"):
|
||||||
|
return
|
||||||
|
if not terminal.alive:
|
||||||
|
return
|
||||||
|
if command == "insert" and "characters" in args and len(view.sel()) == 1 and view.sel()[0].empty():
|
||||||
|
chars = args["characters"]
|
||||||
|
current_cursor = view.sel()[0].end()
|
||||||
|
prev = self._cursor.get(view.id(), 0)
|
||||||
|
region = sublime.Region(max(current_cursor - len(chars), prev), current_cursor)
|
||||||
|
text = view.substr(region)
|
||||||
|
self._cursor[view.id()] = current_cursor
|
||||||
|
view.run_command("vterm_paste_text", {"text": text, "bracketed": False})
|
||||||
|
elif command:
|
||||||
|
view.run_command("soft_undo")
|
||||||
|
|
||||||
|
def on_selection_modified(self, view):
|
||||||
|
"""Track the caret, so on_modified can tell which text was just inserted."""
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal or not terminal.alive:
|
||||||
|
return
|
||||||
|
if len(view.sel()) != 1 or not view.sel()[0].empty():
|
||||||
|
return
|
||||||
|
self._cursor[view.id()] = view.sel()[0].end()
|
||||||
|
|
||||||
|
def on_query_context(self, view, key, operator, operand, match_all):
|
||||||
|
"""Answer the one context the mousemap needs; keymap contexts are native."""
|
||||||
|
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
||||||
|
# those the same way, so the wheel flag still needs the hook
|
||||||
|
if key != "vterm_view.wheel_captured":
|
||||||
|
return None
|
||||||
|
value = view.settings().get(key, False)
|
||||||
|
if operator == sublime.OP_EQUAL:
|
||||||
|
return value == operand
|
||||||
|
elif operator == sublime.OP_NOT_EQUAL:
|
||||||
|
return value != operand
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_text_command(self, view, name, args):
|
||||||
|
"""Reroute copy/paste to the pty and swallow undo, in terminal views."""
|
||||||
|
if not view.settings().get("vterm_view"):
|
||||||
|
return
|
||||||
|
if name == "copy":
|
||||||
|
return ("vterm_copy", None)
|
||||||
|
elif name in ("paste", "paste_and_indent"):
|
||||||
|
return ("vterm_paste", None)
|
||||||
|
elif name == "undo":
|
||||||
|
return ("vterm_noop", None)
|
||||||
99
vterm/mouse.py
Normal file
99
vterm/mouse.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
""" SGR mouse reporting while the app tracks the mouse; otherwise sublime keeps drag_select """
|
||||||
|
import sublime_plugin
|
||||||
|
|
||||||
|
from .terminal import Terminal
|
||||||
|
|
||||||
|
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
||||||
|
WHEEL_UP = 64
|
||||||
|
WHEEL_DOWN = 65
|
||||||
|
|
||||||
|
# lines per wheel tick, as iTerm2 delivers per notch; pi scrolls exactly one
|
||||||
|
# line per report and has no setting of its own
|
||||||
|
WHEEL_LINES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _event_to_cell(view, terminal, event, clamp=False):
|
||||||
|
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
|
||||||
|
|
||||||
|
`clamp` pulls a point above or below the screen onto the nearest row: the
|
||||||
|
wheel still belongs to the app, while a click there does not."""
|
||||||
|
pt = view.window_to_text((event["x"], event["y"]))
|
||||||
|
row, col = view.rowcol(pt)
|
||||||
|
row -= terminal.offset
|
||||||
|
screen = terminal.screen
|
||||||
|
if row < 0 or row >= screen.lines:
|
||||||
|
if not clamp:
|
||||||
|
return None
|
||||||
|
row = min(max(row, 0), screen.lines - 1)
|
||||||
|
return (min(col, screen.columns - 1) + 1, row + 1)
|
||||||
|
|
||||||
|
|
||||||
|
class VtermMouseEventListener(sublime_plugin.EventListener):
|
||||||
|
"""Hands button-1 clicks to the app, but only while it tracks the mouse."""
|
||||||
|
|
||||||
|
def on_text_command(self, view, name, args):
|
||||||
|
"""Replace drag_select with a mouse report; other click variants stay native."""
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
if not terminal.screen.mouse_reporting_enabled():
|
||||||
|
# leave selection, caret and scrolling to sublime
|
||||||
|
return
|
||||||
|
if name == "drag_select" and args and "event" in 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):
|
||||||
|
"""Focus the tab on the first click, report press and release after that."""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
window = self.view.window()
|
||||||
|
if window and window.active_view() != self.view:
|
||||||
|
# we replaced drag_select, which is what focuses the group; this
|
||||||
|
# click only focuses, as clicking an unfocused terminal window does
|
||||||
|
window.focus_view(self.view)
|
||||||
|
return
|
||||||
|
cell = _event_to_cell(self.view, terminal, event)
|
||||||
|
if cell is None:
|
||||||
|
return
|
||||||
|
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
|
||||||
|
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
|
||||||
|
|
||||||
|
def want_event(self):
|
||||||
|
"""Ask sublime for the click coordinates."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class VtermScrollCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
|
||||||
|
|
||||||
|
def run(self, edit, event=None, up=False):
|
||||||
|
"""Report the wheel to the app, or fake arrow keys on the alt screen.
|
||||||
|
|
||||||
|
Only bound while the app owns the wheel; otherwise sublime scrolls the
|
||||||
|
view over the real scrollback text and this never runs.
|
||||||
|
"""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
if terminal.screen.mouse_reporting_enabled():
|
||||||
|
cell = _event_to_cell(self.view, terminal, event, clamp=True) if event else None
|
||||||
|
if cell is None:
|
||||||
|
cell = (1, terminal.screen.lines // 2 + 1)
|
||||||
|
# one report per line, never one batched write: pi matches the SGR
|
||||||
|
# form against a whole stdin chunk and drops anything longer
|
||||||
|
for _ in range(WHEEL_LINES):
|
||||||
|
terminal.send_mouse(WHEEL_UP if up else WHEEL_DOWN, cell[0], cell[1], pressed=True)
|
||||||
|
elif terminal.screen.alternate_buffer_mode:
|
||||||
|
# alternate scroll: an alt-screen app that does not track the mouse
|
||||||
|
# gets arrow keys per wheel tick, as kitty/iTerm2/xterm do
|
||||||
|
for _ in range(WHEEL_LINES):
|
||||||
|
terminal.send_key(key="up" if up else "down")
|
||||||
|
|
||||||
|
def want_event(self):
|
||||||
|
"""Ask sublime for the wheel coordinates."""
|
||||||
|
return True
|
||||||
547
vterm/render.py
Normal file
547
vterm/render.py
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
""" Rendering the emulator screen into the Sublime view
|
||||||
|
|
||||||
|
The view holds real text: scrollback above `terminal.offset`, the live screen
|
||||||
|
window from `offset` on. Dirty rows are whole-line replaced; colors are
|
||||||
|
add_regions overlays with scopes served by the dynamic scheme. The ST caret
|
||||||
|
doubles as the terminal cursor.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
import sublime_plugin
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
# per-cell call in segment_buffer_line; pyte caches its own wcwidth likewise
|
||||||
|
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
||||||
|
|
||||||
|
from .scheme import scheme_manager
|
||||||
|
from .terminal import Terminal
|
||||||
|
from .utils import rev_wcwidth
|
||||||
|
|
||||||
|
# zero-width sentinel appended to soft-wrapped lines so copy can re-join them
|
||||||
|
CONTINUATION = "\u200b\u200c\u200b"
|
||||||
|
|
||||||
|
# scrollback colour overlays are merged onto one key per (scope, chunk of rows):
|
||||||
|
# the key count stays bounded and each merge re-sends at most a chunk's regions
|
||||||
|
CHUNK = 256
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_key(chunk, scope):
|
||||||
|
"""Region key of one scrollback chunk's overlays for one scope."""
|
||||||
|
return "vterm@{}@{}".format(chunk, scope)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fg_bg(fg, bg):
|
||||||
|
"""Swap the colours of a reverse-video run, keeping "default" resolvable."""
|
||||||
|
fg, bg = bg, fg
|
||||||
|
if fg == "default":
|
||||||
|
fg = "reverse_default"
|
||||||
|
if bg == "default":
|
||||||
|
bg = "reverse_default"
|
||||||
|
return fg, bg
|
||||||
|
|
||||||
|
|
||||||
|
# style flags in the order Char stores them, as the compact scope suffix
|
||||||
|
STYLE_FLAGS = (("bold", "b"), ("italics", "i"), ("underscore", "u"))
|
||||||
|
|
||||||
|
# the Char fields that decide whether a run continues
|
||||||
|
ATTRS = slice(1, 8)
|
||||||
|
|
||||||
|
|
||||||
|
def segment_buffer_line(buffer_line):
|
||||||
|
"""Yield (text, start, end, fg, bg, style) runs of a screen row.
|
||||||
|
|
||||||
|
A run continues while the cell attributes are unchanged, so a row becomes a
|
||||||
|
handful of spans rather than one entry per cell.
|
||||||
|
"""
|
||||||
|
if not buffer_line:
|
||||||
|
return
|
||||||
|
last_index = max(buffer_line) + 1
|
||||||
|
|
||||||
|
run = []
|
||||||
|
start = 0
|
||||||
|
column = 0
|
||||||
|
attrs = None
|
||||||
|
skip_continuation = False
|
||||||
|
|
||||||
|
for i in range(last_index):
|
||||||
|
if skip_continuation:
|
||||||
|
skip_continuation = False
|
||||||
|
continue
|
||||||
|
char = buffer_line[i]
|
||||||
|
# a wide char owns the next column too, which holds only a filler cell
|
||||||
|
skip_continuation = cell_width(char.data) >= 2
|
||||||
|
|
||||||
|
if attrs is None:
|
||||||
|
column = i
|
||||||
|
attrs = char[ATTRS]
|
||||||
|
elif char[ATTRS] != attrs:
|
||||||
|
yield _run(run, start, column, attrs)
|
||||||
|
run = []
|
||||||
|
start = column
|
||||||
|
attrs = char[ATTRS]
|
||||||
|
|
||||||
|
run.append(char.data)
|
||||||
|
column += 1
|
||||||
|
|
||||||
|
if attrs is not None:
|
||||||
|
yield _run(run, start, column, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(run, start, end, attrs):
|
||||||
|
"""Pack one segment: its text, span, resolved colours and style suffix."""
|
||||||
|
fg, bg = attrs[0], attrs[1]
|
||||||
|
# attrs[6] is Char.reverse
|
||||||
|
if attrs[6]:
|
||||||
|
fg, bg = reverse_fg_bg(fg, bg)
|
||||||
|
style = "".join(flag for i, (_, flag) in enumerate(STYLE_FLAGS, start=2) if attrs[i])
|
||||||
|
return "".join(run), start, end, fg, bg, style
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_position(view, edit, row, col=0):
|
||||||
|
"""Grow the view so (row, col) exists"""
|
||||||
|
lastrow = view.rowcol(view.size())[0]
|
||||||
|
if lastrow < row:
|
||||||
|
view.insert(edit, view.size(), "\n" * (row - lastrow))
|
||||||
|
line_region = view.line(view.text_point(row, 0))
|
||||||
|
lastcol = view.rowcol(line_region.end())[1]
|
||||||
|
if lastcol < col:
|
||||||
|
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
||||||
|
|
||||||
|
|
||||||
|
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Draws the screen into the view. Sublime keeps one instance per view, so
|
||||||
|
the caches below are that view's render state."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Set up this view's render caches."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.colored_lines = {}
|
||||||
|
# view row -> (segments, lf) as last rendered; pyte's dirty means
|
||||||
|
# "repaint hint", so most dirty rows are identical frame to frame
|
||||||
|
self._row_cache = {}
|
||||||
|
self._key_counter = 0
|
||||||
|
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||||
|
# rows trimmed so far, so they survive trim_history renumbering
|
||||||
|
self._archived_chunks = {}
|
||||||
|
# rows waiting for their chunk to close, and the chunk they belong to
|
||||||
|
self._open_rows = []
|
||||||
|
self._open_chunk = None
|
||||||
|
self._trimmed = 0
|
||||||
|
self._lastrow = 0
|
||||||
|
self._applied_cursor_style = None
|
||||||
|
|
||||||
|
def next_key(self):
|
||||||
|
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
||||||
|
self._key_counter += 1
|
||||||
|
return "vterm#{}".format(self._key_counter)
|
||||||
|
|
||||||
|
def ensure_row(self, edit, row):
|
||||||
|
"""Row-existence check for the hot path, tracking the last row in python"""
|
||||||
|
if self._lastrow < row:
|
||||||
|
self.view.insert(edit, self.view.size(), "\n" * (row - self._lastrow))
|
||||||
|
self._lastrow = row
|
||||||
|
|
||||||
|
def run(self, edit):
|
||||||
|
"""Draw one frame: dirty rows, fold, caret, trimming, title."""
|
||||||
|
view = self.view
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
|
||||||
|
screen = terminal.screen
|
||||||
|
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||||
|
# both alone so no view event of ours reaches the view they are typing in
|
||||||
|
focused = terminal.focused
|
||||||
|
# our own replace expands the caret over the text it inserts
|
||||||
|
had_selection = any(not region.empty() for region in view.sel())
|
||||||
|
|
||||||
|
if terminal.pending_clear:
|
||||||
|
terminal.pending_clear = False
|
||||||
|
self.clear_view(edit, terminal)
|
||||||
|
|
||||||
|
if terminal.pending_clear_screen:
|
||||||
|
terminal.pending_clear_screen = False
|
||||||
|
self.clear_view(edit, terminal, wipe_screen=True)
|
||||||
|
|
||||||
|
self.update_lines(edit, terminal)
|
||||||
|
self.sync_alt_fold(terminal)
|
||||||
|
|
||||||
|
if focused and not had_selection:
|
||||||
|
# drop the artefact; focus_cursor re-places the caret
|
||||||
|
view.sel().clear()
|
||||||
|
|
||||||
|
# follow-scroll gate: viewport_y is where the last auto-scroll left the
|
||||||
|
# view, so a smaller current position means the user scrolled up. Then
|
||||||
|
# trimming, the caret and the scroll are all skipped, keeping their place
|
||||||
|
if terminal.viewport_y < view.viewport_position()[1] + view.line_height():
|
||||||
|
self.trim_trailing_spaces(edit, terminal)
|
||||||
|
self.trim_history(edit, terminal)
|
||||||
|
if focused:
|
||||||
|
# direct call: a nested run_command opens a second edit/undo group
|
||||||
|
focus_cursor(view, edit, terminal)
|
||||||
|
request_scroll(view, terminal)
|
||||||
|
|
||||||
|
self.sync_cursor_style(terminal)
|
||||||
|
|
||||||
|
if focused:
|
||||||
|
title = screen.title or terminal.default_title
|
||||||
|
if view.name() != title:
|
||||||
|
view.set_name(title)
|
||||||
|
|
||||||
|
# this command is the only writer of screen.dirty, so it owns clearing it
|
||||||
|
screen.dirty.clear()
|
||||||
|
|
||||||
|
def sync_alt_fold(self, terminal):
|
||||||
|
"""Hide the scrollback while the app owns the alt screen: fold it away
|
||||||
|
(the view text is its only copy) and drop past-end scrolling so the
|
||||||
|
view has no scroll range. The fold is re-checked every render — the
|
||||||
|
screen's first row is replaced on every region scroll, and an edit at
|
||||||
|
the fold boundary can silently disturb a fold."""
|
||||||
|
view = self.view
|
||||||
|
alt = terminal.screen.alternate_buffer_mode
|
||||||
|
if not alt and not terminal.applied_alt_screen:
|
||||||
|
return
|
||||||
|
if alt != terminal.applied_alt_screen:
|
||||||
|
terminal.applied_alt_screen = alt
|
||||||
|
view.settings().set("scroll_past_end", not alt)
|
||||||
|
end = view.text_point(terminal.offset, 0) - 1 if alt and terminal.offset else 0
|
||||||
|
want = [(0, end)] if end > 0 else []
|
||||||
|
if [(r.begin(), r.end()) for r in view.folded_regions()] == want:
|
||||||
|
return
|
||||||
|
view.unfold(sublime.Region(0, view.size()))
|
||||||
|
if want and view.fold(sublime.Region(0, end)):
|
||||||
|
# a fold collapses the layout; a stale viewport anchor would stop
|
||||||
|
# the follow-scroll gate from re-engaging
|
||||||
|
terminal.viewport_y = 0
|
||||||
|
|
||||||
|
def sync_cursor_style(self, terminal):
|
||||||
|
"""Map DECSCUSR onto the view's caret settings, on transitions only."""
|
||||||
|
screen = terminal.screen
|
||||||
|
# 0 is the terminal default, which DEC and xterm define as a blinking block
|
||||||
|
style = screen.cursor_style or 1
|
||||||
|
hidden = screen.cursor_hidden()
|
||||||
|
if (style, hidden) == self._applied_cursor_style:
|
||||||
|
return
|
||||||
|
self._applied_cursor_style = (style, hidden)
|
||||||
|
settings = self.view.settings()
|
||||||
|
if hidden:
|
||||||
|
# the app draws its own cursor, but the caret has to stay for
|
||||||
|
# typing to work: a solid block sits on the same cell rather than
|
||||||
|
# blinking next to it
|
||||||
|
settings.set("block_caret", True)
|
||||||
|
settings.set("caret_style", "solid")
|
||||||
|
elif 1 <= style <= 6:
|
||||||
|
# ST has no underline caret: 3/4 fall back to bar
|
||||||
|
settings.set("block_caret", style <= 2)
|
||||||
|
settings.set("caret_style", "blink" if style % 2 else "solid")
|
||||||
|
else:
|
||||||
|
# unknown values: back to the user's caret preferences
|
||||||
|
settings.erase("block_caret")
|
||||||
|
settings.erase("caret_style")
|
||||||
|
|
||||||
|
def clear_view(self, edit, terminal, wipe_screen=False):
|
||||||
|
"""Empty the view and scrollback; `wipe_screen` also blanks the screen and asks the app to redraw"""
|
||||||
|
view = self.view
|
||||||
|
screen = terminal.screen
|
||||||
|
|
||||||
|
view.replace(edit, sublime.Region(0, view.size()), "")
|
||||||
|
for line in list(self.colored_lines):
|
||||||
|
self.decolorize_line(line)
|
||||||
|
for chunk, scopes in self._archived_chunks.items():
|
||||||
|
for scope in scopes:
|
||||||
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
|
self._archived_chunks.clear()
|
||||||
|
self._open_rows = []
|
||||||
|
self._open_chunk = None
|
||||||
|
self._trimmed = 0
|
||||||
|
self._row_cache.clear()
|
||||||
|
terminal.offset = 0
|
||||||
|
screen.history.clear()
|
||||||
|
if wipe_screen:
|
||||||
|
screen.wipe()
|
||||||
|
terminal.nudge_repaint()
|
||||||
|
screen.dirty.update(range(screen.lines))
|
||||||
|
terminal.viewport_y = 0
|
||||||
|
|
||||||
|
def update_lines(self, edit, terminal):
|
||||||
|
"""Write the scrollback that just arrived, then every dirty screen row.
|
||||||
|
|
||||||
|
Lines the emulator retired go in above `terminal.offset`, which advances
|
||||||
|
by that many rows; from there down is the live screen.
|
||||||
|
"""
|
||||||
|
screen = terminal.screen
|
||||||
|
columns = screen.columns
|
||||||
|
dirty_lines = sorted(screen.dirty)
|
||||||
|
# valid for this method only; the trim paths erase rows
|
||||||
|
self._lastrow = self.view.rowcol(self.view.size())[0]
|
||||||
|
if dirty_lines:
|
||||||
|
# history pops newest-first, so it fills the rows below `offset` backwards
|
||||||
|
history = screen.history
|
||||||
|
popped = len(history)
|
||||||
|
terminal.offset += popped
|
||||||
|
offset = terminal.offset
|
||||||
|
|
||||||
|
for line in range(popped):
|
||||||
|
buffer_line = history.pop()
|
||||||
|
lf = buffer_line[columns - 1].linefeed
|
||||||
|
self.update_line(edit, offset - line - 1, buffer_line, lf)
|
||||||
|
|
||||||
|
# after the loop: a pop edits an earlier point than the one before
|
||||||
|
# it, so spans measured mid-loop would be stale
|
||||||
|
self.archive_rows(range(offset - popped, offset))
|
||||||
|
|
||||||
|
for line in dirty_lines:
|
||||||
|
buffer_line = screen.buffer[line]
|
||||||
|
lf = buffer_line[columns - 1].linefeed
|
||||||
|
self.update_line(edit, line + offset, buffer_line, lf)
|
||||||
|
|
||||||
|
def update_line(self, edit, line, buffer_line, lf):
|
||||||
|
"""Rewrite one view row from a screen row, unless it is unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: absolute view row.
|
||||||
|
lf: the row ended in a real newline, not a soft wrap.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
segments = list(segment_buffer_line(buffer_line))
|
||||||
|
# pyte's dirty set is a repaint hint, not a diff: most dirty rows come
|
||||||
|
# back identical, and rewriting one costs an edit plus its region churn
|
||||||
|
rendered = (segments, lf)
|
||||||
|
if self._row_cache.get(line) == rendered:
|
||||||
|
return
|
||||||
|
self._row_cache[line] = rendered
|
||||||
|
|
||||||
|
by_scope = {}
|
||||||
|
needed = 0
|
||||||
|
for _, start, end, fg, bg, style in segments:
|
||||||
|
if fg == "default" and bg == "default" and not style:
|
||||||
|
continue
|
||||||
|
needed = max(needed, end)
|
||||||
|
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
|
||||||
|
|
||||||
|
text = "".join(s[0] for s in segments)
|
||||||
|
if lf:
|
||||||
|
text += CONTINUATION
|
||||||
|
text = text.rstrip()
|
||||||
|
if len(text) < needed:
|
||||||
|
# pad only to the last coloured run, in the same edit as the text
|
||||||
|
text += " " * (needed - len(text))
|
||||||
|
|
||||||
|
self.ensure_row(edit, line)
|
||||||
|
line_region = view.line(view.text_point(line, 0))
|
||||||
|
self.decolorize_line(line)
|
||||||
|
view.replace(edit, line_region, text)
|
||||||
|
# replacing a line cannot move its start
|
||||||
|
self.colorize_line(line, line_region.begin(), by_scope)
|
||||||
|
|
||||||
|
def colorize_line(self, line, line_start, by_scope):
|
||||||
|
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||||
|
view = self.view
|
||||||
|
keys = self.colored_lines.setdefault(line, []) if by_scope else None
|
||||||
|
for scope, spans in by_scope.items():
|
||||||
|
key = self.next_key()
|
||||||
|
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
def decolorize_line(self, line):
|
||||||
|
"""Drop a row's colour overlays before it is rewritten or erased."""
|
||||||
|
if line in self.colored_lines:
|
||||||
|
for key in self.colored_lines[line]:
|
||||||
|
self.view.erase_regions(key)
|
||||||
|
del self.colored_lines[line]
|
||||||
|
|
||||||
|
def archive_rows(self, rows):
|
||||||
|
"""Queue rows that left the screen for their chunk's merge."""
|
||||||
|
for row in rows:
|
||||||
|
if row not in self.colored_lines:
|
||||||
|
continue
|
||||||
|
chunk = (row + self._trimmed) // CHUNK
|
||||||
|
if chunk != self._open_chunk:
|
||||||
|
self.flush_chunk()
|
||||||
|
self._open_chunk = chunk
|
||||||
|
self._open_rows.append(row)
|
||||||
|
|
||||||
|
def flush_chunk(self):
|
||||||
|
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||||
|
|
||||||
|
A scrollback row is never repainted, so its per-row keys used to live
|
||||||
|
until trimmed: a long coloured session held ~15k region keys, and every
|
||||||
|
key is a separate pass in sublime's redraw. Merging bounds the count at
|
||||||
|
scopes x (scrollback / CHUNK).
|
||||||
|
|
||||||
|
The merge waits for the chunk to close, because add_regions replaces a
|
||||||
|
key's whole set: merging every frame re-sent everything accumulated so
|
||||||
|
far, which is quadratic in CHUNK when output arrives a few rows a frame.
|
||||||
|
Until then the rows keep their per-row keys, so the count is bounded by
|
||||||
|
scopes x chunks plus one chunk's worth.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
rows, chunk = self._open_rows, self._open_chunk
|
||||||
|
self._open_rows = []
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
grouped = {}
|
||||||
|
stale = []
|
||||||
|
for row in rows:
|
||||||
|
keys = self.colored_lines.pop(row, None)
|
||||||
|
if not keys:
|
||||||
|
continue
|
||||||
|
stale += keys
|
||||||
|
# the segments that were painted, straight from the row cache
|
||||||
|
segments = self._row_cache[row][0]
|
||||||
|
start = view.line(view.text_point(row, 0)).begin()
|
||||||
|
for _, a, b, fg, bg, style in segments:
|
||||||
|
if fg == "default" and bg == "default" and not style:
|
||||||
|
continue
|
||||||
|
scope = scheme_manager.scope(fg, bg, style)
|
||||||
|
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||||
|
|
||||||
|
scopes = self._archived_chunks.setdefault(chunk, set())
|
||||||
|
for scope, regions in grouped.items():
|
||||||
|
key = chunk_key(chunk, scope)
|
||||||
|
if scope in scopes:
|
||||||
|
# a trim flushed this chunk mid-way; keep what it already holds
|
||||||
|
regions = view.get_regions(key) + regions
|
||||||
|
view.add_regions(key, regions, scope)
|
||||||
|
scopes.add(scope)
|
||||||
|
for key in stale:
|
||||||
|
view.erase_regions(key)
|
||||||
|
|
||||||
|
def trim_trailing_spaces(self, edit, terminal):
|
||||||
|
"""Erase the blank tail below the cursor, and padding on the cursor row.
|
||||||
|
|
||||||
|
Without this the view keeps a full screen of blank rows below a short
|
||||||
|
prompt, and the scrollbar reflects them.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
screen = terminal.screen
|
||||||
|
cursor = screen.cursor
|
||||||
|
cursor_row = terminal.offset + cursor.y
|
||||||
|
lastrow = view.rowcol(view.size())[0]
|
||||||
|
row = lastrow
|
||||||
|
while row > cursor_row:
|
||||||
|
line_region = view.line(view.text_point(row, 0))
|
||||||
|
text = view.substr(line_region)
|
||||||
|
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
|
||||||
|
row = row - 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if row < lastrow:
|
||||||
|
# one erase for the whole blank tail; the row cache entries stay,
|
||||||
|
# so a still-blank row is not recreated and re-erased every tick
|
||||||
|
view.erase(edit, sublime.Region(view.text_point(row + 1, 0) - 1, view.size()))
|
||||||
|
if row == cursor_row:
|
||||||
|
line_region = view.line(view.text_point(row, 0))
|
||||||
|
text = view.substr(line_region)
|
||||||
|
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
||||||
|
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
||||||
|
view.erase(edit, trailing_region)
|
||||||
|
self._row_cache.pop(row, None)
|
||||||
|
|
||||||
|
def trim_history(self, edit, terminal):
|
||||||
|
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
||||||
|
view = self.view
|
||||||
|
screen = terminal.screen
|
||||||
|
lastrow = view.rowcol(view.size())[0]
|
||||||
|
n = terminal.scrollback_size
|
||||||
|
if lastrow + 1 > n:
|
||||||
|
# queued rows are about to be renumbered or erased: merge them first
|
||||||
|
self.flush_chunk()
|
||||||
|
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
||||||
|
for line in range(m):
|
||||||
|
self.decolorize_line(line)
|
||||||
|
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
|
||||||
|
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
|
||||||
|
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
||||||
|
view.erase(edit, top_region)
|
||||||
|
terminal.offset -= m
|
||||||
|
lastrow -= m
|
||||||
|
|
||||||
|
# chunks wholly above the cut go; the boundary chunk drops the
|
||||||
|
# regions the erase collapsed to empty
|
||||||
|
self._trimmed += m
|
||||||
|
cut = self._trimmed // CHUNK
|
||||||
|
for chunk in [c for c in self._archived_chunks if c < cut]:
|
||||||
|
for scope in self._archived_chunks.pop(chunk):
|
||||||
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
|
for scope in self._archived_chunks.get(cut, ()):
|
||||||
|
key = chunk_key(cut, scope)
|
||||||
|
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
||||||
|
|
||||||
|
if lastrow > terminal.offset + screen.lines:
|
||||||
|
tail_region = sublime.Region(view.text_point(terminal.offset + screen.lines, 0), view.size())
|
||||||
|
for line in view.lines(tail_region):
|
||||||
|
row = view.rowcol(line.begin())[0]
|
||||||
|
self.decolorize_line(row)
|
||||||
|
self._row_cache.pop(row, None)
|
||||||
|
view.erase(edit, tail_region)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_cursor(view, edit, terminal):
|
||||||
|
"""Put the caret where the terminal cursor is, keeping any user selection.
|
||||||
|
|
||||||
|
The caret is placed even when the app hides the cursor (CSI ?25l): sublime
|
||||||
|
drops a plain space keypress in a view that has no caret, and IME text has
|
||||||
|
no insertion point. sync_cursor_style turns it into a block there, so it
|
||||||
|
lands on the cell the app draws its own cursor over, not beside it.
|
||||||
|
"""
|
||||||
|
sel = view.sel()
|
||||||
|
# keep a user selection; typing collapses it (VtermKeypressCommand)
|
||||||
|
if any(not region.empty() for region in sel):
|
||||||
|
return
|
||||||
|
|
||||||
|
sel.clear()
|
||||||
|
|
||||||
|
screen = terminal.screen
|
||||||
|
cursor = screen.cursor
|
||||||
|
offset = terminal.offset
|
||||||
|
|
||||||
|
ensure_position(view, edit, cursor.y + offset)
|
||||||
|
|
||||||
|
line_region = view.line(view.text_point(cursor.y + offset, 0))
|
||||||
|
text = view.substr(line_region)
|
||||||
|
col = rev_wcwidth(text, cursor.x) + 1
|
||||||
|
|
||||||
|
ensure_position(view, edit, cursor.y + offset, col)
|
||||||
|
pt = view.text_point(cursor.y + offset, col)
|
||||||
|
|
||||||
|
sel.add(sublime.Region(pt, pt))
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_to_cursor(view, terminal):
|
||||||
|
"""Follow the output: pin the viewport to the bottom of the live screen."""
|
||||||
|
terminal._scroll_pending = False
|
||||||
|
# layout_extent is maintained incrementally; text_to_layout(size()) forced
|
||||||
|
# a whole-buffer layout on every call
|
||||||
|
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
|
||||||
|
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
|
||||||
|
y = max(offset_y, viewport_y)
|
||||||
|
terminal.viewport_y = y
|
||||||
|
view.set_viewport_position((0, y), False)
|
||||||
|
|
||||||
|
|
||||||
|
def request_scroll(view, terminal):
|
||||||
|
"""Coalesce follow-scrolls: at most one queued callback per terminal."""
|
||||||
|
if not terminal._scroll_pending:
|
||||||
|
terminal._scroll_pending = True
|
||||||
|
sublime.set_timeout(lambda: scroll_to_cursor(view, terminal))
|
||||||
|
|
||||||
|
|
||||||
|
class VtermShowCursorCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Places the caret at the terminal cursor. For callers outside a render,
|
||||||
|
which has no edit token of its own."""
|
||||||
|
|
||||||
|
def run(self, edit, scroll=True):
|
||||||
|
"""Args:
|
||||||
|
scroll: also follow the output down to the cursor.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
terminal = Terminal.from_id(view.id())
|
||||||
|
if not terminal:
|
||||||
|
return
|
||||||
|
focus_cursor(view, edit, terminal)
|
||||||
|
if scroll:
|
||||||
|
request_scroll(view, terminal)
|
||||||
268
vterm/scheme.py
Normal file
268
vterm/scheme.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
""" Colour scheme generated on demand: a region can only name a scope, and a scope's colours must already exist in the scheme """
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
|
||||||
|
from .emulator import ANSI_COLORS
|
||||||
|
from .utils import settings
|
||||||
|
from .themes import THEMES
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# written under Packages/User so it survives a package reinstall
|
||||||
|
SCHEME_DIR_NAME = "User/vterm"
|
||||||
|
# "hidden-" keeps the generated scheme out of the scheme picker
|
||||||
|
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
|
||||||
|
# the value terminal views get as their color_scheme setting
|
||||||
|
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
|
||||||
|
|
||||||
|
# each write makes sublime reload the scheme and restyle every view
|
||||||
|
WRITE_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
# palette used when no theme is selected, overridable via vterm.sublime-settings
|
||||||
|
DEFAULT_ANSI = {
|
||||||
|
"black": "#000000",
|
||||||
|
"red": "#cd3131",
|
||||||
|
"green": "#0dbc79",
|
||||||
|
"brown": "#e5e510",
|
||||||
|
"blue": "#2472c8",
|
||||||
|
"magenta": "#bc3fbc",
|
||||||
|
"cyan": "#11a8cd",
|
||||||
|
"white": "#e5e5e5",
|
||||||
|
"light_black": "#666666",
|
||||||
|
"light_red": "#f14c4c",
|
||||||
|
"light_green": "#23d18b",
|
||||||
|
"light_brown": "#f5f543",
|
||||||
|
"light_blue": "#3b8eea",
|
||||||
|
"light_magenta": "#d670d6",
|
||||||
|
"light_cyan": "#29b8db",
|
||||||
|
"light_white": "#ffffff",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def next_color(color_text):
|
||||||
|
"""Nudge a #rrggbb colour by one; add_regions inverts fg/bg when they match exactly"""
|
||||||
|
hex_value = int(color_text[5:7], 16)
|
||||||
|
if hex_value == 255:
|
||||||
|
return color_text[:5] + "fe"
|
||||||
|
else:
|
||||||
|
return color_text[:5] + "{:02x}".format(hex_value + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def blend(base, other, ratio):
|
||||||
|
"""Mix two #rrggbb colours"""
|
||||||
|
channels = []
|
||||||
|
for i in (1, 3, 5):
|
||||||
|
a = int(base[i : i + 2], 16)
|
||||||
|
b = int(other[i : i + 2], 16)
|
||||||
|
channels.append(round(a + (b - a) * ratio))
|
||||||
|
return "#{:02x}{:02x}{:02x}".format(*channels)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dark(color_text):
|
||||||
|
"""True when a #rrggbb colour is dark by perceived luminance."""
|
||||||
|
r, g, b = (int(color_text[i : i + 2], 16) for i in (1, 3, 5))
|
||||||
|
return 0.299 * r + 0.587 * g + 0.114 * b < 128
|
||||||
|
|
||||||
|
|
||||||
|
class SchemeManager:
|
||||||
|
"""Owns the generated scheme file and the set of known scopes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Start on the built-in palette; initialize() picks up the real one."""
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.rules = {}
|
||||||
|
# key -> scope name; the render thread reads it without the lock, so it
|
||||||
|
# must never outlive self.rules (cleared together in _build)
|
||||||
|
self._names = {}
|
||||||
|
self.foreground = "#ffffff"
|
||||||
|
self.background = "#000000"
|
||||||
|
self._editor_fg = "#ffffff"
|
||||||
|
self._editor_bg = "#000000"
|
||||||
|
self.ansi = dict(DEFAULT_ANSI)
|
||||||
|
self._write_pending = False
|
||||||
|
self._initialized = False
|
||||||
|
self._dir = None
|
||||||
|
self._host_view = None
|
||||||
|
self._listeners = []
|
||||||
|
|
||||||
|
def initialize(self, view):
|
||||||
|
"""Pick up the palette, seed the rules, write the file. Main thread only"""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
self._host_view = view
|
||||||
|
|
||||||
|
settings().add_on_change("vterm.theme", self.reload)
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def ensure_file(self):
|
||||||
|
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
|
||||||
|
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def add_listener(self, callback):
|
||||||
|
"""Called after every palette rebuild"""
|
||||||
|
self._listeners.append(callback)
|
||||||
|
|
||||||
|
def remove_listener(self, callback):
|
||||||
|
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
|
||||||
|
if callback in self._listeners:
|
||||||
|
self._listeners.remove(callback)
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
"""Rebuild every rule for a new palette; colours resolve at add time, so they cannot be appended"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Rules cannot be appended after a palette change: each one stores resolved
|
||||||
|
colours, so they all have to be rebuilt. Caller holds the lock.
|
||||||
|
"""
|
||||||
|
self._names = {}
|
||||||
|
self.rules = {}
|
||||||
|
self.ansi = dict(DEFAULT_ANSI)
|
||||||
|
|
||||||
|
theme = THEMES.get(settings().get("theme"))
|
||||||
|
|
||||||
|
if theme:
|
||||||
|
self.foreground = theme["foreground"]
|
||||||
|
self.background = theme["background"]
|
||||||
|
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
|
||||||
|
else:
|
||||||
|
# captured values outlive the view they came from
|
||||||
|
if self._host_view is not None and self._host_view.is_valid():
|
||||||
|
style = self._host_view.style()
|
||||||
|
self._editor_fg = style.get("foreground", self._editor_fg)
|
||||||
|
self._editor_bg = style.get("background", self._editor_bg)
|
||||||
|
self.foreground = self._editor_fg
|
||||||
|
self.background = self._editor_bg
|
||||||
|
|
||||||
|
# user overrides sit on top of whichever palette was chosen
|
||||||
|
user_ansi = settings().get("ansi_colors", {})
|
||||||
|
for name in ANSI_COLORS:
|
||||||
|
if name in user_ansi:
|
||||||
|
self.ansi[name] = user_ansi[name]
|
||||||
|
|
||||||
|
# seeded so the first frame does not reference scopes the file lacks
|
||||||
|
for fg in ANSI_COLORS:
|
||||||
|
self._add_rule(fg, "default", "")
|
||||||
|
self._add_rule(fg, "default", "b")
|
||||||
|
self._add_rule("default", "default", "b")
|
||||||
|
self._add_rule("reverse_default", "reverse_default", "")
|
||||||
|
|
||||||
|
self._write_file()
|
||||||
|
|
||||||
|
def is_dark(self):
|
||||||
|
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
|
||||||
|
return _is_dark(self.background)
|
||||||
|
|
||||||
|
def scope(self, fg, bg, style):
|
||||||
|
"""Scope for a colour combination, creating its rule if new. Thread-safe"""
|
||||||
|
key = (fg, bg, style)
|
||||||
|
# hot path: one GIL-atomic dict read, no lock, no formatting
|
||||||
|
name = self._names.get(key)
|
||||||
|
if name is not None:
|
||||||
|
return name
|
||||||
|
with self.lock:
|
||||||
|
if key not in self.rules:
|
||||||
|
self._add_rule(fg, bg, style)
|
||||||
|
if not self._write_pending:
|
||||||
|
self._write_pending = True
|
||||||
|
# a warm-up burst coalesces into one write, off the UI thread
|
||||||
|
sublime.set_timeout_async(self._flush, WRITE_DEBOUNCE_MS)
|
||||||
|
name = self._scope_name(fg, bg, style)
|
||||||
|
self._names[key] = name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _scope_name(self, fg, bg, style):
|
||||||
|
"""Scope for a colour combination, e.g. vterm.red.default.b"""
|
||||||
|
if style:
|
||||||
|
return "vterm.{}.{}.{}".format(fg, bg, style)
|
||||||
|
return "vterm.{}.{}".format(fg, bg)
|
||||||
|
|
||||||
|
def _resolve(self, color, for_bg):
|
||||||
|
"""Turn a pyte colour name into #rrggbb against the active palette."""
|
||||||
|
if color == "default":
|
||||||
|
return self.background if for_bg else self.foreground
|
||||||
|
if color == "reverse_default":
|
||||||
|
return self.foreground if for_bg else self.background
|
||||||
|
if color in self.ansi:
|
||||||
|
return self.ansi[color]
|
||||||
|
return "#" + color
|
||||||
|
|
||||||
|
def _add_rule(self, fg, bg, style):
|
||||||
|
"""Add the scheme rule for one colour combination. Caller holds the lock."""
|
||||||
|
fg_color = self._resolve(fg, for_bg=False)
|
||||||
|
bg_color = self._resolve(bg, for_bg=True)
|
||||||
|
if bg_color.lower() == self.background.lower():
|
||||||
|
bg_color = next_color(bg_color)
|
||||||
|
rule = {
|
||||||
|
"scope": self._scope_name(fg, bg, style),
|
||||||
|
"foreground": fg_color,
|
||||||
|
"background": bg_color,
|
||||||
|
}
|
||||||
|
font_style = []
|
||||||
|
if "b" in style:
|
||||||
|
font_style.append("bold")
|
||||||
|
if "i" in style:
|
||||||
|
font_style.append("italic")
|
||||||
|
if "u" in style:
|
||||||
|
font_style.append("underline")
|
||||||
|
if font_style:
|
||||||
|
rule["font_style"] = " ".join(font_style)
|
||||||
|
self.rules[(fg, bg, style)] = rule
|
||||||
|
|
||||||
|
def _directory(self):
|
||||||
|
"""Path of the generated scheme's directory, created on first use."""
|
||||||
|
if self._dir is None:
|
||||||
|
self._dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/"))
|
||||||
|
os.makedirs(self._dir, exist_ok=True)
|
||||||
|
return self._dir
|
||||||
|
|
||||||
|
def _flush(self):
|
||||||
|
"""Debounced write, on sublime's async thread."""
|
||||||
|
with self.lock:
|
||||||
|
self._write_pending = False
|
||||||
|
self._write_file()
|
||||||
|
|
||||||
|
def _write_file(self):
|
||||||
|
"""Serialize globals and every rule to the scheme file. Caller holds the lock."""
|
||||||
|
scheme = {
|
||||||
|
"name": "vterm",
|
||||||
|
"globals": {
|
||||||
|
"background": self.background,
|
||||||
|
"foreground": self.foreground,
|
||||||
|
"caret": self.foreground,
|
||||||
|
"block_caret": self.foreground,
|
||||||
|
"gutter": self.background,
|
||||||
|
"selection": blend(self.background, self.foreground, 0.3),
|
||||||
|
"selection_foreground": self.foreground,
|
||||||
|
"selection_border_width": "0",
|
||||||
|
"inactive_selection": blend(self.background, self.foreground, 0.15),
|
||||||
|
},
|
||||||
|
"rules": list(self.rules.values()),
|
||||||
|
}
|
||||||
|
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
# compact: sublime reparses the whole file on every write
|
||||||
|
json.dump(scheme, f, separators=(",", ":"))
|
||||||
|
LOG.debug("scheme written: rules=%s", len(self.rules))
|
||||||
|
|
||||||
|
|
||||||
|
scheme_manager = SchemeManager()
|
||||||
391
vterm/terminal.py
Normal file
391
vterm/terminal.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
""" Terminal lifecycle: pty process, emulator state, reader/renderer threads """
|
||||||
|
import codecs
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from queue import Empty, Queue
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
from ptyprocess import PtyProcess
|
||||||
|
|
||||||
|
from .emulator import VtScreen, VtStream
|
||||||
|
from .keys import get_key_code
|
||||||
|
from .scheme import scheme_manager
|
||||||
|
from .utils import intermission, responsive, settings
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# renderer tick, ~33 fps
|
||||||
|
RENDER_PERIOD = 0.03
|
||||||
|
|
||||||
|
# draw rate for a visible terminal the user is not typing in, ~15 fps
|
||||||
|
UNFOCUSED_RENDER_PERIOD = 0.075
|
||||||
|
|
||||||
|
# max chars parsed per tick, so a giant burst still renders intermediate frames
|
||||||
|
FEED_LIMIT = 256 * 1024
|
||||||
|
|
||||||
|
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||||
|
REPAINT_NUDGE_DELAY = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def view_size(view, default=None):
|
||||||
|
"""Terminal (rows, columns) that fit the view's viewport."""
|
||||||
|
pixel_width, pixel_height = view.viewport_extent()
|
||||||
|
pixel_per_line = view.line_height()
|
||||||
|
pixel_per_char = view.em_width()
|
||||||
|
|
||||||
|
if pixel_per_line == 0 or pixel_per_char == 0:
|
||||||
|
return default or (1, 1)
|
||||||
|
|
||||||
|
# -3 columns: scrollbar and padding fudge
|
||||||
|
nb_columns = max(int(pixel_width / pixel_per_char) - 3, 1)
|
||||||
|
nb_rows = max(int(pixel_height / pixel_per_line), 1)
|
||||||
|
|
||||||
|
if nb_columns == 1 and default:
|
||||||
|
return default
|
||||||
|
|
||||||
|
nb_columns = min(
|
||||||
|
max(nb_columns, settings().get("min_columns", 20)),
|
||||||
|
settings().get("max_columns", 500),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (nb_rows, nb_columns)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_env(overrides=None):
|
||||||
|
"""Environment for a vterm child process"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TERM"] = "xterm-256color"
|
||||||
|
env["COLORTERM"] = "truecolor"
|
||||||
|
env.pop("TERM_PROGRAM", None)
|
||||||
|
env.pop("TERM_PROGRAM_VERSION", None)
|
||||||
|
env.setdefault("LANG", "en_US.UTF-8")
|
||||||
|
env.update(overrides or {})
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalPtyProcess(PtyProcess):
|
||||||
|
"""PtyProcess that takes str; ptyprocess itself only writes bytes."""
|
||||||
|
|
||||||
|
def write(self, s):
|
||||||
|
"""Encode text and write it to the pty."""
|
||||||
|
b = s.encode("utf-8", "backslashreplace")
|
||||||
|
return super().write(b)
|
||||||
|
|
||||||
|
|
||||||
|
class Terminal:
|
||||||
|
"""One terminal: its pty, emulator and the threads that keep the view current.
|
||||||
|
|
||||||
|
Instances live in the class-level registry keyed by view id, which is why
|
||||||
|
the plugin never purges its submodules on reload — rebuilding the registry
|
||||||
|
would orphan every open tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_terminals = {}
|
||||||
|
|
||||||
|
def __init__(self, view):
|
||||||
|
"""State only; the pty and the threads come up in start()."""
|
||||||
|
self.view = view
|
||||||
|
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||||
|
self.alive = True
|
||||||
|
self.default_title = ""
|
||||||
|
self.offset = 0
|
||||||
|
self.viewport_y = 0
|
||||||
|
self.scrollback_size = 5000
|
||||||
|
self._hosted = True
|
||||||
|
self._hosted_checked = 0
|
||||||
|
self.focused = True
|
||||||
|
self._visible = True
|
||||||
|
self._visible_checked = 0
|
||||||
|
self._rendered = 0
|
||||||
|
self._cached_cursor = (0, 0, False, 0)
|
||||||
|
self.applied_alt_screen = False
|
||||||
|
self._strings = Queue()
|
||||||
|
self._pending_to_send_string = False
|
||||||
|
self._wheel_captured = False
|
||||||
|
self._scroll_pending = False
|
||||||
|
self.pending_clear = False
|
||||||
|
self.pending_clear_screen = False
|
||||||
|
# a UTF-8 sequence split across read chunks must not be dropped
|
||||||
|
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_id(cls, vid):
|
||||||
|
"""The terminal behind a view id, or None once it is gone."""
|
||||||
|
return cls._terminals.get(vid)
|
||||||
|
|
||||||
|
def is_hosted(self):
|
||||||
|
"""Window lookup, throttled per terminal (a shared @responsive closure would rate-limit all of them)"""
|
||||||
|
now = time.time()
|
||||||
|
if now - self._hosted_checked > 1:
|
||||||
|
self._hosted_checked = now
|
||||||
|
self._hosted = self.view.window() is not None
|
||||||
|
return self._hosted
|
||||||
|
|
||||||
|
def is_visible(self):
|
||||||
|
"""Whether this tab is the front one in its group, throttled like is_hosted.
|
||||||
|
|
||||||
|
Selecting another tab in a group that does not hold focus raises no
|
||||||
|
event for this view, so the flag has to be polled.
|
||||||
|
"""
|
||||||
|
if self.focused:
|
||||||
|
return True
|
||||||
|
now = time.time()
|
||||||
|
if now - self._visible_checked > 1:
|
||||||
|
self._visible_checked = now
|
||||||
|
window = self.view.window()
|
||||||
|
group = window.get_view_index(self.view)[0] if window else -1
|
||||||
|
self._visible = group >= 0 and window.active_view_in_group(group) == self.view
|
||||||
|
return self._visible
|
||||||
|
|
||||||
|
def is_render_due(self):
|
||||||
|
"""Whether an unfocused terminal may draw again.
|
||||||
|
|
||||||
|
A frame costs a command dispatch and a dozen UI round trips however
|
||||||
|
little changed, so a terminal the user is only watching gets them at a
|
||||||
|
lower rate than one they are typing in.
|
||||||
|
"""
|
||||||
|
if self.focused:
|
||||||
|
return True
|
||||||
|
now = time.time()
|
||||||
|
if now - self._rendered < UNFOCUSED_RENDER_PERIOD:
|
||||||
|
return False
|
||||||
|
self._rendered = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start(self, cmd, cwd=None, env=None, default_title=None):
|
||||||
|
"""Spawn the pty sized to the view, build the emulator, start the threads."""
|
||||||
|
view = self.view
|
||||||
|
Terminal._terminals[view.id()] = self
|
||||||
|
self.default_title = default_title
|
||||||
|
|
||||||
|
size = view_size(view, default=(40, 80))
|
||||||
|
LOG.debug("[start] cmd=%s cwd=%s size=%s", cmd, cwd, size)
|
||||||
|
|
||||||
|
self.process = TerminalPtyProcess.spawn(cmd, cwd=cwd, env=agent_env(env), dimensions=size)
|
||||||
|
# one source for the deque and the view row cap (alt screen zeroes the deque)
|
||||||
|
self.scrollback_size = settings().get("scrollback_history_size", 50000)
|
||||||
|
self.screen = VtScreen(
|
||||||
|
size[1],
|
||||||
|
size[0],
|
||||||
|
process=self.process,
|
||||||
|
history=self.scrollback_size,
|
||||||
|
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
||||||
|
clear_callback=self._clear_callback,
|
||||||
|
clipboard_callback=self._clipboard_callback,
|
||||||
|
fg_color=scheme_manager.foreground.lstrip("#"),
|
||||||
|
bg_color=scheme_manager.background.lstrip("#"),
|
||||||
|
is_dark=scheme_manager.is_dark(),
|
||||||
|
)
|
||||||
|
self.stream = VtStream(self.screen)
|
||||||
|
scheme_manager.add_listener(self.apply_scheme)
|
||||||
|
|
||||||
|
self._start_rendering()
|
||||||
|
|
||||||
|
def apply_scheme(self):
|
||||||
|
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
|
||||||
|
# the screen answers OSC 10/11 and DSR 996 from these values
|
||||||
|
screen = self.screen
|
||||||
|
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
|
||||||
|
screen.default_bg_color = scheme_manager.background.lstrip("#")
|
||||||
|
screen.theme_is_dark = scheme_manager.is_dark()
|
||||||
|
if screen.theme_notify_enabled():
|
||||||
|
self.send_string("\x1b[?997;{}n".format(1 if screen.theme_is_dark else 2), normalized=False)
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
"""Terminate the child and drop this terminal from the registry."""
|
||||||
|
LOG.debug("[kill] terminating process")
|
||||||
|
self.alive = False
|
||||||
|
scheme_manager.remove_listener(self.apply_scheme)
|
||||||
|
try:
|
||||||
|
self.process.terminate()
|
||||||
|
except Exception:
|
||||||
|
LOG.warning("terminate failed", exc_info=True)
|
||||||
|
vid = self.view.id()
|
||||||
|
if vid in Terminal._terminals:
|
||||||
|
del Terminal._terminals[vid]
|
||||||
|
|
||||||
|
def _clear_callback(self):
|
||||||
|
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
||||||
|
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):
|
||||||
|
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
||||||
|
screen = self.screen
|
||||||
|
if self.pending_clear or self.pending_clear_screen:
|
||||||
|
# a pending clear must render even when the app is idle
|
||||||
|
return True
|
||||||
|
cursor = (screen.cursor.x, screen.cursor.y, screen.cursor_hidden(), screen.cursor_style)
|
||||||
|
if screen.dirty or cursor != self._cached_cursor:
|
||||||
|
self._cached_cursor = cursor
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _start_rendering(self):
|
||||||
|
"""Start the reader and renderer threads that drive this terminal."""
|
||||||
|
# deque appends/poplefts are atomic, so the reader never blocks behind
|
||||||
|
# a render; a lock here used to backpressure the pty for whole frames
|
||||||
|
chunks = deque()
|
||||||
|
done = [False]
|
||||||
|
|
||||||
|
@responsive(period=1, default=False)
|
||||||
|
def was_resized():
|
||||||
|
"""Whether the viewport no longer matches the screen. Costs sublime API calls, so once a second."""
|
||||||
|
size = view_size(self.view)
|
||||||
|
return self.screen.lines != size[0] or self.screen.columns != size[1]
|
||||||
|
|
||||||
|
def reader():
|
||||||
|
"""Drain the pty into `chunks` until EOF or the view goes away."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# ptyprocess buffers 8K; smaller reads double the syscalls
|
||||||
|
raw = self.process.read(8192)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
chunks.append(self._decoder.decode(raw))
|
||||||
|
if done[0] or not self.is_hosted():
|
||||||
|
LOG.debug("[reader] stops")
|
||||||
|
break
|
||||||
|
self.alive = False
|
||||||
|
done[0] = True
|
||||||
|
|
||||||
|
def feed_data():
|
||||||
|
"""Parse up to FEED_LIMIT of pending output into the screen."""
|
||||||
|
parts = []
|
||||||
|
size = 0
|
||||||
|
while chunks and size < FEED_LIMIT:
|
||||||
|
part = chunks.popleft()
|
||||||
|
parts.append(part)
|
||||||
|
size += len(part)
|
||||||
|
if parts:
|
||||||
|
try:
|
||||||
|
self.stream.feed("".join(parts))
|
||||||
|
except Exception:
|
||||||
|
# pyte resets its parser before re-raising, so the terminal
|
||||||
|
# can carry on; letting this escape would kill the thread
|
||||||
|
LOG.exception("stream.feed failed, dropping the rest of a chunk")
|
||||||
|
|
||||||
|
def renderer():
|
||||||
|
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
||||||
|
while True:
|
||||||
|
# a hidden tab is not drawn at all: the screen keeps the state
|
||||||
|
# and one render on unhide catches the view up. The tick stays at
|
||||||
|
# full rate either way, or the parser would fall behind the pty
|
||||||
|
visible = self.is_visible()
|
||||||
|
with intermission(period=RENDER_PERIOD):
|
||||||
|
feed_data()
|
||||||
|
if self.screen.in_sync_frame():
|
||||||
|
# mid ?2026 frame: wait for the closing sequence
|
||||||
|
continue
|
||||||
|
if visible:
|
||||||
|
if was_resized():
|
||||||
|
self.handle_resize()
|
||||||
|
self.view.run_command("vterm_show_cursor")
|
||||||
|
# due first: _need_to_render consumes the cursor move it reports
|
||||||
|
if self.is_render_due() and self._need_to_render():
|
||||||
|
self.view.run_command("vterm_render")
|
||||||
|
self._sync_wheel_flag()
|
||||||
|
if done[0] or not self.is_hosted():
|
||||||
|
LOG.debug("[renderer] stops")
|
||||||
|
break
|
||||||
|
|
||||||
|
while chunks:
|
||||||
|
feed_data()
|
||||||
|
done[0] = True
|
||||||
|
sublime.set_timeout(lambda: self.view.run_command("vterm_cleanup"))
|
||||||
|
|
||||||
|
threading.Thread(target=reader, daemon=True).start()
|
||||||
|
threading.Thread(target=renderer, daemon=True).start()
|
||||||
|
|
||||||
|
def handle_resize(self):
|
||||||
|
"""Match the pty and the screen to the view, which signals SIGWINCH to the app."""
|
||||||
|
size = view_size(self.view)
|
||||||
|
LOG.debug("[resize] %sx%s -> %sx%s", self.screen.lines, self.screen.columns, size[0], size[1])
|
||||||
|
try:
|
||||||
|
self.process.setwinsize(*size)
|
||||||
|
self.screen.resize(*size)
|
||||||
|
except OSError:
|
||||||
|
# pty already gone; cleanup follows via the reader thread
|
||||||
|
LOG.warning("resize failed", exc_info=True)
|
||||||
|
|
||||||
|
def nudge_repaint(self):
|
||||||
|
"""Make the app repaint by briefly narrowing the pty. Never write ctrl+L instead: it is /clear in Claude Code"""
|
||||||
|
rows, columns = self.screen.lines, self.screen.columns
|
||||||
|
self._set_winsize(rows, max(columns - 1, 1))
|
||||||
|
sublime.set_timeout(lambda: self._set_winsize(rows, columns), int(REPAINT_NUDGE_DELAY * 1000))
|
||||||
|
|
||||||
|
def _set_winsize(self, rows, columns):
|
||||||
|
"""Resize the pty alone, leaving the screen as it is."""
|
||||||
|
try:
|
||||||
|
self.process.setwinsize(rows, columns)
|
||||||
|
except OSError:
|
||||||
|
LOG.warning("setwinsize failed", exc_info=True)
|
||||||
|
|
||||||
|
def send_key(self, **kwargs):
|
||||||
|
"""Encode a key name plus modifiers for the app's current modes and send it."""
|
||||||
|
kwargs["application_mode"] = (1 << 5) in self.screen.mode
|
||||||
|
kwargs["new_line_mode"] = 20 in self.screen.mode
|
||||||
|
self.send_string(get_key_code(**kwargs), normalized=False)
|
||||||
|
|
||||||
|
def send_string(self, string, normalized=True):
|
||||||
|
"""Write text to the pty, chunking anything large.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
normalized: canonicalise line endings for the app's LNM setting;
|
||||||
|
off for escape sequences, which must go through untouched.
|
||||||
|
"""
|
||||||
|
if normalized:
|
||||||
|
string = string.replace("\r\n", "\n")
|
||||||
|
if 20 in self.screen.mode:
|
||||||
|
string = string.replace("\n", "\r\n")
|
||||||
|
else:
|
||||||
|
string = string.replace("\n", "\r")
|
||||||
|
|
||||||
|
# chunked so a big paste cannot overrun the pty buffer; the blocking
|
||||||
|
# pty write is the backpressure, no pacing sleep needed
|
||||||
|
no_queue = not self._pending_to_send_string
|
||||||
|
if no_queue and len(string) <= 4096:
|
||||||
|
self.process.write(string)
|
||||||
|
else:
|
||||||
|
for i in range(0, len(string), 4096):
|
||||||
|
self._strings.put(string[i : i + 4096])
|
||||||
|
if no_queue:
|
||||||
|
self._pending_to_send_string = True
|
||||||
|
threading.Thread(target=self._process_send_string, daemon=True).start()
|
||||||
|
|
||||||
|
def _process_send_string(self):
|
||||||
|
"""Drain the queued paste chunks; the blocking pty write paces them."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# linger so a burst of sends does not respawn the thread
|
||||||
|
string = self._strings.get(timeout=0.2)
|
||||||
|
self.process.write(string)
|
||||||
|
except Empty:
|
||||||
|
self._pending_to_send_string = False
|
||||||
|
return
|
||||||
|
|
||||||
|
def _sync_wheel_flag(self):
|
||||||
|
"""Publish whether the app owns the wheel, for the mousemap context."""
|
||||||
|
# the mousemap routes the wheel to the app while it reports the mouse
|
||||||
|
# or owns the alt screen; VtermScrollCommand picks the encoding
|
||||||
|
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
|
||||||
|
if captured != self._wheel_captured:
|
||||||
|
self._wheel_captured = captured
|
||||||
|
self.view.settings().set("vterm_view.wheel_captured", captured)
|
||||||
|
|
||||||
|
def send_focus(self, focused):
|
||||||
|
"""Report focus gain or loss, when the app asked for it (?1004)."""
|
||||||
|
if self.screen.focus_tracking_enabled():
|
||||||
|
self.send_string("\x1b[I" if focused else "\x1b[O", normalized=False)
|
||||||
|
|
||||||
|
def send_mouse(self, button, col, row, pressed):
|
||||||
|
"""Emit an SGR mouse report (1-based col/row)."""
|
||||||
|
if not self.screen.mouse_reporting_enabled():
|
||||||
|
return
|
||||||
|
self.send_string("\x1b[<{};{};{}{}".format(button, col, row, "M" if pressed else "m"), normalized=False)
|
||||||
53
vterm/themes.py
Normal file
53
vterm/themes.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
""" Terminal colour palettes
|
||||||
|
|
||||||
|
The macOS Terminal.app classics, in the colour naming scheme.py already uses
|
||||||
|
(the names come from pyte). Selected with the `theme` setting; without one, the
|
||||||
|
terminal follows the active Sublime colour scheme instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# the 16 ANSI colours every Terminal.app theme below shares; only fg/bg differ
|
||||||
|
_TERMINAL_APP_ANSI = {
|
||||||
|
"black": "#000000",
|
||||||
|
"red": "#990000",
|
||||||
|
"green": "#00a600",
|
||||||
|
"brown": "#999900",
|
||||||
|
"blue": "#0000b2",
|
||||||
|
"magenta": "#b200b2",
|
||||||
|
"cyan": "#00a6b2",
|
||||||
|
"white": "#bfbfbf",
|
||||||
|
"light_black": "#666666",
|
||||||
|
"light_red": "#e50000",
|
||||||
|
"light_green": "#00d900",
|
||||||
|
"light_brown": "#e5e500",
|
||||||
|
"light_blue": "#0000ff",
|
||||||
|
"light_magenta": "#e500e5",
|
||||||
|
"light_cyan": "#00e5e5",
|
||||||
|
"light_white": "#e5e5e5",
|
||||||
|
}
|
||||||
|
|
||||||
|
# selectable palettes, by the name the "theme" setting takes
|
||||||
|
THEMES = {
|
||||||
|
"default": dict(_TERMINAL_APP_ANSI, foreground="#000000", background="#ffffff"),
|
||||||
|
"homebrew": dict(_TERMINAL_APP_ANSI, foreground="#00ff00", background="#000000"),
|
||||||
|
"pro": dict(_TERMINAL_APP_ANSI, foreground="#f2f2f2", background="#000000", blue="#2009db"),
|
||||||
|
"slate": {
|
||||||
|
"foreground": "#35b1d2",
|
||||||
|
"background": "#222222",
|
||||||
|
"black": "#222222",
|
||||||
|
"red": "#e2a8bf",
|
||||||
|
"green": "#81d778",
|
||||||
|
"brown": "#c4c9c0",
|
||||||
|
"blue": "#264b49",
|
||||||
|
"magenta": "#a481d3",
|
||||||
|
"cyan": "#15ab9c",
|
||||||
|
"white": "#02c5e0",
|
||||||
|
"light_black": "#ffffff",
|
||||||
|
"light_red": "#ffcdd9",
|
||||||
|
"light_green": "#beffa8",
|
||||||
|
"light_brown": "#d0ccca",
|
||||||
|
"light_blue": "#7ab0d2",
|
||||||
|
"light_magenta": "#c5a7d9",
|
||||||
|
"light_cyan": "#8cdfe0",
|
||||||
|
"light_white": "#e0e0e0",
|
||||||
|
},
|
||||||
|
}
|
||||||
72
vterm/utils.py
Normal file
72
vterm/utils.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
""" Small helpers shared across vterm modules """
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
from pyte.screens import wcwidth
|
||||||
|
|
||||||
|
# the plugin's own settings file, loaded once by settings()
|
||||||
|
SETTINGS_FILE = "vterm.sublime-settings"
|
||||||
|
|
||||||
|
_settings = None
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
"""Cached settings handle; a sublime Settings object stays live"""
|
||||||
|
global _settings
|
||||||
|
if _settings is None:
|
||||||
|
_settings = sublime.load_settings(SETTINGS_FILE)
|
||||||
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
def responsive(period=0.1, default=True):
|
||||||
|
"""Rate-limit a function; return `default` between invocations."""
|
||||||
|
|
||||||
|
def wrapper(f):
|
||||||
|
# a list so the inner closure can mutate it without `nonlocal`
|
||||||
|
t = [0]
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def _(*args, **kwargs):
|
||||||
|
now = time.time()
|
||||||
|
if now - t[0] > period:
|
||||||
|
t[0] = now
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
return _
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def intermission(period=0.1):
|
||||||
|
"""Sleep out the remainder of `period` seconds after the body ran."""
|
||||||
|
startt = time.time()
|
||||||
|
yield
|
||||||
|
deltat = time.time() - startt
|
||||||
|
if deltat < period:
|
||||||
|
time.sleep(period - deltat)
|
||||||
|
|
||||||
|
|
||||||
|
def rev_wcwidth(text, width):
|
||||||
|
"""Return the index into `text` at which the substring reaches `width` cells.
|
||||||
|
|
||||||
|
The inverse of wcswidth: maps a terminal column back onto a character
|
||||||
|
index, so a wide char or a tab does not put the caret in the wrong place.
|
||||||
|
"""
|
||||||
|
if width == 0:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
w = 0
|
||||||
|
i = -1
|
||||||
|
for i, c in enumerate(text):
|
||||||
|
w += wcwidth(c)
|
||||||
|
if w >= width:
|
||||||
|
break
|
||||||
|
if w >= width:
|
||||||
|
return i
|
||||||
|
else:
|
||||||
|
return i + width - w
|
||||||
Reference in New Issue
Block a user