Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The commit list: a row per commit, with columns for the subject, the//! author and the date, scrolled and selected in. The refs at a commit are//! labels ahead of its subject, as gitk draws them.//!//! Only the rows in view are ever laid out or drawn, so a history of a//! million commits costs no more to show than one of fifty. Positions are//! in physical pixels; `scroll` is how far the top of the first row is//! above the top of the list.
const std = @import("std");const aerie = @import("aerie");const Canvas = aerie.gpu.Canvas;const Shaper = aerie.text.Shaper;
const Clock = @import("../Clock.zig");const History = @import("../history/History.zig");const Layout = @import("../graph/Layout.zig");const theme = @import("theme.zig");const Text = @import("Text.zig");const Scrollbar = @import("Scrollbar.zig");
const CommitList = @This();
/// Whether a row is one the find matched.pub const Matches = struct { context: *const anyopaque, matches: *const fn (context: *const anyopaque, row: usize) bool,};
/// Where the rows are drawn.rect: Canvas.Rect = .{ .x = 0, .y = 0, .width = 0, .height = 0 },/// The scrollbar's track, beside the rows.vtrack: Canvas.Rect = .{ .x = 0, .y = 0, .width = 0, .height = 0 },row_height: f32 = 1,scroll: f32 = 0,selected: ?usize = null,
/// The rows at least partly in view, `first` up to but not including/// `end`, for a list of `count` rows.pub fn visible(l: *const CommitList, count: usize) struct { first: usize, end: usize } { if (count == 0 or l.row_height <= 0) return .{ .first = 0, .end = 0 }; const first: usize = @intFromFloat(@floor(@max(0, l.scroll) / l.row_height)); const end: usize = @intFromFloat(@ceil((@max(0, l.scroll) + l.rect.height) / l.row_height)); return .{ .first = @min(first, count), .end = @min(end, count) };}
/// How far the list can scroll: to where its last row is at the bottom.pub fn maxScroll(l: *const CommitList, count: usize) f32 { return l.scrollbar(count).maxOffset();}
/// Scrolls by `dy` pixels, down when positive, as far as there is list.pub fn scrollBy(l: *CommitList, dy: f32, count: usize) bool { const before = l.scroll; l.scroll = std.math.clamp(l.scroll + dy, 0, l.maxScroll(count)); return l.scroll != before;}
pub fn scrollbar(l: *const CommitList, count: usize) Scrollbar { return .{ .track = l.vtrack, .axis = .vertical, .content = @as(f32, @floatFromInt(count)) * l.row_height, .view = l.rect.height, .offset = l.scroll };}
/// Puts the list back in its bounds after it, or its count, changed.pub fn clamp(l: *CommitList, count: usize) void { l.scroll = std.math.clamp(l.scroll, 0, l.maxScroll(count)); if (l.selected) |s| if (s >= count) { l.selected = if (count > 0) count - 1 else null; };}
/// The row at `y`, if there is one there.pub fn rowAt(l: *const CommitList, y: f32, count: usize) ?usize { if (y < l.rect.y or y >= l.rect.y + l.rect.height) return null; const row: usize = @intFromFloat(@floor((y - l.rect.y + l.scroll) / l.row_height)); return if (row < count) row else null;}
/// Selects `row`, scrolling as little as it takes to bring it into view.pub fn select(l: *CommitList, row: usize, count: usize) void { if (count == 0) return; const r = @min(row, count - 1); l.selected = r; const top = @as(f32, @floatFromInt(r)) * l.row_height; if (top < l.scroll) l.scroll = top; if (top + l.row_height > l.scroll + l.rect.height) l.scroll = top + l.row_height - l.rect.height; l.clamp(count);}
/// How many whole rows are in view: what a page is.pub fn pageRows(l: *const CommitList) usize { return @max(1, @as(usize, @intFromFloat(@floor(l.rect.height / l.row_height))));}
/// Moves the selection by `delta` rows, or to the first row when there is/// none yet.pub fn moveSelection(l: *CommitList, delta: isize, count: usize) void { if (count == 0) return; const from: isize = if (l.selected) |s| @intCast(s) else 0; const to = std.math.clamp(from + delta, 0, @as(isize, @intCast(count - 1))); l.select(@intCast(to), count);}
/// Draws the rows in view: the graph, then the columns beside it.pub fn draw(l: *const CommitList, canvas: *Canvas, text: *Text, history: *const History, layout: *const Layout, clock: *const Clock, marked: ?usize, found: ?Matches, dragging: bool, scale: f32) !void { const count = history.len(); try l.scrollbar(count).draw(canvas, dragging, scale); try canvas.rect(l.rect.x, l.rect.y, l.rect.width, l.rect.height, theme.current.list_background); const saved = canvas.pushClip(l.rect); defer canvas.setClip(saved);
const pad = @round(6 * scale); const v = l.visible(count); const g = graphGeometry(l, layout, v.first, v.end, scale);
const subject_x, const subject_w, const author_w, const date_w = try l.columnWidths(text, g, scale); const columns = [_]struct { x: f32, w: f32 }{ .{ .x = subject_x, .w = subject_w }, .{ .x = subject_x + subject_w, .w = author_w }, .{ .x = subject_x + subject_w + author_w, .w = date_w }, };
for (v.first..v.end) |row| { const c = history.get(row); const top = l.rect.y + @as(f32, @floatFromInt(row)) * l.row_height - l.scroll; const selected = l.selected == row; // The selection is the row's text, not its graph, which keeps its // colors. if (selected) try canvas.rect(subject_x, top, l.rect.width - g.width, l.row_height, theme.current.selection_background); // The labels, in the subject's column, and the subject after them. const labels_w = blk: { const inner = canvas.pushClip(.{ .x = subject_x, .y = top, .width = subject_w, .height = l.row_height }); defer canvas.setClip(inner); break :blk try drawRefs(canvas, text, history.refsOf(c), history, subject_x + pad, top, l.row_height, scale); }; const text_x = subject_x + labels_w; const text_w = @max(0, subject_w - labels_w); if (!selected) if (found) |f| if (f.matches(f.context, row)) { // A match is lit behind its subject, as gitk lights it. const w = @min(try text.measure(.ui, history.text(c.subject)) + 2 * pad, text_w); try canvas.rect(text_x, top + 1, w, l.row_height - 2, theme.current.find_match); }; // The marked commit's subject is boxed, as gitk boxes it. if (marked == row) { const w = @min(try text.measure(.ui, history.text(c.subject)) + 2 * pad, text_w); try canvas.frame(text_x + 1, top + 1, w, l.row_height - 2, @max(1, @round(scale)), if (selected) theme.current.selection_foreground else theme.current.mark); } const fg = if (selected) theme.current.selection_foreground else theme.current.foreground; const dim = if (selected) theme.current.selection_foreground else theme.current.dim_foreground; var date_buf: [32]u8 = undefined;
const cells = [_]struct { s: []const u8, color: [4]u8 }{ .{ .s = history.text(c.subject), .color = fg }, .{ .s = history.text(history.person(c.author).name), .color = dim }, .{ .s = if (c.pseudo == .none) clock.short(c.time, &date_buf) else "", .color = dim }, }; var cols = columns; cols[0] = .{ .x = text_x, .w = text_w }; for (cols, cells) |col, cell| { if (col.w <= 2 * pad) continue; const inner = canvas.pushClip(.{ .x = col.x + pad, .y = top, .width = col.w - 2 * pad, .height = l.row_height }); defer canvas.setClip(inner); _ = try text.draw(canvas, .ui, cell.s, col.x + pad, top + text.baselineIn(.ui, l.row_height), cell.color); } }
try drawGraph(l, canvas, history, layout, v.first, @min(v.end, layout.len()), g, scale);}
/// Where the subject's column starts, and how wide it, the author's and/// the date's are, beside a graph laid out as `g`.fn columnWidths(l: *const CommitList, text: *Text, g: Geometry, scale: f32) !struct { f32, f32, f32, f32 } { const pad = @round(6 * scale); const date_w = try text.measure(.ui, "0000-00-00 00:00") + 2 * pad; const author_w = std.math.clamp(@round(l.rect.width * 0.2), @round(120 * scale), @round(240 * scale)); const subject_w = @max(0, l.rect.width - g.width - date_w - author_w); return .{ l.rect.x + g.width, subject_w, author_w, date_w };}
/// A ref's label at a row.pub const RefHit = struct { row: usize, /// Which of the row's refs, in `History.refsOf`'s order. index: usize,};
/// The label at `x`, `y`, if a ref's label is there.pub fn refAt(l: *const CommitList, text: *Text, history: *const History, layout: *const Layout, x: f32, y: f32, scale: f32) !?RefHit { const row = l.rowAt(y, history.len()) orelse return null; const v = l.visible(history.len()); const g = graphGeometry(l, layout, v.first, v.end, scale); const subject_x, const subject_w, _, _ = try l.columnWidths(text, g, scale); if (x < subject_x or x >= subject_x + subject_w) return null; const m = labelMetrics(scale, l.row_height); var left = subject_x + @round(6 * scale); for (history.refsOf(history.get(row)), 0..) |r, i| { const w = try labelWidth(text, r, history, m); if (x >= left and x < left + w) return .{ .row = row, .index = i }; left += w + m.gap; } return null;}
/// How a ref's label is drawn: inset from the row's top and bottom, padded/// either side of its name, and apart from the next. A tag's label is cut/// to a point on its left, like a luggage tag, `point` deep: half its/// height, so that the cut is at forty-five degrees.fn labelMetrics(scale: f32, row_height: f32) struct { inset: f32, pad: f32, gap: f32, border: f32, point: f32 } { const inset = @max(1, @round(2 * scale)); return .{ .inset = inset, .pad = @round(4 * scale), .gap = @round(4 * scale), .border = @max(1, @round(scale)), .point = @round((row_height - 2 * inset) / 2), };}
/// How wide `r`'s label is, its point included.fn labelWidth(text: *Text, r: History.Ref, history: *const History, m: anytype) !f32 { const w = try text.measure(.ui, history.text(r.name)) + 2 * m.pad; return if (r.kind == .tag) w + m.point else w;}
/// Draws a label for each of `refs` from `x` along the row at `top`, and/// returns how much of the row they took, with the gap after the last; none/// when there are no refs. The branch checked out has a heavier border,/// where gitk has it in bold.fn drawRefs(canvas: *Canvas, text: *Text, refs: []const History.Ref, history: *const History, x0: f32, top: f32, row_height: f32, scale: f32) !f32 { if (refs.len == 0) return 0; const m = labelMetrics(scale, row_height); const pad = m.pad; const gap = m.gap; var x = x0; for (refs) |r| { const name = history.text(r.name); const w = try labelWidth(text, r, history, m); const fill = switch (r.kind) { .head => theme.current.ref_head, .remote => theme.current.ref_remote, .tag => theme.current.ref_tag, .other => theme.current.ref_other, }; const border = if (r.current) 2 * m.border else m.border; const y = top + m.inset; const h = row_height - 2 * m.inset; if (r.kind == .tag) { try drawTag(canvas, x, y, w, h, m.point, border, fill, scale); } else { try canvas.rect(x, y, w, h, fill); try canvas.frame(x, y, w, h, border, theme.current.ref_border); } const text_x = if (r.kind == .tag) x + m.point + pad else x + pad; _ = try text.draw(canvas, .ui, name, text_x, top + text.baselineIn(.ui, row_height), theme.current.ref_foreground); x += w + gap; } return x - x0;}
/// A tag's label, `w` by `h` at `x`, `y`, with its left `point` cut to a/// point and a hole punched through it: the whole shape in the border's/// color, and the fill over it inset by `border`, as `Canvas.frame` has its/// border inside the rectangle.fn drawTag(canvas: *Canvas, x: f32, y: f32, w: f32, h: f32, point: f32, border: f32, fill: theme.Color, scale: f32) !void { const mid = y + h / 2; const half = h / 2; const color = theme.current.ref_border; try canvas.polygon(&.{ .{ .x = x, .y = mid }, .{ .x = x + point, .y = y }, .{ .x = x + w, .y = y }, .{ .x = x + w, .y = y + h }, .{ .x = x + point, .y = y + h }, }, color); // A slanted edge moved `border` in along its normal is `border` times // its length over its height further right, all the way along it. const shift = border * @sqrt(point * point + half * half) / half; // Where it meets the inset top and bottom, `border` nearer the middle. const corner = x + shift + (half - border) * point / half; try canvas.polygon(&.{ .{ .x = x + shift, .y = mid }, .{ .x = corner, .y = y + border }, .{ .x = x + w - border, .y = y + border }, .{ .x = x + w - border, .y = y + h - border }, .{ .x = corner, .y = y + h - border }, }, fill); try canvas.disc(x + point * 0.55, mid, @max(1, 1.5 * scale), color);}
/// How the graph is laid out in pixels for the rows in view.const Geometry = struct { /// The width of a lane, and of the whole graph with its margin. lane_width: f32, width: f32,};
fn graphGeometry(l: *const CommitList, layout: *const Layout, first: usize, end: usize, scale: f32) Geometry { const lane_width = @round(14 * scale); // As wide as the widest row in view needs, so that the text beside it // is not pushed off by lanes far away; at most two fifths of the list. var lanes: usize = 1; for (first..@min(end, layout.len())) |row| lanes = @max(lanes, layout.rows.items[row].width); const wanted = @as(f32, @floatFromInt(lanes)) * lane_width + @round(4 * scale); return .{ .lane_width = lane_width, .width = @min(wanted, @round(l.rect.width * 0.4)) };}
fn drawGraph(l: *const CommitList, canvas: *Canvas, history: *const History, layout: *const Layout, first: usize, end: usize, g: Geometry, scale: f32) !void { const inner = canvas.pushClip(.{ .x = l.rect.x, .y = l.rect.y, .width = g.width, .height = l.rect.height }); defer canvas.setClip(inner);
const line_width = @max(1.5, 2 * scale); const x0 = l.rect.x + @round(2 * scale) + g.lane_width / 2; const half = l.row_height / 2; for (first..end) |row| { const r = &layout.rows.items[row]; const top = l.rect.y + @as(f32, @floatFromInt(row)) * l.row_height - l.scroll; const mid = top + half; const bottom = top + l.row_height; for (layout.edgesOf(r)) |e| { const xf = x0 + @as(f32, @floatFromInt(e.from)) * g.lane_width; const xt = x0 + @as(f32, @floatFromInt(e.to)) * g.lane_width; const color = theme.graphColor(e.color); switch (e.half) { .top => try canvas.line(xf, top, xt, mid, line_width, color), .bottom => try canvas.line(xf, mid, xt, bottom, line_width, color), .full => try canvas.line(xf, top, xf, bottom, line_width, color), } } // The node, ringed in the background so that lines meeting it do // not run into one another. const x = x0 + @as(f32, @floatFromInt(r.lane)) * g.lane_width; const radius = @round(4 * scale); try canvas.disc(x, mid, radius + @max(1, @round(scale)), theme.current.list_background); // A pseudo-commit's node is gitk's: red for the working tree, // green for the index. const node_color = switch (history.get(row).pseudo) { .none => theme.graphColor(r.color), .working_tree => theme.current.pseudo_working_tree, .index => theme.current.pseudo_index, }; try canvas.disc(x, mid, radius, node_color); }}
// ---------------------------------------------------------------------------
const testing = std.testing;
fn testList() CommitList { return .{ .rect = .{ .x = 0, .y = 100, .width = 400, .height = 200 }, .row_height = 20 };}
test "only the rows in view are visible" { var l = testList(); try testing.expectEqual(@as(usize, 0), l.visible(1000).first); try testing.expectEqual(@as(usize, 10), l.visible(1000).end); l.scroll = 15; // Half of row 0 is still there, and part of row 10. try testing.expectEqual(@as(usize, 0), l.visible(1000).first); try testing.expectEqual(@as(usize, 11), l.visible(1000).end); try testing.expectEqual(@as(usize, 3), l.visible(3).end); try testing.expectEqual(@as(usize, 0), l.visible(0).end);}
test "scrolling stops at either end" { var l = testList(); try testing.expect(!l.scrollBy(-50, 100)); try testing.expect(l.scrollBy(10_000, 100)); try testing.expectEqual(@as(f32, 100 * 20 - 200), l.scroll); // A list shorter than its place does not scroll at all. l.scroll = 0; try testing.expect(!l.scrollBy(50, 5));}
test "a click finds its row" { var l = testList(); try testing.expectEqual(@as(?usize, 0), l.rowAt(100, 50)); try testing.expectEqual(@as(?usize, 1), l.rowAt(125, 50)); try testing.expectEqual(@as(?usize, null), l.rowAt(99, 50)); try testing.expectEqual(@as(?usize, null), l.rowAt(125, 1)); l.scroll = 30; try testing.expectEqual(@as(?usize, 2), l.rowAt(110, 50));}
test "selecting scrolls as little as it can" { var l = testList(); l.select(12, 100); // Row 12 is at the bottom. try testing.expectEqual(@as(f32, 13 * 20 - 200), l.scroll); l.select(11, 100); try testing.expectEqual(@as(f32, 13 * 20 - 200), l.scroll); l.select(0, 100); try testing.expectEqual(@as(f32, 0), l.scroll); l.select(500, 100); try testing.expectEqual(@as(?usize, 99), l.selected);}
test "moving the selection stays in the list" { var l = testList(); l.moveSelection(1, 5); try testing.expectEqual(@as(?usize, 1), l.selected); l.moveSelection(-10, 5); try testing.expectEqual(@as(?usize, 0), l.selected); l.moveSelection(@intCast(l.pageRows()), 5); try testing.expectEqual(@as(?usize, 4), l.selected);}