summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-03 17:33:05 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitf3b62fe53b19e36f21f8020831718cbf095d6a7b (patch)
treecdfd008869bb5f5aeb69f0ef30584fec86f6f95b
parent6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08 (diff)
downloadpardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.tar.gz
pardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.zip
performance pass
-rw-r--r--build.zig44
-rw-r--r--main.zig310
-rw-r--r--paint.zig197
-rw-r--r--term.zig7
-rw-r--r--tests.zig48
-rw-r--r--tests_syntax_perf.zig795
-rw-r--r--tests_tty_perf.zig88
-rw-r--r--ui_sdl.zig5
-rw-r--r--ui_vaxis.zig5
9 files changed, 1362 insertions, 137 deletions
diff --git a/build.zig b/build.zig
index f2a691c3..bc9308c8 100644
--- a/build.zig
+++ b/build.zig
@@ -145,6 +145,7 @@ pub fn build(b: *std.Build) void {
// You'll want to use a lazy dependency here so that ghostty is only
// downloaded if you actually need it.
+ var ghostty_vt_for_tests: ?*std.Build.Module = null;
if (b.lazyDependency("ghostty", .{
// Setting simd to false will force a pure static build that
// doesn't even require libc, but it has a significant performance
@@ -157,6 +158,7 @@ pub fn build(b: *std.Build) void {
// own uucode into vaxis. We undo that below by overriding both imports
// with ours — order matters, so this must happen after this point.
const ghostty_vt = dep.module("ghostty-vt");
+ ghostty_vt_for_tests = ghostty_vt;
ghostty_vt.addImport("uucode", uucode_mod);
if (is_emscripten) {
const term_opts = b.addOptions();
@@ -517,6 +519,48 @@ pub fn build(b: *std.Build) void {
const test_sdl_step = b.step("test-sdl", "run SDL3 GPU e2e tests (requires -Dsdl)");
test_sdl_step.dependOn(&run_sdl_tests.step);
+ const tty_perf = b.addExecutable(.{
+ .name = "pardes-tty-edit-perf",
+ .linkage = exe_linkage,
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests_tty_perf.zig"),
+ .link_libc = true,
+ }),
+ });
+ if (ghostty_vt_for_tests) |ghostty_vt| tty_perf.root_module.addImport("ghostty-vt", ghostty_vt);
+ const run_tty_perf = b.addRunArtifact(tty_perf);
+ run_tty_perf.addArtifactArg(exe);
+ const tty_perf_step = b.step("perf-tty-edit", "measure text editing latency through the vaxis tty backend");
+ if (sdl_backend) {
+ tty_perf_step.dependOn(&b.addFail("perf-tty-edit measures the terminal backend; run without -Dsdl and without an Emscripten target").step);
+ } else {
+ tty_perf_step.dependOn(&run_tty_perf.step);
+ }
+
+ const syntax_perf = b.addExecutable(.{
+ .name = "pardes-syntax-perf",
+ .linkage = exe_linkage,
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests_syntax_perf.zig"),
+ .link_libc = true,
+ }),
+ });
+ if (ghostty_vt_for_tests) |ghostty_vt| syntax_perf.root_module.addImport("ghostty-vt", ghostty_vt);
+ const run_syntax_perf = b.addRunArtifact(syntax_perf);
+ run_syntax_perf.addArtifactArg(exe);
+ const syntax_perf_step = b.step("perf-syntax", "measure highlighted file open/edit latency through the vaxis tty backend");
+ if (sdl_backend) {
+ syntax_perf_step.dependOn(&b.addFail("perf-syntax measures the terminal backend; run without -Dsdl and without an Emscripten target").step);
+ } else if (!syntax_highlighting) {
+ syntax_perf_step.dependOn(&b.addFail("perf-syntax requires tree-sitter; run with -Dtree-sitter=minimal or -Dtree-sitter=full").step);
+ } else {
+ syntax_perf_step.dependOn(&run_syntax_perf.step);
+ }
+
// Unit tests for the pure modal-editing math (modal.zig has no vaxis/ghostty
// deps, so `zig test` on it is fast and needs no wired-in modules).
const unit_tests = b.addTest(.{
diff --git a/main.zig b/main.zig
index 93c3324e..cd760ecf 100644
--- a/main.zig
+++ b/main.zig
@@ -34,6 +34,12 @@ fn fileLog(
_ = std.os.linux.write(log_fd, line.ptr, line.len);
}
+fn nowNs() u64 {
+ var ts: std.os.linux.timespec = undefined;
+ _ = std.os.linux.clock_gettime(std.os.linux.CLOCK.MONOTONIC, &ts);
+ return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
+}
+
extern "c" fn forkpty(
amaster: *c_int,
name: ?[*:0]u8,
@@ -99,6 +105,7 @@ const Cli = struct {
tty_only: bool = false,
tty_toggle: u21 = default_tty_toggle,
help: bool = false,
+ perf_startup: bool = false,
};
const help_text =
@@ -138,6 +145,8 @@ fn parseCli(args: []const [:0]const u8) !Cli {
const a = args[i];
if (std.mem.eql(u8, a, "--tty")) {
cli.tty_only = true;
+ } else if (std.mem.eql(u8, a, "--perf-startup")) {
+ cli.perf_startup = true;
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
cli.help = true;
} else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
@@ -164,6 +173,10 @@ const MINH: u16 = pane_ui.MINH; // min pane height (cells)
const MAX_TERMS: usize = 16;
const MAX_COLS: usize = 6;
const MAX_PTY_DRAIN_EVENTS: usize = 128;
+const MAX_KEY_DRAIN_EVENTS: usize = 512;
+const MAX_SCROLL_DRAIN_EVENTS: usize = 4096;
+const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
+const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
const PREFIX_W: u16 = pane_ui.PREFIX_W; // file-pane line-number prefix width: "%4d "
const Rect = pane_ui.Rect;
@@ -211,7 +224,7 @@ const T = paint.T;
const rgbColor = paint.rgbColor;
const colorOpt = paint.colorOpt;
const synStyle = paint.synStyle;
-const highlightFile = paint.highlightFile;
+const highlightFileRange = paint.highlightFileRange;
const cellStyle = paint.cellStyle;
// Master switch for the per-cell recolor passes (tty ansi + file syntax). Off =
@@ -254,6 +267,27 @@ const file_pane_tail = " " ++ file_pane_builtins_str;
// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme.
const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
+fn writeStdoutBestEffort(bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const rc = std.os.linux.write(1, bytes[off..].ptr, bytes.len - off);
+ const n: isize = @bitCast(rc);
+ if (n <= 0) return;
+ off += @intCast(n);
+ }
+}
+
+fn paintStartupTopbar() void {
+ writeStdoutBestEffort("\x1b[?1049h\x1b[H" ++ topbar_str);
+}
+
+fn writeStartupPerfMarker(start_ns: u64) void {
+ const elapsed_us = (nowNs() - start_ns) / 1000;
+ var buf: [64]u8 = undefined;
+ const marker = std.fmt.bufPrint(&buf, "\x1b]1337;PARDES_STARTUP_US={d}\x07", .{elapsed_us}) catch return;
+ writeStdoutBestEffort(marker);
+}
+
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That
@@ -324,6 +358,22 @@ fn fileLineCount(content: []const u8) usize {
return std.mem.count(u8, content, "\n") + 1;
}
+fn lineStartByte(content: []const u8, row: usize) usize {
+ var off: usize = 0;
+ var r: usize = 0;
+ while (r < row) : (r += 1) {
+ const rel = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len;
+ off = rel + 1;
+ }
+ return off;
+}
+
+fn visibleByteRange(content: []const u8, row: usize, rows: usize) struct { start: usize, end: usize } {
+ const start = lineStartByte(content, row);
+ const end = lineStartByte(content, row + rows);
+ return .{ .start = start, .end = @max(start, end) };
+}
+
// the pane's current scroll offset (top visible row, in body coordinates). For a
// file it's `scroll`; for a terminal it's the emulator's scrollback offset.
fn paneScroll(t: *Term) i32 {
@@ -335,12 +385,45 @@ fn paneScrollBy(t: *Term, delta: i32) void {
if (t.file) |*f| {
const max: i64 = @intCast(fileLineCount(f.content) -| 1);
const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
- f.scroll = @intCast(n);
+ const next: usize = @intCast(n);
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
} else {
t.term.screens.active.scroll(.{ .delta_row = @intCast(delta) });
}
}
+fn hoveredPane(terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, mcol: u16, mrow: u16) ?usize {
+ return for (terms, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = rects[i];
+ if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
+ } else null;
+}
+
+fn handleWheelMouse(
+ terms: *[MAX_TERMS]?*Term,
+ rects: *const [MAX_TERMS]Rect,
+ screen_w: u16,
+ screen_h: u16,
+ hover_col: *u16,
+ hover_row: *u16,
+ mouse: vaxis.Mouse,
+) bool {
+ if (mouse.button != .wheel_down and mouse.button != .wheel_up) return false;
+ const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse);
+ const mcol = mpos.col;
+ const mrow = mpos.row;
+ hover_col.* = mcol;
+ hover_row.* = mrow;
+ if (hoveredPane(terms, rects, mcol, mrow)) |id| {
+ if (terms[id]) |t| paneScrollBy(t, if (mouse.button == .wheel_down) 1 else -1);
+ }
+ return true;
+}
+
// Before running a composed command in a terminal, push the shell down so its
// output lands BELOW the last row the user typed overlay text on. The shell owns
// its cursor, so we move it the only way we can: emit newlines to the pty until
@@ -781,15 +864,63 @@ fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
return buf.toOwnedSlice(gpa);
}
-// Replace a file pane's content (freeing the old buffer) and re-derive syntax
-// highlighting to match. Every file edit funnels through here, so highlights stay
-// in sync with content (and can never index past it). The reparse is skipped when
-// colors are off, keeping the edit path exactly as fast as before colors existed.
+fn refreshFileHighlightsVisible(gpa: std.mem.Allocator, f: *Term.File, rows: usize) void {
+ const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
+ const range = visibleByteRange(f.content, start_row, SYNTAX_CONTEXT_BEFORE_ROWS + rows + SYNTAX_CONTEXT_AFTER_ROWS);
+ if (!colors_on) {
+ if (f.highlights.len > 0) gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ return;
+ }
+ const new_highlights = highlightFileRange(gpa, f.path, f.content, range.start, range.end) catch {
+ f.syntax_dirty = false;
+ return;
+ };
+ if (f.highlights.len > 0) gpa.free(f.highlights);
+ f.highlights = new_highlights;
+ f.highlight_start = if (f.highlights.len > 0) range.start else 0;
+ f.syntax_dirty = false;
+}
+
+fn refreshDirtyFileHighlightsVisible(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term) bool {
+ var refreshed = false;
+ for (terms) |*slot| {
+ if (slot.*) |t| {
+ if (t.file) |*f| {
+ if (f.syntax_dirty) {
+ refreshFileHighlightsVisible(gpa, f, t.rows);
+ refreshed = true;
+ }
+ }
+ }
+ }
+ return refreshed;
+}
+
+// Replace a file pane's content without synchronously reparsing the whole file.
+// The old style map remains in use until a bounded visible-range refresh catches
+// up, so edits do not flash to unhighlighted text.
fn setFileContent(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void {
+ const had_syntax = f.syntax_dirty or f.highlights.len > 0;
gpa.free(f.content);
f.content = new;
- if (f.highlights.len > 0) gpa.free(f.highlights);
- f.highlights = if (colors_on) (highlightFile(gpa, f.path, new) catch &.{}) else &.{};
+ if (had_syntax and colors_on) {
+ // Keep the old style map while dirty. This preserves most visible color
+ // state during input batching; the synchronous visible-range refresh below
+ // swaps in a correctly offset map before the next frame is rendered.
+ f.syntax_dirty = true;
+ } else {
+ if (f.highlights.len > 0) gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ }
+}
+
+fn setFileContentStaleSyntax(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void {
+ setFileContent(gpa, f, new);
}
// Open a read/editable FILE pane: a Term with `file` set, sharing all the
@@ -801,8 +932,6 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
errdefer gpa.free(content);
const path = try gpa.dupe(u8, std.mem.span(rp));
errdefer gpa.free(path);
- const highlights = try highlightFile(gpa, path, content);
- errdefer if (highlights.len > 0) gpa.free(highlights);
const t = try gpa.create(Term);
errdefer gpa.destroy(t);
const total = fileLineCount(content);
@@ -813,7 +942,7 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
.term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
.reader = .{ .any_future = null, .result = {} },
- .file = .{ .path = path, .content = content, .scroll = scroll, .highlights = highlights },
+ .file = .{ .path = path, .content = content, .scroll = scroll, .highlights = &.{}, .syntax_dirty = true },
.cols = cols,
.rows = rows,
.cur_pinned = true,
@@ -1083,7 +1212,11 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
var c: usize = 0;
while (c < line.len and pw + c < bw) : (c += 1) {
- const ss = synStyle(@enumFromInt(f.highlights[base + c])) orelse continue;
+ const idx = base + c;
+ if (idx < f.highlight_start) continue;
+ const hidx = idx - f.highlight_start;
+ if (hidx >= f.highlights.len) continue;
+ const ss = synStyle(@enumFromInt(f.highlights[hidx])) orelse continue;
const cx: u16 = @intCast(pw + c);
if (text.readCell(cx, BOX_H + vr)) |cell| {
var nc = cell;
@@ -1391,7 +1524,7 @@ fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, wher
.open_below => {
if (t.file) |*f| {
const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
t.cur_col = 0;
} else {
@@ -1405,7 +1538,7 @@ fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, wher
.open_above => {
if (t.file) |*f| {
const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
// current line shifts down; the new empty line is at cur.row
t.cur_col = 0;
} else {
@@ -2047,7 +2180,7 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
if (key.text) |text| {
const new = modal.insertAt(gpa, f.content, c, text) catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
for (text) |ch| {
if (ch == '\n') {
t.cur_row += 1;
@@ -2061,7 +2194,7 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
switch (key.codepoint) {
vaxis.Key.enter => {
const new = modal.insertAt(gpa, f.content, c, "\n") catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
t.cur_row += 1;
t.cur_col = 0;
t.cur_pinned = true;
@@ -2070,12 +2203,12 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
vaxis.Key.backspace => {
if (t.cur_col > 0) {
const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
t.cur_col -= 1;
} else if (t.cur_row > 0) {
const prevlen = modal.lineSlice(f.content, c.row - 1).len;
const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
- setFileContent(gpa, f, new);
+ setFileContentStaleSyntax(gpa, f, new);
t.cur_row -= 1;
t.cur_col = @intCast(prevlen);
}
@@ -2146,6 +2279,24 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
}
}
+fn isBatchableInsertKey(key: vaxis.Key) bool {
+ if (key.mods.ctrl or key.mods.alt) return false;
+ if (key.text != null) return true;
+ return key.matches(vaxis.Key.backspace, .{});
+}
+
+fn isBatchableNormalMoveKey(key: vaxis.Key) bool {
+ if (key.mods.alt) return false;
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return true;
+ if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return true;
+ if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return true;
+ if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return true;
+ if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{})) return true;
+ if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{})) return true;
+ if (key.matches('f', .{ .ctrl = true }) or key.matches('b', .{ .ctrl = true })) return true;
+ return false;
+}
+
// tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as
// the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...).
fn forwardToPty(t: *Term, key: vaxis.Key) void {
@@ -2323,7 +2474,11 @@ fn actOnSelection(
active.* = fid;
if (!as_image and pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff|
if (pl.line <= fileLineCount(ff.content)) {
- ff.scroll = pl.line - 1;
+ const next = pl.line - 1;
+ if (next != ff.scroll) {
+ ff.scroll = next;
+ ff.syntax_dirty = true;
+ }
};
} else {
const slot: ?usize = for (terms.*, 0..) |sl, i| {
@@ -2497,6 +2652,7 @@ fn cursorWordSel(t: *Term) Term.Sel {
}
pub fn main(init: std.process.Init) !void {
+ const startup_main_t0 = nowNs();
const io = init.io;
const gpa = init.gpa;
@@ -2511,11 +2667,24 @@ pub fn main(init: std.process.Init) !void {
}
const tty_only = cli.tty_only;
const tty_toggle = cli.tty_toggle;
+ const perf_startup = cli.perf_startup;
var screen_w: u16 = 80;
var screen_h: u16 = 24;
var resize_count: usize = 0;
+ var startup_alt_screen = false;
+ if (vaxis.supports_terminal_prepaint) {
+ paintStartupTopbar();
+ if (perf_startup) writeStartupPerfMarker(startup_main_t0);
+ startup_alt_screen = true;
+ }
+ errdefer if (startup_alt_screen)
+ writeStdoutBestEffort("\x1b[?1049l");
+
+ var tty_buf: [0x10000]u8 = undefined;
+ var tty = try vaxis.Tty.init(io, &tty_buf);
+
// stb_image allocator for image panes (see image.zig).
image.start(io, gpa);
defer image.stop();
@@ -2527,18 +2696,20 @@ pub fn main(init: std.process.Init) !void {
defer freeYank(gpa);
var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render()
- var tty_buf: [0x10000]u8 = undefined;
- var tty = try vaxis.Tty.init(io, &tty_buf);
-
// system_clipboard_allocator lets the loop's reader thread decode OSC 52 paste
// replies (delivered as .paste events). gpa is already used cross-thread by the
// pty readers, so it's safe here too.
var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
defer vx.deinit(gpa, tty.writer());
+ if (startup_alt_screen) {
+ vaxis.adoptAltScreen(&vx);
+ } else {
+ try vx.enterAltScreen(tty.writer());
+ }
+ defer vx.exitAltScreen(tty.writer()) catch {};
+ startup_alt_screen = false;
try vx.setMouseMode(tty.writer(), true);
- try vx.enterAltScreen(tty.writer());
- defer vx.exitAltScreen(tty.writer()) catch {};
// ---- terminals + layout ----
var terms: [MAX_TERMS]?*Term = @splat(null);
@@ -2598,7 +2769,7 @@ pub fn main(init: std.process.Init) !void {
// from the replies. We only need kitty graphics (for image panes). Keep the
// timeout tiny — a real terminal answers in well under a millisecond, and we
// don't want to stall startup on one that never will.
- vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(20)) catch {};
+ vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {};
// now safe to spawn the per-terminal reader tasks (no forking here)
for (&terms, 0..) |*slot, id| {
@@ -2619,7 +2790,9 @@ pub fn main(init: std.process.Init) !void {
// middle release, freed on overwrite / app exit.
var chord_arg: ?[]u8 = null;
defer if (chord_arg) |a| gpa.free(a);
- var pending_event: ?@TypeOf(Command.value) = null;
+ // Paint the chrome once immediately. Without this, startup waits for the
+ // first winsize/pty event before the user sees a frame.
+ var pending_event: ?@TypeOf(Command.value) = .tick;
while (true) {
const event = if (pending_event) |ev| blk: {
@@ -2627,6 +2800,9 @@ pub fn main(init: std.process.Init) !void {
break :blk ev;
} else try loop.nextEvent();
var drain_pty_after = false;
+ var drain_insert_keys = false;
+ var drain_scroll_events = false;
+ var drain_normal_moves = false;
switch (event) {
.mouse => |mouse| {
const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse);
@@ -2656,8 +2832,10 @@ pub fn main(init: std.process.Init) !void {
if (mouse.button == .wheel_down) {
if (hovered) |id| paneScrollBy(terms[id].?, 1);
+ drain_scroll_events = true;
} else if (mouse.button == .wheel_up) {
if (hovered) |id| paneScrollBy(terms[id].?, -1);
+ drain_scroll_events = true;
} else if (mouse.type == .press and mrow < TOPBAR_H) {
// global tagbar: MIDDLE-click runs the command under the click
// (acme "execute"; left-click must NOT execute — a stray left-click
@@ -3016,13 +3194,19 @@ pub fn main(init: std.process.Init) !void {
// on terminals (above).
// tty forwards every key to the pty.
switch (at.mode) {
- .normal => handleNormal(gpa, frame_arena.allocator(), at, key),
+ .normal => {
+ drain_normal_moves = isBatchableNormalMoveKey(key);
+ handleNormal(gpa, frame_arena.allocator(), at, key);
+ },
.insert => {
if (key.matches(vaxis.Key.escape, .{})) {
at.mode = .normal;
at.msel.active = false;
at.pending = 0;
- } else handleInsert(gpa, frame_arena.allocator(), at, key);
+ } else {
+ drain_insert_keys = isBatchableInsertKey(key);
+ handleInsert(gpa, frame_arena.allocator(), at, key);
+ }
},
.tty => forwardToPty(at, key),
}
@@ -3067,6 +3251,74 @@ pub fn main(init: std.process.Init) !void {
else => {},
}
+ if (drain_normal_moves and pending_event == null) {
+ var drained: usize = 0;
+ while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) {
+ const next = try loop.tryEvent() orelse break;
+ var handled = false;
+ switch (next) {
+ .key_press => |next_key| {
+ if (isBatchableNormalMoveKey(next_key)) {
+ if (terms[active]) |at| {
+ if (!at.tag_edit and at.mode == .normal) {
+ handleNormal(gpa, frame_arena.allocator(), at, next_key);
+ handled = true;
+ }
+ }
+ }
+ },
+ else => {},
+ }
+ if (!handled) {
+ pending_event = next;
+ break;
+ }
+ }
+ }
+
+ if (drain_scroll_events and pending_event == null) {
+ var drained: usize = 0;
+ while (drained < MAX_SCROLL_DRAIN_EVENTS) : (drained += 1) {
+ const next = try loop.tryEvent() orelse break;
+ var handled = false;
+ switch (next) {
+ .mouse => |next_mouse| {
+ handled = handleWheelMouse(&terms, &rects, screen_w, screen_h, &hover_col, &hover_row, next_mouse);
+ },
+ else => {},
+ }
+ if (!handled) {
+ pending_event = next;
+ break;
+ }
+ }
+ }
+
+ if (drain_insert_keys and pending_event == null) {
+ var drained: usize = 0;
+ while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) {
+ const next = try loop.tryEvent() orelse break;
+ var handled = false;
+ switch (next) {
+ .key_press => |next_key| {
+ if (isBatchableInsertKey(next_key)) {
+ if (terms[active]) |at| {
+ if (!at.tag_edit and at.mode == .insert) {
+ handleInsert(gpa, frame_arena.allocator(), at, next_key);
+ handled = true;
+ }
+ }
+ }
+ },
+ else => {},
+ }
+ if (!handled) {
+ pending_event = next;
+ break;
+ }
+ }
+ }
+
if (drain_pty_after) {
var drained: usize = 0;
while (drained < MAX_PTY_DRAIN_EVENTS) : (drained += 1) {
@@ -3120,6 +3372,8 @@ pub fn main(init: std.process.Init) !void {
}
}
+ _ = refreshDirtyFileHighlightsVisible(gpa, &terms);
+
// ---- render ----
const win = vx.window();
win.clear();
diff --git a/paint.zig b/paint.zig
index b4ab932b..97efc334 100644
--- a/paint.zig
+++ b/paint.zig
@@ -7,6 +7,7 @@ const syntax_minimal_grammars = app_config.syntax_minimal_grammars;
const syntax_full_grammars = app_config.syntax_full_grammars;
const ts = if (syntax_highlighting) @import("tree-sitter") else struct {
pub const Language = opaque {};
+ pub const Query = opaque {};
};
const ts_queries = if (syntax_highlighting) @import("ts_queries") else struct {};
@@ -134,6 +135,99 @@ pub const SynStyle = struct {
bold: bool,
};
+const HighlightLanguage = struct {
+ lang: *const ts.Language,
+ query: *ts.Query,
+};
+
+const HighlightTier = enum { minimal, full };
+
+const HighlightSpec = struct {
+ tier: HighlightTier,
+ exts: []const []const u8,
+ language: *const fn () callconv(.c) *const ts.Language,
+ query_src: []const u8,
+ compiled_query: ?*ts.Query = null,
+};
+
+var highlight_specs = if (syntax_minimal_grammars) [_]HighlightSpec{
+ .{ .tier = .minimal, .exts = &[_][]const u8{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
+ .{ .tier = .minimal, .exts = &[_][]const u8{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
+ .{ .tier = .minimal, .exts = &[_][]const u8{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
+} else [_]HighlightSpec{};
+
+var full_highlight_specs = if (syntax_full_grammars) [_]HighlightSpec{
+ .{ .tier = .full, .exts = &[_][]const u8{ ".adb", ".ads", ".ada" }, .language = tree_sitter_ada, .query_src = ts_queries.ada_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".sh", ".bash", ".zsh" }, .language = tree_sitter_bash, .query_src = ts_queries.bash_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".cs", ".csx" }, .language = tree_sitter_c_sharp, .query_src = ts_queries.c_sharp_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".clj", ".cljs", ".cljc", ".edn" }, .language = tree_sitter_clojure, .query_src = ts_queries.clojure_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{".css"}, .language = tree_sitter_css, .query_src = ts_queries.css_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".ex", ".exs" }, .language = tree_sitter_elixir, .query_src = ts_queries.elixir_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".erl", ".hrl" }, .language = tree_sitter_erlang, .query_src = ts_queries.erlang_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .language = tree_sitter_fortran, .query_src = ts_queries.fortran_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{".go"}, .language = tree_sitter_go, .query_src = ts_queries.go_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".hs", ".lhs" }, .language = tree_sitter_haskell, .query_src = ts_queries.haskell_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".html", ".htm" }, .language = tree_sitter_html, .query_src = ts_queries.html_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{".java"}, .language = tree_sitter_java, .query_src = ts_queries.java_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".js", ".jsx", ".mjs", ".cjs" }, .language = tree_sitter_javascript, .query_src = ts_queries.javascript_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{".json"}, .language = tree_sitter_json, .query_src = ts_queries.json_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".kt", ".kts" }, .language = tree_sitter_kotlin, .query_src = ts_queries.kotlin_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".ml", ".mli" }, .language = tree_sitter_ocaml, .query_src = ts_queries.ocaml_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".pas", ".pp", ".p" }, .language = tree_sitter_pascal, .query_src = ts_queries.pascal_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .language = tree_sitter_php, .query_src = ts_queries.php_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".ps1", ".psm1", ".psd1" }, .language = tree_sitter_powershell, .query_src = ts_queries.powershell_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".py", ".pyw" }, .language = tree_sitter_python, .query_src = ts_queries.python_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".rb", ".rake" }, .language = tree_sitter_ruby, .query_src = ts_queries.ruby_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{".rs"}, .language = tree_sitter_rust, .query_src = ts_queries.rust_highlights },
+ .{ .tier = .full, .exts = &[_][]const u8{ ".scala", ".sc" }, .language = tree_sitter_scala, .query_src = ts_queries.scala_highlights },
+} else [_]HighlightSpec{};
+
+fn tierEnabled(tier: HighlightTier) bool {
+ return switch (tier) {
+ .minimal => syntax_minimal_grammars,
+ .full => syntax_full_grammars,
+ };
+}
+
+fn specMatchesExt(spec: HighlightSpec, ext: []const u8) bool {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
+ }
+ return false;
+}
+
+fn ensureHighlightLanguage(spec: *HighlightSpec) !HighlightLanguage {
+ const lang = spec.language();
+ if (spec.compiled_query) |query| return .{ .lang = lang, .query = query };
+ var error_offset: u32 = 0;
+ const query = try ts.Query.create(lang, spec.query_src, &error_offset);
+ spec.compiled_query = query;
+ return .{ .lang = lang, .query = query };
+}
+
+fn highlightLanguageForExt(ext: []const u8) !?HighlightLanguage {
+ for (&highlight_specs) |*spec| {
+ if (!tierEnabled(spec.tier)) continue;
+ if (!specMatchesExt(spec.*, ext)) continue;
+ return try ensureHighlightLanguage(spec);
+ }
+ for (&full_highlight_specs) |*spec| {
+ if (!tierEnabled(spec.tier)) continue;
+ if (!specMatchesExt(spec.*, ext)) continue;
+ return try ensureHighlightLanguage(spec);
+ }
+ return null;
+}
+
+pub fn compileStartupHighlightQueries() void {
+ if (!syntax_highlighting) return;
+ for (&highlight_specs) |*spec| {
+ if (spec.tier != .minimal) continue;
+ if (!tierEnabled(spec.tier)) continue;
+ _ = ensureHighlightLanguage(spec) catch {};
+ }
+}
+
pub fn synStyle(s: Syn) ?SynStyle {
return switch (s) {
.none => null,
@@ -154,7 +248,6 @@ fn synFor(name: []const u8) Syn {
.{ "boolean", .number },
.{ "keyword", .keyword },
.{ "include", .keyword },
- .{ "operator", .keyword },
.{ "conditional", .keyword },
.{ "repeat", .keyword },
}) |m| {
@@ -163,115 +256,35 @@ fn synFor(name: []const u8) Syn {
return .none;
}
-fn extIs(ext: []const u8, comptime choices: anytype) bool {
- inline for (choices) |choice| {
- if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
- }
- return false;
+pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
+ return highlightFileRange(gpa, path, content, 0, content.len);
}
-pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
+pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
if (!syntax_highlighting) return &.{};
const ext = std.fs.path.extension(path);
- const selected = blk: {
- const Selected = struct {
- lang: *const ts.Language,
- query_src: []const u8,
- };
+ const selected = (highlightLanguageForExt(ext) catch return &.{}) orelse return &.{};
- if (syntax_full_grammars) {
- if (extIs(ext, .{ ".adb", ".ads", ".ada" })) {
- break :blk Selected{ .lang = tree_sitter_ada(), .query_src = ts_queries.ada_highlights };
- } else if (extIs(ext, .{ ".sh", ".bash", ".zsh" })) {
- break :blk Selected{ .lang = tree_sitter_bash(), .query_src = ts_queries.bash_highlights };
- }
- }
- if (syntax_minimal_grammars) {
- if (extIs(ext, .{ ".c", ".h" })) {
- break :blk Selected{ .lang = tree_sitter_c(), .query_src = ts_queries.c_highlights };
- }
- }
- if (syntax_full_grammars) {
- if (extIs(ext, .{ ".cs", ".csx" })) {
- break :blk Selected{ .lang = tree_sitter_c_sharp(), .query_src = ts_queries.c_sharp_highlights };
- } else if (extIs(ext, .{ ".clj", ".cljs", ".cljc", ".edn" })) {
- break :blk Selected{ .lang = tree_sitter_clojure(), .query_src = ts_queries.clojure_highlights };
- }
- }
- if (syntax_minimal_grammars) {
- if (extIs(ext, .{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" })) {
- break :blk Selected{ .lang = tree_sitter_cpp(), .query_src = ts_queries.cpp_highlights };
- }
- }
- if (syntax_full_grammars) {
- if (extIs(ext, .{".css"})) {
- break :blk Selected{ .lang = tree_sitter_css(), .query_src = ts_queries.css_highlights };
- } else if (extIs(ext, .{ ".ex", ".exs" })) {
- break :blk Selected{ .lang = tree_sitter_elixir(), .query_src = ts_queries.elixir_highlights };
- } else if (extIs(ext, .{ ".erl", ".hrl" })) {
- break :blk Selected{ .lang = tree_sitter_erlang(), .query_src = ts_queries.erlang_highlights };
- } else if (extIs(ext, .{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" })) {
- break :blk Selected{ .lang = tree_sitter_fortran(), .query_src = ts_queries.fortran_highlights };
- } else if (extIs(ext, .{".go"})) {
- break :blk Selected{ .lang = tree_sitter_go(), .query_src = ts_queries.go_highlights };
- } else if (extIs(ext, .{ ".hs", ".lhs" })) {
- break :blk Selected{ .lang = tree_sitter_haskell(), .query_src = ts_queries.haskell_highlights };
- } else if (extIs(ext, .{ ".html", ".htm" })) {
- break :blk Selected{ .lang = tree_sitter_html(), .query_src = ts_queries.html_highlights };
- } else if (extIs(ext, .{".java"})) {
- break :blk Selected{ .lang = tree_sitter_java(), .query_src = ts_queries.java_highlights };
- } else if (extIs(ext, .{ ".js", ".jsx", ".mjs", ".cjs" })) {
- break :blk Selected{ .lang = tree_sitter_javascript(), .query_src = ts_queries.javascript_highlights };
- } else if (extIs(ext, .{".json"})) {
- break :blk Selected{ .lang = tree_sitter_json(), .query_src = ts_queries.json_highlights };
- } else if (extIs(ext, .{ ".kt", ".kts" })) {
- break :blk Selected{ .lang = tree_sitter_kotlin(), .query_src = ts_queries.kotlin_highlights };
- } else if (extIs(ext, .{ ".ml", ".mli" })) {
- break :blk Selected{ .lang = tree_sitter_ocaml(), .query_src = ts_queries.ocaml_highlights };
- } else if (extIs(ext, .{ ".pas", ".pp", ".p" })) {
- break :blk Selected{ .lang = tree_sitter_pascal(), .query_src = ts_queries.pascal_highlights };
- } else if (extIs(ext, .{ ".php", ".phtml", ".php3", ".php4", ".php5" })) {
- break :blk Selected{ .lang = tree_sitter_php(), .query_src = ts_queries.php_highlights };
- } else if (extIs(ext, .{ ".ps1", ".psm1", ".psd1" })) {
- break :blk Selected{ .lang = tree_sitter_powershell(), .query_src = ts_queries.powershell_highlights };
- } else if (extIs(ext, .{ ".py", ".pyw" })) {
- break :blk Selected{ .lang = tree_sitter_python(), .query_src = ts_queries.python_highlights };
- } else if (extIs(ext, .{ ".rb", ".rake" })) {
- break :blk Selected{ .lang = tree_sitter_ruby(), .query_src = ts_queries.ruby_highlights };
- } else if (extIs(ext, .{".rs"})) {
- break :blk Selected{ .lang = tree_sitter_rust(), .query_src = ts_queries.rust_highlights };
- } else if (extIs(ext, .{ ".scala", ".sc" })) {
- break :blk Selected{ .lang = tree_sitter_scala(), .query_src = ts_queries.scala_highlights };
- }
- }
- if (syntax_minimal_grammars) {
- if (extIs(ext, .{ ".zig", ".zon" })) {
- break :blk Selected{ .lang = tree_sitter_zig(), .query_src = ts_queries.zig_highlights };
- }
- }
- return &.{};
- };
-
- const styles = try gpa.alloc(u8, content.len);
+ const start_byte = @min(start_byte_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
+ const source = content[start_byte..end_byte];
+ const styles = try gpa.alloc(u8, source.len);
errdefer gpa.free(styles);
@memset(styles, 0);
const parser = ts.Parser.create();
defer parser.destroy();
parser.setLanguage(selected.lang) catch return styles;
- const tree = parser.parseString(content, null) orelse return styles;
+ const tree = parser.parseString(source, null) orelse return styles;
defer tree.destroy();
- var error_offset: u32 = 0;
- const query = ts.Query.create(selected.lang, selected.query_src, &error_offset) catch return styles;
- defer query.destroy();
const cursor = ts.QueryCursor.create();
defer cursor.destroy();
- cursor.exec(query, tree.rootNode());
+ cursor.exec(selected.query, tree.rootNode());
while (cursor.nextMatch()) |match| {
for (match.captures) |cap| {
- const syn = synFor(query.captureNameForId(cap.index) orelse "");
+ const syn = synFor(selected.query.captureNameForId(cap.index) orelse "");
if (syn == .none) continue;
var b: usize = cap.node.startByte();
const end = @min(@as(usize, cap.node.endByte()), styles.len);
diff --git a/term.zig b/term.zig
index d332488e..cb451b7c 100644
--- a/term.zig
+++ b/term.zig
@@ -49,8 +49,13 @@ pub const File = struct {
content: []u8, // gpa-owned file bytes
scroll: usize = 0, // top visible file line
/// tree-sitter syntax styles, one byte per `content` byte (0 = none). Empty
- /// when the file's language isn't highlighted. gpa-owned. See main.highlightFile.
+ /// when the file's language isn't highlighted. gpa-owned. `highlight_start`
+ /// is the content byte offset represented by `highlights[0]`.
highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ /// The content or viewport changed since `highlights` was refreshed for the
+ /// visible byte window.
+ syntax_dirty: bool = false,
/// dumb whole-content undo/redo: each entry is a gpa-owned snapshot of
/// `content`. main.pushUndo snapshots before an edit (one per insert session /
/// delete / paste); `u`/`U` move between them. Bounded (oldest dropped).
diff --git a/tests.zig b/tests.zig
index ebcc12df..6080aa3b 100644
--- a/tests.zig
+++ b/tests.zig
@@ -4,6 +4,8 @@ const Harness = h.Harness;
const config = @import("config");
const dump = @import("dump.zig");
+const ls_ready = "tests.zig";
+
// E2E tests for pardes. The harness forks the app in a pty and parses its
// output with a ghostty terminal emulator, so all assertions read the 2D screen
// GRID (plainString) — not raw byte streams. This is decoupled from the app's
@@ -37,6 +39,17 @@ fn colOf(text: []const u8, needle: []const u8) ?usize {
return null;
}
+fn clearRightColumnTerminal(hs: *Harness) !void {
+ try hs.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // focus the right-column terminal
+ try hs.pump(300);
+ try hs.send("\x02"); // normal -> tty
+ try hs.pump(400);
+ try hs.send("clear\r");
+ try hs.pump(800);
+ try hs.send("\x02"); // tty -> normal
+ try hs.pump(400);
+}
+
fn writeReplayFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
const file_content =
"const std = @import(\"std\");\n" ++
@@ -191,7 +204,7 @@ pub fn main(init: std.process.Init) !void {
// the resize counter to 1 and repaints, so "resizes=1" shows in the debug
// overlay. Confirms the app is up and handled its first size.
try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");
- try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open");
+ try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");
// 1b. Dump is a global-tagbar builtin. It writes a backend-independent ZON
// snapshot with compact pane ids, layout columns, rendered pane text, and
@@ -227,7 +240,7 @@ pub fn main(init: std.process.Init) !void {
}
const tty_bytes = try dump.decodeBytes(gpa, dumped.panes[0].terminal.?.stream_b64);
defer gpa.free(tty_bytes);
- if (std.mem.indexOf(u8, tty_bytes, "build.zig") == null) {
+ if (std.mem.indexOf(u8, tty_bytes, ls_ready) == null) {
hs.dump("Dump terminal byte stream does not replay the visible ls output");
return error.ExpectFailed;
}
@@ -238,7 +251,7 @@ pub fn main(init: std.process.Init) !void {
defer replay_h.deinit();
try replay_h.pump(1200);
try replay_h.expectContains("Kill Newcol", "replay did not render the dump topbar");
- try replay_h.expectContains("build.zig", "replay did not rebuild the terminal from the dumped byte stream");
+ try replay_h.expectContains(ls_ready, "replay did not rebuild the terminal from the dumped byte stream");
try replay_h.expectNotContains("\xc2\xaa", "replay rendered corrupt prefix bytes");
}
{
@@ -307,7 +320,7 @@ pub fn main(init: std.process.Init) !void {
// 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app
// boots in the project dir, so a known file shows up as command output
// (visible even in normal mode — only prompt rows are hidden).
- try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open");
+ try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");
// 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor.
// Drop a one-line executable, echo its path onto body row 0 (clear homes the
@@ -518,6 +531,7 @@ pub fn main(init: std.process.Init) !void {
}
try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
try hs.pump(700); // settle the reflow
+ try clearRightColumnTerminal(&hs);
try hs.send("\x1b[<0;60;26M\x1b[<0;60;26m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar)
try hs.pump(400);
try hs.send("i"); // normal -> insert so typing composes the path:line
@@ -615,9 +629,10 @@ pub fn main(init: std.process.Init) !void {
hs_save.trace = config.trace;
defer hs_save.deinit();
try h.writeFile("/tmp/pardesE2Esave.txt", "ZZORIG_SAVE_SHOULD_DISAPPEAR_AND_TAIL\n");
- try hs_save.expectWaitFor("build.zig", 8000, "save regression app never auto-ls'd");
+ try hs_save.expectWaitFor(ls_ready, 8000, "save regression app never auto-ls'd");
try hs_save.resize(30, 100);
try hs_save.pump(700);
+ try clearRightColumnTerminal(&hs_save);
try hs_save.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
try hs_save.pump(400);
try hs_save.send("i");
@@ -705,7 +720,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs3 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs3.deinit();
- try hs3.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs3.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
// pin the cursor on the top-left pane and type a marker (insert mode).
try hs3.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // left-click -> normal + pin (+1 row: global tagbar)
try hs3.pump(400);
@@ -743,7 +758,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs4 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs4.deinit();
- try hs4.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs4.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
try hs4.send("\x02"); // normal -> tty
try hs4.pump(500);
try hs4.send("clear\r"); // blank the pane, prompt to row 0
@@ -771,7 +786,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs5 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs5.deinit();
- try hs5.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs5.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
{
var ppm: std.ArrayList(u8) = .empty;
defer ppm.deinit(gpa);
@@ -815,11 +830,12 @@ pub fn main(init: std.process.Init) !void {
{
var hs6 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs6.deinit();
- try hs6.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs6.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
try h.writeFile("/tmp/pardesE2Efa.txt", "ZZFILEA\nfa1\nfa2\nfa3\nfa4\nfa5\nfa6\nfa7\nfa8\nfa9\n");
try h.writeFile("/tmp/pardesE2Efb.txt", "ZZFILEB\nfb1\nfb2\nfb3\nfb4\nfb5\nfb6\nfb7\nfb8\nfb9\n");
try hs6.resize(30, 100); // clean geometry: right column starts ~col 51
try hs6.pump(700);
+ try clearRightColumnTerminal(&hs6);
// open file A from the right-column terminal: the FIRST doc pane, so it
// takes the stack-at-top fallback (no existing doc pane to split).
try hs6.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
@@ -865,7 +881,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs7 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs7.deinit();
- try hs7.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs7.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
try hs7.resize(30, 120); // 3 columns of ~40 each
try hs7.pump(700);
// add a 3rd column via the global-tagbar "Newcol" (cols 5-10, 0-based).
@@ -917,7 +933,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs8 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs8.deinit();
- try hs8.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs8.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
try hs8.send("\x02"); // normal -> tty
try hs8.pump(600);
// -icanon so the reply's bytes reach `read` without a newline; read until the
@@ -938,7 +954,7 @@ pub fn main(init: std.process.Init) !void {
{
var hs9 = try Harness.init(gpa, exe.ptr, 24, 80);
defer hs9.deinit();
- try hs9.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs9.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
try hs9.send("\x02"); // normal -> tty
try hs9.pump(600);
// read until 'c' (primary DA ends in 'c'); the kitty reply ESC[?0u has no 'c',
@@ -957,7 +973,7 @@ pub fn main(init: std.process.Init) !void {
{
var hsc = try Harness.init(gpa, exe.ptr, 24, 80);
defer hsc.deinit();
- try hsc.expectWaitFor("build.zig", 8000, "chord test: fresh terminal never auto-ls'd");
+ try hsc.expectWaitFor(ls_ready, 8000, "chord test: fresh terminal never auto-ls'd");
try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
try hsc.send("\x02"); // normal -> tty
try hsc.pump(500);
@@ -980,7 +996,7 @@ pub fn main(init: std.process.Init) !void {
{
var hscl = try Harness.init(gpa, exe.ptr, 24, 80);
defer hscl.deinit();
- try hscl.expectWaitFor("build.zig", 8000, "clear test: fresh terminal never auto-ls'd");
+ try hscl.expectWaitFor(ls_ready, 8000, "clear test: fresh terminal never auto-ls'd");
try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
try hscl.send("\x02");
try hscl.pump(500);
@@ -1005,7 +1021,7 @@ pub fn main(init: std.process.Init) !void {
{
var hsr = try Harness.init(gpa, exe.ptr, 24, 80);
defer hsr.deinit();
- try hsr.expectWaitFor("build.zig", 8000, "replace test: fresh terminal never auto-ls'd");
+ try hsr.expectWaitFor(ls_ready, 8000, "replace test: fresh terminal never auto-ls'd");
try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
try hsr.send("\x02");
try hsr.pump(500);
@@ -1029,7 +1045,7 @@ pub fn main(init: std.process.Init) !void {
{
var hsb = try Harness.init(gpa, exe.ptr, 24, 80);
defer hsb.deinit();
- try hsb.expectWaitFor("build.zig", 8000, "builtin-chord test: fresh terminal never auto-ls'd");
+ try hsb.expectWaitFor(ls_ready, 8000, "builtin-chord test: fresh terminal never auto-ls'd");
try hsb.send("\x02");
try hsb.pump(500);
try hsb.send("clear; printf 'ZZBUILTARG\\nDebug\\n'\r");
diff --git a/tests_syntax_perf.zig b/tests_syntax_perf.zig
new file mode 100644
index 00000000..4179cd38
--- /dev/null
+++ b/tests_syntax_perf.zig
@@ -0,0 +1,795 @@
+const std = @import("std");
+const h = @import("e2e_harness.zig");
+
+const Harness = h.Harness;
+const linux = std.os.linux;
+const posix = std.posix;
+
+const payload_len = 64;
+const wheel_events = 240;
+const j_events = 240;
+const ctrl_d_events = 60;
+const delete_line_input = "xxxxxxxxxxxxxxxxd";
+const delete_select_lines = delete_line_input.len - 1;
+const scroll_marker_line = wheel_events;
+const marker_wait_ms = 60_000;
+const scroll_quiet_ms = 1000;
+const large_stdlib_path = "/usr/lib/zig/std/Io/Threaded.zig";
+const target_ratio_x100 = 150;
+const movement_ratio_floor_us = 20_000;
+const startup_first_frame_target_us = 15_000;
+const startup_samples = 5;
+
+fn nowNs() u64 {
+ var ts: linux.timespec = undefined;
+ _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
+ return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
+}
+
+fn fillPayload(buf: *[payload_len]u8, marker: []const u8) []const u8 {
+ const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ for (buf, 0..) |*ch, i| ch.* = alphabet[(i * 7 + marker.len * 11) % alphabet.len];
+ @memcpy(buf[0..marker.len], marker);
+ return buf[0..marker.len];
+}
+
+fn waitRawGrowth(hs: *Harness, start_len: usize, ms: i64) !bool {
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 5) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ if (hs.raw.items.len > start_len) return true;
+ }
+ }
+ return hs.raw.items.len > start_len;
+}
+
+fn waitForOutputQuiet(hs: *Harness, quiet_ms: i64, max_ms: i64) !bool {
+ const deadline = nowNs() + @as(u64, @intCast(max_ms)) * 1_000_000;
+ var quiet_deadline = nowNs() + @as(u64, @intCast(quiet_ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ if (nowNs() >= quiet_deadline) return true;
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 10) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ quiet_deadline = nowNs() + @as(u64, @intCast(quiet_ms)) * 1_000_000;
+ }
+ }
+ return false;
+}
+
+fn leftPaneTopLine(hs: *Harness) !usize {
+ const text = try hs.screenText();
+ defer hs.gpa.free(text);
+ var rows = std.mem.splitScalar(u8, text, '\n');
+ var row_idx: usize = 0;
+ while (rows.next()) |row| : (row_idx += 1) {
+ if (row_idx < 2) continue;
+ if (row_idx > 2) break;
+ const trimmed = std.mem.trimStart(u8, row, " ");
+ var end: usize = 0;
+ while (end < trimmed.len and std.ascii.isDigit(trimmed[end])) : (end += 1) {}
+ if (end > 0) return std.fmt.parseInt(usize, trimmed[0..end], 10) catch 0;
+ }
+ return 0;
+}
+
+fn waitForTopLineAbove(hs: *Harness, start_line: usize, ms: i64) !?usize {
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 10) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ }
+ const top_line = try leftPaneTopLine(hs);
+ if (top_line > start_line) return top_line;
+ }
+ return null;
+}
+
+fn waitForScreenMissing(hs: *Harness, needle: []const u8, ms: i64) !bool {
+ {
+ const text = try hs.screenText();
+ defer hs.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) == null) return true;
+ }
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 10) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ }
+ const text = try hs.screenText();
+ defer hs.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) == null) return true;
+ }
+ return false;
+}
+
+fn waitForScreenContainsPoll(hs: *Harness, needle: []const u8, ms: i64, poll_ms: i32) !bool {
+ {
+ const text = try hs.screenText();
+ defer hs.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) return true;
+ }
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, poll_ms) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ }
+ const text = try hs.screenText();
+ defer hs.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) return true;
+ }
+ return false;
+}
+
+fn waitForRawContainsPoll(hs: *Harness, needle: []const u8, ms: i64, poll_ms: i32) !bool {
+ if (std.mem.indexOf(u8, hs.raw.items, needle) != null) return true;
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, poll_ms) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ hs.stream.nextSlice(buf[0..n]);
+ if (std.mem.indexOf(u8, hs.raw.items, needle) != null) return true;
+ }
+ }
+ return std.mem.indexOf(u8, hs.raw.items, needle) != null;
+}
+
+const startup_metric_prefix = "PARDES_STARTUP_US=";
+
+fn parseStartupMetric(raw: []const u8) ?u64 {
+ const start = std.mem.indexOf(u8, raw, startup_metric_prefix) orelse return null;
+ var i = start + startup_metric_prefix.len;
+ const digit_start = i;
+ while (i < raw.len and std.ascii.isDigit(raw[i])) : (i += 1) {}
+ if (i == digit_start) return null;
+ return std.fmt.parseInt(u64, raw[digit_start..i], 10) catch null;
+}
+
+fn waitForStartupMetric(hs: *Harness, ms: i64, poll_ms: i32) !?u64 {
+ if (parseStartupMetric(hs.raw.items)) |value| return value;
+ const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
+ var buf: [4096]u8 = undefined;
+ while (nowNs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, poll_ms) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(hs.master, &buf) catch break;
+ if (n == 0) break;
+ try hs.raw.appendSlice(hs.gpa, buf[0..n]);
+ if (parseStartupMetric(hs.raw.items)) |value| return value;
+ }
+ }
+ return parseStartupMetric(hs.raw.items);
+}
+
+fn wheelDownBurst(gpa: std.mem.Allocator) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ var i: usize = 0;
+ while (i < wheel_events) : (i += 1)
+ try out.appendSlice(gpa, "\x1b[<65;10;6M");
+ return out.toOwnedSlice(gpa);
+}
+
+const Movement = enum { wheel, j, ctrl_d };
+
+fn movementName(movement: Movement) []const u8 {
+ return switch (movement) {
+ .wheel => "wheel",
+ .j => "j",
+ .ctrl_d => "ctrl_d",
+ };
+}
+
+fn movementEventCount(movement: Movement) usize {
+ return switch (movement) {
+ .wheel => wheel_events,
+ .j => j_events,
+ .ctrl_d => ctrl_d_events,
+ };
+}
+
+fn movementInput(gpa: std.mem.Allocator, movement: Movement) ![]u8 {
+ if (movement == .wheel) return wheelDownBurst(gpa);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ var i: usize = 0;
+ const bytes = switch (movement) {
+ .wheel => unreachable,
+ .j => "j",
+ .ctrl_d => "\x04",
+ };
+ while (i < movementEventCount(movement)) : (i += 1)
+ try out.appendSlice(gpa, bytes);
+ return out.toOwnedSlice(gpa);
+}
+
+fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
+ const rc = linux.open(path, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ var buf: [16 * 1024]u8 = undefined;
+ while (true) {
+ const n = posix.read(fd, &buf) catch return error.ReadFailed;
+ if (n == 0) break;
+ try out.appendSlice(gpa, buf[0..n]);
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+fn appendZigMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8) !void {
+ const line = try std.fmt.allocPrint(gpa, "const {s} = \"visible\";\n", .{marker});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+}
+
+fn appendTextMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8) !void {
+ const line = try std.fmt.allocPrint(gpa, "{s} visible\n", .{marker});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+}
+
+fn appendMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8, highlighted: bool) !void {
+ if (highlighted) {
+ try appendZigMarker(gpa, out, marker);
+ } else {
+ try appendTextMarker(gpa, out, marker);
+ }
+}
+
+fn makeSmallFixture(gpa: std.mem.Allocator, open_marker: []const u8, scroll_marker: []const u8, highlighted: bool) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ try appendMarker(gpa, &out, open_marker, highlighted);
+ if (highlighted) {
+ try out.appendSlice(gpa, "const std = @import(\"std\");\n");
+ try out.appendSlice(gpa, "pub fn perf_entry() usize {\n");
+ try out.appendSlice(gpa, " return perf_func_0() + perf_func_199();\n");
+ try out.appendSlice(gpa, "}\n");
+ }
+ var line_no: usize = if (highlighted) 5 else 1;
+ var i: usize = 0;
+ while (line_no < scroll_marker_line) : ({
+ i += 1;
+ line_no += 1;
+ }) {
+ const line = if (highlighted)
+ try std.fmt.allocPrint(gpa, "fn perf_func_{d}() usize {{ return {d} + @as(usize, {d}); }}\n", .{ i, i, i % 17 })
+ else
+ try std.fmt.allocPrint(gpa, "plain perf line {d}: words numbers strings comments\n", .{i});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+ }
+ try appendMarker(gpa, &out, scroll_marker, highlighted);
+ line_no += 1;
+ while (line_no < scroll_marker_line + 40) : ({
+ i += 1;
+ line_no += 1;
+ }) {
+ const line = if (highlighted)
+ try std.fmt.allocPrint(gpa, "fn perf_tail_{d}() usize {{ return {d}; }}\n", .{ i, i })
+ else
+ try std.fmt.allocPrint(gpa, "plain perf tail {d}: words numbers strings comments\n", .{i});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+fn makeStdlibFixture(gpa: std.mem.Allocator, source: []const u8, open_marker: []const u8, scroll_marker: []const u8, highlighted: bool) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ try appendMarker(gpa, &out, open_marker, highlighted);
+ var line_no: usize = 1;
+ var lines = std.mem.splitScalar(u8, source, '\n');
+ while (lines.next()) |line| {
+ if (line_no == scroll_marker_line) {
+ try appendMarker(gpa, &out, scroll_marker, highlighted);
+ line_no += 1;
+ }
+ try out.appendSlice(gpa, line);
+ try out.append(gpa, '\n');
+ line_no += 1;
+ }
+ while (line_no <= scroll_marker_line) : (line_no += 1) {
+ if (line_no == scroll_marker_line) {
+ try appendMarker(gpa, &out, scroll_marker, highlighted);
+ } else if (highlighted) {
+ const line = try std.fmt.allocPrint(gpa, "fn perf_padding_{d}() void {{}}\n", .{line_no});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+ } else {
+ const line = try std.fmt.allocPrint(gpa, "plain stdlib padding {d}\n", .{line_no});
+ defer gpa.free(line);
+ try out.appendSlice(gpa, line);
+ }
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+fn lineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+const Fixture = struct {
+ name: []const u8,
+ path: []const u8,
+ open_marker: []const u8,
+ edit_marker: []const u8,
+ scroll_marker: []const u8,
+ highlighted: bool,
+ bytes: usize,
+ lines: usize,
+};
+
+const BenchResult = struct {
+ startup_app_first_frame_us: u64,
+ startup_spawn_first_frame_us: u64,
+ startup_shell_ready_us: u64,
+ open_visible_us: u64,
+ open_repaint_us: ?u64,
+ edit_us: u64,
+ delete_line_us: u64,
+ undo_us: u64,
+ move_first_us: u64,
+ move_done_us: u64,
+ move_active_us: u64,
+ move_top_line: usize,
+};
+
+const StartupResult = struct {
+ internal_first_frame_us: u64,
+ harness_first_frame_us: u64,
+ spawn_first_frame_us: u64,
+};
+
+const BestOps = struct {
+ open_visible_us: u64 = std.math.maxInt(u64),
+ edit_us: u64 = std.math.maxInt(u64),
+ delete_line_us: u64 = std.math.maxInt(u64),
+ undo_us: u64 = std.math.maxInt(u64),
+
+ fn add(self: *BestOps, result: BenchResult) void {
+ self.open_visible_us = @min(self.open_visible_us, result.open_visible_us);
+ self.edit_us = @min(self.edit_us, result.edit_us);
+ self.delete_line_us = @min(self.delete_line_us, result.delete_line_us);
+ self.undo_us = @min(self.undo_us, result.undo_us);
+ }
+};
+
+const StartupStats = struct {
+ best_us: u64 = std.math.maxInt(u64),
+ worst_us: u64 = 0,
+ total_us: u64 = 0,
+ count: usize = 0,
+
+ fn add(self: *StartupStats, us: u64) void {
+ self.best_us = @min(self.best_us, us);
+ self.worst_us = @max(self.worst_us, us);
+ self.total_us += us;
+ self.count += 1;
+ }
+
+ fn avg(self: StartupStats) u64 {
+ if (self.count == 0) return 0;
+ return self.total_us / self.count;
+ }
+};
+
+fn runFixture(gpa: std.mem.Allocator, exe: [*:0]const u8, fixture: Fixture, movement: Movement) !BenchResult {
+ const startup_spawn_t0 = nowNs();
+ var hs = try Harness.init(gpa, exe, 30, 100);
+ defer hs.deinit();
+ const startup_app_t0 = nowNs();
+
+ if (!try waitForRawContainsPoll(&hs, "Kill Newcol", 8000, 1)) {
+ hs.dump("app first frame did not render before syntax perf");
+ return error.StartupFirstFrameTimeout;
+ }
+ const startup_app_first_frame_us = (nowNs() - startup_app_t0) / 1000;
+ const startup_spawn_first_frame_us = (nowNs() - startup_spawn_t0) / 1000;
+ try hs.expectWaitFor("tests.zig", 8000, "app never auto-ls'd before syntax perf");
+ const startup_shell_ready_us = (nowNs() - startup_spawn_t0) / 1000;
+ try hs.send("\x02"); // normal -> tty
+ try hs.pump(500);
+ {
+ var cmd_buf: [256]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&cmd_buf, "clear; echo {s}\r", .{fixture.path});
+ try hs.send(cmd);
+ }
+ try hs.expectWaitFor(fixture.path, 8000, "perf path did not render in tty");
+ try hs.send("\x02"); // tty -> normal
+ try hs.expectWaitFor("nm /", 6000, "failed to leave tty before syntax perf open");
+
+ const open_t0 = nowNs();
+ try hs.send("\x1b[<2;8;3M\x1b[<2;8;3m"); // right-click body row 0 path
+ try hs.expectWaitFor(fixture.open_marker, marker_wait_ms, "syntax perf file did not open");
+ const open_visible_us = (nowNs() - open_t0) / 1000;
+ var open_repaint_us: ?u64 = null;
+ if (fixture.highlighted) {
+ open_repaint_us = open_visible_us;
+ }
+ if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
+ hs.dump("syntax perf open did not go quiet");
+ return error.OpenQuietTimeout;
+ }
+
+ try hs.send("i");
+ try hs.expectWaitFor("in /tmp/pardes_perf_", 5000, "syntax perf did not enter file insert mode");
+
+ var payload: [payload_len]u8 = undefined;
+ const edit_marker = fillPayload(&payload, fixture.edit_marker);
+ const edit_t0 = nowNs();
+ try hs.send(&payload);
+ try hs.expectWaitFor(edit_marker, marker_wait_ms, "syntax perf edit marker did not appear");
+ const edit_us = (nowNs() - edit_t0) / 1000;
+
+ try hs.send("\x1b"); // insert -> normal
+ try hs.expectWaitFor("nm /tmp/pardes_perf_", 5000, "syntax perf did not leave insert mode");
+ if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
+ hs.dump("syntax perf edit repaint did not go quiet");
+ return error.EditRepaintQuietTimeout;
+ }
+
+ const delete_t0 = nowNs();
+ try hs.send(delete_line_input);
+ if (!try waitForScreenMissing(&hs, edit_marker, marker_wait_ms)) {
+ hs.dump("syntax perf line delete marker did not disappear");
+ return error.DeleteLineTimeout;
+ }
+ const delete_line_us = (nowNs() - delete_t0) / 1000;
+ if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
+ hs.dump("syntax perf line delete repaint did not go quiet");
+ return error.DeleteLineQuietTimeout;
+ }
+
+ const undo_t0 = nowNs();
+ try hs.send("u");
+ try hs.expectWaitFor(edit_marker, marker_wait_ms, "syntax perf undo did not restore edit marker");
+ const undo_us = (nowNs() - undo_t0) / 1000;
+ if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
+ hs.dump("syntax perf undo repaint did not go quiet");
+ return error.UndoQuietTimeout;
+ }
+
+ const move_input = try movementInput(gpa, movement);
+ defer gpa.free(move_input);
+
+ const start_top_line = @max(try leftPaneTopLine(&hs), 1);
+ const move_t0 = nowNs();
+ try hs.send(move_input);
+ if (try waitForTopLineAbove(&hs, start_top_line, marker_wait_ms)) |_| {
+ // Progress observed; keep draining below until the movement settles.
+ } else {
+ hs.dump("syntax perf movement did not move the visible file window");
+ return error.MovementDidNotMove;
+ }
+ const move_first_us = (nowNs() - move_t0) / 1000;
+ if (!try waitForOutputQuiet(&hs, scroll_quiet_ms, marker_wait_ms)) {
+ hs.dump("syntax perf movement did not go quiet");
+ return error.MovementQuietTimeout;
+ }
+ const move_done_us = (nowNs() - move_t0) / 1000;
+ const move_top_line = try leftPaneTopLine(&hs);
+
+ return .{
+ .startup_app_first_frame_us = startup_app_first_frame_us,
+ .startup_spawn_first_frame_us = startup_spawn_first_frame_us,
+ .startup_shell_ready_us = startup_shell_ready_us,
+ .open_visible_us = open_visible_us,
+ .open_repaint_us = open_repaint_us,
+ .edit_us = edit_us,
+ .delete_line_us = delete_line_us,
+ .undo_us = undo_us,
+ .move_first_us = move_first_us,
+ .move_done_us = move_done_us,
+ .move_active_us = move_done_us -| (@as(u64, scroll_quiet_ms) * 1000),
+ .move_top_line = move_top_line,
+ };
+}
+
+fn runStartup(gpa: std.mem.Allocator, exe: [*:0]const u8) !StartupResult {
+ const startup_spawn_t0 = nowNs();
+ var hs = try Harness.initArgs(gpa, exe, 30, 100, "--perf-startup");
+ defer hs.deinit();
+ const startup_app_t0 = nowNs();
+
+ const internal_first_frame_us = (try waitForStartupMetric(&hs, 8000, 1)) orelse {
+ hs.dump("app first frame did not render before startup perf");
+ return error.StartupFirstFrameTimeout;
+ };
+ if (!try waitForRawContainsPoll(&hs, "Kill Newcol", 8000, 1)) {
+ hs.dump("app first frame marker rendered without topbar text");
+ return error.StartupFirstFrameTimeout;
+ }
+ return .{
+ .internal_first_frame_us = internal_first_frame_us,
+ .harness_first_frame_us = (nowNs() - startup_app_t0) / 1000,
+ .spawn_first_frame_us = (nowNs() - startup_spawn_t0) / 1000,
+ };
+}
+
+fn printResult(fixture: Fixture, movement: Movement, result: BenchResult) void {
+ std.debug.print(
+ "case={s} movement={s} highlight={d} bytes={d} lines={d} startup_app_first_frame_us={d} startup_spawn_first_frame_us={d} startup_shell_ready_us={d} open_visible_us={d} open_repaint_us={d} edit_us={d} edit_us_per_char={d} delete_line_us={d} undo_us={d} move_first_us={d} move_done_us={d} move_active_us={d} move_top_line={d}\n",
+ .{
+ fixture.name,
+ movementName(movement),
+ @intFromBool(fixture.highlighted),
+ fixture.bytes,
+ fixture.lines,
+ result.startup_app_first_frame_us,
+ result.startup_spawn_first_frame_us,
+ result.startup_shell_ready_us,
+ result.open_visible_us,
+ result.open_repaint_us orelse 0,
+ result.edit_us,
+ result.edit_us / payload_len,
+ result.delete_line_us,
+ result.undo_us,
+ result.move_first_us,
+ result.move_done_us,
+ result.move_active_us,
+ result.move_top_line,
+ },
+ );
+}
+
+fn ratioX100(highlight_us: u64, plain_us: u64) u64 {
+ if (plain_us == 0) return 0;
+ return (highlight_us * 100 + plain_us / 2) / plain_us;
+}
+
+fn movementRatioX100(highlight_us: u64, plain_us: u64) u64 {
+ return ratioX100(highlight_us, @max(plain_us, movement_ratio_floor_us));
+}
+
+fn printRatioSummary(movement: Movement, small_txt: BenchResult, small_zig: BenchResult, large_txt: BenchResult, large_zig: BenchResult) void {
+ std.debug.print(
+ "ratio movement={s} small_move_active_x100={d} large_move_active_x100={d} target_x100={d} small_open_visible_x100={d} large_open_visible_x100={d} small_edit_x100={d} large_edit_x100={d} small_delete_line_x100={d} large_delete_line_x100={d} small_undo_x100={d} large_undo_x100={d}\n",
+ .{
+ movementName(movement),
+ movementRatioX100(small_zig.move_active_us, small_txt.move_active_us),
+ movementRatioX100(large_zig.move_active_us, large_txt.move_active_us),
+ target_ratio_x100,
+ ratioX100(small_zig.open_visible_us, small_txt.open_visible_us),
+ ratioX100(large_zig.open_visible_us, large_txt.open_visible_us),
+ ratioX100(small_zig.edit_us, small_txt.edit_us),
+ ratioX100(large_zig.edit_us, large_txt.edit_us),
+ ratioX100(small_zig.delete_line_us, small_txt.delete_line_us),
+ ratioX100(large_zig.delete_line_us, large_txt.delete_line_us),
+ ratioX100(small_zig.undo_us, small_txt.undo_us),
+ ratioX100(large_zig.undo_us, large_txt.undo_us),
+ },
+ );
+}
+
+fn checkMovementRatio(movement: Movement, size_name: []const u8, highlighted_us: u64, plain_us: u64) !void {
+ const ratio = movementRatioX100(highlighted_us, plain_us);
+ if (ratio > target_ratio_x100) {
+ std.debug.print(
+ "FAIL movement={s} size={s} move_active_x100={d} target_x100={d} highlighted_us={d} plain_us={d}\n",
+ .{ movementName(movement), size_name, ratio, target_ratio_x100, highlighted_us, plain_us },
+ );
+ return error.HighlightMovementPerfRegression;
+ }
+}
+
+fn checkMovementRatios(movement: Movement, small_txt: BenchResult, small_zig: BenchResult, large_txt: BenchResult, large_zig: BenchResult) !void {
+ try checkMovementRatio(movement, "small", small_zig.move_active_us, small_txt.move_active_us);
+ try checkMovementRatio(movement, "large", large_zig.move_active_us, large_txt.move_active_us);
+}
+
+fn printBestOpsRatioSummary(best: [4]BestOps) void {
+ std.debug.print(
+ "ratio aggregate=best_of_movements target_x100={d} small_open_visible_x100={d} large_open_visible_x100={d} small_edit_x100={d} large_edit_x100={d} small_delete_line_x100={d} large_delete_line_x100={d} small_undo_x100={d} large_undo_x100={d}\n",
+ .{
+ target_ratio_x100,
+ ratioX100(best[1].open_visible_us, best[0].open_visible_us),
+ ratioX100(best[3].open_visible_us, best[2].open_visible_us),
+ ratioX100(best[1].edit_us, best[0].edit_us),
+ ratioX100(best[3].edit_us, best[2].edit_us),
+ ratioX100(best[1].delete_line_us, best[0].delete_line_us),
+ ratioX100(best[3].delete_line_us, best[2].delete_line_us),
+ ratioX100(best[1].undo_us, best[0].undo_us),
+ ratioX100(best[3].undo_us, best[2].undo_us),
+ },
+ );
+}
+
+fn printStartupSummary(name: []const u8, target_us: ?u64, stats: StartupStats) void {
+ if (target_us) |target| {
+ std.debug.print(
+ "startup summary metric={s} target_us={d} samples={d} best_us={d} avg_us={d} worst_us={d}\n",
+ .{ name, target, stats.count, stats.best_us, stats.avg(), stats.worst_us },
+ );
+ return;
+ }
+ std.debug.print(
+ "startup summary metric={s} samples={d} best_us={d} avg_us={d} worst_us={d}\n",
+ .{ name, stats.count, stats.best_us, stats.avg(), stats.worst_us },
+ );
+}
+
+fn checkStartupAverage(name: []const u8, target_us: u64, stats: StartupStats) !void {
+ const avg_us = stats.avg();
+ if (avg_us <= target_us) return;
+ std.debug.print(
+ "FAIL startup metric={s} avg_us={d} target_us={d} samples={d} best_us={d} worst_us={d}\n",
+ .{ name, avg_us, target_us, stats.count, stats.best_us, stats.worst_us },
+ );
+ return error.StartupPerfRegression;
+}
+
+pub fn main(init: std.process.Init) !void {
+ const gpa = init.gpa;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len != 2) {
+ std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
+ return error.BadArgs;
+ }
+
+ const small_txt_path = "/tmp/pardes_perf_small_plain.txt";
+ const small_zig_path = "/tmp/pardes_perf_small_code.zig";
+ const large_txt_path = "/tmp/pardes_perf_large_plain.txt";
+ const large_zig_path = "/tmp/pardes_perf_large_code.zig";
+
+ const stdlib_content = try readFileAlloc(gpa, large_stdlib_path);
+ defer gpa.free(stdlib_content);
+ const small_txt_content = try makeSmallFixture(gpa, "SMALLTXTOPENPERFEND", "SMALLTXTSCROLLPERFEND", false);
+ defer gpa.free(small_txt_content);
+ const small_zig_content = try makeSmallFixture(gpa, "SMALLZIGOPENPERFEND", "SMALLZIGSCROLLPERFEND", true);
+ defer gpa.free(small_zig_content);
+ const large_txt_content = try makeStdlibFixture(gpa, stdlib_content, "LARGETXTOPENPERFEND", "LARGETXTSCROLLPERFEND", false);
+ defer gpa.free(large_txt_content);
+ const large_zig_content = try makeStdlibFixture(gpa, stdlib_content, "LARGEZIGOPENPERFEND", "LARGEZIGSCROLLPERFEND", true);
+ defer gpa.free(large_zig_content);
+
+ try h.writeFile(small_txt_path, small_txt_content);
+ try h.writeFile(small_zig_path, small_zig_content);
+ try h.writeFile(large_txt_path, large_txt_content);
+ try h.writeFile(large_zig_path, large_zig_content);
+
+ const fixtures = [_]Fixture{
+ .{
+ .name = "small_txt",
+ .path = small_txt_path,
+ .open_marker = "SMALLTXTOPENPERFEND",
+ .edit_marker = "SMALLTXTEDITPERFEND",
+ .scroll_marker = "SMALLTXTSCROLLPERFEND",
+ .highlighted = false,
+ .bytes = small_txt_content.len,
+ .lines = lineCount(small_txt_content),
+ },
+ .{
+ .name = "small_zig",
+ .path = small_zig_path,
+ .open_marker = "SMALLZIGOPENPERFEND",
+ .edit_marker = "SMALLZIGEDITPERFEND",
+ .scroll_marker = "SMALLZIGSCROLLPERFEND",
+ .highlighted = true,
+ .bytes = small_zig_content.len,
+ .lines = lineCount(small_zig_content),
+ },
+ .{
+ .name = "large_txt",
+ .path = large_txt_path,
+ .open_marker = "LARGETXTOPENPERFEND",
+ .edit_marker = "LARGETXTEDITPERFEND",
+ .scroll_marker = "LARGETXTSCROLLPERFEND",
+ .highlighted = false,
+ .bytes = large_txt_content.len,
+ .lines = lineCount(large_txt_content),
+ },
+ .{
+ .name = "large_zig",
+ .path = large_zig_path,
+ .open_marker = "LARGEZIGOPENPERFEND",
+ .edit_marker = "LARGEZIGEDITPERFEND",
+ .scroll_marker = "LARGEZIGSCROLLPERFEND",
+ .highlighted = true,
+ .bytes = large_zig_content.len,
+ .lines = lineCount(large_zig_content),
+ },
+ };
+
+ std.debug.print(
+ "syntax-perf backend=vaxis chars_per_edit={d} delete_select_lines={d} wheel_events={d} j_events={d} ctrl_d_events={d} scroll_quiet_ms={d} scroll_marker_line={d} large_stdlib={s}\n",
+ .{ payload_len, delete_select_lines, wheel_events, j_events, ctrl_d_events, scroll_quiet_ms, scroll_marker_line, large_stdlib_path },
+ );
+ var startup_only_internal_stats: StartupStats = .{};
+ var startup_only_harness_stats: StartupStats = .{};
+ var startup_only_spawn_stats: StartupStats = .{};
+ for (0..startup_samples) |sample| {
+ const startup = try runStartup(gpa, args[1].ptr);
+ startup_only_internal_stats.add(startup.internal_first_frame_us);
+ startup_only_harness_stats.add(startup.harness_first_frame_us);
+ startup_only_spawn_stats.add(startup.spawn_first_frame_us);
+ std.debug.print(
+ "startup_sample={d} internal_first_frame_us={d} harness_first_frame_us={d} spawn_first_frame_us={d}\n",
+ .{ sample, startup.internal_first_frame_us, startup.harness_first_frame_us, startup.spawn_first_frame_us },
+ );
+ }
+ printStartupSummary("startup_only_internal_first_frame", null, startup_only_internal_stats);
+ printStartupSummary("startup_only_harness_first_frame", startup_first_frame_target_us, startup_only_harness_stats);
+ printStartupSummary("startup_only_spawn_first_frame", null, startup_only_spawn_stats);
+ try checkStartupAverage("startup_only_harness_first_frame", startup_first_frame_target_us, startup_only_harness_stats);
+
+ const movements = [_]Movement{ .wheel, .j, .ctrl_d };
+ var best_ops: [4]BestOps = .{ .{}, .{}, .{}, .{} };
+ var startup_app_first_frame_stats: StartupStats = .{};
+ var startup_spawn_first_frame_stats: StartupStats = .{};
+ var startup_shell_ready_stats: StartupStats = .{};
+ for (movements) |movement| {
+ const small_txt = try runFixture(gpa, args[1].ptr, fixtures[0], movement);
+ startup_app_first_frame_stats.add(small_txt.startup_app_first_frame_us);
+ startup_spawn_first_frame_stats.add(small_txt.startup_spawn_first_frame_us);
+ startup_shell_ready_stats.add(small_txt.startup_shell_ready_us);
+ best_ops[0].add(small_txt);
+ printResult(fixtures[0], movement, small_txt);
+ const small_zig = try runFixture(gpa, args[1].ptr, fixtures[1], movement);
+ startup_app_first_frame_stats.add(small_zig.startup_app_first_frame_us);
+ startup_spawn_first_frame_stats.add(small_zig.startup_spawn_first_frame_us);
+ startup_shell_ready_stats.add(small_zig.startup_shell_ready_us);
+ best_ops[1].add(small_zig);
+ printResult(fixtures[1], movement, small_zig);
+ const large_txt = try runFixture(gpa, args[1].ptr, fixtures[2], movement);
+ startup_app_first_frame_stats.add(large_txt.startup_app_first_frame_us);
+ startup_spawn_first_frame_stats.add(large_txt.startup_spawn_first_frame_us);
+ startup_shell_ready_stats.add(large_txt.startup_shell_ready_us);
+ best_ops[2].add(large_txt);
+ printResult(fixtures[2], movement, large_txt);
+ const large_zig = try runFixture(gpa, args[1].ptr, fixtures[3], movement);
+ startup_app_first_frame_stats.add(large_zig.startup_app_first_frame_us);
+ startup_spawn_first_frame_stats.add(large_zig.startup_spawn_first_frame_us);
+ startup_shell_ready_stats.add(large_zig.startup_shell_ready_us);
+ best_ops[3].add(large_zig);
+ printResult(fixtures[3], movement, large_zig);
+ printRatioSummary(movement, small_txt, small_zig, large_txt, large_zig);
+ try checkMovementRatios(movement, small_txt, small_zig, large_txt, large_zig);
+ }
+ printBestOpsRatioSummary(best_ops);
+ printStartupSummary("app_first_frame", null, startup_app_first_frame_stats);
+ printStartupSummary("spawn_first_frame", null, startup_spawn_first_frame_stats);
+ printStartupSummary("shell_ready", null, startup_shell_ready_stats);
+}
diff --git a/tests_tty_perf.zig b/tests_tty_perf.zig
new file mode 100644
index 00000000..06ce9cc3
--- /dev/null
+++ b/tests_tty_perf.zig
@@ -0,0 +1,88 @@
+const std = @import("std");
+const h = @import("e2e_harness.zig");
+
+const Harness = h.Harness;
+const linux = std.os.linux;
+
+const samples = 5;
+const payload_len = 64;
+
+fn nowNs() u64 {
+ var ts: linux.timespec = undefined;
+ _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
+ return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
+}
+
+fn fillPayload(buf: *[payload_len]u8, sample: usize) []const u8 {
+ const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ for (buf, 0..) |*ch, i| ch.* = alphabet[(i + sample * 13) % alphabet.len];
+
+ var marker_buf: [16]u8 = undefined;
+ const marker = std.fmt.bufPrint(&marker_buf, "ZTPERF{d}END", .{sample}) catch unreachable;
+ const start = payload_len - marker.len;
+ @memcpy(buf[start..][0..marker.len], marker);
+ return buf[start..payload_len];
+}
+
+fn clickBodyRow(hs: *Harness, screen_row: usize) !void {
+ var seq_buf: [64]u8 = undefined;
+ const seq = try std.fmt.bufPrint(&seq_buf, "\x1b[<0;3;{d}M\x1b[<0;3;{d}m", .{ screen_row, screen_row });
+ try hs.send(seq);
+}
+
+pub fn main(init: std.process.Init) !void {
+ const gpa = init.gpa;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len != 2) {
+ std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
+ return error.BadArgs;
+ }
+
+ var hs = try Harness.initArgs(gpa, args[1].ptr, 24, 80, "--tty");
+ defer hs.deinit();
+
+ try hs.expectWaitFor("sy /", 6000, "--tty did not start in tty mode");
+ try hs.send("clear\r");
+ try hs.pump(900);
+ try hs.send("\x02");
+ try hs.expectWaitFor("nm /", 6000, "Ctrl-b did not leave tty mode");
+
+ var best_us: u64 = std.math.maxInt(u64);
+ var total_us: u64 = 0;
+
+ std.debug.print("tty-edit-perf backend=vaxis samples={d} chars_per_sample={d}\n", .{ samples, payload_len });
+
+ var sample: usize = 0;
+ while (sample < samples) : (sample += 1) {
+ const row = 5 + sample * 3;
+ try clickBodyRow(&hs, row);
+ try hs.pump(80);
+ try hs.send("i");
+ try hs.expectWaitFor("in /", 3000, "failed to enter insert mode");
+
+ var payload_buf: [payload_len]u8 = undefined;
+ const marker = fillPayload(&payload_buf, sample);
+
+ const t0 = nowNs();
+ try hs.send(&payload_buf);
+ try hs.expectWaitFor(marker, 10_000, "typed marker never appeared");
+ const elapsed_us = (nowNs() - t0) / 1000;
+
+ best_us = @min(best_us, elapsed_us);
+ total_us += elapsed_us;
+ std.debug.print(" sample {d}: elapsed_us={d} us_per_char={d}\n", .{
+ sample + 1,
+ elapsed_us,
+ elapsed_us / payload_len,
+ });
+
+ try hs.send("\x1b");
+ try hs.expectWaitFor("nm /", 3000, "failed to leave insert mode");
+ }
+
+ std.debug.print("tty-edit-perf summary: best_us={d} avg_us={d} best_us_per_char={d}\n", .{
+ best_us,
+ total_us / samples,
+ best_us / payload_len,
+ });
+}
diff --git a/ui_sdl.zig b/ui_sdl.zig
index a86d02f4..0b2a2fd2 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -62,10 +62,15 @@ pub const Options = struct {
// The backend struct is named `Vaxis` so `image.zig`'s `*vaxis.Vaxis` resolves.
pub const Vaxis = SdlBackend;
pub const Tty = SdlTty;
+pub const supports_terminal_prepaint = false;
pub fn Loop(comptime EventType: type) type {
return SdlLoop(EventType);
}
+pub fn adoptAltScreen(vx: *Vaxis) void {
+ _ = vx;
+}
+
const font_ttf = @embedFile("assets/AdwaitaMono-Regular.ttf");
const vert_spv_bytes = if (is_emscripten) "" else @embedFile("ui.vert.spv");
const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv");
diff --git a/ui_vaxis.zig b/ui_vaxis.zig
index 61bf51ca..1a3ce0af 100644
--- a/ui_vaxis.zig
+++ b/ui_vaxis.zig
@@ -31,3 +31,8 @@ pub const Event = vaxis.Event;
pub const Capabilities = vaxis.Capabilities;
pub const Options = vaxis.Options;
pub const CursorShape = vaxis.Cell.CursorShape;
+pub const supports_terminal_prepaint = true;
+
+pub fn adoptAltScreen(vx: *Vaxis) void {
+ vx.state.alt_screen = true;
+}