From 0b9fcb7f4e4519efc9ff2761ff8497428cd17bd6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH] refactor(engine): split cmd/component.rs into component/ --- crates/engine/src/cmd/component.rs | 1193 +------------------ crates/engine/src/cmd/component/instance.rs | 404 +++++++ crates/engine/src/cmd/component/props.rs | 609 ++++++++++ crates/engine/src/cmd/component/variants.rs | 175 +++ 4 files changed, 1199 insertions(+), 1182 deletions(-) create mode 100644 crates/engine/src/cmd/component/instance.rs create mode 100644 crates/engine/src/cmd/component/props.rs create mode 100644 crates/engine/src/cmd/component/variants.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 428738d1..5abe79b8 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,17 @@ use crate::components::*; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +mod instance; +mod props; +mod variants; + +use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, reset_override, reset_slot, restore_main, slot_insert, swap}; +pub(crate) use instance::{swap_at, swap_instance}; +use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props}; +pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props}; +use variants::{add_variant, combine}; +pub use variants::{can_add_variant, can_combine_variants}; + /// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and /// sets (those `ids` are or are in), put on or taken off. fn hide_when_publishing(s: &mut Session, p: &Value) -> Result { @@ -343,1185 +354,3 @@ fn set_name_from(variant: &str) -> Option { let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) } - -/// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - 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(), INSIDE_INSTANCE.into())); - } - 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)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); - 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 i in ids { - doc.reparent(*i, set, None)?; - } - Ok(set) -} - -const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; - -/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they -/// can: anything but main components, components that are variants already (combining them would -/// put a set in a set), library copies, components inside an instance, and a component inside -/// another one of `ids`. -fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { - if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - Some("select components only") - } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { - Some("components that are variants already can't be combined again") - } else if ids.iter().any(|i| doc.is_remote(*i)) { - Some(crate::library::READ_ONLY) - } else if ids.iter().any(|i| inside_instance(doc, *i)) { - Some(INSIDE_INSTANCE) - } else if roots(doc, ids).len() != ids.len() { - Some("a component can't be combined with one inside it") - } else { - None - } -} - -/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more -/// main components that [`combine_refusal`] lets through. -pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { - ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() -} - -/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main -/// component not in a set yet (it becomes one); never a library copy or inside an instance. -pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { - let Some(n) = doc.node(id) else { return false }; - let local = !doc.is_remote(id) && !inside_instance(doc, id); - match n.kind { - NodeKind::ComponentSet { .. } => { - local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) - } - NodeKind::Component { .. } => local, - _ => false, - } -} - -/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", -/// "Property 1=VariantN" unless already in prop=value form. Returns the set. -fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let set = wrap_in_set(cmd, doc, ids)?; - for (k, i) in ids.iter().enumerate() { - let node = doc.get_mut(*i)?; - if !node.name.contains('=') { - node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); - } - } - Ok(set) -} - -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let mut seen = BTreeSet::new(); - let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); - let doc = s.doc_mut()?; - if let Some(why) = combine_refusal(doc, &ids) { - return Err(EngineError::Disabled(cmd.into(), why.into())); - } - let ids = z_sorted(doc, &ids); - let set = make_variant_set(cmd, doc, &ids)?; - s.set_selection(vec![set]); - 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 component"))?; - 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 { .. } => match variant_set(doc, t) { - Some(set) => (set, t), - // A main component on its own becomes a set of itself, then gains the new variant. - None => (make_variant_set(cmd, doc, &[t])?, t), - }, - _ => return Err(bad(cmd, "select a component set or component")), - }; - 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 cmd = "component.detach"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for i in ids { - 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 (slot content stays as it is); nested instances stay - // instances of their own mains. - let nested = unlink_layers(doc, i, Some(i)); - keep_main_modes(doc, i)?; - let n = doc.get_mut(i)?; - n.kind = NodeKind::Frame; - n.source = None; - n.overrides.clear(); - n.prop_refs.remove("mainComponent"); - // No properties left to drive. - n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - for nn in nested { - 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})) -} - -/// Make the variable modes instance `i` takes from its main its own (it's being detached). -fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { - let modes = match doc.get(i)?.kind { - NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), - _ => return Ok(()), - }; - let own = &mut doc.get_mut(i)?.var_modes; - for (c, m) in modes { - own.entry(c).or_insert(m); - } - Ok(()) -} - -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 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; - } - // ...and stop following variables. - if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { - doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - } - 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})) -} - -/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. -fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { - let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); - let base = if base.is_empty() { "Slot".to_string() } else { base }; - let taken = |n: &str| { - owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) - || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) - }; - (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) -} - -/// The `preferred` components of a slot property: component or component set ids. -fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { - let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; - let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; - let mut out = Vec::new(); - for x in list.iter().take(500) { - let c = id_of(x) - .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) - .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; - if !out.contains(&c) { - out.push(c); - } - } - Ok(out) -} - -fn make_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.makeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; - let doc = s.doc()?; - let n = doc.get(id)?; - let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); - if !matches!(n.kind, NodeKind::Frame) || !inside_main { - return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); - } - if is_slot(doc, id) { - return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); - } - let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; - let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); - let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) -} - -fn remove_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.removeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; - let doc = s.doc()?; - if owning_instance(doc, id).is_some() { - return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); - } - let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; - delete_prop(s, &json!({"id": id, "name": name}))?; - Ok(json!({"id": id, "name": name})) -} - -/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. -fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { - let slot = match name { - Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, - None => id, - }; - if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { - return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); - } - Ok(slot) -} - -fn slot_insert(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.slotInsert"; - let c = req_id(cmd, p, "component")?; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let index = match get(p, "index").filter(|v| !v.is_null()) { - None => None, - Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), - }; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - if boolean(p, "replace").unwrap_or(false) { - for k in doc.get(slot)?.children.clone() { - doc.remove(k)?; - } - } - let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; - s.set_selection(vec![i]); - Ok(json!({"id": i, "slot": slot})) -} - -fn reset_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.resetSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; - let changed = slot_edited(doc, slot); - if changed { - doc.get_mut(slot)?.overrides.remove(SLOT); - } - Ok(json!({"id": slot, "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()?; - // Changes go to the outer instance's main (never into a nested instance's own main). - let inst = root_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 (geometry, resizing, swaps, - // property values and slot content stay with the instance). - 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) || !doc.is_ancestor(component, src) { - continue; - } - let Some(mut target) = doc.node(src).cloned() else { continue }; - let groups: BTreeSet = n - .overrides - .iter() - .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) - .cloned() - .collect(); - for g in &groups { - copy_group(&n, &mut target, g); - } - 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.retain(|g| !groups.contains(g)); - } - } - Ok(json!({"layers": pushed})) -} - -fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { - 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, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } - defs -} - -fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { - 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); - } - map -} - -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 map = restore_id_map(doc, i, component, &sub); - 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 defs = restore_prop_defs(doc, &props); - 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"))?; - for i in ids { - swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; - } - ok() -} - -/// Make instance `i` show main component `c`, keeping the values of properties with the same names -/// (the layers' overrides follow by name when the instance is rebuilt). -pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { - if doc.is_ancestor(c, i) { - return Err("an instance can't show the component it is inside".into()); - } - let defaults = doc.node(c).map(default_props).unwrap_or_default(); - match doc.get_mut(i).map(|n| &mut n.kind) { - Ok(NodeKind::Instance { component, props }) => { - *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(); - Ok(()) - } - _ => Err(format!("{i} is not an instance")), - } -} - -/// Swap the instance under a page point to a component (`instance.swapAt`). -pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swapAt"; - let c = req_id(cmd, p, "component")?; - let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let page = s.page()?; - let doc = s.doc()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - let family = crate::drop::swap_family(doc, c); - let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; - keep_text_overrides(s.doc_mut()?, target); - swap(s, &json!({"ids": [target], "component": c}))?; - s.set_selection(vec![target]); - Ok(json!({"id": target, "component": c})) -} - -/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a -/// boolean property takes a boolean variable, a text or variant property a string one). -pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { - return Err(format!("{id} is not an instance (component properties are bound on instances)")); - }; - if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { - return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; - } - match (prop_defs(doc, *component).get(name), kind) { - (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), - (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), - (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), - (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), - (None, _) => Err(format!("the instance has no property named `{name}`")), - } -} - -/// Write bound variable values into instance `id`'s boolean and text property values, and switch -/// it to the variant its bound variant properties name (only what changed; the instance then -/// follows like after `instance.setProperties`). A value that names no variant changes nothing. -pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; - let component = *component; - if let Some(set) = variant_set(doc, component) { - let options = variant_options(doc, set); - let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut wanted = current.clone(); - for (name, val) in sets { - if let VariableValue::String(t) = val - && options.iter().any(|(o, vals)| o == name && vals.contains(t)) - { - wanted.insert(name.clone(), t.clone()); - } - } - if wanted != current - && let Some(target) = find_variant(doc, set, &wanted) - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { component: c, .. } = &mut n.kind - { - *c = target; - } - } - let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; - let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; - let mut next = props.clone(); - for (name, val) in sets { - let v = match (defs.get(name), val) { - (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), - (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), - _ => continue, - }; - next.insert(name.clone(), v); - } - if next != *props - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { props, .. } = &mut n.kind - { - *props = next; - } -} - -/// 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:?}"))) } - } - PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), - } -} - -fn set_props(s: &mut Session, p: &Value) -> Result { - 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 { - return Err(bad(cmd, format!("{i} is not an instance"))); - }; - // Variant properties: switch to the matching sibling variant. - 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 target = component; - let mut rest = Vec::new(); - for (k, v) in &props { - 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())); - } - } - if let Some(set) = set - && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() - { - 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.iter().filter(|(_, d)| takes_value(d)) { - 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 { - add_prop_for("component.addProperty", s, p) -} - -/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its -/// slot property here). -fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - 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() { - // Without a default, a bound layer keeps showing as it does: its visibility is the default. - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, - "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" => { - 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, preferred: preferred_arg(cmd, doc, p)? } - } - "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(cmd, 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}") }; - } - s.set_selection(vec![set]); - return Ok(json!({"id": set})); - } - "slot" => { - if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { - return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); - } - PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } - } - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), - }; - for c in &comps { - set_prop_defs(doc, *c, |m| { - m.insert(name.clone(), def.clone()); - })?; - // Existing instances get the default. - if !takes_value(&def) { - continue; - } - 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)); - } - } - } - if let Some(l) = layer { - bind(cmd, doc, l, Some(&name), None)?; - } - 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")), - PropDef::Slot { .. } => SLOT, - }; - 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")), - SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), - SLOT if doc - .subtree(comp) - .into_iter() - .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => - { - return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); - } - _ => {} - } - let n = doc.get_mut(layer)?; - n.prop_refs.insert(field.to_string(), property.to_string()); - // On the main, a boolean property's value is the visibility of the layers it controls. - if let PropDef::Boolean { default } = def { - n.visible = *default; - } - 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" | SLOT) - { - return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); - } - 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), - } - } - } - // Instances' values and the variables bound to them follow (a deleted property drops both). - let old_key = format!("{PROP_BINDING}{old}"); - for inst in instances_of(doc, comp) { - let n = doc.get_mut(inst)?; - if let NodeKind::Instance { props, .. } = &mut n.kind - && let Some(v) = props.remove(old) - && let Some(nw) = new - { - props.insert(nw.to_string(), v); - } - if let Some(var) = n.bound.remove(&old_key) - && let Some(nw) = new - { - n.bound.insert(format!("{PROP_BINDING}{nw}"), var); - } - } - 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 mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); - // Reordered: every variant's name lists the properties in the new order. - if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { - let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; - let key = new_name.clone().unwrap_or(name.clone()); - order.retain(|k| *k != key); - order.insert(to.min(order.len()), key); - } - 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 top-left one: the variant with that value (and the default's - // other values, when there is one) trades places with it. - if let Some(dv) = string(p, "default") { - let key = new_name.clone().unwrap_or(name.clone()); - let current = default_variant(doc, set); - let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - wanted.insert(key.clone(), dv.clone()); - let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); - let pick = - comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); - let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; - if f != d { - let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); - let (fp, dp) = (place(doc, f), place(doc, d)); - for (c, (x, y)) in [(f, dp), (d, fp)] { - let n = doc.get_mut(c)?; - let [a, b, cc, dd, ..] = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); - } - fit_set(doc, set); - } - } - 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 preferred = if get(p, "preferred").is_some() { - if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { - return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); - } - Some(preferred_arg(cmd, doc, p)?) - } else { - None - }; - let mut 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 { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, - _ => def.clone(), - }, - }, - None => def.clone(), - }; - if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { - *preferred = list; - } - 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))?; - } - } - // The main's bound layers show the new default. - if let PropDef::Boolean { default } = new_def { - set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); - } - 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, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), - PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), - PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), - } -} - -/// Properties of a component, set or instance as JSON (`component.properties`). -pub(crate) 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(); - let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - for (k, vals) in variant_options(doc, set) { - let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); - if values.is_some() { - v["value"] = json!(current.get(&k)); - } - list.push(v); - } - } - // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. - let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); - for (k, d) in prop_defs(doc, comp) { - let mut v = def_json(&k, &d); - if !takes_value(&d) { - let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); - if values.is_some() { - v["value"] = layers.first().map_or(Value::Null, |l| { - let count = doc.node(*l).map_or(0, |n| n.children.len()); - json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) - }); - } else { - v["layers"] = json!(layers); - } - } else if let Some(vals) = &values { - v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); - } - list.push(v); - } - let set = variant_set(doc, comp); - let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); - json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) -} diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs new file mode 100644 index 00000000..ffc5ec1c --- /dev/null +++ b/crates/engine/src/cmd/component/instance.rs @@ -0,0 +1,404 @@ +use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; +use super::*; + +pub(super) fn detach(s: &mut Session, p: &Value) -> Result { + 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 { + 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 (slot content stays as it is); nested instances stay + // instances of their own mains. + let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; + let n = doc.get_mut(i)?; + n.kind = NodeKind::Frame; + n.source = None; + n.overrides.clear(); + n.prop_refs.remove("mainComponent"); + // No properties left to drive. + n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + for nn in nested { + 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})) +} + +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + +pub(super) 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 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; + } + // ...and stop following variables. + if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { + doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + } + done.push(inst); + } + if done.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + Ok(json!({"ids": done})) +} + +pub(super) 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})) +} + +/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. +fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { + let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); + let base = if base.is_empty() { "Slot".to_string() } else { base }; + let taken = |n: &str| { + owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) + || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) + }; + (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) +} + +/// The `preferred` components of a slot property: component or component set ids. +pub(super) fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { + let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; + let mut out = Vec::new(); + for x in list.iter().take(500) { + let c = id_of(x) + .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) + .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; + if !out.contains(&c) { + out.push(c); + } + } + Ok(out) +} + +pub(super) fn make_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.makeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; + let doc = s.doc()?; + let n = doc.get(id)?; + let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); + if !matches!(n.kind, NodeKind::Frame) || !inside_main { + return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); + } + if is_slot(doc, id) { + return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); + } + let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; + let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); + let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) +} + +pub(super) fn remove_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.removeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; + let doc = s.doc()?; + if owning_instance(doc, id).is_some() { + return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); + } + let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; + delete_prop(s, &json!({"id": id, "name": name}))?; + Ok(json!({"id": id, "name": name})) +} + +/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. +fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { + let slot = match name { + Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, + None => id, + }; + if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { + return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); + } + Ok(slot) +} + +pub(super) fn slot_insert(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.slotInsert"; + let c = req_id(cmd, p, "component")?; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let index = match get(p, "index").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), + }; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + if boolean(p, "replace").unwrap_or(false) { + for k in doc.get(slot)?.children.clone() { + doc.remove(k)?; + } + } + let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; + s.set_selection(vec![i]); + Ok(json!({"id": i, "slot": slot})) +} + +pub(super) fn reset_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.resetSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; + let changed = slot_edited(doc, slot); + if changed { + doc.get_mut(slot)?.overrides.remove(SLOT); + } + Ok(json!({"id": slot, "changed": changed})) +} + +pub(super) 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()?; + // Changes go to the outer instance's main (never into a nested instance's own main). + let inst = root_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 (geometry, resizing, swaps, + // property values and slot content stay with the instance). + 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) || !doc.is_ancestor(component, src) { + continue; + } + let Some(mut target) = doc.node(src).cloned() else { continue }; + let groups: BTreeSet = n + .overrides + .iter() + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) + .cloned() + .collect(); + for g in &groups { + copy_group(&n, &mut target, g); + } + 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.retain(|g| !groups.contains(g)); + } + } + Ok(json!({"layers": pushed})) +} + +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + 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, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + 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); + } + map +} + +pub(super) 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 map = restore_id_map(doc, i, component, &sub); + 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 defs = restore_prop_defs(doc, &props); + 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})) +} + +pub(super) 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"))?; + for i in ids { + swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; + } + ok() +} + +/// Make instance `i` show main component `c`, keeping the values of properties with the same names +/// (the layers' overrides follow by name when the instance is rebuilt). +pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { + if doc.is_ancestor(c, i) { + return Err("an instance can't show the component it is inside".into()); + } + let defaults = doc.node(c).map(default_props).unwrap_or_default(); + match doc.get_mut(i).map(|n| &mut n.kind) { + Ok(NodeKind::Instance { component, props }) => { + *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(); + Ok(()) + } + _ => Err(format!("{i} is not an instance")), + } +} + +/// Swap the instance under a page point to a component (`instance.swapAt`). +pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swapAt"; + let c = req_id(cmd, p, "component")?; + let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let page = s.page()?; + let doc = s.doc()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let family = crate::drop::swap_family(doc, c); + let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + keep_text_overrides(s.doc_mut()?, target); + swap(s, &json!({"ids": [target], "component": c}))?; + s.set_selection(vec![target]); + Ok(json!({"id": target, "component": c})) +} diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs new file mode 100644 index 00000000..3f2d7002 --- /dev/null +++ b/crates/engine/src/cmd/component/props.rs @@ -0,0 +1,609 @@ +use super::instance::preferred_arg; +use super::variants::{fit_set, wrap_in_set}; +use super::*; + +/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a +/// boolean property takes a boolean variable, a text or variant property a string one). +pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { + return Err(format!("{id} is not an instance (component properties are bound on instances)")); + }; + if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { + return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; + } + match (prop_defs(doc, *component).get(name), kind) { + (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), + (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), + (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), + (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), + (None, _) => Err(format!("the instance has no property named `{name}`")), + } +} + +/// Write bound variable values into instance `id`'s boolean and text property values, and switch +/// it to the variant its bound variant properties name (only what changed; the instance then +/// follows like after `instance.setProperties`). A value that names no variant changes nothing. +pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; + let component = *component; + if let Some(set) = variant_set(doc, component) { + let options = variant_options(doc, set); + let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut wanted = current.clone(); + for (name, val) in sets { + if let VariableValue::String(t) = val + && options.iter().any(|(o, vals)| o == name && vals.contains(t)) + { + wanted.insert(name.clone(), t.clone()); + } + } + if wanted != current + && let Some(target) = find_variant(doc, set, &wanted) + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { component: c, .. } = &mut n.kind + { + *c = target; + } + } + let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; + let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; + let mut next = props.clone(); + for (name, val) in sets { + let v = match (defs.get(name), val) { + (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), + (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), + _ => continue, + }; + next.insert(name.clone(), v); + } + if next != *props + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { props, .. } = &mut n.kind + { + *props = next; + } +} + +/// 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:?}"))) } + } + PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), + } +} + +pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { + 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 { + return Err(bad(cmd, format!("{i} is not an instance"))); + }; + // Variant properties: switch to the matching sibling variant. + 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 target = component; + let mut rest = Vec::new(); + for (k, v) in &props { + 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())); + } + } + if let Some(set) = set + && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() + { + 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.iter().filter(|(_, d)| takes_value(d)) { + 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. +pub(super) enum Owner { + Set(NodeId), + Comp(NodeId), +} + +impl Owner { + pub(super) 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), + }) + } + } + } + + pub(super) 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, + } + } +} + +pub(super) 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()) +} + +pub(super) fn add_prop(s: &mut Session, p: &Value) -> Result { + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + 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() { + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, + "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" => { + 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, preferred: preferred_arg(cmd, doc, p)? } + } + "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(cmd, 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}") }; + } + s.set_selection(vec![set]); + return Ok(json!({"id": set})); + } + "slot" => { + if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { + return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); + } + PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } + } + _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), + }; + for c in &comps { + set_prop_defs(doc, *c, |m| { + m.insert(name.clone(), def.clone()); + })?; + // Existing instances get the default. + if !takes_value(&def) { + continue; + } + 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)); + } + } + } + if let Some(l) = layer { + bind(cmd, doc, l, Some(&name), None)?; + } + 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")), + PropDef::Slot { .. } => SLOT, + }; + 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")), + SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), + SLOT if doc + .subtree(comp) + .into_iter() + .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => + { + return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); + } + _ => {} + } + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } + Ok(()) +} + +pub(super) 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" | SLOT) + { + return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); + } + 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), + } + } + } + // Instances' values and the variables bound to them follow (a deleted property drops both). + let old_key = format!("{PROP_BINDING}{old}"); + for inst in instances_of(doc, comp) { + let n = doc.get_mut(inst)?; + if let NodeKind::Instance { props, .. } = &mut n.kind + && let Some(v) = props.remove(old) + && let Some(nw) = new + { + props.insert(nw.to_string(), v); + } + if let Some(var) = n.bound.remove(&old_key) + && let Some(nw) = new + { + n.bound.insert(format!("{PROP_BINDING}{nw}"), var); + } + } + Ok(()) +} + +pub(super) 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 mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + // Reordered: every variant's name lists the properties in the new order. + if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { + let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; + let key = new_name.clone().unwrap_or(name.clone()); + order.retain(|k| *k != key); + order.insert(to.min(order.len()), key); + } + 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 top-left one: the variant with that value (and the default's + // other values, when there is one) trades places with it. + if let Some(dv) = string(p, "default") { + let key = new_name.clone().unwrap_or(name.clone()); + let current = default_variant(doc, set); + let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + wanted.insert(key.clone(), dv.clone()); + let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); + let pick = + comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); + let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; + if f != d { + let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); + let (fp, dp) = (place(doc, f), place(doc, d)); + for (c, (x, y)) in [(f, dp), (d, fp)] { + let n = doc.get_mut(c)?; + let [a, b, cc, dd, ..] = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); + } + fit_set(doc, set); + } + } + 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 preferred = if get(p, "preferred").is_some() { + if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { + return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); + } + Some(preferred_arg(cmd, doc, p)?) + } else { + None + }; + let mut 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 { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { + *preferred = list; + } + 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))?; + } + } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } + ok() +} + +pub(super) 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, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), + PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), + } +} + +/// Properties of a component, set or instance as JSON (`component.properties`). +pub(crate) 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(); + let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + for (k, vals) in variant_options(doc, set) { + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); + if values.is_some() { + v["value"] = json!(current.get(&k)); + } + list.push(v); + } + } + // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. + let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); + for (k, d) in prop_defs(doc, comp) { + let mut v = def_json(&k, &d); + if !takes_value(&d) { + let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); + if values.is_some() { + v["value"] = layers.first().map_or(Value::Null, |l| { + let count = doc.node(*l).map_or(0, |n| n.children.len()); + json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) + }); + } else { + v["layers"] = json!(layers); + } + } else if let Some(vals) = &values { + v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); + } + list.push(v); + } + let set = variant_set(doc, comp); + let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); + json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) +} diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs new file mode 100644 index 00000000..f6e7ac6c --- /dev/null +++ b/crates/engine/src/cmd/component/variants.rs @@ -0,0 +1,175 @@ +use super::*; + +/// Wrap components in a new component set (named after the first) placed around them. +pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + 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(), INSIDE_INSTANCE.into())); + } + 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)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); + 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 i in ids { + doc.reparent(*i, set, None)?; + } + Ok(set) +} + +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { + if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None + } +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; + for (k, i) in ids.iter().enumerate() { + let node = doc.get_mut(*i)?; + if !node.name.contains('=') { + node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); + } + } + Ok(set) +} + +pub(super) fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; + s.set_selection(vec![set]); + Ok(json!({"id": set})) +} + +/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). +pub(super) 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); + } +} + +pub(super) 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 component"))?; + 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 { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), + }; + 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})) +}