M3: component slots and nested override edge cases (#56)

Slots: a frame of a main component becomes a slot property
(component.makeSlot / removeSlot, preferred components, type "slot" in
component.properties). Instances show the main's default content until
the slot is edited; from then on its layers are the instance's own
(override group "slot"): free to add, remove and reorder, kept across
main updates, variant switches, copy/paste and detach, and instances
placed there stand alone. instance.slotInsert fills a slot,
instance.resetSlot brings the default content back. Structure
protection allows edits inside slots only; drops from the Assets panel,
canvas drags, drawing and paste land in slots. UI: slot icon in the
layers panel, dashed outlines and labels on the canvas, slot property
rows with a fill menu in the properties panel, Make/Remove slot on
frames inside a main.

Nested overrides: overrides inside a nested instance survive the main
moving it into another frame (layers matched by source alone when
unique); a nested instance copied out of an instance is re-linked to
its own main with its look kept (values set by component properties
aren't overrides); plain layers copied out of instances stop mirroring
them; canvas drags can no longer pull layers out of an instance or
reorder its auto layout children. Tests for swaps, variant switches,
main restructuring, detach and copy/paste.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 08:04:13 +07:00 committed by GitHub
parent ec5da02a35
commit 0638c98564
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1456 additions and 131 deletions

View File

@ -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 |

View File

@ -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<String>, default: String },
Boolean {
default: bool,
},
Text {
default: String,
},
InstanceSwap {
default: NodeId,
},
Variant {
options: Vec<String>,
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<NodeId>,
},
}
/// 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<String>,
/// 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<String, String>,
/// Free-form plugin / agent data.

View File

@ -93,12 +93,40 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<Value> {
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<Value> {
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::<String>().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<Vec<NodeId>> {
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<Value> {
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<Value> {
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<NodeId> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<NodeId> = 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);

View File

@ -101,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// 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<NodeId> {
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize) -> Option<NodeId> {
/// `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<NodeId> {
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)> {

View File

@ -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<NodeId> {
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<NodeId> {
let mut chain = vec![id];
@ -40,21 +80,117 @@ pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
/// The innermost instance containing `id` (`id` itself when it is an instance).
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
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<NodeId> {
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<NodeId> {
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<String> {
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<NodeId> {
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<NodeId> = 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<String, Value> {
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<NodeId, String> {
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<NodeId, S
if out.len() > 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<Node>)> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = 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<Node>)>) {
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<NodeId> = 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<NodeId> = 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<NodeId, Option<Node>> = BTreeMap::new();
let mut by_path: BTreeMap<String, Node> = 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<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let main_paths = path_map(doc, component, false);
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut olds: BTreeMap<NodeId, Node> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = 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<NodeId> = BTreeSet::new();
let mut skipped: BTreeSet<NodeId> = 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<NodeId>) {
// 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<NodeId> = 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<NodeId>) -> Vec<NodeId> {
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<String> = 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<String, Strin
/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
/// own main. Returns the problems found.
/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
pub fn mirror_issues(doc: &Document) -> Vec<String> {
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<String> {
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
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<String> {
}
}
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).

View File

@ -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<NodeId> {
pub(crate) fn kids(s: &Session, id: NodeId) -> Vec<NodeId> {
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,

View File

@ -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<NodeId> {
fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, depth: usize) -> Option<NodeId> {
/// `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<NodeId> {
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.

View File

@ -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) => {

View File

@ -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;

View File

@ -64,10 +64,15 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet<NodeId> {
}
}
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<NodeId, NodeId>, 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(),
_ => {}
}
}
}

View File

@ -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);
}

View File

@ -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<Value> {
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<T: Copy>(&mut self, v: &[T]) -> Option<T> {
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<NodeId> = 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<NodeId> = slots.iter().flat_map(|sl| doc.node(*sl).map(|n| n.children.clone()).unwrap_or_default()).collect();
let main_layers: Vec<NodeId> = 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());
}
}
}

View File

@ -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.

View File

@ -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<NodeId>) {
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
let mut roots: Vec<NodeId> = 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<Vec<Vec<Pos2>>> {
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));
}

View File

@ -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<String, PropDef> {
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<u64> = 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<String> = 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);
}
}
}
});
}

View File

@ -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);

View File

@ -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 {

View File

@ -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 {}`

View File

@ -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": "<property>"` on slot layers.
### Shape builder
The selected shapes' outlines split the canvas into regions (areas covered by the same shapes).