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 locations = @import("locations.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, source_id: u64 = 0, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; /// A rectangle keeps one source span per rendered row. Wrapped segments /// remain separate rows when copied, even when they share a source line. pub const PointerRow = struct { row: i32, lo: usize, hi: usize, prefix: [32]u8 = @splat(0), prefix_len: u8 = 0, raw_terminal: bool = false, prompt_bytes: usize = 0, }; pub const PointerSelection = struct { gesture: Sel, rows: []PointerRow, raw_text: ?[]u8 = null, }; /// 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, /// A folded pane keeps its expansion weight, but occupies only its tag row. collapsed: bool = false, cols: u16, rows: u16, greet: bool = false, pending_command: Terminal.PendingCommand = .{}, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, 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_saved_col: ?u16 = null, tag_anchor: u16 = 0, /// Filename edits are staged separately from the live buffer identity. /// Enter commits the name; leaving tag editing discards the draft. tag_name: [limits.host_path_cap]u8 = undefined, tag_name_len: ?u16 = null, /// Display columns hidden to reveal the tag caret; tag addresses stay whole. tag_scroll: 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, /// A row map also exists for sticky headers when soft wrapping is off. body_wrapped: bool = false, context_rows: u16 = 0, body_rows: u16 = 0, body_visible_rows: u16 = 0, context_row_limit: ?u16 = null, sel: [3]Sel = @splat(.{}), pointer_selections: [3]?PointerSelection = @splat(null), next_pointer_selection: u64 = 0, /// 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, /// Where the newest message line is in its life. msg_life: MessageLife = .{}, /// The row (among the notice rows) the newest line holds for its life. msg_slot: u8 = 0, /// Earlier message lines still on screen, oldest first, each running its /// own life: a new message stacks under them instead of replacing them. msg_older: [message_stack]OlderMessage = undefined, msg_older_len: u8 = 0, /// The newest line is a builtin announcing itself in this step: what /// the step says next replaces it rather than stacking under it. msg_announcement: bool = false, /// The newest line is a status (progress, a server's state): the next /// line replaces it rather than stacking under it. msg_status: bool = false, /// The transient lines this pane shows between its body and its tagline: /// a builtin's message, the pending leader chord, a prompt waiting for /// input. Rebuilt every frame from the state that owns each one, so a /// line that goes away is simply not added again and the rest close the /// gap -- there is no second copy of the truth to fall out of step. The /// body layer reserves exactly `len` rows for them, the way it already /// reserves rows for sticky context headers. notices: Notices = .{}, pub const MessagePhase = enum { entering, shown, lingering, leaving }; /// One message line's animation: falling in, shown, lingering after the /// key or click that dismissed it, dissolving. `frame` counts animation /// frames within the phase. pub const MessageLife = struct { phase: MessagePhase = .shown, frame: u16 = 0, /// Dismissed while still falling: it lingers once it has landed. dismissed: bool = false, }; /// Older message lines kept on screen under a new one. pub const message_stack = 3; pub const OlderMessage = struct { text: [256]u8 = undefined, len: u16 = 0, life: MessageLife = .{}, slot: u8 = 0, pub fn slice(line: *const OlderMessage) []const u8 { return line.text[0..line.len]; } }; /// Move the newest line onto the stack of older ones, dropping the oldest /// when it is full; the newest slot is left empty for what arrives. pub fn pushOlderMessage(pane: *Pane) void { if (pane.msg_older_len == message_stack) { std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]); pane.msg_older_len -= 1; } pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot }; pane.msg_older_len += 1; pane.msg_len = 0; pane.msg_life = .{}; } /// The first row no older line holds. A line keeps its row until it /// leaves, and a new one fills the first gap: nothing on screen moves. pub fn freeMessageSlot(pane: *const Pane) u8 { var used: u8 = 0; for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot); var slot: u8 = 0; while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1; return slot; } pub fn removeOlderMessage(pane: *Pane, i: usize) void { std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]); pane.msg_older_len -= 1; } pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest /// the tagline that it has always had, and its cursor with it. /// `older` is an earlier message line still on screen; `index` says /// which (`Pane.msg_older`). pub const Kind = enum { older, message, leader, prompt }; pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack; kinds: [max]Kind = undefined, index: [max]u8 = @splat(0), /// Each band's row below the body's top: message lines keep theirs, /// so a band that leaves leaves a gap rather than moving the rest. row: [max]u8 = @splat(0), /// Where each band's chip starts, as an absolute grid column. A chip /// is only as wide as its own text, so the rest of the row underneath /// is ordinary body text and a click there has to reach it. left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, /// A band on the next row after every band so far. pub fn push(n: *Notices, kind: Kind) void { n.pushAt(kind, 0, n.span()); } pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void { if (n.len >= max) return; n.kinds[n.len] = kind; n.index[n.len] = index; n.row[n.len] = row; n.left[n.len] = std.math.maxInt(u16); n.len += 1; } /// Is this cell inside the chip drawn for band `i`? Nothing else on /// that row belongs to the notice. pub fn covers(n: *const Notices, row: usize, col: u16) bool { for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i]; return false; } /// Rows from the body's top down to the last band's. pub fn span(n: *const Notices) u8 { var rows: u8 = 0; for (n.row[0..n.len]) |row| rows = @max(rows, row + 1); return rows; } pub fn slice(n: *const Notices) []const Kind { return n.kinds[0..n.len]; } }; pub fn clearPointerSelection(pane: *Pane, slot: usize) void { if (pane.pointer_selections[slot]) |selection| { pane.gpa.free(selection.rows); if (selection.raw_text) |text| pane.gpa.free(text); } pane.pointer_selections[slot] = null; } pub fn rawPointerText(pane: *const Pane, gesture: Sel) ?[]const u8 { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.raw_text; } return null; } pub fn pointerSelection(pane: *const Pane, gesture: Sel) ?[]PointerRow { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.rows; } return null; } 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 (f.mini != null) return .source; if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; if (f.output != null) { const tr = Output.fileTraits(f.output); if (tr.locations) return .locations; if (!tr.saves) return .none; } 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 { const off = pane.scroll(); var in_context = false; // Maps survive until the next render. A jump may have changed scroll // already, so only treat an ancestor as pinned in the matching view. if (pane.context_rows < pane.wrap_n and pane.wrap_line[pane.context_rows] == off) { for (pane.wrap_line[0..pane.context_rows]) |row| { if (row == pane.cur_row) { in_context = true; break; } } } // Scrolloff is measured in the ordinary body, excluding sticky rows. var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); var last = off + @as(i32, pane.rows) - 1; const visible_rows = if (pane.body_visible_rows > 0) @min(pane.wrap_n, pane.body_visible_rows) else pane.wrap_n; if (pane.context_rows < visible_rows) { const lines_shown = pane.wrap_line[visible_rows - 1] - pane.wrap_line[pane.context_rows]; last = off + lines_shown; margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); } if (!in_context) { if (pane.cur_row < off + margin) { pane.scrollBy(pane.cur_row - margin - off); } else if (pane.cur_row > last - margin) { 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.body_wrapped) 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, /// Seconds of the last content change, for the filesystem's stat; zero until edited. mtime: u32 = 0, /// 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 = &.{}, location_rows: []locations.Row = &.{}, highlight_start: usize = 0, syntax_dirty: bool = true, tree_context: bool = false, context_declarations: []syntax.ContextDeclaration = &.{}, context_revision: ?u32 = null, 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, .collapsed = pane.collapsed, .file = .{ .tree_context = file.tree_context, .location_rows = try dumpLocationRows(arena, file.location_rows), .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 "", }, }; } fn dumpLocationRows(arena: std.mem.Allocator, rows: []const locations.Row) ![]const dump.LocationRow { const saved = try arena.alloc(dump.LocationRow, rows.len); for (rows, saved) |row, *out| out.* = .{ .kind = @enumFromInt(@intFromEnum(row.kind)), .declaration = row.declaration, .path = row.path, .line = row.at.line, .col = row.at.col, .end_line = row.at.end_line, .end_col = row.at.end_col, .location_end = row.location_end, .code_start = row.code_start, .colors_b64 = try dump.encodeBytes(arena, row.colors), }; return saved; } fn sameSavedLocation(a: dump.LocationRow, b: dump.LocationRow) bool { return std.mem.eql(u8, a.path, b.path) and a.line == b.line and a.col == b.col and a.end_line == b.end_line and a.end_col == b.end_col; } fn restoreLocationRows(gpa: std.mem.Allocator, content: []const u8, saved: []const dump.LocationRow) ![]locations.Row { if (saved.len == 0) return &.{}; if (saved.len > std.mem.count(u8, content, "\n") + 1) return error.InvalidLocationRows; const rows = try gpa.alloc(locations.Row, saved.len); var initialized: usize = 0; errdefer { for (rows[0..initialized]) |row| { gpa.free(row.path); gpa.free(row.colors); } gpa.free(rows); } var lines = std.mem.splitScalar(u8, content, '\n'); var previous_line: []const u8 = ""; for (saved, rows, 0..) |row, *out, index| { const line = lines.next() orelse return error.InvalidLocationRows; if (row.path.len == 0 or row.path.len >= 4096 or row.location_end > row.code_start or row.code_start > line.len or (row.kind == .match and row.location_end == 0) or (row.declaration and row.kind != .context) or row.line > std.math.maxInt(i32) or row.col > std.math.maxInt(i32) or row.end_line > std.math.maxInt(i32) or row.end_col > std.math.maxInt(i32)) return error.InvalidLocationRows; const inline_row = row.code_start > row.location_end and row.code_start > 0 and line[row.code_start - 1] == '\t'; const address_row = !inline_row and row.location_end > 0 and row.code_start == line.len; const source_row = row.location_end == 0 and row.code_start == 0; if (!inline_row and !address_row and !source_row) return error.InvalidLocationRows; if (row.kind == .preview) { if (!source_row or index == 0) return error.InvalidLocationRows; const previous = saved[index - 1]; if (previous.kind != .match or previous.location_end == 0 or previous.code_start != previous_line.len or previous_line[previous_line.len - 1] == '\t' or !sameSavedLocation(previous, row)) return error.InvalidLocationRows; } if (address_row) { if (index + 1 >= saved.len) return error.InvalidLocationRows; const following = saved[index + 1]; const expected: @TypeOf(row.kind) = if (row.kind == .match) .preview else .context; if (following.kind != expected or following.code_start != 0 or following.location_end != 0 or following.declaration != row.declaration or !sameSavedLocation(row, following)) return error.InvalidLocationRows; } const code_len = line.len - row.code_start; if (row.colors_b64.len > std.base64.standard.Encoder.calcSize(code_len)) return error.InvalidLocationRows; const colors = try dump.decodeBytes(gpa, row.colors_b64); errdefer gpa.free(colors); if (colors.len != 0 and colors.len != code_len) return error.InvalidLocationRows; for (colors) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidLocationRows; const path = try gpa.dupe(u8, row.path); out.* = .{ .kind = @enumFromInt(@intFromEnum(row.kind)), .declaration = row.declaration, .path = path, .at = .{ .line = row.line, .col = row.col, .end_line = row.end_line, .end_col = row.end_col }, .location_end = row.location_end, .code_start = row.code_start, .colors = colors, }; initialized += 1; previous_line = line; } if (lines.next()) |remaining| if (remaining.len != 0 or lines.next() != null) return error.InvalidLocationRows; return rows; } test "location metadata restore rejects offsets and invalid syntax bytes" { const gpa = std.testing.allocator; var row: dump.LocationRow = .{ .kind = .context, .path = "x.zig", .line = 1, .location_end = 0, .code_start = 3, }; const rows = try restoreLocationRows(gpa, "| \tx\n", &.{row}); defer locations.freeRows(gpa, rows); try std.testing.expectEqualStrings("x.zig", rows[0].path); row.code_start = 8; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); row.code_start = 3; row.colors_b64 = "/w=="; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); row.colors_b64 = ""; row.kind = .match; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); } test "stacked location metadata requires matching adjacent preview ownership" { const gpa = std.testing.allocator; const header = "x.c:2:1-3"; const content = header ++ "\nfoo\n"; const match: dump.LocationRow = .{ .kind = .match, .path = "x.c", .line = 2, .col = 1, .end_line = 2, .end_col = 3, .location_end = header.len, .code_start = header.len }; const preview: dump.LocationRow = .{ .kind = .preview, .path = "x.c", .line = 2, .col = 1, .end_line = 2, .end_col = 3, .location_end = 0, .code_start = 0 }; const restored = try restoreLocationRows(gpa, content, &.{ match, preview }); defer locations.freeRows(gpa, restored); try std.testing.expectEqual(.preview, restored[1].kind); try std.testing.expectEqual(restored[0].at, restored[1].at); try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "foo\n", &.{preview})); try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, header ++ "\n", &.{match})); var inline_match = match; inline_match.code_start += 1; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, header ++ "\t\nfoo\n", &.{ inline_match, preview })); var wrong = preview; wrong.path = "other.c"; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); wrong = preview; wrong.line = 3; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); wrong = preview; wrong.code_start = 1; try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); } 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)); // An edit carries a built index forward; it must equal a fresh one. if (file.line_starts.len > 0) { const carried = file.line_starts; defer p.gpa.free(carried); file.line_starts = &.{}; try std.testing.expectEqualSlices(usize, try lineIndex(p.gpa, file), carried); p.gpa.free(file.line_starts); file.line_starts = &.{}; } 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; } /// The line index of `new` from `old`'s: an edit replaces one span, so /// line starts before it stand, starts after it shift, and only the span /// is scanned. Rebuilding scanned the whole file on every keystroke. fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { const n = @min(old.len, new.len); var head: usize = 0; while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; while (head < n and old[head] == new[head]) head += 1; var tail: usize = 0; while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; // A start s follows the newline at s-1: kept while that newline is in // the common head, shifted while it is in the common tail. const kept = std.sort.upperBound(usize, starts, head, struct { fn order(key: usize, item: usize) std.math.Order { return std.math.order(key, item); } }.order); const old_tail_at = old.len - tail; const moved = starts.len - std.sort.upperBound(usize, starts, old_tail_at, struct { fn order(key: usize, item: usize) std.math.Order { return std.math.order(key, item); } }.order); const middle = new[head .. new.len - tail]; const out = try gpa.alloc(usize, kept + std.mem.count(u8, middle, "\n") + moved); @memcpy(out[0..kept], starts[0..kept]); var i = kept; var off: usize = 0; while (std.mem.indexOfScalarPos(u8, middle, off, '\n')) |nl| { off = nl + 1; out[i] = head + off; i += 1; } for (starts[starts.len - moved ..]) |start| { out[i] = start + new.len - old.len; i += 1; } return out; } test "edited line index equals a rebuilt one" { const gpa = std.testing.allocator; var prng = std.Random.DefaultPrng.init(7); const r = prng.random(); var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); for (0..300) |_| try text.append(gpa, "ab\n"[r.uintLessThan(usize, 3)]); for (0..2000) |_| { var old_state: State = undefined; old_state.content = text.items; old_state.line_starts = &.{}; const starts = try lineIndex(gpa, &old_state); defer gpa.free(starts); // one random replace, like any edit through setContent const at = r.uintAtMost(usize, text.items.len); const del = r.uintAtMost(usize, @min(8, text.items.len - at)); var ins: [8]u8 = undefined; const ins_len = r.uintAtMost(usize, 8); for (ins[0..ins_len]) |*c| c.* = "ab\n"[r.uintLessThan(usize, 3)]; const old = try gpa.dupe(u8, text.items); defer gpa.free(old); try text.replaceRange(gpa, at, del, ins[0..ins_len]); const edited = try editedLineStarts(gpa, starts, old, text.items); defer gpa.free(edited); var fresh_state: State = undefined; fresh_state.content = text.items; fresh_state.line_starts = &.{}; const fresh = try lineIndex(gpa, &fresh_state); defer gpa.free(fresh); try std.testing.expectEqualSlices(usize, fresh, edited); } } /// 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; if (saved.location_rows.len != 0 and output == null) return error.InvalidLocationRows; const location_rows = try restoreLocationRows(p.gpa, content, saved.location_rows); errdefer locations.freeRows(p.gpa, location_rows); 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, .location_rows = location_rows, .scroll = src.scroll, .revision = @intFromBool(saved.dirty), .history = history, }; pane.file.?.tree_context = saved.tree_context and supportsContext(pane); 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); locations.freeRows(p.gpa, file.location_rows); if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations); 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.ctlfs.events.noteReplace(p, id, false, f.content, new); } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { locations.freeRows(p.gpa, f.location_rows); f.location_rows = &.{}; for (p.panes) |slot| if (slot) |pane| { if (pane.file) |*file| if (file == f) { for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; } }; }; reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; p.gpa.free(f.content); f.content = new; f.revision +%= 1; f.mtime = pardes.ctlfs.events.now(); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); f.line_starts = 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.tree_context and supportsContext(pane) and f.context_revision != f.revision) { const declarations = syntax.contextDeclarations(p.tree_sitter_gpa, f.path, f.content) catch continue; if (f.context_declarations.len > 0) p.tree_sitter_gpa.free(f.context_declarations); f.context_declarations = declarations; f.context_revision = f.revision; } 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.tree_context or (f.highlight_start == 0 and f.highlights.len == f.content.len)) and 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 or f.tree_context; 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()) { .none => @as([]u8, &.{}), .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), .locations => if (f.location_rows.len > 0) syntax.highlightLocationRows(p.tree_sitter_gpa, f.content, f.location_rows) else 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 supportsContext(pane: *const Pane) bool { const file = pane.file orelse return false; return file.output == null and file.mini == null and syntax.supportsPath(file.path); } fn prepareContextRows(pane: *Pane, file: *State) void { pane.context_rows = 0; if (!file.tree_context or !supportsContext(pane) or pane.rows > pane.wrap_line.len) return; const capacity = @min(pane.rows -| 1, pane.context_row_limit orelse std.math.maxInt(u16)); // retain an ordinary body row for (file.context_declarations) |declaration| { if (declaration.start_line >= file.scroll) break; if (declaration.end_line < file.scroll) continue; var row = declaration.start_line; while (row <= declaration.header_end_line and row < file.scroll) : (row += 1) { if (pane.context_rows >= capacity) return; if (pane.context_rows > 0 and row <= pane.wrap_line[pane.context_rows - 1]) continue; pane.wrap_line[pane.context_rows] = @intCast(row); pane.context_rows += 1; } } } 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.body_wrapped = width > 0; prepareContextRows(pane, f); 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); const total = nlines(pane.gpa, f); var body_line: usize = f.scroll; var body_at: usize = 0; var written: usize = 0; for (0..if (pane.body_rows > 0) pane.body_rows else pane.rows) |i| { if (i > 0) { if (dst) |out| out[written] = '\n'; written += 1; } const context = i < pane.context_rows; const row = if (context) @as(usize, @intCast(pane.wrap_line[i])) else body_line; const at = if (context) 0 else body_at; const text = if (row < total) sourceLine(pane, @intCast(row)) else ""; // Headers use one screen row per source row, preserving signatures // without allowing a long ancestor to consume the whole viewport. const end = if (width == 0 or context) text.len else fitEnd(text, at, width); const cut = if (pane.hscroll > 0 and width == 0) modal.graphemeStart(text, @min(@as(usize, @intCast(pane.hscroll)), text.len)) else at; if ((width > 0 or pane.context_rows > 0) and record_wrap) { pane.wrap_line[i] = @intCast(row); pane.wrap_col[i] = @intCast(cut); pane.wrap_n = @intCast(i + 1); } if (row < total) { var lbuf: [@max(config.PREFIX_W, 32)]u8 = undefined; const prefix = lbuf[0..prefix_width]; @memset(prefix, ' '); if (at == 0) { var lineno = row + 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 shown = text[cut..end]; if (dst) |out| @memcpy(out[written..][0..shown.len], shown); written += shown.len; } if (!context) { if (width > 0 and end < text.len) { body_at = end; } else { body_line += 1; body_at = 0; } } } 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 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 = p.bodyTop(r); const context_bg = p.theme().tag_bg; for (0..@min(pane.context_rows, body_h)) |context_row| { for (0..tw) |col| { const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(context_row))); cell.default = false; cell.style.bg = .{ .rgb = context_bg }; if (p.settings.tree_context_tag_style) cell.style.font_role = .tagline; } } 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 cell.style.fg = .{ .rgb = if (on_cursor) p.theme().lineno_active orelse p.theme().lineno else p.theme().lineno }; cell.style.bold = on_cursor; } } } 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 = p.settings.syntax_bold }, .string => .{ .fg = p.theme().str, .bold = false }, .number => .{ .fg = p.theme().num, .bold = false }, .comment => .{ .fg = p.theme().comment, .bold = false }, }; } /// 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 and f.output == null) 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 = p.bodyTop(r); 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)); const row_index: usize = @intCast(@max(0, pane.wrapAt(vr).line)); const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; const decoration = Output.decorateRow(f.output, line, metadata); 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; } } } } // Results keep their exact live text. Only the location, // matched source range and diagnostic label gain emphasis. if (decoration.styleAt(p, hs + c)) |style| { 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 = style.fg; cell.style.bold = style.bold; if (style.bg != .default) cell.style.bg = style.bg; } } 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 = p.bodyTop(r); 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 = p.bodyTop(r); 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, pdf_links, }; 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]; } }; const RowDecoration = struct { location_end: usize = 0, prefix_end: usize = 0, declaration: bool = false, mark_start: usize = 0, mark_end: usize = 0, severity: enum { none, err, warning, info, hint } = .none, fn styleAt(row: RowDecoration, p: *const Pardes, byte: usize) ?pardes.CellStyle { // Body paper changes immediately, so its ink must change with it // even while the surrounding tags are still fading. const chrome = pardes.ChromeTheme.fromTheme(p.theme()); if (row.declaration or byte < @max(row.location_end, row.prefix_end)) return .{ .fg = .{ .rgb = chrome.lineno } }; if (byte < row.mark_start or byte >= row.mark_end) return null; if (row.severity == .none) return .{ .fg = .{ .rgb = chrome.search_fg }, .bg = .{ .rgb = chrome.search_bg }, }; return .{ .fg = .{ .rgb = switch (row.severity) { .err => chrome.diagnostic_error, .warning => chrome.diagnostic_warning, .info => chrome.diagnostic_info, .hint => chrome.diagnostic_hint, .none => unreachable, } }, .bold = true }; } }; fn rowDecoration(output: ?State, line: []const u8) RowDecoration { return decorateRow(output, line, null); } fn decorateRow(output: ?State, line: []const u8, metadata: ?locations.Row) RowDecoration { const origin = (output orelse return .{}).from; if (!traits(origin).locations) return .{}; const target = locations.parse(line); const spot = if (metadata) |m| m.at else target.at; if (spot.line == 0) return .{}; var row: RowDecoration = .{ .location_end = if (metadata) |m| m.location_end else target.end, .prefix_end = if (metadata) |m| m.code_start else target.end, .declaration = if (metadata) |m| m.declaration else false, }; const source_start = if (metadata) |m| m.code_start else target.code_start; if (source_start >= line.len) return row; if (metadata) |m| if (m.kind == .context) return row; const mark_occurrence = switch (origin) { .search => true, .cmd => |cmd| cmd == .Grep, .query => |kind| switch (kind) { .definition, .declaration, .type_definition, .implementation, .references => true, else => false, }, else => false, }; if (mark_occurrence and spot.col > 0 and spot.end_line == spot.line and spot.end_col >= spot.col) { row.mark_start = source_start +| (spot.col - 1); row.mark_end = @min(line.len, source_start +| spot.end_col); } const is_diagnostic = switch (origin) { .query => |kind| kind == .diagnostics or kind == .workspace_diagnostics, else => false, }; if (is_diagnostic) { const text = line[source_start..]; inline for (.{ .{ "error:", .err }, .{ "warning:", .warning }, .{ "info:", .info }, .{ "hint:", .hint } }) |label| { if (std.mem.startsWith(u8, text, label[0])) { row.mark_start = source_start; row.mark_end = source_start + label[0].len; row.severity = label[1]; } } } return row; } test "result decoration follows source byte ranges and diagnostic provenance" { const row = rowDecoration(.{ .from = .search }, "src/a.rs:3:5-7 let abc = 1;"); try std.testing.expectEqualStrings("abc", "src/a.rs:3:5-7 let abc = 1;"[row.mark_start..row.mark_end]); const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqual(@as(usize, 0), prose.mark_end); const diagnostic = rowDecoration(.{ .from = .{ .query = .diagnostics } }, "src/a.rs:3:5-7 warning: unused"); try std.testing.expectEqual(.warning, diagnostic.severity); try std.testing.expectEqualStrings("warning:", "src/a.rs:3:5-7 warning: unused"[diagnostic.mark_start..diagnostic.mark_end]); const malformed = rowDecoration(.{ .from = .search }, "src/a.rs:3:99999999999999999999-99999999999999999999 text"); try std.testing.expect(malformed.mark_end <= "src/a.rs:3:99999999999999999999-99999999999999999999 text".len); const snippet = "src/a.rs:3:14-16 \t\tfoo + 界 + foo"; for ([_]lsp.Kind{ .definition, .declaration, .type_definition, .implementation, .references }) |kind| { const occurrence = rowDecoration(.{ .from = .{ .query = kind } }, snippet); try std.testing.expectEqualStrings("foo", snippet[occurrence.mark_start..occurrence.mark_end]); } for ([_]lsp.Kind{ .document_symbols, .workspace_symbols, .completion, .incoming_calls, .outgoing_calls, .supertypes, .subtypes, .rename }) |kind| { const label = rowDecoration(.{ .from = .{ .query = kind } }, snippet); try std.testing.expectEqual(@as(usize, 0), label.mark_end); } } test "stacked empty previews distinguish separators from trailing newlines" { var path = [_]u8{ 'x', '.', 'c' }; const rows = [_]locations.Row{ .{ .kind = .match, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 7, .code_start = 7 }, .{ .kind = .preview, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 0, .code_start = 0 }, }; try std.testing.expect(locations.sameInput("x.c:1:1 ", "x.c:1:1\n", &rows, .stacked)); try std.testing.expect(locations.sameInput("x.c:1:1 \n", "x.c:1:1\n\n", &rows, .stacked)); try std.testing.expect(!locations.sameInput("x.c:1:1 ", "x.c:1:1\n\n", &rows, .stacked)); try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked)); } test "plain output does not opt into location decoration" { const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on"); try std.testing.expectEqualDeep(RowDecoration{}, report); const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqualDeep(RowDecoration{}, prose); const errors = rowDecoration(.{ .from = .errors }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqualDeep(RowDecoration{}, errors); try std.testing.expect(!traits(.{ .cmd = .Find }).locations); try std.testing.expect(traits(.{ .cmd = .Grep }).locations); try std.testing.expect(traits(.{ .query = .completion }).locations); } 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, .locations = true }, .errors => .{ .name = config.errors_buffer, .doc = true }, .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = 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, .locations = true }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, .locations = 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, .locations = true, .jumps = true, }, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ .name = config.search_buffer, .steps = true, .locations = true, }, }, }; } pub fn usesLocationsConfig(from: Origin) bool { const tr = traits(from); return tr.steps and !tr.commands and from != .pdf_links and !std.meta.eql(from, Origin{ .cmd = .Find }) and !std.meta.eql(from, Origin{ .query = .completion }); } 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, .pdf_links => config.pdf_links_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.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; 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 target = locations.parse(std.mem.trimEnd(u8, line, " \t\r\n")); return .{ .path = target.path, .at = target.at, .end = target.end }; } pub fn isResultRow(file: *const File.State, row: usize) bool { if (row < file.location_rows.len) return file.location_rows[row].kind == .match; return !locations.isContextLine(modal.lineSlice(file.content, row)); } 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, .pdf_links => 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, rows: []const locations.Row, 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) { if (row < rows.len) { if (rows[row].kind != .match) continue; } else if (locations.isContextLine(line)) continue; 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 { var result_content = content; errdefer p.gpa.free(result_content); var result_rows: []locations.Row = &.{}; errdefer locations.freeRows(p.gpa, result_rows); const pane = p.panes[id] orelse return error.MissingPane; if (arg.len > max_arg) return error.ArgumentTooLong; var anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); const by_arg = std.meta.activeTag(from) != .query; if (p.locations_config.context == 0 and !p.locations_config.tscontext) { 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 output = if (rf.output) |*o| o else continue; if (!std.meta.eql(output.from, from)) continue; if (by_arg and !std.mem.eql(u8, output.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; if (!locations.sameInput(content, rf.content, rf.location_rows, p.locations_config.layout)) continue; try setArg(output, arg); p.gpa.free(content); p.active = id; if (traits(from).steps) { pane.search_pane = i; pane.search_row = anchor; p.armLookWalk(i); } return; } } if (usesLocationsConfig(from)) { const formatted = try locations.format(p, dir, content, anchor, true); p.gpa.free(result_content); result_content = formatted.content; result_rows = formatted.rows; anchor = formatted.anchor; } 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, result_content)) { p.gpa.free(result_content); } else { File.setContent(p, rf, result_content); resetBody(p, rp); } if (locations.equalRows(rf.location_rows, result_rows)) { locations.freeRows(p.gpa, result_rows); } else { locations.freeRows(p.gpa, rf.location_rows); rf.location_rows = result_rows; if (rf.highlights.len > 0) p.tree_sitter_gpa.free(rf.highlights); rf.highlights = &.{}; rf.highlight_start = 0; rf.syntax_dirty = true; } 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, result_content); np.file.?.location_rows = result_rows; 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(); try out.writer.writeAll("# Pardes themes\n"); for (pardes.themes, 0..) |t, i| { if (i == pardes.native_theme_count) try out.writer.writeAll("\n# Legacy and imported themes\n"); 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 isWalkRow(file: *const File.State, row: usize, line: []const u8) bool { const state = file.output orelse return true; if (usesLocationsConfig(state.from)) return isResultRow(file, row); if (!std.meta.eql(state.from, Origin{ .cmd = .ThemeSel })) return true; // Section labels are for reading, not stops in the command walk. return std.mem.startsWith(u8, std.mem.trimStart(u8, line, " \t"), comptime config.Runtime.findAction(.theme).?.word ++ " "); } 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(); var dir_buf: [1024]u8 = undefined; const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4); try config.Runtime.writeReport(&out.writer, .{ .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "", .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; // Without -Dembed-sources the paths would name nothing this build can open. if (!limits.embedded_sources) 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. /// Without them the file is read here, not at the first draw: a draw /// must not go out into the host (`pardes.turn`), and the path may be a /// mount this editor serves. A file that cannot be read draws blank. pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { var bytes = raw; var read_here = false; if (bytes.len == 0) { // The slot stays ours across the read: another request may // make a pane meanwhile. p.reserved_slots[id] = true; defer p.reserved_slots[id] = false; if (filesystem.read(p, path)) |from_disk| { defer p.gpa.free(from_disk); bytes = try p.image_gpa.dupe(u8, from_disk); read_here = true; } else |_| {} } errdefer if (read_here) p.image_gpa.free(bytes); 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 = bytes }; 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, .collapsed = pane.collapsed, .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; if (state.raw.len == 0) return; // nothing was readable at open if (image.decode(p.image_gpa, state.raw)) |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, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) { const start = path.len + 2; const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null; var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/'); _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; if (parts.next() != null) return null; return saved_tag[0 .. end + 1]; } 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 { const Page = struct { page: usize, items: []const Highlight }; pages: [3]Page = undefined, len: usize = 0, /// Repaint whenever the set changes, not only when a page's raster was /// invalidated (`HighlightInput.live`). live: bool = false, pub fn forPage(highlights: @This(), page: usize) []const Highlight { for (highlights.pages[0..highlights.len]) |group| if (group.page == page) return group.items; return &.{}; } } else void; pub const HighlightInput = if (enabled) struct { hover_quads: []const Quad = &.{}, hover_page: ?usize = null, hover_color: [3]u8, selection_color: [3]u8, live: bool = false, } 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 items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len); var result: Highlights = .{ .live = input.live }; var n: usize = 0; // Hover, search and selection can belong to three different pages. // Group them once so raster rendering can borrow a contiguous slice. for ([_]?usize{ input.hover_page, state.page, state.selection_page }) |candidate| { const page = candidate orelse continue; var seen = false; for (result.pages[0..result.len]) |group| { if (group.page == page) seen = true; } if (seen) continue; const start = n; if (input.hover_page == page) for (input.hover_quads) |quad| { items[n] = pdf.Highlight.init(quad, .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c, }, .custom); n += 1; }; if (state.page == page) if (state.search_results) |results| { for (results.quads) |item| { items[n] = pdf.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search); n += 1; } }; if (state.selection_page == page) if (state.selection) |selection| { for (selection.quads) |quad| { items[n] = pdf.Highlight.init(quad, .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78, }, .selection); n += 1; } }; result.pages[result.len] = .{ .page = page, .items = items[start..n] }; result.len += 1; } return result; } test "PDF selections retain their owning page across page focus" { if (comptime !enabled) return; const gpa = std.testing.allocator; var state = try State.open(gpa, "docs/design.pdf", 1); defer state.deinit(gpa); try std.testing.expect(state.page_count > 1); var found = try state.document.search(gpa, 0, "Pardes"); defer found.deinit(gpa); const quad = found.quads[0].quad; const point: Point = .{ .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, }; try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, true)); const selected = try gpa.dupe(u8, state.selection_text); defer gpa.free(selected); try std.testing.expect(selected.len > 0); try std.testing.expect(state.focusLocation(gpa, 2, 0)); try std.testing.expectEqual(@as(?usize, 0), state.selection_page); try std.testing.expectEqualStrings(selected, state.selection_text); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const highlights = try buildHighlights(&state, arena.allocator(), .{ .hover_quads = &.{quad}, .hover_page = 1, .hover_color = .{ 1, 2, 3 }, .selection_color = .{ 4, 5, 6 }, }); try std.testing.expectEqual(state.selection.?.quads.len, highlights.forPage(0).len); try std.testing.expectEqual(@as(usize, 1), highlights.forPage(1).len); try std.testing.expectEqual(@as(usize, 0), highlights.forPage(2).len); try std.testing.expect(state.focusLocation(gpa, 1, 0)); try std.testing.expectEqualStrings(selected, state.selection_text); try std.testing.expectEqual(.unchanged, state.setSelection(gpa, point, point, true)); try std.testing.expect(state.focusLocation(gpa, 2, 0)); state.rasters[0] = .{ .valid = true, .page = 0, .tried = true, .decorated = true }; state.rasters_len = 1; try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, false)); try std.testing.expectEqual(@as(?usize, 1), state.selection_page); try std.testing.expect(!state.rasters[0].tried); state.clearSelection(gpa); try std.testing.expect(state.selection_page == null); try std.testing.expectEqual(@as(usize, 0), state.selection_text.len); } 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, /// `rgba` before its highlights and tint, kept once the page has been /// highlighted: a highlight change repaints rows from it instead of /// rendering the page again. clean: []u8 = &.{}, /// The highlights baked into `rgba` (gpa-owned), so a change repaints /// only the rows of the quads that differ. baked: []Highlight = &.{}, /// `baked` is a true record (false after a render could not copy it). baked_valid: bool = false, /// Page rows changed since revision `patch_from`, the one last handed /// to a shell; 0 when the pixels were replaced wholesale since. patch_from: u32 = 0, patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 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_page: ?usize = 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, /// Read whole and opened from memory (the bridge copies), never from /// the file itself: MuPDF reads a file lazily at every page, and the /// path may be a mount this editor serves, which only a read that /// gives the turn up can come back from (`filesystem.readFile`). pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() { const local = filesystem.localPath(path) orelse return error.NonLocalPath; const bytes = try filesystem.readFile(gpa, local); defer gpa.free(bytes); return initDocument(gpa, path, try Document.openBytes(bytes), 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 { if (raster.clean.len > 0) gpa.free(raster.clean); gpa.free(raster.baked); 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; state.selection_page = 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_page == state.page 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; }; const old_page = state.selection_page; state.dropSelection(gpa); state.selection = selection; state.selection_page = state.page; state.selection_text = text; state.selection_anchor = start; state.selection_head = end; if (old_page) |page| if (page != state.page) state.invalidateRaster(page); 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 page = state.selection_page; state.dropSelection(gpa); if (page) |selected_page| state.invalidateRaster(selected_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.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); if (raster.clean.len > 0) gpa.free(raster.clean); gpa.free(raster.baked); } 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 and state.selection_page == 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); const decorated = page_highlights.len > 0; const base_stale = !slot.request_valid or !slot.request.eql(request) or slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or slot.rgba.len == 0 or slot.band_h == 0; const uncovered = !base_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 (!base_stale and !uncovered) { if (slot.tried and slot.decorated == decorated and !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue; // Invalidated, but it already shows exactly these highlights // (a preview withdrawn that was never drawn, on every pointer // move over a page): nothing to render. if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) { slot.tried = true; continue; } // Only the highlights moved: repaint their rows in place. if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) 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(); // A highlighted page keeps its clean rows for repaints. const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); state.document.renderIntoAtWithPaper( page, request, shape, want, if (keep_clean) &.{} else page_highlights, fresh, pardes.platform == .macos, ) catch break :filled false; if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) { gpa.free(slot.clean); slot.clean = &.{}; } if (keep_clean) { if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{}; if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh); state.document.paintHighlightsAt(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); // `clean` may already hold the band that failed. if (slot.clean.len > 0) gpa.free(slot.clean); slot.clean = &.{}; 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; if (gpa.dupe(Highlight, page_highlights)) |copy| { gpa.free(slot.baked); slot.baked = copy; slot.baked_valid = true; } else |_| { // Unrecorded highlights cannot be compared or diffed. slot.baked_valid = false; if (slot.clean.len > 0) gpa.free(slot.clean); slot.clean = &.{}; } slot.patch_from = 0; slot.patch_rows = .{ .start = 0, .end = 0 }; 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); } /// Repaint only the rows where the old and new highlights differ: the /// clean rows kept from the page's last render, MuPDF's highlight pass /// (the whole new set) over them, the per-pixel tint — the operations a /// full render performs, in its order, on fewer rows and without running /// the page again. False leaves the slot for the full path. fn repaintHighlights( state: *State, gpa: std.mem.Allocator, slot: *Raster, request: RenderRequest, tint_key: TintKey, page_highlights: []const Highlight, ) bool { const tz = tracy.zone(@src(), "pdf.repaint_highlights"); defer tz.end(); if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false; if (sameHighlights(slot.baked, page_highlights)) { slot.tried = true; return true; } const baked = gpa.dupe(Highlight, page_highlights) catch return false; const changed = changedHighlights(gpa, slot.baked, page_highlights) catch { gpa.free(baked); return false; }; defer gpa.free(changed); var dirty = state.document.highlightRows(slot.page, request, changed) catch { gpa.free(baked); return false; }; slot.tried = true; dirty.start = @max(dirty.start, slot.band_y); dirty.end = @min(dirty.end, slot.band_y + slot.band_h); if (dirty.start < dirty.end) { const shape = slotShape(slot); const band = shape.band(dirty.start, dirty.end - dirty.start); const offset = (dirty.start - slot.band_y) * shape.stride; const out = slot.rgba[offset..][0..band.len]; @memcpy(out, slot.clean[offset..][0..band.len]); // On failure the rows are half-made: let the full path redo them. state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch { gpa.free(baked); slot.tried = false; return false; }; pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch { gpa.free(baked); slot.tried = false; return false; }; slot.patch_rows = unionRows(slot.patch_rows, dirty); state.next_raster_revision +%= 1; if (state.next_raster_revision == 0) state.next_raster_revision = 1; slot.revision = state.next_raster_revision; } gpa.free(slot.baked); slot.baked = baked; slot.decorated = page_highlights.len > 0; return true; } fn sameHighlights(a: []const Highlight, b: []const Highlight) bool { return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b)); } /// The highlights in one set and not the other, as multisets (a quad /// painted twice blends twice). When only the order changed, blending /// order did, so everything counts. // ponytail: quadratic match; past 64k comparisons every quad counts. fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight { const out = try gpa.alloc(Highlight, old.len + new.len); errdefer gpa.free(out); var n: usize = 0; if (old.len * new.len <= 1 << 16) { const matched = try gpa.alloc(bool, new.len); defer gpa.free(matched); @memset(matched, false); for (old) |h| { for (new, matched) |candidate, *used| { if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) { used.* = true; break; } } else { out[n] = h; n += 1; } } for (new, matched) |h, used| if (!used) { out[n] = h; n += 1; }; } if (n == 0) { @memcpy(out[0..old.len], old); @memcpy(out[old.len..], new); return out; } // Freed by length: hand back exactly what the caller will free. return gpa.realloc(out, n); } fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows { if (a.start >= a.end) return b; if (b.start >= b.end) return a; return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) }; } 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{}; 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, link: ?pdf.Link = null, pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { if (probe.link) |*link| link.deinit(gpa); 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; // The slot stays ours across the read: another request may make a // pane meanwhile. core.reserved_slots[id] = true; defer core.reserved_slots[id] = false; 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| { core.clearNavigationSelection(owner.pane); 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 = core.bodyTop(rect); 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, core.bodyTop(rect)); 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); if (state.document.linkAt(core.pdf_gpa, page, point) catch null) |found| { var link = found; if (core.pdfLinkLocation(pane, link) != null) { const quads = core.pdf_gpa.alloc(pdf.Quad, 1) catch { link.deinit(core.pdf_gpa); return null; }; const text = state.document.copyRectangle(core.pdf_gpa, page, link.quad) catch { core.pdf_gpa.free(quads); link.deinit(core.pdf_gpa); return null; }; quads[0] = link.quad; return .{ .page = page, .quads = quads, .text = text, .link = link }; } link.deinit(core.pdf_gpa); } 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_page == state.page) 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 link_hover = hover_quads.len > 0 and core.pdf_hover_preview.?.probe.link != null; const selection_color = core.theme().sel_bg; return .{ .hover_quads = hover_quads, .hover_page = hover_page, .hover_color = if (link_hover) core.theme().box else selection_color, .selection_color = selection_color, .live = core.native_image_patches, }; } 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; const slot = rasterForPage(state, page).?; const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and slot.patch_rows.start < slot.patch_rows.end) .{ .from = slot.patch_from, .y = @intCast(slot.patch_rows.start - slot.band_y), .h = @intCast(slot.patch_rows.end - slot.patch_rows.start), } else null; 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, .paper_bg = if (pardes.platform == .macos) (if (key.mode == .disabled) .{ 255, 255, 255 } else key.colors.background) else null, }, .x = text_x, // Below the notice chips, like an image: a placed page is // drawn after the cells and would paint a chip out. .y = core.bodyTop(rect) + pane.notices.span(), .w = text_width, .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(), .rgba = placed.rgba, .iw = placed.width, // The texture contains the retained band, not the full page. .ih = placed.band_height, .patch = patch, })) break; // A shell now holds (or will fetch whole) this revision. slot.patch_from = slot.revision; slot.patch_rows = .{ .start = 0, .end = 0 }; 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 = core.bodyTop(rect); const body_h = rect.h -| pardes.BOX_H; const chrome = core.chromeTheme(); const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; // The rail runs past the notice bands; only the thumb tracks the text. const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); const track_h: usize = body_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 }); } /// Is the viewport already showing the last row of output? pub fn atBottom(pane: *const Pane) bool { if (comptime !enabled) return true; const sb = scrollbar(pane); return sb.offset + sb.len >= sb.total; } /// 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; if (cols != pane.cols) for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; }; 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; } /// Where a restored terminal's history ends and its new shell begins. pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; /// Rebuild a dump's terminal emulator from its recorded output. `live`: a /// shell will be spawned into it, so the history is marked off from what /// that shell prints and the pane waits for it like any new terminal. /// 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, live: bool) !*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; } // An older dump's raw output tail cannot come back live: cut off mid // redraw, or written at sizes the pane no longer has, it replays as // garbage. Its rendered text can. const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; const bytes = if (legacy_text) try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") else 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 (live) { if (bytes.len > 0) { ingest(pane, bytes); ingest(pane, restored_banner); } followOutput(pane); // A view left scrolled back stays on what it was showing: the // banner's line breaks scrolled a full screen by that many more. if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); armShellSpawn(pane); } else 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 }, }, false); 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)); } test "a restored terminal keeps its history above a marker and gets a shell where it was" { if (comptime !enabled or pardes.isolated) 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, "old output\r\n$ ls\r\n"); try pane.setOwnedCwd("/tmp"); try p.dumpState(); const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); defer restored.deinit(); var spawned = false; while (restored.nextEffect()) |effect| switch (effect) { .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), else => {}, }; try std.testing.expect(spawned); const again = restored.panes[0].?; try std.testing.expectEqual(.spawn, again.pending_command.wait); const text = try screenTextAlloc(again, gpa); defer gpa.free(text); const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; try std.testing.expect(old_at < mark_at); } test "an older dump comes back live from its rendered text, not its raw output tail" { 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(); // A tail cut mid-redraw: relative moves from a cursor it never set. const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); defer gpa.free(tail); const restored = try restore(p, .{ .kind = .terminal, .tag = "", .body = "", .cols = 40, .rows = 12, .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, }, true); defer gpa.destroy(restored); defer deinitEmulator(restored, gpa); defer deinitPendingCommand(restored); const text = try screenTextAlloc(restored, gpa); defer gpa.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); } /// 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 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so // a reader scrolled back into the scrollback stays where they scrolled. const at_bottom = atBottom(pane); 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 and at_bottom) 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 pointerRowCount(pane: *const Pane) usize { var count: usize = 0; for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { count += rows.len; }; return count; } 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; } const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; defer if (pointer_positions) |pins| pane.gpa.free(pins); if (pointer_positions) |pins| { var index: usize = 0; for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { for (rows) |*row| { pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed); index += 1; } }; } else failed = true; 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; var pointer_rows_lost = alive == null or pointer_positions == null; if (pointer_positions) |pins| for (pins) |position| { if (alive != null) if (position.row.pin) |pin| { if (pin.garbage) pointer_rows_lost = true; }; }; 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 ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |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 (pointer_rows_lost) for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; }; 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, respecting the application's keyboard protocol and terminal modes. pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { if (comptime enabled) { const pane = p.panes[id] orelse return; const state = pane.terminal orelse return; const input = ghostty_vt.input; const physical: input.Key = switch (key.cp) { Key.enter => .enter, Key.backspace => .backspace, Key.tab => .tab, Key.escape => .escape, Key.up => .arrow_up, Key.down => .arrow_down, Key.left => .arrow_left, Key.right => .arrow_right, Key.home => .home, Key.end => .end, Key.page_up => .page_up, Key.page_down => .page_down, Key.delete => .delete, else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, }; var utf8: [4]u8 = undefined; const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] else ""; var buffer: [256]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer); input.encodeKey(&writer, .{ .key = physical, .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, .utf8 = text, .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, }, options: { var opts = input.KeyEncodeOptions.fromTerminal(&state.vt); // fromTerminal cannot know this one and says so, defaulting to // "option is not alt" — right for an app that lets macOS do the // unicode translation and hands the encoder the composed text. // Every pardes shell resolves the modifier itself and hands the // core the BASE key with the alt bit and no text at all (see // PardesView.keyDown, and app.mjs doing the same with the same // two bits), so by the time this runs alt already means alt. // Left at the default, a macOS build drops the ESC prefix on // the floor and forwards Alt-n to the child as a bare `n`. opts.macos_option_as_alt = .true; break :options opts; }) catch return; if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); return; } 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; const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null; if (comptime enabled) if (!selected and 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; followOutput(pane); // entering raw mode is a request for the live shell pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; // The raw renderer follows the live cursor. A captured mouse selection // keeps its modal endpoint for the next return to editor mode. pane.cur_pinned = selected; 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 }; } pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize { if (comptime !enabled) return 0; if (row < 0) return 0; const state = pane.terminal orelse return 0; const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0; if (pin.rowAndCell().row.semantic_prompt == .none) return 0; return switch (promptCut(pin)) { .keep => 0, .blank => raw.len, .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, 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); for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; } p.gpa.free(overlay.text); overlay.text = new; } /// The shell's screen and scrollback (the primary screen, even under a /// full-screen program) as VT that replays to what was shown: styled /// text, soft-wrapped lines rejoined so they reflow at another width. fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; var out: std.Io.Writer.Allocating = .init(arena); try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); return out.written(); } /// 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, .collapsed = pane.collapsed, .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, .collapsed = pane.collapsed, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream[0..len], .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), .stream_is_screen = true, .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 = p.bodyTop(r); 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; // Raw index assertions require a theme that inherits the ANSI palette. const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme acme\n", .tty_only = true }); defer p.deinit(); 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); // Native dark/light palettes plus legacy helix/dark/acme, including // the null-page fallback that only the legacy dark theme exercises. for (0..9) |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..9) |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..9) |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 = .{ .rgb = p.theme().palette.?[1] }; const blue: pardes.Color = .{ .rgb = p.theme().palette.?[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{ .rgb = p.theme().palette.?[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; }