summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/File.zig1305
-rw-r--r--src/fs.zig1
-rw-r--r--src/panes.zig1288
3 files changed, 1307 insertions, 1287 deletions
diff --git a/src/File.zig b/src/File.zig
new file mode 100644
index 00000000..eb1c6a7c
--- /dev/null
+++ b/src/File.zig
@@ -0,0 +1,1305 @@
+//! 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 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 locations = @import("locations.zig");
+const Pane = panes.Pane;
+const Output = panes.Output;
+const Mini = panes.Mini;
+const Terminal = panes.Terminal;
+
+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 };
+ }
+ }
+}
diff --git a/src/fs.zig b/src/fs.zig
index e6ebddf2..8ed6fc77 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -36,6 +36,7 @@ else
const source_files = [_]Source{
.{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") },
.{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") },
+ .{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
.{ .path = "src/image.zig", .contents = @embedFile("image.zig") },
diff --git a/src/panes.zig b/src/panes.zig
index 6ee7d70b..051470b1 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -1120,1293 +1120,7 @@ pub const Pane = struct {
}
};
-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 File = @import("File.zig");
pub const Output = @import("Output.zig");