diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-03 17:33:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | f3b62fe53b19e36f21f8020831718cbf095d6a7b (patch) | |
| tree | cdfd008869bb5f5aeb69f0ef30584fec86f6f95b | |
| parent | 6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08 (diff) | |
| download | pardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.tar.gz pardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.zip | |
performance pass
| -rw-r--r-- | build.zig | 44 | ||||
| -rw-r--r-- | main.zig | 310 | ||||
| -rw-r--r-- | paint.zig | 197 | ||||
| -rw-r--r-- | term.zig | 7 | ||||
| -rw-r--r-- | tests.zig | 48 | ||||
| -rw-r--r-- | tests_syntax_perf.zig | 795 | ||||
| -rw-r--r-- | tests_tty_perf.zig | 88 | ||||
| -rw-r--r-- | ui_sdl.zig | 5 | ||||
| -rw-r--r-- | ui_vaxis.zig | 5 |
9 files changed, 1362 insertions, 137 deletions
@@ -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(.{ @@ -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(); @@ -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); @@ -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). @@ -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, + }); +} @@ -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; +} |
