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 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 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`). - 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 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 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. - 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| { change(&added, cx, |mind| {
mind.made += 1; mind.made += 1;
let key = SharedString::from(format!("idea-{}", mind.made)); let key = SharedString::from(format!("idea-{}", mind.made));
mind.root = mind.root.adding(parent, Topic::new(key.clone(), "New idea")); if let Some(root) = mind.root.adding(parent, Topic::new(key.clone(), "New idea")) {
mind.selected = Some(key); mind.root = root;
mind.selected = Some(key);
}
}) })
}) })
.on_remove(move |key, _, cx| { .on_remove(move |key, _, cx| {
change(&removed, cx, |mind| { change(&removed, cx, |mind| {
mind.selected = mind.root.parent_of(key); if let Some(root) = mind.root.removing(key) {
mind.root = mind.root.removing(key); mind.selected = mind.root.parent_of(key);
mind.root = root;
}
}) })
}) })
.on_rename(move |key, text, _, cx| { .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)), .on_viewport(move |next, _, cx| change(&viewed, cx, |mind| mind.view = next)),
cx, cx,
+8 -16
View File
@@ -121,9 +121,8 @@ pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.gap_6() .gap_6()
.child( .child(
div().w(px(260.)).child( div().w(px(260.)).child(
ColorPanel::new("canvas-color", color) 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))))) .swatches(SWATCHES.map(|(name, hex)| (name, Hsla::from(rgb(hex))))),
.on_change(move |next, _, cx| change(&colored, cx, |look| look.color = next)),
), ),
) )
.child( .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())), .on_change(move |next, _, cx| change(&graded, cx, |look| look.stops = next.to_vec())),
) )
.child( .child(
ShadowEditor::new("canvas-shadows", shadows) ShadowEditor::new("canvas-shadows", shadows, move |next, _, cx| change(&shaded, cx, |look| look.shadows = next)),
.on_change(move |next, _, cx| change(&shaded, cx, |look| look.shadows = next)),
), ),
) )
.child( .child(
@@ -148,15 +146,13 @@ pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col() .flex_col()
.gap_6() .gap_6()
.child( .child(
BorderEditor::new("canvas-border", border) BorderEditor::new("canvas-border", border, move |next, _, cx| change(&bordered, cx, |look| look.border = next)),
.on_change(move |next, _, cx| change(&bordered, cx, |look| look.border = next)),
) )
.child(div().p_6().child(card)), .child(div().p_6().child(card)),
) )
.child( .child(
div().w(px(280.)).child( div().w(px(280.)).child(
TypographyPanel::new("canvas-type", style, &search) TypographyPanel::new("canvas-type", style, &search, move |next, _, cx| change(&typed, cx, |look| look.style = next)),
.on_change(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() .flex_col()
.gap_3() .gap_3()
.child( .child(
ConstraintsEditor::new("canvas-constraints", constraints) ConstraintsEditor::new("canvas-constraints", constraints, move |next, _, cx| change(&pinned, cx, |rules| rules.constraints = next)),
.on_change(move |next, _, cx| change(&pinned, cx, |rules| rules.constraints = next)),
) )
.child(pinned_preview), .child(pinned_preview),
) )
@@ -294,8 +289,7 @@ pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col() .flex_col()
.gap_3() .gap_3()
.child( .child(
AutoLayoutControls::new("canvas-auto-layout", layout) AutoLayoutControls::new("canvas-auto-layout", layout, move |next, _, cx| change(&laid, cx, |rules| rules.layout = next)),
.on_change(move |next, _, cx| change(&laid, cx, |rules| rules.layout = next)),
) )
.child(laid_preview), .child(laid_preview),
) )
@@ -306,9 +300,7 @@ pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.flex_col() .flex_col()
.gap_3() .gap_3()
.child( .child(
ExportPanel::new("canvas-export", "Card", exports) 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)),
.on_change(move |next, _, cx| change(&set, cx, |rules| rules.exports = next))
.on_export(move |files, _, cx| change(&sent, cx, |rules| rules.exported = files)),
) )
.when(!exported.is_empty(), |panel| { .when(!exported.is_empty(), |panel| {
panel.child(note(format!("Exported {}", exported.join(", ")).into())) 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()); studio.record(align.words().into());
}) })
}); });
let inspector = let inspector = InspectorPanel::new("canvas-inspector", chosen, move |shape, _, cx| {
InspectorPanel::new("canvas-inspector", chosen).on_change(move |shape, _, cx| { change(&edited, cx, |studio| {
change(&edited, cx, |studio| { let at = studio
let at = studio .shapes
.shapes .iter()
.iter() .position(|each| each.key == shape.key)
.position(|each| each.key == shape.key) .expect("a listed shape");
.expect("a listed shape"); let words = format!("Edit {}", shape.name);
let words = format!("Edit {}", shape.name); studio.shapes[at] = shape;
studio.shapes[at] = shape; studio.record(words);
studio.record(words); })
}) });
});
let assets = AssetPanel::new("canvas-assets") let assets = AssetPanel::new("canvas-assets")
.group("Shapes", shapes()) .group("Shapes", shapes())
.group("Notes", notes()) .group("Notes", notes())
+8 -14
View File
@@ -104,32 +104,26 @@ impl AutoLayout {
pub struct AutoLayoutControls { pub struct AutoLayoutControls {
id: ElementId, id: ElementId,
layout: AutoLayout, layout: AutoLayout,
on_change: Option<OnEdit<AutoLayout>>, on_change: OnEdit<AutoLayout>,
} }
impl AutoLayoutControls { 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 { Self {
id: id.into(), id: id.into(),
layout, 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 { impl RenderOnce for AutoLayoutControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("auto layout controls {:?} has no on_change", self.id));
let (id, layout, on_change) = (self.id, self.layout, &on_change); let (id, layout, on_change) = (self.id, self.layout, &on_change);
let place = Rc::new(editing( let place = Rc::new(editing(
"auto layout", "auto layout",
+8 -14
View File
@@ -102,25 +102,21 @@ impl Constraints {
pub struct ConstraintsEditor { pub struct ConstraintsEditor {
id: ElementId, id: ElementId,
constraints: Constraints, constraints: Constraints,
on_change: Option<OnEdit<Constraints>>, on_change: OnEdit<Constraints>,
} }
impl ConstraintsEditor { 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 { Self {
id: id.into(), id: id.into(),
constraints, 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. /// 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 { impl RenderOnce for ConstraintsEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("constraints editor {:?} has no on_change", self.id));
let (id, now, on_change) = (self.id, self.constraints, &on_change); let (id, now, on_change) = (self.id, self.constraints, &on_change);
let theme = cx.theme(); let theme = cx.theme();
let (lit, rest) = (theme.colors.accent, theme.colors.border); let (lit, rest) = (theme.colors.accent, theme.colors.border);
+8 -26
View File
@@ -99,8 +99,8 @@ pub struct ExportPanel {
id: ElementId, id: ElementId,
name: SharedString, name: SharedString,
settings: Vec<ExportSetting>, settings: Vec<ExportSetting>,
on_change: Option<OnEdit<Vec<ExportSetting>>>, on_change: OnEdit<Vec<ExportSetting>>,
on_export: Option<OnEdit<Vec<String>>>, on_export: OnEdit<Vec<String>>,
} }
impl ExportPanel { impl ExportPanel {
@@ -109,41 +109,23 @@ impl ExportPanel {
id: impl Into<ElementId>, id: impl Into<ElementId>,
name: impl Into<SharedString>, name: impl Into<SharedString>,
settings: impl IntoIterator<Item = ExportSetting>, 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 {
Self { Self {
id: id.into(), id: id.into(),
name: name.into(), name: name.into(),
settings: settings.into_iter().collect(), settings: settings.into_iter().collect(),
on_change: None, on_change: Rc::new(on_change),
on_export: None, 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 { impl RenderOnce for ExportPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change let on_export = self.on_export;
.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 (id, settings, on_change) = (self.id, self.settings, &on_change); let (id, settings, on_change) = (self.id, self.settings, &on_change);
let files = export_files(&self.name, &settings); let files = export_files(&self.name, &settings);
let next = added(&settings); let next = added(&settings);
+9 -6
View File
@@ -168,12 +168,11 @@ pub(super) fn commit(
from, from,
to, to,
} => { } => {
let frame = scene let Some(shape) = scene.shapes.iter().find(|shape| shape.key == key) else {
.shapes log::error!("tool layer: resized shape {key} left the canvas");
.iter() return;
.find(|shape| shape.key == key) };
.expect("the resized shape is listed") let frame = shape.frame;
.frame;
let next = resized(frame, handle, (to.0 - from.0, to.1 - from.1), LEAST, square); let next = resized(frame, handle, (to.0 - from.0, to.1 - from.1), LEAST, square);
log::info!("tool layer: resize {key} to {next:?}"); log::info!("tool layer: resize {key} to {next:?}");
if let Some(on_resize) = &handlers.on_resize { if let Some(on_resize) = &handlers.on_resize {
@@ -191,6 +190,10 @@ pub(super) fn commit(
} }
} }
Gesture::Linking { from, to } => { 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 { let Some(ix) = hit(&scene.shapes, to, REACH / scene.view.zoom) else {
log::info!("tool layer: the link from {from} lands on nothing"); log::info!("tool layer: the link from {from} lands on nothing");
return; return;
+31 -27
View File
@@ -70,18 +70,9 @@ impl Node {
(x, self.at.1 + HEAD + ROW * row as f32 + ROW / 2.0) (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 }; let ports = if out { &self.outputs } else { &self.inputs };
ports ports.iter().position(|each| each.key == port)
.iter()
.position(|each| each.key == port)
.unwrap_or_else(|| {
panic!(
"node {} has no {} {port}",
self.key,
if out { "output" } else { "input" }
)
})
} }
} }
@@ -119,22 +110,26 @@ pub(crate) struct Socket {
pub kind: SharedString, pub kind: SharedString,
} }
fn node<'a>(nodes: &'a [Node], key: &str) -> &'a Node { /// Where a wire starts and ends in canvas units, and the kind it carries.
nodes pub(crate) type Ends = ((f32, f32), (f32, f32), SharedString);
.iter()
.find(|node| node.key == key)
.unwrap_or_else(|| panic!("no node {key} in the graph"))
}
/// An edge's ends in canvas units and the kind it carries. /// An edge's ends; none when a node or port it names left the graph.
pub(crate) fn ends(nodes: &[Node], edge: &Edge) -> ((f32, f32), (f32, f32), SharedString) { pub(crate) fn ends(nodes: &[Node], edge: &Edge) -> Option<Ends> {
let (from, to) = (node(nodes, &edge.from.0), node(nodes, &edge.to.0)); let node = |key: &SharedString| nodes.iter().find(|node| node.key == *key);
let row = from.row(true, &edge.from.1); let ends = node(&edge.from.0)
( .zip(node(&edge.to.0))
from.socket(true, row), .and_then(|(from, to)| {
to.socket(false, to.row(false, &edge.to.1)), let (out, input) = (from.row(true, &edge.from.1)?, to.row(false, &edge.to.1)?);
from.outputs[row].kind.clone(), 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. /// The socket within `reach` of `point`, the topmost node first.
@@ -199,7 +194,8 @@ mod tests {
#[test] #[test]
fn sockets_sit_on_the_sides_a_row_apart() { fn sockets_sit_on_the_sides_a_row_apart() {
let nodes = graph(); 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!(from, (WIDE, HEAD + ROW / 2.0));
assert_eq!(to, (300.0, HEAD + ROW / 2.0)); assert_eq!(to, (300.0, HEAD + ROW / 2.0));
assert_eq!(kind, "image"); assert_eq!(kind, "image");
@@ -210,6 +206,14 @@ mod tests {
("blur", "radius", false) ("blur", "radius", false)
); );
assert_eq!(socket_at(&nodes, (150.0, 40.0), 6.0), None); 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] #[test]
+6 -5
View File
@@ -27,11 +27,12 @@ impl HistoryPanel {
at: usize, at: usize,
) -> Self { ) -> Self {
let steps: Vec<SharedString> = steps.into_iter().map(Into::into).collect(); let steps: Vec<SharedString> = steps.into_iter().map(Into::into).collect();
assert!( if at > steps.len() {
at <= steps.len(), log::error!(
"history at {at} of {} steps", "history panel: at {at} of {} steps; none current",
steps.len() steps.len()
); );
}
Self { Self {
id: id.into(), id: id.into(),
steps, steps,
+8 -11
View File
@@ -25,29 +25,26 @@ const REACH: f64 = 100_000.0;
pub struct InspectorPanel { pub struct InspectorPanel {
id: ElementId, id: ElementId,
selection: Vec<Shape>, selection: Vec<Shape>,
on_change: Option<OnEdit<Shape>>, on_change: OnEdit<Shape>,
} }
impl InspectorPanel { 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 { Self {
id: id.into(), id: id.into(),
selection: selection.into_iter().collect(), 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 { impl RenderOnce for InspectorPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("inspector panel {:?} has no on_change", self.id));
let id = self.id; let id = self.id;
let [shape] = self.selection.as_slice() else { let [shape] = self.selection.as_slice() else {
let title: SharedString = match self.selection.len() { let title: SharedString = match self.selection.len() {
+14 -12
View File
@@ -8,30 +8,30 @@ use super::{
}; };
use crate::lists::{DropAt, Tree, TreeNode}; 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( pub(crate) fn restacked(
order: &[SharedString], order: &[SharedString],
dragged: &SharedString, dragged: &SharedString,
target: &SharedString, target: &SharedString,
at: DropAt, at: DropAt,
) -> Vec<SharedString> { ) -> Option<Vec<SharedString>> {
assert!(order.contains(dragged), "layer {dragged} is not listed");
let mut rest: Vec<SharedString> = order let mut rest: Vec<SharedString> = order
.iter() .iter()
.filter(|key| *key != dragged) .filter(|key| *key != dragged)
.cloned() .cloned()
.collect(); .collect();
let under = rest let under = rest.iter().position(|key| key == target);
.iter() let (true, Some(under)) = (order.contains(dragged), under) else {
.position(|key| key == target) log::error!("layer panel: {dragged} or {target} left the layers; nothing moves");
.unwrap_or_else(|| panic!("layer {dragged} lands against {target}, which is not listed")); return None;
};
let ix = match at { let ix = match at {
DropAt::Before => under + 1, DropAt::Before => under + 1,
DropAt::After => under, DropAt::After => under,
DropAt::Inside => panic!("layer {target} holds no layers"), DropAt::Inside => panic!("layer {target} holds no layers"),
}; };
rest.insert(ix, dragged.clone()); 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. /// 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 { if let Some(on_restack) = self.on_restack {
tree = tree.on_move(move |dragged, target, at, window, cx| { tree = tree.on_move(move |dragged, target, at, window, cx| {
log::info!("layer panel: {dragged} {at:?} {target}"); 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 tree
@@ -160,15 +162,15 @@ mod tests {
let order = keys(&["a", "b", "c"]); let order = keys(&["a", "b", "c"]);
assert_eq!( assert_eq!(
restacked(&order, &"a".into(), &"c".into(), DropAt::Before), restacked(&order, &"a".into(), &"c".into(), DropAt::Before),
keys(&["b", "c", "a"]) Some(keys(&["b", "c", "a"]))
); );
assert_eq!( assert_eq!(
restacked(&order, &"c".into(), &"a".into(), DropAt::After), restacked(&order, &"c".into(), &"a".into(), DropAt::After),
keys(&["c", "a", "b"]) Some(keys(&["c", "a", "b"]))
); );
assert_eq!( assert_eq!(
restacked(&order, &"a".into(), &"b".into(), DropAt::After), restacked(&order, &"a".into(), &"b".into(), DropAt::After),
keys(&["a", "b", "c"]), Some(keys(&["a", "b", "c"])),
"just under b is where a lies" "just under b is where a lies"
); );
} }
+29 -21
View File
@@ -24,37 +24,40 @@ impl Topic {
self self
} }
/// The map with `topic` added last under `parent`. /// The map with `topic` added last under `parent`; none, logged, when the map holds no `parent`.
pub fn adding(&self, parent: &str, topic: Topic) -> Topic { pub fn adding(&self, parent: &str, topic: Topic) -> Option<Topic> {
let mut next = self.clone(); let mut next = self.clone();
let under = next let Some(under) = next.find(parent) else {
.find(parent) log::error!("mind map: no topic {parent} to add under");
.unwrap_or_else(|| panic!("no topic {parent} to add under")); return None;
};
under.children.push(topic); under.children.push(topic);
next Some(next)
} }
/// The map without `key` and what lies under it. /// 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) -> Topic { pub fn removing(&self, key: &str) -> Option<Topic> {
assert!(self.key != key, "the root topic {key} stays");
let mut next = self.clone(); let mut next = self.clone();
let parent = next let Some(parent) = next.parent_of(key) else {
.parent_of(key) log::error!("mind map: no topic {key} below the root to remove");
.unwrap_or_else(|| panic!("no topic {key} to remove")); return None;
};
next.find(&parent) next.find(&parent)
.expect("the parent is in the map") .expect("the parent is in the map")
.children .children
.retain(|topic| topic.key != key); .retain(|topic| topic.key != key);
next Some(next)
} }
/// The map with `key` reading `text`. /// The map with `key` reading `text`; none, logged, when the map holds no `key`.
pub fn renaming(&self, key: &str, text: impl Into<SharedString>) -> Topic { pub fn renaming(&self, key: &str, text: impl Into<SharedString>) -> Option<Topic> {
let mut next = self.clone(); let mut next = self.clone();
next.find(key) let Some(topic) = next.find(key) else {
.unwrap_or_else(|| panic!("no topic {key} to rename")) log::error!("mind map: no topic {key} to rename");
.text = text.into(); return None;
next };
topic.text = text.into();
Some(next)
} }
fn find(&mut self, key: &str) -> Option<&mut Topic> { fn find(&mut self, key: &str) -> Option<&mut Topic> {
@@ -229,10 +232,15 @@ mod tests {
fn edits_add_remove_and_rename_by_key() { fn edits_add_remove_and_rename_by_key() {
let map = map() let map = map()
.adding("b", Topic::new("b1", "Release")) .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[1].children[0].key, "b1");
assert_eq!(map.children[2].text, "Money"); 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!( assert_eq!(
map.children map.children
.iter() .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 focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
let editing = let editing =
window.use_keyed_state((id.clone(), "editing"), cx, |_, _| Editing::default()); window.use_keyed_state((id.clone(), "editing"), cx, |_, _| Editing::default());
let selected = self.selected.clone(); let selected = self.selected.clone().filter(|key| {
if let Some(key) = &selected { let placed = placed.iter().any(|topic| topic.key == *key);
assert!( if !placed {
placed.iter().any(|topic| topic.key == *key), log::error!("mind map {id:?}: no topic {key}; none selected");
"mind map {id:?}: no topic {key} to select" }
); placed
} });
let rename = { let rename = {
let (editing, placed, on_rename, home) = ( let (editing, placed, on_rename, home) = (
editing.clone(), editing.clone(),
+36 -25
View File
@@ -234,17 +234,20 @@ impl RenderOnce for NodeGraph {
let wired = edges let wired = edges
.iter() .iter()
.find(|edge| edge.to == (node_key.clone(), port_key.clone())); .find(|edge| edge.to == (node_key.clone(), port_key.clone()));
match wired { let source = wired.and_then(|edge| {
Some(edge) => { let port = nodes
let source = nodes .iter()
.iter() .find(|node| node.key == edge.from.0)?
.find(|node| node.key == edge.from.0) .outputs
.expect("a wired node"); .iter()
let port = source .find(|port| port.key == edge.from.1);
.outputs if port.is_none() {
.iter() log::error!("node editor: edge {edge:?} starts at a port gone from the graph");
.find(|port| port.key == edge.from.1) }
.expect("a wired port"); Some(edge).zip(port)
});
match source {
Some((edge, port)) => {
Pull::Wiring { Pull::Wiring {
from: Socket { from: Socket {
node: edge.from.0.clone(), node: edge.from.0.clone(),
@@ -330,16 +333,26 @@ impl RenderOnce for NodeGraph {
let (nodes, edges, hue) = (nodes.clone(), edges.clone(), hue.clone()); let (nodes, edges, hue) = (nodes.clone(), edges.clone(), hue.clone());
let pending = match &pull { let pending = match &pull {
Pull::Wiring { from, to, .. } => { Pull::Wiring { from, to, .. } => {
let source = nodes let source = nodes.iter().find(|node| node.key == from.node);
.iter() let wiring = source.and_then(|source| {
.find(|node| node.key == from.node) let row = source
.expect("a wiring node"); .outputs
let row = source .iter()
.outputs .position(|port| port.key == from.port)?;
.iter() Some((
.position(|port| port.key == from.port) source.socket(true, row),
.expect("a wiring port"); *to,
Some((source.socket(true, row), *to, from.kind.clone())) 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, _ => None,
}; };
@@ -353,10 +366,8 @@ impl RenderOnce for NodeGraph {
let drawn = edges let drawn = edges
.iter() .iter()
.filter(|edge| Some(*edge) != lifted.as_ref()) .filter(|edge| Some(*edge) != lifted.as_ref())
.map(|edge| { .filter_map(|edge| ends(&nodes, edge))
let (from, to, kind) = ends(&nodes, edge); .map(|(from, to, kind)| (from, to, hue(&kind)))
(from, to, hue(&kind))
})
.chain( .chain(
pending pending
.iter() .iter()
+7 -6
View File
@@ -66,12 +66,13 @@ impl ToolPalette {
impl RenderOnce for ToolPalette { impl RenderOnce for ToolPalette {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
assert!( if !self.tools.contains(&self.tool) {
self.tools.contains(&self.tool), log::error!(
"tool palette {:?}: {} is not offered", "tool palette {:?}: {} is not offered; none chosen",
self.id, self.id,
self.tool.words() self.tool.words()
); );
}
let on_change = self.on_change; let on_change = self.on_change;
self.tools self.tools
.into_iter() .into_iter()
+7 -8
View File
@@ -167,15 +167,14 @@ impl RenderOnce for InfiniteCanvas {
.links .links
.iter() .iter()
.filter_map(|link| { .filter_map(|link| {
let named = |key: &SharedString| { let named = |key: &SharedString| self.shapes.iter().find(|shape| shape.key == *key);
self.shapes let (Some(from), Some(to)) = (named(&link.from), named(&link.to)) else {
.iter() log::error!(
.find(|shape| shape.key == *key) "canvas: link {} names a shape gone from the canvas",
.unwrap_or_else(|| { link.key
panic!("link {} names {key}, which is not on the canvas", link.key) );
}) return None;
}; };
let (from, to) = (named(&link.from), named(&link.to));
(!from.hidden && !to.hidden).then(|| { (!from.hidden && !to.hidden).then(|| {
let points = route(&from.frame, &to.frame, link.elbow) let points = route(&from.frame, &to.frame, link.elbow)
.into_iter() .into_iter()
+24 -36
View File
@@ -23,16 +23,20 @@ pub struct ColorPanel {
id: ElementId, id: ElementId,
color: Hsla, color: Hsla,
swatches: Vec<(SharedString, Hsla)>, swatches: Vec<(SharedString, Hsla)>,
on_change: Option<OnEdit<Hsla>>, on_change: OnEdit<Hsla>,
} }
impl ColorPanel { 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 { Self {
id: id.into(), id: id.into(),
color: color.into(), color: color.into(),
swatches: Vec::new(), swatches: Vec::new(),
on_change: None, on_change: Rc::new(on_change),
} }
} }
@@ -47,18 +51,11 @@ impl ColorPanel {
.collect(); .collect();
self 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 { impl RenderOnce for ColorPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("color panel {:?} has no on_change", self.id));
let (picked, chosen) = (on_change.clone(), on_change); let (picked, chosen) = (on_change.clone(), on_change);
let report = |on_change: OnEdit<Hsla>| { let report = |on_change: OnEdit<Hsla>| {
move |color: Hsla, window: &mut Window, cx: &mut App| { move |color: Hsla, window: &mut Window, cx: &mut App| {
@@ -98,32 +95,26 @@ pub struct Shadow {
pub struct ShadowEditor { pub struct ShadowEditor {
id: ElementId, id: ElementId,
shadows: Vec<Shadow>, shadows: Vec<Shadow>,
on_change: Option<OnEdit<Vec<Shadow>>>, on_change: OnEdit<Vec<Shadow>>,
} }
impl ShadowEditor { 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 { Self {
id: id.into(), id: id.into(),
shadows: shadows.into_iter().collect(), 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 { impl RenderOnce for ShadowEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("shadow editor {:?} has no on_change", self.id));
let lift = cx let lift = cx
.theme() .theme()
.elevation(Elevation::Floating) .elevation(Elevation::Floating)
@@ -247,31 +238,28 @@ pub struct Border {
pub struct BorderEditor { pub struct BorderEditor {
id: ElementId, id: ElementId,
border: Border, border: Border,
on_change: Option<OnEdit<Border>>, on_change: OnEdit<Border>,
} }
impl BorderEditor { 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 { Self {
id: id.into(), id: id.into(),
border, 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"]; const CORNERS: [&str; 4] = ["TL", "TR", "BR", "BL"];
impl RenderOnce for BorderEditor { impl RenderOnce for BorderEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("border editor {:?} has no on_change", self.id));
let (id, border, on_change) = (self.id, self.border, &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 parted = window.use_keyed_state((id.clone(), "split"), cx, |_, _| false);
let uneven = border.radii.iter().any(|radius| *radius != border.radii[0]); 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::{ use super::{
Stage, said, say, settle, stage, Stage, said, say, settle, stage,
tools::{drag, layer, pair, press}, tools::{at, drag, layer, pair, press},
}; };
use crate::canvas::{ use crate::canvas::{
Board, Edge, Link, MindMap, Node, NodeGraph, Port, Tool, Topic, Viewport, Whiteboard, 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"] ["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 boards;
mod panels; mod panels;
mod stale;
mod tools; mod tools;
/// A view that shows one canvas part and keeps the viewports and words it heard. /// 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), padding: (8.0, 8.0),
place: (Place::Start, Place::Start), place: (Place::Start, Place::Start),
}; };
AutoLayoutControls::new("auto", layout) AutoLayoutControls::new("auto", layout, move |next, _, cx| {
.on_change(move |next, _, cx| say(&owner, format!("{:?}", next.place), cx)) say(&owner, format!("{:?}", next.place), cx)
.into_any_element() })
.into_any_element()
} }
#[gpui::test] #[gpui::test]
@@ -89,12 +90,12 @@ fn sizes(_: &Stage, owner: Entity<Stage>) -> AnyElement {
scale: 1.0, scale: 1.0,
format: ExportFormat::Png, 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() .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 => { Gesture::Stroke { points } if points.len() > 1 => {
vec![stroke_shape(points, self.brush.size, self.brush.hue)] vec![stroke_shape(points, self.brush.size, self.brush.hue)]
} }
Gesture::Linking { from, to } => { Gesture::Linking { from, to } => match shapes.iter().find(|shape| shape.key == *from) {
let start = shapes Some(start) => {
.iter() let start = start.frame.center();
.find(|shape| shape.key == *from) let (kind, frame) =
.expect("a link starts on a listed shape") drawn(ShapeKind::Arrow(Corner::BottomRight), start, *to, false);
.frame vec![Shape::new("+link", "Link", kind, frame)]
.center(); }
let (kind, frame) = drawn(ShapeKind::Arrow(Corner::BottomRight), start, *to, false); None => {
vec![Shape::new("+link", "Link", kind, frame)] log::error!("tool layer: the link's shape {from} left the canvas");
} Vec::new()
}
},
_ if tool == Tool::Pen && pen.len() > 1 => { _ if tool == Tool::Pen && pen.len() > 1 => {
vec![stroke_shape(&pen, PEN, self.brush.hue)] vec![stroke_shape(&pen, PEN, self.brush.hue)]
} }
+9 -14
View File
@@ -48,34 +48,29 @@ pub struct TypographyPanel {
id: ElementId, id: ElementId,
style: TypeStyle, style: TypeStyle,
search: Entity<TextInput>, search: Entity<TextInput>,
on_change: Option<OnEdit<TypeStyle>>, on_change: OnEdit<TypeStyle>,
} }
impl TypographyPanel { impl TypographyPanel {
/// `search` is the family field's text, which the owner keeps. /// `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 { Self {
id: id.into(), id: id.into(),
style, style,
search: search.clone(), 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 { impl RenderOnce for TypographyPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self let on_change = self.on_change;
.on_change
.unwrap_or_else(|| panic!("typography panel {:?} has no on_change", self.id));
let (id, style, on_change) = (self.id, self.style, &on_change); let (id, style, on_change) = (self.id, self.style, &on_change);
let theme = cx.theme(); let theme = cx.theme();
let family = editing( let family = editing(
+10 -10
View File
@@ -195,11 +195,11 @@ impl RenderOnce for Whiteboard {
let key = key.clone(); let key = key.clone();
sized( sized(
&|board, _| { &|board, _| {
let shape = board let Some(shape) = board.shapes.iter_mut().find(|shape| shape.key == key)
.shapes else {
.iter_mut() log::error!("whiteboard: resized shape {key} left the board");
.find(|shape| shape.key == key) return;
.expect("a resized shape is on the board"); };
shape.frame = frame; shape.frame = frame;
}, },
window, window,
@@ -235,11 +235,11 @@ impl RenderOnce for Whiteboard {
let (key, text) = (key.clone(), text.clone()); let (key, text) = (key.clone(), text.clone());
written( written(
&|board, _| { &|board, _| {
let shape = board let Some(shape) = board.shapes.iter_mut().find(|shape| shape.key == key)
.shapes else {
.iter_mut() log::error!("whiteboard: written shape {key} left the board");
.find(|shape| shape.key == key) return;
.expect("a written shape is on the board"); };
*shape = shape.written(text.clone()); *shape = shape.written(text.clone());
}, },
window, window,
+17
View File
@@ -66,6 +66,23 @@ impl Ending {
} }
} }
Pull::Wiring { from, lifted, to } => { 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 = let target =
socket_at(&self.nodes, to, REACH / self.zoom).filter(|to| fits(&from, to)); socket_at(&self.nodes, to, REACH / self.zoom).filter(|to| fits(&from, to));
let made = target.map(|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] P29 Mail — `src/mail`
- [x] P30 Calendar — `src/calendar` - [x] P30 Calendar — `src/calendar`
- [x] P31 Project — `src/project` - [x] P31 Project — `src/project`
- [ ] P32 Canvas & Design — `src/canvas` - [x] P32 Canvas & Design — `src/canvas`
- [ ] P33 DB & Dev Tools — `src/devtools` - [ ] P33 DB & Dev Tools — `src/devtools`
- [ ] P34 Dashboard — `src/dashboard` - [ ] P34 Dashboard — `src/dashboard`
- [ ] P35 Settings — `src/settings` - [ ] 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. - 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. - 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. - 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.