Files
notedeck/crates/notedeck_notebook/src/ui.rs
William Casarin d12e5b363c notebook: move ui code into its own file
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:08:23 -07:00

185 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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` youre painting in
/// * `side` which edge of the box were attaching to
/// * `point` the exact spot on that edge the arrows tip should touch
/// * `fill` colour to fill the arrow with (usually your popups 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), // baseleft (above)
Pos2::new(point.x + width * 0.5, point.y - len), // baseright (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
})
}