From f1c206509f89c5a4d26c98f2b8219fcc0640d6fd Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 13:41:51 +0200 Subject: [PATCH] M3: files from before the modes override keep the modes set inside their instances A file or library saved before "modes" was an override group has a mode chosen on a layer inside an instance without its marker, so the next rebuild or library import still put the main's mode back. Copies follow their main layer's modes when the main changes them, so a layer inside an instance on other modes chose them itself. mark_layer_modes marks those when a file opens and when a published library is read from a file or a server. Layers on their main's modes and top-level instances stay unmarked. --- crates/engine/src/components.rs | 17 ++++++ crates/engine/src/instance_modes_tests.rs | 19 +++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/library.rs | 9 ++- crates/engine/src/library_server.rs | 2 +- crates/engine/src/library_variables_tests.rs | 60 ++++++++++++++------ 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index f011625d..c0612bcd 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -116,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { enclosing_instance(doc, id).is_some() } +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + /// The slot property frame `id` stands for (its name and preferred components): a frame bound to /// a slot property of its component, or an instance's copy of one (followed through its sources). pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 0836048f..43de3ca4 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -175,3 +175,22 @@ fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { run(&mut s, "component.resetInstance", json!({"ids": [placed]})); assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); } + +#[test] +fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() { + let mut s = Session::new(); + let (button, _, nested) = modal(&mut s); + // Saved before `modes` was an override group: the copy has its mode but no marker. + s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id())); + let p = path.to_string_lossy().into_owned(); + run(&mut s, "file.saveAs", json!({"path": p})); + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose"); + assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..d878d6ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -413,6 +413,7 @@ impl DocState { } doc.link_layer_text_styles(); cmd::styles::apply_paint_bindings_on_load(&mut doc); + components::mark_layer_modes(&mut doc); // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..9740182c 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -553,11 +553,18 @@ fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } +/// A library read from a file or a server, with the modes chosen inside its instances marked when +/// it was published before they were overrides ([`crate::components::mark_layer_modes`]). +pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary { + crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc)); + lib +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(settled(lib)), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), Err(e) => Err(format!("{path}: {e}")), } diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index 99d5f29a..10852b5f 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> { /// The library in an archive from a server (untrusted: read with the library file's limits). pub fn read_archive(bytes: &[u8]) -> std::result::Result { match figcraft_format::read_library(Cursor::new(bytes)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(crate::library::settled(lib)), Ok(None) => Err("the server sent a file without a library".into()), Err(e) => Err(format!("the server sent a library that can't be read ({e})")), } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 7104164b..b4a4171a 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -345,32 +345,42 @@ fn library_style_bindings_ignore_unrelated_local_variables() { let _ = std::fs::remove_dir_all(&d); } +/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, +/// the icon copy in Modal). +fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { + theme(lib); + let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(lib, "component.create", json!({"ids": [m]})); + (button, nested) +} + +/// The icon inside the Button inside library Modal instance `inst`. +fn modal_icon(s: &Session, inst: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] +} + #[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 (button, _) = nested_mode_kit(&mut lib); 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] - }; + let copy = |s: &Session| modal_icon(s, inst); 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"})); @@ -380,3 +390,21 @@ fn modes_set_inside_nested_instances_come_along() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { + let d = dir("nested-modes-old"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (_, nested) = nested_mode_kit(&mut lib); + // Published before `modes` was an override group: the copy has its mode but no marker. + lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + 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}))); + assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +}