Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The details and diff pane: the selected commit's `Document`, scrolled//! through, under a bar of the diff's settings — how many lines of context,//! and whether changes only in whitespace count.//!//! As with the commit list, only the lines in view are laid out and//! drawn, however long the diff. It scrolls both ways, with a scrollbar//! for each: down through the lines, and sideways along the longest.
const std = @import("std");const aerie = @import("aerie");const Canvas = aerie.gpu.Canvas;const Rect = Canvas.Rect;
const Document = @import("Document.zig");const Text = @import("Text.zig");const theme = @import("theme.zig");const Scrollbar = @import("Scrollbar.zig");const l10n = @import("../l10n.zig");
const DiffView = @This();
/// The whole pane, bar and all.rect: Rect = .{ .x = 0, .y = 0, .width = 0, .height = 0 },line_height: f32 = 1,bar_height: f32 = 0,/// How wide the scrollbars are.scrollbar_size: f32 = 0,scroll: f32 = 0,/// How far the lines are scrolled sideways.scroll_x: f32 = 0,/// How wide the widest line is, with its margins, as last drawn.content_width: f32 = 0,/// Text selected, from where the press was to where the pointer went;/// in the document being shown.selection: ?Selection = null,/// What is under the pointer in the bar.hovered: ?Control = null,/// Where each control is, laid out by `draw` and kept for hit-testing.controls: [3]Rect = @splat(.{ .x = 0, .y = 0, .width = 0, .height = 0 }),
pub const Selection = struct { anchor: Document.Pos, head: Document.Pos,
pub fn isEmpty(sel: Selection) bool { return sel.anchor.eql(sel.head); }
/// Its ends, first first. pub fn ordered(sel: Selection) [2]Document.Pos { return if (sel.head.lessThan(sel.anchor)) .{ sel.head, sel.anchor } else .{ sel.anchor, sel.head }; }};
/// How far in from the body's left edge text starts.fn textInset(scale: f32) f32 { return @round(6 * scale);}
/// The place in `doc` nearest `x`, `y`: a point above the text is its/// first line, and one below its last.pub fn posAt(v: *const DiffView, text: *Text, doc: *const Document, x: f32, y: f32, scale: f32) !Document.Pos { if (doc.lines.len == 0) return .{ .line = 0, .byte = 0 }; const b = v.body(); const row = @floor((y - b.y + v.scroll) / v.line_height); if (row < 0) return .{ .line = 0, .byte = 0 }; const line: usize = @intFromFloat(row); if (line >= doc.lines.len) return .{ .line = doc.lines.len - 1, .byte = doc.lines[doc.lines.len - 1].text.len }; const t = doc.lines[line].text; return .{ .line = line, .byte = try text.offsetAt(.mono, t, x - (b.x + textInset(scale) - v.scroll_x)) };}
/// What the bar has to click on.pub const Control = enum { fewer, more, whitespace };
/// Where the text is: the pane less its bar and its scrollbars.pub fn body(v: *const DiffView) Rect { return .{ .x = v.rect.x, .y = v.rect.y + v.bar_height, .width = @max(0, v.rect.width - v.scrollbar_size), .height = @max(0, v.rect.height - v.bar_height - v.scrollbar_size), };}
/// The scrollbar down the right of the text.pub fn vscrollbar(v: *const DiffView, count: usize) Scrollbar { const b = v.body(); return .{ .track = .{ .x = b.right(), .y = b.y, .width = v.scrollbar_size, .height = b.height }, .axis = .vertical, .content = @as(f32, @floatFromInt(count)) * v.line_height, .view = b.height, .offset = v.scroll, };}
/// The scrollbar along the bottom of the text.pub fn hscrollbar(v: *const DiffView) Scrollbar { const b = v.body(); return .{ .track = .{ .x = b.x, .y = b.bottom(), .width = b.width, .height = v.scrollbar_size }, .axis = .horizontal, .content = v.content_width, .view = b.width, .offset = v.scroll_x, };}
pub fn maxScroll(v: *const DiffView, count: usize) f32 { return v.vscrollbar(count).maxOffset();}
/// Scrolls sideways by `dx`, right when positive, as far as the widest/// line goes.pub fn scrollXBy(v: *DiffView, dx: f32) bool { const before = v.scroll_x; v.scroll_x = std.math.clamp(v.scroll_x + dx, 0, v.hscrollbar().maxOffset()); return v.scroll_x != before;}
pub fn scrollBy(v: *DiffView, dy: f32, count: usize) bool { const before = v.scroll; v.scroll = std.math.clamp(v.scroll + dy, 0, v.maxScroll(count)); return v.scroll != before;}
/// Scrolls so that `line` is at the top, as far as the document goes.pub fn scrollTo(v: *DiffView, line: usize, count: usize) void { v.scroll = std.math.clamp(@as(f32, @floatFromInt(line)) * v.line_height, 0, v.maxScroll(count));}
/// The line at the top of the view.pub fn topLine(v: *const DiffView) usize { return @intFromFloat(@floor(v.scroll / v.line_height));}
pub fn page(v: *const DiffView) f32 { return @max(v.line_height, v.body().height - v.line_height);}
pub fn controlAt(v: *const DiffView, x: f32, y: f32) ?Control { for (v.controls, 0..) |r, i| if (r.contains(x, y)) return @fromBackingInt(@intCast(i)); return null;}
pub const Settings = struct { context: u16, ignore_whitespace: bool,};
pub const Status = union(enum) { /// Nothing selected yet. empty, loading, shown: *const Document, failed: []const u8,};
pub fn draw(v: *DiffView, canvas: *Canvas, text: *Text, status: Status, settings: Settings, dragging: ?Scrollbar.Axis, scale: f32) !void { try canvas.rect(v.rect.x, v.rect.y, v.rect.width, v.rect.height, theme.current.list_background); const saved = canvas.pushClip(v.rect); defer canvas.setClip(saved); try v.drawBar(canvas, text, settings, scale);
const count = switch (status) { .shown => |doc| doc.lines.len, else => 0, }; v.content_width = switch (status) { .shown => |doc| @as(f32, @floatFromInt(doc.columns * text.family(.mono).metrics.cell_width)) + 2 * textInset(scale), else => 0, }; v.scroll_x = std.math.clamp(v.scroll_x, 0, v.hscrollbar().maxOffset()); try v.vscrollbar(count).draw(canvas, dragging == .vertical, scale); const h = v.hscrollbar(); try h.draw(canvas, dragging == .horizontal, scale); // The corner where the two meet. try canvas.rect(h.track.right(), h.track.y, v.scrollbar_size, v.scrollbar_size, theme.current.scrollbar_track);
const b = v.body(); _ = canvas.pushClip(b); const x0 = b.x + textInset(scale) - v.scroll_x; const selected: ?[2]Document.Pos = if (v.selection) |sel| (if (sel.isEmpty()) null else sel.ordered()) else null; switch (status) { .empty => {}, .loading => _ = try text.draw(canvas, .ui, l10n.text("diff-reading"), b.x + textInset(scale), b.y + text.baselineIn(.ui, v.line_height), theme.current.dim_foreground), .failed => |msg| _ = try text.draw(canvas, .ui, msg, b.x + textInset(scale), b.y + text.baselineIn(.ui, v.line_height), theme.current.error_foreground), .shown => |doc| { const first: usize = @intFromFloat(@floor(v.scroll / v.line_height)); const end = @min(doc.lines.len, first + @as(usize, @intFromFloat(@ceil(b.height / v.line_height))) + 1); for (first..end) |i| { const line = doc.lines[i]; const top = b.y + @as(f32, @floatFromInt(i)) * v.line_height - v.scroll; const fg: [4]u8, const band: ?[4]u8 = switch (line.style) { .header => .{ theme.current.dim_foreground, null }, .message, .context, .blank => .{ theme.current.foreground, null }, .file => .{ theme.current.foreground, theme.current.diff_file_background }, .note, .no_newline => .{ theme.current.dim_foreground, null }, .hunk => .{ theme.current.diff_hunk, theme.current.diff_hunk_background }, .added => .{ theme.current.diff_added, theme.current.diff_added_background }, .removed => .{ theme.current.diff_removed, theme.current.diff_removed_background }, .remark => .{ theme.current.error_foreground, null }, }; if (band) |c| try canvas.rect(b.x, top, b.width, v.line_height, c); if (selected) |sel| if (i >= sel[0].line and i <= sel[1].line) { const from = if (i == sel[0].line) try text.xOf(.mono, line.text, sel[0].byte) else 0; // A selection that goes on past the line takes its // line break: a column's width more. const to = if (i == sel[1].line) try text.xOf(.mono, line.text, sel[1].byte) else try text.measure(.mono, line.text) + @as(f32, @floatFromInt(text.family(.mono).metrics.cell_width)); if (to > from) try canvas.rect(x0 + from, top, to - from, v.line_height, theme.current.text_selection); }; if (line.text.len == 0) continue; _ = try text.draw(canvas, .mono, line.text, x0, top + text.baselineIn(.mono, v.line_height), fg); } }, }}
fn drawBar(v: *DiffView, canvas: *Canvas, text: *Text, settings: Settings, scale: f32) !void { const r = v.rect; try canvas.rect(r.x, r.y, r.width, v.bar_height, theme.current.bar_background); try canvas.rect(r.x, r.y + v.bar_height - @max(1, @round(scale)), r.width, @max(1, @round(scale)), theme.current.divider); const pad = @round(8 * scale); const baseline = r.y + text.baselineIn(.ui, v.bar_height); var x = r.x + pad;
x += try text.draw(canvas, .ui, l10n.text("diff-context"), x, baseline, theme.current.dim_foreground) + pad; var buf: [32]u8 = undefined; const items = [_]struct { c: Control, label: []const u8 }{ .{ .c = .fewer, .label = "−" }, .{ .c = .more, .label = "+" }, }; const button_w = @round(22 * scale); // −, the number, + . for (items, 0..) |item, i| { if (i == 1) { const n = std.fmt.bufPrint(&buf, "{d}", .{settings.context}) catch ""; x += try text.draw(canvas, .ui, n, x, baseline, theme.current.foreground) + @round(4 * scale); } const rect: Rect = .{ .x = x, .y = r.y + @round(2 * scale), .width = button_w, .height = v.bar_height - @round(4 * scale) }; try v.button(canvas, text, rect, item.label, item.c, baseline); x += button_w + @round(4 * scale); } x += pad;
var box_buf: [128]u8 = undefined; const box = std.fmt.bufPrint(&box_buf, "{s} {s}", .{ if (settings.ignore_whitespace) "☑" else "☐", l10n.text("diff-ignore-whitespace") }) catch ""; const w = try text.measure(.ui, box) + 2 * @round(4 * scale); try v.button(canvas, text, .{ .x = x, .y = r.y + @round(2 * scale), .width = w, .height = v.bar_height - @round(4 * scale) }, box, .whitespace, baseline);}
fn button(v: *DiffView, canvas: *Canvas, text: *Text, rect: Rect, label: []const u8, c: Control, baseline: f32) !void { v.controls[@backingInt(c)] = rect; if (v.hovered == c) try canvas.rect(rect.x, rect.y, rect.width, rect.height, theme.current.control_hover); const w = try text.measure(.ui, label); _ = try text.draw(canvas, .ui, label, rect.x + @round((rect.width - w) / 2), baseline, theme.current.foreground);}