Canvas · Graphs and boards: node graph, flow chart, mind map, whiteboard

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 08:22:54 -04:00
1 parent ec1988cd6f
commit 4498ff09dc
28 files changed
+3068 -240

No files matched your search

+148
View File
@@ -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<Node> {
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<Stage>) -> 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<Stage>) -> 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<Stage>) -> AnyElement {
Whiteboard::new(
"board",
Board {
shapes: pair(),
links: Vec::new(),
},
)
.on_change(move |board, _, cx| {
let keys: Vec<String> = 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\"]"]);
}
+1
View File
@@ -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;
+9 -7
View File
@@ -8,12 +8,12 @@ use crate::canvas::{
Frame, InfiniteCanvas, Shape, ShapeKind, Tool, ToolLayer, ToolPalette, Viewport,
};
fn at(x: f32, y: f32) -> gpui::Point<gpui::Pixels> {
pub(super) fn at(x: f32, y: f32) -> gpui::Point<gpui::Pixels> {
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<Shape> {
pub(super) fn pair() -> Vec<Shape> {
vec![
Shape::new(
"a",
@@ -61,9 +61,9 @@ fn pair() -> Vec<Shape> {
]
}
fn layer(tool: Tool, selected: &[&'static str], owner: Entity<Stage>) -> AnyElement {
pub(super) fn layer(tool: Tool, selected: &[&'static str], owner: Entity<Stage>) -> 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<Stage>) -> 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)