summaryrefslogtreecommitdiff
path: root/src/panes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/panes.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/panes.zig')
-rw-r--r--src/panes.zig7681
1 files changed, 7681 insertions, 0 deletions
diff --git a/src/panes.zig b/src/panes.zig
new file mode 100644
index 00000000..6db7f53b
--- /dev/null
+++ b/src/panes.zig
@@ -0,0 +1,7681 @@
+const std = @import("std");
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const config = @import("config.zig");
+const Pardes = pardes.Pardes;
+const modal = @import("modal.zig");
+const filesystem = @import("fs.zig");
+const syntax = @import("syntax.zig");
+const tracy = @import("tracy.zig");
+const dump = @import("dump.zig");
+const limits = @import("memory.zig").limits;
+const builtins = @import("builtins.zig");
+const effect_sources = @import("effect_sources.zig");
+const build_options = @import("pardes_config");
+const lsp = @import("lsp/lsp.zig");
+const image = @import("image.zig");
+const look = @import("look.zig");
+const Key = pardes.Key;
+
+/// An owned editable buffer and the absolute surface row of its first line.
+/// Files use row zero; terminal overlays may begin anywhere in scrollback.
+pub const EditText = struct { text: []u8, row0: i32 };
+
+pub const Pane = struct {
+ pub const Mode = enum { normal, insert, tty };
+
+ /// One mouse selection (block-shaped), per button. c/r are text-area relative;
+ /// r counts from the tag row (body starts at BOX_H).
+ pub const Sel = struct {
+ state: enum { none, dragging, done } = .none,
+ c0: i32 = 0,
+ c1: i32 = 0,
+ r0: i32 = 0,
+ r1: i32 = 0,
+ };
+
+ /// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
+ pub const LineSel = struct {
+ active: bool = false,
+ r0: i32 = 0,
+ r1: i32 = 0,
+ };
+
+ pub const CharSel = struct {
+ active: bool = false,
+ row: i32 = 0,
+ col: i32 = 0,
+ explicit: bool = false,
+ };
+
+ pub const LookSpot = struct {
+ row: i32,
+ col0: i32,
+ col1: i32,
+ };
+
+ pub const max_selections = 64;
+
+ pub const SelRange = struct {
+ row: i32,
+ col: i32,
+ arow: i32,
+ acol: i32,
+ /// this range's own j/k goal column (helix Range::old_visual_position);
+ /// the PRIMARY's copy is Pane.sticky_col
+ sticky: i32 = -1,
+ };
+
+ const PdfSlot = if (Pdf.enabled) ?Pdf.State else void;
+
+ pub const Prompt = union(enum) {
+ none,
+ search: u16,
+ pipe: struct { at: u16, how: modal.Normal.PipeBehavior },
+ /// Save on a scratch buffer or a terminal: the tail is a path to write to.
+ save: u16,
+ };
+
+ pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 };
+ terminal: ?*Terminal.State = null,
+ gpa: std.mem.Allocator,
+ serial: u32 = 0,
+ mode: Mode = .normal,
+ vweight: f32 = 1,
+ cols: u16,
+ rows: u16,
+ greet: bool = false,
+ pending_command: Terminal.PendingCommand = .{},
+ file: ?File.State = null,
+ image: ?Image.State = null,
+ pdf: PdfSlot = if (Pdf.enabled) null else {},
+ msel: LineSel = .{},
+ vsel: CharSel = .{},
+ sels: [max_selections - 1]SelRange = undefined,
+ nsel: u8 = 0,
+ select: bool = false,
+ /// sticky goal column for j/k runs (helix old_visual_position): any
+ /// non-vertical range write resets it to -1.
+ sticky_col: i32 = -1,
+ append_at: ?struct { row: i32, col: i32 } = null,
+ normal: modal.Normal.State = .{},
+ /// last f/F/t/T motion, for Alt-. repeat
+ find_op: u8 = 0,
+ find_ch: u21 = 0,
+ /// Tag-tail input state. The tag text is presentation; this tag carries
+ /// which operation owns it and the tail offset restored on submit/cancel.
+ prompt: Prompt = .none,
+ search_pane: ?usize = null,
+ search_row: ?usize = null,
+ look_at: ?LookSpot = null,
+ sel_snap: [max_selections]modal.Selection = undefined,
+ nsel_snap: u8 = 0,
+ sel_snap_pri: u8 = 0,
+ sel_snap_expl: bool = false,
+ /// the editable tag tail: a bounded one-line command buffer. Input that
+ /// does not fit is refused atomically.
+ tag_tail: [limits.max_tag_tail]u8 = undefined,
+ tag_tail_len: usize = 0,
+ tag_init: bool = false,
+ tag_edit: bool = false,
+ tag_sel: bool = false,
+ /// the body mode a tag edit hijacked (tags are always insert); terminals
+ /// restore it on exit so clicking the tag never changes the pane's mode
+ tag_mode: Mode = .normal,
+ tag_col: u16 = 0,
+ tag_anchor: u16 = 0,
+ ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined,
+ ed_undo_len: usize = 0,
+ ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined,
+ ed_redo_len: usize = 0,
+ cwd: Cwd = .none,
+ cur_pinned: bool = false,
+ cur_row: i32 = 0,
+ cur_col: i32 = 0,
+ hscroll: i32 = 0,
+ // Visible rows map to source lines and raw/display column origins.
+ wrap_line: [limits.wrap_rows]i32 = undefined,
+ wrap_col: [limits.wrap_rows]i32 = undefined,
+ wrap_n: u16 = 0,
+ sel: [3]Sel = @splat(.{}),
+ /// terminals only: the typed-text buffer standing in for shell rows
+ ovl: ?Terminal.EditBuffer = null,
+ tty_filter: bool = false,
+ msg: [256]u8 = undefined,
+ msg_len: u16 = 0,
+
+ pub fn tagSlice(p: *const Pane) []const u8 {
+ return p.tag_tail[0..p.tag_tail_len];
+ }
+ pub fn promptAt(p: *const Pane) ?u16 {
+ return switch (p.prompt) {
+ .none => null,
+ .search, .save => |at| at,
+ .pipe => |pipe| pipe.at,
+ };
+ }
+
+ pub fn appendTag(p: *Pane, text: []const u8) bool {
+ if (text.len > p.tag_tail.len - p.tag_tail_len) return false;
+ @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text);
+ p.tag_tail_len += text.len;
+ return true;
+ }
+
+ pub fn insertTagByte(p: *Pane, at: usize, byte: u8) bool {
+ if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false;
+ std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]);
+ p.tag_tail[at] = byte;
+ p.tag_tail_len += 1;
+ return true;
+ }
+
+ pub fn removeTagByte(p: *Pane, at: usize) void {
+ if (at >= p.tag_tail_len) return;
+ std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]);
+ p.tag_tail_len -= 1;
+ }
+
+ pub fn cwdSlice(p: *const Pane) []const u8 {
+ return switch (p.cwd) {
+ .none => "",
+ .owned => |dir| dir,
+ .inherited => |src| src.cwdSlice(),
+ };
+ }
+
+ pub fn clearCwd(pane: *Pane) void {
+ if (pane.cwd == .owned) pane.gpa.free(pane.cwd.owned);
+ pane.cwd = .none;
+ }
+
+ pub fn setOwnedCwd(pane: *Pane, dir: []const u8) !void {
+ if (dir.len > limits.host_path_cap) return error.PathTooLong;
+ const copy = try pane.gpa.dupe(u8, dir);
+ pane.clearCwd();
+ pane.cwd = .{ .owned = copy };
+ }
+
+ pub fn isTerminal(pane: *const Pane) bool {
+ const no_pdf = if (comptime Pdf.enabled) pane.pdf == null else true;
+ return pane.file == null and pane.image == null and no_pdf;
+ }
+
+ /// The one coloring choice keyed on what a pane IS, so the highlight
+ /// producer (refreshHighlights) and the render pass agree on the algorithm.
+ pub const ColorAlgo = enum { none, tty, source, diff, locations };
+ pub fn colorAlgo(pane: *const Pane) ColorAlgo {
+ if (pane.isTerminal()) return .tty;
+ if (pane.file) |f| {
+ if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff;
+ if (f.output != null and !Output.fileTraits(f.output).saves) return .locations;
+ return .source;
+ }
+ return .none;
+ }
+
+ pub fn pdfPath(pane: *const Pane) ?[]const u8 {
+ if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.path;
+ return null;
+ }
+
+ pub fn pdfPage(pane: *const Pane) ?usize {
+ if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.page;
+ return null;
+ }
+
+ pub fn surfRow(pane: *const Pane, g: i32) i32 {
+ const o = pane.ovl orelse return g;
+ if (g <= o.row) return g;
+ const lines: i32 = @intCast(modal.lineCount(o.text));
+ if (g >= o.row + o.rows) return g + lines - o.rows;
+ return @min(g, o.row + lines - 1); // inside the buffer: its own rows
+ }
+
+ /// the inverse; every surface row inside the edit buffer maps to its anchor
+ pub fn gridRow(pane: *const Pane, s: i32) i32 {
+ const o = pane.ovl orelse return s;
+ if (s <= o.row) return s;
+ const lines: i32 = @intCast(modal.lineCount(o.text));
+ if (s < o.row + lines) return o.row;
+ return s - lines + o.rows;
+ }
+
+ /// current scroll offset: file top line, or the scrollback offset
+ pub fn scroll(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(f.scroll);
+ if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll);
+ return pane.surfRow(Terminal.gridOffset(pane));
+ }
+
+ pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } {
+ if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n))
+ return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] };
+ // unwrapped: rows ARE lines, and the byte column a row starts at is the
+ // horizontal scroll (always 0 on a terminal, which never has one)
+ return .{ .line = pane.scroll() + vr, .at = pane.hscroll };
+ }
+
+ pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } {
+ if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll };
+ var i: u16 = 0;
+ while (i < pane.wrap_n) : (i += 1) {
+ if (pane.wrap_line[i] != line) continue;
+ // the LAST row of a line owns every column past its start, so a
+ // cursor parked on the trailing newline still has somewhere to draw
+ if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue;
+ return .{ .row = @intCast(i), .at = pane.wrap_col[i] };
+ }
+ return .{ .row = -1, .at = 0 };
+ }
+
+ pub fn scrollBy(pane: *Pane, delta: i32) void {
+ if (pane.file) |*f| {
+ const max: i64 = @intCast(File.nlines(pane.gpa, f) -| 1);
+ const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
+ const next: usize = @intCast(n);
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
+ } else if (hasPdf(pane)) {
+ if (comptime Pdf.enabled) {
+ const pv = &pane.pdf.?;
+ const max: i64 = @intCast(modal.lineCount(pv.text) -| 1);
+ pv.text_scroll = @intCast(std.math.clamp(
+ @as(i64, @intCast(pv.text_scroll)) + delta,
+ 0,
+ max,
+ ));
+ }
+ } else {
+ // the vt scrolls in SHELL rows; convert through the edit buffer
+ const off = Terminal.gridOffset(pane);
+ Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off);
+ }
+ }
+
+ pub fn ensureCursorVisible(pane: *Pane) void {
+ // scrolloff margin, shrunk on short panes so the band stays non-empty
+ var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ const off = pane.scroll();
+ var last = off + @as(i32, pane.rows) - 1;
+ if (pane.wrap_n > 0) {
+ const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0];
+ last = off + lines_shown;
+ margin = @min(margin, @divTrunc(@max(0, lines_shown), 2));
+ }
+ if (pane.cur_row < off + margin) {
+ pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0
+ } else if (pane.cur_row > last - margin) {
+ // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp);
+ // terminals overshoot harmlessly — the vt clamps at the live bottom
+ var to = pane.cur_row + margin;
+ if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1)));
+ if (comptime Pdf.enabled) {
+ if (pane.pdf) |pv|
+ to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
+ }
+ pane.scrollBy(@max(0, to - last));
+ }
+ if (pane.file) |f| {
+ if (pane.wrap_n != 0) return;
+ const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane)));
+ const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2));
+ const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)));
+ const raw_cur: usize = @intCast(@max(0, pane.cur_col));
+ const raw_scroll: usize = @intCast(@max(0, pane.hscroll));
+ const cur = @as(i32, @intCast(File.rawDisplayCol(line, raw_cur)));
+ const visual_scroll = @as(i32, @intCast(File.rawDisplayCol(line, raw_scroll)));
+ var target = visual_scroll;
+ if (cur < visual_scroll + hmargin)
+ target = @max(0, cur - hmargin)
+ else if (cur > visual_scroll + w - 1 - hmargin)
+ target = cur - (w - 1 - hmargin);
+ if (target != visual_scroll) pane.hscroll = @intCast(File.rawAtDisplay(line, @intCast(target)));
+ }
+ }
+
+ pub fn pinCursor(pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ if (pane.file != null or hasPdf(pane)) {
+ pane.cur_row = pane.scroll();
+ pane.cur_col = 0;
+ } else {
+ const cur = Terminal.gridCursor(pane);
+ pane.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane));
+ pane.cur_col = @intCast(cur.x);
+ }
+ pane.cur_pinned = true;
+ }
+
+ pub fn hasPdf(pane: *const Pane) bool {
+ return if (comptime Pdf.enabled) pane.pdf != null else false;
+ }
+
+ pub fn hasPdfSelection(pane: *const Pane) bool {
+ return if (comptime Pdf.enabled)
+ if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false
+ else
+ false;
+ }
+
+ pub fn toModalCursor(pane: *Pane) modal.Cursor {
+ return .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ }
+
+ pub fn fromModalCursor(pane: *Pane, c: modal.Cursor) void {
+ pane.cur_row = @as(i32, @intCast(c.row));
+ pane.cur_col = @intCast(c.col);
+ pane.cur_pinned = true;
+ }
+
+ pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor {
+ if (lines.len == 0) return c;
+ const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1;
+ const target = lines[row];
+ if (target.len == 0) return .{ .row = row, .col = 0 };
+ const source = if (c.row < lines.len) lines[c.row] else "";
+ const goal = File.rawDisplayCol(source, c.col);
+ const mapped = File.rawAtDisplay(target, goal);
+ const last = modal.prevGrapheme(target, target.len);
+ return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) };
+ }
+
+ pub fn primaryRange(pane: *Pane, text: []const u8, row0: i32) modal.Selection {
+ const c = File.textOffset(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) });
+ if (pane.msel.active) {
+ // legacy line selection (file-search results highlight): linewise
+ const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0));
+ const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0));
+ const s = modal.lineStartOffset(text, r0);
+ const e = if (r1 + 1 >= modal.cursorLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1);
+ return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) };
+ }
+ if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col);
+ return .{ .anchor = c, .head = modal.nextGrapheme(text, c) };
+ }
+
+ pub fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.Selection {
+ const a = modal.offsetAt(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) });
+ const c = modal.offsetAt(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) });
+ return cellOffRange(text, a, c);
+ }
+
+ /// the same, from the two cells' gap offsets
+ pub fn cellOffRange(text: []const u8, a: usize, c: usize) modal.Selection {
+ if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) };
+ return .{ .anchor = modal.nextGrapheme(text, a), .head = c };
+ }
+
+ pub fn rangeCells(text: []const u8, r: modal.Selection) struct { cur: usize, anc: usize } {
+ if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor };
+ if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) };
+ return .{ .cur = r.head, .anc = r.head };
+ }
+
+ pub fn setRange(pane: *Pane, text: []const u8, row0: i32, r0: modal.Selection, explicit: bool) void {
+ var r = r0;
+ if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1
+ const off = rangeCells(text, r);
+ const cc = File.textPosition(pane, text, off.cur);
+ // a bare block cursor has both cells on the same offset — the common
+ // case by far — and this conversion is not free even indexed
+ const ac = if (off.anc == off.cur) cc else File.textPosition(pane, text, off.anc);
+ pane.cur_row = @as(i32, @intCast(cc.row)) + row0;
+ pane.cur_col = @intCast(cc.col);
+ pane.vsel = .{
+ .active = off.anc != off.cur or pane.select,
+ .row = @as(i32, @intCast(ac.row)) + row0,
+ .col = @intCast(ac.col),
+ .explicit = explicit or pane.select,
+ };
+ pane.msel.active = false;
+ pane.nsel = 0; // writing ONE range means the selection IS that range
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.normal.clear();
+ pane.ensureCursorVisible();
+ }
+
+ pub fn ranges(pane: *Pane, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } {
+ const pr = primaryRange(pane, text, row0);
+ var n: usize = 0;
+ var pri: usize = 0;
+ var placed = false;
+ for (pane.sels[0..pane.nsel]) |s| {
+ const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col);
+ if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) {
+ pri = n;
+ out[n] = pr;
+ n += 1;
+ placed = true;
+ }
+ out[n] = r;
+ n += 1;
+ }
+ if (!placed) {
+ pri = n;
+ out[n] = pr;
+ n += 1;
+ }
+ return .{ .n = n, .pri = pri };
+ }
+
+ pub fn setRanges(pane: *Pane, text: []const u8, in: []const modal.Selection, sticky: []const i32, pri0: usize, explicit: bool) void {
+ if (in.len == 0) return; // helix asserts non-empty; here it just means "no change"
+ var r: [max_selections]modal.Selection = undefined;
+ var st: [max_selections]i32 = undefined;
+ var n: usize = @min(in.len, max_selections);
+ var pri: usize = @min(pri0, n - 1);
+ for (in[0..n], 0..) |x, i| {
+ r[i] = x;
+ if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head);
+ st[i] = if (i < sticky.len) sticky[i] else -1;
+ }
+ // insertion sort by start — n is tiny and usually already ordered
+ var i: usize = 1;
+ while (i < n) : (i += 1) {
+ var j = i;
+ while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) {
+ std.mem.swap(modal.Selection, &r[j], &r[j - 1]);
+ std.mem.swap(i32, &st[j], &st[j - 1]);
+ if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j;
+ }
+ }
+ var k: usize = 0;
+ i = 1;
+ while (i < n) : (i += 1) {
+ const a = r[k];
+ const b = r[i];
+ const af = @min(a.anchor, a.head);
+ const at = @max(a.anchor, a.head);
+ const bf = @min(b.anchor, b.head);
+ const bt = @max(b.anchor, b.head);
+ if (af == bf or (at > bf and bt > af)) {
+ r[k] = if (a.anchor > a.head and b.anchor > b.head)
+ .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) }
+ else
+ .{ .anchor = @min(af, bf), .head = @max(at, bt) };
+ if (pri == i) pri = k;
+ if (st[k] < 0) st[k] = st[i];
+ continue;
+ }
+ k += 1;
+ r[k] = b;
+ st[k] = st[i];
+ if (pri == i) pri = k;
+ }
+ n = k + 1;
+ setRange(pane, text, 0, r[pri], explicit);
+ pane.sticky_col = st[pri];
+ var w: usize = 0;
+ for (r[0..n], 0..) |x, idx| {
+ if (idx == pri) continue;
+ const c = rangeCells(text, x);
+ const cc = modal.positionAt(text, c.cur);
+ const ac = modal.positionAt(text, c.anc);
+ pane.sels[w] = .{
+ .row = @as(i32, @intCast(cc.row)),
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)),
+ .acol = @intCast(ac.col),
+ .sticky = st[idx],
+ };
+ w += 1;
+ }
+ pane.nsel = @intCast(w);
+ }
+
+ pub fn multiSelAction(pane: *Pane, text: []const u8, kind: modal.Normal.Multi, cnt: usize) void {
+ var rs: [max_selections]modal.Selection = undefined;
+ const got = ranges(pane, text, 0, &rs);
+ const n = got.n;
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ if (kind == .remove_primary) {
+ if (n < 2) return; // helix: "no selections remaining"
+ var out: [max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ if (i == got.pri) continue;
+ out[m] = r;
+ m += 1;
+ }
+ // helix Selection::remove: the NEXT range takes over, or the
+ // previous one when the primary was last
+ return setRanges(pane, text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
+ }
+ if (kind == .rotate_forward or kind == .rotate_backward) {
+ const step = cnt % n;
+ const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n;
+ return setRanges(pane, text, rs[0..n], &.{}, pri, expl);
+ }
+ if (kind == .merge) {
+ // helix merge_selections: first.merge(last) — the ranges are
+ // sorted, so that is simply the whole span
+ const lo = @min(rs[0].anchor, rs[0].head);
+ const hi = @max(rs[n - 1].anchor, rs[n - 1].head);
+ const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head;
+ const one: modal.Selection = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi };
+ return setRanges(pane, text, &.{one}, &.{}, 0, expl);
+ }
+ if (kind == .merge_consecutive) {
+ var out: [max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ var pri: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) {
+ const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head));
+ const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head));
+ out[m - 1] = .{ .anchor = lo, .head = hi };
+ if (i == got.pri) pri = m - 1;
+ continue;
+ }
+ if (i == got.pri) pri = m;
+ out[m] = r;
+ m += 1;
+ }
+ return setRanges(pane, text, out[0..m], &.{}, pri, expl);
+ }
+ if (kind == .split_newline) {
+ // helix selection::split_on_newline — one range per line the
+ // selection covers, the newlines themselves left out
+ var out: [max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ for (rs[0..n]) |r| {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ if (from == to) {
+ if (m < max_selections) {
+ out[m] = r;
+ m += 1;
+ }
+ continue;
+ }
+ var start = from;
+ while (start < to and m < max_selections) {
+ const eol = modal.lineEndOffset(text, modal.lineAtOffset(text, start));
+ if (eol >= to) {
+ out[m] = .{ .anchor = start, .head = to };
+ m += 1;
+ break;
+ }
+ out[m] = .{ .anchor = start, .head = eol };
+ m += 1;
+ start = eol + 1;
+ }
+ }
+ if (m == 0) return;
+ return setRanges(pane, text, out[0..m], &.{}, 0, true); // helix keeps primary 0
+ }
+ if (kind == .trim) {
+ // helix trim_selections: whitespace off both ends; ranges that are
+ // empty or all whitespace are dropped entirely
+ var out: [max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ for (rs[0..n]) |r| {
+ var from = @min(r.anchor, r.head);
+ var to = @max(r.anchor, r.head);
+ while (from < to and std.ascii.isWhitespace(text[from])) from += 1;
+ while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1;
+ if (from >= to) continue;
+ out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to };
+ m += 1;
+ }
+ if (m == 0) { // helix: collapse_selection + keep_primary_selection
+ const c = modal.selectionCursor(text, rs[got.pri]);
+ return setRange(pane, text, 0, .{ .anchor = c, .head = c }, false);
+ }
+ // helix: the first survivor that OVERLAPS the old primary, else the last
+ const pf = @min(rs[got.pri].anchor, rs[got.pri].head);
+ const pt = @max(rs[got.pri].anchor, rs[got.pri].head);
+ var pri = m - 1;
+ for (out[0..m], 0..) |r, i| {
+ const f = @min(r.anchor, r.head);
+ const t = @max(r.anchor, r.head);
+ if (f == pf or (t > pf and pt > f)) {
+ pri = i;
+ break;
+ }
+ }
+ return setRanges(pane, text, out[0..m], &.{}, pri, expl);
+ }
+ const below = kind == .copy_below;
+ var out: [max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ var pri: usize = 0;
+ const nlines = modal.cursorLineCount(text);
+ for (rs[0..n], 0..) |r, ri| {
+ const is_pri = ri == got.pri;
+ // head-exclusive: back the leading end off onto its own cell
+ const hp = modal.positionAt(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head);
+ const ap = modal.positionAt(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor));
+ const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1;
+ if (m >= max_selections) break;
+ if (is_pri) pri = m;
+ out[m] = r;
+ m += 1;
+ var made: usize = 0;
+ var k: usize = 0;
+ while (made < cnt and m < max_selections) : (k += 1) {
+ const d = (k + 1) * height;
+ const arow = if (below) ap.row + d else ap.row -| d;
+ const hrow = if (below) hp.row + d else hp.row -| d;
+ if (arow >= nlines or hrow >= nlines) break;
+ const a2 = modal.offsetAt(text, .{ .row = arow, .col = ap.col });
+ const h2 = modal.offsetAt(text, .{ .row = hrow, .col = hp.col });
+ // a line too short to reach the column is skipped, not clamped
+ if (modal.positionAt(text, a2).col == ap.col and modal.positionAt(text, h2).col == hp.col) {
+ if (is_pri) pri = m;
+ out[m] = modal.moveSelectionCursor(text, .{ .anchor = a2, .head = a2 }, h2, true);
+ m += 1;
+ made += 1;
+ }
+ if (arow == 0 and hrow == 0) break;
+ }
+ }
+ setRanges(pane, text, out[0..m], &.{}, pri, expl);
+ }
+
+ /// a range's start CELL (document order key) — the smaller of its two ends
+ pub fn selStart(s: SelRange) struct { row: i32, col: i32 } {
+ if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol };
+ return .{ .row = s.row, .col = s.col };
+ }
+
+ pub fn maxLine(text: []const u8) usize {
+ const nl = modal.cursorLineCount(text);
+ return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1;
+ }
+
+ /// point-target motion: collapse there (extend in select mode)
+ pub fn pointMove(pane: *Pane, text: []const u8, range: modal.Selection, target: usize) void {
+ setRange(pane, text, 0, modal.moveSelectionCursor(text, range, target, pane.select), false);
+ }
+
+ /// word motions select their traversed span (extend mode: head only)
+ pub fn wordMove(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize, target: modal.WordTarget) void {
+ const wr = modal.moveWord(text, range, cnt, target);
+ const res = if (pane.select) modal.moveSelectionCursor(text, range, modal.selectionCursor(text, wr), true) else wr;
+ setRange(pane, text, 0, res, false);
+ }
+
+ /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move)
+ pub fn findMove(pane: *Pane, text: []const u8, range: modal.Selection, ch: u21, fwd: bool, till: bool, cnt: usize) void {
+ const cur = modal.selectionCursor(text, range);
+ const t = modal.findTarget(text, cur, ch, fwd, till, cnt) orelse return;
+ const res = if (pane.select)
+ modal.moveSelectionCursor(text, range, t, true)
+ else
+ modal.moveSelectionCursor(text, .{ .anchor = cur, .head = cur }, t, true);
+ setRange(pane, text, 0, res, false);
+ }
+
+ /// j/k and friends: sticky goal column, clamped onto short lines' newline
+ pub fn verticalMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool, cnt: usize) void {
+ const cur = modal.selectionCursor(text, range);
+ const pos = File.textPosition(pane, text, cur);
+ const goal: usize = if (pane.sticky_col >= 0)
+ @intCast(pane.sticky_col)
+ else
+ File.rawDisplayCol(File.textLine(pane, text, pos.row), pos.col);
+ const last_row = File.textLineCount(pane, text) - 1;
+ const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt);
+ const target_col = File.rawAtDisplay(File.textLine(pane, text, nline), goal);
+ const t = File.textOffset(pane, text, .{ .row = nline, .col = target_col });
+ // extend mode never walks onto the empty trailing line (helix)
+ if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
+ setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false);
+ pane.sticky_col = @intCast(goal);
+ }
+
+ pub fn visualMove(
+ pane: *Pane,
+ text: []const u8,
+ range: modal.Selection,
+ down: bool,
+ cnt: usize,
+ width: usize,
+ ) void {
+ const cur = modal.selectionCursor(text, range);
+ const pos = File.textPosition(pane, text, cur);
+ const last_row = File.textLineCount(pane, text) - 1;
+ var row = pos.row;
+ var line = modal.lineSlice(text, row);
+ var vrow = File.visualRow(line, pos.col, width);
+ const goal: usize = if (pane.sticky_col >= 0)
+ @intCast(pane.sticky_col)
+ else
+ File.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start);
+ var steps = @max(1, cnt);
+ while (steps > 0) : (steps -= 1) {
+ if (down) {
+ if (vrow.end < line.len) {
+ vrow = File.visualRow(line, vrow.end, width);
+ continue;
+ }
+ if (row == last_row) break;
+ row += 1;
+ line = modal.lineSlice(text, row);
+ vrow = File.visualRow(line, 0, width);
+ } else {
+ if (vrow.start > 0) {
+ vrow = File.visualRow(line, vrow.start - 1, width);
+ continue;
+ }
+ if (row == 0) break;
+ row -= 1;
+ line = modal.lineSlice(text, row);
+ vrow = File.visualRow(line, line.len, width);
+ }
+ }
+ // The newline slot is a real cursor position, but the first byte of the
+ // NEXT visual row is not: landing there would read as two rows moved.
+ var target_col = vrow.start + File.rawAtDisplay(line[vrow.start..vrow.end], goal);
+ if (vrow.end < line.len and target_col >= vrow.end)
+ target_col = modal.graphemeStart(line, vrow.end - 1);
+ const t = File.textOffset(pane, text, .{ .row = row, .col = target_col });
+ // extend mode never walks onto the empty trailing line (helix)
+ if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
+ setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false);
+ pane.sticky_col = @intCast(goal);
+ }
+
+ /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows
+ pub fn halfPageMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool) void {
+ const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2));
+ pane.scrollBy(if (down) half else -half);
+ verticalMove(pane, text, range, down, @intCast(half));
+ }
+
+ /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift
+ /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0
+ pub fn scrollViewMove(pane: *Pane, text: []const u8, range: modal.Selection, delta: i32) void {
+ const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ pane.scrollBy(delta);
+ const top = pane.scroll();
+ const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1);
+ const cur = modal.selectionCursor(text, range);
+ if (delta > 0) {
+ const snap: i32 = @max(0, @min(top + margin, last_row));
+ const head = File.textLineStart(pane, text, @intCast(snap));
+ if (head <= cur) return;
+ const anchor = if (pane.select) range.anchor else head;
+ setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false);
+ } else {
+ const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row));
+ const head = File.textLineStart(pane, text, @intCast(snap));
+ if (head >= cur) return;
+ const anchor = if (pane.select) range.anchor else head;
+ setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false);
+ }
+ }
+
+ /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window)
+ pub fn gotoWindow(pane: *Pane, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void {
+ const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ const top = pane.scroll();
+ const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1);
+ const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
+ const n: i32 = @intCast(cnt - 1);
+ var vline: i32 = switch (which) {
+ .top => top + margin + n,
+ .center => top + @divTrunc(last_vis, 2),
+ .bottom => top + last_vis - (margin + n),
+ };
+ vline = @max(vline, top + margin);
+ vline = @min(vline, top + last_vis - margin);
+ const row: i32 = std.math.clamp(vline, 0, last_row);
+ pointMove(pane, text, range, File.textLineStart(pane, text, @intCast(row)));
+ }
+
+ /// helix Range::line_range — the inclusive line span a range covers
+ pub fn rangeLineSpan(text: []const u8, r: modal.Selection) struct { start: usize, end: usize } {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from);
+ return .{ .start = modal.lineAtOffset(text, from), .end = modal.lineAtOffset(text, to_adj) };
+ }
+
+ fn lineStartOrEof(text: []const u8, line: usize) usize {
+ if (line >= modal.cursorLineCount(text)) return text.len;
+ return modal.lineStartOffset(text, line);
+ }
+
+ /// helix `x` extend_line_below: full lines incl. the newline, cursor ON
+ /// the last one's '\n'; an already-line-bounded selection grows downward
+ pub fn lineSelect(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize) void {
+ const span = rangeLineSpan(text, range);
+ const start = modal.lineStartOffset(text, span.start);
+ const end = lineStartOrEof(text, span.end + 1);
+ const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end;
+ const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full));
+ setRange(pane, text, 0, .{ .anchor = start, .head = head }, true);
+ }
+
+ /// helix `X` extend_to_line_bounds (direction kept)
+ pub fn lineBoundsSelect(pane: *Pane, text: []const u8, range: modal.Selection) void {
+ const span = rangeLineSpan(text, range);
+ const start = modal.lineStartOffset(text, span.start);
+ const end = lineStartOrEof(text, span.end + 1);
+ const r: modal.Selection = if (range.head < range.anchor)
+ .{ .anchor = end, .head = start }
+ else
+ .{ .anchor = start, .head = end };
+ setRange(pane, text, 0, r, true);
+ }
+
+ /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched)
+ pub fn shrinkSelToLineBounds(pane: *Pane, text: []const u8, range: modal.Selection) void {
+ const span = rangeLineSpan(text, range);
+ if (span.start == span.end) return;
+ const from = @min(range.anchor, range.head);
+ const to = @max(range.anchor, range.head);
+ var start = modal.lineStartOffset(text, span.start);
+ var end = lineStartOrEof(text, span.end + 1);
+ if (start != from) start = lineStartOrEof(text, span.start + 1);
+ if (end != to) end = modal.lineStartOffset(text, span.end);
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ const r: modal.Selection = if (range.head < range.anchor)
+ .{ .anchor = end, .head = start }
+ else
+ .{ .anchor = start, .head = end };
+ setRange(pane, text, 0, r, expl);
+ }
+
+ /// pull the cursor back inside `text` after a rewrite; `row0` is the
+ /// absolute surface row of its first line (0 for a file)
+ pub fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void {
+ const n = modal.lineCount(text);
+ const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(text, row).len;
+ pane.cur_row = @as(i32, @intCast(row)) + row0;
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen));
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+};
+
+pub const File = struct {
+ const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+
+ /// Content and primary selection at one file edit boundary. Keeping only the
+ /// primary avoids putting Pane.max_selections ranges in every history entry.
+ pub const Snapshot = struct {
+ content: []u8,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: Pane.CharSel,
+ };
+
+ pub const History = struct {
+ undo: [limits.undo_max]Snapshot = undefined,
+ undo_len: usize = 0,
+ redo: [limits.undo_max]Snapshot = undefined,
+ redo_len: usize = 0,
+
+ pub fn create(gpa: std.mem.Allocator) !*History {
+ const history = try gpa.create(History);
+ history.undo_len = 0;
+ history.redo_len = 0;
+ return history;
+ }
+ };
+
+ /// A file pane's backing: owned content, its derived caches, and undo history.
+ pub const State = struct {
+ path: []u8,
+ content: []u8,
+ revision: u32 = 0,
+ /// Revision last known to match disk, after Save or an external reload.
+ /// Equal means the screen matches disk.
+ saved_revision: u32 = 0,
+ watch_after_save: bool = false,
+ /// Non-null for a generated output buffer rather than an on-disk file.
+ output: ?Output.State = null,
+ mini: ?Mini.State = null,
+ scroll: usize = 0,
+ /// `line_starts[i]` is line i's byte offset. Empty means not built yet.
+ line_starts: []usize = &.{},
+ /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte.
+ highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ syntax_dirty: bool = true,
+ history: *History,
+ };
+
+ pub fn dumpPane(
+ arena: std.mem.Allocator,
+ pane: *const Pane,
+ file: *const State,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+ origin: []const u8,
+ origin_arg: []const u8,
+ ) !dump.Pane {
+ return .{
+ .kind = .file,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .file = .{
+ .path = file.path,
+ .content = file.content,
+ .content_b64 = try dump.encodeBytes(arena, file.content),
+ .dirty = file.revision != file.saved_revision,
+ .origin = origin,
+ .origin_arg = origin_arg,
+ .mini_source = if (file.mini) |mini| mini.source else "",
+ .mini_colors_b64 = if (file.mini) |mini| try dump.encodeBytes(arena, mini.colors) else "",
+ },
+ };
+ }
+
+ pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
+ return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
+ }
+
+ pub fn byteDisplayWidth(byte: u8) usize {
+ return if (byte == '\t') config.tab_width else 1;
+ }
+
+ pub fn displayWidth(text: []const u8) usize {
+ var width: usize = 0;
+ var at: usize = 0;
+ while (at < text.len) {
+ const end = modal.nextGrapheme(text, at);
+ width +|= graphemeDisplayWidth(text[at..end]);
+ at = end;
+ }
+ return width;
+ }
+
+ /// Source byte at a zero-based display column. Every cell occupied by a tab
+ /// maps back to that one tab byte.
+ pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
+ var col: usize = 0;
+ var at: usize = 0;
+ while (at < text.len) {
+ const end = modal.nextGrapheme(text, at);
+ const next = col +| graphemeDisplayWidth(text[at..end]);
+ if (display_col < next) return at;
+ col = next;
+ at = end;
+ }
+ return text.len;
+ }
+
+ /// File cursor columns may live past EOL. Tabs expand before that boundary;
+ /// every virtual column after it remains one screen cell.
+ pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize {
+ const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len));
+ return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len);
+ }
+
+ pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize {
+ const width = displayWidth(line_text);
+ if (display_col > width) return line_text.len +| (display_col - width);
+ return byteAtDisplay(line_text, display_col);
+ }
+
+ pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize {
+ if (from_raw >= line_text.len) return from_raw +| display_col;
+ const from = modal.graphemeStart(line_text, from_raw);
+ return from +| rawAtDisplay(line_text[from..], display_col);
+ }
+
+ pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 {
+ const from_display = rawDisplayCol(line_text, from_raw);
+ const to_display = rawDisplayCol(line_text, to_raw);
+ if (to_display >= from_display) return @intCast(to_display - from_display);
+ return -@as(i32, @intCast(from_display - to_display));
+ }
+
+ pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 {
+ const start = lineDisplayOffset(line_text, from_raw, at_raw);
+ if (at_raw >= line_text.len) return start;
+ const at = modal.graphemeStart(line_text, at_raw);
+ const end = modal.nextGrapheme(line_text, at);
+ return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1;
+ }
+
+ pub fn sourceLine(pane: *const Pane, row: i32) []const u8 {
+ const f = if (pane.file) |*file| file else return "";
+ if (row < 0) return "";
+ const line: usize = @intCast(row);
+ if (f.line_starts.len == 0) return modal.lineSlice(f.content, line);
+ if (line >= f.line_starts.len) return "";
+ const start = f.line_starts[line];
+ const end = if (line + 1 < f.line_starts.len) f.line_starts[line + 1] - 1 else f.content.len;
+ return f.content[start..end];
+ }
+
+ test "indexed source rows match uncached scans across content changes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("");
+ const file = &pane.file.?;
+ for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| {
+ setContent(p, file, try p.gpa.dupe(u8, content));
+ try std.testing.expectEqual(@as(usize, 0), file.line_starts.len);
+ const rows = lineCount(content) + 2;
+ for ([_]bool{ false, true }) |indexed| {
+ if (indexed) _ = try lineIndex(p.gpa, file);
+ try std.testing.expectEqualStrings("", sourceLine(pane, -1));
+ for (0..rows) |row| try std.testing.expectEqualStrings(
+ modal.lineSlice(content, row),
+ sourceLine(pane, @intCast(row)),
+ );
+ if (!indexed) try std.testing.expectEqual(@as(usize, 0), file.line_starts.len);
+ }
+ }
+ }
+
+ pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ const from: usize = @intCast(@max(0, from_raw));
+ const to: usize = @intCast(@max(0, to_raw));
+ return lineDisplayOffset(line_text, from, to);
+ }
+
+ pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw)));
+ }
+
+ pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col))));
+ }
+
+ pub fn gutterWidth(pane: *const Pane) u16 {
+ if (pane.file == null) return 0;
+ const file = &pane.file.?;
+ var lines = if (file.line_starts.len > 0) file.line_starts.len else lineCount(file.content);
+ var digits: u16 = 1;
+ while (lines >= 10) : (lines /= 10) digits += 1;
+ return @max(config.PREFIX_W, digits + 1);
+ }
+
+ /// Convert between rendered cells and UTF-8 byte columns. Tag rows always
+ /// need grapheme conversion; file body rows additionally skip the gutter.
+ pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
+ if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col);
+ if (pane.file == null) return rawAtDisplay(line_text, display_col);
+ const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len);
+ if (display_col <= prefix) return display_col;
+ return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix);
+ }
+
+ pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize {
+ if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col);
+ if (pane.file == null) return rawDisplayCol(line_text, byte_col);
+ const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len);
+ if (byte_col <= prefix) return byte_col;
+ return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix);
+ }
+
+ test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
+ const ref = struct {
+ fn width(grapheme: []const u8) usize {
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
+ }
+ }.width;
+
+ var one: [1]u8 = undefined;
+ var b: u8 = 0;
+ while (b < 0x80) : (b += 1) {
+ one[0] = b;
+ try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1]));
+ }
+ for ([_][]const u8{
+ "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}",
+ "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
+ }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g));
+ }
+
+ test "the ASCII run in fitEnd survives an exhaustive byte sweep" {
+ const reference = struct {
+ fn fitEnd(text: []const u8, start: usize, width: usize) usize {
+ var end = start;
+ var used: usize = 0;
+ while (end < text.len) {
+ const next_end = modal.nextGrapheme(text, end);
+ const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
+ if (next_used > width) return if (end == start) next_end else end;
+ used = next_used;
+ end = next_end;
+ }
+ return end;
+ }
+ }.fitEnd;
+
+ const neighbours = [_][]const u8{
+ "", "z", "\u{301}", "\u{200d}\u{1f680}",
+ "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}",
+ "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8",
+ };
+ var buf: [16]u8 = undefined;
+ // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary
+ // as well as before it and inside the multi-byte cluster that follows it.
+ var prefixed: [18]u8 = undefined;
+ prefixed[0] = 'a';
+ prefixed[1] = 'b';
+ var b: u8 = 0;
+ while (b < 0x80) : (b += 1) {
+ buf[0] = b;
+ for (neighbours) |tail| {
+ @memcpy(buf[1..][0..tail.len], tail);
+ const pair = buf[0 .. 1 + tail.len];
+ @memcpy(prefixed[2..][0..pair.len], pair);
+ for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| {
+ var width: usize = 0;
+ while (width <= text.len + 3) : (width += 1) {
+ var start: usize = 0;
+ while (start <= text.len) : (start += 1) {
+ std.testing.expectEqual(
+ reference(text, start, width),
+ fitEnd(text, start, width),
+ ) catch |e| {
+ std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width });
+ return e;
+ };
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fn fitEnd(text: []const u8, start: usize, width: usize) usize {
+ var end = start;
+ var used: usize = 0;
+ while (used < width and end < text.len) {
+ const b = text[end];
+ if (b < 0x20 or b >= 0x7f) break;
+ if (end + 1 < text.len and text[end + 1] >= 0x80) break;
+ used += 1;
+ end += 1;
+ }
+ while (end < text.len) {
+ const next_end = modal.nextGrapheme(text, end);
+ const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
+ if (next_used > width) return if (end == start) next_end else end;
+ used = next_used;
+ end = next_end;
+ }
+ return end;
+ }
+
+ test "the ASCII run in fitEnd cuts where the grapheme walk would" {
+ const reference = struct {
+ fn fitEnd(text: []const u8, start: usize, width: usize) usize {
+ var end = start;
+ var used: usize = 0;
+ while (end < text.len) {
+ const next_end = modal.nextGrapheme(text, end);
+ const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
+ if (next_used > width) return if (end == start) next_end else end;
+ used = next_used;
+ end = next_end;
+ }
+ return end;
+ }
+ }.fitEnd;
+
+ const cases = [_][]const u8{
+ "",
+ "hello world",
+ // the fast path must hand over at the first non-ASCII byte, mid-run
+ "abc\u{00e9}def",
+ // a wide glyph is two columns, so a width boundary can land inside it
+ "ab\u{4e16}\u{754c}cd",
+ // a cluster the fast path must not split
+ "a\u{0301}bc",
+ // tabs and controls are excluded from the fast path by the range test
+ "ab\tcd",
+ "ab\rcd",
+ // an ASCII byte followed by a continuation byte is NOT its own cluster
+ "e\u{0301}x",
+ "\u{1f1e6}\u{1f1e7}ok",
+ };
+ for (cases) |text| {
+ var width: usize = 0;
+ while (width <= text.len + 3) : (width += 1) {
+ var start: usize = 0;
+ while (start <= text.len) : (start += 1) {
+ try std.testing.expectEqual(
+ reference(text, start, width),
+ fitEnd(text, start, width),
+ );
+ }
+ }
+ }
+ }
+
+ pub fn lineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+ }
+
+ // Content changes invalidate this lazily rebuilt byte-offset index.
+ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
+ if (f.line_starts.len > 0) return f.line_starts;
+ // Exact allocation: deinitPane frees `line_starts` itself, so the stored
+ // slice must span the complete allocation rather than spare capacity.
+ const starts = try gpa.alloc(usize, lineCount(f.content));
+ starts[0] = 0;
+ var i: usize = 1;
+ var off: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| {
+ off = nl + 1;
+ starts[i] = off;
+ i += 1;
+ }
+ f.line_starts = starts;
+ return starts;
+ }
+
+ /// line count, O(1) once the index is warm
+ pub fn nlines(gpa: std.mem.Allocator, f: *State) usize {
+ const idx = lineIndex(gpa, f) catch return lineCount(f.content);
+ return idx.len;
+ }
+
+ /// byte offset of line `row`, or content.len past the end — modal
+ /// .lineStartOffset's contract exactly, without its walk
+ pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize {
+ const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row);
+ return if (row >= idx.len) f.content.len else idx[row];
+ }
+
+ pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 {
+ const index = try lineIndex(pane.gpa, f);
+ const lines = try arena.alloc([]const u8, index.len);
+ for (index, 0..) |start, i| {
+ const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len;
+ lines[i] = f.content[start..end];
+ }
+ return lines;
+ }
+
+ /// Use the file's line index only when `text` is its complete live content.
+ /// Edit-buffer fragments and other temporary text retain modal's scan path.
+ fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize {
+ const f = if (pane.file) |*file| file else return null;
+ if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
+ return lineIndex(pane.gpa, f) catch null;
+ }
+
+ pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize {
+ const index = contentIndex(pane, text) orelse return modal.offsetAt(text, cursor);
+ const row = @min(cursor.row, index.len - 1);
+ const start = index[row];
+ const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
+ return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start));
+ }
+
+ pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize {
+ const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row);
+ return if (row >= index.len) text.len else index[row];
+ }
+
+ pub fn textLine(pane: *Pane, text: []const u8, row: usize) []const u8 {
+ const index = contentIndex(pane, text) orelse return modal.lineSlice(text, row);
+ if (row >= index.len) return "";
+ const start = index[row];
+ const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
+ return text[start..end];
+ }
+
+ test "indexed text rows preserve fragment and allocation failure semantics" {
+ var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("first\n\tλ界\n\nlast\n");
+ const text = pane.file.?.content;
+ allocator.fail_index = allocator.alloc_index;
+ for (0..lineCount(text) + 2) |row| try std.testing.expectEqualStrings(modal.lineSlice(text, row), textLine(pane, text, row));
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.line_starts.len);
+ allocator.fail_index = std.math.maxInt(usize);
+ _ = try lineIndex(p.gpa, &pane.file.?);
+ allocator.fail_index = allocator.alloc_index;
+ const allocations = allocator.allocations;
+ for ([_][]const u8{ text, text[2..], text[0..8], "other\nrows\n\n", "" }) |fragment| {
+ for (0..lineCount(fragment) + 2) |row| try std.testing.expectEqualStrings(
+ modal.lineSlice(fragment, row),
+ textLine(pane, fragment, row),
+ );
+ }
+ try std.testing.expectEqual(allocations, allocator.allocations);
+ }
+
+ pub fn textLineCount(pane: *Pane, text: []const u8) usize {
+ const index = contentIndex(pane, text) orelse return modal.cursorLineCount(text);
+ return index.len;
+ }
+
+ pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor {
+ const index = contentIndex(pane, text) orelse return modal.positionAt(text, offset);
+ const bounded = @min(offset, text.len);
+ const row = std.sort.upperBound(usize, index, bounded, struct {
+ fn cmp(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.cmp) - 1;
+ const start = index[row];
+ const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
+ return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) };
+ }
+
+ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
+ std.debug.assert(p.panes[id] == null and !p.reserved_slots[id]);
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
+ errdefer p.gpa.destroy(pane);
+ const history = try History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ p.reserved_slots[id] = true;
+ defer p.reserved_slots[id] = false;
+ const content = try filesystem.read(p, path);
+ errdefer p.gpa.free(content);
+ const total = lineCount(content);
+ const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
+ pane.file = .{ .path = path_copy, .content = content, .scroll = scroll, .history = history };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(scroll);
+ p.installPane(id, pane);
+ if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return pane;
+ }
+
+ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
+ const saved = src.file.?;
+ if (saved.mini_source.len > 0) {
+ const encoded_limit = std.base64.standard.Encoder.calcSize(Mini.max_output_bytes);
+ if (saved.mini_source.len >= 4096 or saved.content.len > Mini.max_output_bytes or
+ saved.content_b64.len > encoded_limit or saved.mini_colors_b64.len > encoded_limit)
+ return error.InvalidMini;
+ }
+ const content: []u8 = if (saved.content_b64.len > 0)
+ try dump.decodeBytes(p.gpa, saved.content_b64)
+ else
+ try p.gpa.dupe(u8, saved.content);
+ errdefer p.gpa.free(content);
+ const path = try p.gpa.dupe(u8, saved.path);
+ errdefer p.gpa.free(path);
+
+ const output: ?Output.State = if (Output.fromWord(saved.origin)) |origin| blk: {
+ var value: Output.State = .{ .from = origin };
+ try Output.setArg(&value, saved.origin_arg);
+ break :blk value;
+ } else null;
+
+ var mini: ?Mini.State = null;
+ errdefer if (mini) |*state| state.deinit(p.gpa);
+ if (saved.mini_source.len > 0) {
+ if (output == null or !std.meta.eql(output.?.from, Output.Origin{ .cmd = .Mini })) return error.InvalidMini;
+ const source = try p.gpa.dupe(u8, saved.mini_source);
+ errdefer p.gpa.free(source);
+ const colors = try dump.decodeBytes(p.gpa, saved.mini_colors_b64);
+ errdefer p.gpa.free(colors);
+ if (colors.len != content.len or colors.len > Mini.max_output_bytes) return error.InvalidMini;
+ for (colors) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMini;
+ mini = .{ .source = source, .colors = colors };
+ } else if (saved.mini_colors_b64.len > 0) return error.InvalidMini;
+
+ const history = try History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(id);
+ pane.file = .{
+ .path = path,
+ .content = content,
+ .output = output,
+ .mini = mini,
+ .scroll = src.scroll,
+ .revision = @intFromBool(saved.dirty),
+ .history = history,
+ };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(src.scroll);
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ if (output == null and filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true, .mode = .baseline_disk } });
+ return pane;
+ }
+
+ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
+ if (file.output == null) for (p.panes, 0..) |slot, id| {
+ if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ };
+ p.gpa.free(file.path);
+ p.gpa.free(file.content);
+ if (file.mini) |*mini| mini.deinit(p.gpa);
+ if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
+ if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
+ for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
+ for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
+ p.gpa.destroy(file.history);
+ }
+
+ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
+ if (p.fs.listeners == 0) return;
+ const id = for (p.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ if (pane.file) |*state| if (state == f) break i;
+ } else return;
+ pardes.filesystem.noteReplace(p, id, false, f.content, new);
+ }
+
+ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
+ reportEdit(p, f, new);
+ if (f.mini) |*mini| mini.deinit(p.gpa);
+ f.mini = null;
+ p.gpa.free(f.content);
+ f.content = new;
+ f.revision +%= 1;
+ if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
+ f.line_starts = &.{};
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = true;
+ }
+
+ pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void {
+ const n = nlines(pane.gpa, f);
+ const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen));
+ pane.vsel = snap.vsel;
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void {
+ if (len.* == slots.len) {
+ gpa.free(slots[0].content);
+ std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = snap;
+ len.* += 1;
+ }
+
+ pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ const history = f.history;
+ if (history.undo_len > 0 and std.mem.eql(u8, history.undo[history.undo_len - 1].content, f.content)) return;
+ const snap: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &history.undo, &history.undo_len, snap);
+ for (history.redo[0..history.redo_len]) |item| p.gpa.free(item.content);
+ history.redo_len = 0;
+ }
+
+ pub fn undo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ const history = f.history;
+ if (history.undo_len == 0) return;
+ const current: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &history.redo, &history.redo_len, current);
+ history.undo_len -= 1;
+ const previous = history.undo[history.undo_len];
+ setContent(p, f, previous.content);
+ restoreSnap(pane, f, previous);
+ }
+
+ pub fn redo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ const history = f.history;
+ if (history.redo_len == 0) return;
+ const current: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &history.undo, &history.undo_len, current);
+ history.redo_len -= 1;
+ const next = history.redo[history.redo_len];
+ setContent(p, f, next.content);
+ restoreSnap(pane, f, next);
+ }
+
+ /// Commit an externally rewritten file onto the same undo history as typed
+ /// edits. Unsaved work remains one `u` away; there is no third merge state.
+ pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (std.mem.eql(u8, f.content, bytes)) return;
+ const new = p.gpa.dupe(u8, bytes) catch return;
+ pushUndo(p, pane);
+ setContent(p, f, new);
+ f.saved_revision = f.revision;
+ // restoreSnap only consumes cursor/selection from this synthetic snapshot.
+ restoreSnap(pane, f, .{
+ .content = undefined,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ });
+ }
+
+ /// re-highlight the visible window of any file whose syntax went stale
+ /// (edit, scroll, load) — visible-range-first so big files stay snappy
+ pub fn refreshHighlights(p: *Pardes) void {
+ const tz = tracy.zone(@src(), "refreshHighlights");
+ defer tz.end();
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.file == null) continue;
+ const f = &pane.file.?;
+ if (f.mini != null) {
+ f.syntax_dirty = false;
+ continue;
+ }
+ if (!f.syntax_dirty) continue;
+ if (!p.settings.colors) {
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ continue;
+ }
+ const need_start = lineStart(p.gpa, f, f.scroll);
+ const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS));
+ if (f.highlights.len > 0 and need_start >= f.highlight_start and
+ need_end <= f.highlight_start + f.highlights.len)
+ {
+ f.syntax_dirty = false;
+ continue;
+ }
+ const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
+ const whole = pane.colorAlgo() == .locations;
+ const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack);
+ const end = if (whole)
+ f.content.len
+ else
+ @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
+ const new_highlights = (switch (pane.colorAlgo()) {
+ .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end),
+ .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end),
+ else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end),
+ }) catch {
+ f.syntax_dirty = false;
+ continue;
+ };
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ f.highlights = new_highlights;
+ f.highlight_start = if (f.highlights.len > 0) start else 0;
+ f.syntax_dirty = false;
+ }
+ }
+
+ pub fn wrapWidth(pane: *const Pane, wrap: bool) usize {
+ if (!wrap or pane.rows > pane.wrap_line.len) return 0;
+ return @max(1, @as(usize, pane.cols -| gutterWidth(pane)) -| 1);
+ }
+
+ pub const VisualRow = struct { start: usize, end: usize };
+
+ pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow {
+ if (width == 0) return .{ .start = 0, .end = line.len };
+ var start: usize = 0;
+ while (true) {
+ const end = fitEnd(line, start, width);
+ if (col < end or end >= line.len) return .{ .start = start, .end = end };
+ start = end;
+ }
+ }
+
+ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
+ const width = wrapWidth(pane, wrap);
+ pane.wrap_n = 0;
+
+ const len = fillBody(null, pane, f, width, false);
+ const out = try arena.alloc(u8, len);
+ const filled = fillBody(out, pane, f, width, true);
+ std.debug.assert(filled == out.len);
+ return out;
+ }
+
+ /// Run the file-body row walk. With no destination it is the exact sizing
+ /// pass; with one it fills that allocation and records the wrapping map.
+ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize {
+ if (record_wrap) pane.wrap_n = 0;
+ const prefix_width = gutterWidth(pane);
+ // start ON the first visible line instead of walking the file to it: this
+ // walk was O(f.scroll) and recolorSyntax below ran the identical one again
+ var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
+ if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next();
+ // the line the NEXT row comes from and the byte column of it that row
+ // starts at — the two the map records, walked forward by the loop
+ var abs: i32 = @intCast(f.scroll);
+ var at: usize = 0;
+ var cur = flines.next();
+ var written: usize = 0;
+ for (0..pane.rows) |i| {
+ if (i > 0) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
+ if (width > 0 and record_wrap) {
+ pane.wrap_line[i] = abs;
+ pane.wrap_col[i] = @intCast(at);
+ pane.wrap_n = @intCast(i + 1);
+ }
+ if (cur) |text| {
+ var lbuf: [@max(config.PREFIX_W, 32)]u8 = undefined;
+ const prefix = lbuf[0..prefix_width];
+ @memset(prefix, ' ');
+ if (at == 0) {
+ var lineno: usize = @intCast(abs + 1);
+ var digit: usize = prefix.len - 1;
+ while (true) {
+ digit -= 1;
+ prefix[digit] = '0' + @as(u8, @intCast(lineno % 10));
+ lineno /= 10;
+ if (lineno == 0) break;
+ }
+ }
+ if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix);
+ written += prefix.len;
+
+ const end = if (width == 0) text.len else fitEnd(text, at, width);
+ const take = end - at;
+ const cut = if (pane.hscroll > 0 and width == 0)
+ modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take))
+ else
+ 0;
+ const shown = text[at + cut .. end];
+ if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
+ written += shown.len;
+ if (width > 0 and end < text.len) {
+ at = end;
+ } else {
+ abs += 1;
+ at = 0;
+ cur = flines.next();
+ }
+ } else abs += 1;
+ }
+ return written;
+ }
+
+ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
+ const s = &p.surface;
+ const prefix_width = gutterWidth(pane);
+ const ch = p.chromeTheme();
+ const goff = pane.scroll();
+ const gcur = Terminal.gridCursor(pane);
+ const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff;
+ const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
+ // the body's first row, the way renderPane derives it (Tagbottom)
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const row_line: i32 = if (pane.wrap_n == 0)
+ goff + @as(i32, vr)
+ else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32);
+ const on_cursor = row_line == cur_line;
+ var c: u16 = 0;
+ while (c < prefix_width and c < tw) : (c += 1) {
+ const cell = s.at(tx + c, body_y + vr);
+ cell.default = false; // paints blank gutter rows too
+ if (on_cursor) {
+ cell.style.fg = .{ .rgb = ch.tag_fg };
+ cell.style.bg = .{ .rgb = ch.tag_bg };
+ } else cell.style.fg = .{ .rgb = ch.lineno };
+ }
+ }
+ }
+
+ const SynStyle = struct { fg: [3]u8, bold: bool };
+
+ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
+ return switch (sy) {
+ .none => null,
+ .keyword => .{ .fg = p.theme().kw, .bold = true },
+ .string => .{ .fg = p.theme().str, .bold = false },
+ .number => .{ .fg = p.theme().num, .bold = false },
+ .comment => .{ .fg = p.theme().comment, .bold = true },
+ };
+ }
+
+ /// syntax colors: recolor each content cell from its tree-sitter style byte;
+ /// content starts after the lineno gutter
+ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+ const highlights = if (f.mini) |mini| mini.colors else f.highlights;
+ const highlight_start = if (f.mini != null) 0 else f.highlight_start;
+ if (highlights.len == 0) return;
+ const s = &p.surface;
+ const prefix_width = gutterWidth(pane);
+ const tz_recolor = tracy.zone(@src(), "synRecolor");
+ defer tz_recolor.end();
+ // indexed start, same as bodyText — an empty tail simply paints nothing
+ var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
+ const total = nlines(p.gpa, f);
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var base: usize = undefined;
+ var line: []const u8 = undefined;
+ var hs: usize = @intCast(@max(0, pane.hscroll));
+ var limit: usize = undefined;
+ if (pane.wrap_n == 0) {
+ line = flines.next() orelse break;
+ base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
+ limit = line.len;
+ } else {
+ if (vr >= pane.wrap_n) break;
+ const lrow: usize = @intCast(@max(0, pane.wrap_line[vr]));
+ if (lrow >= total) break;
+ base = lineStart(p.gpa, f, lrow);
+ const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len;
+ line = f.content[base..lend];
+ hs = @intCast(pane.wrap_col[vr]);
+ limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr])
+ @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1])))
+ else
+ line.len;
+ }
+ hs = modal.graphemeStart(line, @min(hs, line.len));
+ var c: usize = 0;
+ var screen_c: usize = 0;
+ while (hs + c < limit and prefix_width + screen_c < tw) {
+ const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c));
+ const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]);
+ const idx = base + hs + c;
+ if (idx >= highlight_start) {
+ const hidx = idx - highlight_start;
+ if (hidx < highlights.len) {
+ if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| {
+ var fill: usize = 0;
+ while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ }
+ }
+ screen_c += cells;
+ c = grapheme_end - hs;
+ }
+ }
+ }
+
+ pub fn drawWrapMarkers(
+ p: *Pardes,
+ pane: *const Pane,
+ r: pardes.Rect,
+ tx: u16,
+ tw: u16,
+ body_h: u16,
+ pane_bg: pardes.Color,
+ ) void {
+ if (tw <= gutterWidth(pane) + 1) return;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const marker_fg = p.chromeTheme().lineno;
+ var row: u16 = 0;
+ while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
+ if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue;
+ p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
+ .fg = .{ .rgb = marker_fg },
+ .bg = pane_bg,
+ });
+ }
+ }
+
+ pub fn paintWordSelection(
+ p: *Pardes,
+ pane: *Pane,
+ r: pardes.Rect,
+ row: i32,
+ word_lo: i32,
+ word_hi: i32,
+ bg: [3]u8,
+ ) void {
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ const prefix_width = gutterWidth(pane);
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var vr: i32 = 0;
+ while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
+ const here = pane.wrapAt(vr);
+ if (here.line != row) continue;
+ var hi = word_hi;
+ const next = pane.wrapAt(vr + 1);
+ if (next.line == row) hi = @min(hi, next.at);
+ const lo = @max(word_lo, here.at);
+ if (hi <= lo) continue;
+ const c0 = @as(i32, prefix_width) + displayOffset(pane, row, here.at, lo);
+ const c1 = @as(i32, prefix_width) + displayEndOffset(pane, row, here.at, hi - 1);
+ var col = @max(@as(i32, prefix_width), c0);
+ while (col <= c1 and col < @as(i32, tw)) : (col += 1) {
+ const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ }
+ }
+ }
+};
+
+pub const Output = struct {
+ const Builtin = builtins.registry.Builtin();
+ const gui_shader_source_mode = effect_sources.guiShaderSourceMode();
+
+ const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {};
+
+ pub const Origin = union(enum) {
+ cmd: Builtin,
+ query: lsp.Kind,
+ search,
+ errors,
+ };
+
+ pub const max_arg = dump.max_origin_arg;
+
+ pub const State = struct {
+ from: Origin,
+ arg_buf: [max_arg]u8 = undefined,
+ arg_len: u16 = 0,
+
+ pub fn arg(o: *const State) []const u8 {
+ return o.arg_buf[0..o.arg_len];
+ }
+ };
+
+ pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void {
+ if (text.len > max_arg) return error.ArgumentTooLong;
+ o.arg_len = @intCast(text.len);
+ @memcpy(o.arg_buf[0..o.arg_len], text);
+ }
+
+ pub const Traits = builtins.OutputTraits;
+
+ const file_row: Traits = .{ .name = "", .doc = true, .saves = true };
+
+ pub fn traits(o: Origin) Traits {
+ return switch (o) {
+ // rows are `location text`, so n/N walk them
+ .search => .{ .name = config.search_buffer, .steps = true },
+ .errors => .{ .name = config.errors_buffer, .doc = true },
+ .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable,
+ .query => |k| switch (k) {
+ .hover => .{ .name = config.hover_buffer },
+ // prose: an action list, a diff, a report about the backend
+ .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer },
+ .rename => .{ .name = config.search_buffer, .steps = true },
+ .definition, .declaration, .type_definition, .implementation, .references => .{
+ .name = config.search_buffer,
+ .steps = true,
+ .jumps = true,
+ },
+ // The hierarchy kinds behave like references: a list of places,
+ // and a lone answer (one caller, one subtype) is a jump.
+ .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{
+ .name = config.search_buffer,
+ .steps = true,
+ .jumps = true,
+ },
+ .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{
+ .name = config.search_buffer,
+ .steps = true,
+ },
+ },
+ };
+ }
+
+ pub fn fileTraits(out: ?State) Traits {
+ return traits((out orelse return file_row).from);
+ }
+
+ pub const Grain = enum {
+ word,
+ line,
+ whole,
+ };
+
+ pub fn grain(out: ?State) Grain {
+ const tr = fileTraits(out);
+ if (tr.commands) return .whole;
+ return if (tr.steps) .line else .word;
+ }
+
+ pub fn word(o: Origin) []const u8 {
+ return switch (o) {
+ .cmd => |b| @tagName(b),
+ .query => |k| @tagName(k),
+ .search => "/",
+ .errors => config.errors_buffer,
+ };
+ }
+
+ pub fn fromWord(w: []const u8) ?Origin {
+ if (w.len == 0) return null;
+ if (std.mem.eql(u8, w, "/")) return .search;
+ if (std.mem.eql(u8, w, config.errors_buffer)) return .errors;
+ if (std.meta.stringToEnum(Builtin, w)) |b|
+ if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b };
+ if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k };
+ return null;
+ }
+
+ pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool {
+ const rp = p.panes[pane.search_pane orelse return false] orelse return false;
+ const f = rp.file orelse return false;
+ const o = f.output orelse return false;
+ return std.meta.eql(o.from, from);
+ }
+
+ pub const Location = struct {
+ path: []const u8,
+ at: look.Spot,
+ end: usize,
+
+ fn order(a: Location, b: Location) std.math.Order {
+ const path = std.mem.order(u8, a.path, b.path);
+ if (path != .eq) return path;
+ if (a.at.line != b.at.line) return std.math.order(a.at.line, b.at.line);
+ return std.math.order(a.at.col, b.at.col);
+ }
+ };
+
+ pub fn location(line: []const u8) Location {
+ const text = std.mem.trimEnd(u8, line, " \t\r\n");
+ var at: usize = 1;
+ while (at < text.len) {
+ if (text[at] != ':' or at + 1 == text.len or !std.ascii.isDigit(text[at + 1])) {
+ at += 1;
+ continue;
+ }
+ const start = at;
+ at += 1;
+ while (at < text.len and (std.ascii.isDigit(text[at]) or text[at] == ':' or text[at] == '-')) at += 1;
+ if (at < text.len and text[at] != ' ' and text[at] != '\t') continue;
+ const parsed = look.parsePathLine(text[start..at]);
+ if (parsed.at.line == 0 or parsed.end != at - start) continue;
+ return .{ .path = text[0..start], .at = parsed.at, .end = at };
+ }
+ return .{ .path = text, .at = .{}, .end = text.len };
+ }
+
+ fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize {
+ switch (from) {
+ .search => {},
+ .cmd => |cmd| switch (cmd) {
+ .Find, .Grep => {},
+ else => return anchor,
+ },
+ .query => |kind| switch (kind) {
+ .definition,
+ .declaration,
+ .type_definition,
+ .implementation,
+ .references,
+ .incoming_calls,
+ .outgoing_calls,
+ .supertypes,
+ .subtypes,
+ .document_symbols,
+ .workspace_symbols,
+ .diagnostics,
+ .workspace_diagnostics,
+ .select_refs,
+ .rename,
+ => {},
+ else => return anchor,
+ },
+ .errors => return anchor,
+ }
+ if (content.len == 0) return anchor;
+ const path_only = std.meta.eql(from, Origin{ .cmd = .Find });
+ const trailing_newline = content[content.len - 1] == '\n';
+ const body = content[0 .. content.len - @intFromBool(trailing_newline)];
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ var previous: ?Location = null;
+ var sorted = true;
+ var count: usize = 0;
+ while (lines.next()) |line| {
+ const current: Location = if (path_only) .{ .path = line, .at = .{}, .end = line.len } else location(line);
+ if (!path_only and current.at.line == 0) return anchor;
+ if (previous) |last| if (last.order(current) == .gt) {
+ sorted = false;
+ };
+ previous = current;
+ count += 1;
+ }
+ if (sorted) return anchor;
+
+ const Row = struct {
+ text: []const u8,
+ target: Location,
+ original: usize,
+
+ fn lessThan(_: void, a: @This(), b: @This()) bool {
+ return switch (a.target.order(b.target)) {
+ .lt => true,
+ .eq => a.original < b.original,
+ .gt => false,
+ };
+ }
+ };
+ const rows = try arena.alloc(Row, count);
+ const copy = try arena.dupe(u8, body);
+ lines = std.mem.splitScalar(u8, copy, '\n');
+ for (rows, 0..) |*row, original| {
+ const text = lines.next().?;
+ const target: Location = if (path_only) .{ .path = text, .at = .{}, .end = text.len } else location(text);
+ row.* = .{
+ .text = text,
+ .target = target,
+ .original = original,
+ };
+ }
+ std.mem.sort(Row, rows, {}, Row.lessThan);
+ var mapped = anchor;
+ var offset: usize = 0;
+ for (rows, 0..) |row, i| {
+ if (anchor == row.original) mapped = i;
+ @memcpy(content[offset..][0..row.text.len], row.text);
+ offset += row.text.len;
+ if (i + 1 < rows.len or trailing_newline) {
+ content[offset] = '\n';
+ offset += 1;
+ }
+ }
+ std.debug.assert(offset == content.len);
+ return mapped;
+ }
+
+ pub fn nextResult(content: []const u8, path: []const u8, at: look.Spot) usize {
+ const current: Location = .{ .path = path, .at = at, .end = 0 };
+ var lines = std.mem.splitScalar(u8, content, '\n');
+ var first: ?usize = null;
+ var row: usize = 0;
+ while (lines.next()) |line| : (row += 1) {
+ const target = location(line);
+ if (target.at.line == 0) continue;
+ if (first == null) first = row;
+ if (target.order(current) == .gt) return row;
+ }
+ return first orelse 0;
+ }
+
+ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane {
+ const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{
+ std.mem.trimEnd(u8, dir, "/"), traits(from).name,
+ });
+ errdefer p.gpa.free(path);
+ var out: State = .{ .from = from };
+ try setArg(&out, arg);
+ const history = try File.History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content, .output = out, .history = history };
+ pane.cur_pinned = true;
+ return pane;
+ }
+
+ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void {
+ errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
+ const anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor);
+ const by_arg = std.meta.activeTag(from) != .query;
+ for (p.panes, 0..) |slot, i| {
+ if (i == id) continue;
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = if (rf.output) |*x| x else continue;
+ if (!std.meta.eql(o.from, from)) continue;
+ if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ try setArg(o, arg);
+ if (std.mem.eql(u8, rf.content, content)) {
+ p.gpa.free(content);
+ } else {
+ File.setContent(p, rf, content);
+ resetBody(p, rp);
+ }
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = anchor;
+ p.armLookWalk(i);
+ }
+ return;
+ }
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, from, arg, content);
+ p.placeDoc(id, free, np);
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = free;
+ pane.search_row = anchor;
+ p.armLookWalk(free);
+ }
+ }
+
+ pub fn openJumps(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ for (p.jumps[0..p.njumps]) |j| {
+ const jp = p.panes[j.pane] orelse continue;
+ var idbuf: [16]u8 = undefined;
+ const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null;
+ const has_path = if (jp.file) |f| f.output == null else pdf_path != null;
+ const loc: []const u8 = if (has_path)
+ (if (jp.file) |f| f.path else pdf_path.?)
+ else
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
+ const what: []const u8 = if (jp.file) |f|
+ std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
+ else if (jp.image) |iv|
+ iv.path
+ else if (pdf_path) |path|
+ path
+ else
+ jp.cwdSlice();
+ var cut = @min(what.len, 120);
+ while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
+ if (j.line == 0)
+ try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] })
+ else
+ try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
+ }
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .Jumplist }, content);
+ }
+
+ pub fn openThemes(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ for (pardes.themes) |t|
+ try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name});
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .ThemeSel }, content);
+ }
+
+ pub fn openFonts(p: *Pardes, id: usize) !void {
+ if (builtins.capabilities.font_picker) {
+ const arena = p.scratch.allocator();
+ const font_list = fonts.list(arena, null);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ for (font_list) |f|
+ try out.writer.print(comptime config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name});
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .FontSel }, content);
+ }
+ }
+
+ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
+ errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, from, "", content);
+ p.placeDoc(id, free, np);
+ p.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+ p.armLookWalk(free);
+ }
+
+ fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 {
+ const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n";
+ const group_header = "pardes builtins under SPC";
+ const language_footer =
+ "\nlanguage keys (motions, not words):\n" ++
+ "gd gD gy gi gr goto: definition,\n" ++
+ " declaration, type-def,\n" ++
+ " implementation, refs\n" ++
+ "]d [d ]D [D diagnostics: next,\n" ++
+ " prev, last, first\n" ++
+ "= format (applies, one\n" ++
+ " undo step)\n" ++
+ "Tab after a . completion, in insert\n" ++
+ "C-left-click definition, by mouse\n" ++
+ "SPC l ... hover, rename, symbols,\n" ++
+ " calls, types: above\n";
+ var len: usize = if (prefix.len == 0)
+ full_header.len + language_footer.len
+ else
+ group_header.len + prefix.len * 2 + 2;
+ for (pardes.builtin_rows) |row| {
+ // a path-less builtin filters as the empty path: in the full listing
+ // (which starts with nothing) and out of every group
+ if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
+ len += row.line.len + 1;
+ }
+ const content = try gpa.alloc(u8, len);
+ var at: usize = 0;
+ if (prefix.len == 0) {
+ @memcpy(content[0..full_header.len], full_header);
+ at = full_header.len;
+ } else {
+ @memcpy(content[0..group_header.len], group_header);
+ at = group_header.len;
+ for (prefix) |c| {
+ content[at] = ' ';
+ content[at + 1] = c;
+ at += 2;
+ }
+ content[at] = '\n';
+ content[at + 1] = '\n';
+ at += 2;
+ }
+ for (pardes.builtin_rows) |row| {
+ if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
+ @memcpy(content[at..][0..row.line.len], row.line);
+ at += row.line.len;
+ content[at] = '\n';
+ at += 1;
+ }
+ if (prefix.len == 0) {
+ @memcpy(content[at..][0..language_footer.len], language_footer);
+ at += language_footer.len;
+ }
+ std.debug.assert(at == content.len);
+ return content;
+ }
+
+ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
+ const content = try helpContent(p.gpa, prefix);
+ // content is handed off unfreed on purpose: openRead adopts it or frees
+ // it, and nothing between the alloc above and this line can fail.
+ return openRead(p, id, .{ .cmd = .Help }, prefix, content);
+ }
+
+ test "full Help renders every builtin row, then the language keys" {
+ const content = try helpContent(std.testing.allocator, "");
+ defer std.testing.allocator.free(content);
+
+ // FIRST blank line: the end of the header (the footer opens with one too)
+ const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse
+ return error.MissingHelpHeader) + 2 ..];
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ for (pardes.builtin_rows) |row|
+ try std.testing.expectEqualStrings(row.line, lines.next() orelse
+ return error.MissingBuiltinHelpRow);
+ // ...and after the last row, the language-keys section: the one part of
+ // the keymap no builtin row can carry, closing the page.
+ try std.testing.expectEqualStrings("", lines.next() orelse
+ return error.MissingLanguageKeys);
+ try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse
+ return error.MissingLanguageKeys);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null);
+ // the group view stays a pure filter: no footer under a prefix
+ const group = try helpContent(std.testing.allocator, "l");
+ defer std.testing.allocator.free(group);
+ try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null);
+ }
+
+ pub fn openConfig(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try config.Runtime.writeReport(&out.writer, .{
+ .startup_config_path = p.opts.startup_config_path,
+ .platform = @tagName(pardes.platform),
+ .theme_name = p.theme().name,
+ .compiled_default_shell = config.default_shell,
+ .gui_shader_source_mode = if (gui_shader_source_mode) |mode|
+ mode.label()
+ else
+ null,
+ .hover_delay_frames = config.look_preview_delay_frames,
+ .native_images = p.native_images,
+ .capabilities = builtins.capabilities,
+ .state = &p.settings,
+ });
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .Config }, "", content);
+ }
+
+ /// The message-row log, oldest first — the lines that were said in passing and
+ /// then cleared by the next keystroke.
+ pub fn openMessages(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ var i: usize = 0;
+ while (p.messageLog(i)) |m| : (i += 1) {
+ if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane});
+ try out.writer.writeAll(m.slice());
+ if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats});
+ try out.writer.writeByte('\n');
+ }
+ if (i == 0) try out.writer.writeAll("nothing has been said yet\n");
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .Messages }, "", content);
+ }
+
+ /// The version banner plus the embedded CHANGELOG, so an installed binary can
+ /// say what it is and what changed without a repository beside it.
+ pub fn openChangelog(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try out.writer.print("pardes {s}\n\n", .{build_options.version});
+ try out.writer.writeAll(@embedFile("CHANGELOG.md"));
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .Changelog }, "", content);
+ }
+
+ pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void {
+ const name = std.mem.trim(u8, argument, " \t\r\n");
+ const setting = config.Runtime.find(name) orelse return error.UnknownEffect;
+ switch (setting.action) {
+ .transition, .scene => {},
+ else => return error.NotAnEffect,
+ }
+ if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable;
+ const paths = effect_sources.forSetting(setting) orelse
+ return error.EffectUnavailable;
+
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) });
+ if (gui_shader_source_mode) |mode|
+ try out.writer.print("GUI shader source: {s}\n", .{mode.label()});
+ try out.writer.writeByte('\n');
+ for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path});
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content);
+ }
+
+ pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void {
+ return openRead(p, id, .errors, "", content);
+ }
+
+ fn resetBody(p: *Pardes, pane: *Pane) void {
+ pane.file.?.scroll = 0;
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.hscroll = 0;
+ pane.wrap_n = 0;
+ pane.msel = .{};
+ pane.vsel = .{};
+ pane.nsel = 0;
+ pane.nsel_snap = 0;
+ pane.select = false;
+ pane.sel = @splat(.{});
+ pane.sticky_col = -1;
+ pane.append_at = null;
+ pane.normal.clear();
+ pane.look_at = null;
+ if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) {
+ p.look_hover_wait = null;
+ };
+ if (p.look_hover_preview) |preview| if (preview.serial == pane.serial) {
+ p.look_hover_preview = null;
+ };
+ if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none;
+ }
+
+ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void {
+ errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
+ for (p.panes, 0..) |slot, i| {
+ const hp = slot orelse continue;
+ const hf = if (hp.file) |*f| f else continue;
+ const ho = if (hf.output) |*o| o else continue;
+ if (!std.meta.eql(ho.from, from)) continue;
+ try setArg(ho, arg);
+ File.setContent(p, hf, content);
+ resetBody(p, hp);
+ p.active = i;
+ return;
+ }
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, from, arg, content);
+ p.placeDoc(id, free, np);
+ p.active = free;
+ }
+};
+
+pub const Mini = struct {
+ pub const max_input_bytes = 4 * 1024 * 1024;
+ pub const max_output_bytes = 4 * 1024 * 1024;
+
+ pub const State = struct {
+ source: []u8,
+ colors: []u8,
+
+ pub fn deinit(state: *State, gpa: std.mem.Allocator) void {
+ gpa.free(state.source);
+ gpa.free(state.colors);
+ state.* = undefined;
+ }
+ };
+
+ pub const Result = struct {
+ content: []u8,
+ colors: []u8,
+
+ pub fn deinit(result: Result, gpa: std.mem.Allocator) void {
+ gpa.free(result.content);
+ gpa.free(result.colors);
+ }
+ };
+
+ const Row = struct {
+ text: []const u8 = "",
+ base: usize = 0,
+ at: usize = 0,
+ left: usize = 0,
+ ink: bool = false,
+ color: u8 = 0,
+
+ fn dot(row: *Row, styles: []const u8) ?u8 {
+ if (row.left == 0) {
+ if (row.at == row.text.len) return null;
+ const end = modal.nextGrapheme(row.text, row.at);
+ const grapheme = row.text[row.at..end];
+ row.left = File.graphemeDisplayWidth(grapheme);
+ const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable;
+ const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable;
+ const blank = switch (cp) {
+ '\t'...'\r', ' ', 0x85, 0xa0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true,
+ else => false,
+ };
+ row.ink = !blank or grapheme.len != n;
+ row.color = if (styles.len == 0) 0 else styles[row.base + row.at];
+ row.at = end;
+ }
+ row.left -= 1;
+ return if (row.ink) row.color else null;
+ }
+ };
+
+ fn render(output: ?Result, source: []const u8, styles: []const u8) !usize {
+ if (source.len == 0) return 0;
+ const end = source.len - @intFromBool(source[source.len - 1] == '\n');
+ var lines = std.mem.splitScalar(u8, source[0..end], '\n');
+ var offset: usize = 0;
+ while (lines.peek() != null) {
+ var rows: [4]Row = @splat(.{});
+ for (&rows) |*row| {
+ const line = lines.next() orelse break;
+ row.text = std.mem.trimEnd(u8, line, "\r");
+ row.base = @intFromPtr(line.ptr) - @intFromPtr(source.ptr);
+ }
+ var spaces: usize = 0;
+ while (true) {
+ var more = false;
+ for (rows) |row| more = more or row.at < row.text.len or row.left > 0;
+ if (!more) break;
+ const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } };
+ var mask: u8 = 0;
+ var counts: [5]u8 = @splat(0);
+ for (&rows, 0..) |*row, y| {
+ for (0..2) |x| {
+ if (row.dot(styles)) |color| {
+ mask |= @as(u8, 1) << bits[y][x];
+ counts[color] += 1;
+ }
+ }
+ }
+ if (mask == 0) {
+ spaces += 1;
+ continue;
+ }
+ if (spaces + 3 > max_output_bytes - offset) return error.MiniTooLarge;
+ var color: u8 = 0;
+ var most: u8 = 0;
+ for (counts[1..], 1..) |count, i| {
+ if (count > most) {
+ color = @intCast(i);
+ most = count;
+ }
+ }
+ if (output) |out| {
+ @memset(out.content[offset..][0..spaces], ' ');
+ @memset(out.colors[offset..][0..spaces], 0);
+ _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable;
+ @memset(out.colors[offset + spaces ..][0..3], color);
+ }
+ offset += spaces + 3;
+ spaces = 0;
+ }
+ if (offset == max_output_bytes) return error.MiniTooLarge;
+ if (output) |out| {
+ out.content[offset] = '\n';
+ out.colors[offset] = 0;
+ }
+ offset += 1;
+ }
+ return offset;
+ }
+
+ pub fn generate(gpa: std.mem.Allocator, source: []const u8, styles: []const u8) !Result {
+ if (source.len > max_input_bytes) return error.MiniTooLarge;
+ if (!std.unicode.utf8ValidateSlice(source)) return error.InvalidUtf8;
+ if (styles.len != 0 and styles.len != source.len) return error.InvalidMiniColors;
+ for (styles) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMiniColors;
+ const len = try render(null, source, styles);
+ const content = try gpa.alloc(u8, len);
+ errdefer gpa.free(content);
+ const colors = try gpa.alloc(u8, len);
+ errdefer gpa.free(colors);
+ const result: Result = .{ .content = content, .colors = colors };
+ const written = try render(result, source, styles);
+ std.debug.assert(written == len);
+ return result;
+ }
+
+ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void {
+ const caller = p.panes[id] orelse return error.MissingPane;
+ const word = std.mem.trim(u8, argument, " \t\r\n");
+ if (word.len == 0) return error.MissingPath;
+ var path_buf: [4096]u8 = undefined;
+ const target = filesystem.resolve(p, word, Pardes.paneDir(caller), &path_buf) orelse return error.FileNotFound;
+ if (target.dir) return error.NotAFile;
+ const source = try p.gpa.dupe(u8, target.path);
+ errdefer p.gpa.free(source);
+ const input = try filesystem.readLimit(p, source, max_input_bytes);
+ defer p.gpa.free(input);
+ const styles = try syntax.highlightFileRange(p.tree_sitter_gpa, source, input, 0, input.len);
+ defer p.tree_sitter_gpa.free(styles);
+ const result = try generate(p.gpa, input, styles);
+ errdefer result.deinit(p.gpa);
+ for (p.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ const file = if (pane.file) |*f| f else continue;
+ const old = file.mini orelse continue;
+ if (!std.mem.eql(u8, old.source, source)) continue;
+ if (std.mem.eql(u8, file.content, result.content) and std.mem.eql(u8, old.colors, result.colors)) {
+ p.gpa.free(source);
+ result.deinit(p.gpa);
+ } else {
+ File.setContent(p, file, result.content);
+ file.mini = .{ .source = source, .colors = result.colors };
+ file.syntax_dirty = false;
+ Output.resetBody(p, pane);
+ }
+ p.active = i;
+ return;
+ }
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const dir = std.fs.path.dirname(source) orelse "/";
+ const path = try std.fmt.allocPrint(p.gpa, "{s}/Mini {s}", .{ std.mem.trimEnd(u8, dir, "/"), std.fs.path.basename(source) });
+ errdefer p.gpa.free(path);
+ const history = try File.History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(free);
+ pane.file = .{
+ .path = path,
+ .content = result.content,
+ .output = .{ .from = .{ .cmd = .Mini } },
+ .mini = .{ .source = source, .colors = result.colors },
+ .history = history,
+ .syntax_dirty = false,
+ };
+ pane.cur_pinned = true;
+ p.placeDoc(id, free, pane);
+ p.active = free;
+ }
+
+ test "Mini maps every braille dot and partial line group" {
+ const gpa = std.testing.allocator;
+ const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } };
+ for (0..256) |mask| {
+ var source: [12]u8 = undefined;
+ for (0..4) |row| {
+ for (0..2) |col| source[row * 3 + col] = if (mask & (@as(usize, 1) << bits[row][col]) != 0) 'x' else ' ';
+ source[row * 3 + 2] = '\n';
+ }
+ const result = try generate(gpa, &source, "");
+ defer result.deinit(gpa);
+ var expected: [4]u8 = undefined;
+ const len: usize = if (mask == 0) 0 else try std.unicode.utf8Encode(@as(u21, 0x2800) + @as(u21, @intCast(mask)), &expected);
+ expected[len] = '\n';
+ try std.testing.expectEqualStrings(expected[0 .. len + 1], result.content);
+ try std.testing.expectEqual(result.content.len, result.colors.len);
+ for (result.colors) |color| try std.testing.expectEqual(@as(u8, 0), color);
+ }
+ for ([_]struct { source: []const u8, expected: []const u8 }{
+ .{ .source = "", .expected = "" },
+ .{ .source = "x", .expected = "⠁\n" },
+ .{ .source = "xx\n", .expected = "⠉\n" },
+ .{ .source = "x \n", .expected = "⠁\n" },
+ .{ .source = "\n\n\n\nx", .expected = "\n⠁\n" },
+ .{ .source = "x\r\nx\r\n", .expected = "⠃\n" },
+ }) |case| {
+ const result = try generate(gpa, case.source, "");
+ defer result.deinit(gpa);
+ try std.testing.expectEqualStrings(case.expected, result.content);
+ }
+ }
+
+ test "Mini uses display cells for tabs combining text and wide characters" {
+ const gpa = std.testing.allocator;
+ for ([_]struct { source: []const u8, expected: []const u8 }{
+ .{ .source = "e\u{301}界\n", .expected = "⠉⠁\n" },
+ .{ .source = " x\n", .expected = " ⠁\n" },
+ .{ .source = "\u{a0}x\n", .expected = "⠈\n" },
+ }) |case| {
+ const result = try generate(gpa, case.source, "");
+ defer result.deinit(gpa);
+ try std.testing.expectEqualStrings(case.expected, result.content);
+ }
+ const tabs = try generate(gpa, "\tx\n", "");
+ defer tabs.deinit(gpa);
+ const spaces = config.tab_width / 2;
+ for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte);
+ try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]);
+ }
+
+ test "Mini chooses highlighted dots over plain ink with stable color ties" {
+ const gpa = std.testing.allocator;
+ const source = "xx\nxx\nxx\nxx\n";
+ var styles: [source.len]u8 = @splat(0);
+ styles[0] = @intFromEnum(syntax.Syn.keyword);
+ const rare = try generate(gpa, source, &styles);
+ defer rare.deinit(gpa);
+ try std.testing.expectEqualStrings("⣿\n", rare.content);
+ try std.testing.expectEqualSlices(u8, &.{ 1, 1, 1, 0 }, rare.colors);
+ styles[0] = @intFromEnum(syntax.Syn.string);
+ styles[1] = @intFromEnum(syntax.Syn.number);
+ const tied = try generate(gpa, source, &styles);
+ defer tied.deinit(gpa);
+ try std.testing.expectEqualSlices(u8, &.{ 2, 2, 2, 0 }, tied.colors);
+ styles[3] = @intFromEnum(syntax.Syn.number);
+ const majority = try generate(gpa, source, &styles);
+ defer majority.deinit(gpa);
+ try std.testing.expectEqualSlices(u8, &.{ 3, 3, 3, 0 }, majority.colors);
+ }
+
+ test "Mini generation bounds and allocation failures leave no partial result" {
+ const Case = struct {
+ fn run(gpa: std.mem.Allocator) !void {
+ const result = try generate(gpa, "alpha\nbeta\ngamma\ndelta\nepsilon\n", "");
+ defer result.deinit(gpa);
+ }
+ };
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{});
+ try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "x", &.{5}));
+ try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "xx", &.{0}));
+ var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
+ const source = try std.testing.allocator.alloc(u8, max_input_bytes + 1);
+ defer std.testing.allocator.free(source);
+ try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source, ""));
+ for (source[0..max_input_bytes], 0..) |*byte, i| byte.* = if (i % 2 == 0) 'x' else ' ';
+ try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source[0..max_input_bytes], ""));
+ try std.testing.expect(!allocator.has_induced_failure);
+ }
+
+ test "Mini publishes only complete snapshots and content replacement drops metadata" {
+ const Case = struct {
+ fn run(gpa: std.mem.Allocator, path: []const u8) !void {
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const source = try p.setTestFile("untouched\n");
+ const free = p.freeSlot();
+ open(p, 0, path) catch |err| {
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqual(free, p.freeSlot());
+ try std.testing.expectEqual(source, p.panes[0].?);
+ try std.testing.expectEqualStrings("untouched\n", source.file.?.content);
+ return err;
+ };
+ const file = &p.panes[p.active].?.file.?;
+ try std.testing.expectEqualStrings("⠉\n", file.content);
+ try std.testing.expectEqualStrings(path, file.mini.?.source);
+ const replacement = try gpa.dupe(u8, "plain\n");
+ File.setContent(p, file, replacement);
+ try std.testing.expect(file.mini == null);
+ try std.testing.expectEqualStrings("plain\n", file.content);
+ }
+ };
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "mini.txt", .data = "xx\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var path_buf: [4096]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir});
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path});
+ }
+};
+
+pub const Image = struct {
+ const GridKey = struct {
+ cols: u16 = 0,
+ rows: u16 = 0,
+ palette: image.PaletteMode = .commodore,
+ ascii: bool = true,
+ };
+
+ pub const State = struct {
+ path: []u8,
+ glyph_art: bool = false,
+ pmode: image.PaletteMode = .commodore,
+ ascii: bool = true,
+ tried: bool = false,
+ rgba: []u8 = &.{},
+ iw: usize = 0,
+ ih: usize = 0,
+ /// Dump-loaded bytes, decoded lazily by the same path as a disk image.
+ raw: []u8 = &.{},
+ grid: image.GlyphArt.Grid = .{ .cells = &.{}, .cols = 0, .rows = 0 },
+ grid_key: GridKey = .{},
+
+ pub fn deinit(state: *State, gpa: std.mem.Allocator) void {
+ gpa.free(state.path);
+ if (state.rgba.len > 0) gpa.free(state.rgba);
+ if (state.raw.len > 0) gpa.free(state.raw);
+ if (state.grid.cells.len > 0) gpa.free(state.grid.cells);
+ state.* = undefined;
+ }
+ };
+
+ /// Construct an image pane from a path and optionally transferred dump bytes.
+ /// `raw` must be image_gpa-owned and ownership transfers only on success.
+ pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane {
+ const path_copy = try p.image_gpa.dupe(u8, path);
+ errdefer p.image_gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ pane.image = .{ .path = path_copy, .raw = raw };
+ return pane;
+ }
+
+ /// Serialize the binary image record used by images and, for dump-schema
+ /// compatibility, PDFs. Common pane metadata is supplied by the core.
+ pub fn dumpPane(
+ p: *pardes.Pardes,
+ arena: std.mem.Allocator,
+ pane: *const pardes.Pane,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+ path: []const u8,
+ raw: []const u8,
+ ) !dump.Pane {
+ const bytes = if (raw.len > 0) raw else filesystem.read(p, path) catch &.{};
+ defer if (raw.len == 0) p.gpa.free(bytes);
+ return .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .image = .{
+ .path = path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
+ .petscii = if (pane.image) |state| state.glyph_art else false,
+ .palette = if (pane.image) |state| switch (state.pmode) {
+ .commodore => .commodore,
+ .terminal => .terminal,
+ } else .commodore,
+ .ascii = if (pane.image) |state| state.ascii else true,
+ },
+ };
+ }
+
+ pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane {
+ const saved = src.image.?;
+ var raw: []u8 = if (saved.bytes_b64.len > 0)
+ try dump.decodeBytes(p.image_gpa, saved.bytes_b64)
+ else
+ &.{};
+ errdefer if (raw.len > 0) p.image_gpa.free(raw);
+ const pane = try create(p, id, saved.path, raw);
+ raw = &.{};
+ pane.image.?.glyph_art = saved.petscii;
+ pane.image.?.pmode = switch (saved.palette) {
+ .commodore => .commodore,
+ .terminal => .terminal,
+ };
+ pane.image.?.ascii = saved.ascii;
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ return pane;
+ }
+
+ pub fn toggleGlyphArt(state: *State) void {
+ state.glyph_art = !state.glyph_art;
+ }
+
+ pub fn togglePalette(state: *State) void {
+ state.pmode = if (state.pmode == .commodore) .terminal else .commodore;
+ }
+
+ pub fn toggleAscii(state: *State) void {
+ state.ascii = !state.ascii;
+ }
+
+ /// Image renderer choices are pane-local, not global Config values. Keep them
+ /// queryable where they apply: the live tag beside the image's path.
+ pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 {
+ return std.fmt.allocPrint(
+ arena,
+ "{s} petscii:{s} palette:{s} ascii:{s} {s}",
+ .{
+ config.tag_image,
+ if (state.glyph_art) "on" else "off",
+ @tagName(state.pmode),
+ if (state.ascii) "on" else "off",
+ state.path,
+ },
+ );
+ }
+
+ pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 {
+ const lead = config.tag_image ++ " ";
+ if (!std.mem.startsWith(u8, saved_tag, lead)) return null;
+ const path_at = lead.len;
+ if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null;
+ return saved_tag[0 .. path_at + state.path.len];
+ }
+
+ fn ensureDecoded(p: *pardes.Pardes, state: *State) void {
+ if (state.tried) return;
+ state.tried = true;
+ const bytes: []const u8 = if (state.raw.len > 0)
+ state.raw
+ else
+ filesystem.read(p, state.path) catch &.{};
+ defer if (state.raw.len == 0) p.gpa.free(bytes);
+ if (image.decode(p.image_gpa, bytes)) |decoded| {
+ state.rgba = decoded.rgba;
+ state.iw = decoded.w;
+ state.ih = decoded.h;
+ }
+ }
+
+ fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void {
+ const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii };
+ if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return;
+ if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells);
+ const palette = switch (state.pmode) {
+ .commodore => image.GlyphArt.commodore,
+ .terminal => image.terminal_palette,
+ };
+ state.grid = image.GlyphArt.render(
+ p.image_gpa,
+ state.rgba,
+ state.iw,
+ state.ih,
+ cols,
+ rows,
+ palette,
+ state.ascii,
+ ) catch .{ .cells = &.{}, .cols = 0, .rows = 0 };
+ state.grid_key = wanted;
+ }
+
+ pub fn draw(
+ p: *pardes.Pardes,
+ state: *State,
+ pane_id: u8,
+ serial: u32,
+ x: u16,
+ y: u16,
+ cols: u16,
+ rows: u16,
+ ) void {
+ ensureDecoded(p, state);
+ if (state.rgba.len == 0 or cols == 0 or rows == 0) return;
+ if (!state.glyph_art and p.native_images) {
+ _ = p.appendImagePlace(.{
+ .pane = pane_id,
+ .serial = serial,
+ .x = x,
+ .y = y,
+ .w = cols,
+ .h = rows,
+ .rgba = state.rgba,
+ .iw = state.iw,
+ .ih = state.ih,
+ });
+ return;
+ }
+
+ ensureGrid(p, state, cols, rows);
+ if (state.grid.cells.len == 0) return;
+ const offx = if (cols > state.grid.cols) (@as(usize, cols) - state.grid.cols) / 2 else 0;
+ const offy = if (rows > state.grid.rows) (@as(usize, rows) - state.grid.rows) / 2 else 0;
+ for (0..state.grid.rows) |cy| for (0..state.grid.cols) |cx| {
+ const cell = &state.grid.cells[cy * state.grid.cols + cx];
+ const fg: pardes.Color = switch (state.pmode) {
+ .commodore => .{ .rgb = image.GlyphArt.commodore[cell.fg] },
+ .terminal => .{ .index = cell.fg },
+ };
+ const bg: pardes.Color = switch (state.pmode) {
+ .commodore => .{ .rgb = image.GlyphArt.commodore[cell.bg] },
+ .terminal => .{ .index = cell.bg },
+ };
+ const sx = x + @as(u16, @intCast(offx + cx));
+ const sy = y + @as(u16, @intCast(offy + cy));
+ if (sx < p.surface.cols and sy < p.surface.rows)
+ p.surface.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg });
+ };
+ }
+};
+
+pub const Pdf = struct {
+ pub const enabled = @import("pardes_config").mupdf;
+ pub const pdf = if (enabled) @import("mupdf") else struct {
+ pub const PageSize = struct { width: f32, height: f32 };
+ pub const Raster = struct {
+ width: usize = 0,
+ height: usize = 0,
+ stride: usize = 0,
+ len: usize = 0,
+ pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 };
+ pub fn wholePage(_: @This()) Band {
+ return .{};
+ }
+ pub fn band(_: @This(), _: usize, _: usize) Band {
+ return .{};
+ }
+ };
+ };
+ pub const Point = if (enabled) pdf.Point else void;
+ pub const Quad = if (enabled) pdf.Quad else void;
+ pub const Document = if (enabled) pdf.Document else opaque {};
+ pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void;
+ const raster_max = 256;
+ const raster_spare = 4;
+ pub const page_gap_px: u32 = 8;
+ const band_grain: usize = 64;
+
+ pub const FitMode = if (enabled) enum { width, height } else void;
+ pub const TintMode = if (enabled) pdf.TintMode else void;
+ pub const TintColors = if (enabled) pdf.TintColors else void;
+ pub const RenderRequest = if (enabled) pdf.RenderRequest else void;
+
+ /// Dump records must remain recognizable as PDFs even in a build without
+ /// MuPDF, where Look deliberately treats them as ordinary files.
+ pub fn isPath(path: []const u8) bool {
+ return std.ascii.endsWithIgnoreCase(path, ".pdf");
+ }
+
+ pub const RasterPolicy = struct {
+ dpi: u16,
+ max_dimension: u16,
+ match_viewport: bool,
+ };
+
+ pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 {
+ if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null;
+ const marker = " PdfSections ";
+ const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null;
+ const path_at = marker_at + marker.len;
+ if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null;
+ return saved_tag[0 .. path_at + path.len];
+ }
+
+ pub const TintKey = if (enabled) struct {
+ mode: TintMode,
+ colors: pdf.TintColors,
+
+ pub fn eql(a: @This(), b: @This()) bool {
+ return a.mode == b.mode and
+ (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
+ }
+ } else void;
+
+ pub const Highlight = if (enabled) pdf.Highlight else void;
+ pub const Highlights = if (enabled) struct {
+ items: []const Highlight,
+ /// Hover items occupy [0..active_start); search/selection follow them.
+ active_start: usize,
+ hover_page: ?usize,
+
+ pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight {
+ const hover = highlights.items[0..highlights.active_start];
+ if (page == active_page)
+ return if (highlights.hover_page == page)
+ highlights.items
+ else
+ highlights.items[highlights.active_start..];
+ return if (highlights.hover_page == page) hover else &.{};
+ }
+ } else void;
+
+ pub const HighlightInput = if (enabled) struct {
+ hover_quads: []const Quad = &.{},
+ hover_page: ?usize = null,
+ hover_color: [3]u8,
+ selection_color: [3]u8,
+ } else void;
+
+ pub fn buildHighlights(
+ state: *const State,
+ arena: std.mem.Allocator,
+ input: HighlightInput,
+ ) !Highlights {
+ if (comptime !enabled) return;
+ const search_len = if (state.search_results) |results| results.quads.len else 0;
+ const selection_len = if (state.selection) |selection| selection.quads.len else 0;
+ const active_start = input.hover_quads.len;
+ const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len);
+ var n: usize = 0;
+ for (input.hover_quads) |quad| {
+ highlights[n] = pdf.Highlight.init(
+ quad,
+ .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c },
+ .custom,
+ );
+ n += 1;
+ }
+ if (state.search_results) |results| {
+ for (results.quads) |item| {
+ highlights[n] = pdf.Highlight.init(
+ item.quad,
+ .{ 0xff, 0xd5, 0x4f, 0x70 },
+ .search,
+ );
+ n += 1;
+ }
+ }
+ if (state.selection) |selection| {
+ for (selection.quads) |quad| {
+ highlights[n] = pdf.Highlight.init(
+ quad,
+ .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 },
+ .selection,
+ );
+ n += 1;
+ }
+ }
+ return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page };
+ }
+
+ pub const Raster = if (enabled) struct {
+ valid: bool = false,
+ page: usize = 0,
+ rgba: []u8 = &.{},
+ /// Full page shape; rgba contains only the band below.
+ iw: usize = 0,
+ ih: usize = 0,
+ band_y: usize = 0,
+ band_h: usize = 0,
+ request: pdf.RenderRequest = .{},
+ request_valid: bool = false,
+ tried: bool = false,
+ decorated: bool = false,
+ tint_key: ?TintKey = null,
+ revision: u32 = 0,
+ } else void;
+
+ pub const SectionsOutput = if (enabled) struct {
+ pane: usize,
+ serial: u32,
+ revision: u32,
+ } else void;
+
+ pub const SelectionUpdate = enum { failed, stationary, unchanged, changed };
+
+ pub const State = if (enabled) struct {
+ path: []u8,
+ document: Document,
+ page: usize = 0,
+ page_count: usize,
+ page_sizes: []pdf.PageSize,
+ page_starts: []u64,
+ page_heights: []u32,
+ document_height: u64 = 0,
+ layout_viewport_w: u32 = 0,
+ layout_viewport_h: u32 = 0,
+ layout_fit: FitMode = .width,
+ layout_valid: bool = false,
+ document_scroll_y: f64 = 0,
+ scroll_to_page_pending: bool = true,
+ rasters: [raster_max]Raster = undefined,
+ rasters_len: usize = 0,
+ spare: [raster_spare][]u8 = @splat(&.{}),
+ spare_len: usize = 0,
+ layout_anchor_pending: bool = false,
+ layout_anchor_page: usize = 0,
+ layout_anchor_fraction: f64 = 0,
+ next_raster_revision: u32 = 0,
+ scroll_travel: f64 = 0,
+ fit: FitMode = .width,
+ tint: TintMode = .filtered,
+ pan_x: u16 = 0,
+ pan_y: u16 = 0,
+ highlights_dirty: bool = false,
+ search_reveal_pending: bool = false,
+ search_results: ?pdf.SearchResults = null,
+ selection: ?pdf.Selection = null,
+ selection_text: []u8 = &.{},
+ selection_anchor: ?Point = null,
+ selection_head: ?Point = null,
+ drag_anchor: ?Point = null,
+ drag_head: ?Point = null,
+ text: []u8 = &.{},
+ text_tried: bool = false,
+ text_scroll: usize = 0,
+ text_scroll_clamp_pending: bool = false,
+ search_query: []u8 = &.{},
+ search_hit: usize = 0,
+ reveal_viewport_w: u32 = 0,
+ reveal_viewport_h: u32 = 0,
+ reveal_fit: FitMode = .width,
+ reveal_viewport_valid: bool = false,
+ outline: ?pdf.Outline = null,
+ outline_tried: bool = false,
+ sections_output: ?SectionsOutput = null,
+ outline_reveal_pending: ?pdf.OutlineInternalDestination = null,
+
+ pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() {
+ const local = filesystem.localPath(path) orelse return error.NonLocalPath;
+ return initDocument(gpa, path, try Document.open(local), page_one_based);
+ }
+
+ pub fn openBytes(gpa: std.mem.Allocator, path: []const u8, bytes: []const u8, page_one_based: usize) !@This() {
+ return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based);
+ }
+
+ fn initDocument(gpa: std.mem.Allocator, path: []const u8, opened: Document, page_one_based: usize) !@This() {
+ var document = opened;
+ errdefer document.deinit();
+ const page_sizes = try gpa.alloc(pdf.PageSize, document.pages);
+ errdefer gpa.free(page_sizes);
+ for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page);
+ const page_starts = try gpa.alloc(u64, document.pages);
+ errdefer gpa.free(page_starts);
+ const page_heights = try gpa.alloc(u32, document.pages);
+ errdefer gpa.free(page_heights);
+ const owned_path = try gpa.dupe(u8, path);
+ errdefer gpa.free(owned_path);
+ return .{
+ .path = owned_path,
+ .document = document,
+ .page = if (page_one_based > 0)
+ @min(page_one_based - 1, document.pages - 1)
+ else
+ 0,
+ .page_count = document.pages,
+ .page_sizes = page_sizes,
+ .page_starts = page_starts,
+ .page_heights = page_heights,
+ };
+ }
+
+ pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void {
+ const preserve_anchor = !state.scroll_to_page_pending and
+ (state.layout_anchor_pending or
+ (state.layout_valid and state.page_count > 0 and state.document_height > 0));
+ var anchor_page: usize = state.layout_anchor_page;
+ var anchor_fraction: f64 = state.layout_anchor_fraction;
+ if (preserve_anchor and !state.layout_anchor_pending) {
+ anchor_page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[anchor_page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page]));
+ anchor_fraction = std.math.clamp(
+ (state.document_scroll_y - start) / height,
+ 0,
+ 1,
+ );
+ }
+
+ var fresh = try @This().open(gpa, state.path, state.page + 1);
+ errdefer fresh.deinit(gpa);
+ if (state.search_query.len > 0)
+ fresh.search_query = try gpa.dupe(u8, state.search_query);
+
+ fresh.fit = state.fit;
+ fresh.tint = state.tint;
+ fresh.pan_x = state.pan_x;
+ fresh.pan_y = state.pan_y;
+ fresh.text_scroll = state.text_scroll;
+ fresh.text_scroll_clamp_pending = true;
+ fresh.search_hit = state.search_hit;
+ fresh.highlights_dirty = fresh.search_query.len > 0;
+ fresh.search_reveal_pending = state.search_reveal_pending;
+ fresh.reveal_viewport_w = state.reveal_viewport_w;
+ fresh.reveal_viewport_h = state.reveal_viewport_h;
+ fresh.reveal_fit = state.reveal_fit;
+ fresh.reveal_viewport_valid = state.reveal_viewport_valid;
+ // Revisions are part of the backend texture key. Resetting this counter
+ // while the pane serial stays live can alias a cached pre-reload page.
+ fresh.next_raster_revision = state.next_raster_revision;
+ fresh.sections_output = state.sections_output;
+ if (preserve_anchor) {
+ fresh.scroll_to_page_pending = false;
+ fresh.layout_anchor_pending = true;
+ fresh.layout_anchor_page = anchor_page;
+ fresh.layout_anchor_fraction = anchor_fraction;
+ }
+
+ var old = state.*;
+ state.* = fresh;
+ old.deinit(gpa);
+ }
+
+ pub fn invalidateRaster(state: *@This(), page: usize) void {
+ if (rasterForPage(state, page)) |raster| raster.tried = false;
+ }
+
+ pub fn invalidateAllRasters(state: *@This()) void {
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid) raster.tried = false;
+ }
+ }
+
+ fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void {
+ if (rgba.len == 0) return;
+ if (state.spare_len == state.spare.len) return gpa.free(rgba);
+ state.spare[state.spare_len] = rgba;
+ state.spare_len += 1;
+ }
+
+ fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void {
+ state.retireRgba(gpa, raster.rgba);
+ raster.* = .{};
+ }
+
+ fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 {
+ for (state.spare[0..state.spare_len], 0..) |candidate, index| {
+ if (candidate.len != bytes) continue;
+ state.spare_len -= 1;
+ state.spare[index] = state.spare[state.spare_len];
+ return candidate;
+ }
+ return gpa.alloc(u8, bytes) catch null;
+ }
+
+ fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void {
+ while (state.spare_len > 1) {
+ state.spare_len -= 1;
+ gpa.free(state.spare[state.spare_len]);
+ }
+ }
+
+ fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.search_results) |*results| results.deinit(gpa);
+ state.search_results = null;
+ }
+
+ fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.selection) |*selection| selection.deinit(gpa);
+ state.selection = null;
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ state.selection_text = &.{};
+ state.selection_anchor = null;
+ state.selection_head = null;
+ }
+
+ pub fn setSelection(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ start: Point,
+ end: Point,
+ invalidate_raster: bool,
+ ) SelectionUpdate {
+ if (state.selection != null and
+ state.selection_anchor != null and state.selection_head != null and
+ state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and
+ state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged;
+ var selection = state.document.select(gpa, state.page, start, end) catch return .failed;
+ const text = state.document.copySelection(
+ gpa,
+ state.page,
+ selection.start,
+ selection.end,
+ ) catch {
+ selection.deinit(gpa);
+ return .failed;
+ };
+
+ state.dropSelection(gpa);
+ state.selection = selection;
+ state.selection_text = text;
+ state.selection_anchor = start;
+ state.selection_head = end;
+ if (invalidate_raster) state.invalidateRaster(state.page);
+ return .changed;
+ }
+
+ pub fn clearDrag(state: *@This()) void {
+ state.drag_anchor = null;
+ state.drag_head = null;
+ }
+
+ pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ const changed = state.selection != null or state.selection_text.len > 0;
+ state.dropSelection(gpa);
+ if (changed) state.invalidateRaster(state.page);
+ }
+
+ pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void {
+ state.clearDrag();
+ state.clearSelection(gpa);
+ if (state.search_query.len == 0) return;
+ state.dropSearchQuery(gpa);
+ state.invalidateRaster(state.page);
+ }
+
+ fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void {
+ state.dropSearchResults(gpa);
+ state.dropSelection(gpa);
+ state.clearDrag();
+ if (state.text.len > 0) gpa.free(state.text);
+ state.text = &.{};
+ state.text_tried = false;
+ state.text_scroll = 0;
+ state.text_scroll_clamp_pending = false;
+ state.highlights_dirty = state.search_query.len > 0;
+ state.search_reveal_pending = state.search_query.len > 0;
+ state.search_hit = 0;
+ }
+
+ fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ state.search_query = &.{};
+ state.search_hit = 0;
+ state.dropSearchResults(gpa);
+ state.highlights_dirty = false;
+ state.search_reveal_pending = false;
+ }
+
+ pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void {
+ if (std.mem.eql(u8, state.search_query, query)) return;
+ const owned = try gpa.dupe(u8, query);
+ state.dropSearchQuery(gpa);
+ state.search_query = owned;
+ state.highlights_dirty = query.len > 0;
+ state.search_reveal_pending = query.len > 0;
+ state.invalidateRaster(state.page);
+ }
+
+ pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 {
+ if (!state.text_tried) {
+ state.text_tried = true;
+ state.text = state.document.pageText(gpa, state.page) catch &.{};
+ }
+ if (state.text_scroll_clamp_pending) {
+ const lines = std.mem.count(u8, state.text, "\n") + 1;
+ state.text_scroll = @min(state.text_scroll, lines - 1);
+ state.text_scroll_clamp_pending = false;
+ }
+ return state.text;
+ }
+
+ pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void {
+ if (!state.highlights_dirty) return;
+ state.highlights_dirty = false;
+ state.dropSearchResults(gpa);
+ if (state.search_query.len == 0) return;
+ const results = state.document.search(gpa, state.page, state.search_query) catch return;
+ state.search_hit = if (results.hit_count == 0)
+ 0
+ else
+ @min(state.search_hit, results.hit_count - 1);
+ state.search_results = results;
+ }
+
+ pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline {
+ if (!state.outline_tried) {
+ state.outline_tried = true;
+ state.outline = state.document.outline(gpa) catch null;
+ }
+ return if (state.outline) |*outline| outline else null;
+ }
+
+ pub fn renderSections(
+ state: *@This(),
+ pdf_gpa: std.mem.Allocator,
+ output_gpa: std.mem.Allocator,
+ ) ![]u8 {
+ const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline|
+ outline.entries
+ else
+ &.{};
+ return SectionRows.render(output_gpa, state.path, entries);
+ }
+
+ pub fn sectionDestination(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ ordinal: usize,
+ ) ?pdf.OutlineDestination {
+ const outline = state.ensureOutline(gpa) orelse return null;
+ return SectionRows.resolve(outline.entries, ordinal);
+ }
+
+ /// Apply the one-based page/hit location encoded in a PDF search row.
+ /// Returns whether host pane cursor chrome must be reset.
+ pub fn focusLocation(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ line: usize,
+ column: usize,
+ ) bool {
+ const changed = line > 0 and state.activatePage(gpa, line - 1, true);
+ if (column > 0 and state.search_query.len > 0) {
+ state.search_hit = column - 1;
+ state.search_reveal_pending = true;
+ }
+ return changed;
+ }
+
+ /// Change the document page while leaving pane cursor/selection chrome to
+ /// the UI adapter. Returns whether that pane-local chrome must be reset.
+ pub fn activatePage(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ page: usize,
+ reveal: bool,
+ ) bool {
+ state.outline_reveal_pending = null;
+ const next = @min(page, state.page_count -| 1);
+ const changed = next != state.page;
+ if (changed) {
+ state.invalidatePage(gpa);
+ state.page = next;
+ }
+ if (reveal) {
+ state.scroll_to_page_pending = true;
+ if (state.layout_valid) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[next]);
+ state.scroll_to_page_pending = false;
+ }
+ } else {
+ state.search_reveal_pending = false;
+ }
+ return changed;
+ }
+
+ pub fn toggleFit(state: *@This()) void {
+ state.fit = if (state.fit == .width) .height else .width;
+ state.pan_x = 0;
+ state.pan_y = 0;
+ state.layout_valid = false;
+ state.scroll_to_page_pending = true;
+ state.search_reveal_pending = state.search_query.len > 0;
+ }
+
+ pub fn toggleTint(state: *@This()) void {
+ state.tint = state.tint.next();
+ state.invalidateAllRasters();
+ }
+
+ pub fn queueOutlineReveal(
+ state: *@This(),
+ destination: pdf.OutlineInternalDestination,
+ ) void {
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ state.outline_reveal_pending = destination;
+ }
+
+ pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void {
+ gpa.free(state.path);
+ for (state.rasters[0..state.rasters_len]) |raster|
+ if (raster.rgba.len > 0) gpa.free(raster.rgba);
+ for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba);
+ gpa.free(state.page_sizes);
+ gpa.free(state.page_starts);
+ gpa.free(state.page_heights);
+ if (state.text.len > 0) gpa.free(state.text);
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ if (state.search_results) |*results| results.deinit(gpa);
+ if (state.selection) |*selection| selection.deinit(gpa);
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ if (state.outline) |*outline| outline.deinit(gpa);
+ state.document.deinit();
+ state.* = undefined;
+ }
+ } else void;
+
+ pub const SearchOutput = struct {
+ bytes: usize = 0,
+ rows: usize = 0,
+ anchor: ?usize = null,
+ };
+
+ pub fn textLines(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ ) ![]const []const u8 {
+ if (comptime !enabled) return &.{};
+ const page_text = state.ensureText(gpa);
+ const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1);
+ var it = std.mem.splitScalar(u8, page_text, '\n');
+ var n: usize = 0;
+ while (it.next()) |line| : (n += 1) lines[n] = line;
+ return lines;
+ }
+
+ pub fn visibleText(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ max_rows: usize,
+ ) ![]const u8 {
+ if (comptime !enabled) return "";
+ const page_text = state.ensureText(gpa);
+ var start: usize = 0;
+ for (0..state.text_scroll) |_| {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse
+ return arena.dupe(u8, "");
+ start = newline + 1;
+ }
+ if (max_rows == 0) return arena.dupe(u8, "");
+
+ var end = start;
+ var row: usize = 0;
+ while (row < max_rows) : (row += 1) {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse {
+ end = page_text.len;
+ break;
+ };
+ if (row + 1 == max_rows) {
+ end = newline;
+ break;
+ }
+ end = newline + 1;
+ }
+ return arena.dupe(u8, page_text[start..end]);
+ }
+
+ /// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The
+ /// same hit numbering drives persistent highlights and later reveal actions.
+ pub fn searchRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ pattern: []const u8,
+ from_cursor: bool,
+ out: []u8,
+ ) !SearchOutput {
+ if (comptime !enabled) return .{};
+ try state.setSearchQuery(gpa, pattern);
+ const shown = std.fs.path.basename(state.path);
+ var result: SearchOutput = .{};
+ const max_hits = 512;
+ var snippet_len = @min(pattern.len, 200);
+ while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80)
+ snippet_len -= 1;
+ const snippet = pattern[0..snippet_len];
+ for (0..state.page_count) |page| {
+ if (result.rows >= max_hits) break;
+ var found = try state.document.search(gpa, page, pattern);
+ defer found.deinit(gpa);
+
+ const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: {
+ const selection = state.selection orelse break :cursor null;
+ for (found.quads) |item| {
+ const q = item.quad;
+ const center: Point = .{
+ .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4,
+ .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4,
+ };
+ if (selection.contains(center)) break :cursor item.hit;
+ }
+ break :cursor null;
+ } else null;
+
+ for (0..found.hit_count) |hit_index| {
+ if (result.rows >= max_hits) break;
+ const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ shown, page + 1, hit_index + 1, snippet,
+ });
+ if (line.len > out.len - result.bytes) return result;
+ if (from_cursor and
+ (page < state.page or
+ (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?)))
+ result.anchor = result.rows;
+ @memcpy(out[result.bytes..][0..line.len], line);
+ result.bytes += line.len;
+ result.rows += 1;
+ }
+ }
+ return result;
+ }
+
+ pub const Viewport = struct { pixel_w: u32, pixel_h: u32 };
+ pub const VisiblePages = struct { first: usize = 0, len: usize = 0 };
+ pub const PanAxis = enum { horizontal, vertical };
+ pub const PanResult = enum { moved, edge, unavailable };
+ pub const ScrollResult = struct { active_page: usize };
+ pub const CellPixels = struct { w: u16, h: u16 };
+ pub const NormalHost = enum {
+ none,
+ leader,
+ command_line,
+ search,
+ search_forward,
+ search_backward,
+ };
+ pub const NormalResult = struct {
+ host: NormalHost = .none,
+ page_changed: bool = false,
+ };
+
+ const PlacedGeometry = struct {
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ };
+
+ pub const PlacedRaster = if (enabled) struct {
+ page: usize,
+ revision: u32,
+ fit: FitMode,
+ pan_x: u16,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ rgba: []const u8,
+ width: usize,
+ band_height: usize,
+ } else void;
+
+ const VisibleRows = struct {
+ base: image.NativeGeometry,
+ y0: u32,
+ y1: u32,
+ dst_y: u32,
+ dst_h: u32,
+ pixel_offset_y: f32,
+ };
+
+ pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest {
+ if (comptime !enabled) return;
+ return .{
+ .dpi = policy.dpi,
+ .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0,
+ .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0,
+ .max_dimension = policy.max_dimension,
+ };
+ }
+
+ fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 {
+ if (comptime !enabled) return 0;
+ if (fit == .height) return viewport.pixel_h;
+ const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
+ @as(f64, size.height) / @as(f64, size.width);
+ return @max(1, @as(u32, @intFromFloat(@min(
+ @as(f64, @floatFromInt(std.math.maxInt(u32))),
+ @round(scaled),
+ ))));
+ }
+
+ fn pageAtOffset(state: *const State, offset: f64) usize {
+ if (comptime !enabled) return 0;
+ const y: u64 = @intFromFloat(std.math.clamp(
+ @floor(offset),
+ 0,
+ @as(f64, @floatFromInt(state.document_height -| 1)),
+ ));
+ var lo: usize = 0;
+ var hi: usize = state.page_count;
+ while (lo + 1 < hi) {
+ const mid = lo + (hi - lo) / 2;
+ if (state.page_starts[mid] <= y) lo = mid else hi = mid;
+ }
+ const end = state.page_starts[lo] + state.page_heights[lo];
+ return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo;
+ }
+
+ fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ const bottom = top + @as(f64, @floatFromInt(state.page_heights[page]));
+ return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
+ }
+
+ pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages {
+ if (comptime !enabled) return .{};
+ var out: VisiblePages = .{};
+ var page = pageAtOffset(state, state.document_scroll_y);
+ if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1;
+ out.first = page;
+ while (page < state.page_count) : (page += 1) {
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
+ if (pageVisible(state, page, viewport)) out.len += 1;
+ }
+ return out;
+ }
+
+ fn visibleContains(visible: VisiblePages, page: usize) bool {
+ return page >= visible.first and page - visible.first < visible.len;
+ }
+
+ pub fn rasterForPage(state: *State, page: usize) ?*Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+ }
+
+ fn rasterForPageConst(state: *const State, page: usize) ?*const Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+ }
+
+ fn visibleRows(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ iw: usize,
+ ih: usize,
+ ) ?VisibleRows {
+ if (comptime !enabled) return null;
+ const page_h = state.page_heights[page];
+ const base = image.nativeGeometry(
+ iw,
+ ih,
+ viewport.pixel_w,
+ page_h,
+ switch (state.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ state.pan_x,
+ 0,
+ ) orelse return null;
+ if (base.dst.h == 0 or base.src.h == 0) return null;
+
+ const scroll_floor = @floor(state.document_scroll_y);
+ const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor);
+ const scroll_i: i64 = @intFromFloat(@min(
+ scroll_floor,
+ @as(f64, @floatFromInt(std.math.maxInt(i64))),
+ ));
+ const start_i: i64 = @intCast(@min(
+ state.page_starts[page],
+ @as(u64, std.math.maxInt(i64)),
+ ));
+ const full_y = start_i - scroll_i + @as(i64, base.dst.y);
+ const full_bottom = full_y + @as(i64, base.dst.h);
+ const visible_y = @max(@as(i64, 0), full_y);
+ const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
+ if (visible_bottom <= visible_y) return null;
+
+ const rel_y0: u64 = @intCast(visible_y - full_y);
+ const rel_y1: u64 = @intCast(visible_bottom - full_y);
+ const src_y0: u32 = base.src.y + @as(u32, @intCast(
+ rel_y0 * base.src.h / base.dst.h,
+ ));
+ const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
+ @as(u64, base.src.h),
+ (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
+ )));
+ if (src_y1 <= src_y0) return null;
+ return .{
+ .base = base,
+ .y0 = src_y0,
+ .y1 = src_y1,
+ .dst_y = @intCast(visible_y),
+ .dst_h = @intCast(visible_bottom - visible_y),
+ .pixel_offset_y = -fractional,
+ };
+ }
+
+ fn placedGeometry(
+ state: *const State,
+ raster: *const Raster,
+ viewport: Viewport,
+ page: usize,
+ ) ?PlacedGeometry {
+ if (comptime !enabled) return null;
+ const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null;
+ const band_y: u32 = @intCast(raster.band_y);
+ const band_end: u32 = @intCast(raster.band_y + raster.band_h);
+ if (rows.y0 < band_y or rows.y1 > band_end) return null;
+ return .{
+ .geometry = .{
+ .src = .{
+ .x = rows.base.src.x,
+ .y = rows.y0 - band_y,
+ .w = rows.base.src.w,
+ .h = rows.y1 - rows.y0,
+ },
+ .dst = .{
+ .x = rows.base.dst.x,
+ .y = rows.dst_y,
+ .w = rows.base.dst.w,
+ .h = rows.dst_h,
+ },
+ },
+ .pixel_offset_y = rows.pixel_offset_y,
+ };
+ }
+
+ pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return .{
+ .page = page,
+ .revision = raster.revision,
+ .fit = state.fit,
+ .pan_x = state.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ .rgba = raster.rgba,
+ .width = raster.iw,
+ .band_height = raster.band_h,
+ };
+ }
+
+ pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, state.page) orelse return null;
+ const placed = placedGeometry(state, raster, viewport, state.page) orelse return null;
+ return placed.geometry;
+ }
+
+ pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize {
+ if (comptime !enabled) return null;
+ if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null;
+ const document_y = state.document_scroll_y + local_y;
+ const page = pageAtOffset(state, document_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ if (document_y < start or
+ document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null;
+ return page;
+ }
+
+ pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool {
+ if (comptime !enabled) return false;
+ return placedRaster(state, viewport, page) != null;
+ }
+
+ pub fn nativeReady(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid and raster.rgba.len > 0 and
+ placedGeometry(state, raster, viewport, raster.page) != null) return true;
+ }
+ return false;
+ }
+
+ pub fn pointAtPage(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ px: i64,
+ py: i64,
+ clamp_to_page: bool,
+ ) ?Point {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return pointAtGeometry(
+ placed.geometry,
+ placed.pixel_offset_y,
+ raster.iw,
+ raster.ih,
+ raster.band_y,
+ px,
+ py,
+ clamp_to_page,
+ );
+ }
+
+ fn slotShape(slot: *const Raster) pdf.Raster {
+ const stride = slot.iw * 4;
+ return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih };
+ }
+
+ fn flinging(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
+ }
+
+ fn wantedBand(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ shape: pdf.Raster,
+ is_flinging: bool,
+ ) pdf.Raster.Band {
+ if (!is_flinging) return shape.wholePage();
+ const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse
+ return shape.wholePage();
+ const first = (@as(usize, rows.y0) / band_grain) * band_grain;
+ const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch
+ return shape.wholePage();
+ return shape.band(first, last * band_grain - first);
+ }
+
+ fn reconcile(
+ state: *State,
+ gpa: std.mem.Allocator,
+ request: RenderRequest,
+ tint_key: TintKey,
+ highlights: Highlights,
+ visible: VisiblePages,
+ viewport: Viewport,
+ ) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.reconcile");
+ defer tz.end();
+
+ // Remove first so arriving pages can claim departing page buffers. Only
+ // the final visible set can be seen, so a fling skips crossed-over pages.
+ var index: usize = 0;
+ while (index < state.rasters_len) {
+ if (visibleContains(visible, state.rasters[index].page)) {
+ index += 1;
+ continue;
+ }
+ state.retireRaster(gpa, &state.rasters[index]);
+ state.rasters_len -= 1;
+ if (index != state.rasters_len)
+ state.rasters[index] = state.rasters[state.rasters_len];
+ }
+
+ const is_flinging = flinging(state, viewport);
+ var page = visible.first;
+ const end = visible.first + visible.len;
+ while (page < end) : (page += 1) {
+ var raster = rasterForPage(state, page);
+ if (raster == null) {
+ if (state.rasters_len == state.rasters.len) continue;
+ state.rasters[state.rasters_len] = .{ .valid = true, .page = page };
+ state.rasters_len += 1;
+ raster = &state.rasters[state.rasters_len - 1];
+ }
+ const slot = raster.?;
+ const page_highlights = highlights.forPage(page, state.page);
+ const decorated = page_highlights.len > 0;
+ const stale = !slot.tried or !slot.request_valid or
+ !slot.request.eql(request) or slot.decorated != decorated or
+ slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
+ slot.rgba.len == 0 or slot.band_h == 0;
+ const uncovered = !stale and uncovered: {
+ const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging);
+ break :uncovered slot.band_y > want.y or
+ slot.band_y + slot.band_h < want.y + want.height;
+ };
+ if (!stale and !uncovered) continue;
+
+ slot.tried = true;
+ slot.request = request;
+ slot.request_valid = true;
+ const shape_or_null = shape: {
+ const tz_measure = tracy.zone(@src(), "pdf.measure");
+ defer tz_measure.end();
+ break :shape state.document.measureRenderAt(page, request) catch null;
+ };
+ const shape = shape_or_null orelse continue;
+ const want = wantedBand(state, viewport, page, shape, is_flinging);
+ const fresh = state.claimRgba(gpa, want.len) orelse continue;
+ const filled = filled: {
+ {
+ const tz_render = tracy.zone(@src(), "pdf.render_into");
+ defer tz_render.end();
+ state.document.renderIntoAt(
+ page,
+ request,
+ shape,
+ want,
+ page_highlights,
+ fresh,
+ ) catch break :filled false;
+ }
+ const tz_tint = tracy.zone(@src(), "pdf.tint");
+ defer tz_tint.end();
+ pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
+ break :filled false;
+ break :filled true;
+ };
+ if (!filled) {
+ state.retireRgba(gpa, fresh);
+ continue;
+ }
+
+ state.retireRgba(gpa, slot.rgba);
+ slot.rgba = fresh;
+ slot.iw = shape.width;
+ slot.ih = shape.height;
+ slot.band_y = want.y;
+ slot.band_h = want.height;
+ slot.decorated = decorated;
+ slot.tint_key = tint_key;
+ state.next_raster_revision +%= 1;
+ if (state.next_raster_revision == 0) state.next_raster_revision = 1;
+ slot.revision = state.next_raster_revision;
+ }
+ state.trimSpares(gpa);
+ }
+
+ pub fn renderFrame(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ viewport: Viewport,
+ policy: RasterPolicy,
+ tint_key: TintKey,
+ highlight_input: HighlightInput,
+ ) VisiblePages {
+ if (comptime !enabled) return .{};
+ ensureLayout(state, viewport);
+ state.resolveSearch(gpa);
+ const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{
+ .items = &.{},
+ .active_start = 0,
+ .hover_page = null,
+ };
+ const request = renderRequest(viewport, policy);
+ var visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+
+ rearmSearchReveal(state, viewport);
+ revealSearch(state, viewport, activeGeometry(state, viewport));
+ visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+ return visible;
+ }
+
+ pub fn normalizedPixel(value: f32, dimension: usize) u32 {
+ const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension));
+ return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled))));
+ }
+
+ pub fn scaledStep(base: u32, count: u32) u32 {
+ return @intCast(@min(
+ @as(u64, std.math.maxInt(u32)),
+ @as(u64, base) * @max(@as(u64, 1), count),
+ ));
+ }
+
+ pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult {
+ if (comptime !enabled) return null;
+ if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll);
+ if (next == state.document_scroll_y) return null;
+ state.scroll_travel += @abs(next - state.document_scroll_y);
+ state.document_scroll_y = next;
+ return .{ .active_page = pageAtOffset(state, next) };
+ }
+
+ pub fn panPixels(
+ state: *State,
+ geometry: image.NativeGeometry,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+ ) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const raster = rasterForPage(state, state.page) orelse return .unavailable;
+ const source_full: u32 = @intCast(switch (axis) {
+ .horizontal => raster.iw,
+ .vertical => raster.ih,
+ });
+ const crop = switch (axis) {
+ .horizontal => geometry.src.w,
+ .vertical => geometry.src.h,
+ };
+ const source_at = switch (axis) {
+ .horizontal => geometry.src.x,
+ .vertical => geometry.src.y,
+ };
+ const displayed = @max(@as(u32, 1), switch (axis) {
+ .horizontal => geometry.dst.w,
+ .vertical => geometry.dst.h,
+ });
+ const overflow = source_full -| crop;
+ if (overflow == 0 or
+ (direction < 0 and source_at == 0) or
+ (direction > 0 and source_at >= overflow)) return .edge;
+
+ const source_step = @max(
+ @as(u64, 1),
+ (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed,
+ );
+ const normalized_step: u32 = @intCast(@min(
+ @as(u64, std.math.maxInt(u16)),
+ @max(
+ @as(u64, 1),
+ (source_step * std.math.maxInt(u16) + overflow - 1) / overflow,
+ ),
+ ));
+ const position = switch (axis) {
+ .horizontal => &state.pan_x,
+ .vertical => &state.pan_y,
+ };
+ if (direction > 0) {
+ position.* = @intCast(@min(
+ @as(u32, std.math.maxInt(u16)),
+ @as(u32, position.*) + normalized_step,
+ ));
+ } else {
+ position.* -|= @intCast(normalized_step);
+ }
+ return .moved;
+ }
+
+ fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void {
+ if (comptime !enabled) return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ if (raster.iw <= geometry.src.w) return;
+ state.pan_x = if (end) std.math.maxInt(u16) else 0;
+ }
+
+ fn rearmSearchReveal(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ if (state.search_query.len == 0) return;
+ if (!state.reveal_viewport_valid or
+ state.reveal_viewport_w != viewport.pixel_w or
+ state.reveal_viewport_h != viewport.pixel_h or
+ state.reveal_fit != state.fit)
+ state.search_reveal_pending = true;
+ }
+
+ pub fn revealSearch(
+ state: *State,
+ viewport: Viewport,
+ geometry: ?image.NativeGeometry,
+ ) void {
+ if (comptime !enabled) return;
+ if (!state.search_reveal_pending) return;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+ const results = state.search_results orelse {
+ state.search_reveal_pending = false;
+ return;
+ };
+ if (results.hit_count == 0 or results.quads.len == 0) {
+ state.search_reveal_pending = false;
+ return;
+ }
+ state.search_hit = @min(state.search_hit, results.hit_count - 1);
+ const q = for (results.quads) |item| {
+ if (item.hit == state.search_hit) break item.quad;
+ } else {
+ state.search_reveal_pending = false;
+ return;
+ };
+ state.search_reveal_pending = false;
+ const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4;
+ const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4;
+ if (state.fit == .width) {
+ ensureLayout(state, viewport);
+ const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) +
+ @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page]));
+ const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
+ return;
+ }
+ const placed = geometry orelse return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ const full: u32 = @intCast(raster.iw);
+ const at = normalizedPixel(center_x, raster.iw);
+ if (at >= placed.src.x and at < placed.src.x + placed.src.w) return;
+ const overflow = full -| placed.src.w;
+ if (overflow == 0) return;
+ const wanted = @min(overflow, at -| placed.src.w / 2);
+ state.pan_x = @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+ }
+
+ pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool {
+ const current: i64 = @intCast(state.page);
+ const last: i64 = @intCast(state.page_count -| 1);
+ return state.activatePage(
+ gpa,
+ @intCast(std.math.clamp(current + delta, 0, last)),
+ true,
+ );
+ }
+
+ fn scrollNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ viewport: ?Viewport,
+ delta_pixels: f64,
+ ) bool {
+ const view = viewport orelse return false;
+ ensureLayout(state, view);
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ return result.active_page != state.page and
+ state.activatePage(gpa, result.active_page, false);
+ }
+
+ fn moveRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ count: u32,
+ ) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction,
+ );
+ }
+
+ fn movePage(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ kind: modal.Normal.Page,
+ count: u32,
+ ) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ const base: u32 = switch (kind) {
+ .half_down, .half_up => if (viewport) |view|
+ @max(@as(u32, 1), view.pixel_h / 2)
+ else
+ cell_pixels.h,
+ .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h,
+ };
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(base, count))) * direction,
+ );
+ }
+
+ pub fn applyNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ semantic: modal.Normal.Action,
+ native_images: bool,
+ cell_pixels: CellPixels,
+ viewport: ?Viewport,
+ geometry: ?image.NativeGeometry,
+ ) NormalResult {
+ if (comptime !enabled) return .{};
+ var result: NormalResult = .{};
+ switch (semantic) {
+ .escape => state.cancelChrome(gpa),
+ .move => |move| switch (move.motion) {
+ .down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ move.count,
+ ),
+ .up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ move.count,
+ ),
+ .left => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count));
+ },
+ .right => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count));
+ },
+ else => {},
+ },
+ .goto => |go| switch (go.target) {
+ .file_start => result.page_changed = state.activatePage(
+ gpa,
+ if (go.explicit_count) go.count -| 1 else 0,
+ true,
+ ),
+ .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true),
+ .line_start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .line_end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ .line_down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ go.count,
+ ),
+ .line_up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ go.count,
+ ),
+ else => {},
+ },
+ .line => |line| switch (line) {
+ .start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ },
+ .goto_line => |go| {
+ if (go.explicit)
+ result.page_changed = state.activatePage(gpa, go.count -| 1, true);
+ },
+ .page => |page| result.page_changed = switch (page.kind) {
+ .half_down, .down => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ page.kind,
+ page.count,
+ ),
+ .half_up, .up => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ page.kind,
+ page.count,
+ ),
+ },
+ .view => |view| result.page_changed = switch (view) {
+ .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1),
+ .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1),
+ else => false,
+ },
+ .leader => result.host = .leader,
+ .command_line => result.host = .command_line,
+ .search => result.host = .search,
+ .search_step => |direction| result.host = if (direction == .forward)
+ .search_forward
+ else
+ .search_backward,
+ else => {},
+ }
+ return result;
+ }
+
+ pub fn captureLayoutAnchor(state: *State) void {
+ if (comptime !enabled) return;
+ if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return;
+ const page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page]));
+ state.layout_anchor_page = page;
+ state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1);
+ state.layout_anchor_pending = true;
+ }
+
+ fn consumeOutlineReveal(state: *State, viewport: Viewport) void {
+ const destination = state.outline_reveal_pending orelse return;
+ state.outline_reveal_pending = null;
+ const page = @min(destination.page, state.page_count -| 1);
+ const size = state.page_sizes[page];
+ const raw_y = destination.y orelse 0;
+ const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page]));
+
+ if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) {
+ const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) *
+ @as(f64, size.width) / @as(f64, size.height);
+ const viewport_width: f64 = @floatFromInt(viewport.pixel_w);
+ if (display_width > viewport_width) {
+ const x = std.math.clamp(raw_x, 0, size.width);
+ const target = @as(f64, x) / @as(f64, size.width) * display_width;
+ const overflow = display_width - viewport_width;
+ const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow);
+ state.pan_x = @intFromFloat(@round(
+ wanted / overflow * @as(f64, std.math.maxInt(u16)),
+ ));
+ }
+ };
+ state.search_reveal_pending = false;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+ }
+
+ pub fn ensureLayout(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.ensure_layout");
+ defer tz.end();
+ if (state.layout_valid and !state.scroll_to_page_pending and
+ !state.layout_anchor_pending and
+ (state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state);
+ if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit)
+ {
+ var at: u64 = 0;
+ for (state.page_sizes, 0..) |size, page| {
+ state.page_starts[page] = at;
+ const height = pageHeight(size, viewport, state.fit);
+ state.page_heights[page] = height;
+ at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
+ if (page + 1 < state.page_count)
+ at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64);
+ }
+ state.document_height = at;
+ state.layout_viewport_w = viewport.pixel_w;
+ state.layout_viewport_h = viewport.pixel_h;
+ state.layout_fit = state.fit;
+ state.layout_valid = true;
+ if (state.scroll_to_page_pending) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[state.page]);
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ } else if (state.layout_anchor_pending) {
+ const page = @min(state.layout_anchor_page, state.page_count -| 1);
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
+ state.layout_anchor_pending = false;
+ }
+ }
+ consumeOutlineReveal(state, viewport);
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll);
+ }
+
+ pub const WordProbe = if (enabled) struct {
+ page: usize,
+ quads: []pdf.Quad,
+ text: []u8,
+
+ pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void {
+ gpa.free(probe.quads);
+ gpa.free(probe.text);
+ probe.* = undefined;
+ }
+ } else void;
+
+ pub const PointerDrag = if (enabled) struct {
+ native: bool = false,
+ /// Right-button Look probes this cell on release without borrowing or
+ /// replacing the pane's persistent MuPDF selection.
+ word_at: ?struct { col: u16, row: u16 } = null,
+ /// Drag updates keep selection geometry/text live, but their expensive
+ /// baked raster highlight is committed once on release.
+ selection_changed: bool = false,
+ } else struct {};
+
+ pub const PointerAction = enum { look, exec };
+
+ pub const PointerRelease = if (enabled) struct {
+ action: ?PointerAction = null,
+ text: []const u8 = &.{},
+ probe: ?WordProbe = null,
+
+ pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void {
+ if (release.probe) |*probe| probe.deinit(gpa);
+ release.* = undefined;
+ }
+ } else struct {
+ pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
+ };
+
+ pub fn probeWord(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ point: if (enabled) pdf.Point else void,
+ ) !?WordProbe {
+ if (comptime !enabled) return null;
+ var selection = try document.select(gpa, page, point, point);
+ errdefer selection.deinit(gpa);
+ const text = try document.copySelection(gpa, page, selection.start, selection.end);
+ errdefer gpa.free(text);
+ if (selection.quads.len == 0 or text.len == 0) {
+ selection.deinit(gpa);
+ gpa.free(text);
+ return null;
+ }
+ // Transfer the quad allocation out of Selection. There is intentionally
+ // no Selection.deinit on this success path: WordProbe is now its owner.
+ return .{ .page = page, .quads = selection.quads, .text = text };
+ }
+
+ pub fn pointAtGeometry(
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ full_width: usize,
+ full_height: usize,
+ band_y: usize,
+ pixel_x: i64,
+ pixel_y: i64,
+ clamp_to_page: bool,
+ ) ?Point {
+ if (comptime !enabled) return null;
+ if (full_width == 0 or full_height == 0 or
+ geometry.src.w == 0 or geometry.src.h == 0 or
+ geometry.dst.w == 0 or geometry.dst.h == 0) return null;
+
+ const left: f64 = @floatFromInt(geometry.dst.x);
+ const top: f64 = @floatFromInt(geometry.dst.y);
+ const right = left + @as(f64, @floatFromInt(geometry.dst.w));
+ const bottom = top + @as(f64, @floatFromInt(geometry.dst.h));
+ var x: f64 = @floatFromInt(pixel_x);
+ var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y);
+ if (clamp_to_page) {
+ // Half-open rectangles: keep a clamped point infinitesimally inside
+ // the last presented pixel instead of letting it become `right`.
+ const epsilon = 1.0 / 1024.0;
+ x = std.math.clamp(x, left, @max(left, right - epsilon));
+ y = std.math.clamp(y, top, @max(top, bottom - epsilon));
+ } else if (x < left or x >= right or y < top or y >= bottom) {
+ return null;
+ }
+
+ const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) +
+ (x - left) * @as(f64, @floatFromInt(geometry.src.w)) /
+ @as(f64, @floatFromInt(geometry.dst.w));
+ const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) +
+ (y - top) * @as(f64, @floatFromInt(geometry.src.h)) /
+ @as(f64, @floatFromInt(geometry.dst.h));
+ const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f))));
+ const band_source_y: usize = @intFromFloat(@floor(source_y_f));
+ const source_y = @min(full_height - 1, band_y + band_source_y);
+ return .{
+ .x = (@as(f32, @floatFromInt(source_x)) + 0.5) /
+ @as(f32, @floatFromInt(full_width)),
+ .y = (@as(f32, @floatFromInt(source_y)) + 0.5) /
+ @as(f32, @floatFromInt(full_height)),
+ };
+ }
+
+ pub fn openPane(
+ core: *pardes.Pardes,
+ id: usize,
+ path: []const u8,
+ page_one_based: usize,
+ ) !*pardes.Pane {
+ if (comptime !enabled) return error.PdfDisabled;
+ var state = if (filesystem.localPath(path) != null)
+ try State.open(core.pdf_gpa, path, page_one_based)
+ else virtual: {
+ const bytes = try filesystem.read(core, path);
+ defer core.gpa.free(bytes);
+ break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based);
+ };
+ errdefer state.deinit(core.pdf_gpa);
+ const pane = try core.newDocPane(id);
+ pane.pdf = state;
+ pane.cur_pinned = true;
+ if (filesystem.localPath(path) != null) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return pane;
+ }
+
+ /// Release a PDF payload while its pane slot is still installed, so the host
+ /// can retire the corresponding directory watch before that id is reused.
+ pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void {
+ if (comptime !enabled) return;
+ for (core.panes, 0..) |slot, id| {
+ if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ }
+ state.deinit(core.pdf_gpa);
+ }
+
+ pub fn reloadPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ ) !void {
+ if (comptime !enabled) return error.PdfDisabled;
+ const state = &(pane.pdf orelse return error.MissingPdfState);
+ try state.reload(core.pdf_gpa);
+ resetPageChrome(pane);
+ }
+
+ pub fn isSectionsOutput(pane: *const pardes.Pane) bool {
+ if (comptime !enabled) return false;
+ const file = pane.file orelse return false;
+ const output = file.output orelse return false;
+ return switch (output.from) {
+ .cmd => |builtin| builtin == .PdfSections,
+ else => false,
+ };
+ }
+
+ fn refreshCleanSections(core: *pardes.Pardes, state: *State) void {
+ if (comptime !enabled) return;
+ const remembered = state.sections_output orelse return;
+ if (remembered.pane >= core.panes.len) return;
+ const result = core.panes[remembered.pane] orelse return;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return;
+
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch {
+ state.sections_output = null;
+ return;
+ };
+ if (std.mem.eql(u8, file.content, content)) {
+ core.gpa.free(content);
+ return;
+ }
+ core.invalidateLookHover(remembered.pane);
+ File.setContent(core, file, content);
+ const rows = File.lineCount(file.content);
+ file.scroll = @min(file.scroll, rows - 1);
+ const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1);
+ result.cur_row = @intCast(row);
+ result.cur_col = @intCast(@min(
+ @as(usize, @intCast(@max(0, result.cur_col))),
+ modal.lineSlice(file.content, row).len,
+ ));
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ state.sections_output.?.revision = file.revision;
+ }
+
+ /// One successful watched-path transaction, including every piece of derived
+ /// PDF output. The caller owns generic update bookkeeping and status text.
+ pub fn reloadWatched(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ ) !void {
+ try reloadPane(core, pane);
+ refreshCleanSections(core, &pane.pdf.?);
+ }
+
+ fn rearmCleanSections(
+ core: *pardes.Pardes,
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+ ) bool {
+ const remembered = state.sections_output orelse return false;
+ if (remembered.pane >= core.panes.len) return false;
+ const result = core.panes[remembered.pane] orelse return false;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return false;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return false;
+
+ file.scroll = 0;
+ result.cur_row = 0;
+ result.cur_col = 0;
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ pane.search_pane = remembered.pane;
+ pane.search_row = null;
+ core.armLookWalk(remembered.pane);
+ core.active = id;
+ return true;
+ }
+
+ /// Lazily materialise this pane's cached outline as a location list. All
+ /// PDF-specific ownership stays here; Pardes contributes only placement.
+ pub fn openSections(core: *pardes.Pardes, id: usize) void {
+ if (comptime !enabled) return;
+ const pane = core.panes[id] orelse return;
+ const state = &(pane.pdf orelse return);
+ if (rearmCleanSections(core, id, pane, state)) return;
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch return;
+
+ const free = core.freeSlot() orelse {
+ core.gpa.free(content);
+ return;
+ };
+ const dir = std.fs.path.dirname(state.path) orelse "/";
+ const result = Output.open(
+ core,
+ free,
+ dir,
+ .{ .cmd = .PdfSections },
+ "",
+ content,
+ ) catch {
+ core.gpa.free(content);
+ return;
+ };
+ state.sections_output = .{
+ .pane = free,
+ .serial = result.serial,
+ .revision = result.file.?.revision,
+ };
+ core.placeDoc(id, free, result);
+ layout.compute(core);
+ core.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+ core.armLookWalk(free);
+ }
+
+ const SectionsOwner = struct {
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+ };
+
+ fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner {
+ const output = core.panes[output_id] orelse return null;
+ if (!isSectionsOutput(output)) return null;
+ const file = output.file.?;
+ for (core.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const state = if (pane.pdf) |*pdf_state| pdf_state else continue;
+ const token = state.sections_output orelse continue;
+ if (token.pane == output_id and token.serial == output.serial and
+ token.revision == file.revision)
+ return .{ .id = id, .pane = pane, .state = state };
+ }
+ return null;
+ }
+
+ pub fn lookSection(
+ core: *pardes.Pardes,
+ output_id: usize,
+ path: []const u8,
+ at: look.Spot,
+ ) bool {
+ if (comptime !enabled) return false;
+ const output = core.panes[output_id] orelse return false;
+ if (!isSectionsOutput(output)) return false;
+ if (at.line == 0 or at.col == 0 or at.end_line != 0 or
+ !look.isPdfPath(path)) return false;
+ const owner = sectionsOwner(core, output_id) orelse return true;
+
+ var joined_path: [4096]u8 = undefined;
+ const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/";
+ const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
+ const target_path = if (std.fs.path.isAbsolute(path))
+ path
+ else
+ std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
+ if (!std.mem.eql(u8, owner.state.path, target_path)) return true;
+
+ const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true;
+ switch (destination) {
+ .internal => |internal| revealOutlineDestination(core, owner.pane, internal),
+ .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }),
+ .none => unreachable,
+ }
+ core.active = owner.id;
+ return true;
+ }
+
+ pub fn resetPageChrome(pane: *pardes.Pane) void {
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.nsel = 0;
+ }
+
+ pub fn activatePage(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ page: usize,
+ reveal: bool,
+ ) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane);
+ }
+
+ pub fn revealOutlineDestination(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ destination: OutlineInternalDestination,
+ ) void {
+ if (comptime !enabled) return;
+ activatePage(core, pane, destination.page, false);
+ const state = &pane.pdf.?;
+ state.queueOutlineReveal(destination);
+ // Consume immediately when geometry already exists; otherwise the PDF
+ // draw pass consumes the same value after the next layout transaction.
+ _ = ensurePaneLayout(core, pane);
+ }
+
+ pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void {
+ activatePage(core, pane, page, true);
+ }
+
+ pub fn toggleFit(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleFit();
+ }
+
+ pub fn toggleTint(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleTint();
+ }
+
+ pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane);
+ }
+
+ pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const cols = rect.w -| config.GUTTER;
+ const rows = rect.h -| pardes.BOX_H;
+ if (cols == 0 or rows == 0) return null;
+ return .{
+ .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
+ .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h),
+ };
+ }
+
+ pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const state = &(pane.pdf orelse return null);
+ const view = paneViewport(core, pane) orelse return null;
+ ensureLayout(state, view);
+ return view;
+ }
+
+ pub fn tintColors(core: *const pardes.Pardes) TintColors {
+ if (comptime !enabled) return;
+ const theme = core.theme();
+ return .{
+ .background = theme.bg orelse theme.tag_bg,
+ .foreground = theme.fg orelse theme.tag_fg,
+ };
+ }
+
+ pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ return activeGeometry(state, view);
+ }
+
+ pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
+ const local_y = @as(f64, @floatFromInt(
+ @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
+ ));
+ return pageAtViewportY(&state, local_y);
+ }
+
+ pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images) return false;
+ const state = if (pane.pdf) |*view| view else return false;
+ const view = paneViewport(core, pane) orelse return false;
+ return nativeReady(state, view);
+ }
+
+ pub fn nativePageAtGridRow(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ row: u16,
+ ) ?usize {
+ if (comptime !enabled) return null;
+ if (!core.native_images) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const page = pageAtGridRow(core, pane, row) orelse return null;
+ const view = paneViewport(core, pane) orelse return null;
+ if (!pageReady(state, view, page)) return null;
+ return page;
+ }
+
+ pub fn pointAt(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+ ) ?Point {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ return panePointAtPage(core, pane, state.page, col, row, clamp_to_page);
+ }
+
+ pub fn panePointAtPage(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ page: usize,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+ ) ?Point {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_x = @as(i64, rect.x + config.GUTTER);
+ const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H);
+ const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
+ const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
+ return pointAtPage(state, view, page, px, py, clamp_to_page);
+ }
+
+ pub fn probeAt(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ ) ?WordProbe {
+ if (comptime !enabled) return null;
+ const page = nativePageAtGridRow(core, pane, row) orelse return null;
+ const point = panePointAtPage(core, pane, page, col, row, false) orelse return null;
+ const state = &(pane.pdf orelse return null);
+ return probeWord(&state.document, core.pdf_gpa, page, point) catch null;
+ }
+
+ pub fn beginDrag(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ snap_word: bool,
+ ) bool {
+ if (comptime !enabled) return false;
+ const hit_page = pageAtGridRow(core, pane, row) orelse return false;
+ if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false);
+ const state = &pane.pdf.?;
+ state.clearDrag();
+ const point = pointAt(core, pane, col, row, false) orelse return false;
+ state.drag_anchor = point;
+ state.drag_head = point;
+ if (!snap_word) return true;
+ if (state.selection) |selection| if (selection.contains(point)) return true;
+ if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) {
+ // Preserve the old selection transactionally, but never let an
+ // outside click execute its stale text.
+ state.clearDrag();
+ return false;
+ }
+ return true;
+ }
+
+ pub fn beginSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ ) bool {
+ return beginDrag(core, pane, col, row, true);
+ }
+
+ pub fn updateSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ invalidate_raster: bool,
+ ) SelectionUpdate {
+ if (comptime !enabled) return .failed;
+ const state = &(pane.pdf orelse return .failed);
+ const anchor = state.drag_anchor orelse return .failed;
+ const point = pointAt(core, pane, col, row, true) orelse return .failed;
+ if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary;
+ const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster);
+ if (result != .failed) state.drag_head = point;
+ return result;
+ }
+
+ pub fn pointerStart(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ button: pardes.Mouse.Button,
+ col: u16,
+ row: u16,
+ on_tag: bool,
+ ) PointerDrag {
+ if (comptime !enabled) return .{};
+ if (on_tag or !paneNativeReady(core, pane)) return .{};
+ var drag: PointerDrag = .{ .native = true };
+ if (button == config.look_button) {
+ drag.word_at = .{ .col = col, .row = row };
+ } else if (button == config.select_button) {
+ _ = beginDrag(core, pane, col, row, false);
+ } else if (button == config.exec_button) {
+ _ = beginDrag(core, pane, col, row, true);
+ }
+ return drag;
+ }
+
+ pub fn pointerUpdate(
+ drag: *PointerDrag,
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ ) void {
+ if (comptime !enabled) return;
+ if (!drag.native) return;
+ if (drag.word_at) |at| {
+ if (col == at.col and row == at.row) return;
+ if (!beginDrag(core, pane, at.col, at.row, false)) {
+ drag.word_at = null;
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => {
+ drag.word_at = null;
+ pane.pdf.?.clearDrag();
+ },
+ // Adjacent grid cells can still address one raster pixel. Keep
+ // click mode and retry farther out.
+ .stationary => {},
+ .unchanged => drag.word_at = null,
+ .changed => {
+ drag.word_at = null;
+ drag.selection_changed = true;
+ },
+ }
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => pane.pdf.?.clearDrag(),
+ .stationary, .unchanged => {},
+ .changed => drag.selection_changed = true,
+ }
+ }
+
+ pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void {
+ if (comptime !enabled) return;
+ if (drag.native) if (pane.pdf) |*state| {
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ state.clearDrag();
+ };
+ }
+
+ pub fn pointerRelease(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ drag: PointerDrag,
+ button: pardes.Mouse.Button,
+ chorded: bool,
+ ) PointerRelease {
+ if (comptime !enabled) return .{};
+ const state = &(pane.pdf orelse return .{});
+ const slot = @intFromEnum(button);
+ const grid_selection = pane.sel[slot];
+ pane.sel[slot].state = .none; // native quads, not projected text cells
+
+ // Drag updates changed live geometry/text while leaving baked pixels
+ // stable. Commit exactly once before any chord/Exec/Look can clear state.
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ if (chorded) {
+ state.clearDrag();
+ return .{};
+ }
+ if (drag.word_at) |at| {
+ const maybe_probe = probeAt(core, pane, at.col, at.row);
+ state.clearDrag();
+ const probe = maybe_probe orelse return .{};
+ // A neighboring visible page becomes current before Look dispatch.
+ // WordProbe owns its text/quads independently of that state change.
+ if (probe.page != state.page) activatePage(core, pane, probe.page, false);
+ const text = probe.text;
+ return .{ .action = .look, .text = text, .probe = probe };
+ }
+ if (button == config.select_button) {
+ const dragged = grid_selection.c0 != grid_selection.c1 or
+ grid_selection.r0 != grid_selection.r1;
+ if (!dragged) state.clearSelection(core.pdf_gpa);
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.normal.clear();
+ pane.nsel = 0;
+ state.clearDrag();
+ return .{};
+ }
+ if (state.drag_anchor == null or state.selection_text.len == 0) {
+ state.clearDrag();
+ return .{};
+ }
+ const text = state.selection_text;
+ state.clearDrag();
+ return .{
+ .action = if (button == config.look_button) .look else .exec,
+ .text = text,
+ };
+ }
+
+ pub fn highlightInput(
+ core: *pardes.Pardes,
+ pane_id: usize,
+ pane: *const pardes.Pane,
+ ) HighlightInput {
+ if (comptime !enabled) return;
+ const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview|
+ if (preview.pane == pane_id and preview.serial == pane.serial)
+ preview.probe.quads
+ else
+ &.{}
+ else
+ &.{};
+ const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null;
+ const selection_color = core.theme().sel_bg;
+ return .{
+ .hover_quads = hover_quads,
+ .hover_page = hover_page,
+ .hover_color = selection_color,
+ .selection_color = selection_color,
+ };
+ }
+
+ pub fn panPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+ ) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const placed = paneGeometry(core, pane) orelse return .unavailable;
+ const state = &(pane.pdf orelse return .unavailable);
+ return panPixels(state, placed, axis, direction, display_pixels);
+ }
+
+ pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool {
+ if (comptime !enabled) return false;
+ const tz = tracy.zone(@src(), "pdf.scroll_notch");
+ defer tz.end();
+ const state = &(pane.pdf orelse return false);
+ const view = ensurePaneLayout(core, pane) orelse return false;
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ if (result.active_page != state.page) activatePage(core, pane, result.active_page, false);
+ return true;
+ }
+
+ pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ if (!core.native_images) return stepPanePage(core, pane, direction);
+ const rows: u32 = @intCast(@max(1, config.wheel_rows));
+ const pixels = scaledStep(core.cell_pixels.h, rows);
+ _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction);
+ }
+
+ pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ const state = pane.pdf orelse return;
+ if (!core.native_images or state.fit != .height) return;
+ const cols: u32 = @intCast(@max(1, config.wheel_cols));
+ _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols));
+ }
+
+ pub fn draw(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ rect: pardes.Rect,
+ pane_id: usize,
+ text_x: u16,
+ text_width: u16,
+ ) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images or rect.h <= pardes.BOX_H) return false;
+ const state = &(pane.pdf orelse return false);
+ const view = paneViewport(core, pane) orelse return false;
+ const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
+ const visible = renderFrame(
+ state,
+ core.pdf_gpa,
+ core.scratch.allocator(),
+ view,
+ pardes.pdf_raster_policy,
+ key,
+ highlightInput(core, pane_id, pane),
+ );
+ var placed_any = false;
+ var page = visible.first;
+ const visible_end = visible.first + visible.len;
+ while (page < visible_end) : (page += 1) {
+ const placed = placedRaster(state, view, page) orelse continue;
+ if (!core.appendImagePlace(.{
+ .pane = @intCast(pane_id),
+ .serial = pane.serial,
+ .native = .{
+ .revision = placed.revision,
+ .page = @intCast(placed.page),
+ .fit = switch (placed.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ .pan_x = placed.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ },
+ .x = text_x,
+ .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
+ .w = text_width,
+ .h = rect.h - pardes.BOX_H,
+ .rgba = placed.rgba,
+ .iw = placed.width,
+ // The texture contains the retained band, not the full page.
+ .ih = placed.band_height,
+ })) break;
+ placed_any = true;
+ }
+ if (!placed_any) return false;
+
+ // This frame has spent the motion used to choose its raster band.
+ state.scroll_travel = 0;
+ const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const chrome = core.chromeTheme();
+ const theme = core.theme();
+ const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
+ core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
+
+ const track_h: usize = rect.h - pardes.BOX_H;
+ const total = @max(@as(u64, 1), state.document_height);
+ const len = @max(
+ @as(usize, 1),
+ @as(usize, @intCast(@min(
+ @as(u64, track_h),
+ @as(u64, track_h) * view.pixel_h / total,
+ ))),
+ );
+ const offset: u64 = @intFromFloat(@floor(state.document_scroll_y));
+ const pos: usize = @intCast(@min(
+ @as(u64, track_h -| 1),
+ @as(u64, track_h) * offset / total,
+ ));
+ var y = pos;
+ while (y < track_h and y < pos + len) : (y += 1)
+ core.surface.fill(
+ rect.x,
+ body_y + @as(u16, @intCast(y)),
+ 1,
+ 1,
+ .{ .bg = .{ .rgb = chrome.scroll_thumb } },
+ );
+ return true;
+ }
+
+ pub const SectionRows = if (enabled) struct {
+ pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination {
+ return switch (destination) {
+ .internal => destination,
+ .external => |uri| if (safeHttpUri(uri)) destination else null,
+ .none => null,
+ };
+ }
+
+ fn safeHttpUri(uri: []const u8) bool {
+ // Effect.open_link is inline and cannot carry a larger URL. Omitting
+ // it here is preferable to rendering a row which can never act.
+ if (uri.len > 256) return false;
+ var has_scheme = false;
+ for (config.url_schemes) |scheme| {
+ if (std.mem.startsWith(u8, uri, scheme)) {
+ has_scheme = true;
+ break;
+ }
+ }
+ if (!has_scheme) return false;
+ for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false;
+ return true;
+ }
+
+ pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination {
+ if (ordinal >= entries.len) return null;
+ const entry = entries[ordinal];
+ if (entry.destination != .none) return usableDestination(entry.destination);
+ var i = ordinal + 1;
+ while (i < entries.len and entries[i].depth > entry.depth) : (i += 1)
+ if (usableDestination(entries[i].destination)) |destination| return destination;
+ return null;
+ }
+
+ pub fn resolveOrdinals(
+ gpa: std.mem.Allocator,
+ entries: []const pdf.OutlineEntry,
+ ) ![]usize {
+ const unresolved = std.math.maxInt(usize);
+ const ordinals = try gpa.alloc(usize, entries.len);
+ @memset(ordinals, unresolved);
+
+ const Pending = struct { depth: u8, ordinal: usize };
+ var pending: [256]Pending = undefined;
+ var pending_len: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
+ pending_len -= 1;
+
+ if (usableDestination(entry.destination) != null) {
+ ordinals[ordinal] = ordinal;
+ for (pending[0..pending_len]) |ancestor|
+ ordinals[ancestor.ordinal] = ordinal;
+ pending_len = 0;
+ } else if (entry.destination == .none) {
+ pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
+ pending_len += 1;
+ }
+ }
+ return ordinals;
+ }
+
+ fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
+ const raw = title orelse {
+ if (out) |buf| @memcpy(buf[0..10], "[untitled]");
+ return 10;
+ };
+ var at: usize = 0;
+ var i: usize = 0;
+ var wrote = false;
+ var pending_space = false;
+ while (i < raw.len) {
+ const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch {
+ pending_space = wrote;
+ i += 1;
+ continue;
+ };
+ if (i + n > raw.len) {
+ pending_space = wrote;
+ break;
+ }
+ const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch {
+ pending_space = wrote;
+ i += n;
+ continue;
+ };
+ const whitespace_or_control = cp <= 0x20 or cp == 0x7f or
+ (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029;
+ if (whitespace_or_control) {
+ pending_space = wrote;
+ } else {
+ if (pending_space) {
+ if (out) |buf| buf[at] = ' ';
+ at += 1;
+ }
+ if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
+ at += n;
+ wrote = true;
+ pending_space = false;
+ }
+ i += n;
+ }
+ if (!wrote) {
+ if (out) |buf| @memcpy(buf[0..13], "[empty title]");
+ return 13;
+ }
+ return at;
+ }
+
+ pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 {
+ const target = std.fs.path.basename(path);
+ const ordinals = try resolveOrdinals(gpa, entries);
+ defer gpa.free(ordinals);
+ var total: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ total += switch (destination) {
+ .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| std.fmt.count("{s} ", .{uri}),
+ .none => unreachable,
+ };
+ total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
+ }
+ const out = try gpa.alloc(u8, total);
+ errdefer gpa.free(out);
+ var at: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ const prefix = switch (destination) {
+ .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
+ .none => unreachable,
+ };
+ at += prefix.len;
+ const indent = @as(usize, entry.depth) * 2;
+ @memset(out[at..][0..indent], ' ');
+ at += indent;
+ at += cleanTitle(out[at..], entry.title);
+ out[at] = '\n';
+ at += 1;
+ }
+ return out;
+ }
+ } else struct {};
+};
+
+pub const Terminal = struct {
+ pub const enabled = pardes.terminal_panes;
+ const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
+
+ pub const history_max = if (enabled) 64 else 8;
+
+ pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
+ pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void;
+
+ pub const Replay = struct {
+ bytes: [1024 * 1024]u8 = undefined,
+ head: usize = 0,
+ len: usize = 0,
+ };
+
+ pub const State = struct {
+ vt: VtSlot,
+ stream: StreamSlot,
+ replay: Replay,
+ reply: [256]u8 = undefined,
+ reply_len: u16 = 0,
+ };
+
+ const GColor = ghostty_vt.color;
+
+ const FilterPaletteKey = struct {
+ bg: GColor.RGB,
+ fg: GColor.RGB,
+ base: [16]GColor.RGB,
+ };
+
+ pub const FilterPalette = if (enabled) LivePalette else struct {};
+
+ const LivePalette = struct {
+ key: ?FilterPaletteKey = null,
+ colors: GColor.Palette = GColor.default,
+
+ fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette {
+ const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var base: [16]GColor.RGB = undefined;
+ if (theme.palette) |palette| {
+ for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
+ } else {
+ const synthesized = [16][3]u8{
+ theme.bg orelse theme.tag_bg,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.box,
+ theme.sel_bg,
+ theme.comment,
+ theme.fg orelse theme.tag_fg,
+ theme.lineno,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.scroll_thumb,
+ theme.sel_fg,
+ theme.tag_fg,
+ theme.fg orelse theme.tag_fg,
+ };
+ for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
+ }
+
+ const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
+ if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
+
+ var seed = GColor.default;
+ for (base, 0..) |rgb, i| seed[i] = rgb;
+ self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
+ self.key = key;
+ return &self.colors;
+ }
+ };
+
+ fn asGhostRgb(rgb: [3]u8) GColor.RGB {
+ return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
+ }
+
+ fn asPardesColor(rgb: GColor.RGB) pardes.Color {
+ return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ }
+
+ /// The owned text standing in for `rows` live terminal rows, beginning at
+ /// absolute surface row `row`. The emulator grid remains untouched underneath.
+ pub const EditBuffer = struct {
+ row: i32 = 0,
+ rows: i32 = 1,
+ text: []u8 = &.{},
+ };
+
+ /// One whole-state terminal edit boundary. Null means the pane has not yet
+ /// materialized an edit buffer; non-null snapshots own their text.
+ pub const Snapshot = struct {
+ ovl: ?EditBuffer,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: Pane.CharSel,
+ };
+
+ pub const PendingCommand = struct {
+ bytes: []u8 = &.{},
+ wait: enum { none, spawn, input } = .none,
+ };
+
+ pub fn armShellSpawn(pane: *Pane) void {
+ if (comptime !enabled) return;
+ std.debug.assert(pane.pending_command.bytes.len == 0);
+ pane.pending_command.wait = .spawn;
+ }
+
+ pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool {
+ if (pane.pending_command.wait == .none) return false;
+ const old_len = pane.pending_command.bytes.len;
+ const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1));
+ const bytes = if (old_len == 0)
+ try pane.gpa.alloc(u8, new_len)
+ else
+ try pane.gpa.realloc(pane.pending_command.bytes, new_len);
+ @memcpy(bytes[old_len..][0..command.len], command);
+ bytes[new_len - 1] = '\r';
+ pane.pending_command.bytes = bytes;
+ pane.greet = false;
+ return true;
+ }
+
+ pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return;
+ if (prompt_marks) {
+ pane.pending_command.wait = .input;
+ releasePendingCommand(p, id, pane, false);
+ } else {
+ pane.greet = false;
+ releasePendingCommand(p, id, pane, true);
+ }
+ }
+
+ pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void {
+ if (pane.pending_command.wait == .input)
+ releasePendingCommand(p, id, pane, promptInputReady(pane));
+ }
+
+ fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void {
+ if (!ready) return;
+ const bytes = pane.pending_command.bytes;
+ pane.pending_command = .{};
+ if (bytes.len > 0) {
+ p.emitWrite(id, bytes);
+ pane.gpa.free(bytes);
+ }
+ }
+
+ pub fn deinitPendingCommand(pane: *Pane) void {
+ if (pane.pending_command.bytes.len > 0)
+ pane.gpa.free(pane.pending_command.bytes);
+ pane.pending_command = .{};
+ }
+
+ pub fn terminalIo() std.Io {
+ return if (comptime !pardes.hosted)
+ std.Io.failing
+ else
+ std.Io.Threaded.global_single_threaded.io();
+ }
+
+ /// The three numbers ghostty's scrollbar reports; all zero without an emulator.
+ pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 };
+
+ pub fn scrollbar(pane: *const Pane) Scrollbar {
+ if (comptime !enabled) return .{};
+ const state = pane.terminal orelse return .{};
+ const sb = state.vt.screens.active.pages.scrollbar();
+ return .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
+ }
+
+ /// The emulator's viewport offset, in SHELL rows: the top of what it shows.
+ pub fn gridOffset(pane: *const Pane) i32 {
+ return @intCast(scrollbar(pane).offset);
+ }
+
+ /// Where the emulator itself puts the cursor, in active-area cells — the origin
+ /// without one, which is where an empty pane's cursor belongs anyway.
+ pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 };
+
+ pub fn gridCursor(pane: *const Pane) GridCursor {
+ if (comptime !enabled) return .{};
+ const state = pane.terminal orelse return .{};
+ const cur = state.vt.screens.active.cursor;
+ return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
+ }
+
+ pub fn visibleCursor(pane: *const Pane) ?GridCursor {
+ if (comptime !enabled) return null;
+ const state = pane.terminal orelse return null;
+ if (!state.vt.modes.get(.cursor_visible)) return null;
+ const cur = state.vt.screens.active.cursor;
+ const sb = scrollbar(pane);
+ const row = sb.total - sb.len + cur.y;
+ if (row < sb.offset or row - sb.offset >= sb.len) return null;
+ return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) };
+ }
+
+ /// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
+ pub fn scrollGrid(pane: *Pane, delta: i32) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.vt.screens.active.scroll(.{ .delta_row = delta });
+ }
+
+ /// Snap the viewport back onto live output.
+ pub fn followOutput(pane: *Pane) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.vt.screens.active.scroll(.active);
+ }
+
+ /// Reflow the grid. A failed reflow keeps the grid it had rather than dropping
+ /// a scrollback; the next resize retries with the same numbers.
+ pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {};
+ }
+
+ /// DECSET 2004: the program wants its pastes bracketed.
+ pub fn bracketedPaste(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.bracketed_paste);
+ }
+
+ /// The program tracks the mouse itself, so a click in its body is its event.
+ pub fn reportsMouse(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ const m = &state.vt.modes;
+ return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
+ }
+
+ /// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
+ pub fn mouseFormatSgr(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.mouse_format_sgr);
+ }
+
+ /// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
+ pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
+ if (comptime !enabled) return &.{};
+ const state = pane.terminal orelse return &.{};
+ return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
+ }
+
+ /// Release the emulator's heap. The Pane allocation itself is the core's.
+ pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.stream.deinit();
+ state.vt.deinit(gpa);
+ gpa.destroy(state);
+ pane.terminal = null;
+ }
+
+ /// Allocate the live emulator half of a terminal pane. Slot ownership, serial
+ /// assignment, and spawn effects remain core lifecycle invariants.
+ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try createDoc(gpa, cols, rows);
+ errdefer gpa.destroy(pane);
+ if (comptime !enabled) return pane;
+ const state = try gpa.create(State);
+ errdefer gpa.destroy(state);
+ state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 16 * 1024 * 1024,
+ });
+ state.stream = state.vt.vtStream();
+ state.replay.head = 0;
+ state.replay.len = 0;
+ state.reply_len = 0;
+ state.stream.handler.effects.write_pty = ptyReport;
+ state.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ pane.terminal = state;
+ pane.tty_filter = true;
+ return pane;
+ }
+
+ pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try gpa.create(Pane);
+ pane.* = .{
+ .gpa = gpa,
+ .cols = cols,
+ .rows = rows,
+ };
+ return pane;
+ }
+
+ /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy
+ /// stay with the core; raw VT replay and viewport restoration belong here.
+ pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
+ const terminal = src.terminal.?;
+ if (comptime !enabled) {
+ const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
+ errdefer p.gpa.destroy(pane);
+ if (terminal.stream.len > 0)
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
+ return pane;
+ }
+ const bytes = if (terminal.stream_b64.len > 0)
+ try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
+ else
+ &.{};
+ const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
+ if (bytes.len > 0) {
+ ingest(pane, bytes);
+ followOutput(pane);
+ if (src.scroll > 0)
+ scrollGrid(pane, -@as(i32, @intCast(src.scroll)));
+ }
+ return pane;
+ }
+
+ /// Feed the emulator and retain the bounded suffix a dump can replay. Live
+ /// output and restoration share this byte path, then apply different views.
+ fn ingest(pane: *Pane, bytes: []const u8) void {
+ const state = pane.terminal.?;
+ const replay = &state.replay;
+ if (bytes.len >= replay.bytes.len) {
+ const kept = bytes[bytes.len - replay.bytes.len ..];
+ @memcpy(&replay.bytes, kept);
+ replay.head = 0;
+ replay.len = replay.bytes.len;
+ } else {
+ const overflow = bytes.len -| (replay.bytes.len - replay.len);
+ replay.head = (replay.head + overflow) % replay.bytes.len;
+ replay.len -= overflow;
+ const tail = (replay.head + replay.len) % replay.bytes.len;
+ const first = @min(bytes.len, replay.bytes.len - tail);
+ @memcpy(replay.bytes[tail..][0..first], bytes[0..first]);
+ @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]);
+ replay.len += bytes.len;
+ }
+ state.stream.nextSlice(bytes);
+ }
+
+ /// Return the replay ring in chronological order. Wrapped records are copied
+ /// into `allocator`; contiguous records remain a borrowed slice of the pane.
+ fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
+ const state = pane.terminal orelse return &.{};
+ const replay = &state.replay;
+ if (replay.len == 0) return &.{};
+ if (replay.head + replay.len <= replay.bytes.len)
+ return replay.bytes[replay.head..][0..replay.len];
+ const out = try allocator.alloc(u8, replay.len);
+ const first = replay.bytes.len - replay.head;
+ @memcpy(out[0..first], replay.bytes[replay.head..]);
+ @memcpy(out[first..], replay.bytes[0 .. replay.len - first]);
+ return out;
+ }
+
+ test "replay ownership is terminal-only and construction rolls back on allocation failure" {
+ const Case = struct {
+ fn run(gpa: std.mem.Allocator, terminal: bool) !void {
+ const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12);
+ defer gpa.destroy(pane);
+ defer deinitEmulator(pane, gpa);
+ try std.testing.expectEqual(terminal and enabled, pane.terminal != null);
+ }
+ };
+ try std.testing.expect(@sizeOf(Pane) < 128 * 1024);
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false});
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true});
+ }
+
+ test "document construction allocates only the pane and has inert terminal defaults" {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
+ const allocator = failing.allocator();
+ const pane = try createDoc(allocator, 40, 12);
+ defer allocator.destroy(pane);
+ defer deinitEmulator(pane, allocator);
+ try std.testing.expect(pane.terminal == null);
+ try std.testing.expectEqual(@as(usize, 1), failing.alloc_index);
+ try std.testing.expectEqual(@as(usize, 0), failing.resize_index);
+ try std.testing.expect(!failing.has_induced_failure);
+ try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes);
+ try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
+ try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
+ try std.testing.expect(!bracketedPaste(pane));
+ try std.testing.expect(!reportsMouse(pane));
+ try std.testing.expect(!mouseFormatSgr(pane));
+ try std.testing.expect(!promptInputReady(pane));
+ scrollGrid(pane, 100);
+ followOutput(pane);
+ resizeGrid(pane, allocator, 80, 24);
+ try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
+ try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
+ try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator));
+ try std.testing.expectEqualStrings("", try replayBytes(pane, allocator));
+ try std.testing.expect(!failing.has_induced_failure);
+ }
+
+ test "terminal state keeps parser fragments and sends emulator replies through its owner" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ const state = pane.terminal.?;
+ try std.testing.expect(state.stream.handler.terminal == &state.vt);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } });
+ while (p.nextEffect()) |effect| {
+ if (effect == .write) return error.PrematureTerminalReply;
+ }
+ p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } });
+ var replies: usize = 0;
+ while (p.nextEffect()) |effect| {
+ if (effect != .write) continue;
+ try std.testing.expectEqual(@as(u8, 0), effect.write.pane);
+ try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice());
+ replies += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), replies);
+ try std.testing.expectEqual(@as(u16, 0), state.reply_len);
+ try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator));
+ }
+
+ test "replay keeps a bounded chronological suffix across large writes and wrapping" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const pane = try create(gpa, 40, 12);
+ defer gpa.destroy(pane);
+ defer deinitEmulator(pane, gpa);
+ try std.testing.expectEqualStrings("", try replayBytes(pane, gpa));
+ ingest(pane, "hello");
+ const small = try replayBytes(pane, gpa);
+ try std.testing.expectEqualStrings("hello", small);
+ try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr));
+
+ const capacity = pane.terminal.?.replay.bytes.len;
+ const input = try gpa.alloc(u8, capacity + 7);
+ defer gpa.free(input);
+ @memset(input, 0);
+ const ending = "\x1b[31mred\x1b[0m\r\n";
+ @memcpy(input[input.len - ending.len ..], ending);
+ ingest(pane, input);
+ try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa));
+ ingest(pane, "next\r\n");
+ const wrapped = try replayBytes(pane, gpa);
+ defer gpa.free(wrapped);
+ try std.testing.expectEqual(capacity, wrapped.len);
+ try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]);
+ try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]);
+ }
+
+ test "replay restores terminal cells from the retained stream" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n");
+ const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa));
+ defer gpa.free(encoded);
+ const restored = try restore(p, .{
+ .kind = .terminal,
+ .tag = "",
+ .body = "",
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .terminal = .{ .stream_b64 = encoded },
+ });
+ defer gpa.destroy(restored);
+ defer deinitEmulator(restored, gpa);
+ const before = try screenTextAlloc(pane, gpa);
+ defer gpa.free(before);
+ const after = try screenTextAlloc(restored, gpa);
+ defer gpa.free(after);
+ try std.testing.expectEqualStrings(before, after);
+ try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
+ }
+
+ /// Record and parse one live pty read, invalidate its motion surface, and
+ /// follow it only when the body (possibly parked under a tag edit) is raw.
+ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
+ // There are no pty reads at all without an emulator to parse them into.
+ if (comptime !enabled) return;
+ if (pane.terminal == null) return;
+ const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or
+ pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or
+ pane.ed_undo_len > 0 or pane.ed_redo_len > 0;
+ if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes);
+ p.shell_rows.markStale(pane);
+ const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
+ if (body_mode == .tty) followOutput(pane);
+ }
+
+ const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 };
+ const PositionPin = struct {
+ target: *i32,
+ row: RowPin,
+ overlay: ?*EditBuffer,
+ edit_row: ?i32 = null,
+ };
+
+ fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin {
+ if (row < 0) return .{ .offset = row };
+ const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1);
+ var left = pages.total_rows - 1 - bounded;
+ var node = pages.pages.last.?;
+ while (left >= node.rows()) {
+ left -= node.rows();
+ node = node.prev.?;
+ }
+ const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch {
+ failed.* = true;
+ return .{};
+ };
+ return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) };
+ }
+
+ fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 {
+ const pin = row.pin orelse return row.offset;
+ const alive = pages orelse return 0;
+ if (pin.garbage) return 0;
+ var after: usize = 0;
+ var node = alive.pages.last;
+ while (node) |item| : (node = item.prev) {
+ if (item == pin.node)
+ return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset;
+ after += item.rows();
+ }
+ return 0;
+ }
+
+ fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin {
+ var grid = target.*;
+ if (overlay) |edit| {
+ const lines: i32 = @intCast(modal.lineCount(edit.text));
+ if (grid >= edit.row and grid < edit.row +| lines)
+ return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row };
+ if (grid > edit.row) grid = grid -| lines +| edit.rows;
+ }
+ return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay };
+ }
+
+ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
+ const screens = &pane.terminal.?.vt.screens;
+ const key = screens.active_key;
+ const generation = screens.generation(key);
+ const pages = &screens.active.pages;
+ const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel };
+ var groups: [history_max * 2 + 1]Group = undefined;
+ groups[0] = .{
+ .overlay = if (pane.ovl) |*overlay| overlay else null,
+ .cursor = &pane.cur_row,
+ .selection = &pane.vsel,
+ };
+ var ngroups: usize = 1;
+ for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| {
+ for (history) |*snapshot| {
+ groups[ngroups] = .{
+ .overlay = if (snapshot.ovl) |*overlay| overlay else null,
+ .cursor = &snapshot.cur_row,
+ .selection = &snapshot.vsel,
+ };
+ ngroups += 1;
+ }
+ }
+ const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin };
+ var spans: [groups.len]Span = undefined;
+ var nspans: usize = 0;
+ var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined;
+ var npositions: usize = 0;
+ var failed = false;
+ for (groups[0..ngroups]) |group| {
+ if (group.overlay) |overlay| {
+ spans[nspans] = .{
+ .overlay = overlay,
+ .start = trackRow(pages, overlay.row, &failed),
+ .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed),
+ };
+ nspans += 1;
+ }
+ positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed);
+ npositions += 1;
+ if (group.selection.active) {
+ positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed);
+ npositions += 1;
+ }
+ }
+ const overlay = groups[0].overlay;
+ var extra: [Pane.max_selections * 2 + 4]*i32 = undefined;
+ var nextra: usize = 0;
+ if (pane.msel.active) {
+ extra[nextra] = &pane.msel.r0;
+ extra[nextra + 1] = &pane.msel.r1;
+ nextra += 2;
+ }
+ for (pane.sels[0..pane.nsel]) |*selection| {
+ extra[nextra] = &selection.row;
+ extra[nextra + 1] = &selection.arow;
+ nextra += 2;
+ }
+ if (pane.append_at) |*at| {
+ extra[nextra] = &at.row;
+ nextra += 1;
+ }
+ if (pane.look_at) |*at| {
+ extra[nextra] = &at.row;
+ nextra += 1;
+ }
+ for (extra[0..nextra]) |target| {
+ positions[npositions] = trackPosition(pages, target, overlay, &failed);
+ npositions += 1;
+ }
+ ingest(pane, bytes);
+ // RIS can destroy the alternate screen and every pin it owned.
+ const alive = if (screens.generation(key) == generation) pages else null;
+ var moved = false;
+ for (spans[0..nspans]) |span| {
+ const start = pinnedRow(alive, span.start);
+ const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1);
+ moved = moved or start != span.overlay.row or rows != span.overlay.rows;
+ span.overlay.row = start;
+ span.overlay.rows = rows;
+ if (alive) |owner| {
+ if (span.start.pin) |pin| owner.untrackPin(pin);
+ if (span.end.pin) |pin| owner.untrackPin(pin);
+ }
+ }
+ for (positions[0..npositions]) |position| {
+ var row = pinnedRow(alive, position.row);
+ if (position.overlay) |edit| {
+ if (position.edit_row) |relative| {
+ row = edit.row +| relative;
+ } else if (row > edit.row) {
+ row = if (row < edit.row +| edit.rows)
+ edit.row
+ else
+ row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text)));
+ }
+ }
+ moved = moved or row != position.target.*;
+ position.target.* = row;
+ if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin);
+ }
+ if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text.
+ if (failed) {
+ const message = "terminal edit position reset: out of memory";
+ @memcpy(pane.msg[0..message.len], message);
+ pane.msg_len = message.len;
+ }
+ }
+
+ test "raw terminal output needs no position tracking allocations" {
+ if (comptime !enabled) return error.SkipZigTest;
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ feedOutput(p, pane, "warm");
+ const allocations = failing.alloc_index;
+ failing.fail_index = allocations;
+ const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins();
+ feedOutput(p, pane, " output");
+ try std.testing.expectEqual(allocations, failing.alloc_index);
+ try std.testing.expect(!failing.has_induced_failure);
+ try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins());
+ }
+
+ pub fn promptInputReady(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.screens.active_key != .alternate and
+ state.vt.screens.active.cursor.semantic_content == .input;
+ }
+
+ /// Encode one key for the program that owns a raw terminal and queue its pty
+ /// write. Global chords and mode routing have already been handled by core.
+ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ var control: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.ctrl) {
+ if (key.cp >= 'a' and key.cp <= 'z') {
+ control[0] = @intCast(key.cp - 0x60);
+ break :blk control[0..1];
+ }
+ // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range.
+ if (key.cp >= '@' and key.cp <= '_') {
+ control[0] = @intCast(key.cp - 0x40);
+ break :blk control[0..1];
+ }
+ }
+ if (key.text.len > 0) break :blk key.text;
+ break :blk switch (key.cp) {
+ Key.enter => "\r",
+ Key.backspace => "\x7f",
+ Key.tab => "\t",
+ Key.escape => "\x1b",
+ Key.up => "\x1b[A",
+ Key.down => "\x1b[B",
+ Key.right => "\x1b[C",
+ Key.left => "\x1b[D",
+ Key.delete => "\x1b[3~",
+ else => null,
+ };
+ };
+ if (bytes) |encoded|
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } });
+ }
+
+ pub fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (comptime enabled) if (pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: {
+ const screen = pane.terminal.?.vt.screens.active;
+ const goff: i32 = @intCast(screen.pages.scrollbar().offset);
+ const vp_row = pane.gridRow(pane.cur_row) - goff;
+ if (vp_row < 0) break :handoff;
+
+ var grid_col: i32 = @max(0, pane.cur_col);
+ if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
+ if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) {
+ .cut => |cols| grid_col += @intCast(cols),
+ .keep, .blank => {},
+ };
+ }
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) },
+ }) orelse break :handoff;
+ const cursor_pin = screen.cursor.page_pin.*;
+ var prompts = cursor_pin.promptIterator(.left_up, null);
+ const prompt_pin = prompts.next() orelse break :handoff;
+ if (click_pin.before(prompt_pin)) break :handoff;
+ const moves = screen.promptClickMove(click_pin);
+ for (0..moves.left) |_| p.emitWrite(id, "\x1b[D");
+ for (0..moves.right) |_| p.emitWrite(id, "\x1b[C");
+ };
+
+ pane.mode = .tty;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.nsel = 0;
+ // A pinned row scrolls away. Raw mode must follow the program's live
+ // cursor, and Last must not restore a stale modal spot on the way back.
+ pane.cur_pinned = false;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ pane.normal.clear();
+ }
+
+ // Returned slices remain valid until the next cache rebuild or reset.
+ pub const RowsCache = struct {
+ /// whose grid this describes; null = the slot is free
+ pane: ?*const Pane = null,
+ /// the rows joined by '\n' — `flatSurface` hands this back verbatim
+ /// instead of rebuilding the join on every keystroke
+ text: []const u8 = &.{},
+ /// slices INTO `text`, absolute grid rows from 0
+ rows: [][]const u8 = &.{},
+ /// `text` is a prefix of this: blanking a prompt row shortens the join,
+ /// and the slack is not worth a second allocation to reclaim
+ text_alloc: []u8 = &.{},
+ stale: bool = false,
+
+ pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
+ if (c.rows.len > 0) gpa.free(c.rows);
+ c.* = .{};
+ }
+
+ /// Free a stale entry. Called at the top of `update`, and nowhere else.
+ pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.stale) c.reset(gpa);
+ }
+
+ /// `pane`'s grid moved: the entry no longer describes it.
+ pub fn markStale(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane == pane) c.stale = true;
+ }
+
+ pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane != pane) return;
+ c.pane = null;
+ c.stale = true;
+ }
+ };
+
+ const Rows = struct {
+ text_alloc: []u8,
+ text: []const u8,
+ rows: [][]const u8,
+ };
+
+ const empty_grid = [1][]const u8{""};
+
+ /// What LEAVING raw tty mode does to one prompt row, decided from its cells
+ /// alone. See config.tty_blank for why any of this happens.
+ const PromptCut = union(enum) {
+ /// show the row exactly as ghostty dumped it
+ keep,
+ /// show nothing at all
+ blank,
+ /// drop this many leading COLUMNS — the prompt — and keep the rest, which
+ /// is what was typed at it
+ cut: usize,
+ };
+
+ fn promptCut(pin: ghostty_vt.Pin) PromptCut {
+ if (config.tty_blank == .prompt_and_input) return .blank;
+ const cells = pin.cells(.all);
+ var cols: usize = 0;
+ while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1;
+ // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or
+ // a repaint that has moved on. Nothing here is the prompt, so hide nothing.
+ if (cols == 0) return .keep;
+ // ...and all prompt, nothing typed yet: the row is chrome end to end.
+ if (cols >= cells.len) return .blank;
+ return .{ .cut = cols };
+ }
+
+ fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
+ const cols = switch (promptCut(pin)) {
+ .keep => return raw,
+ .blank => return "",
+ .cut => |n| n,
+ };
+ const cells = pin.cells(.all);
+ var at: usize = 0;
+ var col: usize = 0;
+ while (col < cols and at < raw.len) {
+ const cell = &cells[col];
+ at = @min(raw.len, at + dumpedBytes(pin, cell));
+ // the tail cell of a wide glyph spells nothing of its own
+ col += if (cell.wide == .wide) @as(usize, 2) else 1;
+ }
+ return std.mem.trimEnd(u8, raw[at..], " \t");
+ }
+
+ fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize {
+ switch (cell.wide) {
+ .spacer_head, .spacer_tail => return 0,
+ .narrow, .wide => {},
+ }
+ var n: usize = switch (cell.content_tag) {
+ .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength(
+ cell.codepoint(),
+ ) catch 1,
+ // A cell carrying only a colour still spells one blank in the dump.
+ else => 1,
+ };
+ if (cell.content_tag == .codepoint_grapheme) {
+ if (pin.grapheme(cell)) |extra| for (extra) |cp| {
+ n += std.unicode.utf8CodepointSequenceLength(cp) catch 1;
+ };
+ }
+ return n;
+ }
+
+ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ if (comptime !enabled) return &empty_grid;
+ if (pane.terminal == null) return &empty_grid;
+ const c = &p.shell_rows;
+ if (!c.stale and c.pane == pane) return c.rows;
+ if (c.pane != null) {
+ c.stale = true;
+ return (try buildRows(p.scratch.allocator(), p, pane)).rows;
+ }
+ const built = try buildRows(p.gpa, p, pane);
+ c.* = .{
+ .pane = pane,
+ .text = built.text,
+ .rows = built.rows,
+ .text_alloc = built.text_alloc,
+ };
+ return c.rows;
+ }
+
+ /// The full modal motion surface: shell history with the live edit overlay
+ /// spliced into the rows it covers.
+ pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ const rows = try shellRows(p, pane);
+ if (pane.ovl == null) return rows;
+ var last = rows.len;
+ if (pane.ovl) |overlay|
+ last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows))));
+ var count = last;
+ if (pane.ovl) |overlay| {
+ if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last)
+ count = count - @min(
+ @as(usize, @intCast(overlay.rows)),
+ last - @as(usize, @intCast(overlay.row)),
+ ) + @max(1, modal.lineCount(overlay.text));
+ }
+ const lines = try p.scratch.allocator().alloc([]const u8, count);
+ var n: usize = 0;
+ var grid_row: usize = 0;
+ while (grid_row < last) : (grid_row += 1) {
+ if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) {
+ var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n');
+ while (overlay_lines.next()) |line| : (n += 1) lines[n] = line;
+ grid_row += @intCast(overlay.rows - 1);
+ continue;
+ };
+ lines[n] = if (grid_row < rows.len) rows[grid_row] else "";
+ n += 1;
+ }
+ return lines[0..n];
+ }
+
+ /// Flatten `cursorLines` without rebuilding the common cached/no-overlay
+ /// case. Scratch-owned when a join is required.
+ pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 {
+ const cache = &p.shell_rows;
+ if (!cache.stale and cache.pane == pane and
+ lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text;
+ var total: usize = if (lines.len > 0) lines.len - 1 else 0;
+ for (lines) |line| total += line.len;
+ const text = try p.scratch.allocator().alloc(u8, total);
+ var at: usize = 0;
+ for (lines, 0..) |line, i| {
+ if (i > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..line.len], line);
+ at += line.len;
+ }
+ return text;
+ }
+
+ /// Materialize or extend the terminal edit overlay with one exact allocation.
+ /// Row slices are scratch-owned/borrowed; only the joined text is installed.
+ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
+ const want_lo = @max(0, @min(lo, hi));
+ const want_hi = @max(want_lo, @max(lo, hi));
+ const fresh = pane.ovl == null;
+ const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
+ const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
+ const up = old.row - want_lo;
+ const down = want_hi - (old.row + lines - 1);
+ const extending = fresh or up > 0 or down > 0;
+ var row0 = old.row;
+ var covered = old.rows;
+ var text = old.text;
+ var owned = false;
+ if (extending) {
+ const rows = shellRows(p, pane) catch return null;
+ row0 = old.row - @max(0, up);
+ covered = old.rows + @max(0, up) + @max(0, down);
+ const up_len: usize = @intCast(@max(0, up));
+ const down_len: usize = @intCast(@max(0, down));
+ const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null;
+ for (parts[0..up_len], 0..) |*part, i| {
+ const src = @as(usize, @intCast(row0)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ const middle: usize = @intCast(old.row);
+ parts[up_len] = if (fresh)
+ (if (middle < rows.len) rows[middle] else "")
+ else
+ old.text;
+ for (parts[up_len + 1 ..], 0..) |*part, i| {
+ const src = @as(usize, @intCast(old.row + old.rows)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ text = std.mem.join(p.gpa, "\n", parts) catch return null;
+ owned = true;
+ }
+
+ const row: usize = @intCast(@max(0, want_lo - row0));
+ const line_len: i32 = @intCast(modal.lineSlice(text, row).len);
+ if (col > line_len) {
+ const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ @memset(spaces, ' ');
+ const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ if (owned) p.gpa.free(text);
+ text = padded;
+ owned = true;
+ }
+ if (owned) {
+ if (pane.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ovl = .{ .row = row0, .rows = covered, .text = text };
+ }
+ return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row };
+ }
+
+ /// Consume a rewritten overlay, freeing the terminal edit text it replaces.
+ pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
+ const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new);
+ p.gpa.free(overlay.text);
+ overlay.text = new;
+ }
+
+ /// Serialize terminal-only state; the core supplies shared pane metadata.
+ pub fn dumpPane(
+ pane: *Pane,
+ arena: std.mem.Allocator,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+ ) !dump.Pane {
+ if (!enabled or pane.terminal == null) {
+ const text = if (pane.ovl) |overlay| overlay.text else "";
+ return .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = try arena.dupe(u8, text),
+ .stream_b64 = &.{},
+ .cursor = .{ .col = 0, .row = 0 },
+ },
+ };
+ }
+ const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ const extra = if (pane.ovl) |overlay| overlay.text.len else 0;
+ const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra));
+ var len: usize = 0;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: i32 = 0;
+ var skip: i32 = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ // Hidden overlay rows still consume prompt pins to keep them aligned.
+ const prompt = if (pane.mode != .tty) prompts.next() else null;
+ if (skip > 0) {
+ skip -= 1;
+ continue;
+ }
+ if (row > 0) {
+ stream[len] = '\n';
+ len += 1;
+ }
+ if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) {
+ @memcpy(stream[len..][0..overlay.text.len], overlay.text);
+ len += overlay.text.len;
+ skip = overlay.rows - 1;
+ continue;
+ };
+ const shown = if (prompt) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
+ else
+ raw;
+ @memcpy(stream[len..][0..shown.len], shown);
+ len += shown.len;
+ }
+ return .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream[0..len],
+ .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)),
+ .cursor = .{
+ .col = pane.terminal.?.vt.screens.active.cursor.x,
+ .row = pane.terminal.?.vt.screens.active.cursor.y,
+ },
+ },
+ };
+ }
+
+ fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
+ const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
+ var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ // split yields one more item than delimiters; the extra final slot is the
+ // cursor row retained below.
+ const n_rows = std.mem.count(u8, full, "\n") + 2;
+ const rows = try alloc.alloc([]const u8, n_rows);
+ errdefer alloc.free(rows);
+ // Blanking a prompt row only ever SHORTENS it and the retained cursor row
+ // adds one separator, so the dump's length plus one bounds the join.
+ const text = try alloc.alloc(u8, full.len + 1);
+ errdefer alloc.free(text);
+ var at: usize = 0;
+ var n: usize = 0;
+ var it = std.mem.splitScalar(u8, full, '\n');
+ while (it.next()) |raw| {
+ const shown = if (pit.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
+ else
+ raw;
+ if (n > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..shown.len], shown);
+ rows[n] = text[at..][0..shown.len];
+ at += shown.len;
+ n += 1;
+ }
+ text[at] = '\n';
+ at += 1;
+ rows[n] = text[at..][0..0];
+ n += 1;
+ std.debug.assert(n == n_rows);
+ return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
+ }
+
+ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
+ const state = pane.terminal orelse break :vp &.{};
+ const screen = state.vt.screens.active;
+ var tl = screen.pages.getTopLeft(.viewport);
+ tl.x = 0;
+ const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint;
+ var rows_out: std.Io.Writer.Allocating = .init(arena);
+ try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false });
+ const raw = try rows_out.toOwnedSlice();
+ var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ var n: usize = 0;
+ while (lines.next()) |ln| {
+ vp[n] = if (pane.mode != .tty)
+ if (prompts.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln
+ else
+ ln
+ else
+ ln;
+ n += 1;
+ }
+ std.debug.assert(n == vp.len);
+ break :vp vp;
+ };
+
+ const len = fillBody(null, pane, vp);
+ const out = try arena.alloc(u8, len);
+ const filled = fillBody(out, pane, vp);
+ std.debug.assert(filled == out.len);
+ return out;
+ }
+
+ pub const BodyRow = union(enum) {
+ grid: i32,
+ edit: struct { line: []const u8, idx: usize },
+ };
+
+ const BodyWalk = struct {
+ pane: *Pane,
+ goff: i32,
+ g: i32,
+ /// the buffer can start above the viewport: drop the lines scrolled past
+ skip: usize,
+ n: usize = 0,
+ lines: ?std.mem.SplitIterator(u8, .scalar) = null,
+ covered: i32 = 0,
+ line_idx: usize = 0,
+
+ fn init(pane: *Pane) BodyWalk {
+ const off = pane.scroll();
+ const goff = gridOffset(pane);
+ return .{
+ .pane = pane,
+ .goff = goff,
+ .g = if (pane.mode == .tty) goff else pane.gridRow(off),
+ .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0,
+ };
+ }
+
+ fn next(w: *BodyWalk) ?BodyRow {
+ while (w.n < w.pane.rows) {
+ if (w.lines) |*it| {
+ if (it.next()) |line| {
+ const idx = w.line_idx;
+ w.line_idx += 1;
+ // Lines scrolled off the top still count: the index names a
+ // line of the BUFFER, not of the visible body.
+ if (w.skip > 0) {
+ w.skip -= 1;
+ continue;
+ }
+ w.n += 1;
+ return .{ .edit = .{ .line = line, .idx = idx } };
+ }
+ // The buffer stands in for `rows` shell rows however many lines
+ // it actually spelled, which is the whole slide.
+ w.g += w.covered;
+ w.skip = 0;
+ w.lines = null;
+ continue;
+ }
+ if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) {
+ w.lines = std.mem.splitScalar(u8, o.text, '\n');
+ w.covered = o.rows;
+ w.line_idx = 0;
+ continue;
+ };
+ const vi = w.g - w.goff;
+ w.g += 1;
+ w.n += 1;
+ return .{ .grid = vi };
+ }
+ return null;
+ }
+ };
+
+ /// Run the terminal body row walk. A null destination counts bytes; a slice
+ /// fills the exact allocation made from that count.
+ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
+ var walk: BodyWalk = .init(pane);
+ var written: usize = 0;
+ var first = true;
+ while (walk.next()) |row| {
+ if (!first) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
+ first = false;
+ const bytes = switch (row) {
+ .edit => |e| e.line,
+ .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len)
+ viewport[@intCast(vi)]
+ else
+ "",
+ };
+ if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes);
+ written += bytes.len;
+ }
+ return written;
+ }
+
+ // Match surviving edit-buffer rows to their original terminal styles.
+ const EditAnchors = struct {
+ /// the buffer's own text, walked in order: a line the VIEWPORT skipped still
+ /// consumes the row it came from, so the lines below it stay aligned
+ text: []const u8 = &.{},
+ at: usize = 0,
+ shell: []const []const u8 = &.{},
+ /// the covered span, absolute grid rows, as `[first, end)`
+ first: usize = 0,
+ end: usize = 0,
+ lines: usize = 0,
+ /// the line `at` names, and the first row still unclaimed
+ idx: usize = 0,
+ cursor: usize = 0,
+ budget: usize = 0,
+ active: bool = false,
+
+ fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors {
+ if (o.rows <= 0 or o.row < 0) return .{};
+ const first: usize = @intCast(o.row);
+ const screen = pane.terminal.?.vt.screens.active;
+ const total = screen.pages.scrollbar().total;
+ if (first >= total) return .{};
+ const covered: usize = @intCast(o.rows);
+ const count = @min(covered, total - first);
+ const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{};
+ const br = screen.pages.pin(.{ .screen = .{
+ .x = screen.pages.cols - 1,
+ .y = @intCast(first + count - 1),
+ } }) orelse return .{};
+ const arena = p.scratch.allocator();
+ var output: std.Io.Writer.Allocating = .init(arena);
+ screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{};
+ const shell = arena.alloc([]const u8, count) catch return .{};
+ var raw = std.mem.splitScalar(u8, output.written(), '\n');
+ var pins = tl.rowIterator(.right_down, br);
+ for (shell) |*row| {
+ const text = raw.next() orelse "";
+ row.* = if (pins.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text
+ else
+ text;
+ }
+ const lines = std.mem.count(u8, o.text, "\n") + 1;
+ return .{
+ .text = o.text,
+ .shell = shell,
+ .first = first,
+ .end = first + count,
+ .lines = lines,
+ .cursor = first,
+ .budget = covered + 4 * lines,
+ .active = true,
+ };
+ }
+
+ fn shellRow(a: *EditAnchors, idx: usize) ?Anchor {
+ if (!a.active or idx >= a.lines) return null;
+ var found: ?Anchor = null;
+ while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null);
+ return found;
+ }
+
+ fn nextLine(a: *EditAnchors) ?[]const u8 {
+ if (a.at > a.text.len) return null;
+ const rest = a.text[a.at..];
+ if (std.mem.indexOfScalar(u8, rest, '\n')) |n| {
+ a.at += n + 1;
+ return rest[0..n];
+ }
+ // The last line has no terminator; one past the end ends the walk.
+ a.at = a.text.len + 1;
+ return rest;
+ }
+
+ /// Where this line still stands over the grid, if anywhere.
+ fn claim(a: *EditAnchors, line: []const u8) ?Anchor {
+ const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end;
+ var k = a.cursor;
+ while (k < end) : (k += 1) {
+ if (a.budget == 0) return null;
+ a.budget -= 1;
+ if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue;
+ a.cursor = k + 1;
+ return .{ .row = @intCast(k) };
+ }
+ if (a.cursor >= a.end) return null;
+ const shell = a.shell[a.cursor - a.first];
+ var p: usize = 0;
+ while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1;
+ var s: usize = 0;
+ const room = @min(line.len, shell.len) - p;
+ while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1;
+ if (p + s == 0) return null;
+ if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
+ const row = a.cursor;
+ if (s > 0 or p >= shell.len) a.cursor += 1;
+ return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
+ }
+ };
+
+ const Anchor = struct {
+ /// the row, absolute while it comes from `EditAnchors`, viewport once
+ /// `recolorAnsi` has subtracted the walk's offset
+ row: i32,
+ prefix: usize = std.math.maxInt(usize),
+ suffix: usize = 0,
+ /// the row's own dumped length — what the suffix is measured from on the
+ /// GRID side, where the edit may have changed the byte count
+ shell_len: usize = 0,
+
+ fn whole(an: Anchor) bool {
+ return an.prefix == std.math.maxInt(usize);
+ }
+ };
+
+ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
+ // No emulator, no ANSI cells: the whole pass — and the 256-colour theme
+ // projection behind it — is compiled out.
+ if (comptime !enabled) return;
+ const s = &p.surface;
+ if (pane.terminal == null) return;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var filtered_storage: FilteredColors = undefined;
+ const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
+ const tz_filter = tracy.zone(@src(), "filterInit");
+ defer tz_filter.end();
+ filtered_storage = FilteredColors.init(p, pane);
+ break :blk &filtered_storage;
+ } else null;
+ // DECSCNM, read once: the filtered palette folds it in itself, the raw
+ // path needs it per cell.
+ const scnm = pane.terminal.?.vt.modes.get(.reverse_colors);
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ const vp_rows: i32 = @intCast(scrollbar(pane).len);
+ // Which buffer lines the user has not actually changed, so a row swallowed
+ // by a growing buffer keeps the colour it still stands over.
+ var anchors: ?EditAnchors = null;
+ var walk: BodyWalk = .init(pane);
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ var vr: u16 = 0;
+ while (walk.next()) |row| : (vr += 1) {
+ if (vr >= body_h) break;
+ // Before any early exit below, or the lines fall out of step with rows.
+ const text = lines.next() orelse "";
+ const anchor: Anchor = switch (row) {
+ .edit => |e| blk: {
+ if (anchors == null) anchors = .init(p, pane, pane.ovl.?);
+ var an = anchors.?.shellRow(e.idx) orelse continue;
+ an.row -= walk.goff;
+ break :blk an;
+ },
+ .grid => |v| .{ .row = v },
+ };
+ const vi = anchor.row;
+ if (vi < 0 or vi >= vp_rows) continue;
+ const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
+ const cut: u16 = if (pane.mode == .tty) 0 else cut: {
+ if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0;
+ break :cut switch (promptCut(row_pin)) {
+ .keep => 0,
+ // Blanked end to end: the row shows nothing of the grid, so
+ // projecting the prompt's own colours onto it would be a lie.
+ .blank => continue,
+ .cut => |n| std.math.cast(u16, n) orelse continue,
+ };
+ };
+ if (cut > 0 and text.len == 0) continue;
+ var at: usize = 0;
+ var sc: u16 = 0;
+ var gc: u16 = cut;
+ var sb: usize = 0;
+ const mine_from = @min(anchor.prefix, text.len);
+ const mine_to = text.len - @min(anchor.suffix, text.len);
+ var crossed = false;
+ while (at < text.len and sc < tw) {
+ const stop = modal.nextGrapheme(text, at);
+ if (stop <= at) break;
+ const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
+ if (at >= mine_from and at < mine_to) {
+ sc += span;
+ at = stop;
+ continue;
+ }
+ if (at >= mine_to and !crossed) {
+ crossed = true;
+ const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len);
+ while (sb < upto) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ sb += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ }
+ const want = stop - at;
+ // Consume every cell that contributed to this grapheme. A cluster
+ // ghostty split across several cells is still ONE printed glyph.
+ var covered: usize = 0;
+ var style: ?pardes.CellStyle = null;
+ while (covered < want) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head)
+ style = cellStyle(p, ci, filtered, scnm);
+ covered += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A spacer contributes no bytes; without this the loop would
+ // spin on a row that ends in one.
+ if (covered == 0 and gc >= pane.cols) break;
+ }
+ while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| {
+ if (t.cell.wide != .spacer_tail) break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ if (style) |st| for (0..span) |k| {
+ const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = st;
+ };
+ sb += covered;
+ sc += span;
+ at = stop;
+ }
+ var tail: ?pardes.CellStyle = null;
+ if (anchor.whole() or anchor.suffix > 0) {
+ while (sc < tw) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + sc, body_y + vr);
+ sc += 1;
+ const style = cellStyle(p, ci, filtered, scnm);
+ tail = style;
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ }
+ if (tail) |style| while (sc < tw) : (sc += 1) {
+ const cell = s.at(tx + sc, body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ };
+ }
+ }
+ }
+
+ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle {
+ const style = ci.style();
+ var cs: pardes.CellStyle = .{
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ if (filtered == null) switch (ci.cell.content_tag) {
+ .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ };
+ return cs;
+ }
+
+ const FilteredColors = struct {
+ source: GColor.Palette,
+ target: *const GColor.Palette,
+ theme_bg: GColor.RGB,
+ theme_fg: GColor.RGB,
+ dynamic_bg: ?GColor.RGB,
+ dynamic_fg: ?GColor.RGB,
+ default_bg: GColor.RGB,
+ /// What a foreground too near `default_bg` becomes instead.
+ fallback_fg: GColor.RGB,
+ default_bg_luminance: f64,
+ fg_for_palette: [256]pardes.Color = undefined,
+ bg_for_palette: [256]pardes.Color = undefined,
+ /// The two answers for a cell that names no colour of its own.
+ fg_default: pardes.Color = undefined,
+ bg_default: pardes.Color = undefined,
+ cache_rgb: [256]GColor.RGB = undefined,
+ cache_key: [256]u8 = undefined,
+ cache_valid: [256]bool = @splat(false),
+
+ fn init(p: *Pardes, pane: *const Pane) FilteredColors {
+ var source = GColor.default;
+ for (&source, 0..) |*rgb, i|
+ rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
+ const theme = p.theme();
+ var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background });
+ var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground });
+ if (pane.terminal.?.vt.modes.get(.reverse_colors)) {
+ std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
+ std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
+ }
+ var self: FilteredColors = .{
+ .source = source,
+ .target = p.tty_filter_palette.get(theme),
+ .theme_bg = theme_bg,
+ .theme_fg = theme_fg,
+ .dynamic_bg = dynamic_bg,
+ .dynamic_fg = dynamic_fg,
+ .default_bg = theme_bg,
+ .fallback_fg = theme_fg,
+ .default_bg_luminance = luminanceOf(theme_bg),
+ };
+ if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb);
+ self.default_bg_luminance = luminanceOf(self.default_bg);
+ if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg))
+ self.fallback_fg = self.theme_bg;
+
+ self.fg_default = asPardesColor(self.legible(
+ if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg,
+ ));
+ self.bg_default = asPardesColor(self.default_bg);
+ for (&self.source, 0..) |current, i| {
+ const idx: u8 = @intCast(i);
+ const mapped = self.paletteRgb(idx, current);
+ self.fg_for_palette[idx] = asPardesColor(self.legible(mapped));
+ self.bg_for_palette[idx] = asPardesColor(mapped);
+ }
+ return self;
+ }
+
+ fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
+ return switch (style.fg_color) {
+ .none => self.fg_default,
+ .palette => |idx| self.fg_for_palette[idx],
+ .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{
+ .default = self.dynamic_fg orelse self.theme_fg,
+ .palette = &self.source,
+ .bold = null,
+ })))),
+ };
+ }
+
+ fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
+ switch (cell.content_tag) {
+ .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data],
+ .bg_color_rgb => {},
+ else => switch (style.bg_color) {
+ .none => return self.bg_default,
+ .palette => |idx| return self.bg_for_palette[idx],
+ .rgb => {},
+ },
+ }
+ // Truecolour, from either the cell or its style.
+ return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?));
+ }
+
+ fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >=
+ config.tty_filter_min_contrast) return rgb;
+ return self.fallback_fg;
+ }
+
+ fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB {
+ if (current.eql(GColor.default[idx])) return self.target[idx];
+ return self.keyedRgb(current);
+ }
+
+ fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ return self.target[self.nearestKey(rgb)];
+ }
+
+ fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
+ const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
+ const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
+ if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
+ return self.cache_key[slot];
+
+ var best: u8 = 0;
+ var best_distance: u32 = std.math.maxInt(u32);
+ for (GColor.default, 0..) |candidate, i| {
+ const distance = colorDistance(rgb, candidate);
+ // Strict comparison makes duplicate-colour ties stable at the
+ // lowest canonical xterm key.
+ if (distance < best_distance) {
+ best_distance = distance;
+ best = @intCast(i);
+ }
+ }
+ self.cache_rgb[slot] = rgb;
+ self.cache_key[slot] = best;
+ self.cache_valid[slot] = true;
+ return best;
+ }
+ };
+
+ const channel_luminance: [256]f64 = blk: {
+ @setEvalBranchQuota(20000);
+ var table: [256]f64 = undefined;
+ for (&table, 0..) |*slot, c| {
+ const normalized: f64 = @as(f64, @floatFromInt(c)) / 255;
+ slot.* = if (normalized <= 0.03928)
+ normalized / 12.92
+ else
+ std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
+ }
+ break :blk table;
+ };
+
+ fn luminanceOf(rgb: GColor.RGB) f64 {
+ return 0.2126 * channel_luminance[rgb.r] +
+ 0.7152 * channel_luminance[rgb.g] +
+ 0.0722 * channel_luminance[rgb.b];
+ }
+
+ /// ghostty's `RGB.contrast` with both luminances already in hand.
+ fn contrastOf(a_luminance: f64, b_luminance: f64) f64 {
+ const lighter = @max(a_luminance, b_luminance);
+ const darker = @min(a_luminance, b_luminance);
+ return (lighter + 0.05) / (darker + 0.05);
+ }
+
+ fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
+ const dr = @as(i32, a.r) - @as(i32, b.r);
+ const dg = @as(i32, a.g) - @as(i32, b.g);
+ const db = @as(i32, a.b) - @as(i32, b.b);
+ return @intCast(dr * dr + dg * dg + db * db);
+ }
+
+ test "the luminance table answers exactly what ghostty computes" {
+ for (0..256) |i| {
+ const c: u8 = @intCast(i);
+ const grey: GColor.RGB = .{ .r = c, .g = c, .b = c };
+ try std.testing.expectEqual(grey.luminance(), luminanceOf(grey));
+ }
+ // Channel weights are asymmetric, so a grey ramp alone would not catch a
+ // transposed coefficient. The palette is what the tables enumerate.
+ for (GColor.default) |candidate| {
+ try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate));
+ for (GColor.default) |page| {
+ try std.testing.expectEqual(
+ candidate.contrast(page),
+ contrastOf(luminanceOf(candidate), luminanceOf(page)),
+ );
+ }
+ }
+ }
+
+ test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+ pane.tty_filter = false;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
+ "\x1b[38;5;196mB" ++
+ "\x1b[38;2;255;0;0mC" ++
+ "\x1b[0;48;5;25mD" ++
+ "\x1b[0;48;2;0;95;175mE" ++
+ "\x1b[0;1;2;3;4;5;7;8;9mF" ++
+ "\x1b[0;48;5;25m\x1b[K" ++
+ "\r\n\x1b[0;38;5;2m界" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const raw = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
+
+ pane.tty_filter = true;
+ _ = frame.reset(.retain_capacity);
+ const filtered = try p.render(frame.allocator());
+ var expected_cache: FilterPalette = .{};
+ const expected = expected_cache.get(p.theme());
+ try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
+ try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
+
+ const attrs = filtered.at(tx + 5, body_y).style;
+ try testing.expect(attrs.bold);
+ try testing.expect(attrs.dim);
+ try testing.expect(attrs.italic);
+ try testing.expect(attrs.blink);
+ try testing.expect(attrs.reverse);
+ try testing.expect(attrs.invisible);
+ try testing.expect(attrs.strikethrough);
+ try testing.expectEqual(.single, attrs.ul);
+
+ const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
+ try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
+ try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
+ // A filtered terminal never delegates either colour to a backend palette,
+ // including cells which were empty/default before the pass.
+ for (0..r.w - config.GUTTER) |col| {
+ const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
+ try testing.expect(!cell.default);
+ switch (cell.style.fg) {
+ .rgb => {},
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ switch (cell.style.bg) {
+ .rgb => {},
+ else => return error.FilteredBackgroundWasNotRgb,
+ }
+ }
+
+ // Colors remains the global master gate. The pane remembers Filter while
+ // ANSI projection is dormant, and resumes it without replaying VT bytes.
+ p.settings.colors = false;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ try testing.expect(pane.tty_filter);
+ try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
+ p.settings.colors = true;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
+ const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?;
+ try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ pane.tty_filter = false;
+ pane.tty_filter = true;
+ try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?));
+ _ = frame.reset(.retain_capacity);
+ const osc_palette = try p.render(frame.allocator());
+ try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
+
+ // Dynamic default foreground/background colours key every default cell,
+ // including the otherwise blank end of the row.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
+ "\x1b]11;#5f5f5f\x1b\\" } });
+ const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?;
+ const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?;
+ try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
+ _ = frame.reset(.retain_capacity);
+ const dynamic = try p.render(frame.allocator());
+ const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
+
+ const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
+ try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
+ const reversed_explicit = reversed.at(tx, body_y).style;
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg);
+ try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg);
+ try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg));
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
+ _ = frame.reset(.retain_capacity);
+ const unreversed = try p.render(frame.allocator());
+ const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
+
+ // The cache is keyed by values, not a theme name. Replacing a custom
+ // theme in place immediately recolours already-rendered indexed cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
+ var custom = p.theme().*;
+ custom.name = try p.gpa.dupe(u8, "same-name");
+ custom.palette = null;
+ custom.kw = .{ 1, 2, 3 };
+ p.custom_theme = custom;
+ _ = frame.reset(.retain_capacity);
+ const custom_first = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
+ if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
+ _ = frame.reset(.retain_capacity);
+ const custom_second = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
+ }
+
+ test "terminal Filter keeps extended keys dark-to-light on a light theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ // Curated order is a public theme contract: helix, dark, acme.
+ p.settings.theme = 2;
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ var cache: FilterPalette = .{};
+ const palette = cache.get(p.theme());
+ try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
+ try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
+ }
+
+ test "terminal Filter preserves exact palette-null light theme default roles" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ var light = p.theme().*;
+ light.name = try p.gpa.dupe(u8, "filter-light-defaults");
+ light.bg = .{ 0xf8, 0xf8, 0xf8 };
+ light.fg = .{ 0x38, 0x38, 0x38 };
+ light.palette = null;
+ p.custom_theme = light;
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }));
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }));
+ pane.tty_filter = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const ordinary = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
+ }
+
+ test "terminal Filter maps the default roles before it maps anything else" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ const stage_one = FilteredColors.init(p, pane);
+ // `dark` declares no background of its own, which is exactly why the
+ // resolver reads the tag colours as the fallback rather than `.?`.
+ const theme = p.theme();
+ try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg)));
+ try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg)));
+ // With no OSC 11 in play the mapped page IS that anchor, and the
+ // fallback is the other one: a background never contrasts with itself.
+ try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg));
+ try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg));
+ }
+
+ p.settings.theme = 0;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } });
+ var moved = FilteredColors.init(p, pane);
+ try testing.expect(!moved.default_bg.eql(moved.theme_bg));
+ try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f })));
+ }
+
+ test "terminal Filter refuses a foreground that would collapse onto the page" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++
+ "\x1b[0;30mB" ++
+ "\x1b[0;31mR" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ const page = asPardesColor(fc.default_bg);
+ const rescued = asPardesColor(fc.fallback_fg);
+ _ = frame.reset(.retain_capacity);
+ const g = try p.render(frame.allocator());
+
+ // The colour each of the three would have been given with no floor.
+ const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 });
+ const raw_black = fc.paletteRgb(0, GColor.default[0]);
+ const raw_red = fc.paletteRgb(1, GColor.default[1]);
+
+ for ([_]struct { at: u16, raw: GColor.RGB }{
+ .{ .at = 0, .raw = raw_white },
+ .{ .at = 1, .raw = raw_black },
+ .{ .at = 2, .raw = raw_red },
+ }) |case| {
+ const cell = g.at(tx + case.at, body_y).style;
+ try testing.expectEqual(page, cell.bg);
+ if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) {
+ // Refused: the projection's answer is not painted, the anchor is.
+ try testing.expectEqual(rescued, cell.fg);
+ try testing.expect(!std.meta.eql(cell.fg, cell.bg));
+ } else {
+ // Cleared the floor, so stage two leaves it exactly alone.
+ try testing.expectEqual(asPardesColor(case.raw), cell.fg);
+ }
+ // Either way a filtered cell delegates neither colour to a backend.
+ switch (cell.fg) {
+ .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >=
+ config.tty_filter_min_contrast),
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ }
+
+ // At least one of the three has to have been a real collapse, or this
+ // theme proved nothing: white on the light theme, black on the dark.
+ try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or
+ raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ // A saturated red is never the page on any curated theme.
+ try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ }
+ }
+
+ test "terminal Filter holds every projected foreground off the page" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ var refused: usize = 0;
+ for (fc.target, 0..) |projected, key| {
+ const ink = fc.legible(projected);
+ try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ if (!ink.eql(projected)) {
+ refused += 1;
+ try testing.expect(ink.eql(fc.fallback_fg));
+ // Only ever refused for being too near the page.
+ try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ }
+ // The key a background asks for is handed back untouched, including
+ // the one whose value is the page itself.
+ try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])]));
+ }
+ // Every curated theme owns at least one collapsing key — that is why
+ // the floor exists — and the floor must not be flattening the palette.
+ try testing.expect(refused > 0);
+ try testing.expect(refused < fc.target.len / 8);
+ }
+ }
+
+ test "tty ansi colors follow the prompt hug into normal mode" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ // tty mode projects the emulator's ansi colours cell for cell.
+ pane.mode = .tty;
+ p.shell_rows.stale = true;
+ const tty = try p.render(frame.allocator());
+ try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg);
+
+ pane.mode = .normal;
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const norm = try p.render(frame.allocator());
+ try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme());
+ try testing.expectEqual(red, norm.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg);
+ }
+
+ test "a prompt row hidden end to end paints nothing at all" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme());
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg));
+ }
+
+ pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+ }
+
+ pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color {
+ return switch (color) {
+ .none => blk: {
+ const t = if (is_bg) p.theme().bg else p.theme().fg;
+ break :blk if (t) |c| .{ .rgb = c } else .default;
+ },
+ .palette => |idx| palColor(p, idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+ }
+
+ /// executing at a prompt with typed text below it: pad the output area
+ /// with newlines so the command's output doesn't overwrite the buffer
+ pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
+ // Nothing to pad away from: with no emulator there is no prompt and no
+ // child whose output could land on top of the edit buffer.
+ if (comptime !enabled) return;
+ const pane = p.panes[id] orelse return;
+ const o = pane.ovl orelse return;
+ if (!pane.isTerminal()) return;
+ const state = pane.terminal orelse return;
+ if (!state.vt.cursorIsAtPrompt()) return;
+ // the buffer's LAST surface row: its lines may outnumber the shell
+ // rows it covers, and it is the bottom one output must clear
+ const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1;
+ const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset);
+ const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y)));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) p.emitWrite(id, "\r");
+ }
+
+ /// the snapshot takes ownership of a COPY of the edit buffer's text
+ pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot {
+ var ovl: ?EditBuffer = null;
+ if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null };
+ return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel };
+ }
+
+ /// undo/redo restores the selection recorded with the snapshot (helix
+ /// keeps selections in its history transactions)
+ pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void {
+ if (pane.ovl) |o| p.gpa.free(o.text);
+ pane.ovl = s.ovl;
+ pane.cur_row = s.cur_row;
+ pane.cur_col = s.cur_col;
+ pane.cur_pinned = true;
+ pane.vsel = s.vsel;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void {
+ if (len.* == slots.len) {
+ if (slots[0].ovl) |overlay| gpa.free(overlay.text);
+ std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = value;
+ len.* += 1;
+ }
+
+ pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ const current = pane.ovl orelse EditBuffer{ .rows = 0 };
+ if (pane.ed_undo_len > 0) {
+ const top = pane.ed_undo[pane.ed_undo_len - 1];
+ const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and
+ overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null;
+ if (same) return;
+ }
+ const value = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value);
+ for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ed_redo_len = 0;
+ }
+
+ pub fn undo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_undo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current);
+ pane.ed_undo_len -= 1;
+ restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
+ }
+
+ pub fn redo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_redo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current);
+ pane.ed_redo_len -= 1;
+ restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
+ }
+
+ pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
+ const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal));
+ const room = state.reply.len - state.reply_len;
+ const n = @min(room, data.len);
+ @memcpy(state.reply[state.reply_len..][0..n], data[0..n]);
+ state.reply_len += @intCast(n);
+ }
+
+ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
+ @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
+ ).optional.child).pointer.child).@"fn".return_type.?;
+ pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+ }
+};
+
+test {
+ _ = Pane;
+ _ = File;
+ _ = Output;
+ _ = Mini;
+ _ = Image;
+ _ = Pdf;
+ _ = Terminal;
+}