From 23c54a666368185f82e540d893865a38401902fd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 14:14:35 -0400 Subject: [PATCH] M4: components depth: component properties (edit/delete/bind), add variant, reset override, restore main, instance structure protection Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/component.rs | 979 ++++++++++++++++++++++---- crates/engine/src/components.rs | 780 ++++++++++++++------ crates/engine/src/components_tests.rs | 543 ++++++++++++++ crates/engine/src/lib.rs | 12 +- 4 files changed, 1976 insertions(+), 338 deletions(-) create mode 100644 crates/engine/src/components_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 9a45549..ae07099 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -1,4 +1,6 @@ -//! Component commands. +//! Component commands: components, variants, component properties and instances. + +use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use serde_json::{Value, json}; @@ -29,13 +31,22 @@ pub fn specs() -> Vec { Ok(json!({"ids": out})) }), spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine), - spec!("instance.create", "Create instance", "{component, x?, y?, parent?}", true, |s, p| { - let c = req_id("instance.create", p, "component")?; + spec!( + "component.addVariant", + "Add variant", + "{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", + true, + add_variant + ), + spec!("instance.create", "Create instance", "{component (component or set: its default variant), x?, y?, parent?}", true, |s, p| { + let cmd = "instance.create"; + let c = req_id(cmd, p, "component")?; let page = s.page()?; let parent = id(p, "parent").unwrap_or(page); let doc = s.doc_mut()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { - (Some(x), Some(y)) => (x, y), + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => (x, y), _ => doc.world_bounds(c).map(|b| (b.x1 + 40.0, b.y0)).unwrap_or((0.0, 0.0)), }; let i = create_instance(doc, c, parent, x, y)?; @@ -43,28 +54,24 @@ pub fn specs() -> Vec { Ok(json!({"id": i})) }), spec!("component.detach", "Detach instance", "{ids?}", true, detach), - spec!("component.resetInstance", "Reset all changes", "{ids?}", true, |s, p| { - for i in req_targets("component.resetInstance", s, p)? { - let doc = s.doc_mut()?; - let inst = owning_instance(doc, i).unwrap_or(i); - for d in doc.subtree(inst) { - if let Some(n) = doc.node_mut(d) { - n.overrides.clear(); - } - } - sync_instance(doc, inst); - } - ok() - }), + spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance), + spec!( + "component.resetOverride", + "Reset override", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props; all when omitted)}", + true, + reset_override + ), spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| { - let i = targets(s, p).first().copied().ok_or_else(|| bad("component.goToMain", "select an instance"))?; + let cmd = "component.goToMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; let doc = s.doc()?; - let inst = owning_instance(doc, i).ok_or_else(|| bad("component.goToMain", "not an instance"))?; + let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { - return Err(bad("component.goToMain", "not an instance")); + return Err(bad(cmd, "not an instance")); }; if !doc.contains(component) { - return Err(EngineError::Other("the main component was deleted".into())); + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); } super::edit::select_and_page(s, vec![component])?; let ds = s.ds_mut()?; @@ -74,18 +81,14 @@ pub fn specs() -> Vec { Ok(json!({"id": component})) }), spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes), - spec!("component.restoreMain", "Restore main component", "{id?}", true, |_s, _p| { - Err(EngineError::Disabled("component.restoreMain".into(), "the main component still exists".into())) - }), - spec!("instance.swap", "Swap instance", "{ids?, component}", true, |s, p| { - let c = req_id("instance.swap", p, "component")?; - for i in req_targets("instance.swap", s, p)? { - if let NodeKind::Instance { component, .. } = &mut s.doc_mut()?.get_mut(i)?.kind { - *component = c; - } - } - ok() - }), + spec!( + "component.restoreMain", + "Restore main component", + "{id?} (an instance whose main was deleted becomes the main again, in its place)", + true, + restore_main + ), + spec!("instance.swap", "Swap instance", "{ids?, component}", true, swap), spec!( "instance.setProperties", "Set instance properties", @@ -96,10 +99,29 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component or set), name, type: boolean|text|instanceSwap|variant, default, layer? (node bound to it)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant, default?, layer? (node bound to it)}", true, add_prop ), + spec!( + "component.editProperty", + "Edit component property", + "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value)}", + true, + edit_prop + ), + spec!("component.deleteProperty", "Delete component property", "{id? (component or set), name}", true, delete_prop), + spec!( + "component.bindProperty", + "Bind layer to property", + "{layer?, property (null/\"\" unbinds), field?: visible|characters|mainComponent}", + true, + bind_prop + ), + spec!("component.properties", "Component properties", "{id? (component, set or instance)}", false, |s, p| { + let i = targets(s, p).first().copied().ok_or_else(|| bad("component.properties", "select a component or instance"))?; + Ok(describe_props(s.doc()?, i)) + }), spec!("component.list", "List components", "{}", false, |s, _| { let doc = s.doc()?; Ok(Value::Array( @@ -112,12 +134,28 @@ pub fn specs() -> Vec { ] } +/// A component to instantiate: the component itself, or a set's default (first) variant. +fn main_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + match n.kind { + NodeKind::Component { .. } => Some(id), + NodeKind::ComponentSet { .. } => { + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))) + } + _ => None, + } +} + fn create(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("component.create", s, p)?; + let cmd = "component.create"; + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; + if ids.iter().any(|i| inside_instance(doc, *i)) { + return Err(EngineError::Disabled(cmd.into(), "layers inside an instance can't become components".into())); + } let ids = z_sorted(doc, &roots(doc, &ids)); let target = if ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame | NodeKind::Group)) { - ids.first().copied().ok_or_else(|| bad("component.create", "nothing selected"))? + ids.first().copied().ok_or_else(|| bad(cmd, "nothing selected"))? } else { // Wrap the layers in a frame first (like Figma). let r = super::structure::specs() @@ -140,37 +178,48 @@ fn create(s: &mut Session, p: &Value) -> Result { n.clip = false; n.blend = figcraft_color::BlendMode::PassThrough; } - if ids.len() == 1 && was_group { - // Keep the group's name; frames keep theirs too. - } s.set_selection(vec![target]); Ok(json!({"id": target})) } -fn combine(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("component.combineVariants", s, p)?; - let doc = s.doc_mut()?; - if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - return Err(bad("component.combineVariants", "select components only")); +/// Wrap components in a new component set (named after the first) placed around them. +fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { + let cmd = "component.combineVariants"; + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; + if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { + return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into())); } - let ids = z_sorted(doc, &ids); - let top = *ids.last().ok_or_else(|| bad("component.combineVariants", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("component.combineVariants", "page"))?; - let bounds = doc.bounds_of(&ids).unwrap_or_default(); + let bounds = doc.bounds_of(ids).unwrap_or_default(); let pw = doc.world_transform(parent); let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); let base = ids .first() .and_then(|i| doc.node(*i)) - .map(|n| n.name.split('/').next().unwrap_or("Component").trim().to_string()) + .map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string()) + .filter(|n| !n.is_empty() && !n.contains(',')) .unwrap_or_else(|| "Component".into()); let set = doc.alloc_id(); let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); n.fills.clear(); doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; - for (k, i) in ids.iter().enumerate() { + for i in ids { doc.reparent(*i, set, None)?; + } + Ok(set) +} + +fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { + return Err(bad(cmd, "select components only")); + } + let ids = z_sorted(doc, &ids); + let set = wrap_in_set(doc, &ids)?; + for (k, i) in ids.iter().enumerate() { // Name variants "Property 1=Variant N" unless already in prop=value form. let node = doc.get_mut(*i)?; if !node.name.contains('=') { @@ -181,157 +230,833 @@ fn combine(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": set})) } +/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). +fn fit_set(doc: &mut Document, set: NodeId) { + let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; + let (mut w, mut h) = (0.0f64, 0.0f64); + for c in kids { + if let Some(n) = doc.node(c) { + let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); + w = w.max(b.x1 + 20.0); + h = h.max(b.y1 + 20.0); + } + } + if let Some(n) = doc.node_mut(set) { + n.width = n.width.max(w); + n.height = n.height.max(h); + } +} + +fn add_variant(s: &mut Session, p: &Value) -> Result { + let cmd = "component.addVariant"; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?; + let doc = s.doc_mut()?; + let (set, base) = match doc.get(t)?.kind { + NodeKind::ComponentSet { .. } => { + let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; + (t, last) + } + NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t), + _ => return Err(bad(cmd, "select a component set or variant")), + }; + let options = variant_options(doc, set); + let mut pairs = variant_props(&doc.get(base)?.name); + let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); + let existing: BTreeSet> = + doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); + match (string(p, "property"), string(p, "value")) { + (Some(prop), Some(value)) => { + let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); + if !options.iter().any(|(k, _)| *k == prop) { + return Err(bad(cmd, format!("unknown variant property `{prop}`"))); + } + if value.is_empty() || value.contains([',', '=']) { + return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); + } + pairs.insert(prop, value); + if existing.contains(&pairs) { + return Err(bad(cmd, "that variant already exists")); + } + } + (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), + _ => { + let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); + let mut k = existing.len() + 1; + loop { + pairs.insert(prop.clone(), format!("Variant{k}")); + if !existing.contains(&pairs) || k > 10_000 { + break; + } + k += 1; + } + } + } + let name = variant_name(&pairs, &order); + let bottom = doc + .get(set)? + .children + .iter() + .filter_map(|c| doc.node(*c)) + .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) + .fold(0.0f64, f64::max); + let dup = doc.duplicate_into(base, set, None)?; + let n = doc.get_mut(dup)?; + n.name = name; + let c = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); + fit_set(doc, set); + s.set_selection(vec![dup]); + Ok(json!({"id": dup})) +} + fn detach(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("component.detach", s, p)?; + let cmd = "component.detach"; + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; let mut out = Vec::new(); for i in ids { - let inst = if matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { i } else { continue }; - for d in doc.subtree(inst) { - if let Some(n) = doc.node_mut(d) { + if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { + continue; + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); + } + // Own layers lose their link; nested instances stay instances of their own mains. + let mut nested = Vec::new(); + for d in doc.subtree(i).into_iter().filter(|d| *d != i) { + let parent_inst = doc.node(d).and_then(|n| n.parent).and_then(|p| owning_instance(doc, p)); + if parent_inst != Some(i) { + continue; + } + if matches!(doc.node(d).map(|n| &n.kind), Some(NodeKind::Instance { .. })) { + nested.push(d); + } else if let Some(n) = doc.node_mut(d) { n.source = None; n.overrides.clear(); + n.prop_refs.clear(); } } - let n = doc.get_mut(inst)?; + let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; - out.push(inst); + n.source = None; + n.overrides.clear(); + n.prop_refs.remove("mainComponent"); + for nn in nested { + if let Some(m) = doc.node_mut(nn) { + m.prop_refs.clear(); + } + rebase_instance(doc, nn); + } + out.push(i); + } + if out.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); } s.set_selection(out.clone()); Ok(json!({"ids": out})) } -fn push_changes(s: &mut Session, p: &Value) -> Result { - let i = targets(s, p).first().copied().ok_or_else(|| bad("component.pushChanges", "select an instance"))?; +fn reset_instance(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetInstance"; + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; - let inst = owning_instance(doc, i).ok_or_else(|| bad("component.pushChanges", "not an instance"))?; + let mut done = Vec::new(); + for i in ids { + let Some(inst) = owning_instance(doc, i) else { continue }; + let top = !inside_instance(doc, inst); + for d in doc.subtree(inst) { + let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; + if let Some(n) = doc.node(d) + && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) + && let Some(n) = doc.node_mut(d) + { + n.overrides.retain(|g| keep.contains(&g.as_str())); + } + } + // Property values go back to the defaults. + if top + && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind + { + *props = defaults; + } + done.push(inst); + } + if done.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + Ok(json!({"ids": done})) +} + +fn reset_override(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetOverride"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; + let group = string(p, "group"); + if let Some(g) = &group + && !OVERRIDE_GROUPS.contains(&g.as_str()) + { + return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); + } + let doc = s.doc_mut()?; + if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { + return Err(bad(cmd, "not a layer of an instance")); + } + let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); + let had = doc.get(i)?.overrides.clone(); + let n = doc.get_mut(i)?; + match &group { + Some(g) => { + n.overrides.remove(g); + } + None => n.overrides.clear(), + } + let mut changed = had != doc.get(i)?.overrides; + // The instance's own property values reset with `props`. + if top_root + && group.as_deref().is_none_or(|g| g == "props") + && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind + { + *props = defaults; + changed = true; + } + Ok(json!({"changed": changed})) +} + +fn push_changes(s: &mut Session, p: &Value) -> Result { + let cmd = "component.pushChanges"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } // Copy every overridden layer's values to its source in the main. + let mut pushed = 0; for d in doc.subtree(inst) { let Some(n) = doc.node(d).cloned() else { continue }; let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; - if n.overrides.is_empty() || !doc.contains(src) { + if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { continue; } let Some(mut target) = doc.node(src).cloned() else { continue }; + let mut groups = BTreeSet::new(); for g in &n.overrides { - match g.as_str() { - "fills" => target.fills = n.fills.clone(), + let done = match g.as_str() { + "fills" => { + target.fills = n.fills.clone(); + target.styles.fill = n.styles.fill.clone(); + true + } "strokes" => { target.strokes = n.strokes.clone(); target.stroke = n.stroke.clone(); + target.styles.stroke = n.styles.stroke.clone(); + true + } + "effects" => { + target.effects = n.effects.clone(); + target.styles.effect = n.styles.effect.clone(); + true + } + "opacity" => { + target.opacity = n.opacity; + target.blend = n.blend; + true + } + "visible" => { + target.visible = n.visible; + true + } + "name" if d != inst => { + target.name = n.name.clone(); + true + } + "corners" => { + target.corners = n.corners; + true } - "effects" => target.effects = n.effects.clone(), - "opacity" => target.opacity = n.opacity, - "visible" => target.visible = n.visible, - "corners" => target.corners = n.corners, "characters" | "textStyle" => { if let (NodeKind::Text(a), NodeKind::Text(b)) = (&n.kind, &mut target.kind) { *b = a.clone(); } + true } - _ => {} + _ => false, + }; + if done { + groups.insert(g.clone()); } } + if groups.is_empty() { + continue; + } + pushed += 1; if let Some(slot) = doc.node_mut(src) { *slot = target; } if let Some(m) = doc.node_mut(d) { - m.overrides.clear(); + m.overrides.retain(|g| !groups.contains(g)); + } + } + Ok(json!({"layers": pushed})) +} + +fn restore_main(s: &mut Session, p: &Value) -> Result { + let cmd = "component.restoreMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "select an instance")); + }; + if doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); + } + let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; + let index = doc.index_in_parent(i); + // The main comes back with its old id (other instances point to it) and its layers with the + // ids their copies mirror, so every instance re-links by source. + let sub = doc.subtree(i); + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + let mut nodes = Vec::new(); + let mut nested = Vec::new(); + for d in &sub { + let Some(mut n) = doc.node(*d).cloned() else { continue }; + if *d == i { + let mut defs = BTreeMap::new(); + for (k, v) in &props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + n.kind = NodeKind::Component { description: String::new(), props: defs }; + n.source = None; + n.overrides.clear(); + n.prop_refs.clear(); + } else { + let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); + if parent_inst != Some(i) { + nodes.push(n); + continue; + } + if matches!(n.kind, NodeKind::Instance { .. }) { + nested.push(*d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nodes.push(n); + } + doc.remove(i)?; + doc.paste_nodes(&nodes, &map, i, parent, index)?; + for nn in nested { + if let Some(m) = map.get(&nn).copied() { + rebase_instance(doc, m); + } + } + s.set_selection(vec![component]); + Ok(json!({"id": component})) +} + +fn swap(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swap"; + let c = req_id(cmd, p, "component")?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let defaults = doc.node(c).map(default_props).unwrap_or_default(); + for i in ids { + if doc.is_ancestor(c, i) { + return Err(bad(cmd, "an instance can't show the component it is inside")); + } + if let NodeKind::Instance { component, props } = &mut doc.get_mut(i)?.kind { + *component = c; + let old = std::mem::take(props); + *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); + } else { + return Err(bad(cmd, format!("{i} is not an instance"))); } } ok() } +/// Coerce a property value to the definition's type. +fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { + match def { + PropDef::Boolean { .. } => match v { + Value::Bool(b) => Ok(json!(b)), + Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), + _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), + }, + PropDef::Text { .. } => match v { + Value::String(t) => Ok(json!(t)), + Value::Number(n) => Ok(json!(n.to_string())), + _ => Err(bad(cmd, format!("`{name}` is a text property"))), + }, + PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { + Some(c) => Ok(json!(c.0)), + None => Err(bad(cmd, format!("`{name}` takes a component id"))), + }, + PropDef::Variant { options, .. } => { + let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } + } + } +} + fn set_props(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("instance.setProperties", s, p)?; - let props = - get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad("instance.setProperties", "`properties` object required"))?; + let cmd = "instance.setProperties"; + let ids = req_targets(cmd, s, p)?; + let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; let doc = s.doc_mut()?; for i in ids { - let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { continue }; + let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, format!("{i} is not an instance"))); + }; // Variant properties: switch to the matching sibling variant. - let set = - doc.node(component).and_then(|c| c.parent).filter(|pp| doc.node(*pp).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))); + let set = variant_set(doc, component); + let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut variant_change = false; + let mut target = component; + let mut rest = Vec::new(); for (k, v) in &props { - if wanted.contains_key(k) { - wanted.insert(k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())); - variant_change = true; + if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { + let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if !vals.contains(&val) { + return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); + } + wanted.insert(k.clone(), val); + } else { + rest.push((k.clone(), v.clone())); } } - let n = doc.get_mut(i)?; - if let NodeKind::Instance { component: c, props: ip } = &mut n.kind { - for (k, v) in &props { - ip.insert(k.clone(), v.clone()); - } - let _ = c; - } - if variant_change - && let Some(set) = set - && let Some(v) = find_variant(doc, set, &wanted) - && let NodeKind::Instance { component: c, .. } = &mut doc.get_mut(i)?.kind + if let Some(set) = set + && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() { - *c = v; + target = find_variant(doc, set, &wanted).unwrap_or(component); + } + let defs = match doc.node(target).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + }; + let mut values = Vec::new(); + for (k, v) in rest { + let def = defs.get(&k).ok_or_else(|| { + let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); + bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) + })?; + values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); + } + if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { + *c = target; + for (k, v) in values { + ip.insert(k, v); + } + for (k, d) in &defs { + ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); + } } } ok() } +/// Where component properties live: a set (its variants share them) or a lone component. +enum Owner { + Set(NodeId), + Comp(NodeId), +} + +impl Owner { + fn resolve(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + match n.kind { + NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), + _ => { + let c = owning_component(doc, id)?; + Some(match variant_set(doc, c) { + Some(s) => Owner::Set(s), + None => Owner::Comp(c), + }) + } + } + } + + fn components(&self, doc: &Document) -> Vec { + match self { + Owner::Comp(c) => vec![*c], + Owner::Set(s) => doc + .node(*s) + .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) + .unwrap_or_default(), + } + } + + fn id(&self) -> NodeId { + match self { + Owner::Set(s) | Owner::Comp(s) => *s, + } + } +} + +fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { + match doc.node(c).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + } +} + +fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { + if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { + f(props); + } + Ok(()) +} + +fn valid_prop_name(cmd: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { + return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); + } + Ok(name.to_string()) +} + fn add_prop(s: &mut Session, p: &Value) -> Result { let cmd = "component.addProperty"; let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; + let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; let t = req_string(cmd, p, "type")?; + let doc = s.doc_mut()?; + if inside_instance(doc, target) { + return Err(bad(cmd, "select a component, not a layer of an instance")); + } + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; + // A layer selected inside the component gets bound to the new property. + let layer = id(p, "layer") + .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); + let comps = owner.components(doc); + let variants = match &owner { + Owner::Set(s) => variant_options(doc, *s), + Owner::Comp(_) => Vec::new(), + }; + if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { + return Err(bad(cmd, format!("a property named `{name}` already exists"))); + } + let layer_node = layer.and_then(|l| doc.node(l)).cloned(); let def = match t.to_ascii_lowercase().as_str() { "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, - "text" => PropDef::Text { default: string(p, "default").unwrap_or_default() }, + "text" => PropDef::Text { + default: string(p, "default") + .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) + .unwrap_or_else(|| name.clone()), + }, "instanceswap" | "instance_swap" | "instance" => { - PropDef::InstanceSwap { default: id(p, "default").ok_or_else(|| bad(cmd, "default: component id"))? } + let d = id(p, "default") + .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + }) + .and_then(|c| main_of(doc, c)) + .ok_or_else(|| bad(cmd, "default: a component id"))?; + PropDef::InstanceSwap { default: d } } - "variant" => PropDef::Variant { options: vec![], default: string(p, "default").unwrap_or_default() }, - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")), - }; - let layer = id(p, "layer"); - let doc = s.doc_mut()?; - let n = doc.get_mut(target)?; - match &mut n.kind { - NodeKind::Component { props, .. } => { - props.insert(name.clone(), def.clone()); - } - NodeKind::ComponentSet { .. } => { - // Variant property on a set: rename every variant with the new property. - let kids = n.children.clone(); - let dv = string(p, "default").unwrap_or_else(|| "Default".into()); - for c in kids { + "variant" => { + let dv = string(p, "default") + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty() && !d.contains([',', '='])) + .unwrap_or_else(|| "Default".into()); + let set = match owner { + Owner::Set(s) => s, + Owner::Comp(c) => wrap_in_set(doc, &[c])?, + }; + for c in Owner::Set(set).components(doc) { let cn = doc.get_mut(c)?; cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; } - return ok(); + s.set_selection(vec![set]); + return Ok(json!({"id": set})); + } + _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")), + }; + for c in &comps { + set_prop_defs(doc, *c, |m| { + m.insert(name.clone(), def.clone()); + })?; + // Existing instances get the default. + for inst in instances_of(doc, *c) { + if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { + props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); + } } - _ => return Err(bad(cmd, "select a component or component set")), } if let Some(l) = layer { - let field = match def { - PropDef::Boolean { .. } => "visible", - PropDef::Text { .. } => "characters", - PropDef::InstanceSwap { .. } => "mainComponent", - PropDef::Variant { .. } => "variant", - }; - doc.get_mut(l)?.prop_refs.insert(field.into(), name.clone()); + bind(cmd, doc, l, Some(&name), None)?; } - // Existing instances get the default. - for inst in instances_of(doc, target) { - if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { - let v = match &def { - PropDef::Boolean { default } => json!(default), - PropDef::Text { default } => json!(default), - PropDef::InstanceSwap { default } => json!(default.0), - PropDef::Variant { default, .. } => json!(default), - }; - props.entry(name.clone()).or_insert(v); + Ok(json!({"id": owner.id(), "name": name})) +} + +/// Bind (or with `property: None` unbind) a layer's field to a property of its component. +fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { + let n = doc.get(layer)?.clone(); + if inside_instance(doc, layer) { + return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); + } + let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; + let Some(property) = property.filter(|p| !p.is_empty()) else { + let n = doc.get_mut(layer)?; + match field { + Some(f) => { + n.prop_refs.remove(f); + } + None => n.prop_refs.clear(), + } + return Ok(()); + }; + let defs = prop_defs(doc, comp); + let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; + let expected = match def { + PropDef::Boolean { .. } => "visible", + PropDef::Text { .. } => "characters", + PropDef::InstanceSwap { .. } => "mainComponent", + PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), + }; + let field = field.unwrap_or(expected); + if field != expected { + return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); + } + match field { + "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), + "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), + "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), + _ => {} + } + doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); + Ok(()) +} + +fn bind_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.bindProperty"; + let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; + let property = string(p, "property"); + let field = string(p, "field"); + if let Some(f) = &field + && !matches!(f.as_str(), "visible" | "characters" | "mainComponent") + { + return Err(bad(cmd, "field: visible|characters|mainComponent")); + } + let doc = s.doc_mut()?; + bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; + ok() +} + +/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. +fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { + for d in doc.subtree(comp) { + let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); + if refs.values().any(|v| v == old) + && let Some(n) = doc.node_mut(d) + { + match new { + Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), + None => n.prop_refs.retain(|_, v| v != old), + } + } + } + for inst in instances_of(doc, comp) { + if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind + && let Some(v) = props.remove(old) + && let Some(nw) = new + { + props.insert(nw.to_string(), v); + } + } + Ok(()) +} + +fn edit_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.editProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Some(nn) = &new_name { + let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) + || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); + if taken { + return Err(bad(cmd, format!("a property named `{nn}` already exists"))); + } + } + // Variant property of a set. + if let Owner::Set(set) = owner + && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + let rename_value = match (string(p, "value"), string(p, "newValue")) { + (Some(v), Some(nv)) => { + let nv = nv.trim().to_string(); + if !values.contains(&v) { + return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); + } + if nv.is_empty() || nv.contains([',', '=']) { + return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); + } + Some((v, nv)) + } + _ => None, + }; + let order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + if let Some((v, nv)) = &rename_value + && pairs.get(&name) == Some(v) + { + pairs.insert(name.clone(), nv.clone()); + } + if let Some(nn) = &new_name + && let Some(v) = pairs.remove(&name) + { + pairs.insert(nn.clone(), v); + } + doc.get_mut(*c)?.name = variant_name(&pairs, &order); + } + // The default variant is the first one: move the first matching variant there. + if let Some(dv) = string(p, "default") { + let key = new_name.clone().unwrap_or(name.clone()); + let first = comps.iter().copied().find(|c| doc.node(*c).is_some_and(|n| variant_props(&n.name).get(&key) == Some(&dv))); + match first { + Some(f) => doc.reorder(f, 0)?, + None => return Err(bad(cmd, format!("no variant has {key}={dv}"))), + } + } + return ok(); + } + let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; + let new_def = match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + for c in &comps { + let had = prop_defs(doc, *c).contains_key(&name); + if !had { + continue; + } + let key = new_name.clone().unwrap_or(name.clone()); + let nd = new_def.clone(); + let old = name.clone(); + set_prop_defs(doc, *c, move |m| { + m.remove(&old); + m.insert(key, nd); + })?; + if let Some(nn) = &new_name { + rename_prop_refs(doc, *c, &name, Some(nn))?; } } ok() } + +fn delete_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.deleteProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Owner::Set(set) = owner + && variant_options(doc, set).iter().any(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + pairs.remove(&name); + let n = doc.get_mut(*c)?; + n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; + } + return ok(); + } + let mut found = false; + for c in &comps { + if prop_defs(doc, *c).contains_key(&name) { + found = true; + set_prop_defs(doc, *c, |m| { + m.remove(&name); + })?; + rename_prop_refs(doc, *c, &name, None)?; + } + } + if !found { + return Err(bad(cmd, format!("no property named `{name}`"))); + } + ok() +} + +fn def_json(name: &str, d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), + PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), + PropDef::InstanceSwap { default } => json!({"name": name, "type": "instanceSwap", "default": default}), + PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + } +} + +/// Properties of a component, set or instance as JSON (`component.properties`). +fn describe_props(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let (owner_comp, values) = match &n.kind { + NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), + _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), + }; + let Some(comp) = owner_comp else { return json!({"properties": []}) }; + let mut list: Vec = Vec::new(); + if let Some(set) = variant_set(doc, comp) { + let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); + for (k, vals) in variant_options(doc, set) { + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": vals.first()}); + if values.is_some() { + v["value"] = json!(current.get(&k)); + } + list.push(v); + } + } + for (k, d) in prop_defs(doc, comp) { + let mut v = def_json(&k, &d); + if let Some(vals) = &values { + v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); + } + list.push(v); + } + json!({"component": comp, "set": variant_set(doc, comp), "properties": list}) +} diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index a9b3259..f4d5035 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -3,9 +3,17 @@ //! An instance's layers are real nodes copied from its main component; each copy records the //! main-component node it mirrors (`source`) and which property groups were changed locally //! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer -//! ids and re-applying overrides. Variants are components inside a component set, named -//! `Property=Value, …`; component properties (boolean, text, instance swap, variant) are applied -//! through `prop_refs` on the layers they control. +//! ids and re-applying overrides (matched by source, then by layer name path, so switching +//! variants keeps compatible overrides). Variants are components inside a component set, named +//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through +//! `prop_refs` on the layers they control. +//! +//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, +//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance) and +//! `props` (property values of a nested instance). +//! +//! Instances never gain or lose layers of their own: commands that would add, remove or reorder +//! layers inside an instance are refused ([`check_structure_edit`]). use std::collections::{BTreeMap, BTreeSet}; @@ -13,6 +21,16 @@ use figcraft_doc::*; use figcraft_geom::Affine; use serde_json::Value; +/// Nesting limit for instances inside instances. +const MAX_DEPTH: usize = 16; +/// Every override group a layer can carry. +pub const OVERRIDE_GROUPS: &[&str] = + &["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position", "component", "props"]; + +fn is_instance(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) +} + /// The component a node belongs to (itself, or the nearest component ancestor). pub fn owning_component(doc: &Document, id: NodeId) -> Option { let mut chain = vec![id]; @@ -20,11 +38,23 @@ pub fn owning_component(doc: &Document, id: NodeId) -> Option { chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) } -/// The instance root containing `id` (excluding `id` itself unless it is an instance). +/// The innermost instance containing `id` (`id` itself when it is an instance). pub fn owning_instance(doc: &Document, id: NodeId) -> Option { let mut chain = vec![id]; chain.extend(doc.ancestors(id)); - chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) + chain.into_iter().find(|c| is_instance(doc, *c)) +} + +/// The outermost instance containing `id` (`id` itself when it is a top-level instance). +pub fn root_instance(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + chain.into_iter().rfind(|c| is_instance(doc, *c)) +} + +/// Whether `id` is a layer inside an instance (not the outermost instance itself). +pub fn inside_instance(doc: &Document, id: NodeId) -> bool { + doc.node(id).and_then(|n| n.parent).is_some_and(|p| owning_instance(doc, p).is_some()) } /// All instances of `component` in the document. @@ -32,15 +62,51 @@ pub fn instances_of(doc: &Document, component: NodeId) -> Vec { doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect() } +/// The component set a variant belongs to. +pub fn variant_set(doc: &Document, component: NodeId) -> Option { + doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +/// The layer name a fresh instance of `component` gets: the set's name for variants. +pub fn default_instance_name(doc: &Document, component: NodeId) -> String { + let set = variant_set(doc, component).and_then(|s| doc.node(s)); + set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into()) +} + +/// Default property values of a component. +pub fn default_props(comp: &Node) -> BTreeMap { + let mut out = BTreeMap::new(); + if let NodeKind::Component { props, .. } = &comp.kind { + for (k, d) in props { + out.insert(k.clone(), prop_default_value(d)); + } + } + out +} + +/// A property definition's default as a JSON value. +pub fn prop_default_value(d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => Value::Bool(*default), + PropDef::Text { default } => Value::String(default.clone()), + PropDef::InstanceSwap { default } => Value::from(default.0), + PropDef::Variant { default, .. } => Value::String(default.clone()), + } +} + /// Create an instance of `component` under `parent` at `(x, y)` (parent space). pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { let comp = doc.get(component)?.clone(); if !matches!(comp.kind, NodeKind::Component { .. }) { return Err(DocError::Invalid(format!("{component} is not a component"))); } + if doc.is_ancestor(component, parent) { + return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + } let id = doc.alloc_id(); let mut root = comp.clone(); root.id = id; + root.name = default_instance_name(doc, component); root.kind = NodeKind::Instance { component, props: default_props(&comp) }; root.children.clear(); root.parent = None; @@ -53,32 +119,41 @@ pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: Ok(id) } -fn default_props(comp: &Node) -> BTreeMap { - let mut out = BTreeMap::new(); - if let NodeKind::Component { props, .. } = &comp.kind { - for (k, d) in props { - let v = match d { - PropDef::Boolean { default } => Value::Bool(*default), - PropDef::Text { default } => Value::String(default.clone()), - PropDef::InstanceSwap { default } => Value::from(default.0), - PropDef::Variant { default, .. } => Value::String(default.clone()), - }; - out.insert(k.clone(), v); - } - } - out +/// Which part of the carried overrides to apply. +#[derive(Clone, Copy, PartialEq)] +enum Carry { + /// Paint, text, visibility, … (everything but geometry). + Values, + /// Values plus size (instance roots: their size decides how children follow constraints). + Root, + /// Size and position (children, after constraints ran). + Geometry, } /// Copy the overridden property groups of `from` onto `to`. -fn carry_overrides(from: &Node, to: &mut Node) { +fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { for g in &from.overrides { + let geo = matches!(g.as_str(), "size" | "position"); + match what { + Carry::Values if geo => continue, + Carry::Root if g == "position" => continue, + Carry::Geometry if !geo => continue, + _ => {} + } match g.as_str() { - "fills" => to.fills = from.fills.clone(), + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } "strokes" => { to.strokes = from.strokes.clone(); to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); } - "effects" => to.effects = from.effects.clone(), "opacity" => { to.opacity = from.opacity; to.blend = from.blend; @@ -99,203 +174,293 @@ fn carry_overrides(from: &Node, to: &mut Node) { b.align_h = a.align_h; b.align_v = a.align_v; } + to.styles.text = from.styles.text.clone(); } "size" => { to.width = from.width; to.height = from.height; to.child_layout = from.child_layout.clone(); + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } } + "position" => to.transform = from.transform, "component" => { - if let (NodeKind::Instance { component: a, props: pa }, NodeKind::Instance { component: b, props: pb }) = (&from.kind, &mut to.kind) { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; - *pb = pa.clone(); + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); } } _ => {} } } - to.overrides = from.overrides.clone(); + if what != Carry::Geometry { + to.overrides = from.overrides.clone(); + } } -/// Name path from the instance root (for matching layers across variants). -fn name_path(doc: &Document, root: NodeId, id: NodeId) -> String { - let mut parts = Vec::new(); - let mut cur = Some(id); - while let Some(c) = cur { - if c == root { - break; - } - let Some(n) = doc.node(c) else { break }; - parts.push(n.name.clone()); - cur = n.parent; - if parts.len() > 256 { - break; +/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With +/// `src_names`, layers are named after the main layer they mirror (renames don't break matching). +fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![(root, String::new())]; + while let Some((id, prefix)) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + let name = + if src_names { cn.source.and_then(|s| doc.node(s)).map(|s| s.name.as_str()).unwrap_or(cn.name.as_str()) } else { cn.name.as_str() }; + let k = seen.entry(name).or_insert(0); + let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; + *k += 1; + let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; + out.insert(*c, path.clone()); + if out.len() > 1_000_000 { + return out; + } + stack.push((*c, path)); } } - parts.reverse(); - parts.join("/") + out } -/// Rebuild instance `inst` from its main component, keeping ids and overrides. +/// The override group a property-bound field belongs to. +fn field_group(field: &str) -> &'static str { + match field { + "visible" => "visible", + "characters" => "characters", + "mainComponent" => "component", + _ => "", + } +} + +/// Apply one component-property value to a layer. +fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { + match field { + "visible" => { + if let Some(b) = val.as_bool() { + n.visible = b; + } + } + "characters" => { + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str()) + && t.characters != sv + { + let len = t.char_count(); + t.replace_range(0, len, sv); + } + } + "mainComponent" => { + if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) + && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) + { + *c = v; + } + } + _ => {} + } +} + +/// Rebuild instance `inst` from its main component, keeping ids and overrides. An instance whose +/// main component was deleted keeps its layers as they are. pub fn sync_instance(doc: &mut Document, inst: NodeId) { sync_instance_depth(doc, inst, 0); } fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { - if depth > 16 { + if depth > MAX_DEPTH { return; } let Some(root) = doc.node(inst).cloned() else { return }; - let NodeKind::Instance { component, props } = &root.kind else { return }; - let (component, props) = (*component, props.clone()); + let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; + let component = *component; let Some(comp) = doc.node(component).cloned() else { return }; - if doc.is_ancestor(inst, component) { - // An instance inside its own main component would recurse forever. + if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { + // A missing main, or an instance inside its own main (would recurse forever). return; } - // Old layers by source and by name path. - let old_ids: Vec = doc.subtree(inst).into_iter().filter(|i| *i != inst).collect(); - let mut by_source: BTreeMap = BTreeMap::new(); - let mut by_path: BTreeMap = BTreeMap::new(); - for i in &old_ids { - if let Some(n) = doc.node(*i) { - if let Some(s) = n.source { - by_source.insert(s, n.clone()); - } - by_path.insert(name_path(doc, inst, *i), n.clone()); + let mut eff_props = default_props(&comp); + for (k, v) in root_props { + if eff_props.contains_key(k) { + eff_props.insert(k.clone(), v.clone()); } } - // Remove the old layers. - let kids = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); - for c in kids { + // Old layers, by (parent, source) and by name path. + let old_paths = path_map(doc, inst, true); + let mut by_source: BTreeMap<(NodeId, NodeId), Node> = BTreeMap::new(); + let mut by_path: BTreeMap = BTreeMap::new(); + for (i, path) in &old_paths { + let Some(n) = doc.node(*i) else { continue }; + if let (Some(s), Some(p)) = (n.source, n.parent) { + by_source.entry((p, s)).or_insert_with(|| n.clone()); + } + by_path.insert(path.clone(), n.clone()); + } + for c in root.children.clone() { let _ = doc.remove(c); } - // Copy the component's layers. + // Ids for the copies: reuse the matching old layer's id. let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); + let main_paths = path_map(doc, component, false); let mut map: BTreeMap = BTreeMap::new(); + map.insert(component, inst); + let mut olds: BTreeMap = BTreeMap::new(); let mut used: BTreeSet = BTreeSet::new(); for s in &src { - let path = name_path(doc, component, *s); - let old = by_source.get(s).or_else(|| by_path.get(&path)); - let id = match old { - Some(o) if !doc.contains(o.id) && used.insert(o.id) => o.id, + let Some(sn) = doc.node(*s) else { continue }; + let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); + let old = parent_new.and_then(|pn| by_source.get(&(pn, *s))).or_else(|| main_paths.get(s).and_then(|p| by_path.get(p))); + let nid = match old { + Some(o) if !doc.contains(o.id) && used.insert(o.id) => { + olds.insert(o.id, o.clone()); + o.id + } _ => doc.alloc_id(), }; - map.insert(*s, id); + map.insert(*s, nid); } - map.insert(component, inst); + // Build the copies (pre-order: parents first). + let mut enclosing: BTreeMap> = BTreeMap::new(); + enclosing.insert(inst, vec![inst]); + let mut inst_props: BTreeMap> = BTreeMap::new(); + inst_props.insert(inst, eff_props); + let mut nested = Vec::new(); let mut new_nodes = Vec::new(); for s in &src { let Some(orig) = doc.node(*s).cloned() else { continue }; let Some(&nid) = map.get(s) else { continue }; + let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; let mut n = orig.clone(); n.id = nid; - n.parent = orig.parent.and_then(|p| map.get(&p).copied()); + n.parent = Some(parent); n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect(); - // Nested instance copies keep their own main; layers mirror the main's layer. - n.source = Some(orig.source.filter(|_| false).unwrap_or(*s)); + n.source = Some(*s); n.overrides.clear(); - let path = name_path(doc, component, *s); - if let Some(old) = by_source.get(s).or_else(|| by_path.get(&path)) { - carry_overrides(old, &mut n); + n.prop_refs.clear(); + if let Some(old) = olds.get(&nid) { + carry_overrides(old, &mut n, Carry::Values); } - // Component properties. - for (field, prop) in &orig.prop_refs { - let Some(val) = props.get(prop) else { continue }; - match field.as_str() { - "visible" => { - if let Some(b) = val.as_bool() { - n.visible = b; - } + // Component properties: the main layer's bindings use this instance's values; layers of + // nested instances use the nested instance's values (one level per source hop). + let enc = enclosing.get(&parent).cloned().unwrap_or_default(); + let mut chain = vec![orig.clone()]; + while chain.len() < enc.len() { + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)).cloned() else { break }; + chain.push(next); + } + for (j, level) in chain.iter().enumerate().rev() { + let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; + for (field, prop) in &level.prop_refs { + let g = field_group(field); + if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { + continue; } - "characters" => { - if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str()) - && !n.overrides.contains("characters") - { - let len = t.char_count(); - t.replace_range(0, len, sv); - } + if let Some(val) = props.get(prop) { + apply_prop(doc, &mut n, field, val); } - "mainComponent" => { - if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) { - *c = v; - } - } - _ => {} } } + if let NodeKind::Instance { component: c, props } = &n.kind { + inst_props.insert(nid, props.clone()); + let mut e = enc.clone(); + e.push(nid); + enclosing.insert(nid, e); + if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { + nested.push(nid); + } + } else { + enclosing.insert(nid, enc); + } new_nodes.push(n); } for n in new_nodes { - let id = n.id; - let parent = n.parent; - let _ = doc_insert_raw(doc, n); - if parent == Some(inst) - && let Some(r) = doc.node_mut(inst) - && !r.children.contains(&id) + let (id, parent) = (n.id, n.parent); + if insert_linked(doc, n).is_err() + && let Some(p) = parent.and_then(|p| doc.node_mut(p)) { - r.children.push(id); + p.children.retain(|c| *c != id); } } - // Keep the main's child order. - let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).collect(); + // Root: follows the main except its own placement, name, size and overrides. + let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); + let mut fresh = comp.clone(); + fresh.id = inst; + fresh.parent = root.parent; + fresh.children = order; + fresh.transform = root.transform; + fresh.kind = root.kind.clone(); + // A nested instance keeps mirroring its layer of the outer main. + fresh.source = if inside_instance(doc, inst) { root.source } else { Some(component) }; + fresh.constraints = root.constraints; + fresh.child_layout = root.child_layout.clone(); + fresh.interactions = root.interactions.clone(); + fresh.exports = root.exports.clone(); + fresh.prop_refs = root.prop_refs.clone(); + fresh.plugin_data = root.plugin_data.clone(); + fresh.locked = root.locked; + fresh.visible = root.visible; + fresh.var_modes = root.var_modes.clone(); + fresh.is_mask = root.is_mask; + fresh.name = default_instance_name(doc, component); + carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { - r.children = order; - } - // Root properties follow the main unless overridden. - if let Some(r) = doc.node_mut(inst) { - let mut fresh = comp.clone(); - fresh.id = inst; - fresh.parent = r.parent; - fresh.children = r.children.clone(); - fresh.transform = r.transform; - fresh.kind = r.kind.clone(); - fresh.source = Some(component); - fresh.constraints = r.constraints; - fresh.child_layout = r.child_layout.clone(); - fresh.interactions = r.interactions.clone(); - fresh.exports = r.exports.clone(); - fresh.prop_refs.clear(); - fresh.locked = r.locked; - fresh.visible = r.visible; - fresh.name = r.name.clone(); - fresh.var_modes = r.var_modes.clone(); - // Instances keep their own size (resizing an instance is allowed). - fresh.width = r.width; - fresh.height = r.height; - carry_overrides(&r.clone(), &mut fresh); *r = fresh; } - // Instance size differs from main: children follow constraints. - let (cw, ch) = (comp.width, comp.height); - let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((cw, ch)); - if ((iw - cw).abs() > 1e-9 || (ih - ch).abs() > 1e-9) && doc.node(inst).is_some_and(|n| n.layout.is_none()) { - figcraft_layout::apply_constraints(doc, inst, cw, ch); + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); } - // Nested instances inside the copy. - let nested: Vec = - doc.subtree(inst).into_iter().filter(|i| *i != inst && doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))).collect(); - for n in nested { - // Only re-sync nested instances whose main was swapped by a property or override. - let needs = doc.node(n).is_some_and(|nn| nn.overrides.contains("component") || !nn.prop_refs.is_empty()); - if needs { - sync_instance_depth(doc, n, depth + 1); + // Geometry overrides of children win over constraints. + for s in &src { + let Some(nid) = map.get(s).copied() else { continue }; + let Some(old) = olds.get(&nid) else { continue }; + if !old.overrides.contains("size") && !old.overrides.contains("position") { + continue; } + let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; + if old.overrides.contains("size") { + // Through set_size so vector networks scale with the box. + figcraft_layout::set_size(doc, nid, old.width, old.height); + if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { + figcraft_layout::apply_constraints(doc, nid, ow, oh); + } + } + if let Some(n) = doc.node_mut(nid) { + carry_overrides(old, n, Carry::Geometry); + } + } + // Text whose characters or style differ from the main re-measures. + for nid in map.values() { + if let Some(n) = doc.node(*nid) + && let Some((w, h)) = figcraft_text::auto_size(n) + && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + { + figcraft_layout::set_size(doc, *nid, w, h); + } + } + // Nested instances showing another main than the main's copy (swapped): rebuild from it. + for n in nested { + sync_instance_depth(doc, n, depth + 1); } } /// Insert a node whose parent/children links are already set (used when rebuilding instances). -fn doc_insert_raw(doc: &mut Document, n: Node) -> Result<(), DocError> { +fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; let id = n.id; let mut n = n; let kids = std::mem::take(&mut n.children); doc.insert(n, parent, None)?; - // `insert` appended us to the parent; fix sibling order later. Restore children list. if let Some(m) = doc.node_mut(id) { m.children = kids; } - // Parent may have listed us already (inserted twice): dedupe. + // The parent may already list us (its children were copied with ids): keep the first. if let Some(p) = doc.node_mut(parent) { let mut seen = BTreeSet::new(); p.children.retain(|c| seen.insert(*c)); @@ -304,7 +469,7 @@ fn doc_insert_raw(doc: &mut Document, n: Node) -> Result<(), DocError> { } /// Property groups that differ between two versions of a node. -fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { +pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { let mut out = Vec::new(); if a.fills != b.fills { out.push("fills"); @@ -327,65 +492,120 @@ fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.corners != b.corners { out.push("corners"); } + if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { + out.push("size"); + } + if a.transform != b.transform { + out.push("position"); + } if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { if x.characters != y.characters { out.push("characters"); } - if x.style != y.style || x.runs != y.runs { + if x.style != y.style || x.runs != y.runs || x.align_h != y.align_h || x.align_v != y.align_v { out.push("textStyle"); } } - if let (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) = (&a.kind, &b.kind) - && x != y - { - out.push("component"); + if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { + if x != y { + out.push("component"); + } + if px != py { + out.push("props"); + } } out } -/// After an edit: record overrides inside instances, then rebuild instances of edited components. -pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { - // 1. Overrides for layers edited inside instances. - for id in changed { - let Some(n) = doc.node(*id) else { continue }; - if n.source.is_none() - || matches!(n.kind, NodeKind::Instance { .. }) - && owning_instance(doc, *id) == Some(*id) - && n.parent.is_some_and(|p| owning_instance(doc, p).is_none()) - { - // Root instances: switching the main is not an override; other edits are. - if let (Some(b), Some(n)) = (before.node(*id), doc.node(*id)) - && matches!(n.kind, NodeKind::Instance { .. }) - { - let groups: Vec<&str> = diff_groups(b, n).into_iter().filter(|g| *g != "component").collect(); - if !groups.is_empty() - && let Some(m) = doc.node_mut(*id) - { - for g in groups { - m.overrides.insert(g.to_string()); - } - } - } - continue; - } - if let Some(b) = before.node(*id) { - let groups = diff_groups(b, n); - if !groups.is_empty() - && let Some(m) = doc.node_mut(*id) - { - for g in groups { - m.overrides.insert(g.to_string()); - } - } +/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was +/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. +fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { + for a in doc.ancestors(id) { + let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; + if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { + return true; } } - // 2. Components whose layers changed (or lost layers). - let mut comps: BTreeSet = BTreeSet::new(); - for id in changed { - // Edits inside an instance aren't edits of the main. - if let Some(c) = owning_component(doc, *id) - && owning_instance(doc, *id).is_none_or(|i| !doc.is_ancestor(c, i)) + let Some(n) = doc.node(id) else { return true }; + if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { + return true; + } + if group == "size" + && let NodeKind::Text(t) = &n.kind + { + let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); + return match t.auto_resize { + TextAutoResize::WidthAndHeight => true, + TextAutoResize::Height => !wchanged, + _ => false, + }; + } + false +} + +/// After an edit: record overrides inside instances, then rebuild instances whose main changed, +/// whose properties or main were switched, or whose overrides were reset. +pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut resync: BTreeSet = BTreeSet::new(); + // 1. Overrides for layers edited inside instances. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + let Some(inst) = owning_instance(doc, id) else { continue }; + let top_root = inst == id && !inside_instance(doc, id); + if top_root + && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) + && (c1 != c2 || p1 != p2) { + // Switching the main or a property value of an instance isn't an override. + resync.insert(id); + } + let raw = diff_groups(b, n); + let mut groups = raw.clone(); + if top_root { + groups.retain(|g| !matches!(*g, "component" | "props" | "position")); + } + if groups.iter().any(|g| matches!(*g, "size" | "position")) { + groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); + } + // A value equal to the main's isn't an override (setting it back clears the override). + let mut cleared = Vec::new(); + if let Some(src) = n.source.and_then(|s| doc.node(s)) { + let mut differs = diff_groups(src, n); + if inst == id { + differs.retain(|g| *g != "name"); + if n.name != default_instance_name(doc, src.id) { + differs.push("name"); + } + } + groups.retain(|g| differs.contains(g)); + cleared = + raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); + if top_root { + cleared.retain(|g| !matches!(*g, "component" | "props")); + } + } + let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); + let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); + if (!groups.is_empty() || !cleared.is_empty()) + && let Some(m) = doc.node_mut(id) + { + for g in groups { + m.overrides.insert(g.to_string()); + } + for g in cleared { + m.overrides.remove(g); + } + } + if (lost || nested_switch) + && let Some(r) = root_instance(doc, id) + { + resync.insert(r); + } + } + // 2. Components whose layers changed, were added or removed. + let mut comps: BTreeSet = BTreeSet::new(); + for &id in changed { + if let Some(c) = owning_component(doc, id) { comps.insert(c); } } @@ -397,30 +617,35 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] comps.insert(c); } } - // Instance main swapped or props changed: re-sync those instances. - let mut direct: BTreeSet = BTreeSet::new(); - for id in changed { - if let (Some(n), Some(b)) = (doc.node(*id), before.node(*id)) - && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) - && (c1 != c2 || p1 != p2) - { - direct.insert(*id); + for r in resync { + if doc.contains(r) { + sync_instance(doc, r); + if let Some(c) = owning_component(doc, r) { + comps.insert(c); + } } } + // 3. Instances of changed components follow; an instance inside another component makes that + // one change too. Instances nested inside instances are rebuilt with their outer instance. let mut done: BTreeSet = BTreeSet::new(); let mut frontier: Vec = comps.into_iter().collect(); let mut rounds = 0; - while !frontier.is_empty() && rounds < 8 { + while !frontier.is_empty() && rounds < 64 { rounds += 1; let mut next = Vec::new(); + let mut synced: BTreeSet = BTreeSet::new(); for c in frontier { if !done.insert(c) { continue; } for inst in instances_of(doc, c) { - sync_instance(doc, inst); - // The instance may live inside another component: that one changed too. - if let Some(outer) = owning_component(doc, inst) + // A copy inside another instance is rebuilt with its outermost instance. + let r = root_instance(doc, inst).unwrap_or(inst); + if !synced.insert(r) { + continue; + } + sync_instance(doc, r); + if let Some(outer) = owning_component(doc, r) && !done.contains(&outer) { next.push(outer); @@ -429,10 +654,73 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } frontier = next; } - for i in direct { - if doc.contains(i) { - sync_instance(doc, i); +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can). +pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { + for &id in after.dirty() { + let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; + if n.children == b.children { + continue; } + if owning_instance(before, id).is_some() && owning_instance(after, id).is_some() { + let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); + return Err(format!( + "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" + )); + } + } + Ok(()) +} + +/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing +/// values (after detaching an outer instance, or restoring a main): the look is kept exactly. +pub fn rebase_instance(doc: &mut Document, inst: NodeId) { + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; + let mine = path_map(doc, inst, true); + let theirs: BTreeMap = + if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; + let value_groups = |a: &Node, b: &Node| -> Vec { + diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() + }; + let mut updates = Vec::new(); + for (id, path) in &mine { + let Some(n) = doc.node(*id) else { continue }; + // Follow the source chain into the main first (exact), then fall back to the name path. + let mut main = None; + let mut cur = n.source; + for _ in 0..MAX_DEPTH { + let Some(c) = cur else { break }; + if c != component && doc.contains(c) && doc.is_ancestor(component, c) { + main = Some(c); + break; + } + cur = doc.node(c).and_then(|x| x.source); + } + let main = main.or_else(|| theirs.get(path).copied()); + let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); + if let Some(m) = main.and_then(|m| doc.node(m)) { + overrides.extend(value_groups(m, n)); + if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { + overrides.insert("component".into()); + } + } + updates.push((*id, main, overrides)); + } + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } + let root_groups = match (doc.node(component), doc.node(inst)) { + (Some(m), Some(n)) => value_groups(m, n).into_iter().filter(|g| g != "name").collect::>(), + _ => Vec::new(), + }; + if let Some(n) = doc.node_mut(inst) { + n.source = Some(component); + n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); + n.overrides.extend(root_groups); } } @@ -446,13 +734,85 @@ pub fn variant_props(name: &str) -> BTreeMap { .collect() } -/// The variant in `set` matching `wanted` best (all given properties equal). +/// Format variant pairs as a layer name, in the given property order (others after, sorted). +pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { + let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); + keys.extend(pairs.keys().filter(|k| !order.contains(k))); + keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") +} + +/// Variant properties of a set: property → values, in first-seen order. +pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + for kv in cn.name.split(',') { + let Some((k, v)) = kv.split_once('=') else { continue }; + let (k, v) = (k.trim().to_string(), v.trim().to_string()); + match out.iter_mut().find(|(p, _)| *p == k) { + Some((_, vals)) => { + if !vals.contains(&v) { + vals.push(v); + } + } + None => out.push((k, vec![v])), + } + } + } + out +} + +/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { let s = doc.node(set)?; - s.children.iter().copied().find(|c| { - doc.node(*c).is_some_and(|n| { - let p = variant_props(&n.name); - wanted.iter().all(|(k, v)| p.get(k) == Some(v)) - }) - }) + let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); + if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { + return Some(exact); + } + s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) } + +/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each +/// layer's `source` is the main layer at the same place, swapped nested instances mirror their +/// own main. Returns the problems found. +pub fn mirror_issues(doc: &Document) -> Vec { + fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + if inst.children.len() != main.children.len() { + out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); + return; + } + for (a, b) in inst.children.iter().zip(&main.children) { + let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; + if an.source != Some(bn.id) { + out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); + continue; + } + match (&an.kind, &bn.kind) { + (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { + if let Some(m) = doc.node(*x) { + walk(doc, an, m, depth + 1, out); + } + } + _ => walk(doc, an, bn, depth + 1, out), + } + } + } + let mut out = Vec::new(); + for n in doc.nodes() { + let NodeKind::Instance { component, .. } = &n.kind else { continue }; + if inside_instance(doc, n.id) { + continue; + } + if let Some(m) = doc.node(*component) { + walk(doc, n, m, 0, &mut out); + } + } + out +} + +#[cfg(test)] +#[path = "components_tests.rs"] +mod tests; diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs new file mode 100644 index 0000000..9c5b718 --- /dev/null +++ b/crates/engine/src/components_tests.rs @@ -0,0 +1,543 @@ +//! Component / instance behaviour tests (overrides, structure, nesting, variants, properties). + +use super::*; +use crate::{EngineError, Session, params::id_of}; +use serde_json::{Value, json}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + match s.execute(cmd, &p) { + Ok(v) => v, + Err(e) => panic!("{cmd} {p}: {e}"), + } +} + +fn idv(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +fn kids(s: &Session, id: NodeId) -> Vec { + node(s, id).children +} + +fn fill(s: &Session, id: NodeId) -> String { + node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn chars(s: &Session, id: NodeId) -> String { + node(s, id).kind.text().map(|t| t.characters.clone()).unwrap_or_default() +} + +fn healthy(s: &Session) { + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(mirror_issues(doc).is_empty(), "{:?}", mirror_issues(doc)); +} + +/// The instance layer mirroring main layer `src`. +fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.subtree(inst).into_iter().find(|i| doc.node(*i).and_then(|n| n.source) == Some(src)).unwrap() +} + +/// Card: 200×100 component with "Bg" rectangle, "Title" text and "Dot" ellipse. +fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let bg = + idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}))); + let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let dot = + idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot", "fill": "#ff0000"}}))); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + (c, bg, title, dot) +} + +#[test] +fn overrides_survive_main_edits() { + let mut s = Session::new(); + let (c, bg, title, dot) = card(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + healthy(&s); + let (ibg, ititle, idot) = (copy_of(&s, i, bg), copy_of(&s, i, title), copy_of(&s, i, dot)); + // Overrides on the instance. + ex(&mut s, "node.set", json!({"id": ibg, "props": {"fill": "#00ff00", "stroke": "#0000ff", "strokeWeight": 2, "name": "Background"}})); + ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}})); + ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"})); + ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false, "width": 30}})); + let ov = node(&s, ibg).overrides; + for g in ["fills", "strokes", "effects", "name"] { + assert!(ov.contains(g), "{g} in {ov:?}"); + } + // Main edits: overridden groups stay, the rest follows. + ex(&mut s, "node.set", json!({"id": bg, "props": {"fill": "#123456", "cornerRadius": 8, "name": "Bg2", "strokeWeight": 5}})); + ex(&mut s, "text.set", json!({"id": title, "characters": "Main text"})); + ex(&mut s, "node.set", json!({"id": title, "props": {"fontSize": 30}})); + ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ffff", "width": 10, "visible": true}})); + ex(&mut s, "node.set", json!({"id": c, "props": {"fill": "#fafafa"}})); + healthy(&s); + assert_eq!(copy_of(&s, i, bg), ibg, "ids are stable across syncs"); + let n = node(&s, ibg); + assert_eq!(fill(&s, ibg), "00FF00"); + assert_eq!(n.name, "Background"); + assert_eq!(n.corners.radii, [8.0; 4], "non-overridden corners follow"); + assert_eq!(n.stroke.weight, 2.0, "overridden stroke keeps its weight"); + assert_eq!(n.effects.len(), 1); + assert_eq!(chars(&s, ititle), "Overridden"); + assert_eq!(node(&s, ititle).kind.text().unwrap().style.font_size, 30.0, "text style follows the main"); + let d = node(&s, idot); + assert!(!d.visible); + assert_eq!(d.width, 30.0, "size override kept"); + assert_eq!(fill(&s, idot), "00FFFF"); + assert_eq!(fill(&s, i), "FAFAFA"); + // Per-group reset. + ex(&mut s, "component.resetOverride", json!({"id": ibg, "group": "fills"})); + assert_eq!(fill(&s, ibg), "123456"); + assert_eq!(node(&s, ibg).name, "Background", "other overrides stay"); + assert!(s.execute("component.resetOverride", &json!({"id": ibg, "group": "bogus"})).is_err()); + // Reset all. + ex(&mut s, "component.resetInstance", json!({"ids": [i]})); + assert_eq!(node(&s, ibg).name, "Bg2"); + assert_eq!(chars(&s, ititle), "Main text"); + assert!(node(&s, idot).visible); + assert_eq!(node(&s, idot).width, 10.0); + assert!(s.doc().unwrap().subtree(i).iter().all(|d| node(&s, *d).overrides.is_empty())); + healthy(&s); +} + +#[test] +fn main_structure_edits_propagate() { + let mut s = Session::new(); + let (c, bg, title, dot) = card(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let idot = copy_of(&s, i, dot); + ex(&mut s, "node.set", json!({"id": idot, "props": {"fill": "#000000"}})); + // Add a layer. + let star = idv(&ex(&mut s, "shape.star", json!({"x": 100, "y": 50, "width": 20, "height": 20, "parent": c}))); + healthy(&s); + assert_eq!(kids(&s, i).len(), 4); + // Reorder: dot to the bottom. + ex(&mut s, "node.reorder", json!({"id": dot, "index": 0})); + healthy(&s); + assert_eq!(kids(&s, i)[0], idot); + assert_eq!(fill(&s, idot), "000000"); + // Remove layers. + ex(&mut s, "edit.delete", json!({"ids": [title, star]})); + healthy(&s); + assert_eq!(kids(&s, i).len(), 2); + assert_eq!(fill(&s, idot), "000000", "overrides on surviving layers stay"); + assert_eq!(copy_of(&s, i, bg), kids(&s, i)[1]); + // Undo restores the layer in the instance too. + s.undo().unwrap(); + assert_eq!(kids(&s, i).len(), 4); + healthy(&s); +} + +#[test] +fn instances_refuse_structure_edits() { + let mut s = Session::new(); + let (c, bg, _title, dot) = card(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let (ibg, idot) = (copy_of(&s, i, bg), copy_of(&s, i, dot)); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + let refused = [ + ("edit.delete", json!({"ids": [ibg]})), + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": i})), + ("node.reorder", json!({"id": idot, "index": 0})), + ("object.group", json!({"ids": [ibg, idot]})), + ("node.reparent", json!({"ids": [idot], "parent": s.page().unwrap()})), + ("edit.duplicate", json!({"ids": [idot]})), + ("component.create", json!({"ids": [idot]})), + ]; + for (cmd, p) in refused { + let r = s.execute(cmd, &p); + assert!(matches!(r, Err(EngineError::Disabled(..)) | Err(EngineError::BadParams { .. })), "{cmd} should be refused: {r:?}"); + assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back"); + } + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + // Deleting the whole instance is fine. + ex(&mut s, "edit.delete", json!({"ids": [i]})); + healthy(&s); +} + +/// Icon component (one "Shape" rectangle) and a Button component holding an Icon instance. +fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 300, "width": 24, "height": 24, "name": "Icon"}))); + let shape = idv(&ex( + s, + "shape.rectangle", + json!({"x": 2, "y": 2, "width": 20, "height": 20, "parent": f, "props": {"name": "Shape", "fill": "#111111"}}), + )); + let icon = idv(&ex(s, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(s, "frame.create", json!({"x": 0, "y": 400, "width": 120, "height": 40, "name": "Button"}))); + let nested = idv(&ex(s, "instance.create", json!({"component": icon, "x": 8, "y": 8, "parent": b}))); + ex(s, "text.create", json!({"text": "Go", "x": 40, "y": 10, "parent": b, "props": {"name": "Label"}})); + let button = idv(&ex(s, "component.create", json!({"ids": [b]}))); + (icon, shape, button, nested) +} + +#[test] +fn nested_instances_and_swaps() { + let mut s = Session::new(); + let (icon, shape, button, nested) = button(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + healthy(&s); + // The button instance's icon copy mirrors the main's nested instance. + let inested = copy_of(&s, i, nested); + let nested_shape = copy_of(&s, nested, shape); + let ishape = copy_of(&s, i, nested_shape); + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon)); + // Override inside the nested instance, then edit the Icon main. + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}})); + ex(&mut s, "node.set", json!({"id": shape, "props": {"fill": "#222222", "cornerRadius": 6}})); + healthy(&s); + assert_eq!(fill(&s, nested_shape), "222222", "main's nested instance follows the icon"); + assert_eq!(fill(&s, ishape), "FF00FF", "override inside the nested copy kept"); + assert_eq!(node(&s, ishape).corners.radii, [6.0; 4], "icon edit reaches the button instance"); + // Main-level override on the nested instance reaches instances. + ex(&mut s, "node.set", json!({"id": nested_shape, "props": {"strokeWeight": 3, "stroke": "#00ff00"}})); + assert!(node(&s, nested_shape).overrides.contains("strokes")); + assert_eq!(node(&s, ishape).stroke.weight, 3.0); + // Swap the nested instance through an instance-swap property. + let f2 = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 300, "width": 24, "height": 24, "name": "Star icon"}))); + ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f2, "props": {"name": "Glyph"}})); + ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": f2, "props": {"name": "Spark"}})); + let icon2 = idv(&ex(&mut s, "component.create", json!({"ids": [f2]}))); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": nested})); + healthy(&s); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": icon2}})); + healthy(&s); + let inested = copy_of(&s, i, nested); + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2)); + assert_eq!(kids(&s, inested).len(), 2, "swapped nested instance shows the new main's layers"); + // Main edits keep the swap; the swapped icon follows its own main. + ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#0000ff"}})); + ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 4, "height": 4, "parent": icon2})); + healthy(&s); + let inested = copy_of(&s, i, nested); + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2)); + assert_eq!(kids(&s, inested).len(), 3); + // Swapping directly (instance.swap on the nested copy) is an override that survives main edits. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": icon}})); + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": icon2})); + assert!(node(&s, inested).overrides.contains("component")); + ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}})); + healthy(&s); + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2)); + ex(&mut s, "component.resetOverride", json!({"id": inested, "group": "component"})); + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon)); + healthy(&s); + // A nested instance can't be swapped into a component that contains it. + assert!(s.execute("instance.swap", &json!({"ids": [nested], "component": button})).is_err()); +} + +#[test] +fn variant_switch_keeps_compatible_overrides() { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")}))); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}})); + ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}})); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(node(&s, i).name, node(&s, set).name, "instances of variants are named after the set"); + let label = kids(&s, i)[1]; + ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"})); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}})); + healthy(&s); + assert!(matches!(node(&s, i).kind, NodeKind::Instance { component, .. } if component == variants[1])); + let k = kids(&s, i); + assert_eq!(fill(&s, k[0]), "0000FF"); + assert_eq!(chars(&s, k[1]), "Buy", "text override survives the variant switch"); + assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Pressed"}})).is_err()); + assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"Nope": 1}})).is_err()); + // Adding a variant value names it Prop=Value and grows the set. + let h0 = node(&s, set).height; + let v3 = idv(&ex(&mut s, "component.addVariant", json!({"id": set, "property": "State", "value": "Pressed"}))); + assert_eq!(node(&s, v3).name, "State=Pressed"); + assert!(node(&s, set).height > h0); + assert!(s.execute("component.addVariant", &json!({"id": set, "property": "State", "value": "Pressed"})).is_err()); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Pressed"}})); + assert!(matches!(node(&s, i).kind, NodeKind::Instance { component, .. } if component == v3)); + // A second variant property, renamed, then a value renamed. + ex(&mut s, "component.addProperty", json!({"id": set, "name": "Size", "type": "variant", "default": "Large"})); + assert_eq!(node(&s, variants[0]).name, "State=Default, Size=Large"); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "Size", "newName": "Scale"})); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "value": "Hover", "newValue": "Over"})); + assert_eq!(node(&s, variants[1]).name, "State=Over, Scale=Large"); + ex(&mut s, "component.deleteProperty", json!({"id": set, "name": "Scale"})); + assert_eq!(node(&s, variants[1]).name, "State=Over"); + healthy(&s); +} + +#[test] +fn detach_keeps_the_look() { + let mut s = Session::new(); + let (icon, shape, button, nested) = button(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let inested = copy_of(&s, i, nested); + let ishape = kids(&s, inested)[0]; + let ilabel = kids(&s, i)[1]; + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#abcdef"}})); + ex(&mut s, "text.set", json!({"id": ilabel, "characters": "Detached"})); + ex(&mut s, "node.set", json!({"id": i, "props": {"fill": "#fedcba"}})); + // A nested instance alone can't be detached. + assert!(s.execute("component.detach", &json!({"ids": [inested]})).is_err()); + ex(&mut s, "component.detach", json!({"ids": [i]})); + healthy(&s); + assert_eq!(node(&s, i).kind.type_name(), "FRAME"); + assert_eq!(fill(&s, i), "FEDCBA"); + assert_eq!(chars(&s, ilabel), "Detached"); + assert!(node(&s, ilabel).source.is_none()); + // The nested instance stays an instance of Icon, re-linked to it with its override kept. + assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon)); + assert_eq!(node(&s, ishape).source, Some(shape)); + assert_eq!(fill(&s, ishape), "ABCDEF"); + ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 4, "fill": "#000000"}})); + assert_eq!(fill(&s, ishape), "ABCDEF"); + assert_eq!(node(&s, ishape).corners.radii, [4.0; 4]); + // Detached layers are ordinary layers now. + ex(&mut s, "edit.delete", json!({"ids": [ilabel]})); + healthy(&s); +} + +#[test] +fn deleted_main_keeps_instances_and_restores() { + let mut s = Session::new(); + let (c, bg, title, _dot) = card(&mut s); + let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + let atitle = copy_of(&s, a, title); + ex(&mut s, "text.set", json!({"id": atitle, "characters": "Instance A"})); + assert!(s.execute("component.restoreMain", &json!({"id": a})).is_err(), "main still exists"); + ex(&mut s, "edit.delete", json!({"ids": [c]})); + healthy(&s); + assert_eq!(kids(&s, a).len(), 3, "instances keep working without their main"); + assert!(s.execute("component.goToMain", &json!({"id": a})).is_err()); + // Editing an orphan instance still works. + let bbg = copy_of(&s, b, bg); + ex(&mut s, "node.set", json!({"id": bbg, "props": {"fill": "#333333"}})); + // Restore from B: the main comes back with its old id in B's place. + let bx = s.doc().unwrap().world_bounds(b).unwrap(); + let r = idv(&ex(&mut s, "component.restoreMain", json!({"id": b}))); + assert_eq!(r, c); + assert!(!s.doc().unwrap().contains(b)); + assert_eq!(s.doc().unwrap().world_bounds(c).unwrap(), bx); + assert!(matches!(node(&s, c).kind, NodeKind::Component { .. })); + healthy(&s); + // A re-links: main edits reach it, its override stays. + assert_eq!(node(&s, atitle).source, Some(title)); + ex(&mut s, "node.set", json!({"id": bg, "props": {"cornerRadius": 9}})); + assert_eq!(node(&s, copy_of(&s, a, bg)).corners.radii, [9.0; 4]); + assert_eq!(fill(&s, copy_of(&s, a, bg)), "333333", "the restored main has B's look"); + assert_eq!(chars(&s, atitle), "Instance A"); + healthy(&s); +} + +#[test] +fn resized_instances_follow_constraints_and_auto_layout() { + let mut s = Session::new(); + let (c, bg, _title, dot) = card(&mut s); + ex(&mut s, "node.set", json!({"id": dot, "props": {"constraints": {"horizontal": "right"}}})); + ex(&mut s, "node.set", json!({"id": bg, "props": {"constraints": {"horizontal": "leftRight", "vertical": "topBottom"}}})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + ex(&mut s, "node.set", json!({"id": i, "props": {"width": 300, "height": 150}})); + let (ibg, idot) = (copy_of(&s, i, bg), copy_of(&s, i, dot)); + assert_eq!(node(&s, idot).transform.as_coeffs()[4], 270.0); + assert_eq!((node(&s, ibg).width, node(&s, ibg).height), (300.0, 150.0)); + assert!(node(&s, idot).overrides.is_empty(), "constraint moves aren't overrides"); + assert!(node(&s, i).overrides.contains("size")); + // A main edit keeps the instance's size and the constraint layout. + ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ff00"}})); + assert_eq!(node(&s, i).width, 300.0); + assert_eq!(node(&s, idot).transform.as_coeffs()[4], 270.0); + assert_eq!(node(&s, ibg).width, 300.0); + // Main resized: instances without a size override follow it. + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 700, "y": 0}))); + ex(&mut s, "node.set", json!({"id": c, "props": {"width": 220}})); + assert_eq!(node(&s, j).width, 220.0); + assert_eq!(node(&s, i).width, 300.0); + // Auto layout: a longer text override makes a hugging instance grow. + let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 50, "height": 30, "name": "Chip"}))); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": row}))); + ex(&mut s, "layout.add", json!({"ids": [row]})); + let chip = idv(&ex(&mut s, "component.create", json!({"ids": [row]}))); + let ci = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 300, "y": 600}))); + let w0 = node(&s, ci).width; + let it = copy_of(&s, ci, t); + ex(&mut s, "text.set", json!({"id": it, "characters": "A much longer chip label"})); + if figcraft_text::find_face("Inter", "Regular").is_some() || node(&s, it).width > 0.0 { + assert!(node(&s, ci).width > w0, "auto layout instance hugs its overridden text"); + } + healthy(&s); +} + +#[test] +fn component_properties_apply_to_instances() { + let mut s = Session::new(); + let (c, _bg, title, dot) = card(&mut s); + // Boolean bound to the dot's visibility (created from the selected layer). + ex(&mut s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true})); + assert_eq!(node(&s, dot).prop_refs.get("visible").map(String::as_str), Some("Show dot")); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let (ititle, idot) = (copy_of(&s, i, title), copy_of(&s, i, dot)); + assert_eq!(chars(&s, ititle), "Hello", "text property defaults to the layer's text"); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "From prop"}})); + assert!(!node(&s, idot).visible); + assert_eq!(chars(&s, ititle), "From prop"); + assert!(node(&s, idot).overrides.is_empty(), "property values aren't overrides"); + // Main edits keep the property values. + ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ff00"}})); + assert!(!node(&s, idot).visible); + assert_eq!(chars(&s, ititle), "From prop"); + // Rename the property: bindings and instance values follow. + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Title", "newName": "Heading", "default": "Default heading"})); + assert_eq!(node(&s, title).prop_refs.get("characters").map(String::as_str), Some("Heading")); + assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if props.get("Heading") == Some(&json!("From prop")))); + assert_eq!(chars(&s, ititle), "From prop"); + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + assert_eq!(chars(&s, copy_of(&s, j, title)), "Default heading"); + // Wrong types and names are refused. + assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"Show dot": "maybe"}})).is_err()); + assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err()); + assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err()); + assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err()); + // Unbind and delete. + ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"})); + assert!(node(&s, copy_of(&s, i, dot)).visible); + ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"})); + assert!(node(&s, title).prop_refs.is_empty()); + assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading"))); + let desc = ex(&mut s, "component.properties", json!({"id": i})); + assert_eq!(desc["properties"].as_array().unwrap().len(), 1); + // A variant property on a lone component makes it a component set. + let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"}))); + assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, c).name, "Theme=Light"); + healthy(&s); +} + +#[test] +fn push_changes_to_main() { + let mut s = Session::new(); + let (c, bg, _title, _dot) = card(&mut s); + let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + let abg = copy_of(&s, a, bg); + ex(&mut s, "node.set", json!({"id": abg, "props": {"fill": "#445566"}})); + ex(&mut s, "component.pushChanges", json!({"id": a})); + assert_eq!(fill(&s, bg), "445566"); + assert_eq!(fill(&s, copy_of(&s, b, bg)), "445566"); + assert!(node(&s, copy_of(&s, a, bg)).overrides.is_empty()); + healthy(&s); +} + +/// Random edits on mains and instances keep the document valid and instances mirroring mains. +#[test] +fn random_edit_sequences_keep_instances_consistent() { + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn pick(&mut self, v: &[T]) -> Option { + if v.is_empty() { None } else { v.get((self.next() % v.len() as u64) as usize).copied() } + } + } + for seed in 1..=std::env::var("FC_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(24u64) { + let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1); + let mut s = Session::new(); + let (icon, _shape, button, _nested) = button(&mut s); + let (card_c, ..) = card(&mut s); + let comps = [icon, button, card_c]; + for c in comps { + for k in 0..2 { + ex(&mut s, "instance.create", json!({"component": c, "x": 500 + k * 200, "y": c.0 * 10})); + } + } + for step in 0..60 { + let doc = s.doc().unwrap().clone(); + let main_layers: Vec = comps.iter().flat_map(|c| doc.subtree(*c)).filter(|i| doc.contains(*i)).collect(); + let inst_layers: Vec = + doc.nodes().filter(|n| n.source.is_some() && owning_instance(&doc, n.id).is_some()).map(|n| n.id).collect(); + let roots: Vec = + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(&doc, n.id)).map(|n| n.id).collect(); + let containers: Vec = main_layers + .iter() + .copied() + .filter(|i| { + doc.node(*i).is_some_and(|n| n.kind.is_container()) + && !inside_instance(&doc, *i) + && !matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. })) + }) + .collect(); + let color = format!("#{:06x}", r.next() % 0xFF_FFFF); + let (cmd, p) = match r.next() % 18 { + 14 => ("text.set", json!({"id": r.pick(&inst_layers), "characters": format!("T{}", r.next() % 9)})), + 15 => ("component.detach", json!({"ids": [r.pick(&roots)]})), + 16 => ("component.pushChanges", json!({"id": r.pick(&roots)})), + 17 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"name": format!("I{}", r.next() % 3)}})), + 0 => ( + "shape.rectangle", + json!({"x": (r.next() % 50) as f64, "y": 5, "width": 8, "height": 8, "parent": r.pick(&containers), "props": {"name": format!("R{step}")}}), + ), + 1 => ("edit.delete", json!({"ids": [r.pick(&main_layers).filter(|i| !comps.contains(i))]})), + 2 => ("node.reorder", json!({"id": r.pick(&main_layers), "index": r.next() % 3})), + 3 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"fill": color}})), + 4 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"name": format!("N{}", r.next() % 4)}})), + 5 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"fill": color}})), + 6 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"visible": r.next().is_multiple_of(2)}})), + 7 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"width": 5 + r.next() % 40}})), + 8 => ("node.set", json!({"id": r.pick(&roots), "props": {"width": 20 + r.next() % 200, "height": 20 + r.next() % 100}})), + 9 => ("edit.delete", json!({"ids": [r.pick(&inst_layers)]})), + 10 => ("component.resetInstance", json!({"ids": [r.pick(&roots)]})), + 11 => ("component.resetOverride", json!({"id": r.pick(&inst_layers)})), + 12 => ( + "instance.swap", + json!({"ids": [r.pick(&inst_layers).filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. })))], "component": r.pick(&[icon, card_c])}), + ), + _ => ("node.set", json!({"id": r.pick(&main_layers), "props": {"width": 5 + r.next() % 120}})), + }; + if std::env::var("FC_DUMP").ok().as_deref() == Some(&format!("{seed}:{step}")) { + for n in s.doc().unwrap().nodes() { + eprintln!( + "{} {:?} {} parent={:?} src={:?} kids={:?} ov={:?}", + n.id, + n.name, + match &n.kind { + NodeKind::Instance { component, .. } => format!("INST({component})"), + k => k.type_name().into(), + }, + n.parent, + n.source, + n.children, + n.overrides + ); + } + } + let res = s.execute(cmd, &p); + if let Err(EngineError::Internal { cmd, msg }) = &res { + panic!("seed {seed} step {step}: {cmd} panicked: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "seed {seed} step {step} {cmd} {p}: {:?}", doc.validate()); + assert!(mirror_issues(doc).is_empty(), "seed {seed} step {step} {cmd} {p} -> {res:?}: {:?}", mirror_issues(doc)); + } + // Undo everything: still valid at every step. + while s.undo().unwrap() { + assert!(s.doc().unwrap().validate().is_empty()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29180fc..dd469c7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -418,7 +418,7 @@ impl Session { pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid)); - let result = guard::run(id, || (spec.run)(self, params)); + let result = guard::run(id, || (spec.run)(self, params)).and_then(|v| self.check_instance_structure(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); @@ -445,6 +445,16 @@ impl Session { result } + /// Instances can't gain, lose or reorder layers: refuse such an edit (it is rolled back). + fn check_instance_structure(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + components::check_structure_edit(bdoc, &ds.doc).map_err(|m| EngineError::Disabled(id.to_string(), m)) + } + /// Bookkeeping after a successful command: normalise what changed, push undo, bump revision. fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc, Vec, NodeId, u64)>) { let Some((bdoc, bsel, bpage, uid)) = before else {