diff --git a/ROADMAP.md b/ROADMAP.md index 3f33d94..89c31d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,8 +70,15 @@ dimension measured side by side. show on the main's instances); component descriptions (edited on the main, shown on instances); local libraries (publish a file's components, sets and styles with per-asset versions, enable library files, place library components and apply library styles as - read-only linked copies, "Library updates available" with overrides kept on update). - *Left:* nested override edge cases, slots, library variables. 7–11 h. + read-only linked copies, "Library updates available" with overrides kept on update); slots + (a frame of a main made a slot property with preferred components; instances fill it with any + content of their own, kept across main updates, variant switches, copy/paste and detach, with + the main's default content until edited; reset slot; slot badges in the layers panel and on the + canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in + slots); nested overrides kept when the main moves or swaps a nested instance, and on nested + instances copied out of an instance (re-linked to their own main); canvas drags can no longer + pull layers out of an instance. + *Left:* library variables. 2–4 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -113,8 +120,7 @@ dimension measured side by side. ### Where we're going - **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers, - a real-file corpus); variable width strokes; component slots and nested override edge cases - (in progress). + a real-file corpus); variable width strokes (in progress); library variables. - **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. @@ -141,7 +147,7 @@ dimension measured side by side. eyedropper), text editing (caret, selection, words, lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, flip/rotate, scale tool, booleans, flatten, outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, - variants, properties), styles and variables, prototype commands, find/replace, batch rename, + variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG in/out, PNG/JPG/WebP), version snapshots. - **UI (egui, swappable):** the incumbent's 2026 layout: tab strip, icon rail, file header, pages and @@ -170,7 +176,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 | | Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 | | Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 | -| Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 | +| Components & variants | 9 | 71% | library variables | 2–4 | | Styles & variables | 5 | 82% | bindings that follow effects when they are reordered, grid and component property bindings | 1–2 | | Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index a7985c4..3859752 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -37,10 +37,25 @@ impl BoolOp { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PropDef { - Boolean { default: bool }, - Text { default: String }, - InstanceSwap { default: NodeId }, - Variant { options: Vec, default: String }, + Boolean { + default: bool, + }, + Text { + default: String, + }, + InstanceSwap { + default: NodeId, + }, + Variant { + options: Vec, + default: String, + }, + /// A frame (bound through `prop_refs["slot"]`) whose instances take any content of their own; + /// `preferred` components are offered first when filling it. + Slot { + #[serde(default)] + preferred: Vec, + }, } /// What a node is, with per-type data. @@ -380,11 +395,13 @@ pub struct Node { /// Handoff status of a frame or section (shown as a badge next to its name). #[serde(default, skip_serializing_if = "DevStatus::is_none")] pub dev_status: DevStatus, - /// Inside an instance: property groups overridden locally ("fills", "characters", …). + /// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot": + /// this slot holds the instance's own content). #[serde(default)] pub overrides: BTreeSet, /// Component property references: which property controls this node's - /// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap). + /// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot + /// property this frame is ("slot"). #[serde(default)] pub prop_refs: BTreeMap, /// Free-form plugin / agent data. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index d530d09..09419dc 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -93,12 +93,40 @@ pub fn specs() -> Vec { Ok(json!({"instances": out})) } ), + spec!( + "component.makeSlot", + "Make slot", + "{id? (a frame inside a main component), name? (the slot property's name; the frame's by default), preferred? [component or set ids]} (its instances take any content there)", + true, + make_slot + ), + spec!( + "component.removeSlot", + "Remove slot", + "{id? (a slot frame of a main component)} (instances lose their own content in it)", + true, + remove_slot + ), + spec!( + "instance.slotInsert", + "Insert into slot", + "{id? (a slot inside an instance, or the instance with `slot`), slot? (slot property name), component, index? (child index; on top when omitted), replace? (clear the slot first)}", + true, + slot_insert + ), + spec!( + "instance.resetSlot", + "Reset slot", + "{id? (a slot inside an instance, or the instance with `slot`), slot?} (back to the main's default content)", + true, + reset_slot + ), spec!("component.detach", "Detach instance", "{ids?}", true, detach), spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance), spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props|slot; all when omitted)}", true, reset_override ), @@ -143,14 +171,14 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant, default?, layer? (node bound to it)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (slots: component ids)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value)}", + "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (slots: component ids)}", true, edit_prop ), @@ -158,7 +186,7 @@ pub fn specs() -> Vec { spec!( "component.bindProperty", "Bind layer to property", - "{layer?, property (null/\"\" unbinds), field?: visible|characters|mainComponent}", + "{layer?, property (null/\"\" unbinds), field?: visible|characters|mainComponent|slot}", true, bind_prop ), @@ -397,30 +425,15 @@ fn detach(s: &mut Session, p: &Value) -> Result { if inside_instance(doc, i) { return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); } - // Own layers lose their link; nested instances stay instances of their own mains. - let mut nested = Vec::new(); - for d in doc.subtree(i).into_iter().filter(|d| *d != i) { - let parent_inst = doc.node(d).and_then(|n| n.parent).and_then(|p| owning_instance(doc, p)); - if parent_inst != Some(i) { - continue; - } - if matches!(doc.node(d).map(|n| &n.kind), Some(NodeKind::Instance { .. })) { - nested.push(d); - } else if let Some(n) = doc.node_mut(d) { - n.source = None; - n.overrides.clear(); - n.prop_refs.clear(); - } - } + // 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)); let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; n.source = None; n.overrides.clear(); n.prop_refs.remove("mainComponent"); for nn in nested { - if let Some(m) = doc.node_mut(nn) { - m.prop_refs.clear(); - } rebase_instance(doc, nn); } out.push(i); @@ -503,6 +516,108 @@ fn reset_override(s: &mut Session, p: &Value) -> Result { 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(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"))?; @@ -703,6 +818,7 @@ fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Re 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"))), } } @@ -754,7 +870,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result { for (k, v) in values { ip.insert(k, v); } - for (k, d) in &defs { + for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); } } @@ -877,13 +993,22 @@ fn add_prop(s: &mut Session, p: &Value) -> Result { s.set_selection(vec![set]); return Ok(json!({"id": set})); } - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")), + "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)); @@ -920,6 +1045,7 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi 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 { @@ -929,6 +1055,14 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi "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}`"))); + } _ => {} } doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); @@ -941,9 +1075,9 @@ fn bind_prop(s: &mut Session, p: &Value) -> Result { let property = string(p, "property"); let field = string(p, "field"); if let Some(f) = &field - && !matches!(f.as_str(), "visible" | "characters" | "mainComponent") + && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) { - return Err(bad(cmd, "field: visible|characters|mainComponent")); + return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); } let doc = s.doc_mut()?; bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; @@ -1034,16 +1168,20 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { return ok(); } let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; - let new_def = match get(p, "default") { - Some(v) => match coerce(cmd, doc, &name, &def, v)? { - Value::Bool(b) => PropDef::Boolean { default: b }, - Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, - other => match (&def, id_of(&other)) { - (PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c }, - _ => def.clone(), + let new_def = match (&def, get(p, "preferred")) { + (PropDef::Slot { .. }, Some(_)) => PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? }, + (_, Some(_)) => return Err(bad(cmd, "`preferred` is for slot properties")), + (_, None) => match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c }, + _ => def.clone(), + }, }, + None => def.clone(), }, - None => def.clone(), }; for c in &comps { let had = prop_defs(doc, *c).contains_key(&name); @@ -1105,6 +1243,7 @@ fn def_json(name: &str, d: &PropDef) -> Value { PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), PropDef::InstanceSwap { default } => json!({"name": name, "type": "instanceSwap", "default": default}), PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), } } @@ -1127,9 +1266,21 @@ fn describe_props(doc: &Document, id: NodeId) -> Value { 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 let Some(vals) = &values { + 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); diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 49fd31a..47bbeba 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -101,7 +101,8 @@ pub fn specs() -> Vec { /// The top-most frame-like (or section) node on the page containing page point `pt`, /// excluding `exclude` and their descendants. pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> Option { - fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize) -> Option { + /// `locked`: inside an instance, where only slots (and what's in them) take layers. + fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize, locked: bool) -> Option { if depth > 128 || exclude.contains(&id) { return None; } @@ -117,17 +118,16 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> if is_container && !inside { return None; } - if !matches!(n.kind, NodeKind::Instance { .. }) { - for c in n.children.iter().rev() { - if let Some(f) = rec(doc, *c, pt, exclude, depth + 1) { - return Some(f); - } + let locked = crate::components::locks_children(doc, id, locked); + for c in n.children.iter().rev() { + if let Some(f) = rec(doc, *c, pt, exclude, depth + 1, locked) { + return Some(f); } } - (is_container && inside && !matches!(n.kind, NodeKind::Instance { .. })).then_some(id) + (is_container && inside && !locked).then_some(id) } let p = doc.node(page)?; - p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0)) + p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0, false)) } pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> { diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index a6ce142..23ee181 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -9,11 +9,17 @@ //! `prop_refs` on the layers they control. //! //! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, -//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance) and -//! `props` (property values of a nested instance). +//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance), +//! `props` (property values of a nested instance) and `slot` (a slot holding the instance's own +//! content). //! //! Instances never gain or lose layers of their own: commands that would add, remove or reorder -//! layers inside an instance are refused ([`check_structure_edit`]). +//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A +//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An +//! instance's copy of it shows the main's default content until its layers are edited; from then +//! on (override group `slot`) its children are the instance's own content: free to edit, kept +//! across syncs and variant switches, and outside the instance's structure (instances placed in it +//! stand alone). use std::collections::{BTreeMap, BTreeSet}; @@ -23,14 +29,48 @@ use serde_json::Value; /// Nesting limit for instances inside instances. const MAX_DEPTH: usize = 16; +/// The override group (and `prop_refs` field) of slots. +pub const SLOT: &str = "slot"; /// Every override group a layer can carry. -pub const OVERRIDE_GROUPS: &[&str] = - &["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position", "component", "props"]; +pub const OVERRIDE_GROUPS: &[&str] = &[ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "size", + "position", + "component", + "props", + SLOT, +]; fn is_instance(doc: &Document, id: NodeId) -> bool { doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) } +/// A slot (inside an instance) whose children are the instance's own content. +fn holds_content(n: &Node) -> bool { + n.overrides.contains(SLOT) +} + +/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content: +/// content belongs to no instance (it edits freely, and instances placed in it stand alone). +fn scope(doc: &Document, id: NodeId) -> Vec { + let mut out = vec![id]; + for a in doc.ancestors(id) { + if doc.node(a).is_some_and(holds_content) { + break; + } + out.push(a); + } + out +} + /// The component a node belongs to (itself, or the nearest component ancestor). pub fn owning_component(doc: &Document, id: NodeId) -> Option { let mut chain = vec![id]; @@ -40,21 +80,117 @@ pub fn owning_component(doc: &Document, id: NodeId) -> Option { /// The innermost instance containing `id` (`id` itself when it is an instance). pub fn owning_instance(doc: &Document, id: NodeId) -> Option { - let mut chain = vec![id]; - chain.extend(doc.ancestors(id)); - chain.into_iter().find(|c| is_instance(doc, *c)) + scope(doc, id).into_iter().find(|c| is_instance(doc, *c)) } /// The outermost instance containing `id` (`id` itself when it is a top-level instance). pub fn root_instance(doc: &Document, id: NodeId) -> Option { - let mut chain = vec![id]; - chain.extend(doc.ancestors(id)); - chain.into_iter().rfind(|c| is_instance(doc, *c)) + scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c)) +} + +/// The innermost instance `id` is a layer of (never `id` itself). +fn enclosing_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c)) } /// Whether `id` is a layer inside an instance (not the outermost instance itself). pub fn inside_instance(doc: &Document, id: NodeId) -> bool { - doc.node(id).and_then(|n| n.parent).is_some_and(|p| owning_instance(doc, p).is_some()) + enclosing_instance(doc, id).is_some() +} + +/// The slot property frame `id` stands for (its name and preferred components): a frame bound to +/// a slot property of its component, or an instance's copy of one (followed through its sources). +pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { + let mut cur = id; + for _ in 0..=MAX_DEPTH { + let n = doc.node(cur)?; + if !matches!(n.kind, NodeKind::Frame) { + return None; + } + if let Some(name) = n.prop_refs.get(SLOT) { + let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None }; + return match props.get(name) { + Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())), + _ => None, + }; + } + cur = n.source?; + } + None +} + +/// The name of the slot property frame `id` stands for ([`slot_def`]). +pub fn slot_name(doc: &Document, id: NodeId) -> Option { + slot_def(doc, id).map(|(name, _)| name.to_string()) +} + +/// Whether `id` is a slot (in a main component, or an instance's copy of one). +pub fn is_slot(doc: &Document, id: NodeId) -> bool { + slot_def(doc, id).is_some() +} + +/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's +/// default content. +pub fn slot_edited(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(holds_content) +} + +/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and +/// reordered freely. +pub fn slot_scope(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + for c in chain { + if is_instance(doc, c) { + return None; + } + if is_slot(doc, c) { + return Some(c); + } + } + None +} + +/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an +/// instance, outside its slots. +pub fn structure_locked(doc: &Document, id: NodeId) -> bool { + owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none() +} + +/// For tree walks that look for where layers can go: whether the children of `id` are locked (in +/// an instance, outside its slots), given whether `id` itself is (`locked`). +pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool { + is_instance(doc, id) || (locked && !is_slot(doc, id)) +} + +/// Whether layer `id`'s place (parent and position among its siblings) changed between two +/// versions only where instance structure allows it. +pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool { + let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id))); + let (was, is) = (place(before), place(after)); + was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p))) +} + +/// The slots of a component or instance (its own: not those of instances nested in it, nor of the +/// content put into its slots), with their property names, top-most first. +pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 1_000_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + if let Some(name) = slot_name(doc, c) { + out.push((c, name)); + } + if !is_instance(doc, c) && !holds_content(n) { + stack.extend(n.children.iter().rev().copied()); + } + } + out } /// All instances of `component` in the document. @@ -73,17 +209,24 @@ pub fn default_instance_name(doc: &Document, component: NodeId) -> String { set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into()) } -/// Default property values of a component. +/// Default property values of a component (slots hold content, not values: they have none). pub fn default_props(comp: &Node) -> BTreeMap { let mut out = BTreeMap::new(); if let NodeKind::Component { props, .. } = &comp.kind { for (k, d) in props { - out.insert(k.clone(), prop_default_value(d)); + if takes_value(d) { + out.insert(k.clone(), prop_default_value(d)); + } } } out } +/// Whether instances store a value for the property (every kind but slots). +pub fn takes_value(d: &PropDef) -> bool { + !matches!(d, PropDef::Slot { .. }) +} + /// A property definition's default as a JSON value. pub fn prop_default_value(d: &PropDef) -> Value { match d { @@ -91,6 +234,7 @@ pub fn prop_default_value(d: &PropDef) -> Value { PropDef::Text { default } => Value::String(default.clone()), PropDef::InstanceSwap { default } => Value::from(default.0), PropDef::Variant { default, .. } => Value::String(default.clone()), + PropDef::Slot { .. } => Value::Null, } } @@ -209,7 +353,8 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } /// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With -/// `src_names`, layers are named after the main layer they mirror (renames don't break matching). +/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames +/// don't break matching), and the content of slots holding the instance's own is left out. fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { let mut out = BTreeMap::new(); let mut stack = vec![(root, String::new())]; @@ -228,7 +373,9 @@ fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap 1_000_000 { return out; } - stack.push((*c, path)); + if !(src_names && holds_content(cn)) { + stack.push((*c, path)); + } } } out @@ -271,10 +418,56 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { } } -/// Rebuild instance `inst` from its main component, keeping ids and overrides. An instance whose -/// main component was deleted keeps its layers as they are. +/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its +/// slots. An instance whose main component was deleted keeps its layers as they are. pub fn sync_instance(doc: &mut Document, inst: NodeId) { + let kept = slot_content(doc, inst); sync_instance_depth(doc, inst, 0); + restore_slot_content(doc, kept); +} + +/// The slots in instance `inst` (nested instances included) holding the instance's own content: +/// each with its content (the subtrees of its children, pre-order). +fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); + while let Some(c) = stack.pop() { + let Some(n) = doc.node(c) else { continue }; + if holds_content(n) { + let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); + out.push((c, nodes)); + } else { + stack.extend(n.children.iter().copied()); + } + } + out +} + +/// Put slot content back after a rebuild, into the slots that still hold the instance's own +/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints +/// and geometry overrides ran before, so the content keeps its place. +fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { + for (slot, nodes) in kept { + let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); + if !keep { + continue; + } + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + for n in nodes { + // Pre-order: every parent is back before its children. + let _ = insert_linked(doc, n); + } + // Never leave links to a layer that couldn't come back. + for id in ids.into_iter().chain([slot]) { + let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); + if dangling { + let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); + if let Some(n) = doc.node_mut(id) { + n.children = live; + } + } + } + } } fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { @@ -295,31 +488,48 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { eff_props.insert(k.clone(), v.clone()); } } - // Old layers, by (parent, source) and by name path. + // Old layers, by (parent, source), by source alone (when only one layer mirrors it) and by + // name path. let old_paths = path_map(doc, inst, true); let mut by_source: BTreeMap<(NodeId, NodeId), Node> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); let mut by_path: BTreeMap = BTreeMap::new(); for (i, path) in &old_paths { let Some(n) = doc.node(*i) else { continue }; - if let (Some(s), Some(p)) = (n.source, n.parent) { - by_source.entry((p, s)).or_insert_with(|| n.clone()); + if let Some(s) = n.source { + if let Some(p) = n.parent { + by_source.entry((p, s)).or_insert_with(|| n.clone()); + } + by_src.entry(s).and_modify(|e| *e = None).or_insert_with(|| Some(n.clone())); } by_path.insert(path.clone(), n.clone()); } for c in root.children.clone() { let _ = doc.remove(c); } - // Ids for the copies: reuse the matching old layer's id. + // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another + // parent keeps its id and overrides). let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); let main_paths = path_map(doc, component, false); let mut map: BTreeMap = BTreeMap::new(); map.insert(component, inst); let mut olds: BTreeMap = BTreeMap::new(); let mut used: BTreeSet = BTreeSet::new(); + // Slots holding the instance's own content (new ids), and main layers not copied: the + // default content of those slots. + let mut edited: BTreeSet = BTreeSet::new(); + let mut skipped: BTreeSet = BTreeSet::new(); for s in &src { let Some(sn) = doc.node(*s) else { continue }; + if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { + skipped.insert(*s); + continue; + } let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); - let old = parent_new.and_then(|pn| by_source.get(&(pn, *s))).or_else(|| main_paths.get(s).and_then(|p| by_path.get(p))); + let old = parent_new + .and_then(|pn| by_source.get(&(pn, *s))) + .or_else(|| by_src.get(s).and_then(Option::as_ref)) + .or_else(|| main_paths.get(s).and_then(|p| by_path.get(p))); let nid = match old { Some(o) if !doc.contains(o.id) && used.insert(o.id) => { olds.insert(o.id, o.clone()); @@ -327,6 +537,9 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { } _ => doc.alloc_id(), }; + if olds.get(&nid).is_some_and(holds_content) && is_slot(doc, *s) { + edited.insert(nid); + } map.insert(*s, nid); } // Build the copies (pre-order: parents first). @@ -350,6 +563,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { if let Some(old) = olds.get(&nid) { carry_overrides(old, &mut n, Carry::Values); } + if !edited.contains(&nid) { + // No longer a slot in the main: it mirrors the main again. + n.overrides.remove(SLOT); + } // Component properties: the main layer's bindings use this instance's values; layers of // nested instances use the nested instance's values (one level per source hop). let enc = enclosing.get(&parent).cloned().unwrap_or_default(); @@ -551,6 +768,24 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + // 0. A slot whose layers were added, removed or reordered holds the instance's own content + // from now on; copies of instance layers placed elsewhere stop mirroring their main. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.children == b.children || owning_instance(doc, id).is_none() { + continue; + } + if let Some(slot) = slot_scope(doc, id) + && !slot_edited(doc, slot) + { + own_slot_content(doc, slot); + } + } + for &id in changed { + if !before.contains(id) { + relink_copy(doc, id); + } + } let mut resync: BTreeSet = BTreeSet::new(); // 1. Overrides for layers edited inside instances. for &id in changed { @@ -661,7 +896,64 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } } -/// Refuse edits that add, remove or reorder layers inside an instance (only its main can). +/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the +/// main's default content until now) stop mirroring the main, and instances among them stand +/// alone, linked to their own mains. +fn own_slot_content(doc: &mut Document, slot: NodeId) { + if let Some(n) = doc.node_mut(slot) { + n.overrides.insert(SLOT.into()); + } + for nested in unlink_layers(doc, slot, None) { + rebase_instance(doc, nested); + } +} + +/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the +/// content of a slot): plain layers forget the main layer they mirrored and their property +/// bindings. The instances among them are returned, to re-link to their own mains (with +/// [`rebase_instance`]) once the caller is done. +pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { + let picked: Vec<(NodeId, bool)> = + doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); + let mut nested = Vec::new(); + for (d, instance) in picked { + let Some(n) = doc.node_mut(d) else { continue }; + n.prop_refs.clear(); + if instance { + nested.push(d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nested +} + +/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it +/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). +fn relink_copy(doc: &mut Document, id: NodeId) { + let Some(n) = doc.node(id) else { return }; + match &n.kind { + NodeKind::Instance { component, .. } => { + let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); + if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { + rebase_instance(doc, id); + } + } + _ => { + if (n.source.is_some() || !n.overrides.is_empty()) + && owning_instance(doc, id).is_none() + && let Some(m) = doc.node_mut(id) + { + m.source = None; + m.overrides.clear(); + } + } + } +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside +/// its slots. pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { for &id in after.dirty() { let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; @@ -669,7 +961,7 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S if n.children == b.children || after.is_remote(id) { continue; } - if owning_instance(before, id).is_some() && owning_instance(after, id).is_some() { + if structure_locked(before, id) && structure_locked(after, id) { let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); return Err(format!( "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" @@ -680,7 +972,8 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S } /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing -/// values (after detaching an outer instance, or restoring a main): the look is kept exactly. +/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): +/// the look is kept exactly. Values a component property sets aren't overrides. pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; let mine = path_map(doc, inst, true); @@ -706,7 +999,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let main = main.or_else(|| theirs.get(path).copied()); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); if let Some(m) = main.and_then(|m| doc.node(m)) { - overrides.extend(value_groups(m, n)); + let bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { overrides.insert("component".into()); } @@ -780,10 +1074,18 @@ pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap Vec { + doc.nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) + .flat_map(|n| instance_issues(doc, n.id)) + .collect() +} + +/// [`mirror_issues`] for one instance. +fn instance_issues(doc: &Document, id: NodeId) -> Vec { fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { - if depth > MAX_DEPTH { + if depth > MAX_DEPTH || holds_content(inst) { return; } if inst.children.len() != main.children.len() { @@ -807,21 +1109,18 @@ pub fn mirror_issues(doc: &Document) -> Vec { } } let mut out = Vec::new(); - for n in doc.nodes() { - let NodeKind::Instance { component, .. } = &n.kind else { continue }; - if inside_instance(doc, n.id) { - continue; - } - if let Some(m) = doc.node(*component) { - walk(doc, n, m, 0, &mut out); - } + if let Some(n) = doc.node(id) + && let NodeKind::Instance { component, .. } = &n.kind + && let Some(m) = doc.node(*component) + { + walk(doc, n, m, 0, &mut out); } out } #[cfg(test)] #[path = "components_tests.rs"] -mod tests; +pub(crate) mod tests; /// The nested instance copies inside instance `id` whose main-component counterpart is exposed /// (top-most first; an exposed instance's own exposed nested instances follow it). diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index 8b3bd8f..6b6d7b9 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -4,47 +4,47 @@ use super::*; use crate::{EngineError, Session, params::id_of}; use serde_json::{Value, json}; -fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { +pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { match s.execute(cmd, &p) { Ok(v) => v, Err(e) => panic!("{cmd} {p}: {e}"), } } -fn idv(v: &Value) -> NodeId { +pub(crate) fn idv(v: &Value) -> NodeId { id_of(&v["id"]).unwrap() } -fn node(s: &Session, id: NodeId) -> Node { +pub(crate) fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().node(id).unwrap().clone() } -fn kids(s: &Session, id: NodeId) -> Vec { +pub(crate) fn kids(s: &Session, id: NodeId) -> Vec { node(s, id).children } -fn fill(s: &Session, id: NodeId) -> String { +pub(crate) fn fill(s: &Session, id: NodeId) -> String { node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() } -fn chars(s: &Session, id: NodeId) -> String { +pub(crate) fn chars(s: &Session, id: NodeId) -> String { node(s, id).kind.text().map(|t| t.characters.clone()).unwrap_or_default() } -fn healthy(s: &Session) { +pub(crate) fn healthy(s: &Session) { let doc = s.doc().unwrap(); assert!(doc.validate().is_empty(), "{:?}", doc.validate()); assert!(mirror_issues(doc).is_empty(), "{:?}", mirror_issues(doc)); } /// The instance layer mirroring main layer `src`. -fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId { +pub(crate) fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId { let doc = s.doc().unwrap(); doc.subtree(inst).into_iter().find(|i| doc.node(*i).and_then(|n| n.source) == Some(src)).unwrap() } /// Card: 200×100 component with "Bg" rectangle, "Title" text and "Dot" ellipse. -fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { +pub(crate) fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); let bg = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}))); @@ -164,7 +164,7 @@ fn instances_refuse_structure_edits() { } /// Icon component (one "Shape" rectangle) and a Button component holding an Icon instance. -fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { +pub(crate) fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 300, "width": 24, "height": 24, "name": "Icon"}))); let shape = idv(&ex( s, diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index 8d2aa78..5eae2d7 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -125,8 +125,10 @@ pub fn grid_cell_at(doc: &Document, parent: NodeId, p: Point) -> Option<(u32, u3 /// being dragged) and their descendants. Inside auto layout the deepest auto layout frame under the /// pointer wins: plain frames there are items of the flow, not destinations (dropping onto a card /// in a list inserts next to it). Elsewhere the deepest frame or section under the pointer wins. +/// Inside an instance only its slots (and what's in them) take drops; a slot always does. pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -> Option { - fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, depth: usize) -> Option { + /// `locked`: inside an instance, outside its slots. + fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, locked: bool, depth: usize) -> Option { if depth > 128 || exclude.contains(&id) { return None; } @@ -135,19 +137,20 @@ pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) - return None; } let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); + let kids_locked = crate::components::locks_children(doc, id, locked); if !container { // Groups: frames inside them can still take the drop. - return n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al, depth + 1)); + return n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al, kids_locked, depth + 1)); } let inside = doc.world_bounds(id).is_some_and(|b| b.contains(p)) && n.local_rect().contains(doc.world_transform(id).inverse() * p); - if !inside || matches!(n.kind, NodeKind::Instance { .. }) { + if !inside { return None; } let auto_layout = n.layout.is_some(); - let deeper = n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al || auto_layout, depth + 1)); - deeper.or((auto_layout || !in_al).then_some(id)) + let deeper = n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al || auto_layout, kids_locked, depth + 1)); + deeper.or((!kids_locked && (auto_layout || !in_al || crate::components::is_slot(doc, id))).then_some(id)) } - doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, 0)) + doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0)) } /// Margin (page units) around the auto layout frame a dragged layer comes from that still keeps it. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index e404ba2..21e5fa9 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -126,6 +126,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.overrides.is_empty() { o.insert("overrides".into(), json!(n.overrides)); } + if let Some(slot) = crate::components::slot_name(doc, n.id) { + o.insert("slot".into(), json!(slot)); + } } match &n.kind { NodeKind::Text(t) => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 745eab1..5d1eaeb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -887,6 +887,8 @@ mod import_export_tests; #[cfg(test)] mod mask_tests; #[cfg(test)] +mod nested_override_tests; +#[cfg(test)] mod paint_tests; #[cfg(test)] mod proto_multi_tests; @@ -897,6 +899,8 @@ mod section_tests; #[cfg(test)] mod shapebuilder_tests; #[cfg(test)] +mod slot_tests; +#[cfg(test)] mod style_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 85cdddd..789ac44 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -64,10 +64,15 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet { } } NodeKind::Component { props, .. } => { - out.extend(props.values().filter_map(|d| match d { - PropDef::InstanceSwap { default } => Some(*default), - _ => None, - })); + for d in props.values() { + match d { + PropDef::InstanceSwap { default } => { + out.insert(*default); + } + PropDef::Slot { preferred } => out.extend(preferred.iter().copied()), + _ => {} + } + } } _ => {} } @@ -452,10 +457,14 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap, styles NodeKind::Component { props, .. } => { props.retain(|_, d| !matches!(d, PropDef::InstanceSwap { default } if !map.contains_key(default))); for d in props.values_mut() { - if let PropDef::InstanceSwap { default } = d - && let Some(l) = map.get(default) - { - *default = *l; + match d { + PropDef::InstanceSwap { default } => { + if let Some(l) = map.get(default) { + *default = *l; + } + } + PropDef::Slot { preferred } => *preferred = preferred.iter().filter_map(|c| map.get(c).copied()).collect(), + _ => {} } } } diff --git a/crates/engine/src/nested_override_tests.rs b/crates/engine/src/nested_override_tests.rs new file mode 100644 index 0000000..f684a69 --- /dev/null +++ b/crates/engine/src/nested_override_tests.rs @@ -0,0 +1,200 @@ +//! Overrides on and inside nested instances across swaps, variant switches, main updates, detach +//! and copy/paste. + +use crate::Session; +use crate::components::tests::{button, copy_of, ex, fill, healthy, idv, kids, node}; +use crate::components::*; +use figcraft_doc::*; +use serde_json::json; + +/// A 24×24 icon component at (`x`, 300) with one 20×20 rectangle named `layer`. +fn icon(s: &mut Session, x: f64, name: &str, layer: &str) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 2, "y": 2, "width": 20, "height": 20, "parent": f, "props": {"name": layer, "fill": "#111111"}})); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn main_of(s: &Session, id: NodeId) -> NodeId { + match node(s, id).kind { + NodeKind::Instance { component, .. } => component, + _ => panic!("{id} is not an instance"), + } +} + +#[test] +fn swapping_a_nested_instance_keeps_overrides_on_matching_layers() { + let mut s = Session::new(); + let (_icon, shape, button, nested) = button(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let unrelated = icon(&mut s, 300.0, "Unrelated", "Glyph"); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let inested = copy_of(&s, i, nested); + let ishape = kids(&s, inested)[0]; + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff", "opacity": 0.5}})); + // A main with a same-named layer: the overrides carry over. + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": other})); + healthy(&s); + let ishape = kids(&s, inested)[0]; + assert_eq!(main_of(&s, inested), other); + assert_eq!(fill(&s, ishape), "FF00FF"); + assert_eq!(node(&s, ishape).opacity, 0.5); + assert!(node(&s, ishape).overrides.contains("fills")); + // ... and survive edits of the outer main and of the swapped-in main. + ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}})); + let other_shape = kids(&s, other)[0]; + ex(&mut s, "node.set", json!({"id": other_shape, "props": {"cornerRadius": 5, "fill": "#222222"}})); + healthy(&s); + let ishape = kids(&s, copy_of(&s, i, nested))[0]; + assert_eq!(fill(&s, ishape), "FF00FF"); + assert_eq!(node(&s, ishape).corners.radii, [5.0; 4]); + // No matching layer: the overrides go. + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": unrelated})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, inested)[0]), "111111"); + // Back to the original: it shows its main again. + ex(&mut s, "component.resetOverride", json!({"id": inested, "group": "component"})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, inested)[0]), fill(&s, copy_of(&s, nested, shape))); +} + +#[test] +fn variant_switch_keeps_nested_overrides_and_swaps() { + let mut s = Session::new(); + let ic = icon(&mut s, 0.0, "Icon", "Shape"); + let star = icon(&mut s, 100.0, "Star", "Shape"); + let mut variants = Vec::new(); + for (k, state) in ["Default", "Hover"].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 120, "height": 40, "name": format!("State={state}")}))); + ex( + &mut s, + "shape.rectangle", + json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg", "fill": if k == 0 { "#ffffff" } else { "#eeeeee" }}}), + ); + ex(&mut s, "instance.create", json!({"component": ic, "x": 8, "y": 8, "parent": f})); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + let inested = kids(&s, i)[1]; + let ishape = kids(&s, inested)[0]; + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff8800"}})); + ex(&mut s, "node.set", json!({"id": inested, "props": {"opacity": 0.4}})); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}})); + healthy(&s); + let inested = kids(&s, i)[1]; + assert_eq!(fill(&s, kids(&s, i)[0]), "EEEEEE", "the new variant shows"); + assert_eq!(fill(&s, kids(&s, inested)[0]), "FF8800", "override inside the nested instance kept"); + assert_eq!(node(&s, inested).opacity, 0.4, "override on the nested instance kept"); + // A swap override on the nested instance survives switching back. + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": star})); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Default"}})); + healthy(&s); + let inested = kids(&s, i)[1]; + assert_eq!(main_of(&s, inested), star); + assert_eq!(fill(&s, kids(&s, inested)[0]), "FF8800"); +} + +#[test] +fn nested_overrides_survive_main_restructuring() { + let mut s = Session::new(); + let (_icon, shape, button, nested) = button(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let ishape = copy_of(&s, i, copy_of(&s, nested, shape)); + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}})); + // The main wraps its nested instance in a frame, then reorders it. + let wrap = idv(&ex(&mut s, "object.frameSelection", json!({"ids": [nested]}))); + healthy(&s); + let ishape = copy_of(&s, i, copy_of(&s, nested, shape)); + assert_eq!(fill(&s, ishape), "FF00FF", "kept when the main moves the nested instance into a frame"); + ex(&mut s, "node.reorder", json!({"id": wrap, "index": 5})); + healthy(&s); + assert_eq!(fill(&s, copy_of(&s, i, copy_of(&s, nested, shape))), "FF00FF"); + // The main swaps its nested instance to a main with the same layer. + ex(&mut s, "instance.swap", json!({"ids": [nested], "component": other})); + healthy(&s); + let inested = copy_of(&s, i, nested); + assert_eq!(main_of(&s, inested), other); + assert_eq!(fill(&s, kids(&s, inested)[0]), "FF00FF"); +} + +#[test] +fn detach_keeps_swapped_nested_instances_and_their_overrides() { + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button(&mut s); + let other = icon(&mut s, 200.0, "Other", "Glyph"); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": other})); + let iglyph = kids(&s, inested)[0]; + ex(&mut s, "node.set", json!({"id": iglyph, "props": {"fill": "#00aaff"}})); + ex(&mut s, "component.detach", json!({"ids": [i]})); + healthy(&s); + assert_eq!(main_of(&s, inested), other); + assert_eq!(node(&s, inested).source, Some(other)); + assert!(!node(&s, inested).overrides.contains("component")); + let glyph = kids(&s, other)[0]; + assert_eq!(node(&s, iglyph).source, Some(glyph)); + assert_eq!(fill(&s, iglyph), "00AAFF"); + ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#000000", "cornerRadius": 3}})); + assert_eq!(fill(&s, iglyph), "00AAFF"); + assert_eq!(node(&s, iglyph).corners.radii, [3.0; 4]); + healthy(&s); +} + +#[test] +fn copied_instances_keep_their_overrides() { + let mut s = Session::new(); + let (icon, shape, button, nested) = button(&mut s); + ex(&mut s, "component.addProperty", json!({"id": icon, "name": "Filled", "type": "boolean", "default": true, "layer": shape})); + ex(&mut s, "component.exposeInstance", json!({"id": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let inested = copy_of(&s, i, nested); + let ishape = kids(&s, inested)[0]; + let ilabel = kids(&s, i)[1]; + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}})); + ex(&mut s, "text.set", json!({"id": ilabel, "characters": "Copied"})); + ex(&mut s, "instance.setProperties", json!({"ids": [inested], "properties": {"Filled": false}})); + // The whole instance: overrides and property values come along, and survive main edits. + ex(&mut s, "edit.copy", json!({"ids": [i]})); + s.set_selection(vec![]); + let j = ex(&mut s, "edit.paste", json!({"x": 300, "y": 800}))["ids"][0].as_u64().map(NodeId).unwrap(); + healthy(&s); + ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 4}})); + ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#eeeeee"}})); + healthy(&s); + let jnested = copy_of(&s, j, nested); + let jshape = kids(&s, jnested)[0]; + assert_eq!(fill(&s, jshape), "FF00FF"); + assert!(!node(&s, jshape).visible, "property value of the nested instance kept"); + assert_eq!(node(&s, jshape).corners.radii, [4.0; 4]); + assert_eq!(node(&s, kids(&s, j)[1]).kind.text().unwrap().characters, "Copied"); + assert_eq!(fill(&s, j), "EEEEEE"); + // A nested instance copied out on its own becomes an instance of its own main, same look. + let inested = copy_of(&s, i, nested); + ex(&mut s, "edit.copy", json!({"ids": [inested]})); + s.set_selection(vec![]); + let k = ex(&mut s, "edit.paste", json!({"x": 900, "y": 800}))["ids"][0].as_u64().map(NodeId).unwrap(); + healthy(&s); + assert!(!inside_instance(s.doc().unwrap(), k)); + assert_eq!(node(&s, k).source, Some(icon)); + let kshape = kids(&s, k)[0]; + assert_eq!(node(&s, kshape).source, Some(shape)); + assert_eq!(fill(&s, kshape), "FF00FF"); + assert!(node(&s, kshape).overrides.contains("fills")); + assert!(!node(&s, kshape).visible); + assert!(!node(&s, kshape).overrides.contains("visible"), "set by its property, not an override"); + ex(&mut s, "instance.setProperties", json!({"ids": [k], "properties": {"Filled": true}})); + assert!(node(&s, kids(&s, k)[0]).visible); + ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 7}})); + assert_eq!(node(&s, kids(&s, k)[0]).corners.radii, [7.0; 4]); + assert_eq!(fill(&s, kids(&s, k)[0]), "FF00FF"); + // A plain layer copied out of an instance is a plain layer. + let ilabel = kids(&s, i)[1]; + ex(&mut s, "edit.copy", json!({"ids": [ilabel]})); + s.set_selection(vec![]); + let t = ex(&mut s, "edit.paste", json!({"x": 900, "y": 1000}))["ids"][0].as_u64().map(NodeId).unwrap(); + assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty()); + healthy(&s); +} diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs new file mode 100644 index 0000000..516cb9d --- /dev/null +++ b/crates/engine/src/slot_tests.rs @@ -0,0 +1,417 @@ +//! Slots: frames of a main component whose instances take any content of their own. + +use crate::Session; +use crate::components::tests::{copy_of, ex, fill, healthy, idv, kids, node}; +use crate::components::*; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// A "Card" component (300×200 at the origin) with a "Title" text and a "Content" slot (280×140 at +/// 10,50) holding a default "Placeholder" rectangle. Returns (card, title, content, placeholder). +fn slot_card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Card"}))); + let title = idv(&ex(s, "text.create", json!({"text": "Title", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let content = idv(&ex(s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"}))); + let ph = idv(&ex( + s, + "shape.rectangle", + json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": content, "props": {"name": "Placeholder", "fill": "#cccccc"}}), + )); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.makeSlot", json!({"id": content})); + (c, title, content, ph) +} + +/// A 40×20 "Chip" component at (0, 400) with a red "Dot". Returns (chip, dot). +fn chip(s: &mut Session) -> (NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Chip"}))); + let dot = + idv(&ex(s, "shape.ellipse", json!({"x": 2, "y": 2, "width": 16, "height": 16, "parent": f, "props": {"name": "Dot", "fill": "#ff0000"}}))); + (idv(&ex(s, "component.create", json!({"ids": [f]}))), dot) +} + +fn refused(s: &mut Session, cmd: &str, p: Value) { + let before = s.doc().unwrap().clone(); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused"); + assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back"); +} + +fn props_of(s: &mut Session, id: NodeId) -> Vec { + ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default() +} + +#[test] +fn slots_are_frames_of_a_main_with_a_slot_property() { + let mut s = Session::new(); + let (c, title, content, ph) = slot_card(&mut s); + assert_eq!(slot_name(s.doc().unwrap(), content).as_deref(), Some("Content")); + let p = props_of(&mut s, c); + assert_eq!(p[0]["type"], "slot"); + assert_eq!(p[0]["layers"], json!([content.0])); + assert_eq!(slots_of(s.doc().unwrap(), c), vec![(content, "Content".to_string())]); + // A second frame named "Content" gets a fresh property name. + let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Content"}))); + let r = ex(&mut s, "component.makeSlot", json!({"id": other})); + assert_eq!(r["name"], "Content 2"); + ex(&mut s, "component.removeSlot", json!({"id": other})); + assert!(!is_slot(s.doc().unwrap(), other)); + // Preferred components. + let (chip, _) = chip(&mut s); + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Content", "preferred": [chip]})); + assert_eq!(props_of(&mut s, c)[0]["preferred"], json!([chip.0])); + // What isn't a slot, or can't become one. + let loose = idv(&ex(&mut s, "frame.create", json!({"x": 900, "y": 0, "width": 50, "height": 50}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0}))); + let islot = copy_of(&s, i, content); + for (cmd, p) in [ + ("component.makeSlot", json!({"id": c})), + ("component.makeSlot", json!({"id": ph})), + ("component.makeSlot", json!({"id": loose})), + ("component.makeSlot", json!({"id": content})), + ("component.makeSlot", json!({"id": islot})), + ("component.removeSlot", json!({"id": islot})), + ("component.removeSlot", json!({"id": title})), + ("component.editProperty", json!({"id": c, "name": "Content", "preferred": [title]})), + ("component.editProperty", json!({"id": c, "name": "Content", "default": "x"})), + ("component.bindProperty", json!({"layer": ph, "property": "Content", "field": "slot"})), + ("component.bindProperty", json!({"layer": other, "property": "Content"})), + ("instance.setProperties", json!({"ids": [i], "properties": {"Content": 1}})), + ("instance.slotInsert", json!({"id": content, "component": chip})), + ("instance.slotInsert", json!({"id": i, "slot": "Nope", "component": chip})), + ("instance.resetSlot", json!({"id": i})), + ] { + refused(&mut s, cmd, p); + } + // Slots hold content, not values: instances store none. + assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if props.is_empty())); + healthy(&s); +} + +#[test] +fn instances_fill_slots_with_their_own_content() { + let mut s = Session::new(); + let (c, title, content, ph) = slot_card(&mut s); + let (chip, dot) = chip(&mut s); + let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0}))); + let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 800, "y": 0}))); + let (aslot, aph) = (copy_of(&s, a, content), copy_of(&s, a, ph)); + // The default content shows (and follows the main) until the slot is edited. + assert!(!slot_edited(s.doc().unwrap(), aslot)); + ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#00ff00"}})); + assert_eq!(fill(&s, aph), "00FF00"); + // Filling the slot: one undo step; the default content becomes the instance's own. + let undo = s.ds().unwrap().history.undo.len(); + let r = ex(&mut s, "instance.slotInsert", json!({"id": a, "slot": "Content", "component": chip})); + let ichip = idv(&r); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert_eq!(kids(&s, aslot), vec![aph, ichip]); + assert!(slot_edited(s.doc().unwrap(), aslot)); + assert!(node(&s, aph).source.is_none() && node(&s, aph).overrides.is_empty()); + assert!(!inside_instance(s.doc().unwrap(), ichip), "an instance in a slot stands alone"); + assert_eq!(owning_instance(s.doc().unwrap(), aph), None); + let v = props_of(&mut s, a); + assert_eq!(v[0]["value"], json!({"layer": aslot.0, "edited": true, "layers": 2})); + healthy(&s); + // Main edits: the rest of the instance follows, the slot content stays as it is. + ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#0000ff"}})); + let extra = idv(&ex(&mut s, "shape.ellipse", json!({"x": 150, "y": 0, "width": 20, "height": 20, "parent": content}))); + ex(&mut s, "text.set", json!({"id": title, "characters": "New title"})); + ex(&mut s, "node.set", json!({"id": c, "props": {"fill": "#fafafa"}})); + healthy(&s); + assert_eq!(kids(&s, aslot), vec![aph, ichip], "content and its ids survive main updates"); + assert_eq!(fill(&s, aph), "00FF00"); + assert_eq!(fill(&s, a), "FAFAFA"); + assert_eq!(node(&s, copy_of(&s, a, title)).kind.text().unwrap().characters, "New title"); + // The untouched instance still shows the main's default content. + let bslot = copy_of(&s, b, content); + assert_eq!(kids(&s, bslot).len(), 2); + assert_eq!(fill(&s, copy_of(&s, b, ph)), "0000FF"); + assert!(s.doc().unwrap().contains(copy_of(&s, b, extra))); + // An instance in a slot follows its own main. + ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#123456"}})); + assert_eq!(fill(&s, copy_of(&s, ichip, dot)), "123456"); + // Inside the slot, layers come and go freely; outside it the instance is locked. + let r2 = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": aslot}))); + ex(&mut s, "node.reorder", json!({"id": r2, "index": 0})); + ex(&mut s, "edit.delete", json!({"ids": [aph]})); + assert_eq!(kids(&s, aslot), vec![r2, ichip]); + ex(&mut s, "edit.duplicate", json!({"ids": [ichip]})); + assert_eq!(kids(&s, aslot).len(), 3); + refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": a})); + refused(&mut s, "node.reparent", json!({"ids": [r2], "parent": a})); + refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": ichip})); + let idot = copy_of(&s, ichip, dot); + refused(&mut s, "node.reparent", json!({"ids": [idot], "parent": aslot})); + healthy(&s); + // Layers of the instance can't move into its slot either. + let atitle = copy_of(&s, a, title); + refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot})); + // Reset: back to the main's default content. + ex(&mut s, "instance.resetSlot", json!({"id": aslot})); + healthy(&s); + assert!(!slot_edited(s.doc().unwrap(), aslot)); + assert_eq!(kids(&s, aslot).len(), 2); + assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF"); + assert!(!s.doc().unwrap().contains(ichip)); + s.undo().unwrap(); + assert!(s.doc().unwrap().contains(ichip)); + healthy(&s); + // Reset all changes resets slots too; `replace` clears the default content first. + ex(&mut s, "component.resetInstance", json!({"ids": [a]})); + assert!(!slot_edited(s.doc().unwrap(), aslot)); + let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true}))); + assert_eq!(kids(&s, aslot), vec![only]); + ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"})); + assert_eq!(kids(&s, aslot).len(), 2); + healthy(&s); +} + +#[test] +fn slot_content_survives_variants_copies_and_detach() { + let mut s = Session::new(); + let (chip, _) = chip(&mut s); + // Two variants, each with a "Content" slot (the same property, shared by the set). + let mut variants = Vec::new(); + let mut slots = Vec::new(); + for (k, state) in ["Default", "Hover"].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 400.0, "y": 0, "width": 300, "height": 200, "name": format!("State={state}")}))); + let content = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"}))); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + slots.push(content); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + ex(&mut s, "component.addProperty", json!({"id": set, "name": "Body", "type": "slot", "layer": slots[0]})); + ex(&mut s, "component.bindProperty", json!({"layer": slots[1], "property": "Body"})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); + let ichip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Body", "component": chip}))); + ex(&mut s, "node.set", json!({"id": ichip, "props": {"fill": "#abcdef"}})); + // Variant switch: the content moves along. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}})); + healthy(&s); + let islot = copy_of(&s, i, slots[1]); + assert_eq!(kids(&s, islot), vec![ichip]); + assert_eq!(fill(&s, ichip), "ABCDEF"); + // Copy and paste: the copy has its own copy of the content. + ex(&mut s, "edit.copy", json!({"ids": [i]})); + s.set_selection(vec![]); + let pasted = ex(&mut s, "edit.paste", json!({"x": 0, "y": 1000}))["ids"][0].as_u64().map(NodeId).unwrap(); + healthy(&s); + let pslot = copy_of(&s, pasted, slots[1]); + let pchip = kids(&s, pslot)[0]; + assert_ne!(pchip, ichip); + assert!(slot_edited(s.doc().unwrap(), pslot)); + assert_eq!(fill(&s, pchip), "ABCDEF"); + ex(&mut s, "node.set", json!({"id": pchip, "props": {"fill": "#000000"}})); + assert_eq!(fill(&s, ichip), "ABCDEF"); + // Detach: the content stays, the instance in it stays an instance of its own main. + ex(&mut s, "component.detach", json!({"ids": [i]})); + healthy(&s); + assert_eq!(kids(&s, islot), vec![ichip]); + assert!(matches!(node(&s, ichip).kind, NodeKind::Instance { component, .. } if component == chip)); + assert!(node(&s, islot).overrides.is_empty() && node(&s, islot).source.is_none()); +} + +#[test] +fn removing_a_slot_takes_the_instances_content_away() { + let mut s = Session::new(); + let (c, _title, content, _ph) = slot_card(&mut s); + let (chip, _) = chip(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0}))); + let islot = copy_of(&s, i, content); + let ichip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": islot, "component": chip}))); + ex(&mut s, "component.removeSlot", json!({"id": content})); + healthy(&s); + assert!(!is_slot(s.doc().unwrap(), islot)); + assert!(!s.doc().unwrap().contains(ichip)); + assert_eq!(kids(&s, islot).len(), 1, "mirrors the main again"); + assert!(props_of(&mut s, c).is_empty()); + s.undo().unwrap(); + assert!(s.doc().unwrap().contains(ichip)); + healthy(&s); +} + +#[test] +fn drops_land_in_slots() { + let mut s = Session::new(); + let (c, title, content, _ph) = slot_card(&mut s); + let (chip, _) = chip(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0}))); + let islot = copy_of(&s, i, content); + let page = s.page().unwrap(); + let doc = s.doc().unwrap().clone(); + // The slot (at 410..690, 50..190) takes drops; the rest of the instance doesn't. + assert_eq!(crate::drop::slot(&doc, page, figcraft_geom::Point::new(600.0, 150.0)).parent, islot); + assert_eq!(crate::drop::slot(&doc, page, figcraft_geom::Point::new(420.0, 20.0)).parent, page); + let r = ex(&mut s, "instance.place", json!({"component": chip, "x": 600, "y": 150})); + assert_eq!(r["parent"], json!(islot.0)); + assert!(slot_edited(s.doc().unwrap(), islot)); + healthy(&s); + // Dragging a layer onto the slot moves it in; dragging a layer of the instance out of it + // only moves it. + let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 1000, "y": 0, "width": 20, "height": 20}))); + s.set_tool(ToolId::Move); + let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64), cmd: bool| { + let mut down = PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0); + down.cmd = cmd; + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + }; + drag(&mut s, (1010.0, 10.0), (650.0, 170.0), false); + assert_eq!(node(&s, rect).parent, Some(islot)); + healthy(&s); + // Into a slot still showing the main's default content: it becomes the instance's own. + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 300}))); + let jslot = copy_of(&s, j, content); + let dot = idv(&ex(&mut s, "shape.ellipse", json!({"x": 1000, "y": 100, "width": 20, "height": 20}))); + s.set_selection(vec![]); + drag(&mut s, (1010.0, 110.0), (650.0, 470.0), false); + assert_eq!(node(&s, dot).parent, Some(jslot)); + assert!(slot_edited(s.doc().unwrap(), jslot)); + assert_eq!(kids(&s, jslot).len(), 2); + healthy(&s); + s.set_selection(vec![islot]); + drag(&mut s, (450.0, 120.0), (1450.0, 620.0), true); + assert_eq!(node(&s, islot).parent, Some(i), "instance layers stay in the instance"); + assert_eq!(node(&s, title).parent, Some(c)); + healthy(&s); +} + +#[test] +fn drags_into_auto_layout_slots_take_their_place_in_the_flow() { + let mut s = Session::new(); + let (c, _title, content, ph) = slot_card(&mut s); + ex(&mut s, "node.set", json!({"id": content, "props": {"layout": "vertical", "gap": 8, "padding": 10, "sizingH": "fixed", "sizingV": "fixed"}})); + ex(&mut s, "node.set", json!({"id": c, "props": {"layout": "vertical", "padding": 10, "gap": 10}})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0}))); + let (islot, iph) = (copy_of(&s, i, content), copy_of(&s, i, ph)); + let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 1000, "y": 0, "width": 30, "height": 30}))); + s.set_tool(ToolId::Move); + // Below the placeholder copy, inside the slot. + let b = s.doc().unwrap().world_bounds(islot).unwrap(); + let to = (b.center().x, b.y1 - 5.0); + s.pointer(&PointerEvent::at(PointerKind::Down, 1015.0, 15.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 800.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + assert_eq!(kids(&s, islot), vec![iph, rect]); + assert!(slot_edited(s.doc().unwrap(), islot)); + healthy(&s); + // Its own content reorders by dragging within the slot, like any auto layout frame; the + // instance's own layers don't. + let first = s.doc().unwrap().world_bounds(kids(&s, islot)[0]).unwrap(); + let r = s.doc().unwrap().world_bounds(rect).unwrap().center(); + s.set_selection(vec![rect]); + s.pointer(&PointerEvent::at(PointerKind::Down, r.x, r.y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, r.x, first.y0 + 6.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, r.x, first.y0 + 2.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, r.x, first.y0 + 2.0, 1.0)).unwrap(); + assert_eq!(kids(&s, islot)[0], rect); + let order = kids(&s, i); + let at = s.doc().unwrap().world_bounds(islot).unwrap().center(); + s.set_selection(vec![islot]); + let mut down = PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0); + down.cmd = true; + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, at.x, at.y - 60.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, at.x, at.y - 120.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y - 120.0, 1.0)).unwrap(); + assert_eq!(kids(&s, i), order, "the instance's layers keep their order"); + healthy(&s); +} + +#[test] +fn slots_of_nested_instances_fill_from_the_outer_main_and_its_instances() { + let mut s = Session::new(); + let (container, _title, content, ph) = slot_card(&mut s); + let (chip, _) = chip(&mut s); + // A "Page" component holding a Card instance whose slot the Page main fills. + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 800, "width": 400, "height": 300, "name": "Page"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": container, "x": 20, "y": 20, "parent": f}))); + let pagec = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let nslot = copy_of(&s, nested, content); + let mchip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": nslot, "component": chip}))); + assert_eq!(owning_component(s.doc().unwrap(), mchip), Some(pagec)); + // Page instances mirror that content (it's the Page main's) ... + let i = idv(&ex(&mut s, "instance.create", json!({"component": pagec, "x": 600, "y": 800}))); + healthy(&s); + let inested = copy_of(&s, i, nested); + let islot = copy_of(&s, inested, nslot); + assert!(!slot_edited(s.doc().unwrap(), islot)); + assert_eq!(kids(&s, islot).len(), 2); + // ... and a main edit of that content reaches them. + ex(&mut s, "node.set", json!({"id": mchip, "props": {"opacity": 0.5}})); + assert_eq!(node(&s, copy_of(&s, i, mchip)).opacity, 0.5); + // The instance fills the nested slot with its own content, kept across both mains' edits. + let own = idv(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 30, "height": 30, "parent": islot}))); + assert!(slot_edited(s.doc().unwrap(), islot)); + ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#00ff00"}})); + ex(&mut s, "node.set", json!({"id": pagec, "props": {"fill": "#eeeeee"}})); + ex(&mut s, "node.set", json!({"id": container, "props": {"fill": "#dddddd"}})); + healthy(&s); + assert_eq!(kids(&s, islot).len(), 3); + assert_eq!(*kids(&s, islot).last().unwrap(), own); + assert_eq!(fill(&s, nested), "DDDDDD"); + assert_eq!(kids(&s, nslot).len(), 2, "the Page main keeps its own content in the nested slot"); + assert_eq!(fill(&s, *kids(&s, nslot).first().unwrap()), "CCCCCC", "content no longer follows the Card main"); +} + +/// Random slot edits, main edits and resets keep every instance mirroring its main. +#[test] +fn random_slot_edits_keep_instances_consistent() { + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn pick(&mut self, v: &[T]) -> Option { + if v.is_empty() { None } else { v.get((self.next() % v.len() as u64) as usize).copied() } + } + } + for seed in 1..=std::env::var("FC_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(16u64) { + let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1); + let mut s = Session::new(); + let (card, _title, content, _ph) = slot_card(&mut s); + let (chip, _) = chip(&mut s); + for k in 0..3 { + ex(&mut s, "instance.create", json!({"component": card, "x": 400 + k * 350, "y": 0})); + } + for step in 0..50 { + let doc = s.doc().unwrap().clone(); + let slots: Vec = doc.nodes().filter(|n| owning_instance(&doc, n.id).is_some() && is_slot(&doc, n.id)).map(|n| n.id).collect(); + let content_layers: Vec = slots.iter().flat_map(|sl| doc.node(*sl).map(|n| n.children.clone()).unwrap_or_default()).collect(); + let main_layers: Vec = doc.subtree(card).into_iter().filter(|i| *i != card).collect(); + let color = format!("#{:06x}", r.next() % 0xFF_FFFF); + let (cmd, p) = match r.next() % 10 { + 0 | 1 => ("instance.slotInsert", json!({"id": r.pick(&slots), "component": chip, "index": r.next() % 3})), + 2 => ("shape.rectangle", json!({"x": 5, "y": 5, "width": 8, "height": 8, "parent": r.pick(&slots)})), + 3 => ("edit.delete", json!({"ids": [r.pick(&content_layers)]})), + 4 => ("node.reorder", json!({"id": r.pick(&content_layers), "index": r.next() % 3})), + 5 => ("instance.resetSlot", json!({"id": r.pick(&slots)})), + 6 => ( + "shape.ellipse", + json!({"x": 5, "y": 5, "width": 8, "height": 8, "parent": if r.next().is_multiple_of(2) { content } else { card }}), + ), + 7 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"fill": color}})), + 8 => ("edit.delete", json!({"ids": [r.pick(&main_layers).filter(|i| *i != content)]})), + _ => ("node.set", json!({"id": r.pick(&content_layers), "props": {"fill": color, "width": 5 + r.next() % 40}})), + }; + let res = s.execute(cmd, &p); + if let Err(crate::EngineError::Internal { cmd, msg }) = &res { + panic!("seed {seed} step {step}: {cmd} panicked: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "seed {seed} step {step} {cmd} {p}: {:?}", doc.validate()); + assert!(mirror_issues(doc).is_empty(), "seed {seed} step {step} {cmd} {p} -> {res:?}: {:?}", mirror_issues(doc)); + } + while s.undo().unwrap() { + assert!(s.doc().unwrap().validate().is_empty()); + } + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 15dc041..3dd7597 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -720,8 +720,10 @@ impl Session { } let p = ev.p(); let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); - // Absolute children of an auto layout frame stay in it; the rest may change parent. - let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // an instance (outside its slots: they only move); the rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::components::structure_locked(doc, h)); let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); if let (true, Some(mode)) = (flow, mode) { @@ -1173,12 +1175,26 @@ impl Session { let before = ds.doc.clone(); let d = ds.doc_mut(); for m in &moving { + // Instance layers stay put (outside slots): they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::components::structure_locked(d, target) { + continue; + } // A layer can't go into itself; it stays where it is. let _ = d.reparent(*m, target, None); } crate::normalize(ds.doc_mut(), &before); } - self.commit_interaction()?; + // Reordering or dragging layers out of an instance (outside its slots) is a + // structure edit only its main can make: the gesture is undone. + let ds = self.ds()?; + let allowed = + ds.interaction.as_ref().is_none_or(|it| moving.iter().all(|m| crate::components::move_allowed(&it.doc, &ds.doc, *m))); + if allowed { + self.commit_interaction()?; + } else { + self.cancel_interaction()?; + } } else { self.cancel_interaction()?; // Plain click on an already-selected layer among several: select just it. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 95b0942..b8854a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -418,6 +418,46 @@ fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { painter.add(Shape::closed_line(q.to_vec(), s)); } +/// The slots of the selected components and instances (and a slot being dropped onto): a dashed +/// outline and the slot's name above its top-left corner, like a frame label (never over the +/// content). +fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option) { + use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of}; + let mut roots: Vec = Vec::new(); + for id in sel { + let root = match doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) => Some(*id), + _ => owning_instance(doc, *id).or_else(|| owning_component(doc, *id)), + }; + if let Some(r) = root.filter(|r| !roots.contains(r)) { + roots.push(r); + } + } + let mut slots: Vec<(NodeId, String)> = roots.iter().flat_map(|r| slots_of(doc, *r)).collect(); + if let Some(d) = drop + && !slots.iter().any(|(s, _)| *s == d) + && let Some(name) = slot_name(doc, d) + { + slots.push((d, name)); + } + for (id, name) in slots { + let Some(q) = node_quad(app, doc, id) else { continue }; + if !Rect::from_points(&q).intersects(app.canvas.rect) { + continue; + } + painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); + // The label, when the slot is wide enough on screen to hold it. + let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT); + let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0)); + if (q[1] - q[0]).length() < w { + continue; + } + let at = q[0] - vec2(0.0, h + 2.0); + crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT); + painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT); + } +} + /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option>> { let n = doc.node(id)?; @@ -583,8 +623,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { if let Some(d) = ov.drop_target && let Some(q) = node_quad(app, &doc, d) { - quad(painter, q, stroke(ACCENT, 1.0)); + quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); } + slot_badges(app, painter, &doc, &sel, ov.drop_target); if let Some([x0, y0, x1, y1]) = ov.insert_line { painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); } diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index ff82d60..0f847eb 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -4,11 +4,16 @@ //! the selected layer's overrides, and Reset / Detach / Go to main / Push changes. //! - A component or component set: its properties (add, rename, default, delete) and, for a set, //! the variant property matrix. -//! - A layer inside a component: bind its visibility / text / instance to a property. +//! - A layer inside a component: bind its visibility / text / instance to a property, or make a +//! frame a slot. +//! - A slot inside an instance (and an instance's slot properties): fill it with components +//! (preferred ones first), select it, reset it to the main's default content. use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::*; -use figcraft_engine::components::{inside_instance, owning_component, owning_instance, variant_options, variant_props, variant_set}; +use figcraft_engine::components::{ + inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_options, variant_props, variant_set, +}; use serde_json::{Value, json}; use crate::FigApp; @@ -33,6 +38,11 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No } _ => {} } + if owning_instance(doc, n.id).is_some() + && let Some((name, preferred)) = slot_def(doc, n.id) + { + slot_section(app, ui, doc, n.id, name, preferred); + } if !matches!(n.kind, NodeKind::Instance { .. }) && n.source.is_some() && owning_instance(doc, n.id).is_some() { overrides_ui(app, ui, n); } @@ -129,9 +139,71 @@ fn type_icon(d: &PropDef) -> Icon { PropDef::Text { .. } => Icon::Text, PropDef::InstanceSwap { .. } => Icon::Instance, PropDef::Variant { .. } => Icon::ComponentSet, + PropDef::Slot { .. } => Icon::Slot, } } +/// A selected slot inside an instance: its fill menu. +fn slot_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, name: &str, preferred: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let comps = local_components(doc); + widgets::separator(ui); + row(ui, 36.0, |ui| { + ui.label(egui::RichText::new("Slot").font(semibold(11.0)).color(t.text)); + }); + row(ui, 32.0, |ui| { + prop_label(ui, name, Icon::Slot); + let w = ui.available_width().max(60.0); + slot_fill(app, ui, doc, slot, preferred, &comps, w); + }); +} + +/// The fill menu of slot `slot` inside an instance: its preferred components first, then every +/// local component; select the slot; reset it to the main's default content. +fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, preferred: &[NodeId], comps: &[(NodeId, String)], w: f32) { + let t = Tokens::get(ui.ctx()); + let edited = slot_edited(doc, slot); + let label = match (edited, doc.node(slot).map_or(0, |n| n.children.len())) { + (false, 0) => "Empty".to_string(), + (false, _) => "Default content".to_string(), + (true, 1) => "1 layer".to_string(), + (true, k) => format!("{k} layers"), + }; + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Slot)).on_hover_text("Add content to this slot"); + egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| { + let insert = |app: &mut FigApp, c: NodeId| app.run("instance.slotInsert", json!({"id": slot, "component": c.0})); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + if !preferred.is_empty() { + heading(ui, "Preferred"); + for c in preferred.iter().filter(|c| doc.contains(**c)) { + let name = doc.node(*c).map(|n| n.name.clone()).unwrap_or_default(); + if menu_item(ui, &name, "") { + insert(app, *c); + ui.close(); + } + } + } + heading(ui, "All components"); + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + // Never a component the slot is part of. + for (c, name) in comps.iter().filter(|(c, _)| !doc.is_ancestor(*c, slot)) { + if menu_item(ui, name, "") { + insert(app, *c); + ui.close(); + } + } + }); + if menu_item(ui, "Select slot", "") { + app.run("select.set", json!({"ids": [slot.0]})); + ui.close(); + } + if edited && menu_item(ui, "Reset slot", "") { + app.run("instance.resetSlot", json!({"id": slot.0})); + ui.close(); + } + }); +} + fn prop_defs(doc: &Document, c: NodeId) -> std::collections::BTreeMap { match doc.node(c).map(|n| &n.kind) { Some(NodeKind::Component { props, .. }) => props.clone(), @@ -256,7 +328,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } }); } - PropDef::Variant { .. } => {} + PropDef::Variant { .. } | PropDef::Slot { .. } => {} } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, Icon::Minus, 22.0, false, "Delete property").clicked() { @@ -264,6 +336,30 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } }); }); + // A slot's preferred components, on a row of their own. + if let PropDef::Slot { preferred } = d { + row(ui, 30.0, |ui| { + ui.add_space(22.0); + let label = match preferred.len() { + 0 => "No preferred components".to_string(), + 1 => "1 preferred component".to_string(), + k => format!("{k} preferred components"), + }; + let w = (ui.available_width() - 8.0).max(60.0); + let resp = widgets::dropdown(ui, &label, w, None).on_hover_text("Offered first when filling the slot (any content is allowed)"); + egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps.iter().filter(|(c, _)| *c != owner && first != Some(*c)) { + let on = preferred.contains(c); + if menu_item(ui, &format!("{}{name}", if on { "✓ " } else { "" }), "") { + let next: Vec = preferred.iter().filter(|p| *p != c).map(|p| p.0).chain((!on).then_some(c.0)).collect(); + app.run("component.editProperty", json!({"id": owner, "name": k, "preferred": next})); + } + } + }); + }); + }); + } } if is_set { row(ui, 32.0, |ui| { @@ -286,6 +382,9 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { if matches!(n.kind, NodeKind::Instance { .. }) { fields.push(("mainComponent", "Instance", "instanceSwap")); } + if matches!(n.kind, NodeKind::Frame) { + fields.push(("slot", "Slot", "slot")); + } widgets::separator(ui); row(ui, 36.0, |ui| { ui.label(egui::RichText::new("Component property").font(semibold(11.0)).color(t.text)); @@ -296,7 +395,10 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { .filter(|(_, d)| { matches!( (ty, d), - ("boolean", PropDef::Boolean { .. }) | ("text", PropDef::Text { .. }) | ("instanceSwap", PropDef::InstanceSwap { .. }) + ("boolean", PropDef::Boolean { .. }) + | ("text", PropDef::Text { .. }) + | ("instanceSwap", PropDef::InstanceSwap { .. }) + | ("slot", PropDef::Slot { .. }) ) }) .map(|(k, _)| k) @@ -307,6 +409,7 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let icon = match field { "visible" => Icon::Eye, "characters" => Icon::Text, + "slot" => Icon::Slot, _ => Icon::Instance, }; icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary }); @@ -320,7 +423,13 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { ui.close(); } } - if menu_item(ui, "Create property…", "") { + if ty == "slot" { + if bound.is_none() && menu_item(ui, "Make slot", "") { + // Named after the frame. + app.run("component.makeSlot", json!({"id": n.id})); + ui.close(); + } + } else if menu_item(ui, "Create property…", "") { let existing: Vec = defs.keys().cloned().collect(); let name = (1..) .map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") }) @@ -329,7 +438,12 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { app.run("component.addProperty", json!({"id": n.id, "name": name, "type": ty})); ui.close(); } - if bound.is_some() && menu_item(ui, "Detach property", "") { + if ty == "slot" { + if bound.is_some() && menu_item(ui, "Remove slot", "") { + app.run("component.removeSlot", json!({"id": n.id})); + ui.close(); + } + } else if bound.is_some() && menu_item(ui, "Detach property", "") { app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field})); ui.close(); } @@ -381,6 +495,7 @@ fn group_label(g: &str) -> String { "position" => "Position", "component" => "Swapped instance", "props" => "Instance properties", + "slot" => "Slot content", other => other, } .to_string() @@ -510,6 +625,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps let NodeKind::Instance { component, props } = &n.kind else { return }; let id = n.id; let set = variant_set(doc, *component); + let slots = slots_of(doc, id); // Variant properties. if let Some(set) = set { let current = doc.node(*component).map(|c| variant_props(&c.name)).unwrap_or_default(); @@ -567,6 +683,11 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::Variant { .. } => {} + PropDef::Slot { preferred } => { + if let Some((slot, _)) = slots.iter().find(|(_, name)| *name == k) { + slot_fill(app, ui, doc, *slot, &preferred, comps, w); + } + } } }); } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 43df29d..e94c610 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -129,6 +129,8 @@ pub enum Icon { Logo, /// Ignore auto layout: a layer pinned at an offset inside its frame. AbsolutePosition, + /// A component slot: a dashed frame with a plus, a place that takes any content. + Slot, /// Edit settings: two sliders. Adjust, /// Show as a list. @@ -722,6 +724,16 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { p.line(10.5, 7.75, 13.5, 7.75); p.line(8.75, 2.5, 8.75, 6.0); } + Slot => { + for (a, b) in [(2.5, 5.0), (6.75, 9.25), (11.0, 13.5)] { + p.line(a, 2.5, b, 2.5); + p.line(a, 13.5, b, 13.5); + p.line(2.5, a, 2.5, b); + p.line(13.5, a, 13.5, b); + } + p.line(8.0, 5.5, 8.0, 10.5); + p.line(5.5, 8.0, 10.5, 8.0); + } Adjust => { p.line(2.5, 5.0, 13.5, 5.0); p.line(2.5, 11.0, 13.5, 11.0); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 26a8a64..80fe1df 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -17,6 +17,7 @@ pub fn node_icon(doc: &Document, id: NodeId) -> Icon { let Some(n) = doc.node(id) else { return Icon::Rectangle }; match &n.kind { NodeKind::Page { .. } => Icon::Page, + NodeKind::Frame if figcraft_engine::components::is_slot(doc, id) => Icon::Slot, NodeKind::Frame => { if let Some(l) = &n.layout { match l.mode { @@ -531,14 +532,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } } - let icon_col = if is_comp { + let icon = node_icon(&doc, row.id); + let icon_col = if is_comp || icon == Icon::Slot { COMPONENT } else if n.visible { t.icon } else { t.text_tertiary }; - icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), node_icon(&doc, row.id), icon_col); + icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col); let name_x = x0 + 36.0; let editing = app.ui.rename.as_ref().is_some_and(|(i, _)| *i == row.id); if editing { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4463bea..41c6beb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, e layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxWidth` / `minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties, -component/instance links, overrides, boundVariables, interactions, exports. The auto layout props +component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports. The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). ### `ui.inspect {}` diff --git a/docs/mcp.md b/docs/mcp.md index de6998d..2c3bc61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -113,7 +113,7 @@ Or in a project's `.mcp.json`: | Tool | What it does | |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | -| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). | +| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. | @@ -186,6 +186,30 @@ read library files return an error). - **Update**: `library.updates` lists the components and styles with a newer published version; `library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides. +### Slots + +A slot is a frame inside a main component whose instances take any content of their own (add, +remove and reorder layers there, unlike the rest of an instance). It is a component property of type +`slot` (`component.properties` lists it: on a main with its `layers`, on an instance with +`value: {layer, edited, layers}`). + +- **Define** (on the main): `component.makeSlot {id (a frame inside a main component), name?, + preferred? [component or set ids]}` (the property is named after the frame by default); + `component.editProperty {name, preferred}`; `component.removeSlot {id}` (instances lose their own + content in it). `component.addProperty {type: "slot", layer}` and `component.bindProperty {field: + "slot"}` work too (one frame per component for a slot). +- **Fill** (on an instance): `instance.slotInsert {id (the slot layer, or the instance with `slot` + (property name)), component, index?, replace?}` places an instance of a component in it; any + structure command (`shape.*`, `node.reparent`, `edit.paste` with the slot selected, `edit.delete`, + `node.reorder`, `instance.place` or a drag onto it) works inside it. The main's default content + shows (and follows the main) until the slot is first edited; then the slot's layers are the + instance's own (override group `slot`): kept across main updates, variant switches, copy/paste and + detach, and instances placed there stand alone (their own overrides, properties and detach). +- **Reset**: `instance.resetSlot {id}` (or `component.resetOverride {id, group: "slot"}`, or + `component.resetInstance`) brings back the main's default content. + +`get_node` shows `"slot": ""` on slot layers. + ### Shape builder The selected shapes' outlines split the canvas into regions (areas covered by the same shapes).