use egui::{Align, Label, Pos2, Rect, Shape, Stroke, TextWrapMode, epaint::CubicBezierShape, vec2}; use jsoncanvas::{ FileNode, GroupNode, LinkNode, Node, NodeId, TextNode, edge::{Edge, Side}, node::GenericNode, }; use std::collections::HashMap; use std::ops::Neg; fn node_rect(node: &GenericNode) -> Rect { let x = node.x as f32; let y = node.y as f32; let width = node.width as f32; let height = node.height as f32; let min = Pos2::new(x, y); let max = Pos2::new(x + width, y + height); Rect::from_min_max(min, max) } fn side_point(side: &Side, node: &GenericNode) -> Pos2 { let rect = node_rect(node); match side { Side::Top => rect.center_top(), Side::Left => rect.left_center(), Side::Right => rect.right_center(), Side::Bottom => rect.center_bottom(), } } /// a unit vector pointing outward from the given side fn side_tangent(side: &Side) -> egui::Vec2 { match side { Side::Top => vec2(0.0, -1.0), Side::Bottom => vec2(0.0, 1.0), Side::Left => vec2(-1.0, 0.0), Side::Right => vec2(1.0, 0.0), } } pub fn edge_ui( ui: &mut egui::Ui, nodes: &HashMap, edge: &Edge, ) -> Option { let from_node = nodes.get(edge.from_node())?; let to_node = nodes.get(edge.to_node())?; let to_side = edge.to_side()?; let from_side = edge.from_side()?; // anchor from-side let p0 = side_point(from_side, from_node.node()); // anchor b let to_anchor = side_point(to_side, to_node.node()); // to-point is slightly offset to accomidate arrow let p3 = to_anchor + side_tangent(to_side) * 2.0; // bend debug //let bend = debug_slider(ui, ui.id().with("bend"), p3, 0.25, 0.0..=1.0); let bend = 0.28; // How far to pull the tangents. // ¼ of the distance between anchors feels very “Obsidian”. let d = (p3 - p0).length() * bend; // c1 = anchor A + (outward tangent) * d let c1 = p0 + side_tangent(from_side) * d; // c2 = anchor B + (inward tangent) * d let c2 = p3 - side_tangent(to_side).neg() * d; let color = ui.visuals().noninteractive().bg_stroke.color; let stroke = egui::Stroke::new(4.0, color); let bezier = CubicBezierShape::from_points_stroke([p0, c1, c2, p3], false, color, stroke); ui.painter().add(Shape::CubicBezier(bezier)); arrow_ui(ui, to_side, to_anchor, color); None } /// Paint a tiny triangular “arrow”. /// /// * `ui` – the egui `Ui` you’re painting in /// * `side` – which edge of the box we’re attaching to /// * `point` – the exact spot on that edge the arrow’s tip should touch /// * `fill` – colour to fill the arrow with (usually your popup’s background) pub fn arrow_ui(ui: &mut egui::Ui, side: &Side, point: Pos2, fill: egui::Color32) { let len: f32 = 12.0; // distance from tip to base let width: f32 = 16.0; // length of the base let stroke: f32 = 1.0; // length of the base let verts = match side { Side::Top => [ point, // tip Pos2::new(point.x - width * 0.5, point.y - len), // base‑left (above) Pos2::new(point.x + width * 0.5, point.y - len), // base‑right (above) ], Side::Bottom => [ point, Pos2::new(point.x + width * 0.5, point.y + len), // below Pos2::new(point.x - width * 0.5, point.y + len), ], Side::Left => [ point, Pos2::new(point.x - len, point.y + width * 0.5), // left Pos2::new(point.x - len, point.y - width * 0.5), ], Side::Right => [ point, Pos2::new(point.x + len, point.y - width * 0.5), // right Pos2::new(point.x + len, point.y + width * 0.5), ], }; ui.painter().add(egui::Shape::convex_polygon( verts.to_vec(), fill, Stroke::new(stroke, fill), // add a stroke here if you want an outline )); } pub fn node_ui(ui: &mut egui::Ui, node: &Node) -> egui::Response { match node { Node::Text(text_node) => text_node_ui(ui, text_node), Node::File(file_node) => file_node_ui(ui, file_node), Node::Link(link_node) => link_node_ui(ui, link_node), Node::Group(group_node) => group_node_ui(ui, group_node), } } fn text_node_ui(ui: &mut egui::Ui, node: &TextNode) -> egui::Response { node_box_ui(ui, node.node(), |ui| { egui::ScrollArea::vertical() .show(ui, |ui| { ui.with_layout(egui::Layout::left_to_right(Align::Min), |ui| { ui.add(Label::new(node.text()).wrap_mode(TextWrapMode::Wrap)) }) }) .inner .response }) } fn file_node_ui(ui: &mut egui::Ui, node: &FileNode) -> egui::Response { node_box_ui(ui, node.node(), |ui| ui.label("file node")) } fn link_node_ui(ui: &mut egui::Ui, node: &LinkNode) -> egui::Response { node_box_ui(ui, node.node(), |ui| ui.label("link node")) } fn group_node_ui(ui: &mut egui::Ui, node: &GroupNode) -> egui::Response { node_box_ui(ui, node.node(), |ui| ui.label("group node")) } fn node_box_ui( ui: &mut egui::Ui, node: &GenericNode, contents: impl FnOnce(&mut egui::Ui) -> egui::Response, ) -> egui::Response { let pos = node_rect(node); ui.put(pos, |ui: &mut egui::Ui| { egui::Frame::default() .fill(ui.visuals().noninteractive().weak_bg_fill) .inner_margin(egui::Margin::same(16)) .corner_radius(egui::CornerRadius::same(10)) .stroke(egui::Stroke::new( 2.0, ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { let rect = ui.available_rect_before_wrap(); ui.allocate_at_least(ui.available_size(), egui::Sense::click()); ui.put(rect, contents); }) .response }) }