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        if let Some(pos) = event.position()
328            && self.v_scrollbar.rect().contains(pos)
329        {
330            self.v_scrollbar.event(event, ctx);
331            return;
332        }
333
334        match event {
335            Event::PointerDown {
336                pos,
337                button: MouseButton::Left,
338                ..
339            } => {
340                ctx.request_focus();
341                if let Some(row) = self.row_at(*pos) {
342                    self.handle_click(row);
343                }
344                ctx.request_paint();
345            }
346            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
347                let consumed = match key {
348                    Key::Named(NamedKey::Up) => {
349                        self.move_selection(-1);
350                        true
351                    }
352                    Key::Named(NamedKey::Down) => {
353                        self.move_selection(1);
354                        true
355                    }
356                    Key::Named(NamedKey::Home) => {
357                        if !self.rows.is_empty() {
358                            self.select_and_show(0);
359                        }
360                        true
361                    }
362                    Key::Named(NamedKey::End) => {
363                        if let Some(last) = self.rows.len().checked_sub(1) {
364                            self.select_and_show(last);
365                        }
366                        true
367                    }
368                    Key::Named(NamedKey::PageUp) => {
369                        self.move_page(-1);
370                        true
371                    }
372                    Key::Named(NamedKey::PageDown) => {
373                        self.move_page(1);
374                        true
375                    }
376                    Key::Named(NamedKey::Enter) => {
377                        if let Some(idx) = self.selected {
378                            self.activated = Some(idx);
379                        }
380                        true
381                    }
382                    _ => false,
383                };
384                if consumed {
385                    ctx.request_paint();
386                }
387            }
388            _ => {}
389        }
390    }
391
392    fn captures_pointer(&self) -> bool {
393        self.v_scrollbar.captures_pointer()
394    }
395
396    fn focusable(&self) -> bool {
397        true
398    }
399
400    fn set_focused(&mut self, focused: bool) {
401        self.focused = focused;
402    }
403
404    fn layout(&mut self, bounds: Rect) {
405        self.rect = bounds;
406        self.v_scrollbar.set_rect(Rect::new(
407            bounds.right() - SCROLLBAR_THICKNESS,
408            bounds.y,
409            SCROLLBAR_THICKNESS,
410            bounds.h,
411        ));
412        self.ensure_selection_visible();
413    }
414}
415
416/// Paint one row of the commit graph in the left gutter starting at `gutter_x`.
417fn draw_graph_row(painter: &mut Painter, row: &GraphRow, gutter_x: i32, y: i32) {
418    let lane_x = |col: usize| gutter_x + col as i32 * LANE_W + LANE_W / 2;
419    let top = y;
420    let center = y + ROW_HEIGHT / 2;
421    let bottom = y + ROW_HEIGHT;
422
423    // Top half: a lane at the top edge curving in to the row center. Color by
424    // the upper lane so a line keeps its color along its length.
425    for &(from, to) in &row.top {
426        draw_line(
427            painter,
428            lane_x(from),
429            top,
430            lane_x(to),
431            center,
432            lane_color(from),
433        );
434    }
435    // Bottom half: from the center down to a lane at the bottom edge. Color by
436    // the lower lane (the lane the segment becomes).
437    for &(from, to) in &row.bottom {
438        draw_line(
439            painter,
440            lane_x(from),
441            center,
442            lane_x(to),
443            bottom,
444            lane_color(to),
445        );
446    }
447    draw_dot(
448        painter,
449        lane_x(row.node_col),
450        center,
451        lane_color(row.node_col),
452    );
453}
454
455/// Bresenham line via single logical pixels (crisp at any DPI). Straight
456/// verticals use the faster `v_line`.
457fn draw_line(painter: &mut Painter, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) {
458    if x0 == x1 {
459        let (a, b) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
460        painter.v_line(x0, a, b - a + 1, color);
461        return;
462    }
463    let dx = (x1 - x0).abs();
464    let dy = -(y1 - y0).abs();
465    let sx = if x0 < x1 { 1 } else { -1 };
466    let sy = if y0 < y1 { 1 } else { -1 };
467    let mut err = dx + dy;
468    let (mut x, mut y) = (x0, y0);
469    loop {
470        painter.pixel(x, y, color);
471        if x == x1 && y == y1 {
472            break;
473        }
474        let e2 = 2 * err;
475        if e2 >= dy {
476            err += dy;
477            x += sx;
478        }
479        if e2 <= dx {
480            err += dx;
481            y += sy;
482        }
483    }
484}
485
486/// A small filled commit dot.
487fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
488    let r = 3;
489    for dy in -r..=r {
490        // Half-width of the disc at this row (circle of radius r).
491        let hw = ((r * r - dy * dy) as f32).sqrt().round() as i32;
492        painter.h_line(cx - hw, cy + dy, hw * 2 + 1, color);
493    }
494}
495
496/// Background color for a ref badge by kind.
497fn badge_color(kind: RefKind) -> Color {
498    match kind {
499        RefKind::Head => Color::rgb(0x7C, 0xE0, 0x7C),
500        RefKind::LocalBranch => Color::rgb(0xC4, 0xF0, 0xC4),
501        RefKind::RemoteBranch => Color::rgb(0xF0, 0xCF, 0x9C),
502        RefKind::Tag => Color::rgb(0xF2, 0xEA, 0x9C),
503        RefKind::DetachedHead => Color::rgb(0xBE, 0xDE, 0xF2),
504    }
505}
506
507/// Draw one ref badge and return its drawn width.
508fn draw_badge(
509    painter: &mut Painter,
510    x: i32,
511    row_y: i32,
512    label: &str,
513    kind: RefKind,
514    font_size: f32,
515) -> i32 {
516    let tw = painter.measure_text(label, font_size).w;
517    let bw = tw + 8;
518    let bh = font_size as i32 + 3;
519    let by = row_y + (ROW_HEIGHT - bh) / 2;
520    let rect = Rect::new(x, by, bw, bh);
521    painter.fill_rect(rect, badge_color(kind));
522    painter.stroke_rect(rect, Color::BLACK);
523    let label_y = row_y + (ROW_HEIGHT - font_size as i32) / 2 - 1;
524    painter.text(x + 4, label_y, label, font_size, Color::BLACK);
525    bw
526}