Skip to main content

gitkraft_gui/widgets/
commit_graph.rs

1//! Reusable Canvas-based commit graph widget — GitKraken / subway-map style.
2//!
3//! Features:
4//! - Click-and-drag to pan horizontally (for wide graphs)
5//! - Left-click on a node to select the commit
6//! - Right-click on a node for context menu
7//! - Canvas sized to visible rows only (GPU-friendly)
8
9use iced::widget::canvas::{self, Path, Stroke};
10use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme};
11
12use crate::message::Message;
13
14/// Width of one graph lane in pixels.
15pub 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
23/// A commit-graph widget with built-in horizontal drag-to-pan.
24pub 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    /// Total content width in pixels (max_lanes * LANE_W).
31    pub content_width: f32,
32    /// Per-visible-row background highlight colour (selected/range/hover),
33    /// aligned 1:1 with `visible_rows`. `None` means no highlight is painted
34    /// and the panel's base surface colour shows through, matching the
35    /// default state of the BRANCH/TAG and COMMIT MESSAGE columns.
36    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// ── canvas internals ──────────────────────────────────────────────────────────
60
61/// Drag state for horizontal panning, stored as the Canvas `State`.
62#[derive(Default)]
63pub struct GraphDragState {
64    /// Current horizontal pan offset (pixels scrolled to the right).
65    pan_x: f32,
66    /// If dragging, the cursor X at drag start and the pan_x at that moment.
67    drag: Option<(f32, f32)>,
68    /// Global commit row index currently under the cursor, if any. Tracked so
69    /// we only publish `HoverCommit` when it actually changes (and so the
70    /// GRAPH column keeps `hovered_commit` in sync with the BRANCH/TAG and
71    /// COMMIT MESSAGE columns even though the graph is one big canvas rather
72    /// than a per-row widget).
73    hovered_row: Option<usize>,
74}
75
76/// Maximum pan movement (in pixels) between button-press and button-release
77/// still considered a "click" rather than a drag-to-pan gesture. Keeps
78/// left-click-to-select reliable anywhere in the GRAPH column (not just
79/// exactly on a commit node) without breaking horizontal panning.
80const 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                    // Check if clicking on a node first.
122                    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                        // Right-click anywhere in the row (not just on the
143                        // node) opens the context menu for that commit --
144                        // mirrors the BRANCH/TAG and COMMIT MESSAGE columns.
145                        if *btn == iced::mouse::Button::Right {
146                            return Some(canvas::Action::publish(Message::OpenCommitContextMenu(
147                                global_row,
148                            )));
149                        }
150                    }
151
152                    // Not on a node -- start drag-to-pan (middle or left
153                    // button). A plain left click (no significant movement
154                    // before release) is resolved as a row selection in the
155                    // `ButtonReleased` arm below, so clicking anywhere in the
156                    // GRAPH column selects the commit, not just the node dot.
157                    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                // Hover tracking -- without this, moving the mouse over the
191                // GRAPH canvas never updates `hovered_commit`, so the row
192                // highlight only ever engaged via the COMMIT MESSAGE
193                // column's own mouse area. Keep all columns in sync here.
194                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            // Any row is now clickable (selects the commit), not just the
225            // node dot itself, so show a pointer cursor across the whole
226            // row -- matching the BRANCH/TAG and COMMIT MESSAGE columns.
227            if local_row < self.visible_rows.len() {
228                return iced::mouse::Interaction::Pointer;
229            }
230            // Show grab cursor when hoverable (content wider than column).
231            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        // NOTE: no cache — pan_x changes on drag, so we redraw.
247        // The visible slice is small (~80 rows), so this is fast.
248        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        // ── Pass 0: row highlight backgrounds (selected/range/hover) ──────
254        // Painted full-width so this column's highlight lines up with the
255        // BRANCH/TAG and COMMIT MESSAGE columns, making row selection span
256        // the entire table width instead of just the message column.
257        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        // Main branch = color index 0 (first-parent chain from HEAD).
269        // It gets thicker lines and larger nodes to stand out.
270        let main_color_idx: usize = 0;
271
272        // ── Pass 1: edges (draw main-branch edges LAST so they're on top) ─
273        // First: non-main edges, then main edges.
274        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                    // Pass 0: non-main, Pass 1: main
283                    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                        // Merge/branch curve — wider jumps get taller curves.
322                        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        // ── Pass 2: nodes (main branch gets larger ring) ──────────────
343        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// ── Tests ─────────────────────────────────────────────────────────────────────
374
375#[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    /// Builds a program with `row_count` rows, starting at global `offset`.
393    /// `content_width` lets tests opt into a horizontally-scrollable graph
394    /// (needed to exercise real drag-to-pan behaviour).
395    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    // ── hover tracking ───────────────────────────────────────────────────
433
434    #[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)); // row 0
440
441        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)); // row 1
479        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        // Cursor position well outside the canvas's own bounds (still a
502        // valid `CursorMoved` event, since iced dispatches these globally).
503        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    // ── click-vs-drag selection ──────────────────────────────────────────
542
543    #[test]
544    fn plain_click_off_node_selects_the_row() {
545        // Column isn't wide enough to need panning, so any left click
546        // anywhere in the row should be treated as a selection.
547        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); // far from the node at x=7, row 0
551        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        // Pressing (without a node underneath) only starts a potential drag;
556        // it must not select immediately.
557        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        // Wide content (200px) inside a 100px column makes panning possible.
572        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        // Drag far enough to exceed the click/drag threshold.
582        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        // Node for row 0 sits at lane_x(0, 0.0) = 7.0.
622        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}