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        let sb_w = if self.v_scrollbar.rect().w > 0 {
140            SCROLLBAR_THICKNESS
141        } else {
142            0
143        };
144        Rect::new(
145            self.rect.x,
146            self.rect.y,
147            (self.rect.w - sb_w).max(0),
148            self.rect.h,
149        )
150    }
151
152    fn visible_rows(&self) -> i32 {
153        ((self.text_area().h - TEXT_PAD_Y * 2) / ROW_HEIGHT).max(1)
154    }
155
156    fn scroll_top(&self) -> usize {
157        self.v_scrollbar.value().max(0) as usize
158    }
159
160    fn set_scroll_top(&mut self, top: usize) {
161        self.v_scrollbar.set_value(top as i32);
162    }
163
164    fn sync_scrollbar(&mut self) {
165        let visible = self.visible_rows();
166        let max_scroll = (self.rows.len() as i32 - visible).max(0);
167        self.v_scrollbar.set_range(visible, max_scroll);
168    }
169
170    fn ensure_selection_visible(&mut self) {
171        self.sync_scrollbar();
172        let Some(idx) = self.selected else { return };
173        let visible = self.visible_rows() as usize;
174        let mut top = self.scroll_top();
175        if idx < top {
176            top = idx;
177        } else if idx >= top + visible {
178            top = idx + 1 - visible;
179        }
180        self.set_scroll_top(top);
181    }
182
183    fn row_at(&self, pos: Point) -> Option<usize> {
184        let text = self.text_area();
185        if !text.contains(pos) {
186            return None;
187        }
188        let local_y = pos.y - text.y - TEXT_PAD_Y;
189        if local_y < 0 {
190            return None;
191        }
192        let row = self.scroll_top() + (local_y / ROW_HEIGHT) as usize;
193        if row < self.rows.len() {
194            Some(row)
195        } else {
196            None
197        }
198    }
199
200    fn select_and_show(&mut self, idx: usize) {
201        self.selected = Some(idx);
202        self.ensure_selection_visible();
203    }
204
205    fn move_selection(&mut self, delta: i32) {
206        if self.rows.is_empty() {
207            return;
208        }
209        let cur = self.selected.unwrap_or(0) as i32;
210        let next = (cur + delta).clamp(0, self.rows.len() as i32 - 1);
211        self.select_and_show(next as usize);
212    }
213
214    fn move_page(&mut self, pages: i32) {
215        let step = (self.visible_rows() - 1).max(1);
216        self.move_selection(pages * step);
217    }
218
219    fn handle_click(&mut self, idx: usize) {
220        let now = Instant::now();
221        let threshold = Duration::from_millis(DOUBLE_CLICK_MS);
222        let double = self
223            .last_click
224            .map(|(prev_idx, prev_time)| {
225                prev_idx == idx && now.duration_since(prev_time) <= threshold
226            })
227            .unwrap_or(false);
228        self.select_and_show(idx);
229        if double {
230            self.activated = Some(idx);
231            self.last_click = None;
232        } else {
233            self.last_click = Some((idx, now));
234        }
235    }
236}
237
238impl Widget for CommitList {
239    fn bounds(&self) -> Rect {
240        self.rect
241    }
242
243    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
244        self.sync_scrollbar();
245        let text = self.text_area();
246        painter.fill_rect(text, Color::WHITE);
247        painter.sunken_bevel(text, theme.highlight, theme.shadow);
248        painter.stroke_rect(text, theme.border);
249
250        let text_x = text.x + TEXT_PAD_X;
251        let text_y0 = text.y + TEXT_PAD_Y;
252        let row_w = (text.w - TEXT_PAD_X * 2).max(0);
253        let visible = self.visible_rows() as usize;
254        let scroll_top = self.scroll_top();
255        let row_right = text.right() - TEXT_PAD_X;
256        let graph_w = self.graph_width();
257
258        for row_offset in 0..visible {
259            let row = scroll_top + row_offset;
260            let Some(data) = self.rows.get(row) else {
261                break;
262            };
263            let y = text_y0 + row_offset as i32 * ROW_HEIGHT;
264            let selected = self.selected == Some(row);
265            let active = selected && self.focused;
266            if selected {
267                let bg = if self.focused {
268                    theme.highlight_bg
269                } else {
270                    theme.face
271                };
272                painter.fill_rect(Rect::new(text_x, y, row_w, ROW_HEIGHT), bg);
273            }
274            let fg = if active {
275                theme.highlight_text
276            } else {
277                theme.text
278            };
279
280            // Graph gutter, if present, in its own column at the far left.
281            if let Some(graph) = &self.graph
282                && let Some(grow) = graph.rows.get(row)
283            {
284                draw_graph_row(painter, grow, text_x, y);
285            }
286
287            // Right-aligned date, then author column to its left.
288            let date_size = painter.measure_text(&data.date, self.font_size);
289            let date_x = row_right - date_size.w;
290            let author_x = date_x - COL_GAP - AUTHOR_COL_W;
291            let label_y = y + (ROW_HEIGHT - self.font_size as i32) / 2 - 1;
292
293            painter.text(date_x, label_y, &data.date, self.font_size, fg);
294
295            let author_clip = Rect::new(author_x, y, AUTHOR_COL_W, ROW_HEIGHT);
296            let saved = painter.push_clip(author_clip);
297            painter.text(author_x, label_y, &data.author, self.font_size, fg);
298            painter.restore_clip(saved);
299
300            // Left side: graph gutter (reserved), ref badges, then summary.
301            let mut x = text_x + graph_w + 2;
302            for r in &data.refs {
303                x += draw_badge(painter, x, y, &r.name, r.kind, self.font_size) + BADGE_GAP;
304            }
305            let summary_right = author_x - COL_GAP;
306            if summary_right > x {
307                let saved = painter.push_clip(Rect::new(x, y, summary_right - x, ROW_HEIGHT));
308                painter.text(x, label_y, &data.summary, self.font_size, fg);
309                painter.restore_clip(saved);
310            }
311        }
312
313        self.v_scrollbar.paint(painter, theme);
314    }
315
316    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
317        if self.v_scrollbar.captures_pointer() {
318            self.v_scrollbar.event(event, ctx);
319            return;
320        }
321        if let Some(pos) = event.position()
322            && self.v_scrollbar.rect().contains(pos)
323        {
324            self.v_scrollbar.event(event, ctx);
325            return;
326        }
327
328        match event {
329            Event::PointerDown {
330                pos,
331                button: MouseButton::Left,
332            } => {
333                ctx.request_focus();
334                if let Some(row) = self.row_at(*pos) {
335                    self.handle_click(row);
336                }
337                ctx.request_paint();
338            }
339            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
340                let consumed = match key {
341                    Key::Named(NamedKey::Up) => {
342                        self.move_selection(-1);
343                        true
344                    }
345                    Key::Named(NamedKey::Down) => {
346                        self.move_selection(1);
347                        true
348                    }
349                    Key::Named(NamedKey::Home) => {
350                        if !self.rows.is_empty() {
351                            self.select_and_show(0);
352                        }
353                        true
354                    }
355                    Key::Named(NamedKey::End) => {
356                        if let Some(last) = self.rows.len().checked_sub(1) {
357                            self.select_and_show(last);
358                        }
359                        true
360                    }
361                    Key::Named(NamedKey::PageUp) => {
362                        self.move_page(-1);
363                        true
364                    }
365                    Key::Named(NamedKey::PageDown) => {
366                        self.move_page(1);
367                        true
368                    }
369                    Key::Named(NamedKey::Enter) => {
370                        if let Some(idx) = self.selected {
371                            self.activated = Some(idx);
372                        }
373                        true
374                    }
375                    _ => false,
376                };
377                if consumed {
378                    ctx.request_paint();
379                }
380            }
381            _ => {}
382        }
383    }
384
385    fn captures_pointer(&self) -> bool {
386        self.v_scrollbar.captures_pointer()
387    }
388
389    fn focusable(&self) -> bool {
390        true
391    }
392
393    fn set_focused(&mut self, focused: bool) {
394        self.focused = focused;
395    }
396
397    fn layout(&mut self, bounds: Rect) {
398        self.rect = bounds;
399        self.v_scrollbar.set_rect(Rect::new(
400            bounds.right() - SCROLLBAR_THICKNESS,
401            bounds.y,
402            SCROLLBAR_THICKNESS,
403            bounds.h,
404        ));
405        self.ensure_selection_visible();
406    }
407}
408
409/// Paint one row of the commit graph in the left gutter starting at `gutter_x`.
410fn draw_graph_row(painter: &mut Painter, row: &GraphRow, gutter_x: i32, y: i32) {
411    let lane_x = |col: usize| gutter_x + col as i32 * LANE_W + LANE_W / 2;
412    let top = y;
413    let center = y + ROW_HEIGHT / 2;
414    let bottom = y + ROW_HEIGHT;
415
416    // Top half: a lane at the top edge curving in to the row center. Color by
417    // the upper lane so a line keeps its color along its length.
418    for &(from, to) in &row.top {
419        draw_line(
420            painter,
421            lane_x(from),
422            top,
423            lane_x(to),
424            center,
425            lane_color(from),
426        );
427    }
428    // Bottom half: from the center down to a lane at the bottom edge. Color by
429    // the lower lane (the lane the segment becomes).
430    for &(from, to) in &row.bottom {
431        draw_line(
432            painter,
433            lane_x(from),
434            center,
435            lane_x(to),
436            bottom,
437            lane_color(to),
438        );
439    }
440    draw_dot(
441        painter,
442        lane_x(row.node_col),
443        center,
444        lane_color(row.node_col),
445    );
446}
447
448/// Bresenham line via single logical pixels (crisp at any DPI). Straight
449/// verticals use the faster `v_line`.
450fn draw_line(painter: &mut Painter, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) {
451    if x0 == x1 {
452        let (a, b) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
453        painter.v_line(x0, a, b - a + 1, color);
454        return;
455    }
456    let dx = (x1 - x0).abs();
457    let dy = -(y1 - y0).abs();
458    let sx = if x0 < x1 { 1 } else { -1 };
459    let sy = if y0 < y1 { 1 } else { -1 };
460    let mut err = dx + dy;
461    let (mut x, mut y) = (x0, y0);
462    loop {
463        painter.pixel(x, y, color);
464        if x == x1 && y == y1 {
465            break;
466        }
467        let e2 = 2 * err;
468        if e2 >= dy {
469            err += dy;
470            x += sx;
471        }
472        if e2 <= dx {
473            err += dx;
474            y += sy;
475        }
476    }
477}
478
479/// A small filled commit dot.
480fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
481    let r = 3;
482    for dy in -r..=r {
483        // Half-width of the disc at this row (circle of radius r).
484        let hw = ((r * r - dy * dy) as f32).sqrt().round() as i32;
485        painter.h_line(cx - hw, cy + dy, hw * 2 + 1, color);
486    }
487}
488
489/// Background color for a ref badge by kind.
490fn badge_color(kind: RefKind) -> Color {
491    match kind {
492        RefKind::Head => Color::rgb(0x7C, 0xE0, 0x7C),
493        RefKind::LocalBranch => Color::rgb(0xC4, 0xF0, 0xC4),
494        RefKind::RemoteBranch => Color::rgb(0xF0, 0xCF, 0x9C),
495        RefKind::Tag => Color::rgb(0xF2, 0xEA, 0x9C),
496        RefKind::DetachedHead => Color::rgb(0xBE, 0xDE, 0xF2),
497    }
498}
499
500/// Draw one ref badge and return its drawn width.
501fn draw_badge(
502    painter: &mut Painter,
503    x: i32,
504    row_y: i32,
505    label: &str,
506    kind: RefKind,
507    font_size: f32,
508) -> i32 {
509    let tw = painter.measure_text(label, font_size).w;
510    let bw = tw + 8;
511    let bh = font_size as i32 + 3;
512    let by = row_y + (ROW_HEIGHT - bh) / 2;
513    let rect = Rect::new(x, by, bw, bh);
514    painter.fill_rect(rect, badge_color(kind));
515    painter.stroke_rect(rect, Color::BLACK);
516    let label_y = row_y + (ROW_HEIGHT - font_size as i32) / 2 - 1;
517    painter.text(x + 4, label_y, label, font_size, Color::BLACK);
518    bw
519}