From c04df904f2fa686cbe5ce0c078a96260af7b08bd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:39:20 -0300 Subject: A diff reads as the code it changes: hunks in their file's language, added and removed rows tinted A .diff or .patch pane, and a command pane whose output is a diff (`git diff`, `git show`, `diff -u`; told by a `diff --git` line, or `---`/`+++` with a `@@` under them, in its first rows), colour each hunk's code in the language of the file its section names (`+++ b/`, `--- a/` for a deleted file), with tree-sitter, as highlightLocations does for results. src/diff.zig walks a unified diff by its `@@` counts, so a removed line that reads `--- x` is no header. Each side of a hunk is parsed as one text (context and removed lines the old side, context and added lines the new), so a string or comment across lines colours as it does in the file; a side no line takes its colours from is not parsed. A section's hunks share a parse, in pieces of about 40 lines (a hunk past 80 is cut), only those in view, and each piece's colours are kept by its bytes, so scrolling back and a terminal's every repaint parse nothing again. Added and removed lines carry a flag in their style byte; the painter tints their rows to the pane's edge, a little way from the page toward the theme's ANSI green or red, and draws the prefix in that hue pushed to read on it. A file in no known language keeps the old line colours. A command pane is read once its command has finished: its rows are copied once (File.DiffOutput, dropped when the pane runs again), so no frame dumps the scrollback, and painted over what git printed. A running command is shown as it prints. Co-Authored-By: Claude Opus 5.5 --- src/File.zig | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) (limited to 'src/File.zig') diff --git a/src/File.zig b/src/File.zig index 84cec879..2046a7cd 100644 --- a/src/File.zig +++ b/src/File.zig @@ -10,10 +10,12 @@ const Surface = pardes.Surface; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); +const diff = @import("diff.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); +const Colors = @import("colors.zig"); const Pane = panes.Pane; const Output = panes.Output; const Mini = panes.mini; @@ -1301,6 +1303,59 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, const SynStyle = struct { fg: [3]u8, bold: bool }; +/// How added and removed diff lines are told apart in every theme: a row +/// ground a little way from the page toward green or red (the theme's own +/// ANSI green and red where it has a palette), and the prefix column in +/// that hue, pushed toward the ink until it reads on that ground. The +/// syntax colours are drawn over the ground unchanged. +pub const DiffTints = struct { + added_bg: [3]u8, + removed_bg: [3]u8, + added_fg: [3]u8, + removed_fg: [3]u8, + + pub fn of(p: *Pardes) DiffTints { + const th = p.theme(); + const page = th.bg orelse th.tag_bg; + const ink = th.fg orelse th.tag_fg; + const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 }; + const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e }; + const added_bg = Colors.mixOklab(page, green, 0.18); + const removed_bg = Colors.mixOklab(page, red, 0.18); + return .{ + .added_bg = added_bg, + .removed_bg = removed_bg, + .added_fg = legible(green, ink, added_bg), + .removed_fg = legible(red, ink, removed_bg), + }; + } + + fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 { + var fg = hue; + var step: usize = 0; + while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4); + return fg; + } + + /// The row ground and prefix ink for a line's style byte, if it is an + /// added or removed line. + pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } { + if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg }; + if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg }; + return null; + } +}; + +/// A diff row's tint: its ground from `x` to the pane's edge. +fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void { + var col = x; + while (col < end) : (col += 1) { + const cell = s.at(col, y); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + } +} + fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { return switch (sy) { .none => null, @@ -1320,6 +1375,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); + const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null; // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const total = nlines(p.gpa, f); @@ -1352,6 +1408,12 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; const decoration = Output.decorateRow(f.output, line, metadata); const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome(); + // An added or removed line's row, every wrapped row of it, is tinted. + const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len) + t.forStyle(highlights[base - highlight_start]) + else + null else null; + if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -1361,7 +1423,11 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. if (idx >= highlight_start) { const hidx = idx - highlight_start; if (hidx < highlights.len) { - if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| { + if (if (tint != null and hs + c == 0) + SynStyle{ .fg = tint.?.fg, .bold = true } + else + synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |ss| + { var fill: usize = 0; while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); @@ -1390,6 +1456,170 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. } } +/// What finished command panes printed, kept while it cannot change: a +/// copy of the terminal's rows, and whether they are a diff. Taken once +/// when a finished pane is first drawn, so neither a frame nor a look dumps +/// the whole scrollback again; a still running command is shown as it +/// printed. Keyed by the pane and its width (a resize rewraps every row); +/// a rerun in the pane drops it (exec.runCommand). +pub const DiffOutput = struct { + const Entry = struct { + serial: u32, + cols: u16, + is_diff: bool, + /// The rows joined by '\n'; `rows` are slices of it. + text: []u8, + rows: [][]const u8, + used: u64, + }; + slots: [4]?Entry = @splat(null), + clock: u64 = 0, + + pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void { + for (&o.slots) |*slot| { + if (slot.*) |e| { + gpa.free(e.text); + gpa.free(e.rows); + } + slot.* = null; + } + } + + /// A pane's command runs again: what it printed before is gone. + pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void { + for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) { + gpa.free(e.text); + gpa.free(e.rows); + slot.* = null; + }; + } + + /// A finished command pane's rows when they are a diff, else null. + pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 { + if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null; + o.clock += 1; + for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) { + e.used = o.clock; + return if (e.is_diff) e.rows else null; + }; + const shown = terminal.shellRows(p, pane) catch return null; + const is_diff = diff.looksLikeDiff(shown); + var total: usize = 0; + if (is_diff) for (shown) |row| { + total += row.len + 1; + }; + const text = p.gpa.alloc(u8, total) catch return null; + const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch { + p.gpa.free(text); + return null; + }; + var at: usize = 0; + for (rows, 0..) |*row, i| { + @memcpy(text[at..][0..shown[i].len], shown[i]); + row.* = text[at..][0..shown[i].len]; + at += shown[i].len; + text[at] = '\n'; + at += 1; + } + var oldest: usize = 0; + for (&o.slots, 0..) |*slot, i| { + const e = slot.* orelse { + oldest = i; + break; + }; + if (o.slots[oldest]) |old| if (e.used < old.used) { + oldest = i; + }; + } + if (o.slots[oldest]) |old| { + p.gpa.free(old.text); + p.gpa.free(old.rows); + } + o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock }; + return if (is_diff) rows else null; + } +}; + +/// A command pane whose output is a diff (`git diff`, `diff -u`): its +/// rows coloured as a .diff file's are, over what the program printed. +/// The rows are the terminal's own, so a line it wrapped goes on in the +/// row below (syntax.highlightDiffRows). +pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + if (pane.command == null or !p.settings.colors) return; + const tz = tracy.zone(@src(), "diffRecolor"); + defer tz.end(); + const rows = p.diff_output.lines(p, pane) orelse return; + // The copy's rows are one text, joined as a file's lines are. + const flat_start = @intFromPtr(rows[0].ptr); + const last = rows[rows.len - 1]; + const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start]; + const raw = pane.body.mode == .tty; + const View = struct { row: usize, at: usize }; + const arena = p.scratch.allocator(); + const views = arena.alloc(?View, body_h) catch return; + var lo: usize = std.math.maxInt(usize); + var hi: usize = 0; + var shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |*view, vi| { + view.* = null; + const line = shown.next() orelse continue; + const vr: i32 = @intCast(vi); + const w = pane.wrapAt(vr); + const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line; + if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue; + const row: usize = @intCast(row_i); + const from: usize = if (raw) 0 else @intCast(@max(0, w.at)); + if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue; + view.* = .{ .row = row, .at = from }; + const start = @intFromPtr(rows[row].ptr) - flat_start; + lo = @min(lo, start); + hi = @max(hi, start + rows[row].len); + } + if (lo > hi) return; + const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return; + const tints = DiffTints.of(p); + const ink = p.theme().fg orelse p.theme().tag_fg; + const body_y = p.bodyTop(pane, r); + shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |maybe, vr| { + const line = shown.next() orelse break; + const view = maybe orelse continue; + const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo; + const tint = if (base < styles.len) tints.forStyle(styles[base]) else null; + if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg); + var c: usize = 0; + var screen_c: usize = 0; + while (c < line.len and screen_c < tw) { + const grapheme_end = modal.nextGrapheme(line, c); + if (grapheme_end <= c) break; + const cells = graphemeDisplayWidth(line[c..grapheme_end]); + const style = styles[base + view.at + c]; + const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-'); + // On an added or removed line, what the program coloured red or + // green is the tint's to say now: its code takes the page's ink. + const paint: ?SynStyle = if (tint != null and prefix) + .{ .fg = tint.?.fg, .bold = true } + else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss| + ss + else if (tint != null) + .{ .fg = ink, .bold = false } + else + null; + if (paint) |ss| { + var fill: usize = 0; + while (fill < cells and screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr))); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } + screen_c += cells; + c = grapheme_end; + } + } +} + pub fn drawWrapMarkers( p: *Pardes, s: *Surface, -- cgit v1.3