notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Acrates/notedeck_notebook/src/debug.rs | 26++++++++++++++++++++++++++
Mcrates/notedeck_notebook/src/lib.rs | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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)