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.
This commit is contained in:
MortenHusted
2026-10-22 13:41:56 +02:00
parent 6394e96005
commit f1c206509f
6 changed files with 90 additions and 18 deletions
+17
View File
@@ -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<NodeId> = 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])> {
+19
View File
@@ -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");
}
+1
View File
@@ -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);
+8 -1
View File
@@ -553,11 +553,18 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
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<PublishedLibrary, String> {
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}")),
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
/// 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<PublishedLibrary, String> {
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})")),
}
+44 -16
View File
@@ -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);
}