diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 21:24:58 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 7d78a880201d25b8e7b9de1f7bd22199f92a2705 (patch) | |
| tree | 4fc5a2b0202dd29a49b758bb2593bd05073c00bd /src | |
| parent | 2f3f8776613ebb357ff6458267c3d79c79944968 (diff) | |
| download | pardes-7d78a880201d25b8e7b9de1f7bd22199f92a2705.tar.gz pardes-7d78a880201d25b8e7b9de1f7bd22199f92a2705.zip | |
Move the File namespace out of panes.zig into File.zig
Pure move: the body of `pub const File = struct { ... }` is now the file
File.zig, dedented, with the imports it uses; panes.zig keeps
`pub const File = @import("File.zig");`, so `panes.File.x` callers and the
`File.State` field of Pane are unchanged. File.zig joins the embedded
source list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/File.zig | 1305 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/panes.zig | 1288 |
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 }; + } + } +} @@ -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"); |
