1use 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
29const LANE_W: i32 = 14;
31
32const 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#[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 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 pub fn set_graph(&mut self, graph: Option<Graph>) {
89 self.graph = graph;
90 }
91
92 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, 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 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 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 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 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
425fn 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 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 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
464fn 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
495fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
497 let r = 3;
498 for dy in -r..=r {
499 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
505fn 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
516fn 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 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}