fix(components): a boolean property's default is its bound layers' visibility on the main (#993)

component.addProperty {type: "boolean", default: false, layer} hid the
bound layer in new instances but left it showing in the main component,
so the main didn't show its own default.

On the main, a boolean property's value is now the visibility of the
layers bound to it:

- addProperty, bindProperty {field: "visible"} and editProperty {default}
  show or hide the main's bound layers to match the default (every
  variant of a set for editProperty). Without a default, addProperty
  takes the bound layer's current visibility instead of always `true`.
- Showing or hiding a bound layer on the main sets the default, and the
  property's other bound layers (in every variant) follow. Edits inside
  instances stay overrides.
- Existing instances keep their stored values; documents open as saved
  (no normalisation on load).

Fixes #993

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kameron
2026-10-21 11:27:46 -07:00
co-authored by Claude Opus 5.5
parent 35da7b7cff
commit 014098b6c9
4 changed files with 170 additions and 4 deletions
+14 -4
View File
@@ -217,14 +217,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.addProperty",
"Add component property",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
add_prop
),
spec!(
"component.editProperty",
"Edit component property",
"{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)}",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), 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
),
@@ -1141,7 +1141,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
}
let layer_node = layer.and_then(|l| doc.node(l)).cloned();
let def = match t.to_ascii_lowercase().as_str() {
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
// Without a default, a bound layer keeps showing as it does: its visibility is the default.
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) },
"text" => PropDef::Text {
default: string(p, "default")
.or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone()))
@@ -1245,7 +1246,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
}
_ => {}
}
doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string());
let n = doc.get_mut(layer)?;
n.prop_refs.insert(field.to_string(), property.to_string());
// On the main, a boolean property's value is the visibility of the layers it controls.
if let PropDef::Boolean { default } = def {
n.visible = *default;
}
Ok(())
}
@@ -1414,6 +1420,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
rename_prop_refs(doc, *c, &name, Some(nn))?;
}
}
// The main's bound layers show the new default.
if let PropDef::Boolean { default } = new_def {
set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default);
}
ok()
}
+64
View File
@@ -1110,9 +1110,73 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str)
false
}
/// The components sharing the properties of component `c`: the variants of its set, or `c` alone.
pub fn property_owners(doc: &Document, c: NodeId) -> Vec<NodeId> {
match variant_set(doc, c).and_then(|s| doc.node(s)) {
Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(),
None => vec![c],
}
}
/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding
/// their layers bound to it: on a main component, a boolean property's value is the visibility of
/// its layers. Returns the components and layers it changed.
pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec<NodeId> {
let mut out = Vec::new();
for &c in comps {
let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue };
let Some(PropDef::Boolean { default }) = props.get(name) else { continue };
if *default != value
&& let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind)
{
props.insert(name.to_string(), PropDef::Boolean { default: value });
out.push(c);
}
// The main's own layers (not those of instances nested in it, which follow their mains).
let layers: Vec<NodeId> = doc
.subtree(c)
.into_iter()
.filter(|d| *d != c && !inside_instance(doc, *d))
.filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name)))
.collect();
for d in layers {
if let Some(n) = doc.node_mut(d) {
n.visible = value;
out.push(d);
}
}
}
out
}
/// A main component's layer bound to a boolean property was shown or hidden: that is the
/// property's new default, which its other bound layers (in every variant) take too. Returns the
/// components and layers that changed as a result.
fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.visible == b.visible || inside_instance(doc, id) {
continue;
}
let Some(name) = n.prop_refs.get("visible").cloned() else { continue };
let visible = n.visible;
let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue };
out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible));
}
out
}
/// 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 changed = changed.to_vec();
for id in follow_boolean_defaults(doc, before, &changed) {
if !changed.contains(&id) {
changed.push(id);
}
}
let changed = changed.as_slice();
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
for &id in changed {
+84
View File
@@ -429,6 +429,90 @@ fn component_properties_apply_to_instances() {
healthy(&s);
}
/// The default of boolean property `name` on component `c`.
fn bool_default(s: &Session, c: NodeId, name: &str) -> Option<bool> {
match &node(s, c).kind {
NodeKind::Component { props, .. } => match props.get(name) {
Some(PropDef::Boolean { default }) => Some(*default),
_ => None,
},
_ => None,
}
}
#[test]
fn boolean_property_default_is_the_mains_visibility() {
let mut s = Session::new();
let (c, bg, title, dot) = card(&mut s);
// #993: a `false` default hides the bound layer in the main, not only in instances.
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show dot", "type": "boolean", "default": false, "layer": dot}));
assert!(!node(&s, dot).visible);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
assert!(!node(&s, copy_of(&s, i, dot)).visible);
// Without a default, the property takes the layer's visibility.
ex(&mut s, "node.set", json!({"id": bg, "props": {"visible": false}}));
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show bg", "type": "boolean", "layer": bg}));
assert_eq!(bool_default(&s, c, "Show bg"), Some(false));
assert!(!node(&s, bg).visible);
// Binding a layer to an existing property shows it as the default says.
ex(&mut s, "component.bindProperty", json!({"layer": title, "property": "Show dot"}));
assert!(!node(&s, title).visible);
// A new default shows or hides every bound layer of the main; instances keep their values.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}}));
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "default": true}));
assert!(node(&s, dot).visible && node(&s, title).visible);
assert!(!node(&s, copy_of(&s, i, dot)).visible, "the instance's own value stays");
// Renamed with a new default in one go.
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "newName": "Dot", "default": false}));
assert!(!node(&s, dot).visible && !node(&s, title).visible);
assert_eq!(bool_default(&s, c, "Dot"), Some(false));
// Showing a bound layer on the main is the property's new default, for its other layers too.
ex(&mut s, "node.set", json!({"id": dot, "props": {"visible": true}}));
assert_eq!(bool_default(&s, c, "Dot"), Some(true));
assert!(node(&s, title).visible);
let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0})));
assert!(node(&s, copy_of(&s, j, dot)).visible && node(&s, copy_of(&s, j, title)).visible);
assert!(!node(&s, copy_of(&s, i, dot)).visible, "existing instances keep their values");
// Hiding a layer inside an instance is an override there, not a new default.
let jdot = copy_of(&s, j, dot);
ex(&mut s, "node.set", json!({"id": jdot, "props": {"visible": false}}));
assert_eq!(bool_default(&s, c, "Dot"), Some(true));
assert!(node(&s, dot).visible);
// Undo puts the default and the layers back together.
s.undo().unwrap();
s.undo().unwrap();
s.undo().unwrap();
assert_eq!(bool_default(&s, c, "Dot"), Some(false));
assert!(!node(&s, dot).visible && !node(&s, title).visible);
healthy(&s);
}
#[test]
fn boolean_property_default_follows_across_variants() {
let mut s = Session::new();
let mut variants = Vec::new();
let mut labels = Vec::new();
for (k, state) in ["Default", "Hover"].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}")})));
labels.push(idv(&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()})));
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Show label", "type": "boolean", "default": false, "layer": labels[0]}));
ex(&mut s, "component.bindProperty", json!({"layer": labels[1], "property": "Show label"}));
assert!(labels.iter().all(|l| !node(&s, *l).visible));
ex(&mut s, "component.editProperty", json!({"id": set, "name": "Show label", "default": true}));
assert!(labels.iter().all(|l| node(&s, *l).visible));
// Hiding the label in one variant is the set's default: the other variant's label follows.
ex(&mut s, "node.set", json!({"id": labels[1], "props": {"visible": false}}));
assert!(variants.iter().all(|v| bool_default(&s, *v, "Show label") == Some(false)));
assert!(!node(&s, labels[0]).visible);
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
assert!(!node(&s, kids(&s, i)[0]).visible);
healthy(&s);
}
#[test]
fn push_changes_to_main() {
let mut s = Session::new();
+8
View File
@@ -713,6 +713,14 @@ most when it can't be reached).
moves the chosen variant there, `{name, index}` reorders variant properties (the variants'
names follow). `component.properties` on a set reports `conflicts`: variants sharing one
combination of values.
- **Boolean properties**: on the main component, a boolean property's value is the visibility of
the layers bound to it. `component.addProperty {type: "boolean", default, layer}`,
`component.bindProperty {field: "visible"}` and `component.editProperty {name, default}` show or
hide the main's bound layers to match the default (without `default`, `addProperty` takes the
bound layer's visibility). Showing or hiding a bound layer on the main sets the default, and the
property's other bound layers (in every variant of a set) follow. New instances start from the
default; existing instances keep the values they have. Files open as saved: a main whose bound
layer disagrees with the default stays that way until one of them is edited.
- `component.pushChanges {id?}` copies the instance's overrides into its main (from a layer of a
nested instance too: always into the outer instance's main).