1use iced::widget::canvas::{self, Path, Stroke};
10use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme};
11
12use crate::message::Message;
13
14pub const LANE_W: f32 = 14.0;
16
17const NODE_RADIUS: f32 = 5.0;
18const NODE_RADIUS_MAIN: f32 = 6.0;
19const NODE_RING_WIDTH: f32 = 2.5;
20const LINE_WIDTH: f32 = 2.0;
21const LINE_WIDTH_MAIN: f32 = 3.0;
22
23pub struct CommitGraph {
25 pub visible_rows: Vec<gitkraft_core::GraphRow>,
26 pub offset: usize,
27 pub colors: [Color; 8],
28 pub row_height: f32,
29 pub bg_color: Color,
30 pub content_width: f32,
32 pub row_backgrounds: Vec<Option<Color>>,
37}
38
39impl CommitGraph {
40 pub fn view(self, column_width: f32) -> Element<'static, Message> {
41 let visible_height = self.visible_rows.len() as f32 * self.row_height;
42 let program = CommitGraphProgram {
43 visible_rows: self.visible_rows,
44 offset: self.offset,
45 colors: self.colors,
46 row_height: self.row_height,
47 bg_color: self.bg_color,
48 content_width: self.content_width,
49 column_width,
50 row_backgrounds: self.row_backgrounds,
51 };
52 iced::widget::canvas(program)
53 .width(Length::Fixed(column_width))
54 .height(Length::Fixed(visible_height))
55 .into()
56 }
57}
58
59#[derive(Default)]
63pub struct GraphDragState {
64 pan_x: f32,
66 drag: Option<(f32, f32)>,
68 hovered_row: Option<usize>,
74}
75
76const CLICK_DRAG_THRESHOLD: f32 = 4.0;
81
82struct CommitGraphProgram {
83 visible_rows: Vec<gitkraft_core::GraphRow>,
84 offset: usize,
85 colors: [Color; 8],
86 row_height: f32,
87 bg_color: Color,
88 content_width: f32,
89 column_width: f32,
90 row_backgrounds: Vec<Option<Color>>,
91}
92
93#[inline]
94fn lane_x(col: usize, pan: f32) -> f32 {
95 col as f32 * LANE_W + LANE_W / 2.0 - pan
96}
97
98fn round_stroke(width: f32, color: Color) -> Stroke<'static> {
99 Stroke {
100 width,
101 style: canvas::Style::Solid(color),
102 line_cap: canvas::LineCap::Round,
103 line_join: canvas::LineJoin::Round,
104 line_dash: canvas::LineDash::default(),
105 }
106}
107
108impl canvas::Program<Message> for CommitGraphProgram {
109 type State = GraphDragState;
110
111 fn update(
112 &self,
113 state: &mut GraphDragState,
114 event: &canvas::Event,
115 bounds: Rectangle,
116 cursor: iced::mouse::Cursor,
117 ) -> Option<canvas::Action<Message>> {
118 match event {
119 canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(btn)) => {
120 if let Some(pos) = cursor.position_in(bounds) {
121 let local_row = (pos.y / self.row_height) as usize;
123 if local_row < self.visible_rows.len() {
124 let global_row = self.offset + local_row;
125 let gr = &self.visible_rows[local_row];
126 let node_x = lane_x(gr.node_column, state.pan_x);
127 let local_mid_y =
128 local_row as f32 * self.row_height + self.row_height / 2.0;
129 let dx = pos.x - node_x;
130 let dy = pos.y - local_mid_y;
131 if (dx * dx + dy * dy).sqrt() <= NODE_RADIUS + 4.0 {
132 let msg = match btn {
133 iced::mouse::Button::Right => {
134 Message::OpenCommitContextMenu(global_row)
135 }
136 iced::mouse::Button::Left => Message::SelectCommit(global_row),
137 _ => return None,
138 };
139 return Some(canvas::Action::publish(msg));
140 }
141
142 if *btn == iced::mouse::Button::Right {
146 return Some(canvas::Action::publish(Message::OpenCommitContextMenu(
147 global_row,
148 )));
149 }
150 }
151
152 if matches!(btn, iced::mouse::Button::Left | iced::mouse::Button::Middle) {
158 state.drag = Some((pos.x, state.pan_x));
159 return Some(canvas::Action::request_redraw());
160 }
161 }
162 }
163 canvas::Event::Mouse(iced::mouse::Event::ButtonReleased(btn)) => {
164 if let Some((_, start_pan)) = state.drag.take() {
165 let moved = (state.pan_x - start_pan).abs();
166 if *btn == iced::mouse::Button::Left && moved < CLICK_DRAG_THRESHOLD {
167 if let Some(pos) = cursor.position_in(bounds) {
168 let local_row = (pos.y / self.row_height) as usize;
169 if local_row < self.visible_rows.len() {
170 let global_row = self.offset + local_row;
171 return Some(canvas::Action::publish(Message::SelectCommit(
172 global_row,
173 )));
174 }
175 }
176 }
177 return Some(canvas::Action::request_redraw());
178 }
179 }
180 canvas::Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => {
181 if let Some((start_x, start_pan)) = state.drag {
182 if let Some(pos) = cursor.position_in(bounds) {
183 let dx = start_x - pos.x;
184 let max_pan = (self.content_width - self.column_width).max(0.0);
185 state.pan_x = (start_pan + dx).clamp(0.0, max_pan);
186 return Some(canvas::Action::request_redraw());
187 }
188 }
189
190 let hovered = cursor.position_in(bounds).and_then(|pos| {
195 let local_row = (pos.y / self.row_height) as usize;
196 (local_row < self.visible_rows.len()).then(|| self.offset + local_row)
197 });
198 if hovered != state.hovered_row {
199 state.hovered_row = hovered;
200 return Some(canvas::Action::publish(Message::HoverCommit(hovered)));
201 }
202 }
203 canvas::Event::Mouse(iced::mouse::Event::CursorLeft)
204 if state.hovered_row.take().is_some() =>
205 {
206 return Some(canvas::Action::publish(Message::HoverCommit(None)));
207 }
208 _ => {}
209 }
210 None
211 }
212
213 fn mouse_interaction(
214 &self,
215 state: &GraphDragState,
216 bounds: Rectangle,
217 cursor: iced::mouse::Cursor,
218 ) -> iced::mouse::Interaction {
219 if state.drag.is_some() {
220 return iced::mouse::Interaction::Grabbing;
221 }
222 if let Some(pos) = cursor.position_in(bounds) {
223 let local_row = (pos.y / self.row_height) as usize;
224 if local_row < self.visible_rows.len() {
228 return iced::mouse::Interaction::Pointer;
229 }
230 if self.content_width > self.column_width {
232 return iced::mouse::Interaction::Grab;
233 }
234 }
235 iced::mouse::Interaction::default()
236 }
237
238 fn draw(
239 &self,
240 state: &GraphDragState,
241 renderer: &Renderer,
242 _theme: &Theme,
243 bounds: Rectangle,
244 _cursor: iced::mouse::Cursor,
245 ) -> Vec<canvas::Geometry> {
246 let mut frame = canvas::Frame::new(renderer, bounds.size());
249 let rh = self.row_height;
250 let len = self.colors.len();
251 let pan = state.pan_x;
252
253 for (local_idx, bg) in self.row_backgrounds.iter().enumerate() {
258 if let Some(color) = bg {
259 let top_y = local_idx as f32 * rh;
260 frame.fill_rectangle(
261 Point::new(0.0, top_y),
262 iced::Size::new(self.column_width.max(bounds.width), rh),
263 *color,
264 );
265 }
266 }
267
268 let main_color_idx: usize = 0;
271
272 for pass in 0..2 {
275 for (local_idx, gr) in self.visible_rows.iter().enumerate() {
276 let top_y = local_idx as f32 * rh;
277 let mid_y = top_y + rh / 2.0;
278 let bot_y = top_y + rh;
279
280 for edge in &gr.edges {
281 let is_main = edge.color_index % len == main_color_idx;
282 if (pass == 0) == is_main {
284 continue;
285 }
286
287 let color = self.colors[edge.color_index % len];
288 let w = if is_main { LINE_WIDTH_MAIN } else { LINE_WIDTH };
289 let stroke = round_stroke(w, color);
290 let from_x = lane_x(edge.from_column, pan);
291 let to_x = lane_x(edge.to_column, pan);
292 let gap = if is_main {
293 NODE_RADIUS_MAIN + w / 2.0 + 1.0
294 } else {
295 NODE_RADIUS + w / 2.0 + 1.0
296 };
297
298 if edge.from_column == edge.to_column {
299 if edge.from_column == gr.node_column {
300 frame.stroke(
301 &Path::line(
302 Point::new(from_x, top_y),
303 Point::new(from_x, mid_y - gap),
304 ),
305 stroke,
306 );
307 frame.stroke(
308 &Path::line(
309 Point::new(from_x, mid_y + gap),
310 Point::new(from_x, bot_y),
311 ),
312 stroke,
313 );
314 } else {
315 frame.stroke(
316 &Path::line(Point::new(from_x, top_y), Point::new(from_x, bot_y)),
317 stroke,
318 );
319 }
320 } else {
321 let lane_dist = (edge.from_column as f32 - edge.to_column as f32).abs();
323 let extra_rows = (lane_dist / 2.0).ceil().min(4.0);
324 let curve_height = rh * (0.5 + extra_rows * 0.5);
325
326 let start = Point::new(from_x, mid_y);
327 let end = Point::new(to_x, mid_y + curve_height);
328 let path = Path::new(|b| {
329 b.move_to(start);
330 b.bezier_curve_to(
331 Point::new(from_x, mid_y + curve_height * 0.5),
332 Point::new(to_x, mid_y + curve_height * 0.3),
333 end,
334 );
335 });
336 frame.stroke(&path, stroke);
337 }
338 }
339 }
340 }
341
342 for (local_idx, gr) in self.visible_rows.iter().enumerate() {
344 let mid_y = local_idx as f32 * rh + rh / 2.0;
345 let node_x = lane_x(gr.node_column, pan);
346 let node_color = self.colors[gr.node_color % len];
347 let is_main = gr.node_color % len == main_color_idx;
348 let nr = if is_main {
349 NODE_RADIUS_MAIN
350 } else {
351 NODE_RADIUS
352 };
353 let mask_r = nr + LINE_WIDTH_MAIN / 2.0 + 1.5;
354
355 frame.fill(
356 &Path::circle(Point::new(node_x, mid_y), mask_r),
357 self.bg_color,
358 );
359 frame.stroke(
360 &Path::circle(Point::new(node_x, mid_y), nr - NODE_RING_WIDTH / 2.0),
361 round_stroke(NODE_RING_WIDTH, node_color),
362 );
363 frame.fill(
364 &Path::circle(Point::new(node_x, mid_y), (nr - NODE_RING_WIDTH).max(0.0)),
365 self.bg_color,
366 );
367 }
368
369 vec![frame.into_geometry()]
370 }
371}
372
373#[cfg(test)]
376mod tests {
377 use super::*;
378 use canvas::Program;
379 use iced::mouse::{Button, Cursor, Event as MouseEvent};
380
381 const TEST_ROW_HEIGHT: f32 = 20.0;
382
383 fn test_row() -> gitkraft_core::GraphRow {
384 gitkraft_core::GraphRow {
385 width: 1,
386 node_column: 0,
387 node_color: 0,
388 edges: Vec::new(),
389 }
390 }
391
392 fn test_program(
396 row_count: usize,
397 offset: usize,
398 column_width: f32,
399 content_width: f32,
400 ) -> CommitGraphProgram {
401 CommitGraphProgram {
402 visible_rows: (0..row_count).map(|_| test_row()).collect(),
403 offset,
404 colors: [Color::WHITE; 8],
405 row_height: TEST_ROW_HEIGHT,
406 bg_color: Color::BLACK,
407 content_width,
408 column_width,
409 row_backgrounds: vec![None; row_count],
410 }
411 }
412
413 fn bounds(width: f32, rows: usize) -> Rectangle {
414 Rectangle {
415 x: 0.0,
416 y: 0.0,
417 width,
418 height: rows as f32 * TEST_ROW_HEIGHT,
419 }
420 }
421
422 fn cursor_moved_at(x: f32, y: f32) -> canvas::Event {
423 canvas::Event::Mouse(MouseEvent::CursorMoved {
424 position: Point::new(x, y),
425 })
426 }
427
428 fn published_message<M>(action: Option<canvas::Action<M>>) -> Option<M> {
429 action.and_then(|a| a.into_inner().0)
430 }
431
432 #[test]
435 fn hover_publishes_on_first_entry() {
436 let program = test_program(3, 5, 100.0, 100.0);
437 let mut state = GraphDragState::default();
438 let bounds = bounds(100.0, 3);
439 let cursor = Cursor::Available(Point::new(50.0, 10.0)); let action = program.update(&mut state, &cursor_moved_at(50.0, 10.0), bounds, cursor);
442 match published_message(action) {
443 Some(Message::HoverCommit(Some(5))) => {}
444 other => panic!("expected HoverCommit(Some(5)), got {other:?}"),
445 }
446 assert_eq!(state.hovered_row, Some(5));
447 }
448
449 #[test]
450 fn hover_does_not_republish_when_unchanged() {
451 let program = test_program(3, 5, 100.0, 100.0);
452 let mut state = GraphDragState::default();
453 let bounds = bounds(100.0, 3);
454 let cursor = Cursor::Available(Point::new(50.0, 10.0));
455
456 let _ = program.update(&mut state, &cursor_moved_at(50.0, 10.0), bounds, cursor);
457 let action = program.update(&mut state, &cursor_moved_at(51.0, 11.0), bounds, cursor);
458 assert!(
459 published_message(action).is_none(),
460 "hovering within the same row should not republish HoverCommit"
461 );
462 }
463
464 #[test]
465 fn hover_updates_when_moving_to_a_different_row() {
466 let program = test_program(3, 5, 100.0, 100.0);
467 let mut state = GraphDragState::default();
468 let bounds = bounds(100.0, 3);
469
470 let _ = program.update(
471 &mut state,
472 &cursor_moved_at(50.0, 10.0),
473 bounds,
474 Cursor::Available(Point::new(50.0, 10.0)),
475 );
476 assert_eq!(state.hovered_row, Some(5));
477
478 let cursor = Cursor::Available(Point::new(50.0, 25.0)); let action = program.update(&mut state, &cursor_moved_at(50.0, 25.0), bounds, cursor);
480 match published_message(action) {
481 Some(Message::HoverCommit(Some(6))) => {}
482 other => panic!("expected HoverCommit(Some(6)), got {other:?}"),
483 }
484 assert_eq!(state.hovered_row, Some(6));
485 }
486
487 #[test]
488 fn hover_clears_when_cursor_moves_outside_bounds() {
489 let program = test_program(3, 5, 100.0, 100.0);
490 let mut state = GraphDragState::default();
491 let bounds = bounds(100.0, 3);
492
493 let _ = program.update(
494 &mut state,
495 &cursor_moved_at(50.0, 10.0),
496 bounds,
497 Cursor::Available(Point::new(50.0, 10.0)),
498 );
499 assert_eq!(state.hovered_row, Some(5));
500
501 let cursor = Cursor::Available(Point::new(50.0, 500.0));
504 let action = program.update(&mut state, &cursor_moved_at(50.0, 500.0), bounds, cursor);
505 match published_message(action) {
506 Some(Message::HoverCommit(None)) => {}
507 other => panic!("expected HoverCommit(None), got {other:?}"),
508 }
509 assert_eq!(state.hovered_row, None);
510 }
511
512 #[test]
513 fn cursor_left_clears_hover_when_previously_hovering() {
514 let program = test_program(3, 5, 100.0, 100.0);
515 let mut state = GraphDragState {
516 hovered_row: Some(5),
517 ..Default::default()
518 };
519 let bounds = bounds(100.0, 3);
520 let event = canvas::Event::Mouse(MouseEvent::CursorLeft);
521
522 let action = program.update(&mut state, &event, bounds, Cursor::Unavailable);
523 match published_message(action) {
524 Some(Message::HoverCommit(None)) => {}
525 other => panic!("expected HoverCommit(None), got {other:?}"),
526 }
527 assert_eq!(state.hovered_row, None);
528 }
529
530 #[test]
531 fn cursor_left_is_a_no_op_when_not_hovering() {
532 let program = test_program(3, 5, 100.0, 100.0);
533 let mut state = GraphDragState::default();
534 let bounds = bounds(100.0, 3);
535 let event = canvas::Event::Mouse(MouseEvent::CursorLeft);
536
537 let action = program.update(&mut state, &event, bounds, Cursor::Unavailable);
538 assert!(action.is_none());
539 }
540
541 #[test]
544 fn plain_click_off_node_selects_the_row() {
545 let program = test_program(3, 5, 100.0, 100.0);
548 let mut state = GraphDragState::default();
549 let bounds = bounds(100.0, 3);
550 let pos = Point::new(90.0, 10.0); let cursor = Cursor::Available(pos);
552
553 let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
554 let press_action = program.update(&mut state, &press, bounds, cursor);
555 assert!(published_message(press_action).is_none());
558 assert!(state.drag.is_some());
559
560 let release = canvas::Event::Mouse(MouseEvent::ButtonReleased(Button::Left));
561 let release_action = program.update(&mut state, &release, bounds, cursor);
562 match published_message(release_action) {
563 Some(Message::SelectCommit(5)) => {}
564 other => panic!("expected SelectCommit(5), got {other:?}"),
565 }
566 assert!(state.drag.is_none());
567 }
568
569 #[test]
570 fn dragging_beyond_threshold_pans_instead_of_selecting() {
571 let program = test_program(3, 5, 100.0, 200.0);
573 let mut state = GraphDragState::default();
574 let bounds = bounds(100.0, 3);
575
576 let press_pos = Point::new(90.0, 10.0);
577 let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
578 let _ = program.update(&mut state, &press, bounds, Cursor::Available(press_pos));
579 assert!(state.drag.is_some());
580
581 let move_pos = Point::new(40.0, 10.0);
583 let _ = program.update(
584 &mut state,
585 &cursor_moved_at(move_pos.x, move_pos.y),
586 bounds,
587 Cursor::Available(move_pos),
588 );
589 assert!(state.pan_x >= CLICK_DRAG_THRESHOLD);
590
591 let release = canvas::Event::Mouse(MouseEvent::ButtonReleased(Button::Left));
592 let release_action =
593 program.update(&mut state, &release, bounds, Cursor::Available(move_pos));
594 assert!(
595 published_message(release_action).is_none(),
596 "a real pan-drag must not also select a row"
597 );
598 }
599
600 #[test]
601 fn right_click_off_node_opens_context_menu_for_the_row() {
602 let program = test_program(3, 5, 100.0, 100.0);
603 let mut state = GraphDragState::default();
604 let bounds = bounds(100.0, 3);
605 let pos = Point::new(90.0, 10.0);
606 let cursor = Cursor::Available(pos);
607
608 let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Right));
609 let action = program.update(&mut state, &press, bounds, cursor);
610 match published_message(action) {
611 Some(Message::OpenCommitContextMenu(5)) => {}
612 other => panic!("expected OpenCommitContextMenu(5), got {other:?}"),
613 }
614 }
615
616 #[test]
617 fn click_directly_on_node_selects_immediately_without_drag_state() {
618 let program = test_program(3, 5, 100.0, 100.0);
619 let mut state = GraphDragState::default();
620 let bounds = bounds(100.0, 3);
621 let pos = Point::new(7.0, 10.0);
623 let cursor = Cursor::Available(pos);
624
625 let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
626 let action = program.update(&mut state, &press, bounds, cursor);
627 match published_message(action) {
628 Some(Message::SelectCommit(5)) => {}
629 other => panic!("expected SelectCommit(5), got {other:?}"),
630 }
631 assert!(
632 state.drag.is_none(),
633 "a direct node click should not also start a pan drag"
634 );
635 }
636}