mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge #1037: M3: a variable mode set on a layer inside an instance is an override (@MortenHusted)
This commit is contained in:
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
@@ -114,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])> {
|
||||
@@ -415,6 +434,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;
|
||||
@@ -1117,6 +1137,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");
|
||||
@@ -1214,10 +1237,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));
|
||||
@@ -2037,7 +2060,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = 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 {
|
||||
@@ -2061,8 +2084,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -126,3 +126,71 @@ 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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
@@ -414,6 +414,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);
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
|
||||
@@ -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})")),
|
||||
}
|
||||
|
||||
@@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
healthy(&s);
|
||||
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();
|
||||
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| 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"}));
|
||||
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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user