From a43dc216f03c0bfed51ca539d027a90adc689ac8 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 13:50:54 -0300 Subject: tty: coalesce queued input events into one frame; tracy zones behind -Dtracy The tty shell rendered (core.render + full vaxis repaint + tree-sitter re-highlight of the visible window) after EVERY event, so a wheel flick over a highlighted file was 150 re-parses and 150 terminal repaints; the gui shell drains the SDL queue before drawing and never had the issue. Now the tty loop applies every queued input event, then renders once (batch cap 64; pty output keeps a frame per chunk so progress paints and transient-state waits behave exactly as before). Measured with tracy on a 2400-line .zig scroll script: 150 highlightFileRange calls at 1.85ms mean -> 4; render pipeline total 730ms -> 64ms; 364 frames -> 39. Snap suite 18/18. Tracy: -Dtracy= compiles TracyClient.cpp into the binary and turns src/tracy.zig zones live (default off, all no-ops). Sampling, callstacks, system tracing and the crash handler are disabled: tracy's symbol worker SIGSEGVs on this binary's debug info and its crash handler then parks every thread before the first frame. Capture: tracy-capture -o t.tracy; export: tracy-csvexport t.tracy. --- src/pardes.zig | 9 +++++ src/syntax.zig | 3 ++ src/tracy.zig | 50 +++++++++++++++++++++++ src/tty.zig | 126 ++++++++++++++++++++++++++++++++++----------------------- 4 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 src/tracy.zig (limited to 'src') diff --git a/src/pardes.zig b/src/pardes.zig index c4893b25..63943c04 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -14,6 +14,7 @@ const uucode = @import("uucode"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); @@ -3077,6 +3078,8 @@ pub const Pardes = struct { /// re-highlight the visible window of any file whose syntax went stale /// (edit, scroll, load) — visible-range-first so big files stay snappy fn refreshDirtyHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshDirtyHighlights"); + defer tz.end(); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file == null) continue; @@ -3226,6 +3229,8 @@ pub const Pardes = struct { } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); if (r.w <= GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); @@ -3278,7 +3283,9 @@ pub const Pardes = struct { .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; + const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); + tz_body.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { @@ -3303,6 +3310,8 @@ pub const Pardes = struct { // tree-sitter style byte; content starts after the lineno gutter if (p.colors_on) if (pane.file) |*f| { if (f.highlights.len > 0) { + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { diff --git a/src/syntax.zig b/src/syntax.zig index a9914fd7..0d2fa291 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -4,6 +4,7 @@ //! compiles queries at startup; `full` adds ~23 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); +const tracy = @import("tracy.zig"); pub const enabled = config.syntax_highlighting; const minimal_grammars = config.syntax_minimal_grammars; @@ -140,6 +141,8 @@ pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const /// One Syn byte per content byte in [start, end). Caller frees. pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { + const tz = tracy.zone(@src(), "highlightFileRange"); + defer tz.end(); if (!enabled) return &.{}; const ext = std.fs.path.extension(path); const selected = (forExt(ext) catch return &.{}) orelse return &.{}; diff --git a/src/tracy.zig b/src/tracy.zig new file mode 100644 index 00000000..3b0b8621 --- /dev/null +++ b/src/tracy.zig @@ -0,0 +1,50 @@ +//! Minimal Tracy client (the Zig compiler's pattern): `zone(@src(), "name")` +//! ... `.end()`, plus `frameMark()`. Everything compiles to a no-op unless the +//! build sets -Dtracy=, which also compiles and links +//! TracyClient.cpp. Capture with tracy-capture, inspect with tracy-csvexport. +const std = @import("std"); +const builtin = @import("builtin"); + +pub const enable = if (builtin.is_test) false else @import("pardes_config").enable_tracy; + +const SourceLocation = extern struct { + name: ?[*:0]const u8, + function: [*:0]const u8, + file: [*:0]const u8, + line: u32, + color: u32, +}; + +pub const Ctx = if (enable) extern struct { + id: u32, + active: i32, + + pub inline fn end(c: @This()) void { + ___tracy_emit_zone_end(c); + } +} else struct { + pub inline fn end(_: @This()) void {} +}; + +pub inline fn zone(comptime src: std.builtin.SourceLocation, comptime name: [:0]const u8) Ctx { + if (!enable) return .{}; + const static = struct { + const loc: SourceLocation = .{ + .name = name.ptr, + .function = src.fn_name.ptr, + .file = src.file.ptr, + .line = src.line, + .color = 0, + }; + }; + return ___tracy_emit_zone_begin(&static.loc, 1); +} + +pub inline fn frameMark() void { + if (!enable) return; + ___tracy_emit_frame_mark(null); +} + +extern fn ___tracy_emit_zone_begin(srcloc: *const SourceLocation, active: c_int) Ctx; +extern fn ___tracy_emit_zone_end(ctx: Ctx) void; +extern fn ___tracy_emit_frame_mark(name: ?[*:0]const u8) void; diff --git a/src/tty.zig b/src/tty.zig index d9821343..3290cde9 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -7,6 +7,7 @@ const posix = std.posix; const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); +const tracy = @import("tracy.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -87,61 +88,77 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { - const event = if (pending) |ev| blk: { + var event = if (pending) |ev| blk: { pending = null; break :blk ev; } else try loop.nextEvent(); - switch (event) { - .nop, .tick => {}, - .quit => break, - .winsize => |ws| { - try vx.resize(gpa, tty.writer(), ws); - core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); - }, - .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); - gpa.free(pr.bytes); - }, - .pty_eof => |id| { - if (ptys[id]) |*pt| { - _ = linux.close(pt.file.handle); - ptys[id] = null; - } - core.update(.{ .eof = .{ .pane = @intCast(id) } }); - }, - .key_press => |key| core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - } }), - .mouse => |m| { - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .none => .none, // button-less motion: hover tracking - else => null, - }; - if (button) |b| core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(m.col), - .row = @intCast(m.row), - } }); - }, - .paste => |bytes| { - core.update(.{ .paste = bytes }); - gpa.free(@constCast(bytes)); - }, + const tz_event = tracy.zone(@src(), "event"); + // Apply every queued INPUT event, then render ONCE — the gui shell + // drains SDL's queue the same way. Without this a wheel flick is fifty + // full render+repaint (and re-highlight) cycles instead of one. + // pty output keeps a frame per chunk: progress paints as it arrives. + var stop = false; + var batch: usize = 0; + while (true) { + var output = false; + switch (event) { + .nop, .tick => {}, + .quit => stop = true, + .winsize => |ws| { + try vx.resize(gpa, tty.writer(), ws); + core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); + }, + .pty_read => |pr| { + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + gpa.free(pr.bytes); + output = true; + }, + .pty_eof => |id| { + if (ptys[id]) |*pt| { + _ = linux.close(pt.file.handle); + ptys[id] = null; + } + core.update(.{ .eof = .{ .pane = @intCast(id) } }); + }, + .key_press => |key| core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + } }), + .mouse => |m| { + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .none => .none, // button-less motion: hover tracking + else => null, + }; + if (button) |b| core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + } }); + }, + .paste => |bytes| { + core.update(.{ .paste = bytes }); + gpa.free(@constCast(bytes)); + }, + } + batch += 1; + if (stop or output or batch >= 64) break; + event = (try loop.tryEvent()) orelse break; } + tz_event.end(); + if (stop) break; drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true); if (core.quit) break; @@ -158,7 +175,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // ---- render: surface -> vaxis, cell for cell ---- _ = frame_arena.reset(.retain_capacity); + const tz_core = tracy.zone(@src(), "core.render"); const surface = try core.render(frame_arena.allocator()); + tz_core.end(); + const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); win.clear(); var y: u16 = 0; @@ -173,6 +193,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }); } } + tz_cells.end(); // pixel attachments (kitty graphics): transmit once per pane, then // re-place every frame (placements aren't persistent) for (surface.images[0..surface.nimages]) |maybe| { @@ -191,7 +212,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } if (surface.cursor) |cur| win.showCursor(cur.x, cur.y); + const tz_render = tracy.zone(@src(), "vx.render"); try vx.render(tty.writer()); + tz_render.end(); + tracy.frameMark(); } } -- cgit v1.3