// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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); }