Skip to main content

journey/widgets/
commit_list.rs

1//! The commit history list — a gitk-style scrollable list whose rows carry
2//! colored ref badges (branches, tags, HEAD) on the left and author / date
3//! columns on the right.
4//!
5//! It's a specialization of saudade's `List`: the selection, scrolling and
6//! keyboard behavior are the same, but each row is custom-painted so the refs
7//! render as little colored boxes and a graph gutter can be slotted in front
8//! later (Phase 6). Kept in journey rather than saudade because the row
9//! content is git-specific.
10
11use std::time::{Duration, Instant};
12
13use saudade::{
14    Color, Event, EventCtx, Key, MouseButton, NamedKey, Painter, Point, Rect, SCROLLBAR_THICKNESS,
15    ScrollBar, Theme, Widget,
16};
17
18use crate::backend::{RefKind, RefLabel};
19use crate::widgets::graph::{Graph, GraphRow};
20
21const ROW_HEIGHT: i32 = 18;
22const TEXT_PAD_X: i32 = 4;
23const TEXT_PAD_Y: i32 = 2;
24const COL_GAP: i32 = 12;
25const AUTHOR_COL_W: i32 = 120;
26const BADGE_GAP: i32 = 3;
27const DOUBLE_CLICK_MS: u64 = 400;
28
29/// Logical width of one graph lane.
30const LANE_W: i32 = 14;
31
32/// gitk-style lane palette, indexed by lane column (wraps).
33const LANE_COLORS: [Color; 7] = [
34    Color::rgb(0x00, 0x80, 0x00),
35    Color::rgb(0xC0, 0x00, 0x00),
36    Color::rgb(0x00, 0x00, 0xC0),
37    Color::rgb(0xA0, 0x00, 0xA0),
38    Color::rgb(0x00, 0x80, 0x80),
39    Color::rgb(0xB0, 0x60, 0x00),
40    Color::rgb(0x50, 0x50, 0x50),
41];
42
43fn lane_color(col: usize) -> Color {
44    LANE_COLORS[col % LANE_COLORS.len()]
45}
46
47/// One commit's worth of row content.
48#[derive(Clone, Default)]
49pub struct CommitRow {
50    pub id: String,
51    pub parents: Vec<String>,
52    pub summary: String,
53    pub refs: Vec<RefLabel>,
54    pub author: String,
55    pub date: String,
56}
57
58pub struct CommitList {
59    rect: Rect,
60    rows: Vec<CommitRow>,
61    /// Optional precomputed DAG layout, aligned 1:1 with `rows`. Present only
62    /// when showing full (unfiltered) history.
63    graph: Option<Graph>,
64    selected: Option<usize>,
65    focused: bool,
66    v_scrollbar: ScrollBar,
67    activated: Option<usize>,
68    last_click: Option<(usize, Instant)>,
69    font_size: f32,
70}
71
72impl CommitList {
73    pub fn new(rect: Rect) -> Self {
74        Self {
75            rect,
76            rows: Vec::new(),
77            graph: None,
78            selected: None,
79            focused: false,
80            v_scrollbar: ScrollBar::vertical(Rect::new(0, 0, 0, 0)),
81            activated: None,
82            last_click: None,
83            font_size: 12.0,
84        }
85    }
86
87    /// Attach (or clear) the DAG graph. Must be aligned with the current rows.
88    pub fn set_graph(&mut self, graph: Option<Graph>) {
89        self.graph = graph;
90    }
91
92    /// Logical width reserved for the graph gutter (0 when no graph).
93    fn graph_width(&self) -> i32 {
94        match &self.graph {
95            Some(g) => g.lane_count as i32 * LANE_W,
96            None => 0,
97        }
98    }
99
100    pub fn with_rows(mut self, rows: Vec<CommitRow>) -> Self {
101        self.set_rows(rows);
102        self
103    }
104
105    pub fn set_rows(&mut self, rows: Vec<CommitRow>) {
106        self.rows = rows;
107        if let Some(idx) = self.selected
108            && idx >= self.rows.len()
109        {
110            self.selected = None;
111        }
112        self.activated = None;
113        self.last_click = None;
114        self.v_scrollbar.set_value(0);
115    }
116
117    pub fn len(&self) -> usize {
118        self.rows.len()
119    }
120
121    pub fn is_empty(&self) -> bool {
122        self.rows.is_empty()
123    }
124
125    pub fn selected_index(&self) -> Option<usize> {
126        self.selected
127    }
128
129    pub fn set_selected(&mut self, idx: Option<usize>) {
130        self.selected = idx.filter(|&i| i < self.rows.len());
131        self.ensure_selection_visible();
132    }
133
134    pub fn take_activated(&mut self) -> Option<usize> {
135        self.activated.take()
136    }
137
138    fn text_area(&self) -> Rect {
139        // When the scrollbar is present the field overlaps it by 1px so the
140        // field's right border lands on the scrollbar's own left-border column,
141        // collapsing the divider to a single 1px line instead of stacking the
142        // two 1px borders into a 2px band. The scrollbar is painted last, on
143        // top, so that shared column reads as the scrollbar's edge — exactly
144        // how saudade's `List` does it.
145        let (sb_w, overlap) = if self.v_scrollbar.rect().w > 0 {
146            (SCROLLBAR_THICKNESS, 1)
147        } else {
148            (0, 0)
149        };
150        Rect::new(
151            self.rect.x,
152            self.rect.y,
153            (self.rect.w - sb_w + overlap).max(0),
154            self.rect.h,
155        )
156    }
157
158    fn visible_rows(&self) -> i32 {
159        ((self.text_area().h - TEXT_PAD_Y * 2) / ROW_HEIGHT).max(1)
160    }
161
162    fn scroll_top(&self) -> usize {
163        self.v_scrollbar.value().max(0) as usize
164    }
165
166    fn set_scroll_top(&mut self, top: usize) {
167        self.v_scrollbar.set_value(top as i32);
168    }
169
170    fn sync_scrollbar(&mut self) {
171        let visible = self.visible_rows();
172        let max_scroll = (self.rows.len() as i32 - visible).max(0);
173        self.v_scrollbar.set_range(visible, max_scroll);
174    }
175
176    fn ensure_selection_visible(&mut self) {
177        self.sync_scrollbar();
178        let Some(idx) = self.selected else { return };
179        let visible = self.visible_rows() as usize;
180        let mut top = self.scroll_top();
181        if idx < top {
182            top = idx;
183        } else if idx >= top + visible {
184            top = idx + 1 - visible;
185        }
186        self.set_scroll_top(top);
187    }
188
189    fn row_at(&self, pos: Point) -> Option<usize> {
190        let text = self.text_area();
191        if !text.contains(pos) {
192            return None;
193        }
194        let local_y = pos.y - text.y - TEXT_PAD_Y;
195        if local_y < 0 {
196            return None;
197        }
198        let row = self.scroll_top() + (local_y / ROW_HEIGHT) as usize;
199        if row < self.rows.len() {
200            Some(row)
201        } else {
202            None
203        }
204    }
205
206    fn select_and_show(&mut self, idx: usize) {
207        self.selected = Some(idx);
208        self.ensure_selection_visible();
209    }
210
211    fn move_selection(&mut self, delta: i32) {
212        if self.rows.is_empty() {
213            return;
214        }
215        let cur = self.selected.unwrap_or(0) as i32;
216        let next = (cur + delta).clamp(0, self.rows.len() as i32 - 1);
217        self.select_and_show(next as usize);
218    }
219
220    fn move_page(&mut self, pages: i32) {
221        let step = (self.visible_rows() - 1).max(1);
222        self.move_selection(pages * step);
223    }
224
225    fn handle_click(&mut self, idx: usize) {
226        let now = Instant::now();
227        let threshold = Duration::from_millis(DOUBLE_CLICK_MS);
228        let double = self
229            .last_click
230            .map(|(prev_idx, prev_time)| {
231                prev_idx == idx && now.duration_since(prev_time) <= threshold
232            })
233            .unwrap_or(false);
234        self.select_and_show(idx);
235        if double {
236            self.activated = Some(idx);
237            self.last_click = None;
238        } else {
239            self.last_click = Some((idx, now));
240        }
241    }
242}
243
244impl Widget for CommitList {
245    fn bounds(&self) -> Rect {
246        self.rect
247    }
248
249    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
250        self.sync_scrollbar();
251        let text = self.text_area();
252        painter.fill_rect(text, Color::WHITE);
253        painter.sunken_bevel(text, theme.highlight, theme.shadow);
254        painter.stroke_rect(text, theme.border);
255
256        let text_x = text.x + TEXT_PAD_X;
257        let text_y0 = text.y + TEXT_PAD_Y;
258        let row_w = (text.w - TEXT_PAD_X * 2).max(0);
259        let visible = self.visible_rows() as usize;
260        let scroll_top = self.scroll_top();
261        let row_right = text.right() - TEXT_PAD_X;
262        let graph_w = self.graph_width();
263
264        for row_offset in 0..visible {
265            let row = scroll_top + row_offset;
266            let Some(data) = self.rows.get(row) else {
267                break;
268            };
269            let y = text_y0 + row_offset as i32 * ROW_HEIGHT;
270            let selected = self.selected == Some(row);
271            let active = selected && self.focused;
272            if selected {
273                let bg = if self.focused {
274                    theme.highlight_bg
275                } else {
276                    theme.face
277                };
278                painter.fill_rect(Rect::new(text_x, y, row_w, ROW_HEIGHT), bg);
279            }
280            let fg = if active {
281                theme.highlight_text
282            } else {
283                theme.text
284            };
285
286            // Graph gutter, if present, in its own column at the far left.
287            if let Some(graph) = &self.graph
288                && let Some(grow) = graph.rows.get(row)
289            {
290                draw_graph_row(painter, grow, text_x, y);
291            }
292
293            // Right-aligned date, then author column to its left.
294            let date_size = painter.measure_text(&data.date, self.font_size);
295            let date_x = row_right - date_size.w;
296            let author_x = date_x - COL_GAP - AUTHOR_COL_W;
297            let label_y = y + (ROW_HEIGHT - self.font_size as i32) / 2 - 1;
298
299            painter.text(date_x, label_y, &data.date, self.font_size, fg);
300
301            let author_clip = Rect::new(author_x, y, AUTHOR_COL_W, ROW_HEIGHT);
302            let saved = painter.push_clip(author_clip);
303            painter.text(author_x, label_y, &data.author, self.font_size, fg);
304            painter.restore_clip(saved);
305
306            // Left side: graph gutter (reserved), ref badges, then summary.
307            let mut x = text_x + graph_w + 2;
308            for r in &data.refs {
309                x += draw_badge(painter, x, y, &r.name, r.kind, self.font_size) + BADGE_GAP;
310            }
311            let summary_right = author_x - COL_GAP;
312            if summary_right > x {
313                let saved = painter.push_clip(Rect::new(x, y, summary_right - x, ROW_HEIGHT));
314                painter.text(x, label_y, &data.summary, self.font_size, fg);
315                painter.restore_clip(saved);
316            }
317        }
318
319        self.v_scrollbar.paint(painter, theme);
320    }
321
322    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
323        if self.v_scrollbar.captures_pointer() {
324            self.v_scrollbar.event(event, ctx);
325            return;
326        }
327        // The wheel scrolls the list whenever the pointer is anywhere over
328        // it — not just over the scrollbar gutter — without disturbing the
329        // selection, matching native list boxes.
330        if let Event::Scroll { pos, .. } = event {
331            if self.rect.contains(*pos) {
332                self.v_scrollbar.event(event, ctx);
333            }
334            return;
335        }
336        if let Some(pos) = event.position()
337            && self.v_scrollbar.rect().contains(pos)
338        {
339            self.v_scrollbar.event(event, ctx);
340            return;
341        }
342
343        match event {
344            Event::PointerDown {
345                pos,
346                button: MouseButton::Left,
347                ..
348            } => {
349                ctx.request_focus();
350                if let Some(row) = self.row_at(*pos) {
351                    self.handle_click(row);
352                }
353                ctx.request_paint();
354            }
355            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
356                let consumed = match key {
357                    Key::Named(NamedKey::Up) => {
358                        self.move_selection(-1);
359                        true
360                    }
361                    Key::Named(NamedKey::Down) => {
362                        self.move_selection(1);
363                        true
364                    }
365                    Key::Named(NamedKey::Home) => {
366                        if !self.rows.is_empty() {
367                            self.select_and_show(0);
368                        }
369                        true
370                    }
371                    Key::Named(NamedKey::End) => {
372                        if let Some(last) = self.rows.len().checked_sub(1) {
373                            self.select_and_show(last);
374                        }
375                        true
376                    }
377                    Key::Named(NamedKey::PageUp) => {
378                        self.move_page(-1);
379                        true
380                    }
381                    Key::Named(NamedKey::PageDown) => {
382                        self.move_page(1);
383                        true
384                    }
385                    Key::Named(NamedKey::Enter) => {
386                        if let Some(idx) = self.selected {
387                            self.activated = Some(idx);
388                        }
389                        true
390                    }
391                    _ => false,
392                };
393                if consumed {
394                    ctx.request_paint();
395                }
396            }
397            _ => {}
398        }
399    }
400
401    fn captures_pointer(&self) -> bool {
402        self.v_scrollbar.captures_pointer()
403    }
404
405    fn focusable(&self) -> bool {
406        true
407    }
408
409    fn set_focused(&mut self, focused: bool) {
410        self.focused = focused;
411    }
412
413    fn layout(&mut self, bounds: Rect) {
414        self.rect = bounds;
415        self.v_scrollbar.set_rect(Rect::new(
416            bounds.right() - SCROLLBAR_THICKNESS,
417            bounds.y,
418            SCROLLBAR_THICKNESS,
419            bounds.h,
420        ));
421        self.ensure_selection_visible();
422    }
423}
424
425/// Paint one row of the commit graph in the left gutter starting at `gutter_x`.
426fn draw_graph_row(painter: &mut Painter, row: &GraphRow, gutter_x: i32, y: i32) {
427    let lane_x = |col: usize| gutter_x + col as i32 * LANE_W + LANE_W / 2;
428    let top = y;
429    let center = y + ROW_HEIGHT / 2;
430    let bottom = y + ROW_HEIGHT;
431
432    // Top half: a lane at the top edge curving in to the row center. Color by
433    // the upper lane so a line keeps its color along its length.
434    for &(from, to) in &row.top {
435        draw_line(
436            painter,
437            lane_x(from),
438            top,
439            lane_x(to),
440            center,
441            lane_color(from),
442        );
443    }
444    // Bottom half: from the center down to a lane at the bottom edge. Color by
445    // the lower lane (the lane the segment becomes).
446    for &(from, to) in &row.bottom {
447        draw_line(
448            painter,
449            lane_x(from),
450            center,
451            lane_x(to),
452            bottom,
453            lane_color(to),
454        );
455    }
456    draw_dot(
457        painter,
458        lane_x(row.node_col),
459        center,
460        lane_color(row.node_col),
461    );
462}
463
464/// Bresenham line via single logical pixels (crisp at any DPI). Straight
465/// verticals use the faster `v_line`.
466fn draw_line(painter: &mut Painter, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) {
467    if x0 == x1 {
468        let (a, b) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
469        painter.v_line(x0, a, b - a + 1, color);
470        return;
471    }
472    let dx = (x1 - x0).abs();
473    let dy = -(y1 - y0).abs();
474    let sx = if x0 < x1 { 1 } else { -1 };
475    let sy = if y0 < y1 { 1 } else { -1 };
476    let mut err = dx + dy;
477    let (mut x, mut y) = (x0, y0);
478    loop {
479        painter.pixel(x, y, color);
480        if x == x1 && y == y1 {
481            break;
482        }
483        let e2 = 2 * err;
484        if e2 >= dy {
485            err += dy;
486            x += sx;
487        }
488        if e2 <= dx {
489            err += dx;
490            y += sy;
491        }
492    }
493}
494
495/// A small filled commit dot.
496fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
497    let r = 3;
498    for dy in -r..=r {
499        // Half-width of the disc at this row (circle of radius r).
500        let hw = ((r * r - dy * dy) as f32).sqrt().round() as i32;
501        painter.h_line(cx - hw, cy + dy, hw * 2 + 1, color);
502    }
503}
504
505/// Background color for a ref badge by kind.
506fn badge_color(kind: RefKind) -> Color {
507    match kind {
508        RefKind::Head => Color::rgb(0x7C, 0xE0, 0x7C),
509        RefKind::LocalBranch => Color::rgb(0xC4, 0xF0, 0xC4),
510        RefKind::RemoteBranch => Color::rgb(0xF0, 0xCF, 0x9C),
511        RefKind::Tag => Color::rgb(0xF2, 0xEA, 0x9C),
512        RefKind::DetachedHead => Color::rgb(0xBE, 0xDE, 0xF2),
513    }
514}
515
516/// Draw one ref badge and return its drawn width.
517fn draw_badge(
518    painter: &mut Painter,
519    x: i32,
520    row_y: i32,
521    label: &str,
522    kind: RefKind,
523    font_size: f32,
524) -> i32 {
525    let tw = painter.measure_text(label, font_size).w;
526    let bw = tw + 8;
527    let bh = font_size as i32 + 3;
528    let by = row_y + (ROW_HEIGHT - bh) / 2;
529    let rect = Rect::new(x, by, bw, bh);
530    painter.fill_rect(rect, badge_color(kind));
531    painter.stroke_rect(rect, Color::BLACK);
532    let label_y = row_y + (ROW_HEIGHT - font_size as i32) / 2 - 1;
533    painter.text(x + 4, label_y, label, font_size, Color::BLACK);
534    bw
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use saudade::mock::MockBackend;
541
542    const W: i32 = 320;
543    const H: i32 = 200;
544
545    fn scroll(x: i32, y: i32, delta_y: f32) -> Event {
546        Event::Scroll {
547            pos: Point::new(x, y),
548            delta_x: 0.0,
549            delta_y,
550        }
551    }
552
553    /// A list with more rows than fit, so it can actually scroll.
554    fn long_list() -> (MockBackend, CommitList) {
555        let rows = (0..40)
556            .map(|i| CommitRow {
557                id: format!("{i:040x}"),
558                summary: format!("commit {i}"),
559                ..CommitRow::default()
560            })
561            .collect();
562        let be = MockBackend::new(W, H).with_scale(1.0);
563        let mut list = CommitList::new(Rect::new(0, 0, W, H)).with_rows(rows);
564        list.set_selected(Some(0));
565        list.layout(Rect::new(0, 0, W, H));
566        let _ = be.render(&mut list);
567        (be, list)
568    }
569
570    #[test]
571    fn the_wheel_scrolls_the_list_without_touching_the_selection() {
572        let (be, mut list) = long_list();
573        assert_eq!(list.scroll_top(), 0);
574
575        be.dispatch(&mut list, &scroll(W / 2, H / 2, 3.0));
576        assert_eq!(list.scroll_top(), 3, "one notch scrolls three rows down");
577        assert_eq!(list.selected_index(), Some(0), "selection is untouched");
578
579        be.dispatch(&mut list, &scroll(W / 2, H / 2, -3.0));
580        assert_eq!(list.scroll_top(), 0, "scrolling back returns to the top");
581    }
582
583    #[test]
584    fn a_wheel_event_outside_the_list_is_ignored() {
585        let (be, mut list) = long_list();
586        be.dispatch(&mut list, &scroll(W + 10, H + 10, 3.0));
587        assert_eq!(list.scroll_top(), 0);
588    }
589}