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