diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..6860327f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -295,6 +295,10 @@ struct Inst<'a> { path: Vec, overrides: Rc>, props: Rc>>, + /// Slot property GUID → private slot-content node GUID. + slot_contents: Rc>, + /// Layers imported from a slot's independent content subtree. + slot_content: bool, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, } @@ -768,7 +772,13 @@ impl<'a> Mapper<'a> { // Private provenance for the .fig importer; ordinary REST import ignores it. o.insert( crate::rest::FIG_SOURCE_FIELD.into(), - json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}), + json!({ + "origin": id, + "expanded": inst.is_some(), + "wireFrame": own == "FRAME", + "wireInstance": own == "INSTANCE", + "slotContent": inst.is_some_and(|i| i.slot_content), + }), ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); @@ -797,6 +807,16 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); + let slot_content = if own == "FRAME" { + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + } else { + None + }; + if slot_content.is_some() + && let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) + { + source.insert("slotOverride".into(), json!(true)); + } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { @@ -808,6 +828,14 @@ impl<'a> Mapper<'a> { kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } + } else if let Some(content) = slot_content { + let mut content_inst = inst.cloned(); + if let Some(content_inst) = &mut content_inst { + content_inst.slot_content = true; + } + for c in self.children(&content) { + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + } } else { // Inside an instance a layer's children are its main's: they follow it from the main's // size to its own. @@ -1282,6 +1310,11 @@ impl<'a> Mapper<'a> { None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), + "SLOT" => { + self.warn_non_default_slot_config(d); + self.warn_dropped_slot_preferred_values(d); + json!({"type": "SLOT"}) + } _ => { self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase())); continue; @@ -1298,6 +1331,34 @@ impl<'a> Mapper<'a> { } } + /// Preferred slot values are source component keys, but this mapper has no proven way to map + /// those keys to file GUIDs. They are dropped instead of guessing native NodeIds. + fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) { + let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default(); + if !entries.is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + } + + fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) { + let Some(config) = def.get("slotPropConfig") else { return }; + let non_default = [ + ("stretchChildOnInsert", Some(false)), + ("displayByDefault", Some(false)), + ("minChildren", None), + ("maxChildren", None), + ("allowPreferredValuesOnly", Some(false)), + ] + .into_iter() + .any(|(field, bool_default)| { + let Some(value) = config.get(field) else { return false }; + if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) } + }); + if non_default { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// A layer's component property references, and inside an instance the values they take. fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) { let mut refs = Obj::new(); @@ -1306,6 +1367,7 @@ impl<'a> Mapper<'a> { Some("VISIBLE") => "visible", Some("TEXT_DATA") => "characters", Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent", + Some("SLOT_CONTENT_ID") => "slot", _ => continue, }; let Some(def) = @@ -1313,7 +1375,11 @@ impl<'a> Mapper<'a> { else { continue; }; - if let Some((name, _)) = self.prop_defs.get(&def) { + // Expanded frames must follow their main frame's source link. A direct slot reference + // makes the engine resolve ownership against the instance, where the property is not defined. + if !(field == "slot" && inst.is_some()) + && let Some((name, _)) = self.prop_defs.get(&def) + { refs.insert(field.into(), json!(name)); } let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; @@ -1445,15 +1511,50 @@ impl<'a> Mapper<'a> { rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); + let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { all_props.entry(d).or_insert(val); } + let Some(def) = a.get("defID").and_then(guid) else { continue }; + if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + continue; + } + let var_value = a.get("varValue"); + if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") { + self.warn("malformed slot content assignment was skipped"); + continue; + } + let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid); + let Some(target) = target else { + self.warn("malformed slot content assignment was skipped"); + continue; + }; + if target == "4294967295:4294967295" { + self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); + continue; + } + if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) { + self.warn("slot content assignment target is missing or is not slot content"); + continue; + } + slot_contents.entry(def).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone()); - Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym)) + Some(( + Inst { + root, + path: own_path, + overrides: Rc::new(overrides), + props: Rc::new(all_props), + slot_contents: Rc::new(slot_contents), + slot_content: false, + symbols, + }, + sym, + )) } fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..ceb13346 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod slot_tests; +#[cfg(test)] mod source_identity_tests; #[cfg(test)] mod tests; diff --git a/crates/format/src/fig/slot_tests.rs b/crates/format/src/fig/slot_tests.rs new file mode 100644 index 00000000..670e2f6f --- /dev/null +++ b/crates/format/src/fig/slot_tests.rs @@ -0,0 +1,225 @@ +//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and +//! the content subtrees that instances assign, on synthetic messages. + +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn node(local: u32, kind: &'static str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))]))); + } + all.append(&mut fields); + o(all) +} + +fn mapped(msg: &Kv<'_>) -> Value { + to_rest(msg, "Synthetic resources").0 +} + +fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id)) +} + +fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec> { + let Kv::Object(fields) = msg else { panic!() }; + let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() }; + rows +} + +fn slot_message(content: Option, preferred: bool) -> Kv<'static> { + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))]; + if let Some(content) = content { + instance_fields.push(( + "componentPropAssignments", + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]), + )); + } + let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))]; + if preferred { + definition.push(( + "preferredValues", + o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]), + )); + } + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", None, vec![]), + node(2, "CANVAS", Some(1), vec![]), + node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]), + node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]), + node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + node(13, "RECTANGLE", Some(12), vec![]), + node(20, "INSTANCE", Some(2), instance_fields), + node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + node(31, "RECTANGLE", Some(30), vec![]), + node(32, "ELLIPSE", Some(30), vec![]), + node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + ]), + )]) +} +#[test] +fn slot_definition_reference_and_arbitrary_override_children_are_imported() { + let rest = mapped(&slot_message(Some(30), false)); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + let main_slot = named_id(&rest["document"], "9:12").unwrap(); + assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert!(slot.get("componentPropertyReferences").is_none()); + assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); + let kids = slot["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "I9:20;9:31"); + assert_eq!(kids[1]["id"], "I9:20;9:32"); + + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let main = doc + .node(*doc.pages.first().unwrap()) + .unwrap() + .children + .iter() + .find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }))) + .unwrap(); + assert!( + matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty())) + ); + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(instance.children[0]).unwrap(); + assert!(!slot.prop_refs.contains_key("slot")); + assert_eq!(slot.source, Some(main.children[0])); + assert!(slot.overrides.contains("slot")); + assert_eq!(slot.children.len(), 2); + assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none()))); + + let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap(); + let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let public_slot = public.node(public_instance.children[0]).unwrap(); + assert!(!public_slot.overrides.contains("slot")); +} +#[test] +fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() { + let mut msg = slot_message(Some(30), false); + let rows = changes(&mut msg); + let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap(); + let Kv::Object(fields) = slot_frame else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME"); + rows.extend([ + node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![]), + node(42, "RECTANGLE", Some(41), vec![]), + ]); + + let rest = mapped(&msg); + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let outer = doc + .ids() + .filter_map(|id| doc.node(id)) + .find(|n| { + matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }) + && !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. }))) + }) + .unwrap(); + let slot = doc.node(outer.children[0]).unwrap(); + let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap(); + assert!(own_frame.source.is_none()); + + let nested = slot + .children + .iter() + .filter_map(|id| doc.node(*id)) + .flat_map(|n| n.children.iter()) + .filter_map(|id| doc.node(*id)) + .find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })) + .unwrap(); + let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") }; + assert_eq!(nested.source, Some(component)); + let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap(); + let expanded_frame = doc.node(nested.children[0]).unwrap(); + assert_eq!(expanded_frame.source, Some(main_frame.id)); + assert_eq!(expanded_frame.children.len(), 1); + assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0])); +} +#[test] +fn outer_slot_assignment_wins_over_a_nested_instances_own() { + let mut msg = slot_message(Some(30), false); + let assignment = |content: u32| { + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]) + }; + // The nested instance inside the main assigns the empty slot content; the outer instance's content wins. + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + changes(&mut msg).extend([ + node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + ]); + let rest = mapped(&msg); + let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap(); + assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}"); +} +#[test] +fn unassigned_slot_keeps_default_and_bad_content_target_warns() { + let rest = mapped(&slot_message(None, false)); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + assert!(slot.get("overrides").is_none()); + + let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic"); + assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + + let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic"); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}"); + let empty = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs index 61097e52..2793f932 100644 --- a/crates/format/src/rest/fig_sources.rs +++ b/crates/format/src/rest/fig_sources.rs @@ -17,6 +17,7 @@ struct Origin<'a> { rest: &'a str, guid: &'a str, expanded: bool, + slot_content: bool, wire_frame: bool, wire_instance: bool, component: Option<&'a str>, @@ -75,7 +76,8 @@ impl<'a> Sources<'a> { && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); - self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component }); + let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component }); } fn origin(&self, id: NodeId) -> Option> { @@ -211,6 +213,9 @@ impl<'a> Sources<'a> { let found = (|| { let meta = self.origin(id)?; let _ = doc.node(id)?; + if meta.slot_content { + return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None }; + } if !meta.expanded { return self.root_source(id, doc); } @@ -269,8 +274,8 @@ impl<'a> Sources<'a> { } let mut missing = self.rejected; for (&id, meta) in &self.nodes { - let needs_source = - (meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids)) + || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); if !needs_source { continue; } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..6088519b 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -451,6 +451,10 @@ impl<'a> Ctx<'a> { let id = self.doc.alloc_id(); let label = kind.label(); let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h); + if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true) + { + n.overrides.insert("slot".into()); + } n.transform = local(space.world, world); self.common(&mut n, j, parent_mode); let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode)); @@ -605,7 +609,8 @@ impl<'a> Ctx<'a> { n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); n.exports = props::export_settings(j); for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() { - if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) { + let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some()); + if let (Some(p), true) = (prop.as_str(), supported) { n.prop_refs.insert(field.clone(), p.to_string()); } } @@ -709,6 +714,7 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, + Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, _ => { self.warn("unknown component property types were skipped"); continue;