From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: 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. --- src/panes.zig | 7681 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 7681 insertions(+) create mode 100644 src/panes.zig (limited to 'src/panes.zig') 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; +} -- cgit v1.3