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 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
416fn 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 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 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
455fn 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
486fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
488 let r = 3;
489 for dy in -r..=r {
490 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
496fn 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
507fn 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}