use iced::widget::canvas::{self, Path, Stroke};
use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme};
use crate::message::Message;
pub const LANE_W: f32 = 14.0;
const NODE_RADIUS: f32 = 5.0;
const NODE_RADIUS_MAIN: f32 = 6.0;
const NODE_RING_WIDTH: f32 = 2.5;
const LINE_WIDTH: f32 = 2.0;
const LINE_WIDTH_MAIN: f32 = 3.0;
pub struct CommitGraph {
pub visible_rows: Vec<gitkraft_core::GraphRow>,
pub offset: usize,
pub colors: [Color; 8],
pub row_height: f32,
pub bg_color: Color,
pub content_width: f32,
pub row_backgrounds: Vec<Option<Color>>,
}
impl CommitGraph {
pub fn view(self, column_width: f32) -> Element<'static, Message> {
let visible_height = self.visible_rows.len() as f32 * self.row_height;
let program = CommitGraphProgram {
visible_rows: self.visible_rows,
offset: self.offset,
colors: self.colors,
row_height: self.row_height,
bg_color: self.bg_color,
content_width: self.content_width,
column_width,
row_backgrounds: self.row_backgrounds,
};
iced::widget::canvas(program)
.width(Length::Fixed(column_width))
.height(Length::Fixed(visible_height))
.into()
}
}
#[derive(Default)]
pub struct GraphDragState {
pan_x: f32,
drag: Option<(f32, f32)>,
hovered_row: Option<usize>,
}
const CLICK_DRAG_THRESHOLD: f32 = 4.0;
struct CommitGraphProgram {
visible_rows: Vec<gitkraft_core::GraphRow>,
offset: usize,
colors: [Color; 8],
row_height: f32,
bg_color: Color,
content_width: f32,
column_width: f32,
row_backgrounds: Vec<Option<Color>>,
}
#[inline]
fn lane_x(col: usize, pan: f32) -> f32 {
col as f32 * LANE_W + LANE_W / 2.0 - pan
}
fn round_stroke(width: f32, color: Color) -> Stroke<'static> {
Stroke {
width,
style: canvas::Style::Solid(color),
line_cap: canvas::LineCap::Round,
line_join: canvas::LineJoin::Round,
line_dash: canvas::LineDash::default(),
}
}
impl canvas::Program<Message> for CommitGraphProgram {
type State = GraphDragState;
fn update(
&self,
state: &mut GraphDragState,
event: &canvas::Event,
bounds: Rectangle,
cursor: iced::mouse::Cursor,
) -> Option<canvas::Action<Message>> {
match event {
canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(btn)) => {
if let Some(pos) = cursor.position_in(bounds) {
let local_row = (pos.y / self.row_height) as usize;
if local_row < self.visible_rows.len() {
let global_row = self.offset + local_row;
let gr = &self.visible_rows[local_row];
let node_x = lane_x(gr.node_column, state.pan_x);
let local_mid_y =
local_row as f32 * self.row_height + self.row_height / 2.0;
let dx = pos.x - node_x;
let dy = pos.y - local_mid_y;
if (dx * dx + dy * dy).sqrt() <= NODE_RADIUS + 4.0 {
let msg = match btn {
iced::mouse::Button::Right => {
Message::OpenCommitContextMenu(global_row)
}
iced::mouse::Button::Left => Message::SelectCommit(global_row),
_ => return None,
};
return Some(canvas::Action::publish(msg));
}
if *btn == iced::mouse::Button::Right {
return Some(canvas::Action::publish(Message::OpenCommitContextMenu(
global_row,
)));
}
}
if matches!(btn, iced::mouse::Button::Left | iced::mouse::Button::Middle) {
state.drag = Some((pos.x, state.pan_x));
return Some(canvas::Action::request_redraw());
}
}
}
canvas::Event::Mouse(iced::mouse::Event::ButtonReleased(btn)) => {
if let Some((_, start_pan)) = state.drag.take() {
let moved = (state.pan_x - start_pan).abs();
if *btn == iced::mouse::Button::Left && moved < CLICK_DRAG_THRESHOLD {
if let Some(pos) = cursor.position_in(bounds) {
let local_row = (pos.y / self.row_height) as usize;
if local_row < self.visible_rows.len() {
let global_row = self.offset + local_row;
return Some(canvas::Action::publish(Message::SelectCommit(
global_row,
)));
}
}
}
return Some(canvas::Action::request_redraw());
}
}
canvas::Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => {
if let Some((start_x, start_pan)) = state.drag {
if let Some(pos) = cursor.position_in(bounds) {
let dx = start_x - pos.x;
let max_pan = (self.content_width - self.column_width).max(0.0);
state.pan_x = (start_pan + dx).clamp(0.0, max_pan);
return Some(canvas::Action::request_redraw());
}
}
let hovered = cursor.position_in(bounds).and_then(|pos| {
let local_row = (pos.y / self.row_height) as usize;
(local_row < self.visible_rows.len()).then(|| self.offset + local_row)
});
if hovered != state.hovered_row {
state.hovered_row = hovered;
return Some(canvas::Action::publish(Message::HoverCommit(hovered)));
}
}
canvas::Event::Mouse(iced::mouse::Event::CursorLeft)
if state.hovered_row.take().is_some() =>
{
return Some(canvas::Action::publish(Message::HoverCommit(None)));
}
_ => {}
}
None
}
fn mouse_interaction(
&self,
state: &GraphDragState,
bounds: Rectangle,
cursor: iced::mouse::Cursor,
) -> iced::mouse::Interaction {
if state.drag.is_some() {
return iced::mouse::Interaction::Grabbing;
}
if let Some(pos) = cursor.position_in(bounds) {
let local_row = (pos.y / self.row_height) as usize;
if local_row < self.visible_rows.len() {
return iced::mouse::Interaction::Pointer;
}
if self.content_width > self.column_width {
return iced::mouse::Interaction::Grab;
}
}
iced::mouse::Interaction::default()
}
fn draw(
&self,
state: &GraphDragState,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let rh = self.row_height;
let len = self.colors.len();
let pan = state.pan_x;
for (local_idx, bg) in self.row_backgrounds.iter().enumerate() {
if let Some(color) = bg {
let top_y = local_idx as f32 * rh;
frame.fill_rectangle(
Point::new(0.0, top_y),
iced::Size::new(self.column_width.max(bounds.width), rh),
*color,
);
}
}
let main_color_idx: usize = 0;
for pass in 0..2 {
for (local_idx, gr) in self.visible_rows.iter().enumerate() {
let top_y = local_idx as f32 * rh;
let mid_y = top_y + rh / 2.0;
let bot_y = top_y + rh;
for edge in &gr.edges {
let is_main = edge.color_index % len == main_color_idx;
if (pass == 0) == is_main {
continue;
}
let color = self.colors[edge.color_index % len];
let w = if is_main { LINE_WIDTH_MAIN } else { LINE_WIDTH };
let stroke = round_stroke(w, color);
let from_x = lane_x(edge.from_column, pan);
let to_x = lane_x(edge.to_column, pan);
let gap = if is_main {
NODE_RADIUS_MAIN + w / 2.0 + 1.0
} else {
NODE_RADIUS + w / 2.0 + 1.0
};
if edge.from_column == edge.to_column {
if edge.from_column == gr.node_column {
frame.stroke(
&Path::line(
Point::new(from_x, top_y),
Point::new(from_x, mid_y - gap),
),
stroke,
);
frame.stroke(
&Path::line(
Point::new(from_x, mid_y + gap),
Point::new(from_x, bot_y),
),
stroke,
);
} else {
frame.stroke(
&Path::line(Point::new(from_x, top_y), Point::new(from_x, bot_y)),
stroke,
);
}
} else {
let lane_dist = (edge.from_column as f32 - edge.to_column as f32).abs();
let extra_rows = (lane_dist / 2.0).ceil().min(4.0);
let curve_height = rh * (0.5 + extra_rows * 0.5);
let start = Point::new(from_x, mid_y);
let end = Point::new(to_x, mid_y + curve_height);
let path = Path::new(|b| {
b.move_to(start);
b.bezier_curve_to(
Point::new(from_x, mid_y + curve_height * 0.5),
Point::new(to_x, mid_y + curve_height * 0.3),
end,
);
});
frame.stroke(&path, stroke);
}
}
}
}
for (local_idx, gr) in self.visible_rows.iter().enumerate() {
let mid_y = local_idx as f32 * rh + rh / 2.0;
let node_x = lane_x(gr.node_column, pan);
let node_color = self.colors[gr.node_color % len];
let is_main = gr.node_color % len == main_color_idx;
let nr = if is_main {
NODE_RADIUS_MAIN
} else {
NODE_RADIUS
};
let mask_r = nr + LINE_WIDTH_MAIN / 2.0 + 1.5;
frame.fill(
&Path::circle(Point::new(node_x, mid_y), mask_r),
self.bg_color,
);
frame.stroke(
&Path::circle(Point::new(node_x, mid_y), nr - NODE_RING_WIDTH / 2.0),
round_stroke(NODE_RING_WIDTH, node_color),
);
frame.fill(
&Path::circle(Point::new(node_x, mid_y), (nr - NODE_RING_WIDTH).max(0.0)),
self.bg_color,
);
}
vec![frame.into_geometry()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use canvas::Program;
use iced::mouse::{Button, Cursor, Event as MouseEvent};
const TEST_ROW_HEIGHT: f32 = 20.0;
fn test_row() -> gitkraft_core::GraphRow {
gitkraft_core::GraphRow {
width: 1,
node_column: 0,
node_color: 0,
edges: Vec::new(),
}
}
fn test_program(
row_count: usize,
offset: usize,
column_width: f32,
content_width: f32,
) -> CommitGraphProgram {
CommitGraphProgram {
visible_rows: (0..row_count).map(|_| test_row()).collect(),
offset,
colors: [Color::WHITE; 8],
row_height: TEST_ROW_HEIGHT,
bg_color: Color::BLACK,
content_width,
column_width,
row_backgrounds: vec![None; row_count],
}
}
fn bounds(width: f32, rows: usize) -> Rectangle {
Rectangle {
x: 0.0,
y: 0.0,
width,
height: rows as f32 * TEST_ROW_HEIGHT,
}
}
fn cursor_moved_at(x: f32, y: f32) -> canvas::Event {
canvas::Event::Mouse(MouseEvent::CursorMoved {
position: Point::new(x, y),
})
}
fn published_message<M>(action: Option<canvas::Action<M>>) -> Option<M> {
action.and_then(|a| a.into_inner().0)
}
#[test]
fn hover_publishes_on_first_entry() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
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);
match published_message(action) {
Some(Message::HoverCommit(Some(5))) => {}
other => panic!("expected HoverCommit(Some(5)), got {other:?}"),
}
assert_eq!(state.hovered_row, Some(5));
}
#[test]
fn hover_does_not_republish_when_unchanged() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let cursor = Cursor::Available(Point::new(50.0, 10.0));
let _ = program.update(&mut state, &cursor_moved_at(50.0, 10.0), bounds, cursor);
let action = program.update(&mut state, &cursor_moved_at(51.0, 11.0), bounds, cursor);
assert!(
published_message(action).is_none(),
"hovering within the same row should not republish HoverCommit"
);
}
#[test]
fn hover_updates_when_moving_to_a_different_row() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let _ = program.update(
&mut state,
&cursor_moved_at(50.0, 10.0),
bounds,
Cursor::Available(Point::new(50.0, 10.0)),
);
assert_eq!(state.hovered_row, Some(5));
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);
match published_message(action) {
Some(Message::HoverCommit(Some(6))) => {}
other => panic!("expected HoverCommit(Some(6)), got {other:?}"),
}
assert_eq!(state.hovered_row, Some(6));
}
#[test]
fn hover_clears_when_cursor_moves_outside_bounds() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let _ = program.update(
&mut state,
&cursor_moved_at(50.0, 10.0),
bounds,
Cursor::Available(Point::new(50.0, 10.0)),
);
assert_eq!(state.hovered_row, Some(5));
let cursor = Cursor::Available(Point::new(50.0, 500.0));
let action = program.update(&mut state, &cursor_moved_at(50.0, 500.0), bounds, cursor);
match published_message(action) {
Some(Message::HoverCommit(None)) => {}
other => panic!("expected HoverCommit(None), got {other:?}"),
}
assert_eq!(state.hovered_row, None);
}
#[test]
fn cursor_left_clears_hover_when_previously_hovering() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState {
hovered_row: Some(5),
..Default::default()
};
let bounds = bounds(100.0, 3);
let event = canvas::Event::Mouse(MouseEvent::CursorLeft);
let action = program.update(&mut state, &event, bounds, Cursor::Unavailable);
match published_message(action) {
Some(Message::HoverCommit(None)) => {}
other => panic!("expected HoverCommit(None), got {other:?}"),
}
assert_eq!(state.hovered_row, None);
}
#[test]
fn cursor_left_is_a_no_op_when_not_hovering() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let event = canvas::Event::Mouse(MouseEvent::CursorLeft);
let action = program.update(&mut state, &event, bounds, Cursor::Unavailable);
assert!(action.is_none());
}
#[test]
fn plain_click_off_node_selects_the_row() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let pos = Point::new(90.0, 10.0); let cursor = Cursor::Available(pos);
let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
let press_action = program.update(&mut state, &press, bounds, cursor);
assert!(published_message(press_action).is_none());
assert!(state.drag.is_some());
let release = canvas::Event::Mouse(MouseEvent::ButtonReleased(Button::Left));
let release_action = program.update(&mut state, &release, bounds, cursor);
match published_message(release_action) {
Some(Message::SelectCommit(5)) => {}
other => panic!("expected SelectCommit(5), got {other:?}"),
}
assert!(state.drag.is_none());
}
#[test]
fn dragging_beyond_threshold_pans_instead_of_selecting() {
let program = test_program(3, 5, 100.0, 200.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let press_pos = Point::new(90.0, 10.0);
let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
let _ = program.update(&mut state, &press, bounds, Cursor::Available(press_pos));
assert!(state.drag.is_some());
let move_pos = Point::new(40.0, 10.0);
let _ = program.update(
&mut state,
&cursor_moved_at(move_pos.x, move_pos.y),
bounds,
Cursor::Available(move_pos),
);
assert!(state.pan_x >= CLICK_DRAG_THRESHOLD);
let release = canvas::Event::Mouse(MouseEvent::ButtonReleased(Button::Left));
let release_action =
program.update(&mut state, &release, bounds, Cursor::Available(move_pos));
assert!(
published_message(release_action).is_none(),
"a real pan-drag must not also select a row"
);
}
#[test]
fn right_click_off_node_opens_context_menu_for_the_row() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let pos = Point::new(90.0, 10.0);
let cursor = Cursor::Available(pos);
let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Right));
let action = program.update(&mut state, &press, bounds, cursor);
match published_message(action) {
Some(Message::OpenCommitContextMenu(5)) => {}
other => panic!("expected OpenCommitContextMenu(5), got {other:?}"),
}
}
#[test]
fn click_directly_on_node_selects_immediately_without_drag_state() {
let program = test_program(3, 5, 100.0, 100.0);
let mut state = GraphDragState::default();
let bounds = bounds(100.0, 3);
let pos = Point::new(7.0, 10.0);
let cursor = Cursor::Available(pos);
let press = canvas::Event::Mouse(MouseEvent::ButtonPressed(Button::Left));
let action = program.update(&mut state, &press, bounds, cursor);
match published_message(action) {
Some(Message::SelectCommit(5)) => {}
other => panic!("expected SelectCommit(5), got {other:?}"),
}
assert!(
state.drag.is_none(),
"a direct node click should not also start a pan drag"
);
}
}