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 /main.zig | |
| parent | 6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08 (diff) | |
| download | pardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.tar.gz pardes-f3b62fe53b19e36f21f8020831718cbf095d6a7b.zip | |
performance pass
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 310 |
1 files changed, 282 insertions, 28 deletions
@@ -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(); |
