commit 17f72f6127b3e75c463352c9063a473d0d6591b1
parent f592015c0c8eee2f73f166cd0c4c9ce3a2863300
Author: William Casarin <jb55@jb55.com>
Date: Sat, 19 Jul 2025 10:30:12 -0700
notebook: draw edges and arrows
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
2 files changed, 167 insertions(+), 12 deletions(-)
diff --git a/crates/notedeck_notebook/src/debug.rs b/crates/notedeck_notebook/src/debug.rs
@@ -0,0 +1,26 @@
+
+/*
+fn debug_slider(
+ ui: &mut egui::Ui,
+ id: egui::Id,
+ point: Pos2,
+ initial: f32,
+ range: std::ops::RangeInclusive<f32>,
+) -> f32 {
+ let mut val = ui.data_mut(|d| *d.get_temp_mut_or::<f32>(id, initial));
+ let nudge = vec2(10.0, 10.0);
+ let slider = Rect::from_min_max(point - nudge, point + nudge);
+ let label = Rect::from_min_max(point + nudge * 2.0, point - nudge * 2.0);
+
+ let old_val = val;
+ ui.put(slider, egui::Slider::new(&mut val, range));
+ ui.put(label, egui::Label::new(format!("{val}")));
+
+ if val != old_val {
+ ui.data_mut(|d| d.insert_temp(id, val))
+ }
+
+ val
+}
+*/
+
diff --git a/crates/notedeck_notebook/src/lib.rs b/crates/notedeck_notebook/src/lib.rs
@@ -1,6 +1,12 @@
-use egui::{Align, Label, Pos2, Rect, TextWrapMode};
-use jsoncanvas::{FileNode, GroupNode, JsonCanvas, LinkNode, Node, TextNode, node::*};
+use egui::{Align, Label, Pos2, Rect, Shape, Stroke, TextWrapMode, epaint::CubicBezierShape, vec2};
+use jsoncanvas::{
+ FileNode, GroupNode, JsonCanvas, LinkNode, Node, NodeId, TextNode,
+ edge::{Edge, Side},
+ node::GenericNode,
+};
use notedeck::{AppAction, AppContext};
+use std::collections::HashMap;
+use std::ops::Neg;
pub struct Notebook {
canvas: JsonCanvas,
@@ -8,6 +14,12 @@ pub struct Notebook {
loaded: bool,
}
+impl Notebook {
+ pub fn new() -> Self {
+ Notebook::default()
+ }
+}
+
impl Default for Notebook {
fn default() -> Self {
Notebook {
@@ -28,15 +40,138 @@ impl notedeck::App for Notebook {
}
egui::Scene::new().show(ui, &mut self.scene_rect, |ui| {
+ // render nodes
for (_node_id, node) in self.canvas.get_nodes().iter() {
let _resp = node_ui(ui, node);
}
+
+ // render edges
+ for (_edge_id, edge) in self.canvas.get_edges().iter() {
+ let _resp = edge_ui(ui, self.canvas.get_nodes(), edge);
+ }
});
None
}
}
+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),
+ }
+}
+
+fn edge_ui(
+ ui: &mut egui::Ui,
+ nodes: &HashMap<NodeId, Node>,
+ edge: &Edge,
+) -> Option<egui::Response> {
+ 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
+ ));
+}
+
fn node_ui(ui: &mut egui::Ui, node: &Node) -> egui::Response {
match node {
Node::Text(text_node) => text_node_ui(ui, text_node),
@@ -79,21 +214,15 @@ fn node_box_ui(
node: &GenericNode,
contents: impl FnOnce(&mut egui::Ui),
) -> egui::Response {
- 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);
+ let pos = node_rect(node);
- ui.put(Rect::from_min_max(min, max), |ui: &mut egui::Ui| {
+ ui.put(pos, |ui: &mut egui::Ui| {
egui::Frame::default()
.fill(ui.visuals().noninteractive().weak_bg_fill)
- .inner_margin(egui::Margin::same(4))
+ .inner_margin(egui::Margin::same(16))
.corner_radius(egui::CornerRadius::same(10))
.stroke(egui::Stroke::new(
- 1.0,
+ 2.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, contents)