mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 02:16:56 -06:00
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:
co-authored by
Claude Opus 5.5
parent
35da7b7cff
commit
014098b6c9
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user