//! A file pane's text: its content and undo history, display columns, line //! index, syntax highlights and the body it fills. const panes = @import("panes.zig"); const std = @import("std"); const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const Surface = pardes.Surface; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); const diff = @import("diff.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); const Colors = @import("colors.zig"); const Pane = panes.Pane; const Output = panes.Output; const Mini = panes.mini; const terminal = panes.terminal; const Text = panes.Text; const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; /// Content and primary selection at one file edit boundary. Keeping only the /// primary avoids putting Text.max_selections ranges in every history entry. pub const Snapshot = struct { content: []u8, cur_row: i32, cur_col: i32, vsel: Text.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, /// The text at `saved_revision`, hashed as the first edit after it /// replaces it: an edit that brings the text back to it is clean again, /// as an undo to the save point is in acme. saved_hash: ?u64 = null, /// The length of the text `saved_hash` is of, when it is of one: an /// edit to another length cannot be back to it, and is not hashed. saved_len: ?usize = null, watch_after_save: bool = false, /// The file changed on disk under unsaved edits, which were kept (see /// `changed`): its hash, so the same change is said once, until a Save /// or get settles it. A Save warns once before overwriting it, as acme's /// Put does (exec.c:577, "modified since last read"). disk_newer: ?u64 = null, disk_newer_warned: bool = false, /// Its file was deleted on disk: said once (`deleted`) until it is /// written or found again. disk_gone: 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, /// The whole file's parse for the keys that walk its nodes (Alt-o and /// kin), made on the first of them after an edit and kept until the next /// one: `syntax.parseTree`'s, opaque here. node_tree: ?*anyopaque = null, node_tree_revision: u32 = 0, /// helix's object_selections: what Alt-o grew from, for Alt-i to go back /// to; gpa-owned ranges and the primary's index. node_history: std.ArrayList(NodeStep) = .empty, history: *History, }; pub const NodeStep = struct { ranges: []modal.Selection, primary: usize }; 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; if (std.unicode.utf8ValidateSlice(grapheme)) return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); // vaxis's gwidth counts back a replacement rune's 3 bytes over the one // invalid byte it stood for (`\xee` before `e\u{301}` overflowed it): // each invalid byte is a cell, as surface.zig draws it, and each valid // run between is measured alone. var width: usize = 0; var run: usize = 0; var i: usize = 0; while (i < grapheme.len) { const n = std.unicode.utf8ByteSequenceLength(grapheme[i]) catch 0; if (n > 0 and i + n <= grapheme.len and std.unicode.utf8ValidateSlice(grapheme[i .. i + n])) { i += n; continue; } if (run < i) width += vaxis.gwidth.gwidth(grapheme[run..i], .unicode); width += 1; i += 1; run = i; } if (run < grapheme.len) width += vaxis.gwidth.gwidth(grapheme[run..], .unicode); return @max(1, width); } test "an invalid byte in a grapheme is one cell, and never reaches vaxis's width" { try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xeee\xcc\x81")); try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\xee\xffe")); try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\u{4e16}\x80")); // The whole path the 9P fuzzer took: a body holding it, fitted to a width. const text = "\xeee\xcc\x81"; try std.testing.expectEqual(@as(usize, 4), fitEnd(text, 0, 10)); } 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); } /// The display columns from `from_raw` to `to_raw` on a line. Widths add /// (a tab is `tab_width` wherever it stands), so only the text between /// the two is measured, never the line from its start: a selection a /// million bytes into a line draws as fast as one at its start. pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { const lo = @min(from_raw, to_raw); const hi = @max(from_raw, to_raw); const a = modal.graphemeStart(line_text, @min(lo, line_text.len)); const b = modal.graphemeStart(line_text, @min(hi, line_text.len)); const past = (hi -| line_text.len) - (lo -| line_text.len); const width: usize = displayWidth(line_text[a..b]) +| past; return if (to_raw >= from_raw) @intCast(width) else -@as(i32, @intCast(width)); } 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 < pane.tag_rows) 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 < pane.tag_rows) 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", }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); // Two invalid bytes are two cells, as surface.zig draws them, where // vaxis's own count gives the pair one. try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xe4\xb8")); } 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. /// The bytes a splice left alone at either end, when its caller knows them. pub const Span = struct { head: usize, tail: usize }; fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize { const n = @min(old.len, new.len); var head: usize = 0; var tail: usize = 0; if (known) |span| { head = span.head; tail = span.tail; } else { 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; 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]); // As a splice that says what it kept, and as one that does not. const known: Span = .{ .head = at, .tail = old.len - at - del }; const told = try editedLineStarts(gpa, starts, old, text.items, known); defer gpa.free(told); const edited = try editedLineStarts(gpa, starts, old, text.items, null); try std.testing.expectEqualSlices(usize, edited, told); 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(t: *Text, text: []const u8) ?[]const usize { if (t.what != .body) return null; const pane = t.pane() orelse return null; 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(t: *Text, text: []const u8, cursor: modal.Cursor) usize { const index = contentIndex(t, 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(t: *Text, text: []const u8, row: usize) usize { const index = contentIndex(t, text) orelse return modal.lineStartOffset(text, row); return if (row >= index.len) text.len else index[row]; } pub fn textLine(t: *Text, text: []const u8, row: usize) []const u8 { const index = contentIndex(t, 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.body, 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.body, fragment, row), ); } try std.testing.expectEqual(allocations, allocator.allocations); } pub fn textLineCount(t: *Text, text: []const u8) usize { const index = contentIndex(t, text) orelse return modal.cursorLineCount(text); return index.len; } pub fn textPosition(t: *Text, text: []const u8, offset: usize) modal.Cursor { const index = contentIndex(t, 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.body.cur_pinned = true; pane.body.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), // Restored with unsaved text, it has text to lose as an edited pane // has: a hash its text does not match, so Exit, Restore, Del and // get ask first (builtins.warnModifiedIn reads only edited panes). .saved_hash = if (saved.dirty) std.hash.Wyhash.hash(0, content) +% 1 else null, .history = history, }; pane.file.?.tree_context = saved.tree_context and supportsContext(pane); pane.body.cur_pinned = true; pane.body.cur_row = @intCast(src.scroll); // Its dot as it was, where the view the dump kept shows it. if (saved.dot) |dot| if (dot[0] <= dot[1] and dot[1] <= content.len) { const a = modal.positionAt(content, dot[0]); const h = modal.positionAt(content, if (dot[1] > dot[0]) modal.prevGrapheme(content, dot[1]) else dot[0]); pane.body.vsel = .{ .active = dot[1] > dot[0], .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); }; 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 } }); // Its file gone from disk since the dump: its text is nowhere else now, // so it comes back as one deleted on disk, dirty, and Del, Exit and // Restore ask before throwing it away. if (comptime pardes.hosted) if (output == null) if (filesystem.localPath(path)) |local| if (@import("exec.zig").kindOf(local) == null) deleted(p, @intCast(id)); 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); if (file.node_tree) |tree| syntax.freeTree(tree); for (file.node_history.items) |step| p.gpa.free(step.ranges); file.node_history.deinit(p.gpa); 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 { setContentSpan(p, f, new, null); } /// `setContent` for a splice that knows what it left alone at either end, /// so the line index is not found again by comparing the whole text. pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) 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, span) catch &.{} else &.{}; // ponytail: a whole-text hash per edit that keeps the saved length; a // rolling hash when that too matters. if (f.revision == f.saved_revision or f.saved_hash == null) { f.saved_hash = std.hash.Wyhash.hash(0, f.content); f.saved_len = f.content.len; } p.gpa.free(f.content); f.content = new; f.revision +%= 1; if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) { f.saved_revision = f.revision; }; 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.body.cur_row = @intCast(row); pane.body.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); pane.body.vsel = snap.vsel; pane.body.msel.active = false; pane.body.cur_pinned = true; pane.body.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.body.cur_row, .cur_col = pane.body.cur_col, .vsel = pane.body.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.body.cur_row, .cur_col = pane.body.cur_col, .vsel = pane.body.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.body.cur_row, .cur_col = pane.body.cur_col, .vsel = pane.body.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); } /// A pane's file deleted on disk: the text stays, and it says so once, /// ` deleted on disk (Save writes it again)`, logged `changed /// deleted`. pub fn deleted(p: *Pardes, id: u8) void { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (f.disk_gone) return; f.disk_gone = true; var said: [limits.host_path_cap + 64]u8 = undefined; p.setMessage(id, std.fmt.bufPrint(&said, "{s} deleted on disk (Save writes it again)", .{f.path}) catch "deleted on disk (Save writes it again)"); var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} deleted", .{pane.serial}) catch ""); } /// A file rewritten on disk. A clean buffer takes the new text, committed /// onto the same undo history as typed edits. A buffer with unsaved edits /// is never reloaded under them: it keeps its text and stays dirty, and /// says so once (` changed on disk (get reloads it, Save overwrites /// it)`, logged `changed `); get then asks before throwing /// the edits away and Save before overwriting the newer file. acme reloads /// nothing by itself: its get asks (winclean) and its Put refuses a file /// modified since it was read (exec.c:577). 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; f.disk_gone = false; if (std.mem.eql(u8, f.content, bytes)) return; if (f.saved_hash) |saved| if (f.revision != f.saved_revision and std.hash.Wyhash.hash(0, f.content) != saved) { const disk = std.hash.Wyhash.hash(0, bytes); if (f.disk_newer == disk) return; f.disk_newer = disk; f.disk_newer_warned = false; var said: [limits.host_path_cap + 64]u8 = undefined; p.setMessage(id, std.fmt.bufPrint(&said, "{s} changed on disk (get reloads it, Save overwrites it)", .{f.path}) catch "changed on disk (get reloads it, Save overwrites it)"); var rec: [16]u8 = undefined; pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d}", .{pane.serial}) catch ""); return; }; f.disk_newer = null; // Logged as the dirty case is, with what became of it. var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch ""); 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.body.cur_row, .cur_col = pane.body.cur_col, .vsel = pane.body.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, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const prefix_width = gutterWidth(pane); const goff = pane.scroll(); const gcur = terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) const body_y = p.bodyTop(pane, 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 }; /// How added and removed diff lines are told apart in every theme: a row /// ground a little way from the page toward green or red (the theme's own /// ANSI green and red where it has a palette), and the prefix column in /// that hue, pushed toward the ink until it reads on that ground. The /// syntax colours are drawn over the ground unchanged. pub const DiffTints = struct { added_bg: [3]u8, removed_bg: [3]u8, added_fg: [3]u8, removed_fg: [3]u8, pub fn of(p: *Pardes) DiffTints { const th = p.theme(); const page = th.bg orelse th.tag_bg; const ink = th.fg orelse th.tag_fg; const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 }; const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e }; const added_bg = Colors.mixOklab(page, green, 0.18); const removed_bg = Colors.mixOklab(page, red, 0.18); return .{ .added_bg = added_bg, .removed_bg = removed_bg, .added_fg = legible(green, ink, added_bg), .removed_fg = legible(red, ink, removed_bg), }; } fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 { var fg = hue; var step: usize = 0; while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4); return fg; } /// The row ground and prefix ink for a line's style byte, if it is an /// added or removed line. pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } { if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg }; if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg }; return null; } }; /// A diff row's tint: its ground from `x` to the pane's edge. fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void { var col = x; while (col < end) : (col += 1) { const cell = s.at(col, y); cell.default = false; cell.style.bg = .{ .rgb = bg }; } } 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, s: *Surface, 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 prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null; // 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(pane, 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); const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome(); // An added or removed line's row, every wrapped row of it, is tinted. const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len) t.forStyle(highlights[base - highlight_start]) else null else null; if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg); 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 (if (tint != null and hs + c == 0) SynStyle{ .fg = tint.?.fg, .bold = true } else synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |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 (if (chrome) |ch| decoration.styleAt(ch, hs + c) else null) |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; } } } /// What finished command panes printed, kept while it cannot change: a /// copy of the terminal's rows, and whether they are a diff. Taken once /// when a finished pane is first drawn, so neither a frame nor a look dumps /// the whole scrollback again; a still running command is shown as it /// printed. Keyed by the pane and its width (a resize rewraps every row); /// a rerun in the pane drops it (exec.runCommand). pub const DiffOutput = struct { const Entry = struct { serial: u32, cols: u16, is_diff: bool, /// The rows joined by '\n'; `rows` are slices of it. text: []u8, rows: [][]const u8, used: u64, }; slots: [4]?Entry = @splat(null), clock: u64 = 0, pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void { for (&o.slots) |*slot| { if (slot.*) |e| { gpa.free(e.text); gpa.free(e.rows); } slot.* = null; } } /// A pane's command runs again: what it printed before is gone. pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void { for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) { gpa.free(e.text); gpa.free(e.rows); slot.* = null; }; } /// A finished command pane's rows when they are a diff, else null. pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 { if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null; o.clock += 1; for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) { e.used = o.clock; return if (e.is_diff) e.rows else null; }; const shown = terminal.shellRows(p, pane) catch return null; const is_diff = diff.looksLikeDiff(shown); var total: usize = 0; if (is_diff) for (shown) |row| { total += row.len + 1; }; const text = p.gpa.alloc(u8, total) catch return null; const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch { p.gpa.free(text); return null; }; var at: usize = 0; for (rows, 0..) |*row, i| { @memcpy(text[at..][0..shown[i].len], shown[i]); row.* = text[at..][0..shown[i].len]; at += shown[i].len; text[at] = '\n'; at += 1; } var oldest: usize = 0; for (&o.slots, 0..) |*slot, i| { const e = slot.* orelse { oldest = i; break; }; if (o.slots[oldest]) |old| if (e.used < old.used) { oldest = i; }; } if (o.slots[oldest]) |old| { p.gpa.free(old.text); p.gpa.free(old.rows); } o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock }; return if (is_diff) rows else null; } }; /// A command pane whose output is a diff (`git diff`, `diff -u`): its /// rows coloured as a .diff file's are, over what the program printed. /// The rows are the terminal's own, so a line it wrapped goes on in the /// row below (syntax.highlightDiffRows). pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { if (pane.command == null or !p.settings.colors) return; const tz = tracy.zone(@src(), "diffRecolor"); defer tz.end(); const rows = p.diff_output.lines(p, pane) orelse return; // The copy's rows are one text, joined as a file's lines are. const flat_start = @intFromPtr(rows[0].ptr); const last = rows[rows.len - 1]; const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start]; const raw = pane.body.mode == .tty; const View = struct { row: usize, at: usize }; const arena = p.scratch.allocator(); const views = arena.alloc(?View, body_h) catch return; var lo: usize = std.math.maxInt(usize); var hi: usize = 0; var shown = std.mem.splitScalar(u8, body, '\n'); for (views, 0..) |*view, vi| { view.* = null; const line = shown.next() orelse continue; const vr: i32 = @intCast(vi); const w = pane.wrapAt(vr); const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line; if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue; const row: usize = @intCast(row_i); const from: usize = if (raw) 0 else @intCast(@max(0, w.at)); if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue; view.* = .{ .row = row, .at = from }; const start = @intFromPtr(rows[row].ptr) - flat_start; lo = @min(lo, start); hi = @max(hi, start + rows[row].len); } if (lo > hi) return; const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return; const tints = DiffTints.of(p); const ink = p.theme().fg orelse p.theme().tag_fg; const body_y = p.bodyTop(pane, r); shown = std.mem.splitScalar(u8, body, '\n'); for (views, 0..) |maybe, vr| { const line = shown.next() orelse break; const view = maybe orelse continue; const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo; const tint = if (base < styles.len) tints.forStyle(styles[base]) else null; if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg); var c: usize = 0; var screen_c: usize = 0; while (c < line.len and screen_c < tw) { const grapheme_end = modal.nextGrapheme(line, c); if (grapheme_end <= c) break; const cells = graphemeDisplayWidth(line[c..grapheme_end]); const style = styles[base + view.at + c]; const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-'); // On an added or removed line, what the program coloured red or // green is the tint's to say now: its code takes the page's ink. const paint: ?SynStyle = if (tint != null and prefix) .{ .fg = tint.?.fg, .bold = true } else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss| ss else if (tint != null) .{ .fg = ink, .bold = false } else null; if (paint) |ss| { var fill: usize = 0; while (fill < cells and screen_c + fill < tw) : (fill += 1) { const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr))); if (cell.default) continue; cell.style.fg = .{ .rgb = ss.fg }; cell.style.bold = ss.bold; } } screen_c += cells; c = grapheme_end; } } } pub fn drawWrapMarkers( p: *Pardes, s: *Surface, 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(pane, 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; s.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ .fg = .{ .rgb = marker_fg }, .bg = pane_bg, }); } } pub fn paintWordSelection( p: *Pardes, s: *Surface, 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(pane, r); var vr: i32 = 0; while (vr + @as(i32, pane.tag_rows) < @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 = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); cell.default = false; cell.style.bg = .{ .rgb = bg }; } } }