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}
33
34impl CommitGraph {
35    pub fn view(self, column_width: f32) -> Element<'static, Message> {
36        let visible_height = self.visible_rows.len() as f32 * self.row_height;
37        let program = CommitGraphProgram {
38            visible_rows: self.visible_rows,
39            offset: self.offset,
40            colors: self.colors,
41            row_height: self.row_height,
42            bg_color: self.bg_color,
43            content_width: self.content_width,
44            column_width,
45        };
46        iced::widget::canvas(program)
47            .width(Length::Fixed(column_width))
48            .height(Length::Fixed(visible_height))
49            .into()
50    }
51}
52
53// ── canvas internals ──────────────────────────────────────────────────────────
54
55/// Drag state for horizontal panning, stored as the Canvas `State`.
56#[derive(Default)]
57pub struct GraphDragState {
58    /// Current horizontal pan offset (pixels scrolled to the right).
59    pan_x: f32,
60    /// If dragging, the cursor X at drag start and the pan_x at that moment.
61    drag: Option<(f32, f32)>,
62}
63
64struct CommitGraphProgram {
65    visible_rows: Vec<gitkraft_core::GraphRow>,
66    offset: usize,
67    colors: [Color; 8],
68    row_height: f32,
69    bg_color: Color,
70    content_width: f32,
71    column_width: f32,
72}
73
74#[inline]
75fn lane_x(col: usize, pan: f32) -> f32 {
76    col as f32 * LANE_W + LANE_W / 2.0 - pan
77}
78
79fn round_stroke(width: f32, color: Color) -> Stroke<'static> {
80    Stroke {
81        width,
82        style: canvas::Style::Solid(color),
83        line_cap: canvas::LineCap::Round,
84        line_join: canvas::LineJoin::Round,
85        line_dash: canvas::LineDash::default(),
86    }
87}
88
89impl canvas::Program<Message> for CommitGraphProgram {
90    type State = GraphDragState;
91
92    fn update(
93        &self,
94        state: &mut GraphDragState,
95        event: &canvas::Event,
96        bounds: Rectangle,
97        cursor: iced::mouse::Cursor,
98    ) -> Option<canvas::Action<Message>> {
99        match event {
100            canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(btn)) => {
101                if let Some(pos) = cursor.position_in(bounds) {
102                    // Check if clicking on a node first.
103                    let local_row = (pos.y / self.row_height) as usize;
104                    if local_row < self.visible_rows.len() {
105                        let global_row = self.offset + local_row;
106                        let gr = &self.visible_rows[local_row];
107                        let node_x = lane_x(gr.node_column, state.pan_x);
108                        let local_mid_y =
109                            local_row as f32 * self.row_height + self.row_height / 2.0;
110                        let dx = pos.x - node_x;
111                        let dy = pos.y - local_mid_y;
112                        if (dx * dx + dy * dy).sqrt() <= NODE_RADIUS + 4.0 {
113                            let msg = match btn {
114                                iced::mouse::Button::Right => {
115                                    Message::OpenCommitContextMenu(global_row)
116                                }
117                                iced::mouse::Button::Left => Message::SelectCommit(global_row),
118                                _ => return None,
119                            };
120                            return Some(canvas::Action::publish(msg));
121                        }
122                    }
123
124                    // Not on a node — start drag-to-pan (middle or left button).
125                    if matches!(btn, iced::mouse::Button::Left | iced::mouse::Button::Middle) {
126                        state.drag = Some((pos.x, state.pan_x));
127                        return Some(canvas::Action::request_redraw());
128                    }
129                }
130            }
131            canvas::Event::Mouse(iced::mouse::Event::ButtonReleased(
132                iced::mouse::Button::Left | iced::mouse::Button::Middle,
133            )) if state.drag.is_some() => {
134                state.drag = None;
135                return Some(canvas::Action::request_redraw());
136            }
137            canvas::Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => {
138                if let Some((start_x, start_pan)) = state.drag {
139                    if let Some(pos) = cursor.position_in(bounds) {
140                        let dx = start_x - pos.x;
141                        let max_pan = (self.content_width - self.column_width).max(0.0);
142                        state.pan_x = (start_pan + dx).clamp(0.0, max_pan);
143                        return Some(canvas::Action::request_redraw());
144                    }
145                }
146            }
147            _ => {}
148        }
149        None
150    }
151
152    fn mouse_interaction(
153        &self,
154        state: &GraphDragState,
155        bounds: Rectangle,
156        cursor: iced::mouse::Cursor,
157    ) -> iced::mouse::Interaction {
158        if state.drag.is_some() {
159            return iced::mouse::Interaction::Grabbing;
160        }
161        if let Some(pos) = cursor.position_in(bounds) {
162            let local_row = (pos.y / self.row_height) as usize;
163            if local_row < self.visible_rows.len() {
164                let gr = &self.visible_rows[local_row];
165                let node_x = lane_x(gr.node_column, state.pan_x);
166                let local_mid_y = local_row as f32 * self.row_height + self.row_height / 2.0;
167                let dx = pos.x - node_x;
168                let dy = pos.y - local_mid_y;
169                if (dx * dx + dy * dy).sqrt() <= NODE_RADIUS + 4.0 {
170                    return iced::mouse::Interaction::Pointer;
171                }
172            }
173            // Show grab cursor when hoverable (content wider than column).
174            if self.content_width > self.column_width {
175                return iced::mouse::Interaction::Grab;
176            }
177        }
178        iced::mouse::Interaction::default()
179    }
180
181    fn draw(
182        &self,
183        state: &GraphDragState,
184        renderer: &Renderer,
185        _theme: &Theme,
186        bounds: Rectangle,
187        _cursor: iced::mouse::Cursor,
188    ) -> Vec<canvas::Geometry> {
189        // NOTE: no cache — pan_x changes on drag, so we redraw.
190        // The visible slice is small (~80 rows), so this is fast.
191        let mut frame = canvas::Frame::new(renderer, bounds.size());
192        let rh = self.row_height;
193        let len = self.colors.len();
194        let pan = state.pan_x;
195
196        // Main branch = color index 0 (first-parent chain from HEAD).
197        // It gets thicker lines and larger nodes to stand out.
198        let main_color_idx: usize = 0;
199
200        // ── Pass 1: edges (draw main-branch edges LAST so they're on top) ─
201        // First: non-main edges, then main edges.
202        for pass in 0..2 {
203            for (local_idx, gr) in self.visible_rows.iter().enumerate() {
204                let top_y = local_idx as f32 * rh;
205                let mid_y = top_y + rh / 2.0;
206                let bot_y = top_y + rh;
207
208                for edge in &gr.edges {
209                    let is_main = edge.color_index % len == main_color_idx;
210                    // Pass 0: non-main, Pass 1: main
211                    if (pass == 0) == is_main {
212                        continue;
213                    }
214
215                    let color = self.colors[edge.color_index % len];
216                    let w = if is_main { LINE_WIDTH_MAIN } else { LINE_WIDTH };
217                    let stroke = round_stroke(w, color);
218                    let from_x = lane_x(edge.from_column, pan);
219                    let to_x = lane_x(edge.to_column, pan);
220                    let gap = if is_main {
221                        NODE_RADIUS_MAIN + w / 2.0 + 1.0
222                    } else {
223                        NODE_RADIUS + w / 2.0 + 1.0
224                    };
225
226                    if edge.from_column == edge.to_column {
227                        if edge.from_column == gr.node_column {
228                            frame.stroke(
229                                &Path::line(
230                                    Point::new(from_x, top_y),
231                                    Point::new(from_x, mid_y - gap),
232                                ),
233                                stroke,
234                            );
235                            frame.stroke(
236                                &Path::line(
237                                    Point::new(from_x, mid_y + gap),
238                                    Point::new(from_x, bot_y),
239                                ),
240                                stroke,
241                            );
242                        } else {
243                            frame.stroke(
244                                &Path::line(Point::new(from_x, top_y), Point::new(from_x, bot_y)),
245                                stroke,
246                            );
247                        }
248                    } else {
249                        // Merge/branch curve — wider jumps get taller curves.
250                        let lane_dist = (edge.from_column as f32 - edge.to_column as f32).abs();
251                        let extra_rows = (lane_dist / 2.0).ceil().min(4.0);
252                        let curve_height = rh * (0.5 + extra_rows * 0.5);
253
254                        let start = Point::new(from_x, mid_y);
255                        let end = Point::new(to_x, mid_y + curve_height);
256                        let path = Path::new(|b| {
257                            b.move_to(start);
258                            b.bezier_curve_to(
259                                Point::new(from_x, mid_y + curve_height * 0.5),
260                                Point::new(to_x, mid_y + curve_height * 0.3),
261                                end,
262                            );
263                        });
264                        frame.stroke(&path, stroke);
265                    }
266                }
267            }
268        }
269
270        // ── Pass 2: nodes (main branch gets larger ring) ──────────────
271        for (local_idx, gr) in self.visible_rows.iter().enumerate() {
272            let mid_y = local_idx as f32 * rh + rh / 2.0;
273            let node_x = lane_x(gr.node_column, pan);
274            let node_color = self.colors[gr.node_color % len];
275            let is_main = gr.node_color % len == main_color_idx;
276            let nr = if is_main {
277                NODE_RADIUS_MAIN
278            } else {
279                NODE_RADIUS
280            };
281            let mask_r = nr + LINE_WIDTH_MAIN / 2.0 + 1.5;
282
283            frame.fill(
284                &Path::circle(Point::new(node_x, mid_y), mask_r),
285                self.bg_color,
286            );
287            frame.stroke(
288                &Path::circle(Point::new(node_x, mid_y), nr - NODE_RING_WIDTH / 2.0),
289                round_stroke(NODE_RING_WIDTH, node_color),
290            );
291            frame.fill(
292                &Path::circle(Point::new(node_x, mid_y), (nr - NODE_RING_WIDTH).max(0.0)),
293                self.bg_color,
294            );
295        }
296
297        vec![frame.into_geometry()]
298    }
299}