Panics · P32 Canvas: panels take their handlers in new; stale nodes, shapes, layers and topics change nothing

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 05:02:40 -04:00
1 parent 3741d5d5e8
commit ffe4f38103
27 files changed
+471 -297

No files matched your search

+1 -1
View File
@@ -204,7 +204,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`).
- 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`, which takes the owner's handler itself: a component without one fails at render. A swatch that opens a picker is `canvas::panel::color_well`.
- Design panels edit a whole value and hand the owner a changed copy through `canvas::panel::editing`; the owner's handler is an argument of `new`. 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 button inside a pressable row goes through `lists::row_action`, which keeps its press and its Space or Enter from the row (`devtools::ConnectionManager`).
- A field opened over a component hands focus back to that component on Enter and Escape, through `forms::Editing`'s `returning` and `give_back` (`canvas::writing`); a blur keeps the words and leaves focus where it went.
+13 -5
View File
@@ -221,18 +221,26 @@ pub fn mind(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
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);
if let Some(root) = mind.root.adding(parent, Topic::new(key.clone(), "New idea")) {
mind.root = root;
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);
if let Some(root) = mind.root.removing(key) {
mind.selected = mind.root.parent_of(key);
mind.root = root;
}
})
})
.on_rename(move |key, text, _, cx| {
change(&renamed, cx, |mind| mind.root = mind.root.renaming(key, text.clone()))
change(&renamed, cx, |mind| {
if let Some(root) = mind.root.renaming(key, text.clone()) {
mind.root = root;
}
})
})
.on_viewport(move |next, _, cx| change(&viewed, cx, |mind| mind.view = next)),
cx,
+8 -16
View File
@@ -121,9 +121,8 @@ pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.gap_6()
.child(
div().w(px(260.)).child(
ColorPanel::new("canvas-color", color)
.swatches(SWATCHES.map(|(name, hex)| (name, Hsla::from(rgb(hex)))))
.on_change(move |next, _, cx| change(&colored, cx, |look| look.color = next)),
ColorPanel::new("canvas-color", color, move |next, _, cx| change(&colored, cx, |look| look.color = next))
.swatches(SWATCHES.map(|(name, hex)| (name, Hsla::from(rgb(hex))))),
),
)
.child(
@@ -137,8 +136,7 @@ pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.on_change(move |next, _, cx| change(&graded, cx, |look| look.stops = next.to_vec())),
)
.child(
ShadowEditor::new("canvas-shadows", shadows)
.on_change(move |next, _, cx| change(&shaded, cx, |look| look.shadows = next)),
ShadowEditor::new("canvas-shadows", shadows, move |next, _, cx| change(&shaded, cx, |look| look.shadows = next)),
),
)
.child(
@@ -148,15 +146,13 @@ pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col()
.gap_6()
.child(
BorderEditor::new("canvas-border", border)
.on_change(move |next, _, cx| change(&bordered, cx, |look| look.border = next)),
BorderEditor::new("canvas-border", border, move |next, _, cx| change(&bordered, cx, |look| look.border = next)),
)
.child(div().p_6().child(card)),
)
.child(
div().w(px(280.)).child(
TypographyPanel::new("canvas-type", style, &search)
.on_change(move |next, _, cx| change(&typed, cx, |look| look.style = next)),
TypographyPanel::new("canvas-type", style, &search, move |next, _, cx| change(&typed, cx, |look| look.style = next)),
),
),
)
@@ -282,8 +278,7 @@ pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col()
.gap_3()
.child(
ConstraintsEditor::new("canvas-constraints", constraints)
.on_change(move |next, _, cx| change(&pinned, cx, |rules| rules.constraints = next)),
ConstraintsEditor::new("canvas-constraints", constraints, move |next, _, cx| change(&pinned, cx, |rules| rules.constraints = next)),
)
.child(pinned_preview),
)
@@ -294,8 +289,7 @@ pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col()
.gap_3()
.child(
AutoLayoutControls::new("canvas-auto-layout", layout)
.on_change(move |next, _, cx| change(&laid, cx, |rules| rules.layout = next)),
AutoLayoutControls::new("canvas-auto-layout", layout, move |next, _, cx| change(&laid, cx, |rules| rules.layout = next)),
)
.child(laid_preview),
)
@@ -306,9 +300,7 @@ pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col()
.gap_3()
.child(
ExportPanel::new("canvas-export", "Card", exports)
.on_change(move |next, _, cx| change(&set, cx, |rules| rules.exports = next))
.on_export(move |files, _, cx| change(&sent, cx, |rules| rules.exported = files)),
ExportPanel::new("canvas-export", "Card", exports, move |next, _, cx| change(&set, cx, |rules| rules.exports = next), move |files, _, cx| change(&sent, cx, |rules| rules.exported = files)),
)
.when(!exported.is_empty(), |panel| {
panel.child(note(format!("Exported {}", exported.join(", ")).into()))
+12 -13
View File
@@ -134,19 +134,18 @@ pub fn arrange(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
studio.record(align.words().into());
})
});
let inspector =
InspectorPanel::new("canvas-inspector", chosen).on_change(move |shape, _, cx| {
change(&edited, cx, |studio| {
let at = studio
.shapes
.iter()
.position(|each| each.key == shape.key)
.expect("a listed shape");
let words = format!("Edit {}", shape.name);
studio.shapes[at] = shape;
studio.record(words);
})
});
let inspector = InspectorPanel::new("canvas-inspector", chosen, move |shape, _, cx| {
change(&edited, cx, |studio| {
let at = studio
.shapes
.iter()
.position(|each| each.key == shape.key)
.expect("a listed shape");
let words = format!("Edit {}", shape.name);
studio.shapes[at] = shape;
studio.record(words);
})
});
let assets = AssetPanel::new("canvas-assets")
.group("Shapes", shapes())
.group("Notes", notes())
+8 -14
View File
@@ -104,32 +104,26 @@ impl AutoLayout {
pub struct AutoLayoutControls {
id: ElementId,
layout: AutoLayout,
on_change: Option<OnEdit<AutoLayout>>,
on_change: OnEdit<AutoLayout>,
}
impl AutoLayoutControls {
pub fn new(id: impl Into<ElementId>, layout: AutoLayout) -> Self {
pub fn new(
id: impl Into<ElementId>,
layout: AutoLayout,
on_change: impl Fn(AutoLayout, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
layout,
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(
mut self,
handler: impl Fn(AutoLayout, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AutoLayoutControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("auto layout controls {:?} has no on_change", self.id));
let on_change = self.on_change;
let (id, layout, on_change) = (self.id, self.layout, &on_change);
let place = Rc::new(editing(
"auto layout",
+8 -14
View File
@@ -102,25 +102,21 @@ impl Constraints {
pub struct ConstraintsEditor {
id: ElementId,
constraints: Constraints,
on_change: Option<OnEdit<Constraints>>,
on_change: OnEdit<Constraints>,
}
impl ConstraintsEditor {
pub fn new(id: impl Into<ElementId>, constraints: Constraints) -> Self {
pub fn new(
id: impl Into<ElementId>,
constraints: Constraints,
on_change: impl Fn(Constraints, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
constraints,
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(
mut self,
handler: impl Fn(Constraints, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// A hairline from `x`, `y` in shares of its box, `len` long across or down.
@@ -135,9 +131,7 @@ fn hairline(x: f32, y: f32, len: f32, across: bool, color: Hsla) -> gpui::Div {
impl RenderOnce for ConstraintsEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("constraints editor {:?} has no on_change", self.id));
let on_change = self.on_change;
let (id, now, on_change) = (self.id, self.constraints, &on_change);
let theme = cx.theme();
let (lit, rest) = (theme.colors.accent, theme.colors.border);
+8 -26
View File
@@ -99,8 +99,8 @@ pub struct ExportPanel {
id: ElementId,
name: SharedString,
settings: Vec<ExportSetting>,
on_change: Option<OnEdit<Vec<ExportSetting>>>,
on_export: Option<OnEdit<Vec<String>>>,
on_change: OnEdit<Vec<ExportSetting>>,
on_export: OnEdit<Vec<String>>,
}
impl ExportPanel {
@@ -109,41 +109,23 @@ impl ExportPanel {
id: impl Into<ElementId>,
name: impl Into<SharedString>,
settings: impl IntoIterator<Item = ExportSetting>,
on_change: impl Fn(Vec<ExportSetting>, &mut Window, &mut App) + 'static,
on_export: impl Fn(Vec<String>, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
name: name.into(),
settings: settings.into_iter().collect(),
on_change: None,
on_export: None,
on_change: Rc::new(on_change),
on_export: Rc::new(on_export),
}
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<ExportSetting>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_export(
mut self,
handler: impl Fn(Vec<String>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_export = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ExportPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("export panel {:?} has no on_change", self.id));
let on_export = self
.on_export
.unwrap_or_else(|| panic!("export panel {:?} has no on_export", self.id));
let on_change = self.on_change;
let on_export = self.on_export;
let (id, settings, on_change) = (self.id, self.settings, &on_change);
let files = export_files(&self.name, &settings);
let next = added(&settings);
+9 -6
View File
@@ -168,12 +168,11 @@ pub(super) fn commit(
from,
to,
} => {
let frame = scene
.shapes
.iter()
.find(|shape| shape.key == key)
.expect("the resized shape is listed")
.frame;
let Some(shape) = scene.shapes.iter().find(|shape| shape.key == key) else {
log::error!("tool layer: resized shape {key} left the canvas");
return;
};
let frame = shape.frame;
let next = resized(frame, handle, (to.0 - from.0, to.1 - from.1), LEAST, square);
log::info!("tool layer: resize {key} to {next:?}");
if let Some(on_resize) = &handlers.on_resize {
@@ -191,6 +190,10 @@ pub(super) fn commit(
}
}
Gesture::Linking { from, to } => {
if !scene.shapes.iter().any(|shape| shape.key == from) {
log::error!("tool layer: the link's shape {from} left the canvas");
return;
}
let Some(ix) = hit(&scene.shapes, to, REACH / scene.view.zoom) else {
log::info!("tool layer: the link from {from} lands on nothing");
return;
+31 -27
View File
@@ -70,18 +70,9 @@ impl Node {
(x, self.at.1 + HEAD + ROW * row as f32 + ROW / 2.0)
}
fn row(&self, out: bool, port: &str) -> usize {
fn row(&self, out: bool, port: &str) -> Option<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" }
)
})
ports.iter().position(|each| each.key == port)
}
}
@@ -119,22 +110,26 @@ pub(crate) struct Socket {
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"))
}
/// Where a wire starts and ends in canvas units, and the kind it carries.
pub(crate) type Ends = ((f32, f32), (f32, f32), SharedString);
/// 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(),
)
/// An edge's ends; none when a node or port it names left the graph.
pub(crate) fn ends(nodes: &[Node], edge: &Edge) -> Option<Ends> {
let node = |key: &SharedString| nodes.iter().find(|node| node.key == *key);
let ends = node(&edge.from.0)
.zip(node(&edge.to.0))
.and_then(|(from, to)| {
let (out, input) = (from.row(true, &edge.from.1)?, to.row(false, &edge.to.1)?);
Some((
from.socket(true, out),
to.socket(false, input),
from.outputs[out].kind.clone(),
))
});
if ends.is_none() {
log::error!("node editor: edge {edge:?} names a node or port gone from the graph");
}
ends
}
/// The socket within `reach` of `point`, the topmost node first.
@@ -199,7 +194,8 @@ mod tests {
#[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")));
let (from, to, kind) =
ends(&nodes, &Edge::new(("load", "image"), ("blur", "image"))).expect("both ends");
assert_eq!(from, (WIDE, HEAD + ROW / 2.0));
assert_eq!(to, (300.0, HEAD + ROW / 2.0));
assert_eq!(kind, "image");
@@ -210,6 +206,14 @@ mod tests {
("blur", "radius", false)
);
assert_eq!(socket_at(&nodes, (150.0, 40.0), 6.0), None);
assert_eq!(
ends(&nodes, &Edge::new(("load", "image"), ("gone", "image"))),
None
);
assert_eq!(
ends(&nodes, &Edge::new(("load", "gone"), ("blur", "image"))),
None
);
}
#[test]
+6 -5
View File
@@ -27,11 +27,12 @@ impl HistoryPanel {
at: usize,
) -> Self {
let steps: Vec<SharedString> = steps.into_iter().map(Into::into).collect();
assert!(
at <= steps.len(),
"history at {at} of {} steps",
steps.len()
);
if at > steps.len() {
log::error!(
"history panel: at {at} of {} steps; none current",
steps.len()
);
}
Self {
id: id.into(),
steps,
+8 -11
View File
@@ -25,29 +25,26 @@ const REACH: f64 = 100_000.0;
pub struct InspectorPanel {
id: ElementId,
selection: Vec<Shape>,
on_change: Option<OnEdit<Shape>>,
on_change: OnEdit<Shape>,
}
impl InspectorPanel {
pub fn new(id: impl Into<ElementId>, selection: impl IntoIterator<Item = Shape>) -> Self {
pub fn new(
id: impl Into<ElementId>,
selection: impl IntoIterator<Item = Shape>,
on_change: impl Fn(Shape, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
selection: selection.into_iter().collect(),
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(mut self, handler: impl Fn(Shape, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for InspectorPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("inspector panel {:?} has no on_change", self.id));
let on_change = self.on_change;
let id = self.id;
let [shape] = self.selection.as_slice() else {
let title: SharedString = match self.selection.len() {
+14 -12
View File
@@ -8,30 +8,30 @@ use super::{
};
use crate::lists::{DropAt, Tree, TreeNode};
/// The keys in paint order, bottom first, after `dragged` lands `at` `target` in a list drawn top first.
/// The keys in paint order, bottom first, after `dragged` lands `at` `target` in a list drawn top first; none, logged, when either left the list.
pub(crate) fn restacked(
order: &[SharedString],
dragged: &SharedString,
target: &SharedString,
at: DropAt,
) -> Vec<SharedString> {
assert!(order.contains(dragged), "layer {dragged} is not listed");
) -> Option<Vec<SharedString>> {
let mut rest: Vec<SharedString> = order
.iter()
.filter(|key| *key != dragged)
.cloned()
.collect();
let under = rest
.iter()
.position(|key| key == target)
.unwrap_or_else(|| panic!("layer {dragged} lands against {target}, which is not listed"));
let under = rest.iter().position(|key| key == target);
let (true, Some(under)) = (order.contains(dragged), under) else {
log::error!("layer panel: {dragged} or {target} left the layers; nothing moves");
return None;
};
let ix = match at {
DropAt::Before => under + 1,
DropAt::After => under,
DropAt::Inside => panic!("layer {target} holds no layers"),
};
rest.insert(ix, dragged.clone());
rest
Some(rest)
}
/// The canvas's layers, topmost first. A box shows or hides each, a press selects and Shift or Command adds, F2 renames, and a drag restacks; a locked layer says so. It fills its box, which the host sizes.
@@ -137,7 +137,9 @@ impl RenderOnce for LayerPanel {
if let Some(on_restack) = self.on_restack {
tree = tree.on_move(move |dragged, target, at, window, cx| {
log::info!("layer panel: {dragged} {at:?} {target}");
on_restack(&restacked(&order, dragged, target, at), window, cx);
if let Some(order) = restacked(&order, dragged, target, at) {
on_restack(&order, window, cx);
}
});
}
tree
@@ -160,15 +162,15 @@ mod tests {
let order = keys(&["a", "b", "c"]);
assert_eq!(
restacked(&order, &"a".into(), &"c".into(), DropAt::Before),
keys(&["b", "c", "a"])
Some(keys(&["b", "c", "a"]))
);
assert_eq!(
restacked(&order, &"c".into(), &"a".into(), DropAt::After),
keys(&["c", "a", "b"])
Some(keys(&["c", "a", "b"]))
);
assert_eq!(
restacked(&order, &"a".into(), &"b".into(), DropAt::After),
keys(&["a", "b", "c"]),
Some(keys(&["a", "b", "c"])),
"just under b is where a lies"
);
}
+29 -21
View File
@@ -24,37 +24,40 @@ impl Topic {
self
}
/// The map with `topic` added last under `parent`.
pub fn adding(&self, parent: &str, topic: Topic) -> Topic {
/// The map with `topic` added last under `parent`; none, logged, when the map holds no `parent`.
pub fn adding(&self, parent: &str, topic: Topic) -> Option<Topic> {
let mut next = self.clone();
let under = next
.find(parent)
.unwrap_or_else(|| panic!("no topic {parent} to add under"));
let Some(under) = next.find(parent) else {
log::error!("mind map: no topic {parent} to add under");
return None;
};
under.children.push(topic);
next
Some(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");
/// The map without `key` and what lies under it; none, logged, for the root or a topic the map lacks.
pub fn removing(&self, key: &str) -> Option<Topic> {
let mut next = self.clone();
let parent = next
.parent_of(key)
.unwrap_or_else(|| panic!("no topic {key} to remove"));
let Some(parent) = next.parent_of(key) else {
log::error!("mind map: no topic {key} below the root to remove");
return None;
};
next.find(&parent)
.expect("the parent is in the map")
.children
.retain(|topic| topic.key != key);
next
Some(next)
}
/// The map with `key` reading `text`.
pub fn renaming(&self, key: &str, text: impl Into<SharedString>) -> Topic {
/// The map with `key` reading `text`; none, logged, when the map holds no `key`.
pub fn renaming(&self, key: &str, text: impl Into<SharedString>) -> Option<Topic> {
let mut next = self.clone();
next.find(key)
.unwrap_or_else(|| panic!("no topic {key} to rename"))
.text = text.into();
next
let Some(topic) = next.find(key) else {
log::error!("mind map: no topic {key} to rename");
return None;
};
topic.text = text.into();
Some(next)
}
fn find(&mut self, key: &str) -> Option<&mut Topic> {
@@ -229,10 +232,15 @@ mod tests {
fn edits_add_remove_and_rename_by_key() {
let map = map()
.adding("b", Topic::new("b1", "Release"))
.renaming("c", "Money");
.and_then(|map| map.renaming("c", "Money"))
.expect("both topics are in the map");
assert_eq!(map.children[1].children[0].key, "b1");
assert_eq!(map.children[2].text, "Money");
let map = map.removing("a");
assert_eq!(map.adding("gone", Topic::new("x", "X")), None);
assert_eq!(map.renaming("gone", "X"), None);
assert_eq!(map.removing(&map.key), None, "the root stays");
assert_eq!(map.removing("gone"), None);
let map = map.removing("a").expect("a is in the map");
assert_eq!(
map.children
.iter()
+7 -7
View File
@@ -117,13 +117,13 @@ impl RenderOnce for MindMap {
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 selected = self.selected.clone().filter(|key| {
let placed = placed.iter().any(|topic| topic.key == *key);
if !placed {
log::error!("mind map {id:?}: no topic {key}; none selected");
}
placed
});
let rename = {
let (editing, placed, on_rename, home) = (
editing.clone(),
+36 -25
View File
@@ -234,17 +234,20 @@ impl RenderOnce for NodeGraph {
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");
let source = wired.and_then(|edge| {
let port = nodes
.iter()
.find(|node| node.key == edge.from.0)?
.outputs
.iter()
.find(|port| port.key == edge.from.1);
if port.is_none() {
log::error!("node editor: edge {edge:?} starts at a port gone from the graph");
}
Some(edge).zip(port)
});
match source {
Some((edge, port)) => {
Pull::Wiring {
from: Socket {
node: edge.from.0.clone(),
@@ -330,16 +333,26 @@ impl RenderOnce for NodeGraph {
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()))
let source = nodes.iter().find(|node| node.key == from.node);
let wiring = source.and_then(|source| {
let row = source
.outputs
.iter()
.position(|port| port.key == from.port)?;
Some((
source.socket(true, row),
*to,
source.outputs[row].kind.clone(),
))
});
if wiring.is_none() {
log::error!(
"node editor: the wire's port {}.{} left the graph",
from.node,
from.port
);
}
wiring
}
_ => None,
};
@@ -353,10 +366,8 @@ impl RenderOnce for NodeGraph {
let drawn = edges
.iter()
.filter(|edge| Some(*edge) != lifted.as_ref())
.map(|edge| {
let (from, to, kind) = ends(&nodes, edge);
(from, to, hue(&kind))
})
.filter_map(|edge| ends(&nodes, edge))
.map(|(from, to, kind)| (from, to, hue(&kind)))
.chain(
pending
.iter()
+7 -6
View File
@@ -66,12 +66,13 @@ 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()
);
if !self.tools.contains(&self.tool) {
log::error!(
"tool palette {:?}: {} is not offered; none chosen",
self.id,
self.tool.words()
);
}
let on_change = self.on_change;
self.tools
.into_iter()
+7 -8
View File
@@ -167,15 +167,14 @@ impl RenderOnce for InfiniteCanvas {
.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 named = |key: &SharedString| self.shapes.iter().find(|shape| shape.key == *key);
let (Some(from), Some(to)) = (named(&link.from), named(&link.to)) else {
log::error!(
"canvas: link {} names a shape gone from the canvas",
link.key
);
return None;
};
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()
+24 -36
View File
@@ -23,16 +23,20 @@ pub struct ColorPanel {
id: ElementId,
color: Hsla,
swatches: Vec<(SharedString, Hsla)>,
on_change: Option<OnEdit<Hsla>>,
on_change: OnEdit<Hsla>,
}
impl ColorPanel {
pub fn new(id: impl Into<ElementId>, color: impl Into<Hsla>) -> Self {
pub fn new(
id: impl Into<ElementId>,
color: impl Into<Hsla>,
on_change: impl Fn(Hsla, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
color: color.into(),
swatches: Vec::new(),
on_change: None,
on_change: Rc::new(on_change),
}
}
@@ -47,18 +51,11 @@ impl ColorPanel {
.collect();
self
}
pub fn on_change(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ColorPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("color panel {:?} has no on_change", self.id));
let on_change = self.on_change;
let (picked, chosen) = (on_change.clone(), on_change);
let report = |on_change: OnEdit<Hsla>| {
move |color: Hsla, window: &mut Window, cx: &mut App| {
@@ -98,32 +95,26 @@ pub struct Shadow {
pub struct ShadowEditor {
id: ElementId,
shadows: Vec<Shadow>,
on_change: Option<OnEdit<Vec<Shadow>>>,
on_change: OnEdit<Vec<Shadow>>,
}
impl ShadowEditor {
pub fn new(id: impl Into<ElementId>, shadows: impl IntoIterator<Item = Shadow>) -> Self {
pub fn new(
id: impl Into<ElementId>,
shadows: impl IntoIterator<Item = Shadow>,
on_change: impl Fn(Vec<Shadow>, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
shadows: shadows.into_iter().collect(),
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<Shadow>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ShadowEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("shadow editor {:?} has no on_change", self.id));
let on_change = self.on_change;
let lift = cx
.theme()
.elevation(Elevation::Floating)
@@ -247,31 +238,28 @@ pub struct Border {
pub struct BorderEditor {
id: ElementId,
border: Border,
on_change: Option<OnEdit<Border>>,
on_change: OnEdit<Border>,
}
impl BorderEditor {
pub fn new(id: impl Into<ElementId>, border: Border) -> Self {
pub fn new(
id: impl Into<ElementId>,
border: Border,
on_change: impl Fn(Border, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
border,
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(mut self, handler: impl Fn(Border, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
const CORNERS: [&str; 4] = ["TL", "TR", "BR", "BL"];
impl RenderOnce for BorderEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("border editor {:?} has no on_change", self.id));
let on_change = self.on_change;
let (id, border, on_change) = (self.id, self.border, &on_change);
let parted = window.use_keyed_state((id.clone(), "split"), cx, |_, _| false);
let uneven = border.radii.iter().any(|radius| *radius != border.radii[0]);
+132 -1
View File
@@ -2,7 +2,7 @@ use gpui::{AnyElement, Entity, IntoElement, TestAppContext, VisualTestContext};
use super::{
Stage, said, say, settle, stage,
tools::{drag, layer, pair, press},
tools::{at, drag, layer, pair, press},
};
use crate::canvas::{
Board, Edge, Link, MindMap, Node, NodeGraph, Port, Tool, Topic, Viewport, Whiteboard,
@@ -245,3 +245,134 @@ fn after_writing_on_a_shape_its_board_still_takes_backspace(cx: &mut TestAppCont
["shapes [\"a\", \"b\"] links 1", "shapes [\"a\"] links 0"]
);
}
/// Presses at `from` and drags to `to` in four moves.
fn pull(from: (f32, f32), to: (f32, f32), cx: &mut VisualTestContext) {
use gpui::{Modifiers, MouseButton};
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 {
let share = step as f32 / 4.0;
let (x, y) = (
from.0 + (to.0 - from.0) * share,
from.1 + (to.1 - from.1) * share,
);
cx.simulate_mouse_move(at(x, y), Some(MouseButton::Left), Modifiers::none());
settle(cx);
}
}
fn release(to: (f32, f32), cx: &mut VisualTestContext) {
let (left, none) = (gpui::MouseButton::Left, gpui::Modifiers::none());
cx.simulate_mouse_up(at(to.0, to.1), left, none);
settle(cx);
}
/// The owner changes what it hands the part, mid-gesture.
fn change(host: &Entity<Stage>, cx: &mut VisualTestContext) {
host.update(cx, |stage, cx| {
stage.said.push("owner changed".into());
cx.notify();
});
settle(cx);
}
#[gpui::test]
fn f2_on_a_selected_topic_that_left_renames_nothing(cx: &mut TestAppContext) {
let (_, cx) = stage(
|_, _| {
let view = Viewport::new(-200.0, -150.0, 1.0);
MindMap::new("map", Topic::new("root", "Root"), view)
.selected(Some("gone"))
.into_any_element()
},
cx,
);
press(200.0, 150.0, 1, cx);
tap("f2", cx);
}
/// Two wireable nodes whose source leaves, or changes kind, once the owner changes.
fn shifting(stage: &Stage, owner: Entity<Stage>, retype: bool) -> AnyElement {
let mut current = nodes();
if !stage.said.is_empty() {
match retype {
true => current[0].outputs[0].kind = "number".into(),
false => drop(current.remove(0)),
}
}
NodeGraph::new("graph", Viewport::new(0.0, 0.0, 1.0), current, [])
.on_connect(move |edge, _, cx| {
let words = format!(
"connect {}.{} {}.{}",
edge.from.0, edge.from.1, edge.to.0, edge.to.1
);
say(&owner, words, cx)
})
.into_any_element()
}
#[gpui::test]
fn a_wire_whose_source_left_connects_nothing(cx: &mut TestAppContext) {
let (host, cx) = stage(|stage, owner| shifting(stage, owner, false), cx);
pull((200.0, 46.0), (301.0, 47.0), cx);
change(&host, cx);
release((301.0, 47.0), cx);
assert_eq!(said(&host, cx), ["owner changed"]);
}
#[gpui::test]
fn a_wire_whose_source_changed_kind_connects_nothing(cx: &mut TestAppContext) {
let (host, cx) = stage(|stage, owner| shifting(stage, owner, true), cx);
pull((200.0, 46.0), (301.0, 47.0), cx);
change(&host, cx);
release((301.0, 47.0), cx);
assert_eq!(said(&host, cx), ["owner changed"]);
}
#[gpui::test]
fn a_link_whose_shape_left_links_nothing(cx: &mut TestAppContext) {
let (host, cx) = stage(
|stage, owner| {
let mut shapes = pair();
if !stage.said.is_empty() {
shapes.remove(0);
}
let view = Viewport::new(0.0, 0.0, 1.0);
let tool =
crate::canvas::ToolLayer::new("layer", Tool::Connector, view, shapes.clone())
.on_link(move |from, to, _, cx| say(&owner, format!("link {from} {to}"), cx));
crate::canvas::InfiniteCanvas::new("plane", view)
.shapes(shapes)
.layer(tool)
.into_any_element()
},
cx,
);
pull((50.0, 50.0), (250.0, 60.0), cx);
change(&host, cx);
release((250.0, 60.0), cx);
assert_eq!(said(&host, cx), ["owner changed"]);
}
#[gpui::test]
fn a_layer_that_left_mid_drag_restacks_nothing(cx: &mut TestAppContext) {
let (host, cx) = stage(
|stage, owner| {
let mut shapes = pair();
if !stage.said.is_empty() {
shapes.remove(0);
}
crate::canvas::LayerPanel::new("layers", shapes)
.on_restack(move |keys, _, cx| say(&owner, format!("restack {keys:?}"), cx))
.into_any_element()
},
cx,
);
let a = cx.debug_bounds("tree-chevron-a").expect("a row").center();
let b = cx.debug_bounds("tree-chevron-b").expect("b row").center();
pull((100.0, f32::from(a.y)), (100.0, f32::from(b.y)), cx);
change(&host, cx);
release((100.0, f32::from(b.y)), cx);
assert_eq!(said(&host, cx), ["owner changed"]);
}
+1
View File
@@ -9,6 +9,7 @@ use crate::{primitives::FocusNext, theme::Theme};
mod boards;
mod panels;
mod stale;
mod tools;
/// A view that shows one canvas part and keeps the viewports and words it heard.
+9 -8
View File
@@ -59,9 +59,10 @@ fn places(_: &Stage, owner: Entity<Stage>) -> AnyElement {
padding: (8.0, 8.0),
place: (Place::Start, Place::Start),
};
AutoLayoutControls::new("auto", layout)
.on_change(move |next, _, cx| say(&owner, format!("{:?}", next.place), cx))
.into_any_element()
AutoLayoutControls::new("auto", layout, move |next, _, cx| {
say(&owner, format!("{:?}", next.place), cx)
})
.into_any_element()
}
#[gpui::test]
@@ -89,12 +90,12 @@ fn sizes(_: &Stage, owner: Entity<Stage>) -> AnyElement {
scale: 1.0,
format: ExportFormat::Png,
}],
move |next, _, cx| {
let scales: Vec<f32> = next.iter().map(|setting| setting.scale).collect();
say(&added, format!("sizes {scales:?}"), cx)
},
move |files, _, cx| say(&sent, format!("files {files:?}"), cx),
)
.on_change(move |next, _, cx| {
let scales: Vec<f32> = next.iter().map(|setting| setting.scale).collect();
say(&added, format!("sizes {scales:?}"), cx)
})
.on_export(move |files, _, cx| say(&sent, format!("files {files:?}"), cx))
.into_any_element()
}
+43
View File
@@ -0,0 +1,43 @@
use gpui::{AnyElement, Entity, IntoElement, ParentElement, Styled, TestAppContext, div, px};
use super::{Stage, settle, stage};
use crate::canvas::{
Edge, Frame, HistoryPanel, InfiniteCanvas, Link, MindMap, Node, NodeGraph, Port, Shape,
ShapeKind, Tool, ToolPalette, Topic, Viewport,
};
/// Each part handed a key or an edge that its list no longer holds.
fn lagging(_: &Stage, _: Entity<Stage>) -> AnyElement {
let view = Viewport::new(0.0, 0.0, 1.0);
let load = Node::new("load", "Load", (0.0, 0.0)).output(Port::new("image", "Image", "image"));
let shape = Shape::new("a", "A", ShapeKind::Rect, Frame::new(0.0, 0.0, 40.0, 40.0));
div()
.size_full()
.child(div().h(px(120.0)).child(NodeGraph::new(
"graph",
view,
[load],
[Edge::new(("load", "image"), ("gone", "image"))],
)))
.child(
div().h(px(120.0)).child(
InfiniteCanvas::new("plane", view)
.shapes([shape])
.links([Link::new("l", "a", "gone")]),
),
)
.child(
div().h(px(120.0)).child(
MindMap::new("map", Topic::new("root", "Root"), view).selected(Some("gone")),
),
)
.child(ToolPalette::new("tools", Tool::Brush).tools([Tool::Select]))
.child(HistoryPanel::new("history", ["Draw"], 4))
.into_any_element()
}
#[gpui::test]
fn gone_nodes_shapes_topics_tools_and_steps_draw(cx: &mut TestAppContext) {
let (_, cx) = stage(lagging, cx);
settle(cx);
}
+12 -10
View File
@@ -228,16 +228,18 @@ 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)]
}
Gesture::Linking { from, to } => match shapes.iter().find(|shape| shape.key == *from) {
Some(start) => {
let start = start.frame.center();
let (kind, frame) =
drawn(ShapeKind::Arrow(Corner::BottomRight), start, *to, false);
vec![Shape::new("+link", "Link", kind, frame)]
}
None => {
log::error!("tool layer: the link's shape {from} left the canvas");
Vec::new()
}
},
_ if tool == Tool::Pen && pen.len() > 1 => {
vec![stroke_shape(&pen, PEN, self.brush.hue)]
}
+9 -14
View File
@@ -48,34 +48,29 @@ pub struct TypographyPanel {
id: ElementId,
style: TypeStyle,
search: Entity<TextInput>,
on_change: Option<OnEdit<TypeStyle>>,
on_change: OnEdit<TypeStyle>,
}
impl TypographyPanel {
/// `search` is the family field's text, which the owner keeps.
pub fn new(id: impl Into<ElementId>, style: TypeStyle, search: &Entity<TextInput>) -> Self {
pub fn new(
id: impl Into<ElementId>,
style: TypeStyle,
search: &Entity<TextInput>,
on_change: impl Fn(TypeStyle, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
style,
search: search.clone(),
on_change: None,
on_change: Rc::new(on_change),
}
}
pub fn on_change(
mut self,
handler: impl Fn(TypeStyle, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TypographyPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("typography panel {:?} has no on_change", self.id));
let on_change = self.on_change;
let (id, style, on_change) = (self.id, self.style, &on_change);
let theme = cx.theme();
let family = editing(
+10 -10
View File
@@ -195,11 +195,11 @@ impl RenderOnce for Whiteboard {
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");
let Some(shape) = board.shapes.iter_mut().find(|shape| shape.key == key)
else {
log::error!("whiteboard: resized shape {key} left the board");
return;
};
shape.frame = frame;
},
window,
@@ -235,11 +235,11 @@ impl RenderOnce for Whiteboard {
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");
let Some(shape) = board.shapes.iter_mut().find(|shape| shape.key == key)
else {
log::error!("whiteboard: written shape {key} left the board");
return;
};
*shape = shape.written(text.clone());
},
window,
+17
View File
@@ -66,6 +66,23 @@ impl Ending {
}
}
Pull::Wiring { from, lifted, to } => {
let kind = self
.nodes
.iter()
.find(|node| node.key == from.node)
.and_then(|node| {
let port = node.outputs.iter().find(|port| port.key == from.port)?;
Some(port.kind.clone())
});
let Some(kind) = kind else {
log::error!(
"node graph: the wire's port {}.{} left the graph",
from.node,
from.port
);
return;
};
let from = Socket { kind, ..from };
let target =
socket_at(&self.nodes, to, REACH / self.zoom).filter(|to| fits(&from, to));
let made = target.map(|to| {
+2 -1
View File
@@ -46,7 +46,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca
- [x] P29 Mail — `src/mail`
- [x] P30 Calendar — `src/calendar`
- [x] P31 Project — `src/project`
- [ ] P32 Canvas & Design — `src/canvas`
- [x] P32 Canvas & Design — `src/canvas`
- [ ] P33 DB & Dev Tools — `src/devtools`
- [ ] P34 Dashboard — `src/dashboard`
- [ ] P35 Settings — `src/settings`
@@ -97,3 +97,4 @@ One line per item: what changed, what stayed and why, review rounds.
- P29: an open mailbox, ticked label or selected mail no longer listed logs and marks none; a picked moment its zone cannot hold logs and reads as none, so the time dialog's action stays off. Kept: one list's uniqueness, day arithmetic from the owner's now, constant `datetime` patterns, the dialog's action behind its disabled button. Review: 1 round.
- P30: calendar views step only within jiff's range less a year at each end (`calendar::within`, `stepped`), so every grid around a day fits, and a step past logs and stays; recurrences end where jiff's range ends; the year view's marks stop at an event's last day; the event editor refuses moves, ends, all-day starts and switches to timed that leave jiff's range or a stale painted kind, each with a log; a backward timed draft's days are its first day. Kept: owner dates at jiff's far edges, one list's uniqueness, an event's own order, one organizer, working hours, slots, reminder offsets, constant patterns, picks that always name a value. Review: 2 rounds.
- P31: a gone assignee logs and shows nobody; a milestone past its count fills, the text real; a roadmap past jiff's range ends inclusively on its last day; the database timeline leaves out and counts tasks due before they start and counts months in `i32`, and its calendar keeps to days `calendar::within` holds; the board's drag and drop take only columns, cards and places this frame holds. Kept: one list's uniqueness, an initiative's and a milestone's own order, shares, month counts, pomodoro lengths, issue keys, lookups over the components' own tables. Review: 3 rounds.
- P32: the eight design panels take their handlers in `new` (AGENTS.md says so); edges, links, wires, links in progress, resizes, restacks, topics and whiteboard edits that name a node, port, shape, layer or topic gone from this frame log and change nothing; a wire's release fits its source as it now stands; `Topic::adding`, `removing` and `renaming` return `Option`; a mind map's selection, a palette's tool and a history position no longer held mark none. Kept: one-value contracts (alignment counts, frames, zoom, polygon sides, brush hues and type weights among the theme's tables), lookups over the panels' own option tables, button-gated expects. Review: 2 rounds.