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

+306
View File
@@ -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<dyn Fn(Option<&SharedString>, &mut Window, &mut App)>;
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<SharedString>,
on_select: Option<OnTopic>,
on_add: Option<OnKey>,
on_remove: Option<OnKey>,
on_rename: Option<OnPair>,
on_viewport: Option<OnViewport>,
}
impl MindMap {
pub fn new(id: impl Into<ElementId>, 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<impl Into<SharedString>>) -> 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<AnyElement> = 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)
}
}