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 = 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 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 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 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
409fn 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 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 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
448fn 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
479fn draw_dot(painter: &mut Painter, cx: i32, cy: i32, color: Color) {
481 let r = 3;
482 for dy in -r..=r {
483 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
489fn 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
500fn 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}