diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..01914640 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{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)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}", true, reset_override ), diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..f011625d 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -12,9 +12,10 @@ //! `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`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers -//! inside an instance, `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, interactions and resizing are its own, like its place. `position` +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `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, +//! interactions, resizing and variable modes are its own, like its place. `position` //! overrides of layers inside an instance are still read and kept, but new ones are refused //! ([`check_override_edit`]): those layers take their place and constraints from the main. //! @@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "size", "resizing", "position", + "modes", "component", "props", SLOT, @@ -415,6 +417,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); } "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; @@ -1049,6 +1052,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.interactions != b.interactions { out.push("interactions"); } + if a.var_modes != b.var_modes { + out.push("modes"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -1146,10 +1152,10 @@ 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, resizing, export settings and interactions are its own, not - // overrides. + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1969,7 +1975,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || g != "resizing") + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1993,8 +1999,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - // A top-level instance's resizing is its own (a nested one's overrides its main layer's). - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs index b272b82f..94eae71f 100644 --- a/crates/engine/src/components_rebase_source_tests.rs +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); } +#[test] +fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() { + let mut s = scene(); + let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]); + s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone(); + both(&mut s); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode"); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + rebase_instance(&mut s.doc, detached); + let n = s.doc.node(detached).unwrap(); + assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own"); +} + #[test] fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { let mut s = scene(); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 79007622..0836048f 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -126,3 +126,52 @@ fn detaching_keeps_the_mode_it_took_from_its_main() { assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); } + +/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button +/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal). +fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) { + tone(s); + let icon = swatch(s, "Icon", None); + let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame}))); + set_mode(s, button_icon, json!("A")); + let button = id(run(s, "component.create", json!({"ids": [frame]}))); + let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer}))); + let nested = s.doc().unwrap().node(close).unwrap().children[0]; + set_mode(s, nested, json!("B")); + run(s, "component.create", json!({"ids": [outer]})); + (button, button_icon, nested) +} + +#[test] +fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() { + let mut s = Session::new(); + let (button, button_icon, nested) = modal(&mut s); + assert_eq!(fill(&s, nested), "0000FF"); + // An edit the copies can't take as they are rebuilds the Button instance. + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy"); + // The main's layer choosing another mode doesn't reach it either. + set_mode(&mut s, button_icon, json!("B")); + set_mode(&mut s, button_icon, Value::Null); + assert_eq!(fill(&s, nested), "0000FF"); + run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"})); + assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)"); + assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty()); +} + +#[test] +fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { + let mut s = Session::new(); + let (button, _, _) = modal(&mut s); + // A Button placed on the page, its icon recoloured through the icon's mode. + let placed = instance(&mut s, button); + let icon = s.doc().unwrap().node(placed).unwrap().children[0]; + set_mode(&mut s, icon, json!("B")); + assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"})); + assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it"); + run(&mut s, "component.resetInstance", json!({"ids": [placed]})); + assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4fc778a7..7104164b 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -344,3 +344,39 @@ fn library_style_bindings_ignore_unrelated_local_variables() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn modes_set_inside_nested_instances_come_along() { + let d = dir("nested-modes"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + theme(&mut lib); + let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(&mut lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(&mut lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(&mut lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(&mut lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(&mut lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(&mut lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(&mut lib, "component.create", json!({"ids": [m]})); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + let copy = |s: &Session| { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] + }; + assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); + // An update that rebuilds the Button copies keeps it too. + ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, copy(&s)), "FFFFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..4db613ab 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -521,6 +521,7 @@ fn group_label(g: &str) -> String { "grids" => tr("Layout grids"), "exports" => tr("Export settings"), "interactions" => tr("Interactions"), + "modes" => tr("Variable modes"), "size" => tr("Size"), "resizing" => tr("Resizing"), "position" => tr("Position"),