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"
}
+5 -5
View File
@@ -2,7 +2,7 @@
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
//! share one definition (the set's first variant); in React their differences become CSS rules on
//! share one definition (the set's default variant); in React their differences become CSS rules on
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
@@ -53,7 +53,7 @@ pub struct Param {
pub enum_name: String,
}
/// A component as code: the main whose layers are drawn (a set's first variant), its set, its
/// A component as code: the main whose layers are drawn (a set's default variant), its set, its
/// name in code and its parameters (variant properties first).
#[derive(Clone, Debug, PartialEq)]
pub struct Component {
@@ -140,12 +140,12 @@ fn is_component(doc: &Document, id: NodeId) -> bool {
}
/// The main component a layer's code is defined by: a component itself, an instance's main, and
/// for anything in a set the set's first variant.
/// for anything in a set the set's default (top-left) variant.
pub fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let n = doc.node(id)?;
let main = match &n.kind {
NodeKind::Component { .. } => id,
NodeKind::ComponentSet { .. } => return n.children.iter().copied().find(|c| is_component(doc, *c)),
NodeKind::ComponentSet { .. } => return crate::components::default_variant(doc, id),
NodeKind::Instance { component, .. } => *component,
_ => return None,
};
@@ -153,7 +153,7 @@ pub fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
return None;
}
match variant_set(doc, main) {
Some(set) => doc.node(set)?.children.iter().copied().find(|c| is_component(doc, *c)),
Some(set) => crate::components::default_variant(doc, set),
None => Some(main),
}
}
+238 -15
View File
@@ -9,9 +9,18 @@
//! `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),
//! `props` (property values of a nested instance) and `slot` (a slot holding the instance's own
//! content).
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
//! settings and interactions are its own, like its place. `size` and `position` overrides of
//! layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
//! main; the instance itself resizes.
//!
//! Switching variants keeps an override where the layer's name path matches and the two variants
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
//! only text layers' overrides ([`keep_text_overrides`]).
//!
//! 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`]), except inside **slots**. A
@@ -42,6 +51,12 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"position",
"component",
@@ -299,7 +314,7 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
}
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
fn copy_group(from: &Node, to: &mut Node, g: &str) {
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
to.bound.retain(|k, _| binding_group(k) != Some(g));
to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone())));
@@ -335,11 +350,32 @@ fn copy_group(from: &Node, to: &mut Node, g: &str) {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.style = a.style.clone();
b.runs = a.runs.clone();
b.auto_resize = a.auto_resize;
b.align_h = a.align_h;
b.align_v = a.align_v;
b.paragraph_spacing = a.paragraph_spacing;
b.paragraph_indent = a.paragraph_indent;
b.list = a.list;
b.max_lines = a.max_lines;
b.features = a.features.clone();
}
to.styles.text = from.styles.text.clone();
}
"padding" | "gap" | "alignment" => {
if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) {
match g {
"padding" => b.padding = a.padding,
"gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap),
_ => (b.main_align, b.cross_align) = (a.main_align, a.cross_align),
}
}
}
"grids" => {
to.grids = from.grids.clone();
to.styles.grid = from.styles.grid.clone();
}
"exports" => to.exports = from.exports.clone(),
"interactions" => to.interactions = from.interactions.clone(),
"size" => {
to.width = from.width;
to.height = from.height;
@@ -506,7 +542,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
if depth > MAX_DEPTH {
return;
}
let Some(root) = doc.node(inst).cloned() else { return };
let Some(mut root) = doc.node(inst).cloned() else { return };
let NodeKind::Instance { component, props: root_props } = &root.kind else { return };
let component = *component;
let Some(comp) = doc.node(component).cloned() else { return };
@@ -572,6 +608,9 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
}
@@ -701,6 +740,11 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
_ => false,
});
fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
// Switched to another variant: its own overrides stay where both variants agreed.
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
let differs = diff_groups(prev, &comp);
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
}
carry_overrides(&root, &mut fresh, Carry::Root);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
@@ -763,6 +807,43 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
}
}
/// Whether main components `a` and `b` are two variants of one set.
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
}
/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched
/// variants): `o` without the overrides of properties the two variants' layers differ in (`None`
/// when it keeps them all). Text layers keep every override.
fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option<Node> {
let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?;
if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) {
return None;
}
if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) {
return None;
}
let differs = diff_groups(from, doc.node(s)?);
let mut m = o.clone();
m.overrides.retain(|g| !differs.contains(&g.as_str()));
(m.overrides.len() != o.overrides.len()).then_some(m)
}
/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of
/// its slots): what swapping it from the Assets panel keeps.
pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) {
for d in old_layers(doc, inst).into_iter().chain([inst]) {
let Some(n) = doc.node(d) else { continue };
let text = matches!(n.kind, NodeKind::Text(_));
let keep = |g: &String| text || g == SLOT || (d == inst && g == "size");
if !n.overrides.iter().all(keep)
&& let Some(m) = doc.node_mut(d)
{
m.overrides.retain(keep);
}
}
}
/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old
/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually
/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever
@@ -897,10 +978,30 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
if x.characters != y.characters {
out.push("characters");
}
if x.style != y.style || x.runs != y.runs || x.align_h != y.align_h || x.align_v != y.align_v {
if text_style_differs(x, y) {
out.push("textStyle");
}
}
if let (Some(x), Some(y)) = (&a.layout, &b.layout) {
if x.padding != y.padding {
out.push("padding");
}
if x.gap != y.gap || x.cross_gap != y.cross_gap {
out.push("gap");
}
if x.main_align != y.main_align || x.cross_align != y.cross_align {
out.push("alignment");
}
}
if a.grids != b.grids || a.styles.grid != b.styles.grid {
out.push("grids");
}
if a.exports != b.exports {
out.push("exports");
}
if a.interactions != b.interactions {
out.push("interactions");
}
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
if x != y {
out.push("component");
@@ -912,6 +1013,20 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
out
}
/// Whether two texts differ in anything but their characters (the `textStyle` group).
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
x.style != y.style
|| x.runs != y.runs
|| x.auto_resize != y.auto_resize
|| x.align_h != y.align_h
|| x.align_v != y.align_v
|| x.paragraph_spacing != y.paragraph_spacing
|| x.paragraph_indent != y.paragraph_indent
|| x.list != y.list
|| x.max_lines != y.max_lines
|| x.features != y.features
}
/// 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 {
@@ -974,8 +1089,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
}
let raw = diff_groups(b, n);
let mut groups = raw.clone();
// An instance's own place, export settings and interactions are its own, not overrides.
if top_root {
groups.retain(|g| !matches!(*g, "component" | "props" | "position"));
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
@@ -1119,8 +1235,25 @@ fn full_rebuild() -> bool {
/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without
/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed.
fn patchable(doc: &Document, b: &Node, n: &Node) -> Option<Vec<&'static str>> {
const VALUES: [&str; 11] =
["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position"];
const VALUES: [&str; 17] = [
"fills",
"strokes",
"effects",
"opacity",
"visible",
"name",
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"position",
];
if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() {
return None;
}
@@ -1314,14 +1447,18 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
{
continue;
}
// An instance keeps its own visibility and place; its name follows the main's unless
// renamed; a resized one lays its layers out against the main's size.
// An instance keeps its own visibility, place, export settings and interactions; its
// name follows the main's unless renamed; a resized one lays its layers out against
// the main's size.
let root = matches!(pn.kind, NodeKind::Component { .. });
if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) {
return false;
}
let gs: Vec<&'static str> =
groups.iter().copied().filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position"))).collect();
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
}
@@ -1499,6 +1636,61 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S
Ok(())
}
/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled
/// back.
pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> {
match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) {
Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")),
None => Ok(()),
}
}
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
/// instance itself resizes, and its layers follow; text whose box follows its content still
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
/// library update).
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
let (n, b) = (after.node(id)?, before.node(id)?);
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
return None;
}
let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?);
let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode);
// Auto layout settings other than the overridable ones.
let settings = |x: &Node| {
x.layout.clone().map(|mut l| {
(l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min);
l
})
};
let size = |x: &Node| (x.width, x.height);
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
if mode(n) != mode(b) && mode(ma) == mode(mb) {
return Some("The auto layout direction");
}
if settings(n) != settings(b) && settings(ma) == settings(mb) {
return Some("This auto layout setting");
}
if !inside_instance(after, id) || !inside_instance(before, id) {
return None;
}
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
return Some("A layer's position");
}
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
return Some("A layer's size");
}
if n.constraints != b.constraints && ma.constraints == mb.constraints {
return Some("A layer's constraints");
}
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
return Some("A layer's resizing");
}
None
}
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
/// the look is kept exactly. Values a component property sets aren't overrides.
@@ -1545,7 +1737,10 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let root_groups = match (doc.node(component), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
let mut groups: Vec<String> = value_groups(m, n).into_iter().filter(|g| g != "name" && !from_variables.contains(&g.as_str())).collect();
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.collect();
// A layer name of its own (a nested instance renamed in its old main) stays.
if n.name != default_instance_name(doc, component) {
groups.push("name".into());
@@ -1561,6 +1756,32 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
}
}
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
let corner = |c: &NodeId| {
doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x))
};
let kids = &doc.node(set)?.children;
kids.iter()
.copied()
.filter_map(|c| corner(&c).map(|k| (k, c)))
.min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, c)| c)
}
/// Variants of a set sharing one combination of property values (each group two or more, in
/// layer order): a conflict to resolve by giving each a unique combination.
pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec<Vec<NodeId>> {
let mut by: BTreeMap<BTreeMap<String, String>, Vec<NodeId>> = BTreeMap::new();
for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
by.entry(variant_props(&n.name)).or_default().push(c);
}
}
by.into_values().filter(|v| v.len() > 1).collect()
}
/// Parse a variant name "Size=Large, State=Hover" into pairs.
pub fn variant_props(name: &str) -> BTreeMap<String, String> {
name.split(',')
@@ -1671,7 +1892,9 @@ pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec<NodeId> {
break;
}
let Some(n) = doc.node(c) else { continue };
let exposed = matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
// A hidden exposed instance hides its properties too.
let exposed =
n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
if exposed {
out.push(c);
}
@@ -0,0 +1,723 @@
//! Components and variants behaviour suite: each documented rule of instances, overrides, variants,
//! swaps, detach / reset / push and resizing is a table row with a one-line derivation. Where the
//! documentation is silent the row says so and pins today's behaviour. Nested instance overrides
//! are covered in `nested_override_tests`, slots in `components_tests`, accepting library updates
//! with overrides kept in `library_tests` and `components_scale_tests`.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
use figcraft_doc::*;
/// The card: a 200×100 component with a "Bg" rectangle, a "Title" text and a "Row" auto layout
/// frame (padding 4, gap 2) holding a "Chip" rectangle; and one instance of it.
struct Kit {
card: NodeId,
bg: NodeId,
title: NodeId,
row: NodeId,
chip: NodeId,
inst: NodeId,
}
#[derive(Clone, Copy, Debug)]
enum Layer {
Root,
Bg,
Title,
Row,
Chip,
}
impl Kit {
fn main(&self, l: Layer) -> NodeId {
match l {
Layer::Root => self.card,
Layer::Bg => self.bg,
Layer::Title => self.title,
Layer::Row => self.row,
Layer::Chip => self.chip,
}
}
/// The instance's copy of main layer `l`.
fn copy(&self, s: &Session, l: Layer) -> NodeId {
match l {
Layer::Root => self.inst,
_ => copy_of(s, self.inst, self.main(l)),
}
}
}
fn kit(s: &mut Session) -> Kit {
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 row = idv(&ex(s, "frame.create", json!({"x": 10, "y": 50, "width": 120, "height": 30, "parent": f, "name": "Row"})));
ex(s, "node.set", json!({"id": row, "props": {"layout": "horizontal", "padding": 4, "gap": 2}}));
let chip = idv(&ex(
s,
"shape.rectangle",
json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": row, "props": {"name": "Chip", "fill": "#333333"}}),
));
let card = idv(&ex(s, "component.create", json!({"ids": [f]})));
let inst = idv(&ex(s, "instance.create", json!({"component": card, "x": 300, "y": 0})));
Kit { card, bg, title, row, chip, inst }
}
fn undo_len(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
fn component_of(s: &Session, id: NodeId) -> NodeId {
match node(s, id).kind {
NodeKind::Instance { component, .. } => component,
_ => panic!("{id} is not an instance"),
}
}
fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
// derivation)
type Row = (&'static str, Layer, &'static str, Value, Option<Value>, fn(&Node) -> String, &'static str);
let grid = serde_json::to_value(LayoutGrid::default()).unwrap();
let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap();
let click = json!({"trigger": {"type": "onClick"}, "actions": [{"type": "back"}]});
let rows: Vec<Row> = vec![
(
"fill",
Layer::Bg,
"fills",
json!({"fill": "#00ff00"}),
Some(json!({"fill": "#123456"})),
|n| format!("{:?}", n.fills),
"\"change the fill … of any layers\"",
),
(
"fill of the instance itself",
Layer::Root,
"fills",
json!({"fill": "#abcdef"}),
Some(json!({"fill": "#fedcba"})),
|n| format!("{:?}", n.fills),
"the instance is a layer too",
),
(
"stroke",
Layer::Bg,
"strokes",
json!({"stroke": "#0000ff", "strokeWeight": 3}),
Some(json!({"strokeWeight": 7})),
|n| format!("{:?} {:?}", n.strokes, n.stroke),
"\"change the … stroke of any layers\"",
),
(
"effects",
Layer::Bg,
"effects",
json!({"effects": [{"type": "dropShadow"}]}),
Some(json!({"effects": [{"type": "dropShadow"}, {"type": "dropShadow"}]})),
|n| n.effects.len().to_string(),
"\"add, edit, or remove any shadow or blur effects\"",
),
("visibility", Layer::Bg, "visible", json!({"visible": false}), None, |n| n.visible.to_string(), "hidden layers stand in for removed ones"),
("layer name", Layer::Bg, "name", json!({"name": "Back"}), Some(json!({"name": "Bg2"})), |n| n.name.clone(), "\"change the layer name\""),
(
"text content",
Layer::Title,
"characters",
json!({"characters": "Override"}),
Some(json!({"characters": "Main"})),
|n| text(n).characters.clone(),
"text content is overridable",
),
(
"font size",
Layer::Title,
"textStyle",
json!({"fontSize": 30}),
Some(json!({"fontSize": 18})),
|n| text(n).style.font_size.to_string(),
"\"change text properties, including font, weight, size …\"",
),
(
"text resizing",
Layer::Title,
"textStyle",
json!({"textAutoResize": "height"}),
Some(json!({"textAutoResize": "none"})),
|n| format!("{:?}", text(n).auto_resize),
"\"… letter spacing, and resizing\"",
),
(
"paragraph spacing",
Layer::Title,
"textStyle",
json!({"paragraphSpacing": 8}),
Some(json!({"paragraphSpacing": 2})),
|n| text(n).paragraph_spacing.to_string(),
"a text property like the others",
),
(
"padding",
Layer::Row,
"padding",
json!({"padding": 9}),
Some(json!({"padding": 1})),
|n| format!("{:?}", n.layout.as_ref().map(|l| l.padding)),
"\"adjust vertical and horizontal padding\" on instances",
),
(
"gap",
Layer::Row,
"gap",
json!({"gap": 12}),
Some(json!({"gap": 6})),
|n| format!("{:?}", n.layout.as_ref().map(|l| l.gap)),
"\"adjust gap between\" on instances",
),
(
"layout grids",
Layer::Row,
"grids",
json!({"grids": [grid.clone()]}),
Some(json!({"grids": [grid.clone(), grid.clone()]})),
|n| n.grids.len().to_string(),
"\"add, edit, or remove layout guides\"",
),
(
"layout grids of the instance",
Layer::Root,
"grids",
json!({"grids": [grid.clone()]}),
Some(json!({"grids": [grid.clone(), grid.clone()]})),
|n| n.grids.len().to_string(),
"\"add, edit, or remove layout guides\"",
),
(
"export settings",
Layer::Bg,
"exports",
json!({"exports": [export.clone()]}),
Some(json!({"exports": [export.clone(), export.clone()]})),
|n| n.exports.len().to_string(),
"\"add, edit, or remove export settings\"",
),
(
"prototype interactions",
Layer::Bg,
"interactions",
json!({"interactions": [click.clone()]}),
Some(json!({"interactions": [click.clone(), click.clone()]})),
|n| n.interactions.len().to_string(),
"interactions on an instance win over the inherited ones",
),
(
"opacity",
Layer::Bg,
"opacity",
json!({"opacity": 0.5}),
Some(json!({"opacity": 0.8})),
|n| n.opacity.to_string(),
"documentation silent (pinned)",
),
(
"corner radius",
Layer::Bg,
"corners",
json!({"cornerRadius": 6}),
Some(json!({"cornerRadius": 2})),
|n| format!("{:?}", n.corners.radii),
"documentation silent (pinned)",
),
];
for (what, layer, group, over, main_edit, read, why) in rows {
let mut s = Session::new();
let k = kit(&mut s);
let (main, copy) = (k.main(layer), k.copy(&s, layer));
let steps = undo_len(&s);
ex(&mut s, "node.set", json!({"id": copy, "props": over}));
assert_eq!(undo_len(&s), steps + 1, "{what}: one undo step");
assert!(node(&s, copy).overrides.contains(group), "{what}: a `{group}` override ({why}); has {:?}", node(&s, copy).overrides);
let value = read(&node(&s, copy));
assert_ne!(value, read(&node(&s, main)), "{what}: the override shows");
if let Some(m) = main_edit {
ex(&mut s, "node.set", json!({"id": main, "props": m}));
assert_eq!(read(&node(&s, copy)), value, "{what}: kept when the main changes the same property ({why})");
}
// A structure edit of the main rebuilds every instance.
ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card}));
healthy(&s);
assert_eq!(read(&node(&s, copy)), value, "{what}: kept through a rebuild");
ex(&mut s, "component.resetOverride", json!({"id": copy, "group": group}));
assert_eq!(read(&node(&s, copy)), read(&node(&s, main)), "{what}: reset shows the main's value");
assert!(!node(&s, copy).overrides.contains(group), "{what}: reset clears the override");
healthy(&s);
}
}
#[test]
fn unrelated_main_edits_still_reach_overridden_layers() {
// (what, layer, override, main edit of another property, read, expected, derivation)
type Row = (&'static str, Layer, Value, Value, fn(&Node) -> String, &'static str, &'static str);
let rows: Vec<Row> = vec![
(
"text content override, main font size",
Layer::Title,
json!({"characters": "Mine"}),
json!({"fontSize": 24}),
|n| format!("{} {}", text(n).characters, text(n).style.font_size),
"Mine 24",
"only the overridden property stays; the font follows the main",
),
(
"fill override, main corner radius",
Layer::Bg,
json!({"fill": "#00ff00"}),
json!({"cornerRadius": 5}),
|n| format!("{} {:?}", n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default(), n.corners.radii),
"00FF00 [5.0, 5.0, 5.0, 5.0]",
"changes to the main reach every property not overridden",
),
(
"padding override, main direction",
Layer::Row,
json!({"padding": 9}),
json!({"layout": "vertical"}),
|n| format!("{:?}", n.layout.as_ref().map(|l| (l.mode, l.padding))),
"Some((Vertical, [9.0, 9.0, 9.0, 9.0]))",
"the direction isn't an instance's to change, so it follows the main",
),
];
for (what, layer, over, main_edit, read, expected, why) in rows {
let mut s = Session::new();
let k = kit(&mut s);
let copy = k.copy(&s, layer);
ex(&mut s, "node.set", json!({"id": copy, "props": over}));
ex(&mut s, "node.set", json!({"id": k.main(layer), "props": main_edit}));
healthy(&s);
assert_eq!(read(&node(&s, copy)), expected, "{what}: {why}");
}
}
#[test]
fn what_instances_cannot_override_is_refused() {
// (what, command, params (`$` stands for the instance's copy of the layer), layer, derivation)
type Row = (&'static str, &'static str, Value, Layer, &'static str);
let rows: Vec<Row> = vec![
("move a layer", "node.set", json!({"id": "$", "props": {"x": 50}}), Layer::Bg, "\"position of any layers within the component\""),
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
(
"resize a layer",
"node.set",
json!({"id": "$", "props": {"width": 50}}),
Layer::Bg,
"not among the overridable properties; the instance resizes",
),
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
(
"change constraints",
"node.set",
json!({"id": "$", "props": {"constraints": {"horizontal": "right"}}}),
Layer::Bg,
"\"any constraints applied to the layers\"",
),
(
"make a layer fill its auto layout",
"node.set",
json!({"id": "$", "props": {"sizingH": "fill"}}),
Layer::Chip,
"resizing comes with the layer's size",
),
(
"take a layer out of auto layout",
"node.set",
json!({"id": "$", "props": {"absolute": true}}),
Layer::Chip,
"position, including items in an auto layout frame",
),
(
"change the auto layout direction",
"node.set",
json!({"id": "$", "props": {"layout": "vertical"}}),
Layer::Row,
"only padding and gap are adjustable on instances",
),
("remove auto layout", "layout.remove", json!({"ids": ["$"]}), Layer::Row, "structure of the instance"),
("reorder layers", "node.reorder", json!({"id": "$", "index": 3}), Layer::Bg, "\"the order, or z-index, of any layers within the instance\""),
("delete a layer", "edit.delete", json!({"ids": ["$"]}), Layer::Bg, "layers can only be hidden"),
(
"add a layer",
"shape.rectangle",
json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": "$"}),
Layer::Root,
"\"not possible to make changes to the underlying structure\"",
),
];
for (what, cmd, params, layer, why) in rows {
let mut s = Session::new();
let k = kit(&mut s);
let target = k.copy(&s, layer);
let p: Value = serde_json::from_str(&params.to_string().replace("\"$\"", &target.0.to_string())).unwrap();
let (before, steps) = (s.doc().unwrap().clone(), undo_len(&s));
assert!(s.execute(cmd, &p).is_err(), "{what} is refused ({why})");
assert_eq!(*s.doc().unwrap(), before, "{what}: nothing changed");
assert_eq!(undo_len(&s), steps, "{what}: no undo step");
}
// The same edits on the main, and resizing the instance itself, are fine.
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "node.set", json!({"id": k.bg, "props": {"x": 5, "width": 150, "constraints": {"horizontal": "leftRight"}}}));
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 260}}));
let ibg = k.copy(&s, Layer::Bg);
assert_eq!((node(&s, ibg).transform.as_coeffs()[4], node(&s, ibg).width), (5.0, 210.0), "the main's constraints apply when the instance resizes");
assert!(node(&s, ibg).overrides.is_empty(), "layout isn't an override");
// Placing an auto layout item is the layout's job: the x is ignored, not refused.
let ichip = k.copy(&s, Layer::Chip);
let x0 = node(&s, ichip).transform.as_coeffs()[4];
ex(&mut s, "node.set", json!({"id": ichip, "props": {"x": 60}}));
assert_eq!(node(&s, ichip).transform.as_coeffs()[4], x0, "auto layout puts the item back");
assert!(!node(&s, ichip).overrides.contains("position"));
// The instance's own export settings are its own like its place (documentation silent; pinned).
let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap();
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"exports": [export]}}));
ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card}));
assert_eq!((node(&s, k.inst).exports.len(), node(&s, k.inst).overrides.contains("exports")), (1, false));
healthy(&s);
}
/// A "Button" set: State=Default (Bg white, Label "Default" dark, Badge, Dot) and State=Hover (Bg
/// grey, Label "Hover" blue, no Badge, the same Dot), both with a black stroke on Bg and a
/// "Show dot" boolean bound to Dot. Returns (set, default, hover).
fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let mut variants = Vec::new();
for (k, (state, bg, label)) in [("Default", "#ffffff", "#222222"), ("Hover", "#eeeeee", "#0000ff")].into_iter().enumerate() {
let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 120, "height": 40, "name": format!("State={state}")})));
ex(s, "node.set", json!({"id": f, "props": {"fill": "#fafafa", "cornerRadius": 4 + 4 * k}}));
let props = json!({"name": "Bg", "fill": bg, "stroke": "#000000", "strokeWeight": 1});
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": props}));
ex(s, "text.create", json!({"text": state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label", "fill": label}}));
if k == 0 {
ex(s, "shape.ellipse", json!({"x": 90, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Badge", "fill": "#ff0000"}}));
}
ex(s, "shape.ellipse", json!({"x": 104, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Dot", "fill": "#00aa00"}}));
variants.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
ex(s, "component.addProperty", json!({"id": set, "name": "Show dot", "type": "boolean", "default": true}));
for v in &variants {
let dot = kids(s, *v).into_iter().find(|c| node(s, *c).name == "Dot").unwrap();
ex(s, "component.bindProperty", json!({"layer": dot, "property": "Show dot"}));
}
(set, variants[0], variants[1])
}
/// The direct layer of instance `inst` mirroring a main layer named `name` (renames don't
/// matter); `None` when there is none.
fn layer(s: &Session, inst: NodeId, name: &str) -> Option<NodeId> {
kids(s, inst).into_iter().find(|c| node(s, *c).source.is_some_and(|src| node(s, src).name == name))
}
#[test]
fn switching_variants_keeps_overrides_where_the_variants_agreed() {
let mut s = Session::new();
let (set, default, hover) = button_set(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
assert_eq!(component_of(&s, i), default);
// (what, layer ("" = the instance), override, kept after the switch, derivation)
type Row = (&'static str, &'static str, Value, bool, &'static str);
let rows: Vec<Row> = vec![
(
"Bg fill (the variants differ)",
"Bg",
json!({"fill": "#ff00ff"}),
false,
"kept only if the property \"originally matched between variants\"",
),
("Bg stroke (the variants agree)", "Bg", json!({"strokeWeight": 4}), true, "names match and the stroke matched"),
("Label text (the variants differ)", "Label", json!({"characters": "Buy"}), true, "text layers keep their changes when their names match"),
("Label colour (the variants differ)", "Label", json!({"fill": "#ff8800"}), true, "\"any changes you've made to text layers\""),
("Dot renamed and faded (the variants agree)", "Dot", json!({"name": "My dot", "opacity": 0.5}), true, "renaming doesn't break matching"),
("Badge (only in Default)", "Badge", json!({"fill": "#000000"}), false, "no layer of that name in the new variant"),
("the instance's fill (the variants agree)", "", json!({"fill": "#123456"}), true, "names match and the fill matched"),
("the instance's corners (the variants differ)", "", json!({"cornerRadius": 12}), false, "the corner radius didn't match"),
];
let target = |s: &Session, name: &str| if name.is_empty() { Some(i) } else { layer(s, i, name) };
let read = |x: &Node, k: &str| match k {
"fill" => format!("{:?}", x.fills),
"strokeWeight" => x.stroke.weight.to_string(),
"characters" => text(x).characters.clone(),
"name" => x.name.clone(),
"opacity" => x.opacity.to_string(),
"cornerRadius" => format!("{:?}", x.corners.radii),
other => panic!("no reader for `{other}`"),
};
// What each override shows before the switch.
let mut shown: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for (what, name, over, _, _) in &rows {
let id = target(&s, name).unwrap();
ex(&mut s, "node.set", json!({"id": id, "props": over}));
assert!(!node(&s, id).overrides.is_empty(), "{what}: an override");
shown.insert(what.to_string(), over.as_object().unwrap().keys().map(|k| read(&node(&s, id), k)).collect());
}
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}}));
let steps = undo_len(&s);
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
assert_eq!(undo_len(&s), steps + 1, "one undo step");
healthy(&s);
assert_eq!(component_of(&s, i), hover);
for (what, name, over, kept, why) in &rows {
let Some(id) = target(&s, name) else {
assert!(!kept, "{what}: gone ({why})");
continue;
};
let (n, main) = (node(&s, id), node(&s, if id == i { hover } else { node(&s, id).source.unwrap() }));
for (k, overridden) in over.as_object().unwrap().keys().zip(&shown[&what.to_string()]) {
let expected = if *kept { overridden.clone() } else { read(&main, k) };
assert_eq!(read(&n, k), expected, "{what}: {} ({why})", if *kept { "kept" } else { "shows the new variant" });
}
}
let dot = layer(&s, i, "Dot").unwrap();
assert!(!node(&s, dot).visible, "property values stay across variants (documentation silent; pinned)");
// Switching back doesn't bring dropped overrides back.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Default"}}));
assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FFFFFF");
assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Buy");
s.undo().unwrap();
s.undo().unwrap();
assert_eq!(component_of(&s, i), default, "undo restores the variant ...");
assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FF00FF", "... and its overrides");
healthy(&s);
}
#[test]
fn variant_combinations_order_default_and_conflicts() {
let mut s = Session::new();
// Two variants created right to left: the layer order and the top-left corner disagree.
let a = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 80, "height": 40, "name": "Size=Large, State=Default"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Size=Small, State=Default"})));
let c = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 80, "width": 80, "height": 40, "name": "Size=Small, State=Hover"})));
let mut v = Vec::new();
for f in [a, b, c] {
v.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": v.clone()})));
// Default: "the variant in the top-left corner".
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 300})));
assert_eq!(component_of(&s, i), v[1], "the top-left variant is the default");
let props = ex(&mut s, "component.properties", json!({"id": set}));
assert_eq!(props["properties"][0]["default"], json!("Small"), "the default value is the top-left variant's");
// Making Large the default moves its variant to the top-left corner (one undo step).
let steps = undo_len(&s);
ex(&mut s, "component.editProperty", json!({"id": set, "name": "Size", "default": "Large"}));
assert_eq!(undo_len(&s), steps + 1);
let j = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 400})));
assert_eq!(component_of(&s, j), v[0], "the new default is the top-left variant");
// Reordering properties rewrites every variant's name to the new order.
ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "index": 0}));
assert_eq!(node(&s, v[2]).name, "State=Hover, Size=Small");
let props = ex(&mut s, "component.properties", json!({"id": i}));
assert_eq!(props["properties"][0]["name"], json!("State"), "the order shows in the properties");
// A missing combination picks the variant matching the most values (documentation silent; pinned).
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Size": "Large", "State": "Hover"}}));
assert!(v.contains(&component_of(&s, i)));
// Two variants with the same values are a conflict, reported until resolved.
assert_eq!(ex(&mut s, "component.properties", json!({"id": set}))["conflicts"], json!([]));
ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "value": "Hover", "newValue": "Default"}));
let conflicts = ex(&mut s, "component.properties", json!({"id": set}))["conflicts"].clone();
assert_eq!(conflicts, json!([[v[1].0, v[2].0]]), "duplicate variant combinations are reported as a conflict");
healthy(&s);
}
/// Components "From" and "To" with layers named alike ("Bg" rectangle, "Title" text) and one only
/// "From" has ("Mark"); an instance of From with overrides on all three. Returns (from, to, inst).
fn swap_kit(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let mut comps = Vec::new();
for (k, name) in ["From", "To"].iter().enumerate() {
let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 100, "height": 40, "name": name})));
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
ex(s, "text.create", json!({"text": name, "x": 4, "y": 4, "parent": f, "props": {"name": "Title"}}));
if k == 0 {
ex(s, "shape.ellipse", json!({"x": 80, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Mark"}}));
}
comps.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
}
let i = idv(&ex(s, "instance.create", json!({"component": comps[0], "x": 0, "y": 200})));
let ks = kids(s, i);
ex(s, "node.set", json!({"id": ks[0], "props": {"fill": "#ff00ff"}}));
ex(s, "node.set", json!({"id": ks[1], "props": {"characters": "Mine", "fill": "#00ff00"}}));
ex(s, "node.set", json!({"id": ks[2], "props": {"opacity": 0.3}}));
(comps[0], comps[1], i)
}
#[test]
fn swaps_keep_overrides_by_layer_name() {
// (how, command, kept on Bg, derivation)
let rows: [(&str, &str, bool, &str); 2] = [
("the instance menu", "instance.swap", true, "the layer names of the current instance and the instance you're selecting must match"),
("the Assets panel", "instance.swapAt", false, "an Assets-panel swap keeps only text overrides"),
];
for (how, cmd, bg_kept, why) in rows {
let mut s = Session::new();
let (_from, to, i) = swap_kit(&mut s);
let steps = undo_len(&s);
let p = if cmd == "instance.swap" { json!({"ids": [i], "component": to}) } else { json!({"component": to, "x": 50, "y": 220}) };
ex(&mut s, cmd, p);
assert_eq!(undo_len(&s), steps + 1, "{how}: one undo step");
healthy(&s);
assert_eq!(component_of(&s, i), to, "{how}: swapped");
let (bg, title) = (layer(&s, i, "Bg").unwrap(), layer(&s, i, "Title").unwrap());
assert_eq!(fill(&s, bg) == "FF00FF", bg_kept, "{how}: Bg fill ({why})");
assert_eq!((chars(&s, title), fill(&s, title)), ("Mine".to_string(), "00FF00".to_string()), "{how}: text layers keep their changes");
assert!(layer(&s, i, "Mark").is_none(), "{how}: a layer the new main lacks goes with its overrides");
}
// Instance swap properties: preferred values are offered first, any component is allowed.
let mut s = Session::new();
let (from, to, _) = swap_kit(&mut s);
let (icon, shape, button, nested) = crate::components::tests::button(&mut s);
let _ = (shape, from);
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "layer": nested, "preferred": [icon]}));
let props = ex(&mut s, "component.properties", json!({"id": button}));
assert_eq!(props["properties"][0]["preferred"], json!([icon.0]));
let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 600})));
ex(&mut s, "instance.setProperties", json!({"ids": [b], "properties": {"Icon": to}}));
assert_eq!(component_of(&s, copy_of(&s, b, nested)), to, "a component outside the preferred values can still be picked");
healthy(&s);
}
#[test]
fn swapping_to_a_component_of_another_set_takes_its_properties() {
// Documentation silent beyond name matching (pinned): the instance shows the other set's
// default variant's layers and offers that set's properties.
let mut s = Session::new();
let (set, _default, _hover) = button_set(&mut s);
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 60, "height": 20, "name": "Kind=Solid"})));
ex(&mut s, "text.create", json!({"text": "Tag", "x": 2, "y": 2, "parent": a, "props": {"name": "Label"}}));
let tag = idv(&ex(&mut s, "component.create", json!({"ids": [a]})));
let tags = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [tag]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
let t = layer(&s, i, "Label").unwrap();
ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Kept"}}));
ex(&mut s, "instance.swap", json!({"ids": [i], "component": tags}));
healthy(&s);
assert_eq!(component_of(&s, i), tag);
assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Kept");
let props = ex(&mut s, "component.properties", json!({"id": i}));
assert_eq!(props["set"], json!(tags.0));
assert_eq!(props["properties"][0]["name"], json!("Kind"));
}
#[test]
fn hidden_exposed_instances_hide_their_properties() {
let mut s = Session::new();
let (_icon, _shape, button, nested) = crate::components::tests::button(&mut s);
ex(&mut s, "component.exposeInstance", json!({"id": nested}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"].as_array().unwrap().len(), 1);
// "Hidden nested instances hide their attached properties."
let t = copy_of(&s, i, nested);
ex(&mut s, "node.set", json!({"id": t, "props": {"visible": false}}));
assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"], json!([]));
}
#[test]
fn detach_reset_push_and_restore() {
// Detach: "a regular frame, while keeping the current layers and properties".
let mut s = Session::new();
let k = kit(&mut s);
let row = k.copy(&s, Layer::Row);
ex(&mut s, "node.set", json!({"id": row, "props": {"padding": 9}}));
let t = k.copy(&s, Layer::Bg);
ex(&mut s, "node.set", json!({"id": t, "props": {"fill": "#00ff00"}}));
let look = figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap();
let steps = undo_len(&s);
ex(&mut s, "component.detach", json!({"ids": [k.inst]}));
assert_eq!(undo_len(&s), steps + 1);
assert_eq!(node(&s, k.inst).kind.type_name(), "FRAME");
assert_eq!(figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap().data(), look.data(), "the look is kept");
assert_eq!(node(&s, row).layout.as_ref().map(|l| l.padding), Some([9.0; 4]));
healthy(&s);
// Reset all: every layer shows its main again, property values go back to their defaults;
// the instance keeps its size (documentation silent; pinned).
let mut s = Session::new();
let k = kit(&mut s);
let grid = serde_json::to_value(LayoutGrid::default()).unwrap();
let t = k.copy(&s, Layer::Row);
ex(&mut s, "node.set", json!({"id": t, "props": {"padding": 9, "grids": [grid]}}));
let t = k.copy(&s, Layer::Title);
ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Mine", "paragraphSpacing": 6}}));
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 240}}));
ex(&mut s, "component.resetInstance", json!({"ids": [k.inst]}));
healthy(&s);
let (row, title) = (node(&s, k.copy(&s, Layer::Row)), node(&s, k.copy(&s, Layer::Title)));
assert_eq!((row.layout.as_ref().map(|l| l.padding), row.grids.len()), (Some([4.0; 4]), 0));
assert_eq!((text(&title).characters.as_str(), text(&title).paragraph_spacing), ("Hello", 0.0));
assert_eq!(node(&s, k.inst).width, 240.0);
// Push changes: the instance's overrides become the main's ("cannot push changes to nested
// components": from a nested layer they still go to the outer main).
let mut s = Session::new();
let (icon, shape, button, nested) = crate::components::tests::button(&mut s);
let a = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 400})));
let ishape = copy_of(&s, a, copy_of(&s, nested, shape));
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}}));
let label = kids(&s, a)[1];
ex(&mut s, "node.set", json!({"id": label, "props": {"paragraphSpacing": 5}}));
ex(&mut s, "component.pushChanges", json!({"id": ishape}));
healthy(&s);
assert_eq!(fill(&s, copy_of(&s, nested, shape)), "FF00FF", "the outer main's nested instance takes it");
assert_eq!(fill(&s, shape), "111111", "the nested instance's own main doesn't");
assert_eq!(fill(&s, copy_of(&s, b, copy_of(&s, nested, shape))), "FF00FF", "other instances follow");
assert_eq!(text(&node(&s, kids(&s, button)[1])).paragraph_spacing, 5.0, "text properties are pushed");
assert!(node(&s, ishape).overrides.is_empty() && node(&s, label).overrides.is_empty());
let _ = icon;
// Go to main selects it; restore main brings a deleted main back from an instance
// (`components_tests::deleted_main_keeps_instances_and_restores`).
let mut s = Session::new();
let k = kit(&mut s);
let t = k.copy(&s, Layer::Title);
ex(&mut s, "component.goToMain", json!({"id": t}));
assert_eq!(s.selection(), vec![k.card]);
}
#[test]
fn auto_layout_instances_follow_their_main() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Chip"})));
for _ in 0..2 {
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f}));
}
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 8, "gap": 4, "sizingH": "hug", "sizingV": "hug"}}));
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
assert_eq!((node(&s, i).width, node(&s, i).height), (60.0, 36.0), "8 + 20 + 4 + 20 + 8 by 8 + 20 + 8");
// Padding on the instance is an override; the main's gap and direction still reach it.
ex(&mut s, "node.set", json!({"id": i, "props": {"padding": 2}}));
ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 10}}));
assert_eq!(node(&s, i).width, 2.0 + 20.0 + 10.0 + 20.0 + 2.0, "the hug follows the main's gap and the instance's padding");
ex(&mut s, "node.set", json!({"id": chip, "props": {"layout": "vertical"}}));
let l = node(&s, i).layout.unwrap();
assert_eq!((l.mode, l.padding, l.gap), (LayoutMode::Vertical, [2.0; 4], 10.0));
assert_eq!((node(&s, i).width, node(&s, i).height), (24.0, 2.0 + 20.0 + 10.0 + 20.0 + 2.0));
// The direction is the main's: refused on the instance.
assert!(s.execute("node.set", &json!({"id": i, "props": {"layout": "horizontal"}})).is_err());
assert!(s.execute("layout.remove", &json!({"ids": [i]})).is_err());
// Min / max width on the instance is its own (documentation silent; pinned).
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 80}}));
assert_eq!(node(&s, i).width, 80.0);
ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 12}}));
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
healthy(&s);
}
+9 -6
View File
@@ -685,7 +685,6 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() {
})
.unwrap();
s.ds_mut().unwrap().page = doc.page_of(button).unwrap();
let fill = doc.node(glyph).unwrap().fills.clone();
let label = chars(doc, find_path(doc, button, &["Label"]).unwrap());
let other = doc.node(ds.button).unwrap().children.iter().copied().find(|v| *v != main_of(doc, button)).unwrap();
let (x, y) = centre(doc, glyph);
@@ -696,8 +695,9 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() {
healthy(doc);
assert_eq!(main_of(doc, button), other);
assert_eq!(s.selection(), vec![button]);
// ... keeping overrides on layers with matching names and the property values.
assert_eq!(doc.node(find_path(doc, button, &["Icon", "Glyph"]).unwrap()).unwrap().fills, fill);
// ... keeping only text layers' overrides (by matching names) and the property values.
let new_glyph = find_path(doc, button, &["Icon", "Glyph"]).unwrap();
assert!(doc.node(new_glyph).unwrap().overrides.is_empty());
assert_eq!(chars(doc, find_path(doc, button, &["Label"]).unwrap()), label);
// An icon dropped there swaps the button's icon (a nested swap, an override).
let icon = find_path(doc, button, &["Icon"]).unwrap();
@@ -708,7 +708,9 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() {
healthy(doc);
assert_eq!(comp_of(doc, icon), Some(to));
assert!(doc.node(icon).unwrap().overrides.contains("component"));
assert_eq!(doc.node(find_path(doc, icon, &["Glyph"]).unwrap()).unwrap().fills, fill);
let glyph = find_path(doc, icon, &["Glyph"]).unwrap();
assert!(doc.node(glyph).unwrap().overrides.is_empty());
assert_eq!(doc.node(glyph).unwrap().fills, doc.node(doc.node(glyph).unwrap().source.unwrap()).unwrap().fills);
// Nothing there: refused, nothing changes.
let before = s.doc().unwrap().clone();
assert!(s.execute("instance.swapAt", &json!({"component": to, "x": -50_000, "y": -50_000})).is_err());
@@ -731,7 +733,6 @@ fn alt_dropping_a_library_component_swaps_to_it() {
let doc = &s.doc().unwrap().clone();
let (top, glyph) =
inst.iter().copied().filter(|i| local.icons.contains(&main_of(doc, *i))).find_map(|i| find_path(doc, i, &["Glyph"]).map(|g| (i, g))).unwrap();
let fill = doc.node(glyph).unwrap().fills.clone();
s.ds_mut().unwrap().page = doc.page_of(top).unwrap();
let k = ds.icons.len() - 1;
let (x, y) = centre(doc, glyph);
@@ -739,6 +740,8 @@ fn alt_dropping_a_library_component_swaps_to_it() {
let doc = s.doc().unwrap();
healthy(doc);
assert_eq!(main_of(doc, top), local.icons[k]);
assert_eq!(doc.node(find_path(doc, top, &["Glyph"]).unwrap()).unwrap().fills, fill);
// Swapped from the Assets panel: only text layers keep their overrides.
let glyph = find_path(doc, top, &["Glyph"]).unwrap();
assert!(doc.node(glyph).unwrap().overrides.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
+4 -2
View File
@@ -66,7 +66,9 @@ fn overrides_survive_main_edits() {
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}}));
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
// A layer inside an instance keeps its main's size (the instance itself resizes).
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
let ov = node(&s, ibg).overrides;
for g in ["fills", "strokes", "effects", "name"] {
assert!(ov.contains(g), "{g} in {ov:?}");
@@ -89,7 +91,7 @@ fn overrides_survive_main_edits() {
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!(d.width, 10.0, "the main's size reaches the layer");
assert_eq!(fill(&s, idot), "00FFFF");
assert_eq!(fill(&s, i), "FAFAFA");
// Per-group reset.
+5 -2
View File
@@ -726,8 +726,8 @@ impl Session {
}
}
/// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such
/// an edit (it is rolled back).
/// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in
/// them, and library copies are read-only: 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(()) };
@@ -741,6 +741,7 @@ impl Session {
};
library::check_read_only(effective, bdoc, &ds.doc)
.and_then(|()| components::check_structure_edit(bdoc, &ds.doc))
.and_then(|()| components::check_override_edit(bdoc, &ds.doc))
.map_err(|m| EngineError::Disabled(id.to_string(), m))
}
@@ -1227,6 +1228,8 @@ mod comments_polish_tests;
#[cfg(test)]
mod component_codegen_tests;
#[cfg(test)]
mod components_behaviour_tests;
#[cfg(test)]
mod components_scale_tests;
#[cfg(test)]
mod devcompare_tests;
+22 -5
View File
@@ -1465,11 +1465,16 @@ impl Session {
}
crate::normalize(ds.doc_mut(), &before);
}
// Reordering or dragging layers out of an instance (outside its slots) is a
// structure edit only its main can make: the gesture is undone.
// Reordering or dragging layers out of an instance (outside its slots), or
// moving layers inside one, are edits only its main can make: the gesture is
// undone.
let ds = self.ds()?;
let allowed =
ds.interaction.as_ref().is_none_or(|it| moving.iter().all(|m| crate::components::move_allowed(&it.doc, &ds.doc, *m)));
let allowed = ds.interaction.as_ref().is_none_or(|it| {
moving.iter().all(|m| {
crate::components::move_allowed(&it.doc, &ds.doc, *m)
&& crate::components::refused_override(&it.doc, &ds.doc, *m).is_none()
})
});
if allowed {
self.commit_interaction()?;
} else {
@@ -1481,7 +1486,19 @@ impl Session {
let _ = ev;
}
}
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => self.commit_interaction()?,
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => {
// Layers inside an instance keep their main's size and rotation.
let ds = self.ds()?;
let refused = ds
.interaction
.as_ref()
.is_some_and(|it| ds.selection.iter().any(|m| crate::components::refused_override(&it.doc, &ds.doc, *m).is_some()));
if refused {
self.cancel_interaction()?;
} else {
self.commit_interaction()?;
}
}
Gesture::Draw { start, id, parent, .. } => {
let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0;
if tiny || id.is_none() {