diff --git a/AGENTS.md b/AGENTS.md index e4497e0..6333762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/examples/gallery/pages/canvas/boards.rs b/examples/gallery/pages/canvas/boards.rs index a707bfe..0074980 100644 --- a/examples/gallery/pages/canvas/boards.rs +++ b/examples/gallery/pages/canvas/boards.rs @@ -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, diff --git a/examples/gallery/pages/canvas/looks.rs b/examples/gallery/pages/canvas/looks.rs index d06f84d..db7b6d7 100644 --- a/examples/gallery/pages/canvas/looks.rs +++ b/examples/gallery/pages/canvas/looks.rs @@ -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())) diff --git a/examples/gallery/pages/canvas/panels.rs b/examples/gallery/pages/canvas/panels.rs index 3a0247b..5f456fa 100644 --- a/examples/gallery/pages/canvas/panels.rs +++ b/examples/gallery/pages/canvas/panels.rs @@ -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()) diff --git a/src/canvas/autolayout.rs b/src/canvas/autolayout.rs index ac5ba2c..bd766fe 100644 --- a/src/canvas/autolayout.rs +++ b/src/canvas/autolayout.rs @@ -104,32 +104,26 @@ impl AutoLayout { pub struct AutoLayoutControls { id: ElementId, layout: AutoLayout, - on_change: Option>, + on_change: OnEdit, } impl AutoLayoutControls { - pub fn new(id: impl Into, layout: AutoLayout) -> Self { + pub fn new( + id: impl Into, + 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", diff --git a/src/canvas/constraints.rs b/src/canvas/constraints.rs index 378c6c4..272c4f2 100644 --- a/src/canvas/constraints.rs +++ b/src/canvas/constraints.rs @@ -102,25 +102,21 @@ impl Constraints { pub struct ConstraintsEditor { id: ElementId, constraints: Constraints, - on_change: Option>, + on_change: OnEdit, } impl ConstraintsEditor { - pub fn new(id: impl Into, constraints: Constraints) -> Self { + pub fn new( + id: impl Into, + 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); diff --git a/src/canvas/export.rs b/src/canvas/export.rs index beb02ce..f864164 100644 --- a/src/canvas/export.rs +++ b/src/canvas/export.rs @@ -99,8 +99,8 @@ pub struct ExportPanel { id: ElementId, name: SharedString, settings: Vec, - on_change: Option>>, - on_export: Option>>, + on_change: OnEdit>, + on_export: OnEdit>, } impl ExportPanel { @@ -109,41 +109,23 @@ impl ExportPanel { id: impl Into, name: impl Into, settings: impl IntoIterator, + on_change: impl Fn(Vec, &mut Window, &mut App) + 'static, + on_export: impl Fn(Vec, &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, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_change = Some(Rc::new(handler)); - self - } - - pub fn on_export( - mut self, - handler: impl Fn(Vec, &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); diff --git a/src/canvas/gesture.rs b/src/canvas/gesture.rs index 1d4a665..9f47c64 100644 --- a/src/canvas/gesture.rs +++ b/src/canvas/gesture.rs @@ -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; diff --git a/src/canvas/graph.rs b/src/canvas/graph.rs index 12a163b..04245c3 100644 --- a/src/canvas/graph.rs +++ b/src/canvas/graph.rs @@ -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 { 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 { + 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] diff --git a/src/canvas/history.rs b/src/canvas/history.rs index 37a7fe1..0cf80ce 100644 --- a/src/canvas/history.rs +++ b/src/canvas/history.rs @@ -27,11 +27,12 @@ impl HistoryPanel { at: usize, ) -> Self { let steps: Vec = 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, diff --git a/src/canvas/inspector.rs b/src/canvas/inspector.rs index b583d32..e7eddba 100644 --- a/src/canvas/inspector.rs +++ b/src/canvas/inspector.rs @@ -25,29 +25,26 @@ const REACH: f64 = 100_000.0; pub struct InspectorPanel { id: ElementId, selection: Vec, - on_change: Option>, + on_change: OnEdit, } impl InspectorPanel { - pub fn new(id: impl Into, selection: impl IntoIterator) -> Self { + pub fn new( + id: impl Into, + selection: impl IntoIterator, + 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() { diff --git a/src/canvas/layers.rs b/src/canvas/layers.rs index 4ed56b3..d2a9504 100644 --- a/src/canvas/layers.rs +++ b/src/canvas/layers.rs @@ -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 { - assert!(order.contains(dragged), "layer {dragged} is not listed"); +) -> Option> { let mut rest: Vec = 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" ); } diff --git a/src/canvas/mindmap.rs b/src/canvas/mindmap.rs index cf04ce6..29edacf 100644 --- a/src/canvas/mindmap.rs +++ b/src/canvas/mindmap.rs @@ -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 { 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 { 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) -> Topic { + /// The map with `key` reading `text`; none, logged, when the map holds no `key`. + pub fn renaming(&self, key: &str, text: impl Into) -> Option { 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() diff --git a/src/canvas/mindmap_view.rs b/src/canvas/mindmap_view.rs index b519ef3..75dc995 100644 --- a/src/canvas/mindmap_view.rs +++ b/src/canvas/mindmap_view.rs @@ -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(), diff --git a/src/canvas/nodes.rs b/src/canvas/nodes.rs index 37e6191..72c1347 100644 --- a/src/canvas/nodes.rs +++ b/src/canvas/nodes.rs @@ -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() diff --git a/src/canvas/palette.rs b/src/canvas/palette.rs index afb5def..aa154be 100644 --- a/src/canvas/palette.rs +++ b/src/canvas/palette.rs @@ -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() diff --git a/src/canvas/plane.rs b/src/canvas/plane.rs index db7dba6..b28f0dc 100644 --- a/src/canvas/plane.rs +++ b/src/canvas/plane.rs @@ -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() diff --git a/src/canvas/style.rs b/src/canvas/style.rs index 3d9fb94..42aec9f 100644 --- a/src/canvas/style.rs +++ b/src/canvas/style.rs @@ -23,16 +23,20 @@ pub struct ColorPanel { id: ElementId, color: Hsla, swatches: Vec<(SharedString, Hsla)>, - on_change: Option>, + on_change: OnEdit, } impl ColorPanel { - pub fn new(id: impl Into, color: impl Into) -> Self { + pub fn new( + id: impl Into, + color: impl Into, + 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| { move |color: Hsla, window: &mut Window, cx: &mut App| { @@ -98,32 +95,26 @@ pub struct Shadow { pub struct ShadowEditor { id: ElementId, shadows: Vec, - on_change: Option>>, + on_change: OnEdit>, } impl ShadowEditor { - pub fn new(id: impl Into, shadows: impl IntoIterator) -> Self { + pub fn new( + id: impl Into, + shadows: impl IntoIterator, + on_change: impl Fn(Vec, &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, &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>, + on_change: OnEdit, } impl BorderEditor { - pub fn new(id: impl Into, border: Border) -> Self { + pub fn new( + id: impl Into, + 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]); diff --git a/src/canvas/tests/boards.rs b/src/canvas/tests/boards.rs index 09b284c..552a732 100644 --- a/src/canvas/tests/boards.rs +++ b/src/canvas/tests/boards.rs @@ -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, 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, 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"]); +} diff --git a/src/canvas/tests/mod.rs b/src/canvas/tests/mod.rs index 3447931..f1ff5ba 100644 --- a/src/canvas/tests/mod.rs +++ b/src/canvas/tests/mod.rs @@ -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. diff --git a/src/canvas/tests/panels.rs b/src/canvas/tests/panels.rs index e3639a2..904f58b 100644 --- a/src/canvas/tests/panels.rs +++ b/src/canvas/tests/panels.rs @@ -59,9 +59,10 @@ fn places(_: &Stage, owner: Entity) -> 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) -> AnyElement { scale: 1.0, format: ExportFormat::Png, }], + move |next, _, cx| { + let scales: Vec = 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 = 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() } diff --git a/src/canvas/tests/stale.rs b/src/canvas/tests/stale.rs new file mode 100644 index 0000000..0eb5cf7 --- /dev/null +++ b/src/canvas/tests/stale.rs @@ -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) -> 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); +} diff --git a/src/canvas/tools.rs b/src/canvas/tools.rs index f7e8745..667d2b9 100644 --- a/src/canvas/tools.rs +++ b/src/canvas/tools.rs @@ -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)] } diff --git a/src/canvas/typography.rs b/src/canvas/typography.rs index 0a3d836..2fb2417 100644 --- a/src/canvas/typography.rs +++ b/src/canvas/typography.rs @@ -48,34 +48,29 @@ pub struct TypographyPanel { id: ElementId, style: TypeStyle, search: Entity, - on_change: Option>, + on_change: OnEdit, } impl TypographyPanel { /// `search` is the family field's text, which the owner keeps. - pub fn new(id: impl Into, style: TypeStyle, search: &Entity) -> Self { + pub fn new( + id: impl Into, + style: TypeStyle, + search: &Entity, + 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( diff --git a/src/canvas/whiteboard.rs b/src/canvas/whiteboard.rs index 6445b77..9a67de1 100644 --- a/src/canvas/whiteboard.rs +++ b/src/canvas/whiteboard.rs @@ -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, diff --git a/src/canvas/wiring.rs b/src/canvas/wiring.rs index 7c8a1ea..7d73acd 100644 --- a/src/canvas/wiring.rs +++ b/src/canvas/wiring.rs @@ -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| { diff --git a/tasks/panics.md b/tasks/panics.md index 8caba71..49e8ccf 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -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.