From f3822a8d98406baeabbba81362b331f02dc590d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 08:22:54 -0400 Subject: [PATCH] =?UTF-8?q?Canvas=20=C2=B7=20Graphs=20and=20boards:=20node?= =?UTF-8?q?=20graph,=20flow=20chart,=20mind=20map,=20whiteboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeGraph joins Nodes by wires between typed Ports: a title drags a node, a drag out of an output lands on an input of the same kind, an input takes one wire, and a press on a wired input lifts its wire. Links join shapes on the plane and follow them, straight or at right angles, with an arrow and words; the Connector tool draws them and the Note tool drops a sticky note. A double press with Select writes on a shape. FlowChart draws Steps as ellipses, boxes and diamonds with their words. MindMap lays topics out from a root, half to each side, and asks the owner to add, remove and rename by key. Whiteboard keeps its own tool, view and selection over a board it hands back after each change. Painting moves to canvas::paint; a press on the tool layer goes through canvas::press, and the layer focuses itself and asks to delete. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + examples/gallery/pages/canvas/boards.rs | 240 ++++++++++++++ examples/gallery/pages/canvas/graphs.rs | 144 +++++++++ examples/gallery/pages/canvas/mod.rs | 6 + src/canvas/assets.rs | 2 +- src/canvas/edit.rs | 10 +- src/canvas/flow.rs | 114 +++++++ src/canvas/gesture.rs | 27 +- src/canvas/graph.rs | 227 +++++++++++++ src/canvas/layers.rs | 9 +- src/canvas/links.rs | 113 +++++++ src/canvas/mindmap.rs | 244 ++++++++++++++ src/canvas/mindmap_view.rs | 306 ++++++++++++++++++ src/canvas/mod.rs | 18 ++ src/canvas/nodes.rs | 413 ++++++++++++++++++++++++ src/canvas/paint.rs | 145 +++++++++ src/canvas/palette.rs | 20 +- src/canvas/plane.rs | 217 ++++++------- src/canvas/press.rs | 149 +++++++++ src/canvas/shape.rs | 27 ++ src/canvas/tests/boards.rs | 148 +++++++++ src/canvas/tests/mod.rs | 1 + src/canvas/tests/tools.rs | 16 +- src/canvas/tools.rs | 223 +++++++------ src/canvas/whiteboard.rs | 321 ++++++++++++++++++ src/canvas/wiring.rs | 98 ++++++ src/canvas/writing.rs | 54 ++++ tasks/ch31-43.md | 14 +- 28 files changed, 3068 insertions(+), 240 deletions(-) create mode 100644 examples/gallery/pages/canvas/boards.rs create mode 100644 examples/gallery/pages/canvas/graphs.rs create mode 100644 src/canvas/flow.rs create mode 100644 src/canvas/graph.rs create mode 100644 src/canvas/links.rs create mode 100644 src/canvas/mindmap.rs create mode 100644 src/canvas/mindmap_view.rs create mode 100644 src/canvas/nodes.rs create mode 100644 src/canvas/paint.rs create mode 100644 src/canvas/press.rs create mode 100644 src/canvas/tests/boards.rs create mode 100644 src/canvas/whiteboard.rs create mode 100644 src/canvas/wiring.rs create mode 100644 src/canvas/writing.rs diff --git a/AGENTS.md b/AGENTS.md index c2dad1d..ea3894d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). - A ScrubInput shows its number in about 140px beside a short label. In a narrower column a pair hides it, so a long label takes a row of its own (`canvas::TypographyPanel`). - Design panels edit a whole value and hand the owner a changed copy through `canvas::panel::editing`. A swatch that opens a picker is `canvas::panel::color_well`. +- A press that stops propagation also stops the ancestors' focus on press. A layer that takes keys focuses itself in its own press handler (`canvas::ToolLayer`, `canvas::MindMap`), and a field over it stops its own presses. +- A focus ring on a box whose children measure themselves goes on an overlay child: a border insets the children and shifts what they measure (`canvas::ToolLayer`). - A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`). - A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue. - A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`). diff --git a/examples/gallery/pages/canvas/boards.rs b/examples/gallery/pages/canvas/boards.rs new file mode 100644 index 0000000..a707bfe --- /dev/null +++ b/examples/gallery/pages/canvas/boards.rs @@ -0,0 +1,240 @@ +use ely_gpui_component::{ + canvas::{ + FlowChart, Frame, Link, MindMap, Step, StepKind, Tool, ToolLayer, ToolPalette, Topic, + Viewport, + }, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The flow demo's steps, links, view, tool and selection. +struct Flow { + steps: Vec, + links: Vec, + view: Viewport, + tool: Tool, + selected: Vec, +} + +impl Flow { + fn new() -> Self { + let step = |key, kind, text, x, y, w| Step::new(key, kind, text, Frame::new(x, y, w, 56.0)); + Self { + steps: vec![ + step("start", StepKind::Terminal, "Start", 20.0, 132.0, 110.0), + step( + "draft", + StepKind::Process, + "Write the draft", + 180.0, + 132.0, + 150.0, + ), + step( + "check", + StepKind::Decision, + "Reviewed?", + 380.0, + 122.0, + 140.0, + ), + step("ship", StepKind::Process, "Publish", 580.0, 132.0, 120.0), + step("end", StepKind::Terminal, "End", 580.0, 250.0, 120.0), + step( + "revise", + StepKind::Process, + "Ask for changes", + 370.0, + 250.0, + 160.0, + ), + ], + links: vec![ + Link::new("l1", "start", "draft"), + Link::new("l2", "draft", "check"), + Link::new("l3", "check", "ship").label("Yes"), + Link::new("l4", "check", "revise").label("No"), + Link::new("l5", "ship", "end"), + ], + view: Viewport::new(0.0, 0.0, 0.8), + tool: Tool::Select, + selected: Vec::new(), + } + } +} + +fn boxed(width: f32, height: f32, child: impl IntoElement, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .w(px(width)) + .h(px(height)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child(child) +} + +pub fn flow(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("canvas-flow", Flow::new, window, cx); + let now = state.read(cx); + let (steps, links, view, tool, selected) = ( + now.steps.clone(), + now.links.clone(), + now.view, + now.tool, + now.selected.clone(), + ); + let [picked, chosen, moved, sized, linked, written, viewed] = [(); 7].map(|_| state.clone()); + let at = |steps: &mut Vec, key: &SharedString| { + steps + .iter() + .position(|step| step.key == *key) + .expect("a step of the flow") + }; + let layer = ToolLayer::new( + "canvas-flow-tools", + tool, + view, + steps.iter().map(Step::shape), + ) + .selected(selected) + .on_select(move |keys, _, cx| change(&chosen, cx, |flow| flow.selected = keys.to_vec())) + .on_move(move |keys, (dx, dy), _, cx| { + change(&moved, cx, |flow| { + for step in flow + .steps + .iter_mut() + .filter(|step| keys.contains(&step.key)) + { + step.frame = Frame::new( + step.frame.x + dx, + step.frame.y + dy, + step.frame.w, + step.frame.h, + ); + } + }) + }) + .on_resize(move |key, frame, _, cx| { + change(&sized, cx, |flow| { + let ix = at(&mut flow.steps, key); + flow.steps[ix].frame = frame; + }) + }) + .on_link(move |from, to, _, cx| { + change(&linked, cx, |flow| { + let key = format!("made-{}", flow.links.len() + 1); + flow.links.push(Link::new(key, from.clone(), to.clone())); + }) + }) + .on_text(move |key, text, _, cx| { + change(&written, cx, |flow| { + let ix = at(&mut flow.steps, key); + flow.steps[ix].text = text.clone(); + }) + }); + section( + "FlowChart / DiagramEditor · Connector Line", + "Steps joined by links that follow them: ends as ellipses, work as boxes, choices as diamonds, and words on a link. With Select a step moves, resizes, and takes new words on a double press; the connector draws a link from one step to another.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .items_start() + .gap_2() + .child( + ToolPalette::new("canvas-flow-palette", tool) + .tools([Tool::Select, Tool::Connector]) + .on_change(move |next, _, cx| change(&picked, cx, |flow| flow.tool = next)), + ) + .child(probe( + "canvas-flow", + boxed( + 620.0, + 300.0, + FlowChart::new("canvas-flow-chart", view, steps, links) + .layer(layer) + .on_viewport(move |next, _, cx| change(&viewed, cx, |flow| flow.view = next)), + cx, + ), + )), + ) +} + +/// The mind map demo's topics, selection, view and count of topics made. +struct Thinking { + root: Topic, + selected: Option, + view: Viewport, + made: usize, +} + +impl Thinking { + fn new() -> Self { + let topic = Topic::new; + Self { + root: topic("root", "Product launch") + .child( + topic("site", "Website") + .child(topic("copy", "Copy")) + .child(topic("shots", "Screenshots")), + ) + .child(topic("press", "Press").child(topic("kit", "Press kit"))) + .child(topic("budget", "Budget")) + .child(topic("team", "Team").child(topic("roles", "Roles"))), + selected: Some("site".into()), + view: Viewport::fitting( + Frame::new(-520.0, -140.0, 1040.0, 280.0), + (620.0, 300.0), + 16.0, + ), + made: 0, + } + } +} + +pub fn mind(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("canvas-mind", Thinking::new, window, cx); + let now = state.read(cx); + let (root, selected, view) = (now.root.clone(), now.selected.clone(), now.view); + let [picked, added, removed, renamed, viewed] = [(); 5].map(|_| state.clone()); + section( + "MindMap", + "Topics branching from a root, half to each side. A press selects; arrows walk, Tab adds a child and Enter a sibling, Delete removes one, and F2 or a double press rewrites it. The owner keeps the map.", + cx, + ) + .child(boxed( + 620.0, + 300.0, + MindMap::new("canvas-mind-map", root, view) + .selected(selected) + .on_select(move |key, _, cx| change(&picked, cx, |mind| mind.selected = key.cloned())) + .on_add(move |parent, _, cx| { + change(&added, cx, |mind| { + mind.made += 1; + let key = SharedString::from(format!("idea-{}", mind.made)); + mind.root = mind.root.adding(parent, Topic::new(key.clone(), "New idea")); + mind.selected = Some(key); + }) + }) + .on_remove(move |key, _, cx| { + change(&removed, cx, |mind| { + mind.selected = mind.root.parent_of(key); + mind.root = mind.root.removing(key); + }) + }) + .on_rename(move |key, text, _, cx| { + change(&renamed, cx, |mind| mind.root = mind.root.renaming(key, text.clone())) + }) + .on_viewport(move |next, _, cx| change(&viewed, cx, |mind| mind.view = next)), + cx, + )) +} diff --git a/examples/gallery/pages/canvas/graphs.rs b/examples/gallery/pages/canvas/graphs.rs new file mode 100644 index 0000000..ddbb6df --- /dev/null +++ b/examples/gallery/pages/canvas/graphs.rs @@ -0,0 +1,144 @@ +use ely_gpui_component::{ + canvas::{ + Board, Edge, Frame, Link, Node, NodeGraph, Port, Shape, ShapeKind, Viewport, Whiteboard, + }, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The node demo's nodes, wires, view and selection. +struct Pipeline { + nodes: Vec, + edges: Vec, + view: Viewport, + selected: Vec, +} + +impl Pipeline { + fn new() -> Self { + let port = Port::new; + Self { + nodes: vec![ + Node::new("load", "Load image", (20.0, 40.0)) + .output(port("image", "Image", "image")), + Node::new("prompt", "Prompt", (20.0, 170.0)).output(port("text", "Text", "text")), + Node::new("model", "Model", (290.0, 60.0)) + .input(port("image", "Image", "image")) + .input(port("text", "Prompt", "text")) + .input(port("steps", "Steps", "number")) + .output(port("image", "Image", "image")), + Node::new("save", "Save image", (560.0, 90.0)) + .input(port("image", "Image", "image")), + ], + edges: vec![ + Edge::new(("load", "image"), ("model", "image")), + Edge::new(("prompt", "text"), ("model", "text")), + ], + view: Viewport::new(0.0, 0.0, 0.8), + selected: Vec::new(), + } + } +} + +fn boxed(width: f32, height: f32, child: impl IntoElement, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .w(px(width)) + .h(px(height)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child(child) +} + +pub fn nodes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("canvas-nodes", Pipeline::new, window, cx); + let now = state.read(cx); + let (nodes, edges, view, selected) = ( + now.nodes.clone(), + now.edges.clone(), + now.view, + now.selected.clone(), + ); + let [picked, moved, joined, parted, viewed] = [(); 5].map(|_| state.clone()); + section( + "NodeEditor / NodeGraph · Node / Port / Edge / Connection", + "Nodes joined by wires, as in a pipeline editor. A title drags its node; a drag out of an output draws a wire that lands on an input of the same kind, each kind in its own hue. An input takes one wire, so a new one replaces the old; a press on a wired input lifts the wire to move it or let it go.", + cx, + ) + .child(probe( + "canvas-nodes", + boxed( + 620.0, + 300.0, + NodeGraph::new("canvas-node-graph", view, nodes, edges) + .selected(selected) + .on_select(move |keys, _, cx| change(&picked, cx, |graph| graph.selected = keys.to_vec())) + .on_move(move |key, (dx, dy), _, cx| { + change(&moved, cx, |graph| { + let node = graph.nodes.iter_mut().find(|node| node.key == *key).expect("a node of the graph"); + node.at = (node.at.0 + dx, node.at.1 + dy); + }) + }) + .on_connect(move |edge, _, cx| change(&joined, cx, |graph| graph.edges.push(edge.clone()))) + .on_disconnect(move |edge, _, cx| change(&parted, cx, |graph| graph.edges.retain(|each| each != edge))) + .on_viewport(move |next, _, cx| change(&viewed, cx, |graph| graph.view = next)), + cx, + ), + )) +} + +fn first_board() -> Board { + let note = |key: &str, words: &str, x, y, hue| { + Shape::new( + key.to_string(), + "Note", + ShapeKind::Note(words.to_string().into()), + Frame::new(x, y, 150.0, 150.0), + ) + .hue(hue) + }; + Board { + shapes: vec![ + note("why", "Why do people leave in week two?", 30.0, 80.0, 2), + note("guess", "Setup takes too long", 230.0, 80.0, 5), + note("try", "Try a sample project", 430.0, 80.0, 1), + Shape::new( + "goal", + "Goal", + ShapeKind::Text("Week two retention".into()), + Frame::new(30.0, 270.0, 220.0, 32.0), + ), + ], + links: vec![ + Link::new("because", "why", "guess"), + Link::new("so", "guess", "try"), + ], + } +} + +pub fn board(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("canvas-board", first_board, window, cx); + let now = state.read(cx).clone(); + let kept = state.clone(); + section( + "Whiteboard · StickyNote", + "A board to think on, with its tools over its corner and its zoom at its foot. The note tool drops a sticky note, a double press writes on it, the connector links two notes, and Delete removes the selection. It keeps its own tool and view and hands the owner the whole board after each change.", + cx, + ) + .child(probe( + "canvas-board", + boxed( + 620.0, + 340.0, + Whiteboard::new("canvas-whiteboard", now).on_change(move |next, _, cx| change(&kept, cx, |board| *board = next)), + cx, + ), + )) +} diff --git a/examples/gallery/pages/canvas/mod.rs b/examples/gallery/pages/canvas/mod.rs index 2e0e958..226af7d 100644 --- a/examples/gallery/pages/canvas/mod.rs +++ b/examples/gallery/pages/canvas/mod.rs @@ -1,5 +1,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*}; +mod boards; +mod graphs; mod looks; mod panels; mod plane; @@ -47,5 +49,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(panels::arrange(window, cx)) .child(looks::styles(window, cx)) .child(looks::rules(window, cx)) + .child(graphs::nodes(window, cx)) + .child(boards::flow(window, cx)) + .child(boards::mind(window, cx)) + .child(graphs::board(window, cx)) .into_any_element() } diff --git a/src/canvas/assets.rs b/src/canvas/assets.rs index 3ee4f24..967a4e9 100644 --- a/src/canvas/assets.rs +++ b/src/canvas/assets.rs @@ -6,7 +6,7 @@ use gpui::{ }; use super::{ - plane::paint_shape, + paint::paint_shape, shape::{Shape, ShapeKind}, view::Viewport, }; diff --git a/src/canvas/edit.rs b/src/canvas/edit.rs index 82ed044..92eecdb 100644 --- a/src/canvas/edit.rs +++ b/src/canvas/edit.rs @@ -18,10 +18,12 @@ pub enum Tool { Pen, Text, Brush, + Note, + Connector, } impl Tool { - pub const ALL: [Tool; 10] = [ + pub const ALL: [Tool; 12] = [ Tool::Select, Tool::Hand, Tool::Rect, @@ -32,6 +34,8 @@ impl Tool { Tool::Pen, Tool::Text, Tool::Brush, + Tool::Note, + Tool::Connector, ]; pub fn words(self) -> &'static str { @@ -46,6 +50,8 @@ impl Tool { Tool::Pen => "Pen", Tool::Text => "Text", Tool::Brush => "Brush", + Tool::Note => "Note", + Tool::Connector => "Connector", } } @@ -62,6 +68,8 @@ impl Tool { Tool::Pen => "p", Tool::Text => "t", Tool::Brush => "b", + Tool::Note => "n", + Tool::Connector => "c", } } diff --git a/src/canvas/flow.rs b/src/canvas/flow.rs new file mode 100644 index 0000000..e7b7a94 --- /dev/null +++ b/src/canvas/flow.rs @@ -0,0 +1,114 @@ +use std::rc::Rc; + +use gpui::{AnyElement, App, ElementId, IntoElement, RenderOnce, SharedString, Window}; + +use super::{ + links::Link, + plane::{InfiniteCanvas, OnViewport}, + shape::{Shape, ShapeKind}, + view::{Frame, Viewport}, +}; + +/// What a step of a flow is: where it starts or ends, work done, or a choice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepKind { + Terminal, + Process, + Decision, +} + +/// A step of a flow: its key, what it is, its words and its frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Step { + pub key: SharedString, + pub kind: StepKind, + pub text: SharedString, + pub frame: Frame, +} + +impl Step { + pub fn new( + key: impl Into, + kind: StepKind, + text: impl Into, + frame: Frame, + ) -> Self { + Self { + key: key.into(), + kind, + text: text.into(), + frame, + } + } + + /// The shape that draws it, its words inside: an ellipse for an end, a box for work, a diamond for a choice. + pub fn shape(&self) -> Shape { + let (kind, hue) = match self.kind { + StepKind::Terminal => (ShapeKind::Ellipse, 5), + StepKind::Process => (ShapeKind::Rect, 0), + StepKind::Decision => (ShapeKind::Polygon(4), 2), + }; + Shape::new(self.key.clone(), self.text.clone(), kind, self.frame) + .hue(hue) + .label(self.text.clone()) + } +} + +/// Steps joined by links on an endless plane, each link following its steps with its own words. A layer edits it: a ToolLayer given the steps' shapes moves and resizes steps, links them with the connector, and rewrites one on a double press. +#[derive(IntoElement)] +pub struct FlowChart { + id: ElementId, + viewport: Viewport, + steps: Vec, + links: Vec, + layers: Vec, + on_viewport: Option, +} + +impl FlowChart { + pub fn new( + id: impl Into, + viewport: Viewport, + steps: impl IntoIterator, + links: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + viewport, + steps: steps.into_iter().collect(), + links: links.into_iter().collect(), + layers: Vec::new(), + on_viewport: None, + } + } + + pub fn layer(mut self, element: impl IntoElement) -> Self { + self.layers.push(element.into_any_element()); + self + } + + pub fn on_viewport( + mut self, + handler: impl Fn(Viewport, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_viewport = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FlowChart { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let canvas = InfiniteCanvas::new(self.id, self.viewport) + .shapes(self.steps.iter().map(Step::shape)) + .links(self.links); + let canvas = match self.on_viewport { + Some(on_viewport) => { + canvas.on_viewport(move |view, window, cx| on_viewport(view, window, cx)) + } + None => canvas, + }; + self.layers + .into_iter() + .fold(canvas, |canvas, layer| canvas.layer(layer)) + } +} diff --git a/src/canvas/gesture.rs b/src/canvas/gesture.rs index 5e5b51b..1d4a665 100644 --- a/src/canvas/gesture.rs +++ b/src/canvas/gesture.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{App, Bounds, Entity, Pixels, SharedString, Window}; use super::{ - edit::{Handle, drawn, inside, resized, snapped}, + edit::{Handle, drawn, hit, inside, resized, snapped}, shape::{Shape, ShapeKind}, view::{Frame, Viewport}, }; @@ -12,6 +12,8 @@ pub(super) type OnKeys = Rc; pub(super) type OnDraw = Rc; pub(super) type OnMove = Rc; pub(super) type OnResize = Rc; +/// A handler for two keys, or a key and its words. +pub(crate) type OnPair = Rc; /// A brush's width in canvas units and its hue among the chart colors. #[derive(Clone, Copy, Debug, PartialEq)] @@ -47,15 +49,20 @@ pub(super) enum Gesture { Stroke { points: Vec<(f32, f32)>, }, + Linking { + from: SharedString, + to: (f32, f32), + }, } -/// The layer's own state: the gesture under way, whether Shift is held, the box it covers, and the pen's points so far. +/// The layer's own state: the gesture under way, whether Shift is held, the box it covers, the pen's points so far, and the shape being written on. #[derive(Default)] pub(super) struct Hand { pub(super) gesture: Gesture, pub(super) square: bool, pub(super) bounds: Bounds, pub(super) pen: Vec<(f32, f32)>, + pub(super) writing: Option, } /// Shapes a small nudge still counts as a click by, in view pixels. @@ -109,6 +116,8 @@ pub(super) struct Handlers { pub(super) on_draw: Option, pub(super) on_move: Option, pub(super) on_resize: Option, + pub(super) on_link: Option, + pub(super) on_text: Option, } /// What a gesture ends against: the shapes, the selection, the frames to snap to, and the view. @@ -181,6 +190,20 @@ pub(super) fn commit( on_draw(kind, frame, window, cx); } } + Gesture::Linking { from, to } => { + let Some(ix) = hit(&scene.shapes, to, REACH / scene.view.zoom) else { + log::info!("tool layer: the link from {from} lands on nothing"); + return; + }; + let target = &scene.shapes[ix].key; + if *target == from { + return; + } + log::info!("tool layer: link {from} to {target}"); + if let Some(on_link) = &handlers.on_link { + on_link(&from, target, window, cx); + } + } Gesture::Stroke { points } if points.len() > 1 => { let (kind, frame) = path(&points, brush.size); log::info!("tool layer: a stroke of {} points", points.len()); diff --git a/src/canvas/graph.rs b/src/canvas/graph.rs new file mode 100644 index 0000000..12a163b --- /dev/null +++ b/src/canvas/graph.rs @@ -0,0 +1,227 @@ +use gpui::SharedString; + +use super::view::Frame; + +/// A socket on a node that carries one kind of value in or out. +#[derive(Clone, Debug, PartialEq)] +pub struct Port { + pub key: SharedString, + pub name: SharedString, + pub kind: SharedString, +} + +impl Port { + pub fn new( + key: impl Into, + name: impl Into, + kind: impl Into, + ) -> Self { + Self { + key: key.into(), + name: name.into(), + kind: kind.into(), + } + } +} + +/// A box of work at a point on the canvas, its inputs down its left side and its outputs down its right. +#[derive(Clone, Debug, PartialEq)] +pub struct Node { + pub key: SharedString, + pub title: SharedString, + pub at: (f32, f32), + pub inputs: Vec, + pub outputs: Vec, +} + +impl Node { + pub fn new( + key: impl Into, + title: impl Into, + at: (f32, f32), + ) -> Self { + Self { + key: key.into(), + title: title.into(), + at, + inputs: Vec::new(), + outputs: Vec::new(), + } + } + + pub fn input(mut self, port: Port) -> Self { + self.inputs.push(port); + self + } + + pub fn output(mut self, port: Port) -> Self { + self.outputs.push(port); + self + } + + pub(crate) fn frame(&self) -> Frame { + let rows = self.inputs.len().max(self.outputs.len()) as f32; + Frame::new(self.at.0, self.at.1, WIDE, HEAD + ROW * rows + FOOT) + } + + /// Where a port's socket sits, in canvas units: on the left edge for an input, the right for an output. + pub(crate) fn socket(&self, out: bool, row: usize) -> (f32, f32) { + let x = if out { self.at.0 + WIDE } else { self.at.0 }; + (x, self.at.1 + HEAD + ROW * row as f32 + ROW / 2.0) + } + + fn row(&self, out: bool, port: &str) -> usize { + let ports = if out { &self.outputs } else { &self.inputs }; + ports + .iter() + .position(|each| each.key == port) + .unwrap_or_else(|| { + panic!( + "node {} has no {} {port}", + self.key, + if out { "output" } else { "input" } + ) + }) + } +} + +/// A wire from one node's output to another's input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Edge { + pub from: (SharedString, SharedString), + pub to: (SharedString, SharedString), +} + +impl Edge { + pub fn new( + from: (impl Into, impl Into), + to: (impl Into, impl Into), + ) -> Self { + Self { + from: (from.0.into(), from.1.into()), + to: (to.0.into(), to.1.into()), + } + } +} + +/// A node's width, its title's height, a port row's height and the room under the last row, in canvas units. +pub(crate) const WIDE: f32 = 200.0; +pub(crate) const HEAD: f32 = 32.0; +pub(crate) const ROW: f32 = 28.0; +const FOOT: f32 = 8.0; + +/// A port a press or a release reaches: its node and key, whether it is an output, and its kind. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Socket { + pub node: SharedString, + pub port: SharedString, + pub out: bool, + pub kind: SharedString, +} + +fn node<'a>(nodes: &'a [Node], key: &str) -> &'a Node { + nodes + .iter() + .find(|node| node.key == key) + .unwrap_or_else(|| panic!("no node {key} in the graph")) +} + +/// An edge's ends in canvas units and the kind it carries. +pub(crate) fn ends(nodes: &[Node], edge: &Edge) -> ((f32, f32), (f32, f32), SharedString) { + let (from, to) = (node(nodes, &edge.from.0), node(nodes, &edge.to.0)); + let row = from.row(true, &edge.from.1); + ( + from.socket(true, row), + to.socket(false, to.row(false, &edge.to.1)), + from.outputs[row].kind.clone(), + ) +} + +/// The socket within `reach` of `point`, the topmost node first. +pub(crate) fn socket_at(nodes: &[Node], point: (f32, f32), reach: f32) -> Option { + nodes.iter().rev().find_map(|node| { + let near = + |(x, y): (f32, f32)| (x - point.0).abs() <= reach && (y - point.1).abs() <= reach; + let ins = node + .inputs + .iter() + .enumerate() + .map(|(row, port)| (false, row, port)); + let outs = node + .outputs + .iter() + .enumerate() + .map(|(row, port)| (true, row, port)); + ins.chain(outs) + .find(|(out, row, _)| near(node.socket(*out, *row))) + .map(|(out, _, port)| Socket { + node: node.key.clone(), + port: port.key.clone(), + out, + kind: port.kind.clone(), + }) + }) +} + +/// Whether a wire may run from `from` to `to`: out of an output, into an input of the same kind, on another node. +pub(crate) fn fits(from: &Socket, to: &Socket) -> bool { + from.out && !to.out && from.node != to.node && from.kind == to.kind +} + +/// The kinds of value on the graph, in the order they first appear, which gives each its hue. +pub(crate) fn kinds(nodes: &[Node]) -> Vec { + nodes + .iter() + .flat_map(|node| node.inputs.iter().chain(&node.outputs)) + .fold(Vec::new(), |mut kinds, port| { + if !kinds.contains(&port.kind) { + kinds.push(port.kind.clone()); + } + kinds + }) +} + +#[cfg(test)] +mod tests { + use super::{Edge, HEAD, Node, Port, ROW, WIDE, ends, fits, kinds, socket_at}; + + fn graph() -> Vec { + vec![ + Node::new("load", "Load image", (0.0, 0.0)) + .output(Port::new("image", "Image", "image")), + Node::new("blur", "Blur", (300.0, 0.0)) + .input(Port::new("image", "Image", "image")) + .input(Port::new("radius", "Radius", "number")) + .output(Port::new("image", "Image", "image")), + ] + } + + #[test] + fn sockets_sit_on_the_sides_a_row_apart() { + let nodes = graph(); + let (from, to, kind) = ends(&nodes, &Edge::new(("load", "image"), ("blur", "image"))); + assert_eq!(from, (WIDE, HEAD + ROW / 2.0)); + assert_eq!(to, (300.0, HEAD + ROW / 2.0)); + assert_eq!(kind, "image"); + let radius = + socket_at(&nodes, (302.0, HEAD + ROW * 1.5 - 3.0), 6.0).expect("the radius input"); + assert_eq!( + (radius.node.as_ref(), radius.port.as_ref(), radius.out), + ("blur", "radius", false) + ); + assert_eq!(socket_at(&nodes, (150.0, 40.0), 6.0), None); + } + + #[test] + fn a_wire_runs_out_to_in_between_nodes_of_one_kind() { + let nodes = graph(); + let at = |x: f32, row: f32| { + socket_at(&nodes, (x, HEAD + ROW * row + ROW / 2.0), 4.0).expect("a socket") + }; + let (image_out, blur_in, radius_in) = (at(WIDE, 0.0), at(300.0, 0.0), at(300.0, 1.0)); + assert!(fits(&image_out, &blur_in)); + assert!(!fits(&image_out, &radius_in), "an image is no number"); + assert!(!fits(&blur_in, &image_out), "wires run out to in"); + assert_eq!(kinds(&nodes), ["image", "number"]); + } +} diff --git a/src/canvas/layers.rs b/src/canvas/layers.rs index d0ee1fe..4ed56b3 100644 --- a/src/canvas/layers.rs +++ b/src/canvas/layers.rs @@ -2,11 +2,12 @@ use std::rc::Rc; use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Styled, Window}; -use super::{gesture::OnKeys, shape::Shape}; +use super::{ + gesture::{OnKeys, OnPair}, + shape::Shape, +}; use crate::lists::{DropAt, Tree, TreeNode}; -type OnRename = Rc; - /// The keys in paint order, bottom first, after `dragged` lands `at` `target` in a list drawn top first. pub(crate) fn restacked( order: &[SharedString], @@ -41,7 +42,7 @@ pub struct LayerPanel { selected: Vec, on_select: Option, on_show: Option, - on_rename: Option, + on_rename: Option, on_restack: Option, } diff --git a/src/canvas/links.rs b/src/canvas/links.rs new file mode 100644 index 0000000..c59faa5 --- /dev/null +++ b/src/canvas/links.rs @@ -0,0 +1,113 @@ +use gpui::SharedString; + +use super::view::Frame; + +/// A line from one shape to another that follows them as they move: straight or turning at right angles, with an arrow at its end and words at its middle. +#[derive(Clone, Debug, PartialEq)] +pub struct Link { + pub key: SharedString, + pub from: SharedString, + pub to: SharedString, + pub label: Option, + pub elbow: bool, +} + +impl Link { + pub fn new( + key: impl Into, + from: impl Into, + to: impl Into, + ) -> Self { + let (from, to) = (from.into(), to.into()); + assert!(from != to, "a link from {from} to itself"); + Self { + key: key.into(), + from, + to, + label: None, + elbow: false, + } + } + + pub fn label(mut self, text: impl Into) -> Self { + self.label = Some(text.into()); + self + } + + /// Turns at right angles instead of running straight. + pub fn elbow(mut self) -> Self { + self.elbow = true; + self + } +} + +/// The points a link runs through, in canvas units: out of the side of `from` that faces `to`, into the side of `to` that faces back. An elbow turns halfway between. +pub(crate) fn route(from: &Frame, to: &Frame, elbow: bool) -> Vec<(f32, f32)> { + let (a, b) = (from.center(), to.center()); + let across = (b.0 - a.0).abs() >= (b.1 - a.1).abs(); + let (exit, entry) = match (across, b.0 >= a.0, b.1 >= a.1) { + (true, true, _) => ((from.right(), a.1), (to.x, b.1)), + (true, false, _) => ((from.x, a.1), (to.right(), b.1)), + (false, _, true) => ((a.0, from.bottom()), (b.0, to.y)), + (false, _, false) => ((a.0, from.y), (b.0, to.bottom())), + }; + match (elbow, across) { + (false, _) => vec![exit, entry], + (true, true) => { + let middle = (exit.0 + entry.0) / 2.0; + vec![exit, (middle, exit.1), (middle, entry.1), entry] + } + (true, false) => { + let middle = (exit.1 + entry.1) / 2.0; + vec![exit, (exit.0, middle), (entry.0, middle), entry] + } + } +} + +/// The point halfway along a run of points. +pub(crate) fn halfway(points: &[(f32, f32)]) -> (f32, f32) { + let length = + |(a, b): (&(f32, f32), &(f32, f32))| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); + let whole: f32 = points.iter().zip(&points[1..]).map(length).sum(); + let mut left = whole / 2.0; + for (a, b) in points.iter().zip(&points[1..]) { + let span = length((a, b)); + if left <= span && span > 0.0 { + let share = left / span; + return (a.0 + (b.0 - a.0) * share, a.1 + (b.1 - a.1) * share); + } + left -= span; + } + *points.last().expect("a run has points") +} + +#[cfg(test)] +mod tests { + use super::{halfway, route}; + use crate::canvas::Frame; + + #[test] + fn a_link_leaves_the_side_that_faces_the_other_shape() { + let (a, b) = ( + Frame::new(0.0, 0.0, 100.0, 50.0), + Frame::new(200.0, 100.0, 100.0, 50.0), + ); + assert_eq!(route(&a, &b, false), [(100.0, 25.0), (200.0, 125.0)]); + assert_eq!(route(&b, &a, false), [(200.0, 125.0), (100.0, 25.0)]); + let below = Frame::new(0.0, 300.0, 100.0, 50.0); + assert_eq!(route(&a, &below, false), [(50.0, 50.0), (50.0, 300.0)]); + } + + #[test] + fn an_elbow_turns_halfway() { + let (a, b) = ( + Frame::new(0.0, 0.0, 100.0, 50.0), + Frame::new(200.0, 100.0, 100.0, 50.0), + ); + assert_eq!( + route(&a, &b, true), + [(100.0, 25.0), (150.0, 25.0), (150.0, 125.0), (200.0, 125.0)] + ); + assert_eq!(halfway(&route(&a, &b, true)), (150.0, 75.0)); + } +} diff --git a/src/canvas/mindmap.rs b/src/canvas/mindmap.rs new file mode 100644 index 0000000..cf04ce6 --- /dev/null +++ b/src/canvas/mindmap.rs @@ -0,0 +1,244 @@ +use gpui::SharedString; + +use super::view::Frame; + +/// A topic of a mind map, with the topics under it. +#[derive(Clone, Debug, PartialEq)] +pub struct Topic { + pub key: SharedString, + pub text: SharedString, + pub children: Vec, +} + +impl Topic { + pub fn new(key: impl Into, text: impl Into) -> Self { + Self { + key: key.into(), + text: text.into(), + children: Vec::new(), + } + } + + pub fn child(mut self, topic: Topic) -> Self { + self.children.push(topic); + self + } + + /// The map with `topic` added last under `parent`. + pub fn adding(&self, parent: &str, topic: Topic) -> Topic { + let mut next = self.clone(); + let under = next + .find(parent) + .unwrap_or_else(|| panic!("no topic {parent} to add under")); + under.children.push(topic); + next + } + + /// The map without `key` and what lies under it. + pub fn removing(&self, key: &str) -> Topic { + assert!(self.key != key, "the root topic {key} stays"); + let mut next = self.clone(); + let parent = next + .parent_of(key) + .unwrap_or_else(|| panic!("no topic {key} to remove")); + next.find(&parent) + .expect("the parent is in the map") + .children + .retain(|topic| topic.key != key); + next + } + + /// The map with `key` reading `text`. + pub fn renaming(&self, key: &str, text: impl Into) -> Topic { + let mut next = self.clone(); + next.find(key) + .unwrap_or_else(|| panic!("no topic {key} to rename")) + .text = text.into(); + next + } + + fn find(&mut self, key: &str) -> Option<&mut Topic> { + if self.key == key { + return Some(self); + } + self.children.iter_mut().find_map(|topic| topic.find(key)) + } + + /// The key of the topic `key` sits under. + pub fn parent_of(&self, key: &str) -> Option { + self.children.iter().find_map(|topic| { + if topic.key == key { + Some(self.key.clone()) + } else { + topic.parent_of(key) + } + }) + } +} + +/// A topic's box, in canvas units. +pub(crate) const TOPIC: (f32, f32) = (160.0, 36.0); +/// Between a topic and its children's column, and between siblings, in canvas units. +const GAP: (f32, f32) = (56.0, 12.0); + +/// A topic laid out: its key, its frame, the key it sits under, and whether it grows to the left. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Placed { + pub key: SharedString, + pub text: SharedString, + pub frame: Frame, + pub parent: Option, + pub left: bool, +} + +fn tall(topic: &Topic) -> f32 { + let children: f32 = topic.children.iter().map(tall).sum::() + + GAP.1 * topic.children.len().saturating_sub(1) as f32; + children.max(TOPIC.1) +} + +/// Every topic laid out: the root centered on the origin, the first half of its children to its right and the rest to its left, each topic's children a column beside it centered on it. +pub(crate) fn laid(root: &Topic) -> Vec { + let frame = Frame::new(-TOPIC.0 / 2.0, -TOPIC.1 / 2.0, TOPIC.0, TOPIC.1); + let mut out = vec![Placed { + key: root.key.clone(), + text: root.text.clone(), + frame, + parent: None, + left: false, + }]; + let right = root.children.len().div_ceil(2); + let (east, west) = root.children.split_at(right); + column(east, &root.key, &frame, false, &mut out); + column(west, &root.key, &frame, true, &mut out); + out +} + +fn column( + topics: &[Topic], + parent: &SharedString, + beside: &Frame, + left: bool, + out: &mut Vec, +) { + let total: f32 = + topics.iter().map(tall).sum::() + GAP.1 * topics.len().saturating_sub(1) as f32; + let x = if left { + beside.x - GAP.0 - TOPIC.0 + } else { + beside.right() + GAP.0 + }; + let mut top = beside.center().1 - total / 2.0; + for topic in topics { + let room = tall(topic); + let frame = Frame::new(x, top + (room - TOPIC.1) / 2.0, TOPIC.0, TOPIC.1); + out.push(Placed { + key: topic.key.clone(), + text: topic.text.clone(), + frame, + parent: Some(parent.clone()), + left, + }); + column(&topic.children, &topic.key, &frame, left, out); + top += room + GAP.1; + } +} + +/// The topic an arrow key reaches from `at`: Up and Down walk its siblings, and the key toward its children goes to the first of them, the other key back to its parent. +pub(crate) fn stepped(placed: &[Placed], at: &str, key: &str) -> Option { + let here = placed.iter().find(|topic| topic.key == at)?; + let outward = |left: bool| { + placed + .iter() + .find(|topic| { + topic + .parent + .as_ref() + .is_some_and(|parent| parent.as_ref() == at) + && topic.left == left + }) + .map(|topic| topic.key.clone()) + }; + let siblings: Vec<&Placed> = placed + .iter() + .filter(|topic| topic.parent == here.parent && topic.left == here.left) + .collect(); + let ix = siblings.iter().position(|topic| topic.key == at)?; + match (key, here.parent.is_some(), here.left) { + ("up", true, _) => ix.checked_sub(1).map(|ix| siblings[ix].key.clone()), + ("down", true, _) => siblings.get(ix + 1).map(|topic| topic.key.clone()), + ("right", false, _) => outward(false), + ("left", false, _) => outward(true), + ("right", true, false) | ("left", true, true) => outward(here.left), + ("left", true, false) | ("right", true, true) => here.parent.clone(), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{GAP, TOPIC, Topic, laid, stepped}; + + fn map() -> Topic { + Topic::new("root", "Launch") + .child( + Topic::new("a", "Site") + .child(Topic::new("a1", "Copy")) + .child(Topic::new("a2", "Pictures")), + ) + .child(Topic::new("b", "Press")) + .child(Topic::new("c", "Budget")) + } + + #[test] + fn half_the_children_go_right_and_each_column_centers_on_its_parent() { + let placed = laid(&map()); + let at = |key: &str| { + placed + .iter() + .find(|topic| topic.key == key) + .expect("placed") + }; + assert!(!at("a").left && !at("b").left && at("c").left); + assert_eq!(at("a").frame.x, TOPIC.0 / 2.0 + GAP.0); + assert_eq!(at("c").frame.right(), -TOPIC.0 / 2.0 - GAP.0); + assert_eq!( + at("c").frame.center().1, + 0.0, + "a lone child sits level with its parent" + ); + let (a1, a2) = (at("a1").frame.center().1, at("a2").frame.center().1); + assert_eq!((a1 + a2) / 2.0, at("a").frame.center().1); + assert_eq!(a2 - a1, TOPIC.1 + GAP.1); + } + + #[test] + fn arrows_walk_siblings_and_step_in_and_out() { + let placed = laid(&map()); + let step = |at: &str, key: &str| stepped(&placed, at, key).map(|key| key.to_string()); + assert_eq!(step("root", "right").as_deref(), Some("a")); + assert_eq!(step("root", "left").as_deref(), Some("c")); + assert_eq!(step("a", "down").as_deref(), Some("b")); + assert_eq!(step("a", "right").as_deref(), Some("a1")); + assert_eq!(step("a1", "left").as_deref(), Some("a")); + assert_eq!(step("c", "right").as_deref(), Some("root")); + assert_eq!(step("b", "down"), None); + } + + #[test] + fn edits_add_remove_and_rename_by_key() { + let map = map() + .adding("b", Topic::new("b1", "Release")) + .renaming("c", "Money"); + assert_eq!(map.children[1].children[0].key, "b1"); + assert_eq!(map.children[2].text, "Money"); + let map = map.removing("a"); + assert_eq!( + map.children + .iter() + .map(|topic| topic.key.as_ref()) + .collect::>(), + ["b", "c"] + ); + } +} diff --git a/src/canvas/mindmap_view.rs b/src/canvas/mindmap_view.rs new file mode 100644 index 0000000..33faa06 --- /dev/null +++ b/src/canvas/mindmap_view.rs @@ -0,0 +1,306 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + gesture::OnPair, + mindmap::{Placed, Topic, laid, stepped}, + paint::{finish, in_view, wire}, + plane::{InfiniteCanvas, OnViewport}, + view::Viewport, + writing, +}; +use crate::{ + forms::Editing, + primitives::{FocusNext, tab_stop}, + theme::{ActiveTheme, Radius, TextSize}, + typography::Ellipsis, +}; + +type OnTopic = Rc, &mut Window, &mut App)>; +type OnKey = Rc; + +/// Topics branching from a root on an endless plane, half to each side. A press selects a topic; arrows walk, Tab asks for a child, Enter for a sibling, Delete or Backspace to remove one, and F2 or a double press rewrites it; Escape lets the selection go, so Tab moves on. The owner keeps the map and answers each ask with `Topic::adding`, `removing` and `renaming`. +#[derive(IntoElement)] +pub struct MindMap { + id: ElementId, + root: Topic, + viewport: Viewport, + selected: Option, + on_select: Option, + on_add: Option, + on_remove: Option, + on_rename: Option, + on_viewport: Option, +} + +impl MindMap { + pub fn new(id: impl Into, root: Topic, viewport: Viewport) -> Self { + Self { + id: id.into(), + root, + viewport, + selected: None, + on_select: None, + on_add: None, + on_remove: None, + on_rename: None, + on_viewport: None, + } + } + + pub fn selected(mut self, key: Option>) -> Self { + self.selected = key.map(Into::into); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(Option<&SharedString>, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Gets the key of the topic a new one goes under. + pub fn on_add( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_add = Some(Rc::new(handler)); + self + } + + pub fn on_remove( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } + + pub fn on_rename( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rename = Some(Rc::new(handler)); + self + } + + pub fn on_viewport( + mut self, + handler: impl Fn(Viewport, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_viewport = Some(Rc::new(handler)); + self + } +} + +/// Where a branch leaves `parent` and meets `child`, in canvas units. +fn branch(parent: &Placed, child: &Placed) -> ((f32, f32), (f32, f32)) { + let (p, c) = (parent.frame, child.frame); + if child.left { + ((p.x, p.center().1), (c.right(), c.center().1)) + } else { + ((p.right(), p.center().1), (c.x, c.center().1)) + } +} + +impl RenderOnce for MindMap { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let view = self.viewport; + let placed = Rc::new(laid(&self.root)); + let focus = tab_stop((id.clone(), "focus").into(), true, window, cx); + let editing = + window.use_keyed_state((id.clone(), "editing"), cx, |_, _| Editing::default()); + let selected = self.selected.clone(); + if let Some(key) = &selected { + assert!( + placed.iter().any(|topic| topic.key == *key), + "mind map {id:?}: no topic {key} to select" + ); + } + let rename = { + let (editing, placed, on_rename) = + (editing.clone(), placed.clone(), self.on_rename.clone()); + move |key: &SharedString, window: &mut Window, cx: &mut App| { + let topic = placed + .iter() + .find(|topic| topic.key == *key) + .expect("a placed topic"); + let words = topic.text.to_string(); + writing::begin(&editing, key.clone(), words, on_rename.clone(), window, cx); + } + }; + let rename = Rc::new(rename); + let (on_select, on_add, on_remove) = (self.on_select, self.on_add, self.on_remove); + let theme = cx.theme(); + let rem = window.rem_size(); + let text = theme.text_size(TextSize::Sm).to_pixels(rem) * view.zoom; + let (colors, radius) = ( + theme.colors.clone(), + theme.radius(Radius::Lg).to_pixels(rem) * view.zoom, + ); + let stroke = theme.canvas().stroke.to_pixels(rem); + let topics: Vec = placed + .iter() + .map(|topic| { + let frame = in_view(&view, &topic.frame); + let chosen = selected.as_ref() == Some(&topic.key); + let root = topic.parent.is_none(); + let (key, pick, write, hold) = ( + topic.key.clone(), + on_select.clone(), + rename.clone(), + focus.clone(), + ); + div() + .id((id.clone(), format!("topic-{}", topic.key))) + .absolute() + .left(Pixels::from(frame.x)) + .top(Pixels::from(frame.y)) + .w(Pixels::from(frame.w)) + .h(Pixels::from(frame.h)) + .flex() + .items_center() + .justify_center() + .px(text * 0.75) + .rounded(radius) + .border_1() + .border_color(if chosen { + colors.accent + } else if root { + colors.border_strong + } else { + colors.border + }) + .bg(colors.surface) + .text_size(text) + .line_height(text * 1.3) + .text_color(colors.fg) + .when(root, |topic| topic.font_weight(FontWeight::SEMIBOLD)) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + cx.stop_propagation(); + window.focus(&hold); + if let Some(pick) = &pick { + pick(Some(&key), window, cx); + } + if event.click_count >= 2 { + window.prevent_default(); + write(&key, window, cx); + } + }) + .child(Ellipsis::new(topic.text.clone())) + .into_any_element() + }) + .collect(); + let branches = { + let (placed, ink) = (placed.clone(), colors.border_strong); + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for child in placed.iter() { + let Some(parent) = child.parent.as_ref() else { + continue; + }; + let parent = placed + .iter() + .find(|topic| topic.key == *parent) + .expect("a placed parent"); + let (from, to) = branch(parent, child); + let (from, to) = (view.to_view(from), view.to_view(to)); + finish( + wire(from, to, (to.0 - from.0) / 2.0, bounds.origin, stroke), + ink, + window, + ); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() + }; + let field = selected + .as_ref() + .and_then(|key| placed.iter().find(|topic| topic.key == *key)) + .and_then(|topic| writing::field(&editing, in_view(&view, &topic.frame), cx)); + let layer = div() + .absolute() + .inset_0() + .child(branches) + .children(topics) + .children(field); + let plane = InfiniteCanvas::new((id.clone(), "plane"), view).layer(layer); + let plane = match self.on_viewport { + Some(on_viewport) => { + plane.on_viewport(move |next, window, cx| on_viewport(next, window, cx)) + } + None => plane, + }; + let (keys, tabbed, parent_of) = (placed.clone(), selected.clone(), self.root.clone()); + let adding = on_add.clone(); + div() + .id(id) + .track_focus(&focus) + .relative() + .size_full() + .when(selected.is_some(), |map| { + map.capture_action(move |_: &FocusNext, window, cx| { + let (Some(key), Some(on_add)) = (&tabbed, &adding) else { + return; + }; + cx.stop_propagation(); + log::info!("mind map: a child under {key}"); + on_add(key, window, cx); + }) + }) + .on_key_down(move |event, window, cx| { + let Some(at) = &selected else { + return; + }; + let key = event.keystroke.key.as_str(); + match key { + "up" | "down" | "left" | "right" => { + cx.stop_propagation(); + if let (Some(next), Some(pick)) = (stepped(&keys, at, key), &on_select) { + pick(Some(&next), window, cx); + } + } + "enter" => { + cx.stop_propagation(); + let under = parent_of.parent_of(at).unwrap_or_else(|| at.clone()); + log::info!("mind map: a topic under {under}"); + if let Some(on_add) = &on_add { + on_add(&under, window, cx); + } + } + "backspace" | "delete" if parent_of.key != *at => { + cx.stop_propagation(); + log::info!("mind map: remove {at}"); + if let Some(on_remove) = &on_remove { + on_remove(at, window, cx); + } + } + "f2" => { + cx.stop_propagation(); + rename(at, window, cx); + } + "escape" => { + cx.stop_propagation(); + if let Some(pick) = &on_select { + pick(None, window, cx); + } + } + _ => {} + } + }) + .child(plane) + } +} diff --git a/src/canvas/mod.rs b/src/canvas/mod.rs index 313e12b..d00fc4f 100644 --- a/src/canvas/mod.rs +++ b/src/canvas/mod.rs @@ -4,20 +4,31 @@ mod autolayout; mod constraints; mod edit; mod export; +mod flow; mod gesture; +mod graph; mod history; mod inspector; mod layers; +mod links; mod marks; +mod mindmap; +mod mindmap_view; mod minimap; +mod nodes; +mod paint; mod palette; mod panel; mod plane; +mod press; mod shape; mod style; mod tools; mod typography; mod view; +mod whiteboard; +mod wiring; +mod writing; mod zoom; #[cfg(all(test, feature = "test-support"))] @@ -29,12 +40,18 @@ pub use autolayout::{AutoLayout, AutoLayoutControls, Flow, Place}; pub use constraints::{Constraints, ConstraintsEditor, Pin}; pub use edit::{Handle, Tool}; pub use export::{ExportFormat, ExportPanel, ExportSetting, export_files}; +pub use flow::{FlowChart, Step, StepKind}; pub use gesture::Brush; +pub use graph::{Edge, Node, Port}; pub use history::HistoryPanel; pub use inspector::InspectorPanel; pub use layers::LayerPanel; +pub use links::Link; pub use marks::{SelectionBox, SnapIndicator, TransformHandles}; +pub use mindmap::Topic; +pub use mindmap_view::MindMap; pub use minimap::MiniMap; +pub use nodes::NodeGraph; pub use palette::{BrushSettings, ToolPalette}; pub use plane::InfiniteCanvas; pub use shape::{Artboard, Corner, Guide, Shape, ShapeKind}; @@ -42,4 +59,5 @@ pub use style::{Border, BorderEditor, ColorPanel, Dash, Shadow, ShadowEditor}; pub use tools::ToolLayer; pub use typography::{TypeStyle, TypographyPanel}; pub use view::{Frame, Viewport}; +pub use whiteboard::{Board, Whiteboard}; pub use zoom::{ZoomControls, ZoomIndicator}; diff --git a/src/canvas/nodes.rs b/src/canvas/nodes.rs new file mode 100644 index 0000000..1ff99e5 --- /dev/null +++ b/src/canvas/nodes.rs @@ -0,0 +1,413 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, FontWeight, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + graph::{Edge, HEAD, Node, ROW, Socket, ends, kinds}, + paint::{finish, in_view, wire}, + plane::{InfiniteCanvas, OnViewport}, + view::Viewport, + wiring::{Ending, Hold, OnEdge, OnNudge, Pull, Pulling}, +}; +use crate::{ + theme::{ActiveTheme, Radius, TextSize}, + typography::Ellipsis, +}; + +type OnKeys = Rc; + +/// Nodes joined by wires on an endless plane, as in a shader or pipeline editor. A node's title drags it; a drag out of an output draws a wire that lands on an input of the same kind. An input takes one wire, so a new one replaces the old; a press on a wired input lifts its wire to land elsewhere or be dropped. Each kind of value has its hue. The owner keeps the graph and hears each change. +#[derive(IntoElement)] +pub struct NodeGraph { + id: ElementId, + viewport: Viewport, + nodes: Vec, + edges: Vec, + selected: Vec, + on_select: Option, + on_move: Option, + on_connect: Option, + on_disconnect: Option, + on_viewport: Option, +} + +impl NodeGraph { + pub fn new( + id: impl Into, + viewport: Viewport, + nodes: impl IntoIterator, + edges: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + viewport, + nodes: nodes.into_iter().collect(), + edges: edges.into_iter().collect(), + selected: Vec::new(), + on_select: None, + on_move: None, + on_connect: None, + on_disconnect: None, + on_viewport: None, + } + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { + self.selected = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Gets a dragged node's key and how far it went, in canvas units. + pub fn on_move( + mut self, + handler: impl Fn(&SharedString, (f32, f32), &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } + + pub fn on_connect(mut self, handler: impl Fn(&Edge, &mut Window, &mut App) + 'static) -> Self { + self.on_connect = Some(Rc::new(handler)); + self + } + + pub fn on_disconnect( + mut self, + handler: impl Fn(&Edge, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_disconnect = Some(Rc::new(handler)); + self + } + + pub fn on_viewport( + mut self, + handler: impl Fn(Viewport, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_viewport = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for NodeGraph { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let view = self.viewport; + let hold: Entity = + window.use_keyed_state((id.clone(), "hold"), cx, |_, _| Hold::default()); + let owner = hold.entity_id(); + let nodes = Rc::new(self.nodes); + let edges = Rc::new(self.edges); + let kinds = kinds(&nodes); + let theme = cx.theme(); + let rem = window.rem_size(); + let colors = theme.colors.clone(); + let hue = move |kind: &SharedString| { + let ix = kinds + .iter() + .position(|each| each == kind) + .expect("a kind on the graph"); + colors.hue(ix % 8, format_args!("kind {kind}")) + }; + let text = theme.text_size(TextSize::Sm).to_pixels(rem) * view.zoom; + let radius = theme.radius(Radius::Lg).to_pixels(rem) * view.zoom; + let stroke = theme.canvas().stroke.to_pixels(rem) * 1.5; + let dot = Pixels::from(10.0 * view.zoom); + let pull = hold.read(cx).pull.clone(); + let point_of = move |at: Point, bounds: Bounds| { + let local = at - bounds.origin; + view.to_canvas((f32::from(local.x), f32::from(local.y))) + }; + let ending = Rc::new(Ending { + nodes: nodes.clone(), + edges: edges.clone(), + zoom: view.zoom, + on_move: self.on_move, + on_connect: self.on_connect, + on_disconnect: self.on_disconnect, + }); + let (fg, surface, border, accent) = ( + theme.colors.fg, + theme.colors.surface, + theme.colors.border, + theme.colors.accent, + ); + let cards: Vec = nodes + .iter() + .map(|node| { + let frame = in_view(&view, &node.frame()); + let chosen = self.selected.contains(&node.key); + let (grab, key, on_select) = + (hold.clone(), node.key.clone(), self.on_select.clone()); + let head = div() + .id((id.clone(), format!("head-{}", node.key))) + .h(Pixels::from(HEAD * view.zoom)) + .flex() + .items_center() + .px(text * 0.75) + .border_b_1() + .border_color(border) + .font_weight(FontWeight::SEMIBOLD) + .cursor_grab() + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + cx.stop_propagation(); + let bounds = grab.read(cx).bounds; + let at = point_of(event.position, bounds); + log::info!("node graph: select {key}"); + if let Some(on_select) = &on_select { + on_select(std::slice::from_ref(&key), window, cx); + } + grab.update(cx, |hold, _| { + hold.pull = Pull::Moving { + key: key.clone(), + from: at, + to: at, + } + }); + }) + .on_drag(Pulling { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) + .child(Ellipsis::new(node.title.clone())); + let socket = |out: bool, row: usize, port: &super::graph::Port| { + let (press, edges, node_key) = (hold.clone(), edges.clone(), node.key.clone()); + let (port_key, kind) = (port.key.clone(), port.kind.clone()); + let nodes = nodes.clone(); + div() + .id(( + id.clone(), + format!( + "socket-{}-{}-{}", + node.key, + if out { "out" } else { "in" }, + port.key + ), + )) + .absolute() + .top(Pixels::from((ROW * row as f32 + ROW / 2.0) * view.zoom) - dot / 2.0) + .when(out, |socket| socket.right(-dot / 2.0)) + .when(!out, |socket| socket.left(-dot / 2.0)) + .size(dot) + .rounded_full() + .border_1() + .border_color(surface) + .bg(hue(&kind)) + .cursor_crosshair() + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + cx.stop_propagation(); + let bounds = press.read(cx).bounds; + let at = point_of(event.position, bounds); + let here = Socket { + node: node_key.clone(), + port: port_key.clone(), + out, + kind: kind.clone(), + }; + let pull = if out { + Pull::Wiring { + from: here, + lifted: None, + to: at, + } + } else { + let wired = edges + .iter() + .find(|edge| edge.to == (node_key.clone(), port_key.clone())); + match wired { + Some(edge) => { + let source = nodes + .iter() + .find(|node| node.key == edge.from.0) + .expect("a wired node"); + let port = source + .outputs + .iter() + .find(|port| port.key == edge.from.1) + .expect("a wired port"); + Pull::Wiring { + from: Socket { + node: edge.from.0.clone(), + port: edge.from.1.clone(), + out: true, + kind: port.kind.clone(), + }, + lifted: Some(edge.clone()), + to: at, + } + } + None => Pull::Idle, + } + }; + press.update(cx, |hold, cx| { + hold.pull = pull; + cx.notify(); + }); + }) + .on_drag(Pulling { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) + }; + let rows = node.inputs.len().max(node.outputs.len()); + let body = div().relative().children((0..rows).map(|row| { + let (input, output) = (node.inputs.get(row), node.outputs.get(row)); + div() + .h(Pixels::from(ROW * view.zoom)) + .flex() + .items_center() + .justify_between() + .gap(text * 0.5) + .px(text * 0.75) + .child( + div() + .min_w_0() + .children(input.map(|port| Ellipsis::new(port.name.clone()))), + ) + .child( + div() + .min_w_0() + .children(output.map(|port| Ellipsis::new(port.name.clone()))), + ) + })); + let sockets: Vec = node + .inputs + .iter() + .enumerate() + .map(|(row, port)| socket(false, row, port).into_any_element()) + .chain( + node.outputs + .iter() + .enumerate() + .map(|(row, port)| socket(true, row, port).into_any_element()), + ) + .collect(); + div() + .absolute() + .left(Pixels::from(frame.x)) + .top(Pixels::from(frame.y)) + .w(Pixels::from(frame.w)) + .h(Pixels::from(frame.h)) + .rounded(radius) + .border_1() + .border_color(if chosen { accent } else { border }) + .bg(surface) + .text_size(text) + .line_height(text * 1.3) + .text_color(fg) + .child(head) + .child(body.children(sockets)) + .into_any_element() + }) + .collect(); + let wires = { + let (nodes, edges, hue) = (nodes.clone(), edges.clone(), hue.clone()); + let pending = match &pull { + Pull::Wiring { from, to, .. } => { + let source = nodes + .iter() + .find(|node| node.key == from.node) + .expect("a wiring node"); + let row = source + .outputs + .iter() + .position(|port| port.key == from.port) + .expect("a wiring port"); + Some((source.socket(true, row), *to, from.kind.clone())) + } + _ => None, + }; + let lifted = match &pull { + Pull::Wiring { lifted, .. } => lifted.clone(), + _ => None, + }; + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let drawn = edges + .iter() + .filter(|edge| Some(*edge) != lifted.as_ref()) + .map(|edge| { + let (from, to, kind) = ends(&nodes, edge); + (from, to, hue(&kind)) + }) + .chain( + pending + .iter() + .map(|(from, to, kind)| (*from, *to, hue(kind).opacity(0.7))), + ); + for (from, to, ink) in drawn { + let (from, to) = (view.to_view(from), view.to_view(to)); + let pull = ((to.0 - from.0).abs() / 2.0).max(40.0 * view.zoom); + finish(wire(from, to, pull, bounds.origin, stroke), ink, window); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() + }; + let (moved, measured, dropped, released, out) = ( + hold.clone(), + hold.clone(), + hold.clone(), + hold.clone(), + hold.clone(), + ); + let (drop_end, up_end, out_end) = (ending.clone(), ending.clone(), ending); + let layer = div() + .id((id.clone(), "layer")) + .absolute() + .inset_0() + .on_drag_move::(move |event, _, cx| { + if event.drag(cx).owner != owner { + return; + } + let at = point_of(event.event.position, moved.read(cx).bounds); + moved.update(cx, |hold, cx| { + match &mut hold.pull { + Pull::Moving { to, .. } | Pull::Wiring { to, .. } => *to = at, + Pull::Idle => {} + } + cx.notify(); + }); + }) + .on_drop(move |_: &Pulling, window, cx| drop_end.run(&dropped, window, cx)) + .on_mouse_up(MouseButton::Left, move |_, window, cx| { + up_end.run(&released, window, cx) + }) + .on_mouse_up_out(MouseButton::Left, move |_, window, cx| { + out_end.run(&out, window, cx) + }) + .child( + canvas( + move |bounds, window, cx| { + if measured.read(cx).bounds != bounds { + measured.update(cx, |hold, _| hold.bounds = bounds); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child(wires) + .children(cards); + let plane = InfiniteCanvas::new(id, view).layer(layer); + match self.on_viewport { + Some(on_viewport) => { + plane.on_viewport(move |next, window, cx| on_viewport(next, window, cx)) + } + None => plane, + } + } +} diff --git a/src/canvas/paint.rs b/src/canvas/paint.rs new file mode 100644 index 0000000..bbfc6ae --- /dev/null +++ b/src/canvas/paint.rs @@ -0,0 +1,145 @@ +use gpui::{Bounds, Hsla, PathBuilder, Pixels, Point, Window, fill, point, size}; + +use super::{ + shape::{Shape, ShapeKind, polygon}, + view::{Frame, Viewport}, +}; +use crate::theme::Palette; + +/// The view pixels of a canvas frame. +pub(crate) fn in_view(view: &Viewport, frame: &Frame) -> Frame { + let (x, y) = view.to_view((frame.x, frame.y)); + Frame::new(x, y, frame.w * view.zoom, frame.h * view.zoom) +} + +pub(crate) fn at(origin: Point, (x, y): (f32, f32)) -> Point { + origin + point(Pixels::from(x), Pixels::from(y)) +} + +pub(crate) fn quad(origin: Point, frame: &Frame) -> Bounds { + Bounds::new( + at(origin, (frame.x, frame.y)), + size(Pixels::from(frame.w), Pixels::from(frame.h)), + ) +} + +pub(crate) fn outline( + points: &[(f32, f32)], + origin: Point, + width: Pixels, + closed: bool, +) -> PathBuilder { + let mut path = PathBuilder::stroke(width); + if let Some(first) = points.first() { + path.move_to(at(origin, *first)); + points[1..] + .iter() + .for_each(|next| path.line_to(at(origin, *next))); + if closed { + path.close(); + } + } + path +} + +pub(crate) fn solid(points: &[(f32, f32)], origin: Point) -> PathBuilder { + let mut path = PathBuilder::fill(); + if let Some(first) = points.first() { + path.move_to(at(origin, *first)); + points[1..] + .iter() + .for_each(|next| path.line_to(at(origin, *next))); + path.close(); + } + path +} + +pub(crate) fn finish(path: PathBuilder, color: Hsla, window: &mut Window) { + match path.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!("canvas: a path failed to build: {error:#}"), + } +} + +/// Paints a shape's outline and wash in view pixels. +pub(crate) fn paint_shape( + shape: &Shape, + view: &Viewport, + origin: Point, + palette: &Palette, + stroke: Pixels, + window: &mut Window, +) { + let ink = palette.hue(shape.hue, format_args!("shape {}", shape.key)); + let wash = ink.alpha(0.14); + let frame = in_view(view, &shape.frame); + match &shape.kind { + ShapeKind::Rect => { + window.paint_quad(fill(quad(origin, &frame), wash)); + let corners = [ + (frame.x, frame.y), + (frame.right(), frame.y), + (frame.right(), frame.bottom()), + (frame.x, frame.bottom()), + ]; + finish(outline(&corners, origin, stroke, true), ink, window); + } + ShapeKind::Ellipse | ShapeKind::Polygon(_) => { + let sides = match shape.kind { + ShapeKind::Polygon(sides) => sides, + _ => 64, + }; + let corners = polygon(&frame, sides); + finish(solid(&corners, origin), wash, window); + finish(outline(&corners, origin, stroke, true), ink, window); + } + ShapeKind::Line(end) | ShapeKind::Arrow(end) => { + let (from, to) = (end.opposite().of(&frame), end.of(&frame)); + finish(outline(&[from, to], origin, stroke, false), ink, window); + if matches!(shape.kind, ShapeKind::Arrow(_)) { + finish(solid(&head(from, to, stroke), origin), ink, window); + } + } + ShapeKind::Path { points, width } => { + let points: Vec<(f32, f32)> = points + .iter() + .map(|(x, y)| view.to_view((shape.frame.x + x, shape.frame.y + y))) + .collect(); + let wide = Pixels::from(width * view.zoom).max(stroke); + finish(outline(&points, origin, wide, false), ink, window); + } + ShapeKind::Text(_) | ShapeKind::Note(_) => {} + } +} + +/// An arrow's head at `to`, coming from `from`: its tip and its two back corners. +pub(crate) fn head(from: (f32, f32), to: (f32, f32), stroke: Pixels) -> [(f32, f32); 3] { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let length = (dx * dx + dy * dy).sqrt().max(1.0); + let (ux, uy) = (dx / length, dy / length); + let reach = f32::from(stroke) * 5.0; + let back = (to.0 - ux * reach, to.1 - uy * reach); + [ + to, + (back.0 - uy * reach / 2.0, back.1 + ux * reach / 2.0), + (back.0 + uy * reach / 2.0, back.1 - ux * reach / 2.0), + ] +} + +/// A curve from `from` to `to` that leaves and arrives level, its handles `pull` along the way it runs. +pub(crate) fn wire( + from: (f32, f32), + to: (f32, f32), + pull: f32, + origin: Point, + width: Pixels, +) -> PathBuilder { + let mut path = PathBuilder::stroke(width); + path.move_to(at(origin, from)); + path.cubic_bezier_to( + at(origin, to), + at(origin, (from.0 + pull, from.1)), + at(origin, (to.0 - pull, to.1)), + ); + path +} diff --git a/src/canvas/palette.rs b/src/canvas/palette.rs index 5a3169e..afb5def 100644 --- a/src/canvas/palette.rs +++ b/src/canvas/palette.rs @@ -28,14 +28,17 @@ fn icon(tool: Tool) -> IconName { Tool::Pen => IconName::PenTool, Tool::Text => IconName::Type, Tool::Brush => IconName::Brush, + Tool::Note => IconName::StickyNote, + Tool::Connector => IconName::Spline, } } -/// The canvas's tools in a row, one picked, each named in a tip. +/// The canvas's tools in a row, one picked, each named in a tip. It offers every tool unless given fewer. #[derive(IntoElement)] pub struct ToolPalette { id: ElementId, tool: Tool, + tools: Vec, on_change: Option, } @@ -44,10 +47,17 @@ impl ToolPalette { Self { id: id.into(), tool, + tools: Tool::ALL.to_vec(), on_change: None, } } + /// The tools it offers, in order; the picked one among them. + pub fn tools(mut self, tools: impl IntoIterator) -> Self { + self.tools = tools.into_iter().collect(); + self + } + pub fn on_change(mut self, handler: impl Fn(Tool, &mut Window, &mut App) + 'static) -> Self { self.on_change = Some(Rc::new(handler)); self @@ -56,8 +66,14 @@ impl ToolPalette { impl RenderOnce for ToolPalette { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + assert!( + self.tools.contains(&self.tool), + "tool palette {:?}: {} is not offered", + self.id, + self.tool.words() + ); let on_change = self.on_change; - Tool::ALL + self.tools .into_iter() .fold(ToggleGroup::new(self.id), |group, tool| { group.item( diff --git a/src/canvas/plane.rs b/src/canvas/plane.rs index d9ea305..db7dba6 100644 --- a/src/canvas/plane.rs +++ b/src/canvas/plane.rs @@ -1,22 +1,27 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, Hsla, - InteractiveElement, IntoElement, ParentElement, PathBuilder, Pixels, Point, RenderOnce, - ScrollDelta, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, size, + AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, + InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollDelta, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, fill, size, }; use super::{ - shape::{Artboard, Guide, Shape, ShapeKind, polygon}, - view::{Frame, Viewport, marks, step}, + links::{Link, halfway, route}, + paint::{at, finish, head, in_view, outline, paint_shape, quad, solid}, + shape::{Artboard, Guide, Shape, ShapeKind}, + view::{Viewport, marks, step}, }; use crate::{ layout::seeded::use_seeded, - theme::{ActiveTheme, Palette, TextSize}, + theme::{ActiveTheme, Radius, TextSize}, }; pub(crate) type OnViewport = Rc; +/// A link's run in view pixels, with its words. +type Drawn = (Vec<(f32, f32)>, Option); + /// A drag that pans a canvas, and whose canvas it is. struct Pan { owner: EntityId, @@ -29,125 +34,13 @@ struct Plane { grip: Option<(Point, Viewport)>, } -/// The view pixels of a canvas frame. -pub(crate) fn in_view(view: &Viewport, frame: &Frame) -> Frame { - let (x, y) = view.to_view((frame.x, frame.y)); - Frame::new(x, y, frame.w * view.zoom, frame.h * view.zoom) -} - -fn at(origin: Point, (x, y): (f32, f32)) -> Point { - origin + point(Pixels::from(x), Pixels::from(y)) -} - -fn quad(origin: Point, frame: &Frame) -> Bounds { - Bounds::new( - at(origin, (frame.x, frame.y)), - size(Pixels::from(frame.w), Pixels::from(frame.h)), - ) -} - -fn outline( - points: &[(f32, f32)], - origin: Point, - width: Pixels, - closed: bool, -) -> PathBuilder { - let mut path = PathBuilder::stroke(width); - if let Some(first) = points.first() { - path.move_to(at(origin, *first)); - points[1..] - .iter() - .for_each(|next| path.line_to(at(origin, *next))); - if closed { - path.close(); - } - } - path -} - -fn solid(points: &[(f32, f32)], origin: Point) -> PathBuilder { - let mut path = PathBuilder::fill(); - if let Some(first) = points.first() { - path.move_to(at(origin, *first)); - points[1..] - .iter() - .for_each(|next| path.line_to(at(origin, *next))); - path.close(); - } - path -} - -fn finish(path: PathBuilder, color: Hsla, window: &mut Window) { - match path.build() { - Ok(path) => window.paint_path(path, color), - Err(error) => log::error!("canvas: a path failed to build: {error:#}"), - } -} - -/// Paints a shape's outline and wash in view pixels. -pub(crate) fn paint_shape( - shape: &Shape, - view: &Viewport, - origin: Point, - palette: &Palette, - stroke: Pixels, - window: &mut Window, -) { - let ink = palette.hue(shape.hue, format_args!("shape {}", shape.key)); - let wash = ink.alpha(0.14); - let frame = in_view(view, &shape.frame); - match &shape.kind { - ShapeKind::Rect => { - window.paint_quad(fill(quad(origin, &frame), wash)); - let corners = [ - (frame.x, frame.y), - (frame.right(), frame.y), - (frame.right(), frame.bottom()), - (frame.x, frame.bottom()), - ]; - finish(outline(&corners, origin, stroke, true), ink, window); - } - ShapeKind::Ellipse | ShapeKind::Polygon(_) => { - let sides = match shape.kind { - ShapeKind::Polygon(sides) => sides, - _ => 64, - }; - let corners = polygon(&frame, sides); - finish(solid(&corners, origin), wash, window); - finish(outline(&corners, origin, stroke, true), ink, window); - } - ShapeKind::Line(end) | ShapeKind::Arrow(end) => { - let (from, to) = (end.opposite().of(&frame), end.of(&frame)); - finish(outline(&[from, to], origin, stroke, false), ink, window); - if matches!(shape.kind, ShapeKind::Arrow(_)) { - let (dx, dy) = (to.0 - from.0, to.1 - from.1); - let length = (dx * dx + dy * dy).sqrt().max(1.0); - let (ux, uy) = (dx / length, dy / length); - let head = f32::from(stroke) * 5.0; - let back = (to.0 - ux * head, to.1 - uy * head); - let left = (back.0 - uy * head / 2.0, back.1 + ux * head / 2.0); - let right = (back.0 + uy * head / 2.0, back.1 - ux * head / 2.0); - finish(solid(&[to, left, right], origin), ink, window); - } - } - ShapeKind::Path { points, width } => { - let points: Vec<(f32, f32)> = points - .iter() - .map(|(x, y)| view.to_view((shape.frame.x + x, shape.frame.y + y))) - .collect(); - let wide = Pixels::from(width * view.zoom).max(stroke); - finish(outline(&points, origin, wide, false), ink, window); - } - ShapeKind::Text(_) | ShapeKind::Note(_) => {} - } -} - /// An endless plane: a dot grid that thins as it zooms out, rulers along the top and left, guides, artboards with their names, and shapes. A drag on empty space pans it from where it was pressed, a wheel scrolls it, and Command or Control with the wheel zooms about the pointer. The owner keeps the viewport; a new one from the owner shows at once. #[derive(IntoElement)] pub struct InfiniteCanvas { id: ElementId, viewport: Viewport, shapes: Vec, + links: Vec, artboards: Vec, guides: Vec, rulers: bool, @@ -161,6 +54,7 @@ impl InfiniteCanvas { id: id.into(), viewport, shapes: Vec::new(), + links: Vec::new(), artboards: Vec::new(), guides: Vec::new(), rulers: false, @@ -174,6 +68,12 @@ impl InfiniteCanvas { self } + /// Lines between shapes that follow them, drawn over the shapes; each names two shapes on the canvas. + pub fn links(mut self, links: impl IntoIterator) -> Self { + self.links = links.into_iter().collect(); + self + } + pub fn artboards(mut self, artboards: impl IntoIterator) -> Self { self.artboards = artboards.into_iter().collect(); self @@ -263,6 +163,61 @@ impl RenderOnce for InfiniteCanvas { .into_any_element() }) .collect(); + let routes: Vec = self + .links + .iter() + .filter_map(|link| { + let named = |key: &SharedString| { + self.shapes + .iter() + .find(|shape| shape.key == *key) + .unwrap_or_else(|| { + panic!("link {} names {key}, which is not on the canvas", link.key) + }) + }; + let (from, to) = (named(&link.from), named(&link.to)); + (!from.hidden && !to.hidden).then(|| { + let points = route(&from.frame, &to.frame, link.elbow) + .into_iter() + .map(|point| view.to_view(point)) + .collect(); + (points, link.label.clone()) + }) + }) + .collect(); + let chip = theme.text_size(TextSize::Sm).to_pixels(rem) * view.zoom; + let chips: Vec = routes + .iter() + .filter_map(|(points, label)| { + let (x, y) = halfway(points); + Some( + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(y)) + .size_0() + .flex() + .items_center() + .justify_center() + .child( + div() + .flex_none() + .whitespace_nowrap() + .px(chip * 0.5) + .rounded(theme.radius(Radius::Sm)) + .bg(colors.bg) + .border_1() + .border_color(colors.border) + .text_size(chip) + .line_height(chip * 1.4) + .text_color(colors.fg_muted) + .child(label.clone()?), + ) + .into_any_element(), + ) + }) + .collect(); + let lines: Vec> = routes.into_iter().map(|(points, _)| points).collect(); let texts: Vec = self .shapes .iter() @@ -297,7 +252,15 @@ impl RenderOnce for InfiniteCanvas { .into_any_element(), ) } - _ => None, + _ => shape.label.as_ref().map(|label| { + placed(label) + .flex() + .items_center() + .justify_center() + .text_center() + .text_color(colors.fg) + .into_any_element() + }), } }) .collect(); @@ -355,6 +318,19 @@ impl RenderOnce for InfiniteCanvas { for shape in shapes.iter().filter(|shape| !shape.hidden) { paint_shape(shape, &view, origin, &palette, stroke, window); } + for points in &lines { + let end = &points[points.len() - 2..]; + finish( + outline(points, origin, stroke, false), + palette.fg_muted, + window, + ); + finish( + solid(&head(end[0], end[1], stroke), origin), + palette.fg_muted, + window, + ); + } let line = palette.hue(3, "guide"); for guide in &guides { let (from, to) = match guide { @@ -454,6 +430,7 @@ impl RenderOnce for InfiniteCanvas { .child(painted) .children(names) .children(texts) + .children(chips) .children(self.layers) .children(rulers.into_iter().flatten()) .child(measured) diff --git a/src/canvas/press.rs b/src/canvas/press.rs new file mode 100644 index 0000000..5f0d8c8 --- /dev/null +++ b/src/canvas/press.rs @@ -0,0 +1,149 @@ +use gpui::{App, Entity, SharedString, Window}; + +use super::{ + edit::{Tool, hit}, + gesture::{Gesture, Hand, Handlers, PEN, REACH, Scene, path, spanned}, + marks::handle_boxes, + paint::in_view, + shape::ShapeKind, + view::Frame, + writing, +}; +use crate::forms::Editing; + +/// A press on a tool layer: the tool, where it landed in canvas units and in view pixels, how many presses it counts, whether Shift is held, and a handle's side. +pub(super) struct Press { + pub tool: Tool, + pub at: (f32, f32), + pub view_at: (f32, f32), + pub count: usize, + pub shift: bool, + pub side: f32, +} + +/// The gesture a press sets going, after what it asks the owner at once: a selection, a dropped text or note, an ended path, or words to rewrite. +pub(super) fn pressed( + press: &Press, + scene: &Scene, + handlers: &Handlers, + hand: &Entity, + editing: &Entity, + window: &mut Window, + cx: &mut App, +) -> Gesture { + let (at, view) = (press.at, scene.view); + let drop = |kind: ShapeKind, frame: Frame, window: &mut Window, cx: &mut App| { + log::info!("tool layer: {kind:?} at {at:?}"); + if let Some(on_draw) = &handlers.on_draw { + on_draw(kind, frame, window, cx); + } + }; + match press.tool { + Tool::Select => { + let held = (scene.selected.len() == 1) + .then(|| spanned(&scene.shapes, &scene.selected)) + .flatten() + .and_then(|frame| { + handle_boxes(&in_view(&view, &frame), press.side) + .into_iter() + .find(|(_, square)| square.contains(press.view_at)) + }); + match (held, hit(&scene.shapes, at, REACH / view.zoom)) { + (Some((handle, _)), _) => Gesture::Resizing { + key: scene.selected[0].clone(), + handle, + from: at, + to: at, + }, + (None, Some(ix)) if press.count >= 2 => { + let shape = &scene.shapes[ix]; + window.prevent_default(); + hand.update(cx, |hand, _| hand.writing = Some(shape.key.clone())); + let words = shape + .words() + .map(|words| words.to_string()) + .unwrap_or_default(); + writing::begin( + editing, + shape.key.clone(), + words, + handlers.on_text.clone(), + window, + cx, + ); + Gesture::Idle + } + (None, Some(ix)) => { + let key = scene.shapes[ix].key.clone(); + let selected = &scene.selected; + let next: Vec = match (press.shift, selected.contains(&key)) { + (true, true) => selected + .iter() + .filter(|each| **each != key) + .cloned() + .collect(), + (true, false) => selected.iter().cloned().chain([key]).collect(), + (false, true) => selected.to_vec(), + (false, false) => vec![key], + }; + if next != **selected { + log::info!("tool layer: select {next:?}"); + if let Some(on_select) = &handlers.on_select { + on_select(&next, window, cx); + } + } + Gesture::Moving { from: at, to: at } + } + (None, None) => Gesture::Marquee { from: at, to: at }, + } + } + Tool::Connector => match hit(&scene.shapes, at, REACH / view.zoom) { + Some(ix) => Gesture::Linking { + from: scene.shapes[ix].key.clone(), + to: at, + }, + None => Gesture::Idle, + }, + Tool::Pen => { + let ends = press.count >= 2; + let points = hand.update(cx, |hand, _| { + if !ends { + hand.pen.push(at); + } + match ends { + true => std::mem::take(&mut hand.pen), + false => Vec::new(), + } + }); + if ends && points.len() > 1 { + let (kind, frame) = path(&points, PEN); + drop(kind, frame, window, cx); + } + Gesture::Idle + } + Tool::Text => { + drop( + ShapeKind::Text("Text".into()), + Frame::new(at.0, at.1, 160.0, 32.0), + window, + cx, + ); + Gesture::Idle + } + Tool::Note => { + drop( + ShapeKind::Note("Note".into()), + Frame::new(at.0, at.1, 160.0, 160.0), + window, + cx, + ); + Gesture::Idle + } + Tool::Brush => Gesture::Stroke { points: vec![at] }, + drawer => Gesture::Drawing { + kind: drawer.draws().expect("the rest draw shapes"), + from: at, + to: at, + }, + } +} diff --git a/src/canvas/shape.rs b/src/canvas/shape.rs index f48f862..5577d0d 100644 --- a/src/canvas/shape.rs +++ b/src/canvas/shape.rs @@ -94,6 +94,8 @@ pub struct Shape { pub hue: usize, pub hidden: bool, pub locked: bool, + /// Words shown in its middle. + pub label: Option, } impl Shape { @@ -114,6 +116,7 @@ impl Shape { hue: 0, hidden: false, locked: false, + label: None, } } @@ -131,6 +134,30 @@ impl Shape { self.locked = locked; self } + + pub fn label(mut self, text: impl Into) -> Self { + self.label = Some(text.into()); + self + } + + /// The words it shows: a text's or a note's own, or its label. + pub fn words(&self) -> Option<&SharedString> { + match &self.kind { + ShapeKind::Text(text) | ShapeKind::Note(text) => Some(text), + _ => self.label.as_ref(), + } + } + + /// The shape showing `text`: a text or a note says it, and anything else wears it as its label, or none when it is empty. + pub fn written(&self, text: impl Into) -> Shape { + let text = text.into(); + let mut next = self.clone(); + match &mut next.kind { + ShapeKind::Text(words) | ShapeKind::Note(words) => *words = text, + _ => next.label = (!text.is_empty()).then_some(text), + } + next + } } /// A named board on the canvas that designs sit on. diff --git a/src/canvas/tests/boards.rs b/src/canvas/tests/boards.rs new file mode 100644 index 0000000..b3707b9 --- /dev/null +++ b/src/canvas/tests/boards.rs @@ -0,0 +1,148 @@ +use gpui::{AnyElement, Entity, IntoElement, TestAppContext, VisualTestContext}; + +use super::{ + Stage, said, say, settle, stage, + tools::{drag, layer, pair, press}, +}; +use crate::canvas::{ + Board, Edge, MindMap, Node, NodeGraph, Port, Tool, Topic, Viewport, Whiteboard, +}; + +fn tap(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +#[gpui::test] +fn the_connector_links_the_shape_pressed_to_the_one_released_on(cx: &mut TestAppContext) { + let (host, cx) = stage(|_, owner| layer(Tool::Connector, &[], owner), cx); + drag((50.0, 50.0), (250.0, 60.0), cx); + drag((50.0, 50.0), (150.0, 200.0), cx); + assert_eq!( + said(&host, cx), + ["link a b"], + "a release on nothing links nothing" + ); +} + +#[gpui::test] +fn a_double_press_writes_on_a_shape_and_enter_keeps_the_words(cx: &mut TestAppContext) { + let (host, cx) = stage(|_, owner| layer(Tool::Select, &["a"], owner), cx); + press(50.0, 50.0, 1, cx); + press(50.0, 50.0, 2, cx); + cx.simulate_input("Start"); + tap("enter", cx); + assert_eq!(said(&host, cx), ["text a Start"]); +} + +fn nodes() -> Vec { + vec![ + Node::new("load", "Load", (0.0, 0.0)).output(Port::new("image", "Image", "image")), + Node::new("blur", "Blur", (300.0, 0.0)) + .input(Port::new("image", "Image", "image")) + .output(Port::new("image", "Image", "image")), + ] +} + +fn graph(wired: bool, owner: Entity) -> AnyElement { + let (joined, parted) = (owner.clone(), owner); + let edges = wired.then(|| Edge::new(("load", "image"), ("blur", "image"))); + NodeGraph::new("graph", Viewport::new(0.0, 0.0, 1.0), nodes(), edges) + .on_connect(move |edge, _, cx| { + say( + &joined, + format!( + "connect {}.{} {}.{}", + edge.from.0, edge.from.1, edge.to.0, edge.to.1 + ), + cx, + ) + }) + .on_disconnect(move |edge, _, cx| { + say( + &parted, + format!("disconnect {}.{}", edge.to.0, edge.to.1), + cx, + ) + }) + .into_any_element() +} + +/// Sockets sit on the node's sides a title and half a row down: 32 + 14. +#[gpui::test] +fn a_drag_from_an_output_wires_it_to_an_input(cx: &mut TestAppContext) { + let (host, cx) = stage(|_, owner| graph(false, owner), cx); + drag((200.0, 46.0), (301.0, 47.0), cx); + assert_eq!(said(&host, cx), ["connect load.image blur.image"]); +} + +#[gpui::test] +fn a_press_on_a_wired_input_lifts_its_wire(cx: &mut TestAppContext) { + let (host, cx) = stage(|_, owner| graph(true, owner), cx); + drag((300.0, 46.0), (250.0, 250.0), cx); + assert_eq!(said(&host, cx), ["disconnect blur.image"]); +} + +fn map(_: &Stage, owner: Entity) -> AnyElement { + let root = Topic::new("root", "Launch") + .child(Topic::new("a", "Site")) + .child(Topic::new("b", "Press")) + .child(Topic::new("c", "Budget")); + let [picked, added, removed] = [(); 3].map(|_| owner.clone()); + MindMap::new("map", root, Viewport::new(-60.0, -150.0, 1.0)) + .selected(Some("a")) + .on_select(move |key, _, cx| say(&picked, format!("select {key:?}"), cx)) + .on_add(move |key, _, cx| say(&added, format!("add under {key}"), cx)) + .on_remove(move |key, _, cx| say(&removed, format!("remove {key}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn keys_on_a_mind_map_ask_for_children_siblings_and_removals(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + press(276.0, 126.0, 1, cx); + for key in ["tab", "enter", "down", "backspace"] { + tap(key, cx); + } + assert_eq!( + said(&host, cx), + [ + "select Some(\"a\")", + "add under a", + "add under root", + "select Some(\"b\")", + "remove a", + ], + "a press selects and focuses; the keys act on the owner's selection" + ); +} + +fn board(_: &Stage, owner: Entity) -> AnyElement { + Whiteboard::new( + "board", + Board { + shapes: pair(), + links: Vec::new(), + }, + ) + .on_change(move |board, _, cx| { + let keys: Vec = board + .shapes + .iter() + .map(|shape| shape.key.to_string()) + .collect(); + say(&owner, format!("shapes {keys:?}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn backspace_takes_the_selection_off_the_board(cx: &mut TestAppContext) { + let (host, cx) = stage(board, cx); + press(250.0, 50.0, 1, cx); + tap("backspace", cx); + assert_eq!(said(&host, cx), ["shapes [\"a\"]"]); +} diff --git a/src/canvas/tests/mod.rs b/src/canvas/tests/mod.rs index b17319d..1a76c95 100644 --- a/src/canvas/tests/mod.rs +++ b/src/canvas/tests/mod.rs @@ -7,6 +7,7 @@ use gpui::{ use super::{Frame, InfiniteCanvas, MiniMap, Tool, Viewport, ZoomControls}; use crate::{primitives::FocusNext, theme::Theme}; +mod boards; mod panels; mod tools; diff --git a/src/canvas/tests/tools.rs b/src/canvas/tests/tools.rs index 2016fd3..e6f437d 100644 --- a/src/canvas/tests/tools.rs +++ b/src/canvas/tests/tools.rs @@ -8,12 +8,12 @@ use crate::canvas::{ Frame, InfiniteCanvas, Shape, ShapeKind, Tool, ToolLayer, ToolPalette, Viewport, }; -fn at(x: f32, y: f32) -> gpui::Point { +pub(super) fn at(x: f32, y: f32) -> gpui::Point { point(px(x), px(y)) } /// Presses at `from`, drags in steps to `to`, and lets go. -fn drag(from: (f32, f32), to: (f32, f32), cx: &mut VisualTestContext) { +pub(super) fn drag(from: (f32, f32), to: (f32, f32), cx: &mut VisualTestContext) { cx.simulate_mouse_move(at(from.0, from.1), None, Modifiers::none()); cx.simulate_mouse_down(at(from.0, from.1), MouseButton::Left, Modifiers::none()); for step in 1..=4 { @@ -27,7 +27,7 @@ fn drag(from: (f32, f32), to: (f32, f32), cx: &mut VisualTestContext) { settle(cx); } -fn press(x: f32, y: f32, count: usize, cx: &mut VisualTestContext) { +pub(super) fn press(x: f32, y: f32, count: usize, cx: &mut VisualTestContext) { cx.simulate_event(MouseDownEvent { button: MouseButton::Left, position: at(x, y), @@ -44,7 +44,7 @@ fn press(x: f32, y: f32, count: usize, cx: &mut VisualTestContext) { settle(cx); } -fn pair() -> Vec { +pub(super) fn pair() -> Vec { vec![ Shape::new( "a", @@ -61,9 +61,9 @@ fn pair() -> Vec { ] } -fn layer(tool: Tool, selected: &[&'static str], owner: Entity) -> AnyElement { +pub(super) fn layer(tool: Tool, selected: &[&'static str], owner: Entity) -> AnyElement { let view = Viewport::new(0.0, 0.0, 1.0); - let (drawn, chosen, moved, sized) = (owner.clone(), owner.clone(), owner.clone(), owner); + let [drawn, chosen, moved, sized, linked, written] = [(); 6].map(|_| owner.clone()); let layer = ToolLayer::new("layer", tool, view, pair()) .selected(selected.iter().copied()) .on_draw(move |kind, frame, _, cx| { @@ -84,7 +84,9 @@ fn layer(tool: Tool, selected: &[&'static str], owner: Entity) -> AnyElem .on_move(move |keys, by, _, cx| say(&moved, format!("move {keys:?} {by:?}"), cx)) .on_resize(move |key, frame, _, cx| { say(&sized, format!("resize {key} {} {}", frame.w, frame.h), cx) - }); + }) + .on_link(move |from, to, _, cx| say(&linked, format!("link {from} {to}"), cx)) + .on_text(move |key, text, _, cx| say(&written, format!("text {key} {text}"), cx)); InfiniteCanvas::new("plane", view) .shapes(pair()) .layer(layer) diff --git a/src/canvas/tools.rs b/src/canvas/tools.rs index 0a3c072..815430e 100644 --- a/src/canvas/tools.rs +++ b/src/canvas/tools.rs @@ -7,24 +7,26 @@ use gpui::{ }; use super::{ - edit::{Tool, drawn, hit, resized, snapped}, + edit::{Tool, drawn, resized, snapped}, gesture::{ - Brush, Gesture, Hand, Handlers, LEAST, OnDraw, OnKeys, OnMove, OnResize, PEN, REACH, Scene, - commit, path, spanned, stroke_shape, taken, + Brush, Gesture, Hand, Handlers, LEAST, OnDraw, OnKeys, OnMove, OnPair, OnResize, PEN, + REACH, Scene, commit, path, spanned, stroke_shape, taken, }, - marks::{SelectionBox, SnapIndicator, TransformHandles, handle_boxes, placed}, - plane::{in_view, paint_shape}, - shape::{Shape, ShapeKind}, + marks::{SelectionBox, SnapIndicator, TransformHandles, placed}, + paint::{in_view, paint_shape}, + press::{Press, pressed as pressed_with}, + shape::{Corner, Shape, ShapeKind}, view::{Frame, Viewport}, + writing, }; -use crate::theme::ActiveTheme; +use crate::{forms::Editing, primitives::tab_stop, theme::ActiveTheme}; /// A drag on a tool layer, and whose layer it is. struct Tracing { owner: EntityId, } -/// A layer over an InfiniteCanvas where its tools work. Select presses a shape, Shift adds to the selection, a drag moves it and snaps its edges to others, a handle resizes it, and a drag on empty space draws a SelectionBox. The shape tools draw by a drag, Shift keeping them square; the pen adds a point per press and a double press ends the path; text drops a line where pressed; the brush strokes a path. A drag may end anywhere, in the layer or out of it. Everything asks the owner; the hand leaves the canvas to pan. +/// A layer over an InfiniteCanvas where its tools work. Select presses a shape, Shift adds to the selection, a drag moves it and snaps its edges to others, a handle resizes it, and a drag on empty space draws a SelectionBox. The shape tools draw by a drag, Shift keeping them square; the pen adds a point per press and a double press ends the path; text drops a line where pressed; the brush strokes a path; a note drops where pressed; the connector links the shape pressed to the one released on. A double press with Select writes on a shape: a text's or a note's words, or any other's label. A press focuses the layer, where Delete or Backspace asks to delete the selection and Escape lets it go. A drag may end anywhere, in the layer or out of it. Everything asks the owner; the hand leaves the canvas to pan. #[derive(IntoElement)] pub struct ToolLayer { id: ElementId, @@ -37,6 +39,9 @@ pub struct ToolLayer { on_draw: Option, on_move: Option, on_resize: Option, + on_link: Option, + on_text: Option, + on_delete: Option, } impl ToolLayer { @@ -57,6 +62,9 @@ impl ToolLayer { on_draw: None, on_move: None, on_resize: None, + on_link: None, + on_text: None, + on_delete: None, } } @@ -105,12 +113,41 @@ impl ToolLayer { self.on_resize = Some(Rc::new(handler)); self } + + /// Gets the keys of two shapes the connector joined, from first. + pub fn on_link( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_link = Some(Rc::new(handler)); + self + } + + /// Gets the selected keys when Delete or Backspace is pressed on the layer. + pub fn on_delete( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_delete = Some(Rc::new(handler)); + self + } + + /// Gets a shape's key and the words written on it after a double press. + pub fn on_text( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_text = Some(Rc::new(handler)); + self + } } impl RenderOnce for ToolLayer { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let hand: Entity = window.use_keyed_state((self.id.clone(), "hand"), cx, |_, _| Hand::default()); + let editing = + window.use_keyed_state((self.id.clone(), "writing"), cx, |_, _| Editing::default()); if self.tool != Tool::Pen && !hand.read(cx).pen.is_empty() { let points = hand.update(cx, |hand, _| std::mem::take(&mut hand.pen)); end_path(points, self.on_draw.clone(), window, cx); @@ -191,6 +228,16 @@ impl RenderOnce for ToolLayer { Gesture::Stroke { points } if points.len() > 1 => { vec![stroke_shape(points, self.brush.size, self.brush.hue)] } + Gesture::Linking { from, to } => { + let start = shapes + .iter() + .find(|shape| shape.key == *from) + .expect("a link starts on a listed shape") + .frame + .center(); + let (kind, frame) = drawn(ShapeKind::Arrow(Corner::BottomRight), start, *to, false); + vec![Shape::new("+link", "Link", kind, frame)] + } _ if tool == Tool::Pen && pen.len() > 1 => { vec![stroke_shape(&pen, PEN, self.brush.hue)] } @@ -214,24 +261,46 @@ impl RenderOnce for ToolLayer { .filter(|_| selected.len() > 1) .map(|frame| placed(&frame).border_1().border_color(palette.accent)); let snap = (!guides.is_empty()).then(|| SnapIndicator::new(guides.clone(), view)); + let writing_on = hand.read(cx).writing.clone(); + let field = writing_on + .as_ref() + .and_then(|key| shapes.iter().find(|shape| shape.key == *key)) + .and_then(|shape| writing::field(&editing, in_view(&view, &shape.frame), cx)); + if field.is_none() && writing_on.is_some() && editing.read(cx).field().is_some() { + let closing = editing.clone(); + log::info!("tool layer: the shape written on left the canvas"); + window.defer(cx, move |window, cx| { + closing.update(cx, |editing, cx| editing.finish(true, window, cx)) + }); + } let point_of = move |at: Point, bounds: Bounds| { let local = at - bounds.origin; view.to_canvas((f32::from(local.x), f32::from(local.y))) }; let owner = hand.entity_id(); - let (pressed, moved, dropped, released) = - (hand.clone(), hand.clone(), hand.clone(), hand.clone()); - let (press_shapes, press_selected) = (shapes.clone(), selected.clone()); - let (select_on_press, draw_on_press, select_on_up) = ( - self.on_select.clone(), - self.on_draw.clone(), + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let (ring, held_focus) = ( + if focus.is_focused(window) { + palette.focus + } else { + gpui::transparent_black() + }, + focus.clone(), + ); + let (gone, on_delete, let_go) = ( + selected.clone(), + self.on_delete.clone(), self.on_select.clone(), ); + let (pressed, moved, dropped, released) = + (hand.clone(), hand.clone(), hand.clone(), hand.clone()); let handlers = Handlers { on_select: self.on_select, on_draw: self.on_draw, on_move: self.on_move, on_resize: self.on_resize, + on_link: self.on_link, + on_text: self.on_text, }; let scene = Rc::new(Scene { shapes, @@ -240,102 +309,62 @@ impl RenderOnce for ToolLayer { view, }); let handlers = Rc::new(handlers); + let select_on_up = handlers.on_select.clone(); + let (press_scene, press_handlers, press_editing) = + (scene.clone(), handlers.clone(), editing.clone()); let brush = self.brush; let (out_hand, out_scene, out_handlers) = (hand.clone(), scene.clone(), handlers.clone()); let measured = hand.clone(); div() .id(self.id) + .track_focus(&focus) .absolute() .inset_0() + .child(div().absolute().inset_0().border_1().border_color(ring)) .when(tool != Tool::Select, |layer| layer.cursor_crosshair()) + .on_key_down( + move |event, window, cx| match event.keystroke.key.as_str() { + "backspace" | "delete" if !gone.is_empty() => { + cx.stop_propagation(); + log::info!("tool layer: delete {gone:?}"); + if let Some(on_delete) = &on_delete { + on_delete(&gone, window, cx); + } + } + "escape" if !gone.is_empty() => { + cx.stop_propagation(); + log::info!("tool layer: nothing selected"); + if let Some(on_select) = &let_go { + on_select(&[], window, cx); + } + } + _ => {} + }, + ) .on_mouse_down(MouseButton::Left, move |event, window, cx| { cx.stop_propagation(); + window.focus(&held_focus); let bounds = pressed.read(cx).bounds; let at = point_of(event.position, bounds); let local = event.position - bounds.origin; let view_at = (f32::from(local.x), f32::from(local.y)); - let gesture = match tool { - Tool::Select => { - let held = (press_selected.len() == 1) - .then(|| spanned(&press_shapes, &press_selected)) - .flatten() - .and_then(|frame| { - handle_boxes(&in_view(&view, &frame), side) - .into_iter() - .find(|(_, square)| square.contains(view_at)) - }); - match (held, hit(&press_shapes, at, REACH / view.zoom)) { - (Some((handle, _)), _) => Gesture::Resizing { - key: press_selected[0].clone(), - handle, - from: at, - to: at, - }, - (None, Some(ix)) => { - let key = press_shapes[ix].key.clone(); - let next: Vec = - match (event.modifiers.shift, press_selected.contains(&key)) { - (true, true) => press_selected - .iter() - .filter(|each| **each != key) - .cloned() - .collect(), - (true, false) => { - press_selected.iter().cloned().chain([key]).collect() - } - (false, true) => press_selected.to_vec(), - (false, false) => vec![key], - }; - if next != *press_selected { - log::info!("tool layer: select {next:?}"); - if let Some(on_select) = &select_on_press { - on_select(&next, window, cx); - } - } - Gesture::Moving { from: at, to: at } - } - (None, None) => Gesture::Marquee { from: at, to: at }, - } - } - Tool::Pen => { - let ends = event.click_count >= 2; - let points = pressed.update(cx, |hand, _| { - if !ends { - hand.pen.push(at); - } - match ends { - true => std::mem::take(&mut hand.pen), - false => Vec::new(), - } - }); - if ends && points.len() > 1 { - let (kind, frame) = path(&points, PEN); - log::info!("tool layer: a path of {} points", points.len()); - if let Some(on_draw) = &draw_on_press { - on_draw(kind, frame, window, cx); - } - } - Gesture::Idle - } - Tool::Text => { - log::info!("tool layer: text at {at:?}"); - if let Some(on_draw) = &draw_on_press { - on_draw( - ShapeKind::Text("Text".into()), - Frame::new(at.0, at.1, 160.0, 32.0), - window, - cx, - ); - } - Gesture::Idle - } - Tool::Brush => Gesture::Stroke { points: vec![at] }, - drawer => Gesture::Drawing { - kind: drawer.draws().expect("the rest draw shapes"), - from: at, - to: at, - }, + let press = Press { + tool, + at, + view_at, + count: event.click_count, + shift: event.modifiers.shift, + side, }; + let gesture = pressed_with( + &press, + &press_scene, + &press_handlers, + &pressed, + &press_editing, + window, + cx, + ); pressed.update(cx, |hand, cx| { hand.gesture = gesture; hand.square = event.modifiers.shift; @@ -355,7 +384,8 @@ impl RenderOnce for ToolLayer { Gesture::Marquee { to, .. } | Gesture::Moving { to, .. } | Gesture::Resizing { to, .. } - | Gesture::Drawing { to, .. } => *to = at, + | Gesture::Drawing { to, .. } + | Gesture::Linking { to, .. } => *to = at, Gesture::Stroke { points } => points.push(at), Gesture::Idle => {} } @@ -415,6 +445,7 @@ impl RenderOnce for ToolLayer { .children(marquee) .children(handles) .children(outline) + .children(field) .children(snap) .into_any_element() } diff --git a/src/canvas/whiteboard.rs b/src/canvas/whiteboard.rs new file mode 100644 index 0000000..6445b77 --- /dev/null +++ b/src/canvas/whiteboard.rs @@ -0,0 +1,321 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, canvas, div, +}; + +use super::{ + edit::Tool, + gesture::Brush, + links::Link, + palette::ToolPalette, + panel::OnEdit, + plane::InfiniteCanvas, + shape::{Shape, ShapeKind}, + tools::ToolLayer, + view::{Frame, Viewport}, + zoom::ZoomControls, +}; +use crate::{ + layout::seeded::use_seeded, + theme::{ActiveTheme, Elevation, Radius}, +}; + +/// What lies on a whiteboard: its shapes, in paint order, and the links between them. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Board { + pub shapes: Vec, + pub links: Vec, +} + +/// The tools a whiteboard offers, in the palette's order. +const TOOLS: [Tool; 10] = [ + Tool::Select, + Tool::Hand, + Tool::Note, + Tool::Rect, + Tool::Ellipse, + Tool::Arrow, + Tool::Connector, + Tool::Text, + Tool::Pen, + Tool::Brush, +]; + +/// The whiteboard's own state: its tool, view and selection, the size of its box, and how many things it has made. +struct Desk { + tool: Tool, + view: Viewport, + selected: Vec, + size: (f32, f32), + made: usize, +} + +type Apply = Rc; + +/// A board to think on: notes, shapes, text, pen and brush strokes, and links on an endless plane, with its tools floating over the top left and its zoom at the bottom right. It keeps its own tool, view and selection, applies each edit, and hands the owner the whole board after it; a new board from the owner replaces its own. Delete or Backspace removes the selection and its links. It fills its box, which the host sizes. +#[derive(IntoElement)] +pub struct Whiteboard { + id: ElementId, + board: Board, + on_change: Option>, +} + +impl Whiteboard { + pub fn new(id: impl Into, board: Board) -> Self { + Self { + id: id.into(), + board, + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(Board, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +/// A key no shape of `board` holds yet. +fn fresh(board: &Board, made: &mut usize) -> SharedString { + loop { + *made += 1; + let key = SharedString::from(format!("board-{made}")); + if !board.shapes.iter().any(|shape| shape.key == key) + && !board.links.iter().any(|link| link.key == key) + { + return key; + } + } +} + +impl RenderOnce for Whiteboard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let content = use_seeded((id.clone(), "board"), self.board, window, cx); + let desk = window.use_keyed_state((id.clone(), "desk"), cx, |_, _| Desk { + tool: Tool::Select, + view: Viewport::new(0.0, 0.0, 1.0), + selected: Vec::new(), + size: (0.0, 0.0), + made: 0, + }); + let board = content.read(cx).value.clone(); + let (tool, view, selected, size) = { + let desk = desk.read(cx); + (desk.tool, desk.view, desk.selected.clone(), desk.size) + }; + let apply: Apply = { + let (content, desk, on_change) = (content.clone(), desk.clone(), self.on_change); + Rc::new(move |edit, window, cx| { + let mut board = content.read(cx).value.clone(); + desk.update(cx, |desk, cx| { + edit(&mut board, desk); + cx.notify(); + }); + content.update(cx, |content, cx| { + content.value = board.clone(); + cx.notify(); + }); + log::info!( + "whiteboard: {} shapes, {} links", + board.shapes.len(), + board.links.len() + ); + if let Some(on_change) = &on_change { + on_change(board, window, cx); + } + }) + }; + let set = { + let desk = desk.clone(); + move |edit: &dyn Fn(&mut Desk), cx: &mut App| { + desk.update(cx, |desk, cx| { + edit(desk); + cx.notify(); + }) + } + }; + let [drawn, moved, sized, linked, written, removed] = [(); 6].map(|_| apply.clone()); + let [chosen, viewed, picked, zoomed, fitted] = [(); 5].map(|_| set.clone()); + let whole = board + .shapes + .iter() + .map(|shape| shape.frame) + .reduce(|whole, frame| whole.union(&frame)); + let layer = ToolLayer::new((id.clone(), "tools"), tool, view, board.shapes.clone()) + .selected(selected) + .brush(Brush { size: 4.0, hue: 0 }) + .on_select(move |keys, _, cx| { + let keys = keys.to_vec(); + chosen(&|desk| desk.selected = keys.clone(), cx) + }) + .on_draw(move |kind, frame, window, cx| { + drawn( + &|board, desk| { + let key = fresh(board, &mut desk.made); + let (name, hue) = match &kind { + ShapeKind::Note(_) => ("Note", 2), + ShapeKind::Text(_) => ("Text", 0), + ShapeKind::Path { .. } => ("Stroke", 0), + _ => ("Shape", desk.made % 8), + }; + board + .shapes + .push(Shape::new(key.clone(), name, kind.clone(), frame).hue(hue)); + desk.selected = vec![key]; + }, + window, + cx, + ) + }) + .on_move(move |keys, (dx, dy), window, cx| { + let keys = keys.to_vec(); + moved( + &|board, _| { + for shape in board + .shapes + .iter_mut() + .filter(|shape| keys.contains(&shape.key)) + { + shape.frame = Frame::new( + shape.frame.x + dx, + shape.frame.y + dy, + shape.frame.w, + shape.frame.h, + ); + } + }, + window, + cx, + ) + }) + .on_resize(move |key, frame, window, cx| { + let key = key.clone(); + sized( + &|board, _| { + let shape = board + .shapes + .iter_mut() + .find(|shape| shape.key == key) + .expect("a resized shape is on the board"); + shape.frame = frame; + }, + window, + cx, + ) + }) + .on_link(move |from, to, window, cx| { + let (from, to) = (from.clone(), to.clone()); + linked( + &|board, desk| { + let key = fresh(board, &mut desk.made); + board.links.push(Link::new(key, from.clone(), to.clone())); + }, + window, + cx, + ) + }) + .on_delete(move |keys, window, cx| { + let gone = keys.to_vec(); + removed( + &|board, desk| { + desk.selected.clear(); + board.shapes.retain(|shape| !gone.contains(&shape.key)); + board + .links + .retain(|link| !gone.contains(&link.from) && !gone.contains(&link.to)); + }, + window, + cx, + ) + }) + .on_text(move |key, text, window, cx| { + let (key, text) = (key.clone(), text.clone()); + written( + &|board, _| { + let shape = board + .shapes + .iter_mut() + .find(|shape| shape.key == key) + .expect("a written shape is on the board"); + *shape = shape.written(text.clone()); + }, + window, + cx, + ) + }); + let theme = cx.theme(); + let floating = |element: gpui::AnyElement| { + div() + .absolute() + .rounded(theme.radius(Radius::Lg)) + .bg(theme.colors.surface) + .shadow(theme.elevation(Elevation::Floating)) + .child(element) + }; + let measured = desk.clone(); + div() + .id(id.clone()) + .relative() + .size_full() + .overflow_hidden() + .child( + canvas( + move |bounds, window, cx| { + let now = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + if measured.read(cx).size != now { + measured.update(cx, |desk, _| desk.size = now); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child( + InfiniteCanvas::new((id.clone(), "plane"), view) + .shapes(board.shapes) + .links(board.links) + .layer(layer) + .on_viewport(move |next, _, cx| viewed(&|desk| desk.view = next, cx)), + ) + .child( + floating( + ToolPalette::new((id.clone(), "palette"), tool) + .tools(TOOLS) + .on_change(move |next, _, cx| picked(&|desk| desk.tool = next, cx)) + .into_any_element(), + ) + .top_3() + .left_3(), + ) + .child( + floating( + ZoomControls::new((id, "zoom"), view.zoom) + .on_zoom(move |zoom, _, cx| { + let middle = (size.0 / 2.0, size.1 / 2.0); + zoomed( + &|desk| desk.view = desk.view.zoomed(zoom / desk.view.zoom, middle), + cx, + ) + }) + .on_fit(move |_, cx| { + if let Some(whole) = whole { + fitted( + &|desk| desk.view = Viewport::fitting(whole, desk.size, 48.0), + cx, + ) + } + }) + .into_any_element(), + ) + .bottom_3() + .right_3(), + ) + } +} diff --git a/src/canvas/wiring.rs b/src/canvas/wiring.rs new file mode 100644 index 0000000..7c8a1ea --- /dev/null +++ b/src/canvas/wiring.rs @@ -0,0 +1,98 @@ +use std::rc::Rc; + +use gpui::{App, Bounds, Entity, EntityId, Pixels, SharedString, Window}; + +use super::graph::{Edge, Node, Socket, fits, socket_at}; + +pub(super) type OnNudge = Rc; +pub(super) type OnEdge = Rc; + +/// A socket's reach for a press or a release, in view pixels. +pub(super) const REACH: f32 = 10.0; + +/// What a press on the graph set going. +#[derive(Clone, Debug, Default)] +pub(super) enum Pull { + #[default] + Idle, + Moving { + key: SharedString, + from: (f32, f32), + to: (f32, f32), + }, + Wiring { + from: Socket, + lifted: Option, + to: (f32, f32), + }, +} + +/// The graph's own state: the pull under way and the box it covers. +#[derive(Default)] +pub(super) struct Hold { + pub(super) pull: Pull, + pub(super) bounds: Bounds, +} + +/// A drag on a graph, and whose graph it is. +pub(super) struct Pulling { + pub(super) owner: EntityId, +} + +/// What a finished pull asks the owner, given the graph as it was. +pub(super) struct Ending { + pub(super) nodes: Rc>, + pub(super) edges: Rc>, + pub(super) zoom: f32, + pub(super) on_move: Option, + pub(super) on_connect: Option, + pub(super) on_disconnect: Option, +} + +impl Ending { + pub(super) fn run(&self, hold: &Entity, window: &mut Window, cx: &mut App) { + let pull = hold.update(cx, |hold, cx| { + cx.notify(); + std::mem::take(&mut hold.pull) + }); + match pull { + Pull::Moving { key, from, to } => { + let by = (to.0 - from.0, to.1 - from.1); + if by != (0.0, 0.0) { + log::info!("node graph: move {key} by {by:?}"); + if let Some(on_move) = &self.on_move { + on_move(&key, by, window, cx); + } + } + } + Pull::Wiring { from, lifted, to } => { + let target = + socket_at(&self.nodes, to, REACH / self.zoom).filter(|to| fits(&from, to)); + let made = target.map(|to| { + Edge::new((from.node.clone(), from.port.clone()), (to.node, to.port)) + }); + if made.is_some() && made == lifted { + return; + } + let replaced = made.as_ref().and_then(|made| { + self.edges + .iter() + .find(|edge| edge.to == made.to && Some(*edge) != lifted.as_ref()) + }); + for gone in lifted.iter().chain(replaced) { + log::info!("node graph: unwire {gone:?}"); + if let Some(on_disconnect) = &self.on_disconnect { + on_disconnect(gone, window, cx); + } + } + if let Some(made) = &made { + log::info!("node graph: wire {made:?}"); + if let Some(on_connect) = &self.on_connect { + on_connect(made, window, cx); + } + } + } + Pull::Idle => {} + } + } +} diff --git a/src/canvas/writing.rs b/src/canvas/writing.rs new file mode 100644 index 0000000..ecf91ef --- /dev/null +++ b/src/canvas/writing.rs @@ -0,0 +1,54 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + SharedString, Styled, Window, div, +}; + +use super::{gesture::OnPair, view::Frame}; +use crate::forms::{Editing, Input}; + +/// Opens a field on the words of what `key` names. Enter or leaving hands them to the owner; Escape drops them. +pub(super) fn begin( + editing: &Entity, + key: SharedString, + words: String, + on_text: Option, + window: &mut Window, + cx: &mut App, +) { + log::info!("canvas: writing on {key}"); + editing.update(cx, |editing, _| { + editing.on_commit = Some(Rc::new( + move |text: &SharedString, window: &mut Window, cx: &mut App| { + log::info!("canvas: {key} reads {text:?}"); + if let Some(on_text) = &on_text { + on_text(&key, text, window, cx); + } + }, + )); + }); + Editing::begin(editing, words, window, cx); +} + +/// The open field over `frame`, in view pixels. +pub(super) fn field(editing: &Entity, frame: Frame, cx: &App) -> Option { + let field = editing.read(cx).field()?; + let closing = editing.clone(); + Some( + div() + .absolute() + .left(Pixels::from(frame.x)) + .top(Pixels::from(frame.y)) + .w(Pixels::from(frame.w)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + closing.update(cx, |editing, cx| editing.finish(true, window, cx)); + } + }) + .child(Input::new(&field)) + .into_any_element(), + ) +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index f35fba3..beb6c06 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -71,13 +71,13 @@ ## T32d Canvas · Graphs and boards -- [ ] NodeEditor / NodeGraph -- [ ] Node / Port / Edge / Connection -- [ ] FlowChart / DiagramEditor -- [ ] MindMap -- [ ] Whiteboard -- [ ] StickyNote -- [ ] Connector Line +- [x] NodeEditor / NodeGraph +- [x] Node / Port / Edge / Connection +- [x] FlowChart / DiagramEditor +- [x] MindMap +- [x] Whiteboard +- [x] StickyNote +- [x] Connector Line ## T33 DB & Dev Tools — `src/devtools`