Compare commits
10 Commits
2a8fffb9c6
...
e150591763
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e150591763 | ||
|
|
27f1140737 | ||
|
|
d371d35fb3 | ||
|
|
119d4124f0 | ||
|
|
c8caecdc45 | ||
|
|
6e93ae2a5b | ||
|
|
f37da42a74 | ||
|
|
ca0d76cf5b | ||
|
|
01a88f7b19 | ||
|
|
28c9c398a1 |
@@ -123,6 +123,9 @@ child writes
|
|||||||
│ chunks.append(text) collections.deque, no lock
|
│ chunks.append(text) collections.deque, no lock
|
||||||
│
|
│
|
||||||
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
||||||
|
│ _wake.wait(IDLE_TICK) idle: park until the reader has output,
|
||||||
|
│ then SETTLE, so the writes of one app
|
||||||
|
│ update are drawn as a single frame
|
||||||
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
||||||
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
||||||
│ mutates buffer, fills screen.dirty
|
│ mutates buffer, fills screen.dirty
|
||||||
@@ -359,9 +362,11 @@ replaces both together.
|
|||||||
|
|
||||||
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
|
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
|
||||||
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
|
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
|
||||||
`scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
|
`scroll_modifier`, `wheel_lines`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
|
||||||
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
|
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
|
||||||
into a scroll of the view — a page, a row — instead of a key for the app. `terminal` and
|
into a scroll of the view — a page, a row — instead of a key for the app. `wheel_lines`
|
||||||
|
is how many scroll lines one wheel notch sends the app (3, as iTerm2 delivers per notch;
|
||||||
|
a trackpad converted to notches scrolls that much faster, so lower it). `terminal` and
|
||||||
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project
|
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project
|
||||||
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
|
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
|
||||||
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
|
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -9,17 +9,42 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Symlink this folder into Sublime packages as `vterm`:
|
### With Package Control
|
||||||
|
|
||||||
```sh
|
vterm is not on the default channel, so add its repository first. Open
|
||||||
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
**Preferences → Package Settings → Package Control → Settings** and put the URL
|
||||||
|
below in the `repositories` array of the right-hand (User) pane:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repositories": [
|
||||||
|
"https://raw.githubusercontent.com/vaclavt/vterm/main/repository.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared
|
Then run **Package Control: Install Package** from the Command Palette and pick
|
||||||
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was
|
**vterm**. Upgrades arrive through **Package Control: Upgrade Package** like any
|
||||||
never installed), run **Package Control: Satisfy Libraries** and restart Sublime.
|
other package.
|
||||||
Package Control garbage-collects libraries no package requires, so if imports
|
|
||||||
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
|
### From source
|
||||||
|
|
||||||
|
Clone the repository into the Sublime Text `Packages` directory as `vterm`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/vaclavt/vterm.git \
|
||||||
|
"$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use one method or the other, not both: an unpacked `Packages/vterm` directory
|
||||||
|
overrides the `vterm.sublime-package` that Package Control installs.
|
||||||
|
|
||||||
|
Either way, dependencies (pyte, ptyprocess, wcwidth) are Package Control
|
||||||
|
libraries declared in `dependencies.json`. If they are not yet in
|
||||||
|
`Lib/python38` (e.g. Terminus was never installed), run **Package Control:
|
||||||
|
Satisfy Libraries** and restart Sublime. Package Control garbage-collects
|
||||||
|
libraries no package requires, so if imports ever fail after uninstalling
|
||||||
|
another package, re-run *Satisfy Libraries*.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
@@ -56,7 +81,9 @@ wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
|
|||||||
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
|
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
|
||||||
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
||||||
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
||||||
for Sublime Text and it arrives as wheel notches like any other. While an app owns the
|
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
||||||
|
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
|
||||||
|
scrolls too far. While an app owns the
|
||||||
alternate screen the scrollback above it is folded away, kitty-style, and
|
alternate screen the scrollback above it is folded away, kitty-style, and
|
||||||
unfolds when the app leaves it.
|
unfolds when the app leaves it.
|
||||||
|
|
||||||
@@ -154,7 +181,8 @@ instead), image protocols, Windows, panels.
|
|||||||
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
|
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
|
||||||
plugin (sublime_text#1838), so set a utility such as
|
plugin (sublime_text#1838), so set a utility such as
|
||||||
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for
|
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for
|
||||||
Sublime Text. It then arrives as wheel notches and scrolls the app. Without
|
Sublime Text. It then arrives as wheel notches and scrolls the app, three
|
||||||
|
lines a notch unless `wheel_lines` says otherwise. Without
|
||||||
one, the trackpad does nothing in a full-screen app; the mouse wheel,
|
one, the trackpad does nothing in a full-screen app; the mouse wheel,
|
||||||
`shift+pageup/pagedown` and `shift+up/down` work regardless.
|
`shift+pageup/pagedown` and `shift+up/down` work regardless.
|
||||||
|
|
||||||
@@ -167,6 +195,8 @@ python3 tests/test_render.py # render layer on a fake view: trimming, select
|
|||||||
python3 tests/test_commands.py # theme picker, live restyle, generated scheme
|
python3 tests/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 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/gen_keymap.py # regenerate Default.sublime-keymap
|
||||||
|
python3 benchmarks/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
|
||||||
|
python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-benches of draw
|
||||||
```
|
```
|
||||||
|
|
||||||
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
||||||
|
|||||||
438
benchmarks/bench_vs_terminus.py
Normal file
438
benchmarks/bench_vs_terminus.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
""" Render-cost comparison between vterm and Terminus
|
||||||
|
|
||||||
|
Both plugins turn pty output into view text plus add_regions overlays, so what
|
||||||
|
they cost the editor is the number of Sublime calls a frame makes -- the plugin
|
||||||
|
host is a separate process (see Architecture.md). This drives both render
|
||||||
|
commands against a fake view that counts every call, on the same input, with
|
||||||
|
each plugin's own render gate deciding when a frame happens.
|
||||||
|
|
||||||
|
Terminus is read straight out of its .sublime-package by zipimport; it is
|
||||||
|
skipped when not installed. Parse times are real, call counts stand in for the
|
||||||
|
cross-process cost, which a fake view cannot show.
|
||||||
|
|
||||||
|
Run with: python3 benchmarks/bench_vs_terminus.py
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "tests"))
|
||||||
|
import _bootstrap # noqa: F401 sets sys.path for the imports below
|
||||||
|
|
||||||
|
TERMINUS = os.path.expanduser(
|
||||||
|
"~/Library/Application Support/Sublime Text/Installed Packages/Terminus.sublime-package")
|
||||||
|
|
||||||
|
import sublime
|
||||||
|
|
||||||
|
ROWS, COLUMNS = 40, 100
|
||||||
|
SCROLLBACK = 10000
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSelection(list):
|
||||||
|
def clear(self):
|
||||||
|
del self[:]
|
||||||
|
|
||||||
|
def add(self, region):
|
||||||
|
self.append(region)
|
||||||
|
|
||||||
|
|
||||||
|
class CountingView:
|
||||||
|
"""A view that answers with real line arithmetic and tallies every call."""
|
||||||
|
|
||||||
|
_ids = [0]
|
||||||
|
|
||||||
|
def __init__(self, counter):
|
||||||
|
CountingView._ids[0] += 1
|
||||||
|
self._id = CountingView._ids[0]
|
||||||
|
self.c = counter
|
||||||
|
self.text = ""
|
||||||
|
self.selection = FakeSelection()
|
||||||
|
self.regions = {}
|
||||||
|
self.folds = []
|
||||||
|
self._name = ""
|
||||||
|
self._settings = sublime.Settings()
|
||||||
|
# so a plugin that renders through a second command has it counted too
|
||||||
|
self.commands = {}
|
||||||
|
|
||||||
|
def _t(self, name):
|
||||||
|
self.c[name] += 1
|
||||||
|
|
||||||
|
def id(self):
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def size(self):
|
||||||
|
self._t("size")
|
||||||
|
return len(self.text)
|
||||||
|
|
||||||
|
def rowcol(self, point):
|
||||||
|
self._t("rowcol")
|
||||||
|
head = self.text[:point]
|
||||||
|
return (head.count("\n"), point - (head.rfind("\n") + 1))
|
||||||
|
|
||||||
|
def text_point(self, row, col):
|
||||||
|
self._t("text_point")
|
||||||
|
lines = self.text.split("\n")
|
||||||
|
start = sum(len(l) + 1 for l in lines[:row])
|
||||||
|
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
||||||
|
|
||||||
|
def _line(self, point):
|
||||||
|
start = self.text.rfind("\n", 0, point) + 1
|
||||||
|
end = self.text.find("\n", point)
|
||||||
|
return sublime.Region(start, len(self.text) if end == -1 else end)
|
||||||
|
|
||||||
|
def line(self, point):
|
||||||
|
self._t("line")
|
||||||
|
return self._line(point)
|
||||||
|
|
||||||
|
def full_line(self, point):
|
||||||
|
self._t("full_line")
|
||||||
|
region = self._line(point)
|
||||||
|
return sublime.Region(region.begin(), min(region.end() + 1, len(self.text)))
|
||||||
|
|
||||||
|
def lines(self, region):
|
||||||
|
self._t("lines")
|
||||||
|
out = []
|
||||||
|
point = region.begin()
|
||||||
|
while point <= region.end():
|
||||||
|
line = self._line(point)
|
||||||
|
out.append(line)
|
||||||
|
point = line.end() + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
def substr(self, region):
|
||||||
|
self._t("substr")
|
||||||
|
return self.text[region.begin():region.end()]
|
||||||
|
|
||||||
|
def erase(self, edit, region):
|
||||||
|
self._t("erase")
|
||||||
|
self.text = self.text[:region.begin()] + self.text[region.end():]
|
||||||
|
|
||||||
|
def replace(self, edit, region, value):
|
||||||
|
self._t("replace")
|
||||||
|
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
||||||
|
|
||||||
|
def insert(self, edit, point, value):
|
||||||
|
self._t("insert")
|
||||||
|
self.text = self.text[:point] + value + self.text[point:]
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
def fold(self, region):
|
||||||
|
self._t("fold")
|
||||||
|
self.folds.append((region.begin(), region.end()))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unfold(self, region):
|
||||||
|
self._t("unfold")
|
||||||
|
folded, self.folds = self.folds, []
|
||||||
|
return folded
|
||||||
|
|
||||||
|
def folded_regions(self):
|
||||||
|
self._t("folded_regions")
|
||||||
|
return [sublime.Region(a, b) for a, b in self.folds]
|
||||||
|
|
||||||
|
def add_regions(self, key, regions, scope="", *args, **kwargs):
|
||||||
|
self._t("add_regions")
|
||||||
|
# what crosses the process boundary is the regions, not just the call
|
||||||
|
self.c["regions.sent"] += len(regions)
|
||||||
|
self.c["regions.sent.merged" if "@" in key else "regions.sent.row"] += len(regions)
|
||||||
|
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
||||||
|
|
||||||
|
def get_regions(self, key):
|
||||||
|
self._t("get_regions")
|
||||||
|
_, spans = self.regions.get(key, ("", []))
|
||||||
|
self.c["regions.read"] += len(spans)
|
||||||
|
return [sublime.Region(a, b) for a, b in spans]
|
||||||
|
|
||||||
|
def erase_regions(self, key):
|
||||||
|
self._t("erase_regions")
|
||||||
|
self.regions.pop(key, None)
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
self._t("settings")
|
||||||
|
return CountingSettings(self._settings, self.c)
|
||||||
|
|
||||||
|
def viewport_position(self):
|
||||||
|
self._t("viewport_position")
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def viewport_extent(self):
|
||||||
|
self._t("viewport_extent")
|
||||||
|
return (800, ROWS * 20)
|
||||||
|
|
||||||
|
def layout_extent(self):
|
||||||
|
self._t("layout_extent")
|
||||||
|
return (800, len(self.text))
|
||||||
|
|
||||||
|
def text_to_layout(self, point):
|
||||||
|
self._t("text_to_layout")
|
||||||
|
return (0, point)
|
||||||
|
|
||||||
|
def set_viewport_position(self, position, animate=True):
|
||||||
|
self._t("set_viewport_position")
|
||||||
|
|
||||||
|
def line_height(self):
|
||||||
|
self._t("line_height")
|
||||||
|
return 20
|
||||||
|
|
||||||
|
def em_width(self):
|
||||||
|
self._t("em_width")
|
||||||
|
return 8
|
||||||
|
|
||||||
|
def name(self):
|
||||||
|
self._t("name")
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
def set_name(self, name):
|
||||||
|
self._t("set_name")
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def window(self):
|
||||||
|
self._t("window")
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def sel(self):
|
||||||
|
self._t("sel")
|
||||||
|
return self.selection
|
||||||
|
|
||||||
|
def run_command(self, name, args=None):
|
||||||
|
self._t("run_command:" + name)
|
||||||
|
command = self.commands.get(name)
|
||||||
|
if command:
|
||||||
|
command.run(None, **(args or {}))
|
||||||
|
|
||||||
|
|
||||||
|
class CountingSettings:
|
||||||
|
"""Settings reads and writes are round trips too, and Terminus does one per run."""
|
||||||
|
|
||||||
|
def __init__(self, settings, counter):
|
||||||
|
self._settings = settings
|
||||||
|
self._c = counter
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
self._c["settings.get"] += 1
|
||||||
|
return self._settings.get(key, default)
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self._c["settings.set"] += 1
|
||||||
|
self._settings[key] = value
|
||||||
|
|
||||||
|
def erase(self, key):
|
||||||
|
self._c["settings.erase"] += 1
|
||||||
|
self._settings.pop(key, None)
|
||||||
|
|
||||||
|
def has(self, key):
|
||||||
|
return key in self._settings
|
||||||
|
|
||||||
|
def add_on_change(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear_on_change(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.writes.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ workloads
|
||||||
|
|
||||||
|
def colour_flood(lines=2000, runs=6, palette=200):
|
||||||
|
"""Coloured output: `runs` SGR changes per line, drawn from `palette` colours."""
|
||||||
|
width = COLUMNS // runs
|
||||||
|
out = []
|
||||||
|
for i in range(lines):
|
||||||
|
parts = []
|
||||||
|
for r in range(runs):
|
||||||
|
parts.append("\x1b[38;5;{}m".format(16 + (i * runs + r) % palette))
|
||||||
|
parts.append(str((i + r) % 10) * width)
|
||||||
|
parts.append("\x1b[0m\r\n")
|
||||||
|
out.append("".join(parts))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def plain_flood(lines=2000):
|
||||||
|
return "".join("{:04d} ".format(i) + "x" * (COLUMNS - 5) + "\r\n" for i in range(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def tui_repaint(frames=60):
|
||||||
|
"""A full-screen coloured app redrawing itself; one cell differs per frame."""
|
||||||
|
out = []
|
||||||
|
for f in range(frames):
|
||||||
|
parts = ["\x1b[H"]
|
||||||
|
for row in range(ROWS):
|
||||||
|
for r in range(4):
|
||||||
|
parts.append("\x1b[38;5;{}m".format(16 + (row * 4 + r) % 200))
|
||||||
|
parts.append(str((row + r) % 10) * (COLUMNS // 4))
|
||||||
|
parts.append("\x1b[0m")
|
||||||
|
if row < ROWS - 1:
|
||||||
|
parts.append("\r\n")
|
||||||
|
parts.append("\x1b[1;1H\x1b[38;5;99m{:04d}\x1b[0m".format(f))
|
||||||
|
out.append("".join(parts))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- drivers
|
||||||
|
|
||||||
|
def drive(feed, need_to_render, render, data, tick):
|
||||||
|
"""Feed `data` in tick-sized reads, rendering whenever the plugin asks to."""
|
||||||
|
parse = 0.0
|
||||||
|
frames = 0
|
||||||
|
for i in range(0, len(data), tick):
|
||||||
|
start = time.perf_counter()
|
||||||
|
feed(data[i:i + tick])
|
||||||
|
parse += time.perf_counter() - start
|
||||||
|
if need_to_render():
|
||||||
|
render()
|
||||||
|
frames += 1
|
||||||
|
return parse, frames
|
||||||
|
|
||||||
|
|
||||||
|
def run_vterm(data, tick):
|
||||||
|
from vterm.emulator import VtScreen, VtStream
|
||||||
|
from vterm.render import VtermRenderCommand
|
||||||
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
|
counter = collections.Counter()
|
||||||
|
view = CountingView(counter)
|
||||||
|
terminal = Terminal(view)
|
||||||
|
terminal.scrollback_size = SCROLLBACK
|
||||||
|
terminal.process = FakeProcess()
|
||||||
|
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
||||||
|
Terminal._terminals[view.id()] = terminal
|
||||||
|
stream = VtStream(terminal.screen)
|
||||||
|
command = VtermRenderCommand(view)
|
||||||
|
|
||||||
|
def render():
|
||||||
|
counter["run_command:vterm_render"] += 1
|
||||||
|
command.run(None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
||||||
|
finally:
|
||||||
|
del Terminal._terminals[view.id()]
|
||||||
|
return counter, parse, frames, len(view.regions)
|
||||||
|
|
||||||
|
|
||||||
|
def run_terminus(data, tick):
|
||||||
|
from terminus.ptty import TerminalScreen, TerminalStream
|
||||||
|
from terminus.render import TerminusRenderCommand, TerminusShowCursorCommand
|
||||||
|
from terminus.terminal import Terminal
|
||||||
|
|
||||||
|
counter = collections.Counter()
|
||||||
|
view = CountingView(counter)
|
||||||
|
terminal = Terminal(view)
|
||||||
|
terminal.detached = False
|
||||||
|
terminal.offset = 0
|
||||||
|
terminal.title = None
|
||||||
|
terminal.default_title = "Terminus"
|
||||||
|
terminal.screen = TerminalScreen(
|
||||||
|
COLUMNS, ROWS, process=FakeProcess(), clear_callback=lambda: None,
|
||||||
|
reset_callback=lambda: None, history=SCROLLBACK)
|
||||||
|
Terminal._terminals[view.id()] = terminal
|
||||||
|
stream = TerminalStream(terminal.screen)
|
||||||
|
command = TerminusRenderCommand(view)
|
||||||
|
command.scrollback_history_size = SCROLLBACK
|
||||||
|
view.commands["terminus_show_cursor"] = TerminusShowCursorCommand(view)
|
||||||
|
|
||||||
|
def render():
|
||||||
|
counter["run_command:terminus_render"] += 1
|
||||||
|
command.run(None)
|
||||||
|
# terminus clears dirty in its render loop, not in the command
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
||||||
|
finally:
|
||||||
|
del Terminal._terminals[view.id()]
|
||||||
|
return counter, parse, frames, len(view.regions)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- report
|
||||||
|
|
||||||
|
def measure(runner, data, tick):
|
||||||
|
counter, parse, frames, keys = runner(data, tick)
|
||||||
|
calls = sum(v for k, v in counter.items() if not k.startswith("regions."))
|
||||||
|
return {
|
||||||
|
"parse": parse * 1000,
|
||||||
|
"frames": frames,
|
||||||
|
"calls": calls,
|
||||||
|
"per_frame": calls / max(frames, 1),
|
||||||
|
"sent": counter["regions.sent"],
|
||||||
|
"sent_row": counter["regions.sent.row"],
|
||||||
|
"sent_merged": counter["regions.sent.merged"],
|
||||||
|
"read": counter["regions.read"],
|
||||||
|
"keys": keys,
|
||||||
|
"counter": counter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ROW_FORMAT = " {:<26}{:>14}{:>14}{:>10}"
|
||||||
|
|
||||||
|
|
||||||
|
def report(title, data, tick, both):
|
||||||
|
print("=" * 66)
|
||||||
|
print("{}\n {:.0f} KiB fed in {} B reads".format(title, len(data) / 1024, tick))
|
||||||
|
print("=" * 66)
|
||||||
|
ours = measure(run_vterm, data, tick)
|
||||||
|
theirs = measure(run_terminus, data, tick) if both else None
|
||||||
|
|
||||||
|
def line(label, key, fmt="{:.0f}"):
|
||||||
|
mine = fmt.format(ours[key])
|
||||||
|
if not theirs:
|
||||||
|
print(ROW_FORMAT.format(label, mine, "-", "-"))
|
||||||
|
return
|
||||||
|
other = fmt.format(theirs[key])
|
||||||
|
ratio = theirs[key] / ours[key] if ours[key] else 0
|
||||||
|
print(ROW_FORMAT.format(label, mine, other, "{:.1f}x".format(ratio)))
|
||||||
|
|
||||||
|
print(ROW_FORMAT.format("", "vterm", "Terminus", "ratio"))
|
||||||
|
line("parse ms", "parse", "{:.1f}")
|
||||||
|
line("frames", "frames")
|
||||||
|
line("sublime calls", "calls")
|
||||||
|
line("calls / frame", "per_frame", "{:.1f}")
|
||||||
|
line("regions sent", "sent")
|
||||||
|
line(" of them merges", "sent_merged")
|
||||||
|
line("regions read back", "read")
|
||||||
|
line("live region keys", "keys")
|
||||||
|
top = sorted(((v, k) for k, v in ours["counter"].items() if not k.startswith("regions.")),
|
||||||
|
reverse=True)[:6]
|
||||||
|
print(" vterm top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
||||||
|
if theirs:
|
||||||
|
top = sorted(((v, k) for k, v in theirs["counter"].items() if not k.startswith("regions.")),
|
||||||
|
reverse=True)[:6]
|
||||||
|
print(" Terminus top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from vterm.terminal import FEED_LIMIT
|
||||||
|
|
||||||
|
both = os.path.exists(TERMINUS)
|
||||||
|
if both:
|
||||||
|
sys.path.insert(0, TERMINUS)
|
||||||
|
else:
|
||||||
|
print("Terminus is not installed; reporting vterm alone.\n")
|
||||||
|
|
||||||
|
burst = FEED_LIMIT # the most one renderer tick parses during a flood
|
||||||
|
trickle = 512 # a few rows a frame, as a build log arrives
|
||||||
|
|
||||||
|
report("A. colour flood, 6 runs/line, 200 colours",
|
||||||
|
colour_flood(), burst, both)
|
||||||
|
report("B. colour flood, 6 runs/line, 8 colours",
|
||||||
|
colour_flood(palette=8), burst, both)
|
||||||
|
report("C. colour trickle, a few rows a frame",
|
||||||
|
colour_flood(lines=600), trickle, both)
|
||||||
|
report("D. plain flood, no colour",
|
||||||
|
plain_flood(), burst, both)
|
||||||
|
report("E. TUI repaint, 60 full-screen frames, one cell changes",
|
||||||
|
tui_repaint(), burst, both)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
129
benchmarks/profiling.py
Normal file
129
benchmarks/profiling.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Profile the feed/parse path, then micro-bench the per-char steps of VtScreen.draw.
|
||||||
|
|
||||||
|
cProfile ranks the hotspots on the bench workloads; the micro loops put numbers
|
||||||
|
on the per-char candidates inside the draw path.
|
||||||
|
|
||||||
|
Run with: python3 benchmarks/profiling.py
|
||||||
|
"""
|
||||||
|
import cProfile
|
||||||
|
import os
|
||||||
|
import pstats
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "tests"))
|
||||||
|
import _bootstrap # noqa: F401 sets sys.path for the imports below
|
||||||
|
|
||||||
|
from bench_vs_terminus import colour_flood, plain_flood, tui_repaint # noqa: E402
|
||||||
|
from _bootstrap import FakeProcess # noqa: E402
|
||||||
|
from pyte.screens import wcwidth as pyte_wcwidth # noqa: E402 lru_cache'd
|
||||||
|
from wcwidth import wcwidth as raw_wcwidth # noqa: E402
|
||||||
|
|
||||||
|
from vterm.emulator import VtScreen, VtStream # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ feed hotspots
|
||||||
|
|
||||||
|
def build():
|
||||||
|
screen = VtScreen(100, 40, process=FakeProcess(), history=10000)
|
||||||
|
return VtStream(screen)
|
||||||
|
|
||||||
|
|
||||||
|
def run(name, data):
|
||||||
|
stream = build()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for i in range(0, len(data), 32768):
|
||||||
|
stream.feed(data[i:i + 32768])
|
||||||
|
dt = (time.perf_counter() - t0) * 1000
|
||||||
|
print("{:<20} {:.1f} ms".format(name, dt))
|
||||||
|
|
||||||
|
|
||||||
|
def profiled(name, data):
|
||||||
|
stream = build()
|
||||||
|
pr = cProfile.Profile()
|
||||||
|
pr.enable()
|
||||||
|
for i in range(0, len(data), 32768):
|
||||||
|
stream.feed(data[i:i + 32768])
|
||||||
|
pr.disable()
|
||||||
|
s = pstats.Stats(pr)
|
||||||
|
print("\n==== {} ====".format(name))
|
||||||
|
s.sort_stats("cumulative").print_stats(18)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- per-char micro-benches of draw
|
||||||
|
|
||||||
|
data = ("x" * 50 + "\x1b[38;5;100m") * 40000 # ~2M printable chars
|
||||||
|
|
||||||
|
|
||||||
|
def t(label, fn, n=3):
|
||||||
|
best = 1e9
|
||||||
|
for _ in range(n):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
fn()
|
||||||
|
best = min(best, time.perf_counter() - t0)
|
||||||
|
print("{:<42} {:8.3f} ms".format(label, best * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def loop_wcwidth_pyte():
|
||||||
|
for c in data:
|
||||||
|
w = pyte_wcwidth(c)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_wcwidth_raw():
|
||||||
|
for c in data:
|
||||||
|
w = raw_wcwidth(c)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_iter_only():
|
||||||
|
for c in data:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def loop_min():
|
||||||
|
x = 0
|
||||||
|
for c in data:
|
||||||
|
x = min(x + 1, 80)
|
||||||
|
|
||||||
|
|
||||||
|
def loop_dict_get_set():
|
||||||
|
d = {}
|
||||||
|
line = {}
|
||||||
|
for i, c in enumerate(data):
|
||||||
|
v = d.get(c)
|
||||||
|
if v is None:
|
||||||
|
d[c] = c
|
||||||
|
line[i % 80] = c
|
||||||
|
|
||||||
|
|
||||||
|
def loop_min_vs_cmp():
|
||||||
|
x = 0
|
||||||
|
cols = 80
|
||||||
|
for c in data:
|
||||||
|
nx = x + 1
|
||||||
|
x = cols if nx > cols else nx
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
floods = {
|
||||||
|
"colour": colour_flood(),
|
||||||
|
"plain": plain_flood(),
|
||||||
|
"tui": tui_repaint(),
|
||||||
|
}
|
||||||
|
for n, d in floods.items():
|
||||||
|
run(n, d)
|
||||||
|
profiled("colour", colour_flood())
|
||||||
|
profiled("tui", tui_repaint())
|
||||||
|
|
||||||
|
print("==== per-char micro-benches ====")
|
||||||
|
t("iter only", loop_iter_only)
|
||||||
|
t("wcwidth (pyte lru_cache)", loop_wcwidth_pyte)
|
||||||
|
t("wcwidth (raw)", loop_wcwidth_raw)
|
||||||
|
t("min(x+w, cols) per char", loop_min)
|
||||||
|
t("min -> branch per char", loop_min_vs_cmp)
|
||||||
|
t("dict get/miss/set per char", loop_dict_get_set)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
21
repository.json
Normal file
21
repository.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "sublime://packagecontrol.io/schemas/repository",
|
||||||
|
"schema_version": "4.0.0",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "vterm",
|
||||||
|
"details": "https://github.com/vaclavt/vterm",
|
||||||
|
"description": "Minimalist terminal for Sublime Text 4, built to run TUI coding agents (pi, claude) in a tab",
|
||||||
|
"author": "vaclavt",
|
||||||
|
"labels": ["terminal", "shell", "pty"],
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"sublime_text": ">=4149",
|
||||||
|
"platforms": ["osx"],
|
||||||
|
"python_versions": ["3.8"],
|
||||||
|
"tags": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -125,7 +125,9 @@ class FakeView:
|
|||||||
return self.viewport
|
return self.viewport
|
||||||
|
|
||||||
def set_viewport_position(self, xy, animate=True):
|
def set_viewport_position(self, xy, animate=True):
|
||||||
self.viewport = xy
|
# scroll_past_end is off, so sublime clamps to the layout
|
||||||
|
maxy = max(0, self.layout_extent()[1] - self.viewport_extent()[1])
|
||||||
|
self.viewport = (xy[0], max(0, min(xy[1], maxy)))
|
||||||
|
|
||||||
def line_height(self):
|
def line_height(self):
|
||||||
return LINE_HEIGHT
|
return LINE_HEIGHT
|
||||||
@@ -202,6 +204,24 @@ command.trim_history(None, terminal)
|
|||||||
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||||
terminal.view.text
|
terminal.view.text
|
||||||
|
|
||||||
|
# nor may its blank rows be trimmed: an app that blanks and refills its last
|
||||||
|
# row (vim's showcmd corner) would resize the view under a pinned viewport
|
||||||
|
terminal, stream = make_terminal(rows=4)
|
||||||
|
stream.feed("\x1b[?1049h")
|
||||||
|
terminal.view = FakeView("top\nrow\n\n")
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
before = terminal.view.text
|
||||||
|
command.trim_trailing_spaces(None, terminal)
|
||||||
|
assert terminal.view.text == before, \
|
||||||
|
"alt screen blank tail was trimmed: {!r}".format(terminal.view.text)
|
||||||
|
|
||||||
|
# on the primary screen the blank tail below the cursor still goes
|
||||||
|
terminal, stream = make_terminal(rows=4)
|
||||||
|
terminal.view = FakeView("top\nrow\n\n")
|
||||||
|
command = make_command(terminal.view)
|
||||||
|
command.trim_trailing_spaces(None, terminal)
|
||||||
|
assert terminal.view.text == "top\nrow", terminal.view.text
|
||||||
|
|
||||||
print("render tests passed (alt screen keeps its view, cap honoured)")
|
print("render tests passed (alt screen keeps its view, cap honoured)")
|
||||||
|
|
||||||
|
|
||||||
@@ -522,6 +542,23 @@ plugin_settings.set("scroll_modifier", "shift")
|
|||||||
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
|
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
|
||||||
|
|
||||||
|
|
||||||
|
# an app that repaints its block shorter than the screen (pi on every SIGWINCH)
|
||||||
|
# leaves the live screen shorter than the viewport once the blank tail is
|
||||||
|
# trimmed. the anchor must stay where sublime can actually put the viewport, or
|
||||||
|
# the follow gate closes for good and the output is never followed again
|
||||||
|
short, _ = make_terminal(rows=8, cols=20)
|
||||||
|
short.view.viewport_rows = short.screen.lines
|
||||||
|
# 24 scrollback rows, then a live screen holding 3 rows instead of 8
|
||||||
|
short.view.text = "\n".join("row {}".format(i) for i in range(27))
|
||||||
|
short.offset = 24
|
||||||
|
scroll_to_cursor(short.view, short)
|
||||||
|
assert short.viewport_y == short.view.viewport_position()[1], \
|
||||||
|
(short.viewport_y, short.view.viewport_position()[1])
|
||||||
|
assert following(short.view, short), "follow gate latched shut on a short screen"
|
||||||
|
|
||||||
|
print("short screen tests passed (the anchor stays reachable, so the view keeps following)")
|
||||||
|
|
||||||
|
|
||||||
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
||||||
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
||||||
# bare ESC CR reads as alt+enter
|
# bare ESC CR reads as alt+enter
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
// null sends the keys to the app
|
// null sends the keys to the app
|
||||||
"scroll_modifier": "shift",
|
"scroll_modifier": "shift",
|
||||||
|
|
||||||
|
// scroll lines sent to the app per wheel notch (iTerm2 sends 3).
|
||||||
|
// A trackpad via LinearMouse makes many notches per swipe; 1 is calmer.
|
||||||
|
"wheel_lines": 1,
|
||||||
|
|
||||||
"min_columns": 20,
|
"min_columns": 20,
|
||||||
"max_columns": 500,
|
"max_columns": 500,
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
|
|||||||
return
|
return
|
||||||
# the renderer thread owns the screen; it applies this on its next tick
|
# the renderer thread owns the screen; it applies this on its next tick
|
||||||
terminal.pending_clear_screen = True
|
terminal.pending_clear_screen = True
|
||||||
|
terminal.wake()
|
||||||
self.view.run_command("vterm_clear_undo_stack")
|
self.view.run_command("vterm_clear_undo_stack")
|
||||||
|
|
||||||
def is_enabled(self):
|
def is_enabled(self):
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ def quantize_256(r, g, b):
|
|||||||
return 16 + 36 * level(r) + 6 * level(g) + level(b)
|
return 16 + 36 * level(r) + 6 * level(g) + level(b)
|
||||||
|
|
||||||
|
|
||||||
|
# a run of these is width-1 cells only: no wcwidth, no wide/combining cases
|
||||||
|
PLAIN_RUN = re.compile("[ -~]+")
|
||||||
|
|
||||||
# linefeed marks an autowrap (soft wrap), so copy can re-join the halves
|
# linefeed marks an autowrap (soft wrap), so copy can re-join the halves
|
||||||
Char = namedtuple(
|
Char = namedtuple(
|
||||||
"Char",
|
"Char",
|
||||||
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
|
|||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
||||||
self._sgr_cache = {}
|
self._sgr_cache = {}
|
||||||
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run)
|
# one char -> Char cache per attribute set; attrs recur constantly in
|
||||||
|
# coloured output, so the caches are kept rather than rebuilt on a change
|
||||||
|
self._char_caches = {}
|
||||||
self._char_cache = {}
|
self._char_cache = {}
|
||||||
self._cache_attrs = None
|
self._cache_attrs = None
|
||||||
self.history = deque(maxlen=history)
|
self.history = deque(maxlen=history)
|
||||||
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
|
|||||||
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
self._sgr_cache.clear()
|
self._sgr_cache.clear()
|
||||||
|
self._char_caches.clear()
|
||||||
|
self._cache_attrs = None
|
||||||
super().reset()
|
super().reset()
|
||||||
self.cursor = Cursor(0, 0)
|
self.cursor = Cursor(0, 0)
|
||||||
self.cursor_style = 0
|
self.cursor_style = 0
|
||||||
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
|
|||||||
cursor = self.cursor
|
cursor = self.cursor
|
||||||
buffer = self.buffer
|
buffer = self.buffer
|
||||||
columns = self.columns
|
columns = self.columns
|
||||||
# per-run cache: cells differ only by their data while attrs are unchanged
|
attrs = cursor.attrs
|
||||||
if cursor.attrs is not self._cache_attrs:
|
# cells differ only by their data within one attribute set; the cache is
|
||||||
self._cache_attrs = cursor.attrs
|
# picked per set, not discarded, or colour changes would thrash it
|
||||||
self._char_cache = {}
|
if attrs is not self._cache_attrs:
|
||||||
|
self._cache_attrs = attrs
|
||||||
|
cache = self._char_caches.get(attrs)
|
||||||
|
if cache is None:
|
||||||
|
if len(self._char_caches) > 256:
|
||||||
|
self._char_caches.clear()
|
||||||
|
cache = self._char_caches[attrs] = {}
|
||||||
|
self._char_cache = cache
|
||||||
cache = self._char_cache
|
cache = self._char_cache
|
||||||
|
|
||||||
|
if autowrap and not insert_mode and PLAIN_RUN.fullmatch(data):
|
||||||
|
# every cell is width 1, so wraps are pure arithmetic: fill whole
|
||||||
|
# rows with C-speed dict.update instead of looping per char
|
||||||
|
for c in set(data):
|
||||||
|
if c not in cache:
|
||||||
|
cache[c] = attrs._replace(data=c)
|
||||||
|
get = cache.__getitem__
|
||||||
|
dirty = self.dirty
|
||||||
|
pos = 0
|
||||||
|
length = len(data)
|
||||||
|
while pos < length:
|
||||||
|
x = cursor.x
|
||||||
|
if x == columns:
|
||||||
|
line = buffer[cursor.y]
|
||||||
|
last = line[columns - 1]
|
||||||
|
line[columns - 1] = last._replace(linefeed=True)
|
||||||
|
dirty.add(cursor.y)
|
||||||
|
self.carriage_return()
|
||||||
|
self.linefeed()
|
||||||
|
x = 0
|
||||||
|
line = buffer[cursor.y]
|
||||||
|
take = min(columns - x, length - pos)
|
||||||
|
end = pos + take
|
||||||
|
line.update(zip(range(x, x + take), map(get, data[pos:end])))
|
||||||
|
dirty.add(cursor.y)
|
||||||
|
cursor.x = x + take
|
||||||
|
pos = end
|
||||||
|
return
|
||||||
|
|
||||||
line = buffer[cursor.y]
|
line = buffer[cursor.y]
|
||||||
|
|
||||||
for char in data:
|
for char in data:
|
||||||
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
|
|||||||
self.insert_characters(char_width)
|
self.insert_characters(char_width)
|
||||||
cell = cache.get(char)
|
cell = cache.get(char)
|
||||||
if cell is None:
|
if cell is None:
|
||||||
cell = cache[char] = cursor.attrs._replace(data=char)
|
cell = cache[char] = attrs._replace(data=char)
|
||||||
line[cursor.x] = cell
|
line[cursor.x] = cell
|
||||||
if char_width == 2 and cursor.x + 1 < columns:
|
if char_width == 2 and cursor.x + 1 < columns:
|
||||||
filler = cache.get("")
|
filler = cache.get("")
|
||||||
if filler is None:
|
if filler is None:
|
||||||
filler = cache[""] = cursor.attrs._replace(data="")
|
filler = cache[""] = attrs._replace(data="")
|
||||||
line[cursor.x + 1] = filler
|
line[cursor.x + 1] = filler
|
||||||
# a combining mark belongs to the cell before it, which may be the
|
# a combining mark belongs to the cell before it, which may be the
|
||||||
# last column of the previous row
|
# last column of the previous row
|
||||||
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if char_width > 0:
|
if char_width > 0:
|
||||||
self.cursor.x = min(self.cursor.x + char_width, self.columns)
|
x = cursor.x + char_width
|
||||||
|
cursor.x = x if x < columns else columns
|
||||||
|
|
||||||
self.dirty.add(self.cursor.y)
|
self.dirty.add(cursor.y)
|
||||||
|
|
||||||
def index(self):
|
def index(self):
|
||||||
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ def _modifier_code(ctrl, alt, shift):
|
|||||||
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
|
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,
|
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
|
||||||
modify_other_keys=0):
|
|
||||||
"""Escape sequence for a key name and its modifiers, honouring the app's active modes"""
|
"""Escape sequence for a key name and its modifiers, honouring the app's active modes"""
|
||||||
key_lo = key.lower() if len(key) > 1 else key
|
key_lo = key.lower() if len(key) > 1 else key
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
# background — via the renderer tick: a render here would race the
|
# background — via the renderer tick: a render here would race the
|
||||||
# renderer thread, which owns the emulator
|
# renderer thread, which owns the emulator
|
||||||
terminal.pending_render = True
|
terminal.pending_render = True
|
||||||
|
terminal.wake()
|
||||||
view.run_command("vterm_show_cursor", {"scroll": False})
|
view.run_command("vterm_show_cursor", {"scroll": False})
|
||||||
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
|
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
|
||||||
# the plugin host restarted; this tab's terminal is unreachable
|
# the plugin host restarted; this tab's terminal is unreachable
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ notches and take the path below.
|
|||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
|
from .utils import settings
|
||||||
|
|
||||||
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
||||||
WHEEL_UP = 64
|
WHEEL_UP = 64
|
||||||
WHEEL_DOWN = 65
|
WHEEL_DOWN = 65
|
||||||
|
|
||||||
# lines per wheel tick, as iTerm2 delivers per notch
|
|
||||||
WHEEL_LINES = 3
|
|
||||||
|
|
||||||
|
|
||||||
def send_scroll(terminal, up, lines, cell=None):
|
def send_scroll(terminal, up, lines, cell=None):
|
||||||
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
||||||
@@ -105,7 +103,8 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
|
|||||||
cell = None
|
cell = None
|
||||||
if terminal.screen.mouse_reporting_enabled() and event:
|
if terminal.screen.mouse_reporting_enabled() and event:
|
||||||
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
||||||
send_scroll(terminal, up, WHEEL_LINES, cell)
|
lines = max(0, int(settings().get("wheel_lines", 3)))
|
||||||
|
send_scroll(terminal, up, lines, cell)
|
||||||
|
|
||||||
def want_event(self):
|
def want_event(self):
|
||||||
"""Ask sublime for the wheel coordinates."""
|
"""Ask sublime for the wheel coordinates."""
|
||||||
|
|||||||
@@ -410,9 +410,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
Without this the view keeps a full screen of blank rows below a short
|
Without this the view keeps a full screen of blank rows below a short
|
||||||
prompt, and the scrollbar reflects them.
|
prompt, and the scrollbar reflects them.
|
||||||
|
|
||||||
|
Both are skipped on the alternate screen, where the app owns every row:
|
||||||
|
one that blanks and refills - vim's showcmd corner, on every keypress -
|
||||||
|
would resize the view under a viewport pinned to its bottom, and the
|
||||||
|
whole screen shifts by the part of a row the viewport leaves over.
|
||||||
"""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
|
if screen.alternate_buffer_mode:
|
||||||
|
return
|
||||||
cursor = screen.cursor
|
cursor = screen.cursor
|
||||||
cursor_row = terminal.offset + cursor.y
|
cursor_row = terminal.offset + cursor.y
|
||||||
lastrow = view.rowcol(view.size())[0]
|
lastrow = view.rowcol(view.size())[0]
|
||||||
@@ -513,11 +520,10 @@ def following(view, terminal):
|
|||||||
|
|
||||||
|
|
||||||
def bottom_y(view, terminal):
|
def bottom_y(view, terminal):
|
||||||
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
|
"""Viewport y that pins the bottom of the view to the bottom of the viewport."""
|
||||||
|
|
||||||
# layout_extent is maintained incrementally, unlike text_to_layout(size())
|
# layout_extent is maintained incrementally, unlike text_to_layout(size())
|
||||||
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
|
return max(0, view.layout_extent()[1] - view.viewport_extent()[1])
|
||||||
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
|
|
||||||
return max(offset_y, viewport_y)
|
|
||||||
|
|
||||||
|
|
||||||
def scroll_to_cursor(view, terminal):
|
def scroll_to_cursor(view, terminal):
|
||||||
|
|||||||
@@ -24,12 +24,24 @@ RENDER_PERIOD = 0.03
|
|||||||
# draw rate for a visible terminal the user is not typing in, ~15 fps
|
# draw rate for a visible terminal the user is not typing in, ~15 fps
|
||||||
UNFOCUSED_RENDER_PERIOD = 0.075
|
UNFOCUSED_RENDER_PERIOD = 0.075
|
||||||
|
|
||||||
# max chars parsed per tick, so a giant burst still renders intermediate frames
|
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||||
FEED_LIMIT = 256 * 1024
|
FEED_LIMIT = 48 * 1024
|
||||||
|
|
||||||
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||||
REPAINT_NUDGE_DELAY = 0.25
|
REPAINT_NUDGE_DELAY = 0.25
|
||||||
|
|
||||||
|
# extra wait once a tick finds nothing pending, on top of RENDER_PERIOD.
|
||||||
|
IDLE_TICK = 0.2
|
||||||
|
|
||||||
|
# pause before the first frame after an idle park, so the writes of one app
|
||||||
|
# update land together: vim splits every keypress into two writes a fraction of
|
||||||
|
# a millisecond apart, and drawing between them flashes its showcmd echo
|
||||||
|
SETTLE = 0.005
|
||||||
|
|
||||||
|
# frames between undo-stack clears: every render edit lands on the view's undo
|
||||||
|
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
||||||
|
UNDO_CLEAR_FRAMES = 100
|
||||||
|
|
||||||
|
|
||||||
def view_size(view, default=None):
|
def view_size(view, default=None):
|
||||||
"""Terminal (rows, columns) that fit the view's viewport."""
|
"""Terminal (rows, columns) that fit the view's viewport."""
|
||||||
@@ -116,6 +128,12 @@ class Terminal:
|
|||||||
self.pending_render = False
|
self.pending_render = False
|
||||||
# a UTF-8 sequence split across read chunks must not be dropped
|
# a UTF-8 sequence split across read chunks must not be dropped
|
||||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||||
|
# set by the reader (and the UI thread) to end the renderer's idle park
|
||||||
|
self._wake = threading.Event()
|
||||||
|
|
||||||
|
def wake(self):
|
||||||
|
"""Cut short the renderer's idle wait, for a flag set from another thread"""
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_id(cls, vid):
|
def from_id(cls, vid):
|
||||||
@@ -203,6 +221,7 @@ class Terminal:
|
|||||||
"""Terminate the child and drop this terminal from the registry."""
|
"""Terminate the child and drop this terminal from the registry."""
|
||||||
LOG.debug("[kill] terminating process")
|
LOG.debug("[kill] terminating process")
|
||||||
self.alive = False
|
self.alive = False
|
||||||
|
self._wake.set()
|
||||||
scheme_manager.remove_listener(self.apply_scheme)
|
scheme_manager.remove_listener(self.apply_scheme)
|
||||||
try:
|
try:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
@@ -254,11 +273,13 @@ class Terminal:
|
|||||||
except EOFError:
|
except EOFError:
|
||||||
break
|
break
|
||||||
chunks.append(self._decoder.decode(raw))
|
chunks.append(self._decoder.decode(raw))
|
||||||
|
self._wake.set()
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[reader] stops")
|
LOG.debug("[reader] stops")
|
||||||
break
|
break
|
||||||
self.alive = False
|
self.alive = False
|
||||||
done[0] = True
|
done[0] = True
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
def feed_data():
|
def feed_data():
|
||||||
"""Parse up to FEED_LIMIT of pending output into the screen."""
|
"""Parse up to FEED_LIMIT of pending output into the screen."""
|
||||||
@@ -278,11 +299,13 @@ class Terminal:
|
|||||||
|
|
||||||
def renderer():
|
def renderer():
|
||||||
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
||||||
|
frames = 0
|
||||||
while True:
|
while True:
|
||||||
# a hidden tab is not drawn at all: the screen keeps the state
|
# 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
|
# and one render on unhide catches the view up.
|
||||||
# full rate either way, or the parser would fall behind the pty
|
|
||||||
visible = self.is_visible()
|
visible = self.is_visible()
|
||||||
|
|
||||||
|
self._wake.clear()
|
||||||
with intermission(period=RENDER_PERIOD):
|
with intermission(period=RENDER_PERIOD):
|
||||||
feed_data()
|
feed_data()
|
||||||
if self.screen.in_sync_frame():
|
if self.screen.in_sync_frame():
|
||||||
@@ -295,11 +318,26 @@ class Terminal:
|
|||||||
# due first: _need_to_render consumes the cursor move it reports
|
# due first: _need_to_render consumes the cursor move it reports
|
||||||
if self.is_render_due() and self._need_to_render():
|
if self.is_render_due() and self._need_to_render():
|
||||||
self.view.run_command("vterm_render")
|
self.view.run_command("vterm_render")
|
||||||
|
frames += 1
|
||||||
|
if frames >= UNDO_CLEAR_FRAMES:
|
||||||
|
frames = 0
|
||||||
|
self.view.run_command("vterm_clear_undo_stack")
|
||||||
self._sync_wheel_flag()
|
self._sync_wheel_flag()
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[renderer] stops")
|
LOG.debug("[renderer] stops")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# nothing left to do: park until woken, instead of spinning at
|
||||||
|
# RENDER_PERIOD. A sync frame or a teardown skipped this by
|
||||||
|
# leaving the block above, and keeps its own cadence.
|
||||||
|
# dirty rows only count while visible: nothing clears them for a
|
||||||
|
# hidden tab, and on_activated wakes us with pending_render
|
||||||
|
if not (
|
||||||
|
chunks or self.pending_render or self.pending_clear or self.pending_clear_screen or (visible and self.screen.dirty)
|
||||||
|
):
|
||||||
|
if self._wake.wait(IDLE_TICK):
|
||||||
|
time.sleep(SETTLE)
|
||||||
|
|
||||||
while chunks:
|
while chunks:
|
||||||
feed_data()
|
feed_data()
|
||||||
done[0] = True
|
done[0] = True
|
||||||
|
|||||||
Reference in New Issue
Block a user