Compare commits
11 Commits
2a8fffb9c6
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76927a85ea | ||
|
|
e150591763 | ||
|
|
27f1140737 | ||
|
|
d371d35fb3 | ||
|
|
119d4124f0 | ||
|
|
c8caecdc45 | ||
|
|
6e93ae2a5b | ||
|
|
f37da42a74 | ||
|
|
ca0d76cf5b | ||
|
|
01a88f7b19 | ||
|
|
28c9c398a1 |
@@ -123,6 +123,9 @@ child writes
|
||||
│ chunks.append(text) collections.deque, no lock
|
||||
│
|
||||
├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
|
||||
│ _wake.wait(IDLE_TICK) idle: park until the reader has output,
|
||||
│ then SETTLE, so the writes of one app
|
||||
│ update are drawn as a single frame
|
||||
│ feed_data() popleft up to FEED_LIMIT (256 KiB)
|
||||
│ stream.feed(joined) → VtStream parser → VtScreen.draw
|
||||
│ mutates buffer, fills screen.dirty
|
||||
@@ -359,9 +362,11 @@ replaces both together.
|
||||
|
||||
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
54
README.md
54
README.md
@@ -9,17 +9,46 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
|
||||
|
||||
## Install
|
||||
|
||||
Symlink this folder into Sublime packages as `vterm`:
|
||||
### With Package Control
|
||||
|
||||
```sh
|
||||
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
|
||||
vterm is distributed through the shared channel at
|
||||
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the
|
||||
default one. Open **Preferences → Package Settings → Package Control →
|
||||
Settings** and put the channel URL in the `channels` array of the right-hand
|
||||
(User) pane:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": [
|
||||
"https://packagecontrol.io/channel_v3.json",
|
||||
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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*.
|
||||
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
|
||||
hides every default package. Then run **Package Control: Install Package**
|
||||
from the Command Palette and pick **vterm**. Upgrades arrive through
|
||||
**Package Control: Upgrade Package** like any other package.
|
||||
|
||||
### From source
|
||||
|
||||
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
|
||||
|
||||
@@ -56,7 +85,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).
|
||||
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
||||
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
||||
for Sublime Text and it arrives as wheel notches like any other. 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
|
||||
unfolds when the app leaves it.
|
||||
|
||||
@@ -154,7 +185,8 @@ instead), image protocols, Windows, panels.
|
||||
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
|
||||
plugin (sublime_text#1838), so set a utility such as
|
||||
[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,
|
||||
`shift+pageup/pagedown` and `shift+up/down` work regardless.
|
||||
|
||||
@@ -167,6 +199,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/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 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
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
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, \
|
||||
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)")
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
# 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
|
||||
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
||||
# bare ESC CR reads as alt+enter
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
// null sends the keys to the app
|
||||
"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,
|
||||
"max_columns": 500,
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
|
||||
return
|
||||
# the renderer thread owns the screen; it applies this on its next tick
|
||||
terminal.pending_clear_screen = True
|
||||
terminal.wake()
|
||||
self.view.run_command("vterm_clear_undo_stack")
|
||||
|
||||
def is_enabled(self):
|
||||
|
||||
@@ -93,6 +93,9 @@ def quantize_256(r, g, 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
|
||||
Char = namedtuple(
|
||||
"Char",
|
||||
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
|
||||
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)
|
||||
# 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._cache_attrs = None
|
||||
self.history = deque(maxlen=history)
|
||||
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
|
||||
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||
self._sgr_cache.clear()
|
||||
self._char_caches.clear()
|
||||
self._cache_attrs = None
|
||||
super().reset()
|
||||
self.cursor = Cursor(0, 0)
|
||||
self.cursor_style = 0
|
||||
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
|
||||
cursor = self.cursor
|
||||
buffer = self.buffer
|
||||
columns = self.columns
|
||||
# per-run cache: cells differ only by their data while attrs are unchanged
|
||||
if cursor.attrs is not self._cache_attrs:
|
||||
self._cache_attrs = cursor.attrs
|
||||
self._char_cache = {}
|
||||
attrs = cursor.attrs
|
||||
# cells differ only by their data within one attribute set; the cache is
|
||||
# picked per set, not discarded, or colour changes would thrash it
|
||||
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
|
||||
|
||||
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]
|
||||
|
||||
for char in data:
|
||||
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
|
||||
self.insert_characters(char_width)
|
||||
cell = cache.get(char)
|
||||
if cell is None:
|
||||
cell = cache[char] = cursor.attrs._replace(data=char)
|
||||
cell = cache[char] = attrs._replace(data=char)
|
||||
line[cursor.x] = cell
|
||||
if char_width == 2 and cursor.x + 1 < columns:
|
||||
filler = cache.get("")
|
||||
if filler is None:
|
||||
filler = cache[""] = cursor.attrs._replace(data="")
|
||||
filler = cache[""] = 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
|
||||
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
|
||||
break
|
||||
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
||||
modify_other_keys=0):
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
|
||||
"""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
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||
# background — via the renderer tick: a render here would race the
|
||||
# renderer thread, which owns the emulator
|
||||
terminal.pending_render = True
|
||||
terminal.wake()
|
||||
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
|
||||
|
||||
@@ -8,14 +8,12 @@ notches and take the path below.
|
||||
import sublime_plugin
|
||||
|
||||
from .terminal import Terminal
|
||||
from .utils import settings
|
||||
|
||||
# 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
|
||||
WHEEL_LINES = 3
|
||||
|
||||
|
||||
def send_scroll(terminal, up, lines, cell=None):
|
||||
"""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
|
||||
if terminal.screen.mouse_reporting_enabled() and event:
|
||||
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):
|
||||
"""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
|
||||
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
|
||||
screen = terminal.screen
|
||||
if screen.alternate_buffer_mode:
|
||||
return
|
||||
cursor = screen.cursor
|
||||
cursor_row = terminal.offset + cursor.y
|
||||
lastrow = view.rowcol(view.size())[0]
|
||||
@@ -513,11 +520,10 @@ def following(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())
|
||||
viewport_y = 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)
|
||||
return max(0, view.layout_extent()[1] - view.viewport_extent()[1])
|
||||
|
||||
|
||||
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
|
||||
UNFOCUSED_RENDER_PERIOD = 0.075
|
||||
|
||||
# max chars parsed per tick, so a giant burst still renders intermediate frames
|
||||
FEED_LIMIT = 256 * 1024
|
||||
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||
FEED_LIMIT = 48 * 1024
|
||||
|
||||
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||
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):
|
||||
"""Terminal (rows, columns) that fit the view's viewport."""
|
||||
@@ -116,6 +128,12 @@ class Terminal:
|
||||
self.pending_render = False
|
||||
# a UTF-8 sequence split across read chunks must not be dropped
|
||||
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
|
||||
def from_id(cls, vid):
|
||||
@@ -203,6 +221,7 @@ class Terminal:
|
||||
"""Terminate the child and drop this terminal from the registry."""
|
||||
LOG.debug("[kill] terminating process")
|
||||
self.alive = False
|
||||
self._wake.set()
|
||||
scheme_manager.remove_listener(self.apply_scheme)
|
||||
try:
|
||||
self.process.terminate()
|
||||
@@ -254,11 +273,13 @@ class Terminal:
|
||||
except EOFError:
|
||||
break
|
||||
chunks.append(self._decoder.decode(raw))
|
||||
self._wake.set()
|
||||
if done[0] or not self.is_hosted():
|
||||
LOG.debug("[reader] stops")
|
||||
break
|
||||
self.alive = False
|
||||
done[0] = True
|
||||
self._wake.set()
|
||||
|
||||
def feed_data():
|
||||
"""Parse up to FEED_LIMIT of pending output into the screen."""
|
||||
@@ -278,11 +299,13 @@ class Terminal:
|
||||
|
||||
def renderer():
|
||||
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
||||
frames = 0
|
||||
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
|
||||
# and one render on unhide catches the view up.
|
||||
visible = self.is_visible()
|
||||
|
||||
self._wake.clear()
|
||||
with intermission(period=RENDER_PERIOD):
|
||||
feed_data()
|
||||
if self.screen.in_sync_frame():
|
||||
@@ -295,11 +318,26 @@ class Terminal:
|
||||
# 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")
|
||||
frames += 1
|
||||
if frames >= UNDO_CLEAR_FRAMES:
|
||||
frames = 0
|
||||
self.view.run_command("vterm_clear_undo_stack")
|
||||
self._sync_wheel_flag()
|
||||
if done[0] or not self.is_hosted():
|
||||
LOG.debug("[renderer] stops")
|
||||
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:
|
||||
feed_data()
|
||||
done[0] = True
|
||||
|
||||
Reference in New Issue
Block a user