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,
}
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,
};
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)>,
}
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,
}
#[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 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(
iced::mouse::Button::Left | iced::mouse::Button::Middle,
)) if state.drag.is_some() => {
state.drag = None;
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());
}
}
}
_ => {}
}
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() {
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 {
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;
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()]
}
}