M3: components behaviour suite and fixes

**A components and variants behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule of instances (what an instance overrides and what it can't, how overrides persist when the main changes, reset), variants (which overrides survive a switch, the default variant, property order, duplicate combinations), swaps (instance menu vs Assets panel), exposed nested instances, detach / reset all / push changes / go to main, resizing and auto layout instances is a table row with the values the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. Nested instance overrides stay covered by `nested_override_tests`, slots by `components_tests`, accepting library updates with overrides kept by `library_tests` / `components_scale_tests` (#91).

**Fixes (9 of the 10 new tests fail on the old code)**
- **Overrides that were silently lost**: auto layout **padding**, **gap** and **alignment** (three override groups, so a padding override still follows the main's gap), **layout grids**, **export settings** and **prototype interactions** on layers inside instances, and every **text property** (resizing, paragraph spacing and indent, lists, max lines, OpenType features) were reset by the next edit of the main. They are override groups now: kept when the main changes that property, carried by variant switches and swaps, pushed to the main, reset per group, and listed in the Overrides section. REST imports map the matching `overriddenFields`. An instance's own export settings and interactions stay its own, like its place (as before).
- **What an instance can't override is refused** (rolled back with a message, one place for every path: commands, MCP, gestures): moving, rotating, flipping, nudging or aligning a layer inside an instance (auto layout items included: their place is the layout's), resizing it (text boxes keep their main's bounds; text whose box follows its content still grows), changing its constraints or resizing (hug / fill / min / max / absolute), and an instance's auto layout direction or other auto layout settings (only padding, gap and alignment are its own). Before, these became `position` / `size` overrides or were lost on the next main edit. Resizing the instance itself still works and its layers follow the main's constraints. Existing `size` / `position` overrides in files are still read and kept.
- **Variant switches** keep an override only where the layer's name path matches *and* both variants had the same value for that property; text layers keep all their changes (by name and hierarchy). Before, every same-named override survived, even where the new variant differs (a hover background overridden back to the default one). The instance's own overrides follow the same rule. Undo brings the dropped overrides back.
- **Alt-drop swaps from the Assets panel** (`instance.swapAt`, `library.swapAt`) keep only text layers' overrides; the instance menu's swap (`instance.swap`) still keeps every override on same-named layers.
- **Default variant = the top-left one** (was the first in layer order): new instances of a set, the set's Assets thumbnail, generated code, `component.properties` defaults and the panel's default dropdown. Picking another default moves that variant to the top-left corner (one undo step).
- **Hidden exposed instances hide their properties** (`instance.exposed`).
- **Push changes from a nested layer** goes to the outer instance's main, never into the nested instance's own main; it reuses the override copy code, so every group above is pushed too.

**Added**
- **Variant property order**: `component.editProperty {name, index}` moves a variant property; every variant's name follows.
- **Duplicate combinations**: `component.properties` reports `conflicts` (variants sharing one combination of values), and the component panel warns until each has a unique one.
- **Design panel**: Position, Rotation, Constraints, Dimensions / resizing and grid cell fields are greyed out (with a tooltip) for layers inside an instance; the auto layout direction, wrap, strokes / canvas stacking and add / remove auto layout are greyed out or hidden for instances. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; disabled controls are announced as disabled.
This commit is contained in:
bflatastic
2026-10-12 00:57:43 +07:00
committed by BFlat
parent 5923e7dfcf
commit 62db8d4e36
21 changed files with 1211 additions and 149 deletions
+50 -66
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.resetOverride",
"Reset override",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props|slot; all when omitted)}",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
true,
reset_override
),
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"instance.swapAt",
"Swap instance at point",
"{component (component or set: its default variant), x, y (page point)} swaps the instance there (preferring one showing the same component set; Alt-drop from the Assets panel); overrides on layers with matching names stay",
"{component (component or set: its default variant), x, y (page point)} swaps the instance there (preferring one showing the same component set; Alt-drop from the Assets panel); only text layers keep their overrides (by matching names)",
true,
swap_at
),
@@ -185,7 +185,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.editProperty",
"Edit component property",
"{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
edit_prop
),
@@ -241,14 +241,11 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": i, "parent": slot.parent}))
}
/// A component to instantiate: the component itself, or a set's default (first) variant.
/// A component to instantiate: the component itself, or a set's default (top-left) variant.
pub(crate) fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let n = doc.node(id)?;
match n.kind {
match doc.node(id)?.kind {
NodeKind::Component { .. } => Some(id),
NodeKind::ComponentSet { .. } => {
n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. })))
}
NodeKind::ComponentSet { .. } => default_variant(doc, id),
_ => None,
}
}
@@ -635,12 +632,14 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.pushChanges";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
let doc = s.doc_mut()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
// Changes go to the outer instance's main (never into a nested instance's own main).
let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() };
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
}
// Copy every overridden layer's values to its source in the main.
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
// values and slot content stay with the instance).
let mut pushed = 0;
for d in doc.subtree(inst) {
let Some(n) = doc.node(d).cloned() else { continue };
@@ -649,53 +648,14 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let Some(mut target) = doc.node(src).cloned() else { continue };
let mut groups = BTreeSet::new();
for g in &n.overrides {
let done = match g.as_str() {
"fills" => {
target.fills = n.fills.clone();
target.styles.fill = n.styles.fill.clone();
true
}
"strokes" => {
target.strokes = n.strokes.clone();
target.stroke = n.stroke.clone();
target.styles.stroke = n.styles.stroke.clone();
true
}
"effects" => {
target.effects = n.effects.clone();
target.styles.effect = n.styles.effect.clone();
true
}
"opacity" => {
target.opacity = n.opacity;
target.blend = n.blend;
true
}
"visible" => {
target.visible = n.visible;
true
}
"name" if d != inst => {
target.name = n.name.clone();
true
}
"corners" => {
target.corners = n.corners;
true
}
"characters" | "textStyle" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&n.kind, &mut target.kind) {
*b = a.clone();
}
true
}
_ => false,
};
if done {
groups.insert(g.clone());
}
let groups: BTreeSet<String> = n
.overrides
.iter()
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
.cloned()
.collect();
for g in &groups {
copy_group(&n, &mut target, g);
}
if groups.is_empty() {
continue;
@@ -829,6 +789,7 @@ pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result<Value> {
let family = crate::drop::swap_family(doc, c);
let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c))
.ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?;
keep_text_overrides(s.doc_mut()?, target);
swap(s, &json!({"ids": [target], "component": c}))?;
s.set_selection(vec![target]);
Ok(json!({"id": target, "component": c}))
@@ -1248,7 +1209,14 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => None,
};
let order: Vec<String> = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect();
let mut order: Vec<String> = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect();
// Reordered: every variant's name lists the properties in the new order.
if let Some(to) = get(p, "index").filter(|v| !v.is_null()) {
let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?;
let key = new_name.clone().unwrap_or(name.clone());
order.retain(|k| *k != key);
order.insert(to.min(order.len()), key);
}
for c in &comps {
let mut pairs = variant_props(&doc.get(*c)?.name);
if let Some((v, nv)) = &rename_value
@@ -1263,13 +1231,26 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
doc.get_mut(*c)?.name = variant_name(&pairs, &order);
}
// The default variant is the first one: move the first matching variant there.
// The default variant is the top-left one: the variant with that value (and the default's
// other values, when there is one) trades places with it.
if let Some(dv) = string(p, "default") {
let key = new_name.clone().unwrap_or(name.clone());
let first = comps.iter().copied().find(|c| doc.node(*c).is_some_and(|n| variant_props(&n.name).get(&key) == Some(&dv)));
match first {
Some(f) => doc.reorder(f, 0)?,
None => return Err(bad(cmd, format!("no variant has {key}={dv}"))),
let current = default_variant(doc, set);
let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default();
wanted.insert(key.clone(), dv.clone());
let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default();
let pick =
comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv)));
let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) };
if f != d {
let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default();
let (fp, dp) = (place(doc, f), place(doc, d));
for (c, (x, y)) in [(f, dp), (d, fp)] {
let n = doc.get_mut(c)?;
let [a, b, cc, dd, ..] = n.transform.as_coeffs();
n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]);
}
fit_set(doc, set);
}
}
return ok();
@@ -1372,8 +1353,9 @@ fn describe_props(doc: &Document, id: NodeId) -> Value {
let mut list: Vec<Value> = Vec::new();
if let Some(set) = variant_set(doc, comp) {
let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default();
let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default();
for (k, vals) in variant_options(doc, set) {
let mut v = json!({"name": k, "type": "variant", "options": vals, "default": vals.first()});
let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())});
if values.is_some() {
v["value"] = json!(current.get(&k));
}
@@ -1399,5 +1381,7 @@ fn describe_props(doc: &Document, id: NodeId) -> Value {
}
list.push(v);
}
json!({"component": comp, "set": variant_set(doc, comp), "properties": list})
let set = variant_set(doc, comp);
let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default();
json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts})
}
+11 -3
View File
@@ -718,11 +718,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden.
// Inside an instance, record which property groups were overridden (an instance's own export
// settings and interactions are its own, not overrides).
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
let n = doc.get_mut(id)?;
if n.source.is_some() {
for k in props.keys() {
n.overrides.insert(override_group(k).to_string());
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
n.overrides.insert(g.to_string());
}
}
Ok(())
@@ -742,6 +744,12 @@ pub fn override_group(k: &str) -> &'static str {
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" | "variationSettings" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" => "alignment",
"grids" | "layoutGrids" | "gridStyle" => "grids",
"exports" | "exportSettings" => "exports",
"interactions" => "interactions",
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => {
"textStyle"
}