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}
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#[derive(Default)]
57pub struct GraphDragState {
58 pan_x: f32,
60 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 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 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 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 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 let main_color_idx: usize = 0;
199
200 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 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 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 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}