diff --git a/ROADMAP.md b/ROADMAP.md index d8e115d..f034eb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ _Last updated: 2026-10-10 (day 2, evening: measuring, scale tool handles and sel | Infrastructure (crates, command registry, undo, renderer, formats, control channel, tests) | **~72%** | | Look & feel vs the incumbent's 2026 UI (measured side by side) | **~65%** | | Menu/command breadth (`docs/parity.md`) | **97.5%** (197/202) of the catalogued menu tree has a command | -| Feature depth (weighted rubric below) | **~78%** | +| Feature depth (weighted rubric below) | **~79%** | | Parity including interaction fidelity and hardening | **~45–50%** | | Time to **feature parity** | **~45–85 h** one agent · **~12–25 h** with 4–6 agents | | Time to **full parity** (feature parity + fidelity pass + hardening) | **~115–195 h** one agent · **~32–55 h** with 4–6 agents | @@ -123,7 +123,21 @@ dimension measured side by side. collection in). Instance boolean and text properties bound to boolean and string variables (following modes, renames, reset and detach); layout grid settings in the Design panel whose count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and - layout grids reordered by dragging their rows, their variable bindings following. *Left:* QA with larger real-world libraries. 1 h. + layout grids reordered by dragging their rows, their variable bindings following. Components at + scale, QA'd with a synthetic design system (about 300 components: buttons, inputs, cards, navs + and badges as sets of size × state × type × theme variants, boolean/text/instance swap/slot + properties, instances nested four deep, text and paint styles, light/dark variables bound across + it) used 2,000 times with overrides at every level: main edits keep every override and match a + full rebuild exactly; values from variables (a theme switch, a bound corner radius) are no longer + recorded as overrides; detaching a variable on an instance layer is an override that survives main + edits; exposed nested instances stay exposed and swapped nested instances keep their layer names + through main edits; layers a swapped nested instance's main adds keep their ids and overrides. + Instance swap properties have preferred values (offered first). Alt-drop from the Assets panel + onto an instance swaps it (same-set instance preferred, highlighted while dragging); instances + pasted into other files follow their mains (library copies shared, published components used + from their library, other mains left behind as a restorable missing main). The Assets panel lays + out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for + changed components). *Left:* QA with real-world (not synthetic) libraries. 1 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -231,7 +245,17 @@ dimension measured side by side. effects, text, instances, variables and styles) checks every step, and found clip edges on a pixel boundary letting a column through in some renders. Drags show live on the canvas (every finished render is shown, not only the newest one asked for), and a button held still no - longer rebuilds the preview and re-renders every frame. *Left:* a real-world file corpus, Windows/Linux/browser QA, accessibility. 30–50 h. + longer rebuilds the preview and re-renders every frame. + Components at scale (`cargo xtask bench`, synthetic design system of about 300 + components used by 2,000 instances): edits of mains reach their copies directly instead of + rebuilding every instance (value, size and layer added/removed edits; other edits rebuild each + affected instance once, inner mains first), bound variables no longer touch unchanged layers on + every edit, auto layout re-flows only where sizes or places changed: recolouring or resizing a + main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style + in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push + and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000 + assets 3.4 → 1.3 ms a frame. *Left:* a real-world file corpus, Windows/Linux/browser QA, + accessibility. 30–50 h. ### Where we're going @@ -313,14 +337,14 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Frames, groups, sections, masks | 5 | 80% | comparing what changed in ready-for-dev frames and sections, visual QA | 1–2 | | Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 | | Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 | -| Components & variants | 9 | 76% | QA with larger real-world libraries | 1 | +| Components & variants | 9 | 79% | QA with real-world (not synthetic) libraries | 1 | | Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 | | Prototyping | 6 | 89% | dragging actions to reorder them, QA against larger prototypes with conditionals | 2–4 | | Import/export | 6 | 71% | .fig import (best effort), variable-mode lookups in prototype expressions, SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 | | Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 | | Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 | -Weighted feature depth ≈ **78%** (self-graded, from the table). +Weighted feature depth ≈ **79%** (self-graded, from the table). ## Milestones diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index edd71eb..d5cb0ee 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -23,7 +23,7 @@ USAGE: figcraft-cli run [--in ] [--out ] figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] - figcraft-cli perf [--layers N[,N...]] + figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image @@ -122,9 +122,10 @@ fn run(argv: &[String]) -> CliResult { mcp(a.value("attach").as_deref()) } "perf" => { - let a = Args::parse(rest, &["layers"], &[])?; + let a = Args::parse(rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); - perf::run(&sizes) + let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); + perf::run(&sizes, &instances) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 1d955a3..4e90403 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -240,8 +240,9 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { Ok(()) } -/// Time the hot paths for each document size in `sizes` (layers) and print a table. -pub fn run(sizes: &[usize]) -> CliResult { +/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design +/// system, for each instance count in `instances`; print the tables. +pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult { // Text renders with system fonts too, like the desktop app. figcraft_text::scan_system_fonts(); let mut table = Table { rows: Vec::new() }; @@ -249,6 +250,11 @@ pub fn run(sizes: &[usize]) -> CliResult { eprintln!("measuring {n} layers…"); measure(&mut table, col, *n)?; } + let mut comps = Table { rows: Vec::new() }; + for (col, n) in instances.iter().enumerate() { + eprintln!("measuring components with {n} instances…"); + measure_components(&mut comps, col, *n)?; + } if FAILED.load(std::sync::atomic::Ordering::Relaxed) { return Err(failed("a measured command failed")); } @@ -257,14 +263,25 @@ pub fn run(sizes: &[usize]) -> CliResult { std::env::consts::OS, if cfg!(debug_assertions) { "debug build" } else { "release build" } ); + print_table(&table, &sizes.iter().map(|n| format!("{n} layers")).collect::>()); + if !instances.is_empty() { + println!(); + print_table(&comps, &instances.iter().map(|n| format!("{n} inst.")).collect::>()); + } + Ok(ExitCode::SUCCESS) +} + +/// Print `table` as Markdown with one column per `heads`. +fn print_table(table: &Table, heads: &[String]) { let w = table.rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(10); let mut head = format!("| {:10} |", format!("{n} layers"))); + for h in heads { + head.push_str(&format!(" {h:>10} |")); rule.push_str(&format!("{}:|", "-".repeat(11))); } - println!("{head}\n{rule}"); + println!("{head}"); + println!("{rule}"); for (name, vals) in &table.rows { let mut line = format!("| {name: CliResult { } println!("{line}"); } - Ok(ExitCode::SUCCESS) +} + +/// The first top-level instance in `inst` of component set `set` with a layer at name path +/// `path`: (instance, layer). +fn instance_with(doc: &Document, inst: &[NodeId], set: NodeId, path: &[&str]) -> Option<(NodeId, NodeId)> { + use figcraft_engine::components::variant_set; + inst.iter().copied().find_map(|i| { + let NodeKind::Instance { component, .. } = &doc.node(i)?.kind else { return None }; + if variant_set(doc, *component) != Some(set) { + return None; + } + figcraft_engine::synthetic_ds::find_path(doc, i, path).map(|l| (i, l)) + }) +} + +/// Time one command (ms); undone afterwards when `undo`. +fn timed(s: &mut Session, id: &str, p: serde_json::Value, undo: bool) -> f64 { + let t = Instant::now(); + exec(s, id, p); + let v = ms(t); + if undo { + let _ = s.undo(); + } + v +} + +/// Components at scale: the synthetic design system (about 300 components, variables, styles, +/// nesting four deep) used by `instances` instances with overrides at every level, in one file and +/// as a library in another, into column `col`. +fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliResult<()> { + use figcraft_engine::synthetic_ds::{DsSpec, design_system_document, design_system_with_instances, find_path, place_instances}; + let spec = DsSpec::FULL; + let t = Instant::now(); + let (doc, ds, inst) = design_system_with_instances(&spec, instances).map_err(failed)?; + table.put(col, "build design system + instances (like open)", ms(t)); + table.put(col, "layers in the file", doc.node_count() as f64); + let glyph = doc + .node(ds.icons.first().copied().ok_or_else(|| failed("no icons"))?) + .and_then(|n| n.children.first().copied()) + .ok_or_else(|| failed("no glyph"))?; + let icon0 = ds.icons.first().copied().ok_or_else(|| failed("no icons"))?; + let uses = doc.nodes().filter(|n| matches!(&n.kind, NodeKind::Instance { component, .. } if *component == icon0)).count(); + table.put(col, "instances of the edited icon main (nested included)", uses as f64); + // Layers mirroring the icon's glyph at any depth (copies of copies included). + let mirrors = |doc: &Document, of: NodeId| { + doc.nodes() + .filter(|n| { + let mut cur = n.source; + for _ in 0..32 { + match cur { + Some(x) if x == of => return true, + Some(x) => cur = doc.node(x).and_then(|m| m.source), + None => return false, + } + } + false + }) + .count() + }; + table.put(col, "layers following the edited glyph (all depths)", mirrors(&doc, glyph) as f64); + let mut s = Session::with_document(doc); + let page = s.doc().map_err(failed)?.page_of(inst.first().copied().ok_or_else(|| failed("no instances"))?).ok_or_else(|| failed("no page"))?; + if let Ok(d) = s.ds_mut() { + d.page = page; + } + let colors = ["#FF0000", "#00AA00"]; + exec(&mut s, "variable.bind", json!({"ids": [glyph], "field": "fill", "variable": null})); + table.put( + col, + "edit deep main: icon colour (all copies follow)", + mean(5, |i| exec(&mut s, "node.set", json!({"ids": [glyph], "props": {"fill": colors[i % 2]}}))), + ); + table.put( + col, + "edit deep main: icon glyph size", + mean(5, |i| exec(&mut s, "node.set", json!({"ids": [glyph], "props": {"width": 14 + (i % 2) * 2}}))), + ); + table.put( + col, + "edit deep main: add a layer to the icon", + mean(3, |_| { + exec(&mut s, "shape.rectangle", json!({"x": 1, "y": 1, "width": 2, "height": 2, "parent": icon0})); + let _ = s.undo(); + }), + ); + // Every button variant's label: used by buttons, cards' footers and navs' items. + let doc = s.doc().map_err(failed)?.clone(); + let labels: Vec = + doc.node(ds.button).map(|set| set.children.iter().filter_map(|v| find_path(&doc, *v, &["Label"])).map(|l| l.0).collect()).unwrap_or_default(); + let label_uses: usize = labels.iter().map(|l| mirrors(&doc, NodeId(*l))).sum(); + table.put(col, "layers following the 72 button labels (all depths)", label_uses as f64); + table.put( + col, + "edit 72 button mains: label size (all copies follow)", + mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))), + ); + let doc = s.doc().map_err(failed)?.clone(); + let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?; + table.put( + col, + "switch a card's variant (nested overrides kept)", + mean(5, |i| { + exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) + }), + ); + let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?; + table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); + table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); + let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?; + table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); + // A button glyph the drop really lands on: screens can overlap (text sizes differ without + // fonts), so ask the hit test the swap uses. + let now = s.doc().map_err(failed)?.clone(); + let page = s.page().map_err(failed)?; + let b = std::iter::once(bglyph) + .chain(inst.iter().filter_map(|i| { + let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; + (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? + })) + .filter_map(|g| now.world_bounds(g)) + .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()) + .ok_or_else(|| failed("no button glyph to drop on"))?; + let other = ds.icons.get(5).copied().unwrap_or(icon0); + table.put( + col, + "Alt-drop swap onto an instance", + timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), + ); + exec(&mut s, "edit.copy", json!({"ids": [nav]})); + table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo())); + table.put(col, "redo", mean(10, |_| _ = s.redo())); + + // As a library: publish it, use it from another file, publish a change, accept the update. + let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(failed)?; + let path = dir.join("design-system.figcraft").to_string_lossy().into_owned(); + let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?; + let mut lib_s = Session::with_document(lib_doc); + table.put(col, "publish the library", timed(&mut lib_s, "library.publish", json!({}), false)); + exec(&mut lib_s, "file.saveAs", json!({"path": path})); + let lib = figcraft_engine::library::Cache::default().load(&path).map_err(failed)?; + let mut doc = Document::new("Consumer"); + let m = &lib.manifest; + doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version }); + let map = figcraft_engine::library::import(&mut doc, &lib, &lib_ds.assets(), false).map_err(failed)?; + let mut local = lib_ds.remap(&map); + local.theme = doc.libraries.collections.iter().find(|r| r.key == lib_ds.theme).map(|r| r.local.clone()).unwrap_or_default(); + figcraft_engine::normalize(&mut doc, &Document::new("")); + place_instances(&mut doc, &local, instances).map_err(failed)?; + figcraft_engine::normalize(&mut doc, &Document::new("")); + let mut consumer = Session::with_document(doc); + let lglyph = lib_s.doc().map_err(failed)?.node(icon0).and_then(|n| n.children.first().copied()).ok_or_else(|| failed("no glyph"))?; + let label = lib_s + .doc() + .map_err(failed)? + .node(lib_ds.button) + .and_then(|n| n.children.first().copied()) + .and_then(|v| find_path(lib_s.doc().ok()?, v, &["Label"])) + .ok_or_else(|| failed("no label"))?; + exec(&mut lib_s, "node.set", json!({"ids": [lglyph], "props": {"cornerRadius": 4}})); + exec(&mut lib_s, "node.set", json!({"ids": [label], "props": {"fontSize": 16}})); + exec(&mut lib_s, "library.publish", json!({})); + exec(&mut lib_s, "file.save", json!({})); + table.put(col, "check library updates", timed(&mut consumer, "library.updates", json!({}), false)); + table.put(col, "accept library updates (overrides kept)", timed(&mut consumer, "library.acceptUpdates", json!({}), false)); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) } #[cfg(test)] @@ -288,6 +472,15 @@ mod tests { assert!(bad.usizes("layers", &[1]).is_err()); } + #[test] + fn measures_components_at_a_small_scale() { + let mut t = super::Table { rows: Vec::new() }; + super::measure_components(&mut t, 0, 30).unwrap(); + assert!(!super::FAILED.load(std::sync::atomic::Ordering::Relaxed)); + assert!(t.rows.len() > 15); + assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows); + } + #[test] fn measures_a_small_document() { let mut t = super::Table { rows: Vec::new() }; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a065969..edfbe85 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -197,6 +197,21 @@ impl Document { n } + /// Undo a write that changed nothing: when node `old.id` equals `old` by value, `old` goes back + /// in its place (shared with the snapshots holding it again) and, unless `was_dirty`, the node + /// leaves the dirty set. Returns whether it did. + pub fn keep_if_unchanged(&mut self, old: Arc, was_dirty: bool) -> bool { + let id = old.id; + let same = self.nodes.get(&id).is_some_and(|n| Arc::ptr_eq(n, &old) || **n == *old); + if same { + self.nodes.insert(id, old); + if !was_dirty { + self.dirty.0.remove(&id); + } + } + same + } + pub fn get(&self, id: NodeId) -> DocResult<&Node> { self.node(id).ok_or(DocError::NoNode(id)) } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index da865b3..b8264e8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -43,8 +43,12 @@ pub enum PropDef { Text { default: String, }, + /// A nested instance's main; `preferred` components (or sets) are offered first when + /// swapping it. InstanceSwap { default: NodeId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + preferred: Vec, }, Variant { options: Vec, diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index c64b960..603363f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -161,6 +161,13 @@ pub fn specs() -> Vec { restore_main ), spec!("instance.swap", "Swap instance", "{ids?, component}", true, swap), + spec!( + "instance.swapAt", + "Swap instance at point", + "{component (component or set: its default variant), x, y (page point)} swaps the instance there (preferring one showing the same component set; Alt-drop from the Assets panel); overrides on layers with matching names stay", + true, + swap_at + ), spec!( "instance.setProperties", "Set instance properties", @@ -171,14 +178,14 @@ pub fn specs() -> Vec { 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? (slots: component ids)}", + "{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)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (slots: component ids)}", + "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -745,7 +752,7 @@ fn restore_main(s: &mut Session, p: &Value) -> Result { Value::Bool(b) => PropDef::Boolean { default: *b }, Value::String(t) => PropDef::Text { default: t.clone() }, other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c }, + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, _ => continue, }, }; @@ -803,6 +810,22 @@ fn swap(s: &mut Session, p: &Value) -> Result { ok() } +/// Swap the instance under a page point to a component (`instance.swapAt`). +pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swapAt"; + let c = req_id(cmd, p, "component")?; + let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let page = s.page()?; + let doc = s.doc()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let family = crate::drop::swap_family(doc, c); + let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + swap(s, &json!({"ids": [target], "component": c}))?; + s.set_selection(vec![target]); + Ok(json!({"id": target, "component": c})) +} + /// Whether instance `id` has a component property `name` that a `kind` variable can drive (a /// boolean property takes a boolean variable, a text property a string one). pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { @@ -1018,7 +1041,7 @@ fn add_prop(s: &mut Session, p: &Value) -> Result { }) .and_then(|c| main_of(doc, c)) .ok_or_else(|| bad(cmd, "default: a component id"))?; - PropDef::InstanceSwap { default: d } + PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } } "variant" => { let dv = string(p, "default") @@ -1219,21 +1242,28 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { return ok(); } let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; - let new_def = match (&def, get(p, "preferred")) { - (PropDef::Slot { .. }, Some(_)) => PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? }, - (_, Some(_)) => return Err(bad(cmd, "`preferred` is for slot properties")), - (_, None) => match get(p, "default") { - Some(v) => match coerce(cmd, doc, &name, &def, v)? { - Value::Bool(b) => PropDef::Boolean { default: b }, - Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, - other => match (&def, id_of(&other)) { - (PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c }, - _ => def.clone(), - }, - }, - None => def.clone(), - }, + let preferred = if get(p, "preferred").is_some() { + if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { + return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); + } + Some(preferred_arg(cmd, doc, p)?) + } else { + None }; + let mut new_def = match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { + *preferred = list; + } for c in &comps { let had = prop_defs(doc, *c).contains_key(&name); if !had { @@ -1292,7 +1322,7 @@ fn def_json(name: &str, d: &PropDef) -> Value { match d { PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), - PropDef::InstanceSwap { default } => json!({"name": name, "type": "instanceSwap", "default": default}), + PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 67b906f..cabb8e2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -456,6 +456,10 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { } let bounds = doc.bounds_of(&ids); let uid = ds.uid; + let source = Some(ds.doc.clone()); + let source_path = ds.path.clone(); + s.clipboard.source = source; + s.clipboard.source_path = source_path; s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -481,6 +485,8 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re if clip.nodes.is_empty() { return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); } + // From another file: its instances follow the mains this file can have. + let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -491,6 +497,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re _ => Affine::IDENTITY, }; let mut out = Vec::new(); + let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { let mut map = BTreeMap::new(); for n in nodes { @@ -500,8 +507,11 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re if let Some(r) = nodes.first_mut() { r.transform = parent_world.inverse() * shift * r.transform; } - let nid = doc.paste_nodes(&nodes, &map, *root, parent, None)?; - out.push(nid); + out.push(doc.paste_nodes(&nodes, &map, *root, parent, None)?); + all.extend(map); + } + if let Some(src) = foreign { + crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; } Ok(out) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 983ced2..aa490b9 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -64,6 +64,18 @@ pub fn specs() -> Vec { true, place ), + spec!( + "library.swapAt", + "Swap instance to library component", + "{library, component (id or name of a published component, set or variant), x, y (page point)} copies the component in and swaps the instance there to it (as instance.swapAt)", + true, + |s, p| { + let (_, lib, key) = component_arg(s, "library.swapAt", p)?; + let map = import(s.doc_mut()?, &lib, &[key], false)?; + let local = map.get(&key).copied().ok_or_else(|| EngineError::Other("the component didn't import".into()))?; + super::component::swap_at(s, &with(p, &["x", "y"], json!({"component": local}))) + } + ), spec!( "library.import", "Import library component", diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 87ffa9a..48bbcfb 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -651,11 +651,19 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { /// Write bound variable values into nodes (called during normalisation). pub fn apply_bindings(doc: &mut Document) { + apply_bindings_to(doc, None); +} + +/// [`apply_bindings`] for the layers `only` (every bound layer when `None`). +pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet>) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { return; } - let bound: Vec = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(); + let bound: Vec = match only { + Some(ids) => ids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| !n.bound.is_empty())).collect(), + None => doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(), + }; for id in bound { let modes = doc.modes_for(id); let Some(n) = doc.node(id) else { continue }; @@ -696,7 +704,20 @@ pub fn apply_bindings(doc: &mut Document) { sets.insert(field, jv); } if !sets.is_empty() { + // Most bound values are already in place: leave those layers untouched (shared with the + // undo snapshots, and not re-synced or re-laid out as edited). + let old = doc.node_arc(id).cloned(); + let was_dirty = doc.dirty().contains(&id); let _ = super::props::apply("variables", doc, id, &sets); + if let Some(old) = old { + // A value a variable gives a layer of an instance is no override. + if doc.node(id).is_some_and(|n| n.overrides != old.overrides) + && let Ok(n) = doc.get_mut(id) + { + n.overrides = old.overrides.clone(); + } + doc.keep_if_unchanged(old, was_dirty); + } } if !paint_sets.is_empty() { apply_paint_bindings(doc, id, &paint_sets); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 3f60a68..063a1a7 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -232,7 +232,7 @@ pub fn prop_default_value(d: &PropDef) -> Value { match d { PropDef::Boolean { default } => Value::Bool(*default), PropDef::Text { default } => Value::String(default.clone()), - PropDef::InstanceSwap { default } => Value::from(default.0), + PropDef::InstanceSwap { default, .. } => Value::from(default.0), PropDef::Variant { default, .. } => Value::String(default.clone()), PropDef::Slot { .. } => Value::Null, } @@ -290,70 +290,79 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { Carry::Geometry if !geo => continue, _ => {} } - match g.as_str() { - "fills" => { - to.fills = from.fills.clone(); - to.styles.fill = from.styles.fill.clone(); - } - "strokes" => { - to.strokes = from.strokes.clone(); - to.stroke = from.stroke.clone(); - to.styles.stroke = from.styles.stroke.clone(); - } - "effects" => { - to.effects = from.effects.clone(); - to.styles.effect = from.styles.effect.clone(); - } - "opacity" => { - to.opacity = from.opacity; - to.blend = from.blend; - } - "visible" => to.visible = from.visible, - "name" => to.name = from.name.clone(), - "corners" => to.corners = from.corners, - "characters" => { - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.characters = a.characters.clone(); - b.runs = a.runs.clone(); - } - } - "textStyle" => { - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.style = a.style.clone(); - b.runs = a.runs.clone(); - b.align_h = a.align_h; - b.align_v = a.align_v; - } - to.styles.text = from.styles.text.clone(); - } - "size" => { - to.width = from.width; - to.height = from.height; - to.child_layout = from.child_layout.clone(); - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.auto_resize = a.auto_resize; - } - } - "position" => to.transform = from.transform, - "component" => { - if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { - *b = *a; - } - } - "props" => { - if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { - *b = a.clone(); - } - carry_prop_bindings(from, to); - } - _ => {} - } + copy_group(from, to, g); } if what != Carry::Geometry { to.overrides = from.overrides.clone(); } } +/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. +fn copy_group(from: &Node, to: &mut Node, g: &str) { + if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { + to.bound.retain(|k, _| binding_group(k) != Some(g)); + to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); + } + match g { + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } + "strokes" => { + to.strokes = from.strokes.clone(); + to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); + } + "opacity" => { + to.opacity = from.opacity; + to.blend = from.blend; + } + "visible" => to.visible = from.visible, + "name" => to.name = from.name.clone(), + "corners" => to.corners = from.corners, + "characters" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.characters = a.characters.clone(); + b.runs = a.runs.clone(); + } + } + "textStyle" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.style = a.style.clone(); + b.runs = a.runs.clone(); + b.align_h = a.align_h; + b.align_v = a.align_v; + } + to.styles.text = from.styles.text.clone(); + } + "size" => { + to.width = from.width; + to.height = from.height; + to.child_layout = from.child_layout.clone(); + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } + } + "position" => to.transform = from.transform, + "component" => { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { + *b = *a; + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); + } + carry_prop_bindings(from, to); + } + _ => {} + } +} + /// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With /// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames /// don't break matching), and the content of slots holding the instance's own is left out. @@ -435,9 +444,15 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { /// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its /// slots. An instance whose main component was deleted keeps its layers as they are. pub fn sync_instance(doc: &mut Document, inst: NodeId) { + // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). + let old: Vec<(std::sync::Arc, bool)> = + doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); let kept = slot_content(doc, inst); - sync_instance_depth(doc, inst, 0); + sync_instance_depth(doc, inst, 0, BTreeMap::new()); restore_slot_content(doc, kept); + for (a, was_dirty) in old { + doc.keep_if_unchanged(a, was_dirty); + } } /// The slots in instance `inst` (nested instances included) holding the instance's own content: @@ -484,7 +499,9 @@ fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { } } -fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { +/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested +/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. +fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { if depth > MAX_DEPTH { return; } @@ -502,37 +519,38 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { eff_props.insert(k.clone(), v.clone()); } } - // Old layers, by (parent, source), by source alone (when only one layer mirrors it) and by - // name path. - let old_paths = path_map(doc, inst, true); - let mut by_source: BTreeMap<(NodeId, NodeId), Node> = BTreeMap::new(); - let mut by_src: BTreeMap> = BTreeMap::new(); - let mut by_path: BTreeMap = BTreeMap::new(); - for (i, path) in &old_paths { - let Some(n) = doc.node(*i) else { continue }; + // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only + // one layer mirrors it) and, only when those don't match, by name path. + let mut old_ids = old_layers(doc, inst); + old_ids.extend(spare.keys().copied()); + old_ids.sort(); + old_ids.dedup(); + let mut old: BTreeMap> = spare; + let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); + for i in &old_ids { + let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; if let Some(s) = n.source { if let Some(p) = n.parent { - by_source.entry((p, s)).or_insert_with(|| n.clone()); + by_source.entry((p, s)).or_insert(*i); } - by_src.entry(s).and_modify(|e| *e = None).or_insert_with(|| Some(n.clone())); + by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); } - by_path.insert(path.clone(), n.clone()); - } - for c in root.children.clone() { - let _ = doc.remove(c); + old.insert(*i, n); } + let mut by_path: Option> = None; + let mut main_paths: Option> = None; // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another // parent keeps its id and overrides). let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); - let main_paths = path_map(doc, component, false); let mut map: BTreeMap = BTreeMap::new(); map.insert(component, inst); - let mut olds: BTreeMap = BTreeMap::new(); let mut used: BTreeSet = BTreeSet::new(); // Slots holding the instance's own content (new ids), and main layers not copied: the // default content of those slots. let mut edited: BTreeSet = BTreeSet::new(); let mut skipped: BTreeSet = BTreeSet::new(); + let mut fresh_ids = Vec::new(); for s in &src { let Some(sn) = doc.node(*s) else { continue }; if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { @@ -540,22 +558,31 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { continue; } let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); - let old = parent_new - .and_then(|pn| by_source.get(&(pn, *s))) - .or_else(|| by_src.get(s).and_then(Option::as_ref)) - .or_else(|| main_paths.get(s).and_then(|p| by_path.get(p))); - let nid = match old { - Some(o) if !doc.contains(o.id) && used.insert(o.id) => { - olds.insert(o.id, o.clone()); - o.id + let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); + if found.is_none() { + let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); + let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false)); + found = mains.get(s).and_then(|p| paths.get(p)).copied(); + } + let nid = match found { + Some(o) if used.insert(o) => o, + _ => { + fresh_ids.push(*s); + continue; } - _ => doc.alloc_id(), }; - if olds.get(&nid).is_some_and(holds_content) && is_slot(doc, *s) { + if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { edited.insert(nid); } map.insert(*s, nid); } + for c in root.children.clone() { + let _ = doc.remove(c); + } + for s in fresh_ids { + let nid = doc.alloc_id(); + map.insert(s, nid); + } // Build the copies (pre-order: parents first). let mut enclosing: BTreeMap> = BTreeMap::new(); enclosing.insert(inst, vec![inst]); @@ -563,8 +590,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { inst_props.insert(inst, eff_props); let mut nested = Vec::new(); let mut new_nodes = Vec::new(); + // Text layers showing other text or style than their main layer (to re-measure). + let mut remeasure = Vec::new(); for s in &src { - let Some(orig) = doc.node(*s).cloned() else { continue }; + let Some(orig) = doc.node(*s) else { continue }; let Some(&nid) = map.get(s) else { continue }; let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; let mut n = orig.clone(); @@ -574,10 +603,12 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { n.source = Some(*s); n.overrides.clear(); n.prop_refs.clear(); + let prev = old.get(&nid); // Annotations belong to the layer they were added to, not to its copies. - n.annotations = olds.get(&nid).map(|o| o.annotations.clone()).unwrap_or_default(); - if let Some(old) = olds.get(&nid) { - carry_overrides(old, &mut n, Carry::Values); + n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + if let Some(o) = prev { + carry_overrides(o, &mut n, Carry::Values); + keep_laid_out(doc, orig, o, &mut n); } if !edited.contains(&nid) { // No longer a slot in the main: it mirrors the main again. @@ -586,9 +617,9 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { // Component properties: the main layer's bindings use this instance's values; layers of // nested instances use the nested instance's values (one level per source hop). let enc = enclosing.get(&parent).cloned().unwrap_or_default(); - let mut chain = vec![orig.clone()]; + let mut chain: Vec<&Node> = vec![orig]; while chain.len() < enc.len() { - let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)).cloned() else { break }; + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; chain.push(next); } for (j, level) in chain.iter().enumerate().rev() { @@ -605,7 +636,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { } if let NodeKind::Instance { component: c, props } = &n.kind { inst_props.insert(nid, props.clone()); - let mut e = enc.clone(); + let mut e = enc; e.push(nid); enclosing.insert(nid, e); if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { @@ -614,6 +645,11 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { } else { enclosing.insert(nid, enc); } + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) + && a != b + { + remeasure.push(nid); + } new_nodes.push(n); } for n in new_nodes { @@ -634,7 +670,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { fresh.kind = root.kind.clone(); carry_prop_bindings(&root, &mut fresh); // A nested instance keeps mirroring its layer of the outer main. - fresh.source = if inside_instance(doc, inst) { root.source } else { Some(component) }; + let nested_copy = inside_instance(doc, inst); + fresh.source = if nested_copy { root.source } else { Some(component) }; fresh.constraints = root.constraints; fresh.child_layout = root.child_layout.clone(); fresh.interactions = root.interactions.clone(); @@ -646,7 +683,19 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { fresh.visible = root.visible; fresh.var_modes = root.var_modes.clone(); fresh.is_mask = root.is_mask; - fresh.name = default_instance_name(doc, component); + fresh.exposed = root.exposed; + fresh.guides = root.guides.clone(); + fresh.dev_status = root.dev_status; + fresh.dev_changed = root.dev_changed; + fresh.dev_note = root.dev_note.clone(); + fresh.constrain_proportions = root.constrain_proportions; + // A swapped nested instance keeps the name its layer was given in the outer main. + let renamed = nested_copy + && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { + NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c), + _ => false, + }); + fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { *r = fresh; @@ -655,41 +704,108 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); } // Geometry overrides of children win over constraints. for s in &src { let Some(nid) = map.get(s).copied() else { continue }; - let Some(old) = olds.get(&nid) else { continue }; - if !old.overrides.contains("size") && !old.overrides.contains("position") { + let Some(o) = old.get(&nid) else { continue }; + if !o.overrides.contains("size") && !o.overrides.contains("position") { continue; } + remeasure.push(nid); let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; - if old.overrides.contains("size") { + if o.overrides.contains("size") { // Through set_size so vector networks scale with the box. - figcraft_layout::set_size(doc, nid, old.width, old.height); + figcraft_layout::set_size(doc, nid, o.width, o.height); if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { figcraft_layout::apply_constraints(doc, nid, ow, oh); } } if let Some(n) = doc.node_mut(nid) { - carry_overrides(old, n, Carry::Geometry); + carry_overrides(o, n, Carry::Geometry); } } // Text whose characters or style differ from the main re-measures. - for nid in map.values() { - if let Some(n) = doc.node(*nid) + for nid in remeasure { + if let Some(n) = doc.node(nid) && let Some((w, h)) = figcraft_text::auto_size(n) && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) { - figcraft_layout::set_size(doc, *nid, w, h); + figcraft_layout::set_size(doc, nid, w, h); } } - // Nested instances showing another main than the main's copy (swapped): rebuild from it. + // Nested instances showing another main than the main's copy (swapped): rebuild from it, + // with the old layers below them this rebuild didn't reuse. + let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect(); for n in nested { - sync_instance_depth(doc, n, depth + 1); + let below: BTreeMap> = unused + .iter() + .filter(|(_, x)| { + let mut cur = x.parent; + for _ in 0..=MAX_DEPTH * 4 { + match cur { + Some(p) if p == n => return true, + Some(p) => cur = unused.get(&p).and_then(|a| a.parent), + None => return false, + } + } + false + }) + .map(|(i, x)| (*i, x.clone())) + .collect(); + sync_instance_depth(doc, n, depth + 1, below); } } +/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old +/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually +/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever +/// something that sizes or places layers did change. +fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) { + if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") { + return; + } + let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flows { + // The main's rotation and skew, the old place. + let [a, b, c, d, _, _] = n.transform.as_coeffs(); + let [.., e, f] = o.transform.as_coeffs(); + n.transform = Affine::new([a, b, c, d, e, f]); + } + // Only layers whose shape is their box (a vector's points scale with it). + let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. }); + let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows)); + if derived(n.child_layout.sizing_h) { + n.width = o.width; + } + if derived(n.child_layout.sizing_v) { + n.height = o.height; + } +} + +/// The layers of instance `inst` a rebuild matches against (every layer below it but the content +/// of slots holding its own), ascending. +fn old_layers(doc: &Document, inst: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![inst]; + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + for c in &n.children { + out.push(*c); + if out.len() > 1_000_000 { + break; + } + if doc.node(*c).is_some_and(|cn| !holds_content(cn)) { + stack.push(*c); + } + } + } + out.sort(); + out.dedup(); + out +} + /// Insert a node whose parent/children links are already set (used when rebuilding instances). fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; @@ -708,6 +824,40 @@ fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { Ok(()) } +/// The property groups whose value layer `n` takes from the same variables as its main layer +/// `src` (they differ only where the two resolve in different variable modes). +pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + for (k, v) in &n.bound { + let Some(g) = binding_group(k) else { continue }; + if src.bound.get(k) == Some(v) && !out.contains(&g) { + out.push(g); + } + } + out +} + +/// The override group a variable binding key feeds (`None` for component property and layout +/// grid bindings, which follow other rules). +fn binding_group(key: &str) -> Option<&'static str> { + use crate::cmd::lists::{BoundList, list_binding}; + if key.starts_with(PROP_BINDING) { + return None; + } + match list_binding(key).map(|(l, _, _)| l) { + Some(BoundList::Fills) => Some("fills"), + Some(BoundList::Strokes) => Some("strokes"), + Some(BoundList::Effects) => Some("effects"), + Some(BoundList::Grids) => None, + None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), + } +} + +/// The variable bindings of `n` that feed group `g`. +fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { + n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) +} + /// Property groups that differ between two versions of a node. pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { let mut out = Vec::new(); @@ -835,7 +985,18 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] differs.push("name"); } } - groups.retain(|g| differs.contains(g)); + // A value from the same variable as the main's (resolved in this layer's modes) isn't an + // override either. + let from_variables = variable_groups(n, src); + groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); + // Binding another variable (or none) where the main binds one is an override. + for (k, _) in b.bound.iter().chain(n.bound.iter()) { + let Some(g) = binding_group(k) else { continue }; + let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); + if rebound && !groups.contains(&g) { + groups.push(g); + } + } cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); if top_root { @@ -860,15 +1021,46 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] resync.insert(r); } } - // 2. Components whose layers changed, were added or removed. + // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, + // visibility, …) are carried to the copies directly; anything else rebuilds the instances. let mut comps: BTreeSet = BTreeSet::new(); + let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new(); + // Main layers that only gained new layers or lost some: their copies take the same change. + let mut restructured: Vec = Vec::new(); + let mut covered: BTreeSet = BTreeSet::new(); for &id in changed { - if let Some(c) = owning_component(doc, id) { + if let (Some(b), Some(n)) = (before.node(id), doc.node(id)) + && b.children != n.children + && owning_instance(doc, id).is_none() + && owning_component(doc, id).is_some() + && let Some(layers) = restructurable(doc, before, b, n) + { + restructured.push(id); + covered.extend(layers); + } + } + for &id in changed { + if covered.contains(&id) || restructured.contains(&id) { + continue; + } + let Some(c) = owning_component(doc, id) else { continue }; + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { comps.insert(c); + continue; + }; + if b == n { + continue; + } + match patchable(doc, b, n) { + Some(groups) => patches.push((id, groups)), + None => { + comps.insert(c); + } } } for &id in touched { if !doc.contains(id) + && !covered.contains(&id) && let Some(c) = owning_component(before, id) && doc.contains(c) { @@ -883,35 +1075,348 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } } } + // A copy the patch can't follow exactly rebuilds instead (as does any other change). + let patched = comps.is_empty() + && !full_rebuild() + && (restructured.is_empty() || patch_structure(doc, before, &restructured)) + && (patches.is_empty() || patch_copies(doc, &patches)); + #[cfg(test)] + if patched && (!restructured.is_empty() || !patches.is_empty()) { + PATCHED.with(|c| c.set(c.get() + 1)); + } + if !patched { + comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id))); + } // 3. Instances of changed components follow; an instance inside another component makes that - // one change too. Instances nested inside instances are rebuilt with their outer instance. - let mut done: BTreeSet = BTreeSet::new(); - let mut frontier: Vec = comps.into_iter().collect(); + // one change too. + rebuild_instances(doc, &comps); +} + +#[cfg(test)] +thread_local! { + /// Tests: rebuild instances even for edits that could be patched (to compare both ways). + pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: edits carried to the copies without rebuilding instances. + pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn full_rebuild() -> bool { + FULL_REBUILD.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn full_rebuild() -> bool { + false +} + +/// The property groups of a main-component layer edited from `b` to `n` when the edit changed +/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without +/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. +fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { + const VALUES: [&str; 11] = + ["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position"]; + if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { + return None; + } + let groups = diff_groups(b, n); + if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) { + return None; + } + // Exactly those groups (and the layer's own overrides) changed. + let mut probe = n.clone(); + for g in &groups { + copy_group(b, &mut probe, g); + } + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + // Values a component property sets (on this layer or a main it mirrors) come from instances. + let mut cur = Some(n); + for _ in 0..=MAX_DEPTH { + let Some(x) = cur else { break }; + if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) { + return None; + } + cur = x.source.and_then(|s| doc.node(s)); + } + Some(groups) +} + +/// Copies by the layer they mirror; nested instances swapped to a main also follow it. +fn copies_index(doc: &Document) -> BTreeMap> { + let mut copies: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + let Some(s) = n.source else { continue }; + copies.entry(s).or_default().push(n.id); + if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind)) + && component != other + { + copies.entry(*component).or_default().push(n.id); + } + } + copies +} + +/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances, +/// components or property bindings in them) or lost layers that were deleted: those layers, which +/// [`patch_structure`] carries to the copies. `None` for anything else. +fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> { + let mut probe = n.clone(); + probe.children = b.children.clone(); + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + let mut layers = Vec::new(); + for c in n.children.iter().filter(|c| !b.children.contains(c)) { + for d in doc.subtree(*c) { + let x = doc.node(d)?; + let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); + if before.contains(d) || !plain || !x.prop_refs.is_empty() { + return None; + } + layers.push(d); + } + } + for c in b.children.iter().filter(|c| !n.children.contains(c)) { + if doc.contains(*c) { + return None; + } + layers.extend(before.subtree(*c)); + } + Some(layers) +} + +/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy, +/// through copies of copies (slots holding an instance's own content keep it). Returns false +/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool { + let copies = copies_index(doc); + let mut work: Vec = seeds.to_vec(); + let mut steps = 0usize; + while let Some(p) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c).cloned() else { continue }; + if holds_content(&cn) { + continue; + } + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // The copy's layers by the layer of `p` they mirror. + let mut mirrors: BTreeMap = BTreeMap::new(); + for k in &cn.children { + let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false }; + if mirrors.insert(src, *k).is_some() { + return false; + } + } + let mut order = Vec::with_capacity(pn.children.len()); + for pc in &pn.children { + if let Some(k) = mirrors.remove(pc) { + order.push(k); + continue; + } + // A layer the copy lacks must be new in this edit, placed as in the main. + let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute); + if before.contains(*pc) || !child_follows_main(doc, c, absolute) { + return false; + } + match copy_layers(doc, *pc, c) { + Some(k) => order.push(k), + None => return false, + } + } + // Copies of layers the main lost. + for (src, k) in mirrors { + if doc.contains(src) || doc.remove(k).is_err() { + return false; + } + } + if doc.node(c).is_some_and(|x| x.children != order) + && let Some(m) = doc.node_mut(c) + { + m.children = order; + } + work.push(c); + } + } + true +} + +/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's +/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]). +fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { + let Some(p) = doc.node(parent) else { return false }; + let constrains = p.layout.is_none() || absolute; + let resized = p.overrides.contains("size") + || match &p.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) + } + _ => false, + }; + !(constrains && resized) && follows_main_geometry(doc, parent) +} + +/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the +/// new copy (the caller places it among `parent`'s layers). +fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option { + let sub = doc.subtree(src); + let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect(); + for d in &sub { + let mut n = doc.node(*d)?.clone(); + n.id = *map.get(d)?; + n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) }; + n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); + n.source = Some(*d); + n.overrides.clear(); + n.prop_refs.clear(); + n.annotations.clear(); + insert_linked(doc, n).ok()?; + } + map.get(&src).copied() +} + +/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through +/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial +/// patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool { + let copies = copies_index(doc); + let text = |g: &str| matches!(g, "characters" | "textStyle"); + let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec(); + let mut steps = 0usize; + while let Some((p, groups)) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + // A swapped nested instance shows its own main, not the layer it stands in for. + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // An instance keeps its own visibility and place; its name follows the main's unless + // renamed; a resized one lays its layers out against the main's size. + let root = matches!(pn.kind, NodeKind::Component { .. }); + if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { + return false; + } + let gs: Vec<&'static str> = + groups.iter().copied().filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position"))).collect(); + if gs.is_empty() { + continue; + } + let geometry = gs.iter().any(|g| matches!(*g, "size" | "position")); + if geometry && !follows_main_geometry(doc, c) { + return false; + } + // Text content and style share their runs: a copy overriding one keeps its runs when the + // other follows the main (as a rebuild carries them). + let runs = match &cn.kind { + NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()), + _ => None, + }; + if let Some(m) = doc.node_mut(c) { + for g in &gs { + copy_group(&pn, m, g); + } + if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) { + t.runs = r; + } + } + if (geometry || gs.iter().any(|g| text(g))) + && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size) + && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6) + { + figcraft_layout::set_size(doc, c, w, h); + } + work.push((c, gs)); + } + } + true +} + +/// Whether a copy's size and place are its main layer's: no layer around it inside its instances +/// applies constraints on the way down to it (a resized instance or frame without auto layout, or +/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest. +fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { + let chain = scope(doc, id); + chain.windows(2).all(|w| { + let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; + let constrains = a.layout.is_none() || below.child_layout.absolute; + if a.overrides.contains("size") && constrains { + return false; + } + match &a.kind { + NodeKind::Instance { component, .. } => { + !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9) + } + _ => true, + } + }) +} + +/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on +/// outwards), each outermost instance once, inner components first. +fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { + if comps.is_empty() { + return; + } + // Outermost instances by the components their layers instantiate. + let mut roots_of: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + if let NodeKind::Instance { component, .. } = &n.kind { + roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id)); + } + } + // Affected components, with how deep their dependencies go (an instance inside component X of + // component Y: X follows Y). + let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect(); + let mut outer: BTreeMap> = BTreeMap::new(); + let mut frontier: Vec = comps.iter().copied().collect(); let mut rounds = 0; while !frontier.is_empty() && rounds < 64 { rounds += 1; let mut next = Vec::new(); - let mut synced: BTreeSet = BTreeSet::new(); for c in frontier { - if !done.insert(c) { - continue; - } - for inst in instances_of(doc, c) { - // A copy inside another instance is rebuilt with its outermost instance. - let r = root_instance(doc, inst).unwrap_or(inst); - if !synced.insert(r) { - continue; - } - sync_instance(doc, r); - if let Some(outer) = owning_component(doc, r) - && !done.contains(&outer) + let lc = level.get(&c).copied().unwrap_or(0); + for r in roots_of.get(&c).into_iter().flatten() { + let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r)); + if let Some(x) = o + && x != c { - next.push(outer); + let lx = level.entry(x).or_insert(0); + if *lx < lc + 1 { + *lx = lc + 1; + next.push(x); + } } } } frontier = next; } + // Instances inside components by level, then the rest (each once). + let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect(); + order.sort(); + for (_, r) in order { + if doc.contains(r) { + sync_instance(doc, r); + } + } } /// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the @@ -1017,7 +1522,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let main = main.or_else(|| theirs.get(path).copied()); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); if let Some(m) = main.and_then(|m| doc.node(m)) { - let bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { overrides.insert("component".into()); @@ -1032,7 +1538,15 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } } let root_groups = match (doc.node(component), doc.node(inst)) { - (Some(m), Some(n)) => value_groups(m, n).into_iter().filter(|g| g != "name").collect::>(), + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n).into_iter().filter(|g| g != "name" && !from_variables.contains(&g.as_str())).collect(); + // A layer name of its own (a nested instance renamed in its old main) stays. + if n.name != default_instance_name(doc, component) { + groups.push("name".into()); + } + groups + } _ => Vec::new(), }; if let Some(n) = doc.node_mut(inst) { diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs new file mode 100644 index 0000000..8ed92b2 --- /dev/null +++ b/crates/engine/src/components_scale_tests.rs @@ -0,0 +1,744 @@ +//! Components at scale: a synthetic design system (every property kind, nesting four deep, styles +//! and light/dark variables) and a file using it with overrides at every nesting level. Edits of +//! deep mains, variant switches, swaps, resets, detach, push, library updates, copy/paste and +//! undo/redo keep every override and every instance mirroring its main. + +use std::collections::BTreeMap; + +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::*; +use crate::synthetic_ds::*; + +/// Instances in the test file (debug builds: the bench uses 2,000 with the full design system). +const COUNT: usize = 120; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + match s.execute(cmd, &p) { + Ok(v) => v, + Err(e) => panic!("{cmd} {p}: {e}"), + } +} + +fn session() -> (Session, DesignSystem, Vec) { + let (doc, ds, inst) = design_system_with_instances(&DsSpec::SMALL, COUNT).unwrap(); + (Session::with_document(doc), ds, inst) +} + +fn healthy(doc: &Document) { + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + let issues = mirror_issues(doc); + assert!(issues.is_empty(), "{} mirror issues, first: {:?}", issues.len(), issues.first()); +} + +/// Every overridden layer inside an instance: its groups and the values they hold. +fn overrides(doc: &Document) -> BTreeMap, String)> { + doc.nodes() + .filter(|n| !n.overrides.is_empty() && owning_instance(doc, n.id).is_some()) + .map(|n| { + let mut v = Vec::new(); + for g in &n.overrides { + v.push(match g.as_str() { + "fills" => format!("{:?}", n.fills), + "name" => n.name.clone(), + "characters" => n.kind.text().map(|t| t.characters.clone()).unwrap_or_default(), + "component" | "props" => format!("{:?}", n.kind), + other => other.to_string(), + }); + } + (n.id, (n.overrides.iter().cloned().collect(), v.join("|"))) + }) + .collect() +} + +fn chars(doc: &Document, id: NodeId) -> String { + doc.node(id).and_then(|n| n.kind.text()).map(|t| t.characters.clone()).unwrap_or_default() +} + +fn comp_of(doc: &Document, id: NodeId) -> Option { + match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + } +} + +fn main_of(doc: &Document, id: NodeId) -> NodeId { + comp_of(doc, id).unwrap_or_else(|| panic!("{id} is not an instance")) +} + +#[test] +fn the_generated_file_covers_every_property_kind_and_nesting_level() { + let (s, ds, inst) = session(); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(inst.len(), COUNT); + assert!(ds.components >= 40, "{}", ds.components); + let full = design_system_document(&DsSpec::FULL).unwrap().1; + assert!((280..=330).contains(&full.components), "the full design system has {} components", full.components); + // Property kinds on the button set: variant, text, boolean, instance swap (with preferred values). + let props = ex(&mut Session::with_document(doc.clone()), "component.properties", json!({"id": ds.button})); + let kinds: Vec<&str> = props["properties"].as_array().unwrap().iter().filter_map(|p| p["type"].as_str()).collect(); + for k in ["variant", "text", "boolean", "instanceSwap"] { + assert!(kinds.contains(&k), "{kinds:?}"); + } + let swap = props["properties"].as_array().unwrap().iter().find(|p| p["type"] == "instanceSwap").unwrap(); + assert_eq!(swap["preferred"].as_array().unwrap().len(), 4); + // Slots on cards. + let card = doc.node(ds.card).unwrap().children[0]; + assert_eq!(slots_of(doc, card).len(), 1); + // Four levels deep: nav > profile card > footer button > icon > glyph. + let nav = inst.iter().copied().find(|i| variant_set(doc, main_of(doc, *i)) == Some(ds.nav)).unwrap(); + let glyph = find_path(doc, nav, &["Profile", "Footer button", "Icon", "Glyph"]).unwrap(); + assert_eq!(scope_depth(doc, glyph), 4); + // Overrides at every level, styles and variables in use. + let o = overrides(doc); + for g in ["fills", "characters", "component", "props", "name"] { + assert!(o.values().any(|(gs, _)| gs.iter().any(|x| x == g)), "no {g} override"); + } + assert!(doc.nodes().filter(|n| !n.bound.is_empty()).count() > 100); + assert!(doc.nodes().any(|n| n.styles.text.is_some())); + assert!(doc.nodes().any(|n| n.var_modes.values().any(|m| *m == ds.dark))); +} + +/// Instances `id` is nested in (0 for a top-level instance's own layers). +fn scope_depth(doc: &Document, id: NodeId) -> usize { + doc.ancestors(id).into_iter().filter(|a| matches!(doc.node(*a).map(|n| &n.kind), Some(NodeKind::Instance { .. }))).count() +} + +/// Two documents hold the same layers with the same values (the first difference otherwise). +/// Layers created by the edit may have other ids on each side: matched by their place in the +/// tree. Where a hidden layer sits inside an auto layout frame is nobody's concern: not compared. +fn assert_same(a: &Document, b: &Document, what: &str) { + assert_eq!(a.node_count(), b.node_count(), "{what}: layer counts differ"); + // b's ids → a's, pairing layers by position from the roots down. + let mut map: BTreeMap = BTreeMap::new(); + let mut stack: Vec<(NodeId, NodeId)> = a.nodes().filter(|n| n.parent.is_none()).map(|n| (n.id, n.id)).collect(); + while let Some((x, y)) = stack.pop() { + map.insert(y, x); + let (Some(xn), Some(yn)) = (a.node(x), b.node(y)) else { panic!("{what}: layer {x} is missing on one side") }; + assert_eq!(xn.children.len(), yn.children.len(), "{what}: layer {x} has other layers"); + stack.extend(xn.children.iter().copied().zip(yn.children.iter().copied())); + } + let hidden = |d: &Document, id: NodeId| std::iter::once(id).chain(d.ancestors(id)).any(|x| d.node(x).is_some_and(|n| !n.visible)); + for (yid, id) in map.clone() { + let (Some(x), Some(y)) = (a.node(id), b.node(yid)) else { panic!("{what}: layer {id} is missing on one side") }; + let m = |i: NodeId| map.get(&i).copied().unwrap_or(i); + let mut y = y.clone(); + y.id = m(y.id); + y.parent = y.parent.map(m); + y.source = y.source.map(m); + y.children = y.children.iter().map(|c| m(*c)).collect(); + if hidden(a, id) && hidden(b, yid) { + y.transform = x.transform; + } + let (x, y) = (Some(x), Some(&y)); + assert!(x == y, "{what}: layer {id} differs:\n{x:?}\n{y:?}"); + } +} + +/// The first top-level instance of set `set` with a layer at name path `path`: (instance, layer). +fn first_of(doc: &Document, inst: &[NodeId], set: NodeId, path: &[&str]) -> (NodeId, NodeId) { + inst.iter() + .copied() + .filter(|i| variant_set(doc, main_of(doc, *i)) == Some(set)) + .find_map(|i| find_path(doc, i, path).map(|l| (i, l))) + .unwrap_or_else(|| panic!("no instance of {set} has {path:?}")) +} + +fn rgb_of(doc: &Document, var: &str, mode: &str) -> String { + match doc.variables.get(var).and_then(|v| v.values.get(mode)) { + Some(VariableValue::Color(c)) => c.to_hex(), + other => panic!("{var}: {other:?}"), + } +} + +/// The edits of deep mains the propagation tests run (each one undoable command). +fn main_edits(doc: &Document, ds: &DesignSystem) -> Vec<(&'static str, Value)> { + let glyph = doc.node(ds.icons[0]).unwrap().children[0]; + let button = doc.node(ds.button).unwrap().children[0]; + let label = find_path(doc, button, &["Label"]).unwrap(); + let card = doc.node(ds.card).unwrap().children[0]; + let title = find_path(doc, card, &["Title"]).unwrap(); + let body = find_path(doc, card, &["Body"]).unwrap(); + let nav = doc.node(ds.nav).unwrap().children[0]; + vec![ + ("node.set", json!({"ids": [glyph], "props": {"boundVariables": {"fill": null}, "fill": "#ff0000"}})), + ("node.set", json!({"ids": [glyph], "props": {"cornerRadius": 3, "opacity": 0.8}})), + ("node.set", json!({"ids": [glyph], "props": {"width": 18, "height": 18}})), + ("node.set", json!({"ids": [glyph], "props": {"x": 2, "y": 3}})), + ("node.set", json!({"ids": [label], "props": {"fontSize": 17}})), + ("node.set", json!({"ids": [label], "props": {"visible": false}})), + ("node.set", json!({"ids": [title], "props": {"name": "Heading"}})), + ("arrange.back", json!({"ids": [body]})), + ("edit.delete", json!({"ids": [body]})), + ("node.set", json!({"ids": [card], "props": {"strokeWeight": 3}})), + ("node.set", json!({"ids": [nav], "props": {"effects": [{"type": "dropShadow"}]}})), + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": ds.icons[0], "props": {"name": "Badge dot"}})), + ("node.set", json!({"ids": [ds.icons[0]], "props": {"width": 30}})), + ] +} + +#[test] +fn editing_deep_mains_keeps_every_override_and_matches_a_full_rebuild() { + let (mut a, ds, _) = session(); + let mut b = Session::with_document(a.doc().unwrap().clone()); + let edits = main_edits(a.doc().unwrap(), &ds); + let start = overrides(a.doc().unwrap()); + let mut patched = Vec::new(); + for (cmd, p) in &edits { + let was = PATCHED.with(std::cell::Cell::get); + ex(&mut a, cmd, p.clone()); + patched.push(PATCHED.with(std::cell::Cell::get) > was); + FULL_REBUILD.with(|f| f.set(true)); + ex(&mut b, cmd, p.clone()); + FULL_REBUILD.with(|f| f.set(false)); + let doc = a.doc().unwrap(); + healthy(doc); + assert_same(doc, b.doc().unwrap(), &format!("{cmd} {p}")); + // Every override is still there with its value (the edits touch no overridden group). + let now = overrides(doc); + for (id, o) in &start { + assert_eq!(now.get(id), Some(o), "after {cmd} {p}: override of {id}"); + } + } + // Every one of these edits was carried to the copies without rebuilding instances. + let rebuilt: Vec = edits.iter().zip(&patched).filter(|(_, p)| !**p).map(|((c, p), _)| format!("{c} {p}")).collect(); + assert!(rebuilt.is_empty(), "rebuilt for {rebuilt:?}"); + // The copies that don't override it followed: buttons' labels are hidden. + let doc = a.doc().unwrap(); + let hidden = doc.nodes().filter(|n| n.name == "Label" && owning_instance(doc, n.id).is_some() && !n.visible).count(); + assert!(hidden > COUNT / 4, "{hidden} labels hidden"); + // Undo walks every edit back; redo replays them. + for _ in &edits { + a.undo().unwrap(); + } + assert_eq!(overrides(a.doc().unwrap()), start); + for _ in &edits { + a.redo().unwrap(); + } + assert_same(a.doc().unwrap(), b.doc().unwrap(), "after redo"); +} + +#[test] +fn bound_copies_follow_their_own_theme_after_main_edits() { + let (mut s, ds, _) = session(); + let doc = s.doc().unwrap(); + let dark = rgb_of(doc, "ds:icon", &ds.dark); + let light = rgb_of(doc, "ds:icon", &ds.light); + // Edit something else on the icon main: copies in dark screens keep their dark colour. + let glyph = doc.node(ds.icons[0]).unwrap().children[0]; + ex(&mut s, "node.set", json!({"ids": [glyph], "props": {"width": 15}})); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": ds.icons[0]})); + let doc = s.doc().unwrap(); + let (mut seen_dark, mut seen_light) = (0, 0); + for n in doc.nodes().filter(|n| n.name == "Glyph" && n.overrides.is_empty() && owning_instance(doc, n.id).is_some()) { + let is_dark = doc.modes_for(n.id).get(&ds.theme) == Some(&ds.dark); + let want = if is_dark { &dark } else { &light }; + assert_eq!(n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).as_ref(), Some(want), "glyph {}", n.id); + if is_dark { + seen_dark += 1; + } else { + seen_light += 1; + } + } + assert!(seen_dark > 5 && seen_light > 5, "{seen_dark} dark, {seen_light} light"); +} + +#[test] +fn variant_switches_keep_nested_overrides() { + let (mut s, ds, inst) = session(); + let doc = s.doc().unwrap(); + let (card, glyph) = first_of(doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]); + let footer = find_path(doc, card, &["Footer button"]).unwrap(); + let icon = find_path(doc, card, &["Footer button", "Icon"]).unwrap(); + let (fill, swapped, props) = (doc.node(glyph).unwrap().fills.clone(), main_of(doc, icon), doc.node(footer).unwrap().kind.clone()); + let title = chars(doc, find_path(doc, card, &["Title"]).unwrap()); + let before = overrides(doc); + for (size, ty) in [("Medium", "Outlined"), ("Small", "Elevated"), ("Medium", "Elevated")] { + ex(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": size, "Type": ty}})); + let doc = s.doc().unwrap(); + healthy(doc); + assert!(doc.node(main_of(doc, card)).unwrap().name.contains(&format!("Type={ty}"))); + // Same layers, same overrides: the exposed footer button's label, its swapped icon and that + // icon's recoloured glyph, four levels down; the title property. + assert_eq!(find_path(doc, card, &["Footer button", "Icon", "Glyph"]), Some(glyph)); + assert_eq!(doc.node(glyph).unwrap().fills, fill); + assert_eq!(main_of(doc, icon), swapped); + assert_eq!(doc.node(footer).unwrap().kind, props); + assert_eq!(chars(doc, find_path(doc, card, &["Title"]).unwrap()), title); + let now = overrides(doc); + for (id, o) in before.iter().filter(|(id, _)| doc.is_ancestor(card, **id)) { + assert_eq!(now.get(id), Some(o), "override of {id}"); + } + } + // The exposed nested instance's properties are offered on the card. + let exposed = ex(&mut s, "instance.exposed", json!({"id": card})); + assert!(exposed["instances"].as_array().unwrap().iter().any(|e| e["id"] == json!(footer.0))); + for _ in 0..3 { + s.undo().unwrap(); + } + assert_eq!(overrides(s.doc().unwrap()), before); +} + +#[test] +fn instance_swaps_offer_preferred_values_and_keep_matching_overrides() { + let (mut s, ds, inst) = session(); + // The button's icon property prefers the first icons; the list can be edited. + let props = ex(&mut s, "component.properties", json!({"id": ds.button})); + let swap = props["properties"].as_array().unwrap().iter().find(|p| p["name"] == "Icon").unwrap().clone(); + assert_eq!(swap["preferred"], json!(ds.icons.iter().take(4).map(|i| i.0).collect::>())); + ex(&mut s, "component.editProperty", json!({"id": ds.button, "name": "Icon", "preferred": [ds.icons[5].0, ds.icons[4].0]})); + let props = ex(&mut s, "component.properties", json!({"id": ds.button})); + let swap = props["properties"].as_array().unwrap().iter().find(|p| p["name"] == "Icon").unwrap().clone(); + assert_eq!(swap["preferred"], json!([ds.icons[5].0, ds.icons[4].0])); + assert_eq!(swap["default"], json!(ds.icons[0].0), "the default stays"); + assert!(s.execute("component.editProperty", &json!({"id": ds.button, "name": "Label", "preferred": [ds.icons[1].0]})).is_err()); + // Swapping through the property: the recoloured glyph keeps its colour (same layer name). + let doc = s.doc().unwrap(); + let (button, glyph) = first_of(doc, &inst, ds.button, &["Icon", "Glyph"]); + let fill = doc.node(glyph).unwrap().fills.clone(); + ex(&mut s, "instance.setProperties", json!({"ids": [button], "properties": {"Icon": ds.icons[5].0}})); + let doc = s.doc().unwrap(); + healthy(doc); + let icon = find_path(doc, button, &["Icon"]).unwrap(); + assert_eq!(main_of(doc, icon), ds.icons[5]); + assert_eq!(doc.node(find_path(doc, icon, &["Glyph"]).unwrap()).unwrap().fills, fill); + // Swapping a top-level icon instance: same. + let (top, tglyph) = + inst.iter().copied().filter(|i| ds.icons.contains(&main_of(doc, *i))).find_map(|i| find_path(doc, i, &["Glyph"]).map(|g| (i, g))).unwrap(); + let tfill = doc.node(tglyph).unwrap().fills.clone(); + let other = ds.icons.iter().copied().find(|c| *c != main_of(doc, top)).unwrap(); + ex(&mut s, "instance.swap", json!({"ids": [top], "component": other})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(main_of(doc, top), other); + assert_eq!(doc.node(find_path(doc, top, &["Glyph"]).unwrap()).unwrap().fills, tfill); +} + +#[test] +fn reset_detach_and_push_at_scale() { + let (mut s, ds, inst) = session(); + let doc = s.doc().unwrap(); + let navs: Vec = inst.iter().copied().filter(|i| variant_set(doc, main_of(doc, *i)) == Some(ds.nav)).collect(); + assert!(navs.len() >= 3); + // Reset one override four levels down, then everything on a nav. + let glyph = find_path(doc, navs[0], &["Profile", "Footer button", "Icon", "Glyph"]).unwrap(); + assert!(doc.node(glyph).unwrap().overrides.contains("fills")); + ex(&mut s, "component.resetOverride", json!({"id": glyph, "group": "fills"})); + let doc = s.doc().unwrap(); + let src = doc.node(glyph).unwrap().source.unwrap(); + assert_eq!(doc.node(glyph).unwrap().fills, doc.node(src).unwrap().fills); + ex(&mut s, "component.resetInstance", json!({"ids": [navs[0]]})); + let doc = s.doc().unwrap(); + healthy(doc); + let left: Vec<(NodeId, String, Vec)> = doc + .subtree(navs[0]) + .into_iter() + .filter_map(|i| doc.node(i)) + .filter(|n| n.overrides.iter().any(|g| g != "size")) + .map(|n| (n.id, n.name.clone(), n.overrides.iter().cloned().collect())) + .collect(); + assert!(left.is_empty(), "{left:?}"); + // Detach another: a frame; its nested instances keep their look and stay linked. + let look = |doc: &Document, root: NodeId| -> Vec<(String, String, String)> { + doc.subtree(root) + .into_iter() + .skip(1) + .filter_map(|i| doc.node(i)) + .map(|n| (n.name.clone(), format!("{:?}", n.fills), n.kind.text().map(|t| t.characters.clone()).unwrap_or_default())) + .collect() + }; + let was = look(doc, navs[1]); + ex(&mut s, "component.detach", json!({"ids": [navs[1]]})); + let doc = s.doc().unwrap(); + healthy(doc); + assert!(matches!(doc.node(navs[1]).unwrap().kind, NodeKind::Frame)); + assert_eq!(look(doc, navs[1]), was); + let profile = find_path(doc, navs[1], &["Profile"]).unwrap(); + assert!(matches!(doc.node(profile).unwrap().kind, NodeKind::Instance { .. })); + // Push a recoloured glyph from a button to its main: the other instances of that variant + // showing the same icon follow unless they recolour the glyph themselves. + let (button, bglyph) = first_of(doc, &inst, ds.button, &["Icon", "Glyph"]); + let variant = main_of(doc, button); + let fill = doc.node(bglyph).unwrap().fills.clone(); + let bicon = main_of(doc, find_path(doc, button, &["Icon"]).unwrap()); + let r = ex(&mut s, "component.pushChanges", json!({"id": button})); + assert!(r["layers"].as_u64().unwrap() >= 1); + let doc = s.doc().unwrap(); + healthy(doc); + assert!(!doc.node(bglyph).unwrap().overrides.contains("fills")); + + for i in inst.iter().copied().filter(|i| *i != button && comp_of(doc, *i) == Some(variant)) { + let (Some(g), Some(icon)) = (find_path(doc, i, &["Icon", "Glyph"]), find_path(doc, i, &["Icon"])) else { continue }; + let n = doc.node(g).unwrap(); + if !n.overrides.contains("fills") && main_of(doc, icon) == bicon { + assert_eq!(n.fills, fill, "glyph {g} of {i}"); + } + } + + for _ in 0..4 { + s.undo().unwrap(); + } + healthy(s.doc().unwrap()); +} + +#[test] +fn copy_paste_of_instances_keeps_overrides() { + let (mut s, ds, inst) = session(); + let doc = s.doc().unwrap(); + let (nav, _) = first_of(doc, &inst, ds.nav, &["Profile"]); + let o = overrides(doc); + let mine: Vec<(Vec, String)> = doc.subtree(nav).iter().filter_map(|i| o.get(i).cloned()).collect(); + assert!(mine.len() >= 4); + ex(&mut s, "edit.copy", json!({"ids": [nav]})); + let r = ex(&mut s, "edit.paste", json!({})); + let pasted = crate::params::id_of(&r["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + healthy(doc); + let o = overrides(doc); + let theirs: Vec<(Vec, String)> = doc.subtree(pasted).iter().filter_map(|i| o.get(i).cloned()).collect(); + assert_eq!(theirs, mine); + assert_eq!(main_of(doc, pasted), main_of(doc, nav)); +} + +#[test] +fn switching_a_screen_theme_records_no_overrides() { + let (mut s, ds, _) = session(); + let doc = s.doc().unwrap(); + let before = overrides(doc); + let light_screen = doc + .nodes() + .find(|n| n.name.starts_with("Screen ") && matches!(n.kind, NodeKind::Frame) && n.var_modes.is_empty() && !n.children.is_empty()) + .map(|n| n.id) + .unwrap(); + ex(&mut s, "variable.setMode", json!({"ids": [light_screen], "collection": ds.theme, "mode": ds.dark})); + let doc = s.doc().unwrap(); + healthy(doc); + let now = overrides(doc); + let changed: Vec<_> = + now.iter().filter(|(k, v)| before.get(*k) != Some(*v)).chain(before.iter().filter(|(k, _)| !now.contains_key(*k))).collect(); + assert!(changed.is_empty(), "a theme switch isn't an override: {changed:?}"); + let dark = rgb_of(doc, "ds:icon", &ds.dark); + let glyphs: Vec<&Node> = + doc.subtree(light_screen).into_iter().filter_map(|i| doc.node(i)).filter(|n| n.name == "Glyph" && n.overrides.is_empty()).collect(); + assert!(!glyphs.is_empty()); + for g in glyphs { + assert_eq!(g.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some(dark.clone()), "glyph {}", g.id); + } + // Resetting an instance there keeps the theme's colours (they're no overrides to reset). + let inst = doc.node(light_screen).unwrap().children[0]; + ex(&mut s, "component.resetInstance", json!({"ids": [inst]})); + healthy(s.doc().unwrap()); +} + +/// A scratch directory for one test. +fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-scale-{}-{name}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The design system published and saved at `path`, and a consumer file whose instances use it +/// from the library (with overrides at every level). Returns (library session, its design system, +/// consumer session, the consumer's copies, the consumer's instances). +fn library_setup(path: &str) -> (Session, DesignSystem, Session, DesignSystem, Vec) { + let (doc, ds) = design_system_document(&DsSpec::SMALL).unwrap(); + let mut lib_s = Session::with_document(doc); + ex(&mut lib_s, "library.publish", json!({})); + ex(&mut lib_s, "file.saveAs", json!({"path": path})); + let lib = crate::library::Cache::default().load(path).unwrap(); + let mut doc = Document::new("Consumer"); + let m = &lib.manifest; + doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version }); + let map = crate::library::import(&mut doc, &lib, &ds.assets(), false).unwrap(); + let mut local = ds.remap(&map); + local.theme = doc.libraries.collections.iter().find(|r| r.key == ds.theme).map(|r| r.local.clone()).unwrap(); + crate::normalize(&mut doc, &Document::new("")); + let inst = place_instances(&mut doc, &local, COUNT / 2).unwrap(); + crate::normalize(&mut doc, &Document::new("")); + let consumer = Session::with_document(doc); + healthy(consumer.doc().unwrap()); + (lib_s, ds, consumer, local, inst) +} + +#[test] +fn library_updates_reach_consumers_with_every_override_kept() { + let dir = scratch("updates"); + let path = dir.join("ds.figcraft").to_string_lossy().into_owned(); + let (mut lib_s, ds, mut s, local, _) = library_setup(&path); + let before = overrides(s.doc().unwrap()); + assert!(before.len() > 50); + // The library changes deep mains: the icon's glyph colour (unbinding it), a button label's + // size, a new layer in the icon. + let doc = lib_s.doc().unwrap(); + let glyph = doc.node(ds.icons[0]).unwrap().children[0]; + let label = find_path(doc, doc.node(ds.button).unwrap().children[0], &["Label"]).unwrap(); + ex(&mut lib_s, "variable.bind", json!({"ids": [glyph], "field": "fill", "variable": null})); + ex(&mut lib_s, "node.set", json!({"ids": [glyph], "props": {"fill": "#ff0000"}})); + ex(&mut lib_s, "node.set", json!({"ids": [label], "props": {"fontSize": 16}})); + ex(&mut lib_s, "shape.rectangle", json!({"x": 1, "y": 1, "width": 3, "height": 3, "parent": ds.icons[0], "props": {"name": "Dot"}})); + ex(&mut lib_s, "library.publish", json!({})); + ex(&mut lib_s, "file.save", json!({})); + let u = ex(&mut s, "library.updates", json!({})); + assert!(u["count"].as_u64().unwrap() >= 2, "{u}"); + let r = ex(&mut s, "library.acceptUpdates", json!({})); + assert!(r["components"].as_u64().unwrap() >= 2, "{r}"); + let doc = s.doc().unwrap(); + healthy(doc); + // Same layers, same overrides. + let now = overrides(doc); + for (id, o) in &before { + assert_eq!(now.get(id), Some(o), "override of {id}"); + } + // The copies that don't override the glyph's colour show the new one; the new layer is there. + let main_glyph = doc.node(local.icons[0]).unwrap().children[0]; + assert_eq!(doc.node(main_glyph).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).as_deref(), Some("FF0000")); + let red = doc + .nodes() + .filter(|n| n.name == "Glyph" && n.overrides.is_empty() && owning_instance(doc, n.id).is_some() && !doc.is_remote(n.id)) + .filter(|n| { + let icon = owning_instance(doc, n.id).unwrap(); + comp_of(doc, icon) == Some(local.icons[0]) + }) + .inspect(|n| assert_eq!(n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).as_deref(), Some("FF0000"), "glyph {}", n.id)) + .count(); + assert!(red > 0); + assert!(doc.nodes().any(|n| n.name == "Dot" && owning_instance(doc, n.id).is_some() && !doc.is_remote(n.id))); + assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0); + // Undo takes the update back. + s.undo().unwrap(); + assert_eq!(overrides(s.doc().unwrap()), before); + healthy(s.doc().unwrap()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pasting_instances_into_other_files_follows_their_mains() { + let dir = scratch("paste"); + let path = dir.join("ds.figcraft").to_string_lossy().into_owned(); + let (mut lib_s, ds, mut s, local, inst) = library_setup(&path); + // From the consumer (library mains) into a new file: the library copies come along. + let doc = s.doc().unwrap(); + let (nav, _) = first_of(doc, &inst, local.nav, &["Profile"]); + let o = overrides(doc); + let mine: Vec<(Vec, String)> = doc.subtree(nav).iter().filter_map(|i| o.get(i).cloned()).collect(); + ex(&mut s, "edit.copy", json!({"ids": [nav]})); + ex(&mut s, "file.new", json!({"name": "Other"})); + let r = ex(&mut s, "edit.paste", json!({})); + let pasted = crate::params::id_of(&r["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + healthy(doc); + assert!(doc.is_remote(main_of(doc, pasted)), "linked to a library copy"); + assert_eq!(doc.libraries.enabled.len(), 1); + let o = overrides(doc); + let theirs: Vec<(Vec, String)> = doc.subtree(pasted).iter().filter_map(|i| o.get(i).cloned()).collect(); + assert_eq!(theirs.len(), mine.len()); + // Pasting again shares the copies. + let copies = doc.libraries.components.len(); + ex(&mut s, "edit.paste", json!({})); + assert_eq!(s.doc().unwrap().libraries.components.len(), copies); + healthy(s.doc().unwrap()); + // From the library file itself (its published components) into a new file: used from the + // library, which gets enabled. + let b = crate::params::id_of(&ex(&mut lib_s, "instance.create", json!({"component": ds.button, "x": 0, "y": -400}))["id"]).unwrap(); + ex(&mut lib_s, "edit.copy", json!({"ids": [b]})); + ex(&mut lib_s, "file.new", json!({"name": "Third"})); + let r = ex(&mut lib_s, "edit.paste", json!({})); + let pasted = crate::params::id_of(&r["ids"][0]).unwrap(); + let doc = lib_s.doc().unwrap(); + healthy(doc); + assert!(doc.is_remote(main_of(doc, pasted))); + assert_eq!(doc.libraries.enabled.first().map(|l| l.path.as_str()), Some(path.as_str())); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pasting_an_instance_of_an_unpublished_component_into_another_file_leaves_its_main_behind() { + let mut s = Session::new(); + let f = crate::params::id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Tile"}))["id"]).unwrap(); + ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f, "props": {"name": "Dot"}})); + let c = crate::params::id_of(&ex(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap(); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0}))["id"]).unwrap(); + ex(&mut s, "edit.copy", json!({"ids": [i]})); + ex(&mut s, "file.new", json!({})); + // Layers whose ids collide with the copied mains' ids in the other file. + for k in 0..6 { + ex(&mut s, "shape.rectangle", json!({"x": k * 20, "y": 300, "width": 10, "height": 10})); + } + let r = ex(&mut s, "edit.paste", json!({})); + let pasted = crate::params::id_of(&r["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + healthy(doc); + let main = main_of(doc, pasted); + assert!(!doc.contains(main), "the main stays in its own file"); + for d in doc.subtree(pasted) { + assert!(doc.node(d).unwrap().source.is_none_or(|src| !doc.contains(src) || doc.is_ancestor(pasted, src)), "{d} mirrors a layer of this file"); + } + // The main can be restored here. + ex(&mut s, "component.restoreMain", json!({"id": pasted})); + let doc = s.doc().unwrap(); + healthy(doc); + assert!(matches!(doc.node(main).unwrap().kind, NodeKind::Component { .. })); +} + +/// A "Chip" component whose fill and corner radius are bound to variables of a light/dark +/// collection, holding an "Icon" instance (renamed, exposed) of a "Dot" component. Returns +/// (chip, icon instance in the main, the colour variable's light and dark hex). +fn bound_chip(s: &mut Session) -> (NodeId, NodeId) { + let id = |v: Value| crate::params::id_of(&v["id"]).unwrap(); + ex(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + ex(s, "variable.create", json!({"collection": "Theme", "name": "Chip", "type": "color", "value": "#112233"})); + ex(s, "variable.set", json!({"variable": "Chip", "mode": "Dark", "value": "#aabbcc"})); + ex(s, "variable.create", json!({"collection": "Theme", "name": "Round", "type": "number", "value": 4})); + ex(s, "variable.set", json!({"variable": "Round", "mode": "Dark", "value": 9})); + let dot = id(ex(s, "frame.create", json!({"x": 0, "y": 300, "width": 10, "height": 10, "name": "Dot"}))); + let dot = id(ex(s, "component.create", json!({"ids": [dot]}))); + let ring = id(ex(s, "frame.create", json!({"x": 40, "y": 300, "width": 10, "height": 10, "name": "Ring"}))); + ex(s, "component.create", json!({"ids": [ring]})); + let f = id(ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 30, "name": "Chip"}))); + let bg = id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 30, "parent": f, "props": {"name": "Bg"}}))); + ex(s, "variable.bind", json!({"ids": [bg], "field": "fill", "variable": "Chip"})); + ex(s, "variable.bind", json!({"ids": [bg], "field": "cornerRadius", "variable": "Round"})); + let icon = id(ex(s, "instance.create", json!({"component": dot, "x": 5, "y": 5, "parent": f}))); + ex(s, "node.set", json!({"ids": [icon], "props": {"name": "Icon"}})); + let chip = id(ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.exposeInstance", json!({"id": icon})); + (chip, icon) +} + +#[test] +fn variables_on_instance_layers_are_no_overrides_and_detached_ones_stay_overridden() { + let mut s = Session::new(); + let (chip, _) = bound_chip(&mut s); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let bg = find_path(doc, i, &["Bg"]).unwrap(); + ex(&mut s, "variable.setMode", json!({"ids": [i], "collection": "Theme", "mode": "Dark"})); + let n = s.doc().unwrap().node(bg).unwrap().clone(); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "AABBCC"); + assert_eq!(n.corners.radii, [9.0; 4]); + assert!(n.overrides.is_empty(), "values from variables aren't overrides: {:?}", n.overrides); + // Detach the colour on the instance and pick another: an override that survives main edits. + ex(&mut s, "variable.bind", json!({"ids": [bg], "field": "fill", "variable": null})); + ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}})); + assert!(s.doc().unwrap().node(bg).unwrap().overrides.contains("fills")); + let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}})); + ex(&mut s, "shape.ellipse", json!({"x": 60, "y": 5, "width": 5, "height": 5, "parent": chip})); + let doc = s.doc().unwrap(); + healthy(doc); + let n = doc.node(bg).unwrap(); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(!n.bound.contains_key("fill")); + assert_eq!(n.corners.radii, [9.0; 4], "the corner radius still follows its variable"); + assert_eq!(n.height, 28.0); + // Reset brings the variable back. + ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"})); + let n = s.doc().unwrap().node(bg).unwrap().clone(); + assert_eq!(n.bound.get("fill").map(String::as_str), s.doc().unwrap().node(main_bg).unwrap().bound.get("fill").map(String::as_str)); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "AABBCC"); +} + +#[test] +fn nested_instances_keep_exposure_and_names_through_main_edits_and_swaps() { + let mut s = Session::new(); + let (chip, icon) = bound_chip(&mut s); + let ring = s.doc().unwrap().nodes().find(|n| n.name == "Ring" && matches!(n.kind, NodeKind::Component { .. })).unwrap().id; + // Editing the nested instance's own main rebuilds it inside the chip main. + let dot = comp_of(s.doc().unwrap(), icon).unwrap(); + ex(&mut s, "node.set", json!({"ids": [dot], "props": {"fill": "#00ff00", "width": 12}})); + ex(&mut s, "shape.rectangle", json!({"x": 1, "y": 1, "width": 2, "height": 2, "parent": dot})); + let doc = s.doc().unwrap(); + assert!(doc.node(icon).unwrap().exposed, "still exposed"); + assert_eq!(doc.node(icon).unwrap().name, "Icon"); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap(); + let nested = find_path(s.doc().unwrap(), i, &["Icon"]).unwrap(); + assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"][0]["id"], json!(nested.0)); + // Swapped, the nested instance keeps the name its layer has in the main. + ex(&mut s, "instance.swap", json!({"ids": [nested], "component": ring})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(doc.node(nested).unwrap().name, "Icon"); + assert_eq!(comp_of(doc, nested), Some(ring)); +} + +fn centre(doc: &Document, id: NodeId) -> (f64, f64) { + let b = doc.world_bounds(id).unwrap(); + (b.center().x, b.center().y) +} + +#[test] +fn alt_dropping_a_component_onto_an_instance_swaps_it() { + let (mut s, ds, inst) = session(); + let doc = &s.doc().unwrap().clone(); + // Over a button's icon, a button variant swaps the button (same component set) ... + let (button, glyph) = inst + .iter() + .copied() + .filter(|i| variant_set(doc, main_of(doc, *i)) == Some(ds.button)) + .find_map(|i| { + find_path(doc, i, &["Icon", "Glyph"]) + .filter(|_| find_path(doc, i, &["Icon"]).is_some_and(|c| doc.node(c).unwrap().visible)) + .map(|g| (i, g)) + }) + .unwrap(); + s.ds_mut().unwrap().page = doc.page_of(button).unwrap(); + let fill = doc.node(glyph).unwrap().fills.clone(); + let label = chars(doc, find_path(doc, button, &["Label"]).unwrap()); + let other = doc.node(ds.button).unwrap().children.iter().copied().find(|v| *v != main_of(doc, button)).unwrap(); + let (x, y) = centre(doc, glyph); + assert_eq!(crate::drop::swap_target(doc, s.page().unwrap(), figcraft_geom::Point::new(x, y), Some("Button"), Some(other)), Some(button)); + let r = ex(&mut s, "instance.swapAt", json!({"component": other, "x": x, "y": y})); + assert_eq!(r["id"], json!(button.0)); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(main_of(doc, button), other); + assert_eq!(s.selection(), vec![button]); + // ... keeping overrides on layers with matching names and the property values. + assert_eq!(doc.node(find_path(doc, button, &["Icon", "Glyph"]).unwrap()).unwrap().fills, fill); + assert_eq!(chars(doc, find_path(doc, button, &["Label"]).unwrap()), label); + // An icon dropped there swaps the button's icon (a nested swap, an override). + let icon = find_path(doc, button, &["Icon"]).unwrap(); + let to = ds.icons.iter().copied().skip(1).find(|c| Some(*c) != comp_of(doc, icon)).unwrap(); + let (x, y) = centre(doc, find_path(doc, button, &["Icon", "Glyph"]).unwrap()); + ex(&mut s, "instance.swapAt", json!({"component": to, "x": x, "y": y})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(comp_of(doc, icon), Some(to)); + assert!(doc.node(icon).unwrap().overrides.contains("component")); + assert_eq!(doc.node(find_path(doc, icon, &["Glyph"]).unwrap()).unwrap().fills, fill); + // Nothing there: refused, nothing changes. + let before = s.doc().unwrap().clone(); + assert!(s.execute("instance.swapAt", &json!({"component": to, "x": -50_000, "y": -50_000})).is_err()); + assert_eq!(*s.doc().unwrap(), before); + // A component never swaps an instance inside itself. + let main_icon = find_path(s.doc().unwrap(), other, &["Icon"]).unwrap(); + let (x, y) = centre(s.doc().unwrap(), main_icon); + let r = s.execute("instance.swapAt", &json!({"component": other, "x": x, "y": y})); + assert!(r.is_err() || r.unwrap()["id"] != json!(main_icon.0)); + s.undo().unwrap(); + s.undo().unwrap(); + healthy(s.doc().unwrap()); +} + +#[test] +fn alt_dropping_a_library_component_swaps_to_it() { + let dir = scratch("swap"); + let path = dir.join("ds.figcraft").to_string_lossy().into_owned(); + let (_lib, ds, mut s, local, inst) = library_setup(&path); + let doc = &s.doc().unwrap().clone(); + let (top, glyph) = + inst.iter().copied().filter(|i| local.icons.contains(&main_of(doc, *i))).find_map(|i| find_path(doc, i, &["Glyph"]).map(|g| (i, g))).unwrap(); + let fill = doc.node(glyph).unwrap().fills.clone(); + s.ds_mut().unwrap().page = doc.page_of(top).unwrap(); + let k = ds.icons.len() - 1; + let (x, y) = centre(doc, glyph); + ex(&mut s, "library.swapAt", json!({"library": "Design system", "component": ds.icons[k].0, "x": x, "y": y})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(main_of(doc, top), local.icons[k]); + assert_eq!(doc.node(find_path(doc, top, &["Glyph"]).unwrap()).unwrap().fills, fill); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index 25d2863..d614d62 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -190,3 +190,26 @@ pub fn slot(doc: &Document, page: NodeId, p: Point) -> DropSlot { None => DropSlot { parent, index: None, line: None }, } } + +/// The component set a component belongs to, by name (`None` for a lone component): which +/// instances an Alt-drop of it prefers to swap. +pub fn swap_family(doc: &Document, component: NodeId) -> Option { + crate::components::variant_set(doc, component).and_then(|s| doc.node(s)).map(|s| s.name.clone()) +} + +/// The instance that a component dropped at page point `p` with Alt held swaps: of the instances +/// under the point (innermost first; never one inside the dropped component `exclude`), the first +/// showing a variant of the same component set (`family`), else the innermost. +pub fn swap_target(doc: &Document, page: NodeId, p: Point, family: Option<&str>, exclude: Option) -> Option { + let hit = doc.hit_test(page, p, 0.0)?; + let chain: Vec = std::iter::once(hit) + .chain(doc.ancestors(hit)) + .filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. }))) + .filter(|i| exclude.is_none_or(|c| !doc.is_ancestor(c, *i))) + .collect(); + let same_family = |i: &NodeId| match (doc.node(*i).map(|n| &n.kind), family) { + (Some(NodeKind::Instance { component, .. }), Some(f)) => swap_family(doc, *component).as_deref() == Some(f), + _ => false, + }; + chain.iter().copied().find(same_family).or_else(|| chain.first().copied()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96dabd6..5427587 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod proto_run; pub mod shapebuilder; pub mod snap; pub mod synthetic; +pub mod synthetic_ds; pub mod textedit; pub mod textpreview; pub mod tools; @@ -42,7 +43,7 @@ pub use tools::{PointerEvent, PointerKind, ToolId}; use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{Document, Node, NodeId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -462,6 +463,9 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// The document copied from (pastes into other files follow its mains) and its file path. + pub source: Option>, + pub source_path: Option, } /// The editing session: open documents, the current tool, clipboard and prefs. @@ -830,7 +834,8 @@ pub fn normalize(doc: &mut Document, before: &Document) { } let m = measure::TextMeasure; // 0. Variable bindings and style links feed node values. - cmd::styles::apply_bindings(doc); + let scope = binding_scope(doc, before, &touched); + cmd::styles::apply_bindings_to(doc, scope.as_ref()); if doc.styles != before.styles { let changed_styles: Vec = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect(); for sid in changed_styles { @@ -862,6 +867,12 @@ pub fn normalize(doc: &mut Document, before: &Document) { // 2. Instances follow edited main components (and record overrides inside instances). let changed = live(doc, &touched); components::sync_after_edit(doc, before, &changed, &touched); + // Rebuilt copies took their mains' values: their bindings resolve in their own modes. + let synced = doc.take_dirty(); + if !synced.is_empty() { + cmd::styles::apply_bindings_to(doc, Some(&synced)); + touched.extend(synced); + } touched.extend(doc.take_dirty()); // Parents of removed nodes (as they were before) count as touched. let removed_parents: Vec = @@ -891,9 +902,10 @@ pub fn normalize(doc: &mut Document, before: &Document) { } } touched.extend(doc.take_dirty()); - // 4. Auto layout for affected frames. + // 4. Auto layout for frames where something that sizes or places layers changed. let mut frames: BTreeSet = BTreeSet::new(); - for id in live(doc, &touched).into_iter().chain(removed_parents) { + let moved = live(doc, &touched).into_iter().filter(|id| affects_layout(before, doc, *id)); + for id in moved.chain(removed_parents) { if !doc.contains(id) { continue; } @@ -908,7 +920,18 @@ pub fn normalize(doc: &mut Document, before: &Document) { let mut frames: Vec = frames.into_iter().collect(); frames.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len())); for f in frames { + // Layers the layout leaves where they were stay untouched. + let kept: Vec<(Arc, bool)> = doc + .node(f) + .map(|n| std::iter::once(f).chain(n.children.iter().copied()).collect::>()) + .unwrap_or_default() + .into_iter() + .filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))) + .collect(); figcraft_layout::layout_frame(doc, f, &m); + for (a, was_dirty) in kept { + doc.keep_if_unchanged(a, was_dirty); + } } touched.extend(doc.take_dirty()); // 5. Ready-for-dev layers whose contents changed show "Changed". @@ -918,6 +941,44 @@ pub fn normalize(doc: &mut Document, before: &Document) { cmd::comment::follow_anchors(doc, before); } +/// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto +/// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or +/// layout settings. +fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { + let (Some(a), Some(b)) = (before.node(id), doc.node(id)) else { return true }; + a.width != b.width + || a.height != b.height + || a.transform != b.transform + || a.visible != b.visible + || a.parent != b.parent + || a.children != b.children + || a.layout != b.layout + || a.child_layout != b.child_layout + || a.stroke != b.stroke + || a.strokes.len() != b.strokes.len() + || a.kind != b.kind +} + +/// The layers whose variable bindings an edit may have changed: every bound layer when a variable +/// changed, else the touched layers and everything below those that moved, are new or chose +/// another variable mode (modes are inherited). +fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { + if doc.variables != before.variables { + return None; + } + let mut out = BTreeSet::new(); + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); + if inherited { + out.extend(doc.subtree(id)); + } else { + out.insert(id); + } + } + Some(out) +} + /// Flag the frames and sections marked ready for dev (before this edit) that contain a layer whose /// design `touched` changed (added, removed or edited; the handoff status itself and hiding a /// section's contents don't count). @@ -925,17 +986,21 @@ fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet = BTreeSet::new(); for &id in touched { + // Ready-for-dev frames around it first (the cheap test). + let mut chain = vec![id]; + chain.extend(if doc.contains(id) { doc.ancestors(id) } else { before.ancestors(id) }); + chain.retain(|m| ready(before, *m) && ready(doc, *m)); + if chain.is_empty() { + continue; + } let differs = match (before.node(id), doc.node(id)) { (Some(a), Some(b)) => !a.same_design(b), (None, None) => false, _ => true, }; - if !differs { - continue; + if differs { + changed.extend(chain); } - let mut chain = vec![id]; - chain.extend(if doc.contains(id) { doc.ancestors(id) } else { before.ancestors(id) }); - changed.extend(chain.into_iter().filter(|m| ready(before, *m) && ready(doc, *m))); } for m in changed { if let Some(n) = doc.node_mut(m) @@ -965,6 +1030,8 @@ mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; #[cfg(test)] +mod components_scale_tests; +#[cfg(test)] mod devinspect_tests; #[cfg(test)] mod effects_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0a0989a..4802945 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -68,8 +68,9 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet { NodeKind::Component { props, .. } => { for d in props.values() { match d { - PropDef::InstanceSwap { default } => { + PropDef::InstanceSwap { default, preferred } => { out.insert(*default); + out.extend(preferred.iter().copied()); } PropDef::Slot { preferred } => out.extend(preferred.iter().copied()), _ => {} @@ -687,13 +688,14 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap, styles } } NodeKind::Component { props, .. } => { - props.retain(|_, d| !matches!(d, PropDef::InstanceSwap { default } if !map.contains_key(default))); + props.retain(|_, d| !matches!(d, PropDef::InstanceSwap { default, .. } if !map.contains_key(default))); for d in props.values_mut() { match d { - PropDef::InstanceSwap { default } => { + PropDef::InstanceSwap { default, preferred } => { if let Some(l) = map.get(default) { *default = *l; } + *preferred = preferred.iter().filter_map(|c| map.get(c).copied()).collect(); } PropDef::Slot { preferred } => *preferred = preferred.iter().filter_map(|c| map.get(c).copied()).collect(), _ => {} @@ -954,7 +956,7 @@ pub fn merge_store(doc: &mut Document, other: &Document, map: &BTreeMap) -> Result<()> { +/// The component copies of [`merge_store`] (only those rooted at `only` when given). Returns +/// `other`'s ids of their layers → `doc`'s. +fn merge_components( + doc: &mut Document, + other: &Document, + map: &BTreeMap, + only: Option<&BTreeSet>, +) -> Result> { let mut remote: BTreeMap = BTreeMap::new(); let mut fresh = Vec::new(); - for r in other.libraries.components.iter().filter(|r| other.contains(r.root)) { + for r in other.libraries.components.iter().filter(|r| other.contains(r.root) && only.is_none_or(|o| o.contains(&r.root))) { match doc.libraries.components.iter().find(|m| m.library == r.library && m.key == r.key && doc.contains(m.root)) { Some(mine) => remote.extend(r.ids.iter().filter_map(|(lib, theirs)| mine.ids.get(lib).map(|l| (*theirs, *l)))), None => fresh.push(r.clone()), @@ -1018,13 +1026,105 @@ fn merge_components(doc: &mut Document, other: &Document, map: &BTreeMap, map: &BTreeMap) -> Result<()> { + // The mains the pasted instances show and offer as instance swap values (`src`'s ids). + let mut mains = BTreeSet::new(); + for theirs in map.keys() { + let Some(NodeKind::Instance { component, props }) = src.node(*theirs).map(|n| &n.kind) else { continue }; + mains.insert(*component); + for k in swap_props(src, *component) { + mains.extend(props.get(&k).and_then(id_of)); + } + } + let roots: Vec = mains.iter().filter_map(|m| asset_root(src, *m)).collect::>().into_iter().collect(); + let mut remote: BTreeMap = BTreeMap::new(); + // Library copies: of this file's own library they are its own layers; of other libraries they + // join (or come along as) this file's copies. + let own = doc.published.as_ref().map(|p| p.manifest.id.clone()); + let lib_roots: Vec = roots.iter().copied().filter(|r| src.is_remote(*r)).collect(); + let mut foreign = BTreeSet::new(); + for root in closure(src, &lib_roots) { + let Some(r) = src.libraries.import_of(root) else { continue }; + if own.as_deref() == Some(r.library.as_str()) { + remote.extend(r.ids.iter().filter(|(lib, _)| doc.contains(**lib)).map(|(lib, theirs)| (*theirs, *lib))); + } else { + foreign.insert(root); + if doc.libraries.link(&r.library).is_none() + && let Some(l) = src.libraries.link(&r.library) + { + doc.libraries_mut().enabled.push(l.clone()); + } + } + } + if !foreign.is_empty() { + remote.extend(merge_components(doc, src, &BTreeMap::new(), Some(&foreign))?); + } + // Components `src` publishes: from its library. + if let Some(lib) = src.published.as_ref().filter(|l| own.as_deref() != Some(l.manifest.id.as_str())) { + let keys: Vec = + roots.iter().copied().filter(|r| !src.is_remote(*r) && lib.manifest.component(*r).is_some() && lib.doc.contains(*r)).collect(); + if !keys.is_empty() { + remote.extend(import(doc, lib, &keys, false)?); + let m = &lib.manifest; + if doc.libraries.link(&m.id).is_none() + && let Some(path) = src_path + { + doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version }); + } + } + } + // Point the pasted layers at those mains; what stays behind gets ids of its own. + let mut behind: BTreeMap = BTreeMap::new(); + let mut follow = |doc: &mut Document, id: NodeId| -> NodeId { + if let Some(m) = map.get(&id).or_else(|| remote.get(&id)) { + return *m; + } + *behind.entry(id).or_insert_with(|| doc.alloc_id()) + }; + for (theirs, mine) in map { + let Some(n) = src.node(*theirs) else { continue }; + let source = n.source.map(|s| follow(doc, s)); + let kind = match &n.kind { + NodeKind::Instance { component, props } => { + let swaps = swap_props(src, *component); + let mut props = props.clone(); + for k in swaps { + match props.get(&k).and_then(id_of).and_then(|c| map.get(&c).or_else(|| remote.get(&c)).copied()) { + Some(l) => { + props.insert(k, Value::from(l.0)); + } + None => { + props.remove(&k); + } + } + } + Some(NodeKind::Instance { component: follow(doc, *component), props }) + } + _ => None, + }; + let Ok(m) = doc.get_mut(*mine) else { continue }; + m.source = source; + if let Some(k) = kind { + m.kind = k; + } + } Ok(()) } /// Refuse edits of library copies, except by library commands (and imports of other files, /// which bring their copies along). pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::result::Result<(), String> { - if cmd.starts_with("library.") || cmd == "file.import" { + // Pastes from other files bring the library copies their instances use. + if cmd.starts_with("library.") || cmd == "file.import" || cmd.starts_with("edit.paste") { return Ok(()); } if !variables_unchanged(before, after) { diff --git a/crates/engine/src/synthetic_ds.rs b/crates/engine/src/synthetic_ds.rs new file mode 100644 index 0000000..30d95e5 --- /dev/null +++ b/crates/engine/src/synthetic_ds.rs @@ -0,0 +1,701 @@ +//! A synthetic design system and a file using it, for scale tests and benchmarks +//! (`figcraft-cli perf`). Everything is generated: no third-party content. +//! +//! [`design_system`] builds icon components and component sets (buttons, inputs, cards, navs, +//! badges) whose variants span size × state × type × theme, with boolean, text, instance swap and +//! slot properties, exposed nested instances nested four deep (icon in button in card in nav), +//! paint and text styles, and a light/dark variable collection bound across it. +//! [`place_instances`] fills pages with instances of it carrying overrides at every nesting level +//! (property values, swaps, fills, text, names). + +use std::collections::BTreeMap; + +use figcraft_color::{Color, Paint}; +use figcraft_doc::*; +use serde_json::Value; + +use crate::components::{create_instance, sync_instance}; + +/// How large a design system to build. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DsSpec { + /// Icon components. + pub icons: usize, + /// Values per variant property (each capped at what the property has): sizes (≤ 3), states + /// (≤ 4), types (≤ 3), themes (≤ 2). + pub sizes: usize, + pub states: usize, + pub types: usize, + pub themes: usize, +} + +impl DsSpec { + /// About 300 components (160 icons, 144 variants). + pub const FULL: DsSpec = DsSpec { icons: 160, sizes: 3, states: 4, types: 3, themes: 2 }; + /// A small one for debug-mode tests (still every property kind and nesting level). + pub const SMALL: DsSpec = DsSpec { icons: 6, sizes: 2, states: 2, types: 2, themes: 2 }; +} + +/// What [`design_system`] built (ids in the document it was built in, or mapped with +/// [`DesignSystem::remap`] after a library import). +#[derive(Clone, Debug, PartialEq)] +pub struct DesignSystem { + pub icons: Vec, + pub button: NodeId, + pub input: NodeId, + pub card: NodeId, + pub nav: NodeId, + pub badge: NodeId, + /// Component nodes (icons and variants). + pub components: usize, + /// The theme collection and its light and dark modes. + pub theme: CollectionId, + pub light: ModeId, + pub dark: ModeId, +} + +impl DesignSystem { + /// Every component set. + pub fn sets(&self) -> [NodeId; 5] { + [self.button, self.input, self.card, self.nav, self.badge] + } + + /// Every asset root (icons and sets). + pub fn assets(&self) -> Vec { + self.icons.iter().copied().chain(self.sets()).collect() + } + + /// The same design system after its layers were copied with `map` (library id → local id). + pub fn remap(&self, map: &BTreeMap) -> DesignSystem { + let m = |id: NodeId| map.get(&id).copied().unwrap_or(id); + DesignSystem { + icons: self.icons.iter().map(|i| m(*i)).collect(), + button: m(self.button), + input: m(self.input), + card: m(self.card), + nav: m(self.nav), + badge: m(self.badge), + ..self.clone() + } + } +} + +const SIZES: [&str; 3] = ["Small", "Medium", "Large"]; +const STATES: [&str; 4] = ["Default", "Hover", "Pressed", "Disabled"]; +const BUTTON_TYPES: [&str; 3] = ["Primary", "Secondary", "Ghost"]; +const INPUT_STATES: [&str; 4] = ["Default", "Focus", "Error", "Disabled"]; +const INPUT_TYPES: [&str; 2] = ["Text", "Search"]; +const CARD_TYPES: [&str; 2] = ["Elevated", "Outlined"]; +const NAV_TYPES: [&str; 2] = ["Top", "Side"]; +const BADGE_TYPES: [&str; 4] = ["Info", "Success", "Warning", "Danger"]; +const THEMES: [&str; 2] = ["Light", "Dark"]; +const GLYPHS: [&str; 16] = + ["Arrow", "Check", "Close", "Plus", "Minus", "Search", "Home", "User", "Bell", "Star", "Heart", "Gear", "Mail", "Lock", "Pin", "Clock"]; + +/// Theme colour variables: (name, light, dark). +const COLORS: [(&str, u32, u32); 9] = [ + ("Surface", 0xFFFFFF, 0x1E1E1E), + ("On surface", 0x1E1E1E, 0xF5F5F5), + ("Primary", 0x0D99FF, 0x4DB5FF), + ("On primary", 0xFFFFFF, 0x0B1A26), + ("Border", 0xD9D9D9, 0x444444), + ("Danger", 0xF24822, 0xFF7A5C), + ("Success", 0x14AE5C, 0x3DD68C), + ("Warning", 0xFFA629, 0xFFC470), + ("Icon", 0x333333, 0xE6E6E6), +]; + +fn rgb(hex: u32) -> Color { + let [_, r, g, b] = hex.to_be_bytes(); + Color::from_rgb8_const(r, g, b) +} + +fn var_id(name: &str) -> String { + format!("ds:{}", name.to_lowercase().replace(' ', "-")) +} + +fn component() -> NodeKind { + NodeKind::Component { description: String::new(), props: BTreeMap::new() } +} + +fn text(chars: &str) -> NodeKind { + NodeKind::Text(TextData::new(chars)) +} + +/// Pick a (capped) prefix of a value list. +fn take<'a>(list: &[&'a str], n: usize) -> Vec<&'a str> { + list.iter().take(n.clamp(1, list.len())).copied().collect() +} + +fn set_node(doc: &mut Document, id: NodeId, f: impl FnOnce(&mut Node)) { + if let Some(n) = doc.node_mut(id) { + f(n); + } +} + +fn bind(doc: &mut Document, id: NodeId, field: &str, var: &str) { + set_node(doc, id, |n| { + n.bound.insert(field.to_string(), var_id(var)); + }); +} + +fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad: f64) { + set_node(doc, id, |n| { + n.layout = Some(AutoLayout { mode, gap, padding: [pad; 4], cross_align: CrossAlign::Center, ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + }); +} + +/// A text layer with a text style applied. +fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult { + let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?; + let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind { + StyleKind::Text { style } => Some(style.clone()), + _ => None, + }); + set_node(doc, id, |n| { + if let (NodeKind::Text(t), Some(def)) = (&mut n.kind, def) { + t.style = def; + t.auto_resize = TextAutoResize::WidthAndHeight; + } + n.styles.text = Some(style.to_string()); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + }); + Ok(id) +} + +/// A nested instance inside a main, renamed (a name override, as renaming it in the app records). +fn nested(doc: &mut Document, comp: NodeId, parent: NodeId, name: &str, exposed: bool) -> DocResult { + let id = create_instance(doc, comp, parent, 0.0, 0.0)?; + set_node(doc, id, |n| { + n.name = name.to_string(); + n.overrides.insert("name".into()); + n.exposed = exposed; + }); + Ok(id) +} + +fn set_props(doc: &mut Document, comp: NodeId, props: &BTreeMap) { + set_node(doc, comp, |n| { + if let NodeKind::Component { props: p, .. } = &mut n.kind { + *p = props.clone(); + } + }); +} + +fn refs(doc: &mut Document, id: NodeId, field: &str, prop: &str) { + set_node(doc, id, |n| { + n.prop_refs.insert(field.to_string(), prop.to_string()); + }); +} + +/// Variables (a light/dark theme collection with colours and a radius) and styles. +fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { + let (coll, light, dark) = ("ds:theme".to_string(), "ds:light".to_string(), "ds:dark".to_string()); + doc.variables.collections.push(Collection { + id: coll.clone(), + name: "Theme".into(), + modes: vec![Mode { id: light.clone(), name: "Light".into() }, Mode { id: dark.clone(), name: "Dark".into() }], + }); + let var = |name: &str, kind: VariableType, values: [VariableValue; 2]| Variable { + id: var_id(name), + name: name.into(), + collection: coll.clone(), + kind, + values: [light.clone(), dark.clone()].into_iter().zip(values).collect(), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + }; + for (name, l, d) in COLORS { + doc.variables.variables.push(var(name, VariableType::Color, [VariableValue::Color(rgb(l)), VariableValue::Color(rgb(d))])); + } + doc.variables.variables.push(var("Radius", VariableType::Number, [VariableValue::Number(6.0), VariableValue::Number(8.0)])); + for (name, hex) in [("Brand/Primary", 0x0D99FFu32), ("Brand/Accent", 0x9747FF), ("Text/Primary", 0x1E1E1E), ("Text/Secondary", 0x757575)] { + doc.styles.push(Style { + id: format!("ds:paint:{name}"), + name: name.into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(rgb(hex))] }, + }); + } + for (name, size, weight) in [ + ("Heading/L", 24.0, "Bold"), + ("Heading/M", 18.0, "Bold"), + ("Body/M", 14.0, "Regular"), + ("Label/S", 11.0, "Medium"), + ("Label/M", 13.0, "Medium"), + ("Label/L", 15.0, "Medium"), + ] { + let style = TextStyle { font_size: size, font_style: weight.into(), ..TextStyle::default() }; + doc.styles.push(Style { id: format!("ds:text:{name}"), name: name.into(), description: String::new(), kind: StyleKind::Text { style } }); + } + (coll, light, dark) +} + +/// A component set named `name` at (`x`, `y`) whose variants `make` builds, one per combination +/// of `axes` (property, values), laid out in rows of `per_row`. +fn component_set( + doc: &mut Document, + page: NodeId, + name: &str, + (x, y): (f64, f64), + axes: &[(&str, Vec<&str>)], + per_row: usize, + mut make: impl FnMut(&mut Document, NodeId, &BTreeMap<&str, &str>) -> DocResult<()>, +) -> DocResult<(NodeId, usize)> { + let set = doc.alloc_id(); + let mut n = Node::new(set, NodeKind::ComponentSet { description: format!("Synthetic {name} component set") }, name, 100.0, 100.0); + n.transform = figcraft_geom::Affine::translate((x, y)); + n.fills.clear(); + doc.insert(n, page, None)?; + let mut combos: Vec> = vec![BTreeMap::new()]; + for (prop, values) in axes { + combos = combos + .into_iter() + .flat_map(|c| values.iter().map(move |v| (c.clone(), *v))) + .map(|(mut c, v)| { + c.insert(*prop, v); + c + }) + .collect(); + } + let order: Vec<&str> = axes.iter().map(|(p, _)| *p).collect(); + let (mut w, mut h) = (0.0f64, 0.0f64); + for (k, combo) in combos.iter().enumerate() { + let vname = order.iter().filter_map(|p| combo.get(p).map(|v| format!("{p}={v}"))).collect::>().join(", "); + let (cx, cy) = (20.0 + (k % per_row.max(1)) as f64 * 220.0, 20.0 + (k / per_row.max(1)) as f64 * 160.0); + let c = doc.create(set, component(), &vname, cx, cy, 100.0, 40.0)?; + if combo.get("Theme") == Some(&"Dark") { + set_node(doc, c, |n| { + n.var_modes.insert("ds:theme".into(), "ds:dark".into()); + }); + } + make(doc, c, combo)?; + w = w.max(cx + 220.0); + h = h.max(cy + 160.0); + } + set_node(doc, set, |n| { + n.width = w; + n.height = h; + }); + Ok((set, combos.len())) +} + +/// Build the design system on `page` of `doc` (call [`crate::normalize`] afterwards, or use +/// [`design_system_document`]). +pub fn design_system(doc: &mut Document, page: NodeId, spec: &DsSpec) -> DocResult { + let (theme, light, dark) = foundations(doc); + let mut count = 0; + // Icons: a 24×24 frame with one glyph whose colour follows the theme. + let mut icons = Vec::new(); + let cols = 16; + for k in 0..spec.icons.max(2) { + let name = format!("Icon/{} {}", GLYPHS.get(k % GLYPHS.len()).copied().unwrap_or("Glyph"), k / GLYPHS.len() + 1); + let (x, y) = ((k % cols) as f64 * 40.0, (k / cols) as f64 * 40.0); + let c = doc.create(page, component(), &name, x, y, 24.0, 24.0)?; + set_node(doc, c, |n| n.fills.clear()); + let kind = if k % 2 == 0 { NodeKind::Rectangle } else { NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 } }; + let g = doc.create(c, kind, "Glyph", 4.0, 4.0, 16.0, 16.0)?; + bind(doc, g, "fill", "Icon"); + icons.push(c); + count += 1; + } + let icon = |k: usize| icons.get(k % icons.len()).copied().unwrap_or(NodeId(0)); + let preferred: Vec = icons.iter().take(4).copied().collect(); + let top = ((spec.icons.max(2)).div_ceil(cols) as f64) * 40.0 + 80.0; + let sizes = take(&SIZES, spec.sizes); + let themes = take(&THEMES, spec.themes); + // Buttons: icon + label, the icon swappable (preferred: the first icons) and optional. + let (icon0, icon1) = (icon(0), icon(1)); + let mut button_props = BTreeMap::new(); + button_props.insert("Label".to_string(), PropDef::Text { default: "Button".into() }); + button_props.insert("Show icon".to_string(), PropDef::Boolean { default: true }); + button_props.insert("Icon".to_string(), PropDef::InstanceSwap { default: icon0, preferred: preferred.clone() }); + let axes = [("Size", sizes.clone()), ("State", take(&STATES, spec.states)), ("Type", take(&BUTTON_TYPES, spec.types)), ("Theme", themes.clone())]; + let (button, n) = component_set(doc, page, "Button", (0.0, top), &axes, 12, |doc, c, v| { + let size = v.get("Size").copied().unwrap_or("Medium"); + let pad = match size { + "Small" => 6.0, + "Large" => 14.0, + _ => 10.0, + }; + auto_layout(doc, c, LayoutMode::Horizontal, 6.0, pad); + match v.get("Type").copied() { + Some("Primary") | None => bind(doc, c, "fill", "Primary"), + Some("Secondary") => { + bind(doc, c, "fill", "Surface"); + set_node(doc, c, |n| n.strokes = vec![Paint::solid(rgb(0xD9D9D9))]); + bind(doc, c, "stroke", "Border"); + } + Some(_) => set_node(doc, c, |n| n.fills.clear()), + } + bind(doc, c, "cornerRadius", "Radius"); + if v.get("State") == Some(&"Disabled") { + set_node(doc, c, |n| n.opacity = 0.5); + } + let i = nested(doc, icon0, c, "Icon", true)?; + refs(doc, i, "visible", "Show icon"); + refs(doc, i, "mainComponent", "Icon"); + let style = match size { + "Small" => "ds:text:Label/S", + "Large" => "ds:text:Label/L", + _ => "ds:text:Label/M", + }; + let label = styled_text(doc, c, "Button", "Label", style)?; + refs(doc, label, "characters", "Label"); + bind(doc, label, "fill", if v.get("Type") == Some(&"Primary") { "On primary" } else { "On surface" }); + set_props(doc, c, &button_props); + Ok(()) + })?; + count += n; + let button_default = doc.node(button).and_then(|s| s.children.first().copied()).unwrap_or(NodeId(0)); + // Inputs: label above a field holding a swappable leading icon and the value. + let mut input_props = BTreeMap::new(); + input_props.insert("Label".to_string(), PropDef::Text { default: "Label".into() }); + input_props.insert("Placeholder".to_string(), PropDef::Text { default: "Placeholder".into() }); + input_props.insert("Show leading icon".to_string(), PropDef::Boolean { default: true }); + input_props.insert("Leading icon".to_string(), PropDef::InstanceSwap { default: icon1, preferred: preferred.clone() }); + let axes = + [("Size", sizes.clone()), ("State", take(&INPUT_STATES, spec.states)), ("Type", take(&INPUT_TYPES, spec.types)), ("Theme", themes.clone())]; + let y = top + (n.div_ceil(12) as f64) * 160.0 + 120.0; + let (input, n) = component_set(doc, page, "Input", (0.0, y), &axes, 12, |doc, c, v| { + auto_layout(doc, c, LayoutMode::Vertical, 4.0, 0.0); + set_node(doc, c, |n| n.fills.clear()); + let label = styled_text(doc, c, "Label", "Label", "ds:text:Label/S")?; + refs(doc, label, "characters", "Label"); + set_node(doc, label, |n| n.styles.fill = Some("ds:paint:Text/Secondary".into())); + let field = doc.create(c, NodeKind::Frame, "Field", 0.0, 0.0, 160.0, 32.0)?; + auto_layout(doc, field, LayoutMode::Horizontal, 6.0, 8.0); + bind(doc, field, "fill", "Surface"); + set_node(doc, field, |n| n.strokes = vec![Paint::solid(rgb(0xD9D9D9))]); + let stroke = match v.get("State").copied() { + Some("Focus") => "Primary", + Some("Error") => "Danger", + _ => "Border", + }; + bind(doc, field, "stroke", stroke); + bind(doc, field, "cornerRadius", "Radius"); + let i = nested(doc, icon1, field, "Leading icon", false)?; + refs(doc, i, "visible", "Show leading icon"); + refs(doc, i, "mainComponent", "Leading icon"); + let value = styled_text(doc, field, "Placeholder", "Value", "ds:text:Body/M")?; + refs(doc, value, "characters", "Placeholder"); + bind(doc, value, "fill", "On surface"); + if v.get("State") == Some(&"Disabled") { + set_node(doc, c, |n| n.opacity = 0.5); + } + set_props(doc, c, &input_props); + Ok(()) + })?; + count += n; + // Badges: a coloured pill with a text property. + let mut badge_props = BTreeMap::new(); + badge_props.insert("Text".to_string(), PropDef::Text { default: "New".into() }); + let axes = [("Type", take(&BADGE_TYPES, spec.types + 1)), ("Theme", themes.clone())]; + let y = y + (n.div_ceil(12) as f64) * 160.0 + 120.0; + let (badge, n) = component_set(doc, page, "Badge", (0.0, y), &axes, 12, |doc, c, v| { + auto_layout(doc, c, LayoutMode::Horizontal, 0.0, 4.0); + let var = match v.get("Type").copied() { + Some("Success") => "Success", + Some("Warning") => "Warning", + Some("Danger") => "Danger", + _ => "Primary", + }; + bind(doc, c, "fill", var); + set_node(doc, c, |n| n.corners.radii = [10.0; 4]); + let t = styled_text(doc, c, "New", "Text", "ds:text:Label/S")?; + refs(doc, t, "characters", "Text"); + bind(doc, t, "fill", "On primary"); + set_props(doc, c, &badge_props); + Ok(()) + })?; + count += n; + let badge_default = doc.node(badge).and_then(|s| s.children.first().copied()).unwrap_or(NodeId(0)); + // Cards: title, body, a slot for actions (a badge by default) and an exposed footer button. + let mut card_props = BTreeMap::new(); + card_props.insert("Title".to_string(), PropDef::Text { default: "Card title".into() }); + card_props.insert("Body".to_string(), PropDef::Text { default: "Supporting text".into() }); + card_props.insert("Actions".to_string(), PropDef::Slot { preferred: vec![button, badge] }); + let axes = [("Size", sizes.clone()), ("Type", CARD_TYPES.to_vec()), ("Theme", themes.clone())]; + let y = y + (n.div_ceil(12) as f64) * 160.0 + 120.0; + let (card, n) = component_set(doc, page, "Card", (0.0, y), &axes, 6, |doc, c, v| { + auto_layout(doc, c, LayoutMode::Vertical, 8.0, 12.0); + bind(doc, c, "fill", "Surface"); + bind(doc, c, "cornerRadius", "Radius"); + if v.get("Type") == Some(&"Outlined") { + set_node(doc, c, |n| n.strokes = vec![Paint::solid(rgb(0xD9D9D9))]); + bind(doc, c, "stroke", "Border"); + } + let title = + styled_text(doc, c, "Card title", "Title", if v.get("Size") == Some(&"Small") { "ds:text:Heading/M" } else { "ds:text:Heading/L" })?; + refs(doc, title, "characters", "Title"); + bind(doc, title, "fill", "On surface"); + let body = styled_text(doc, c, "Supporting text", "Body", "ds:text:Body/M")?; + refs(doc, body, "characters", "Body"); + set_node(doc, body, |n| n.styles.fill = Some("ds:paint:Text/Secondary".into())); + let actions = doc.create(c, NodeKind::Frame, "Actions", 0.0, 0.0, 120.0, 24.0)?; + auto_layout(doc, actions, LayoutMode::Horizontal, 4.0, 0.0); + set_node(doc, actions, |n| n.fills.clear()); + nested(doc, badge_default, actions, "Badge", false)?; + refs(doc, actions, crate::components::SLOT, "Actions"); + nested(doc, button_default, c, "Footer button", true)?; + set_props(doc, c, &card_props); + Ok(()) + })?; + count += n; + let card_small = doc.node(card).and_then(|s| s.children.first().copied()).unwrap_or(NodeId(0)); + let ghost = doc + .node(button) + .and_then(|s| { + s.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.name.contains("Type=Secondary") || n.name.contains("Type=Ghost"))) + }) + .unwrap_or(button_default); + // Navs: a logo icon, three item buttons and a profile card (card > button > icon: four deep). + let mut nav_props = BTreeMap::new(); + nav_props.insert("Show profile".to_string(), PropDef::Boolean { default: true }); + let axes = [("Type", NAV_TYPES.to_vec()), ("Theme", themes.clone())]; + let y = y + (n.div_ceil(6) as f64) * 160.0 + 160.0; + let (nav, n) = component_set(doc, page, "Nav", (0.0, y), &axes, 2, |doc, c, v| { + let mode = if v.get("Type") == Some(&"Side") { LayoutMode::Vertical } else { LayoutMode::Horizontal }; + auto_layout(doc, c, mode, 8.0, 8.0); + bind(doc, c, "fill", "Surface"); + nested(doc, icon(2), c, "Logo", false)?; + for k in 1..=3 { + nested(doc, ghost, c, &format!("Item {k}"), false)?; + } + let profile = nested(doc, card_small, c, "Profile", true)?; + refs(doc, profile, "visible", "Show profile"); + set_props(doc, c, &nav_props); + Ok(()) + })?; + count += n; + doc.link_layer_text_styles(); + Ok(DesignSystem { icons, button, input, card, nav, badge, components: count, theme, light, dark }) +} + +/// A new document holding the design system on its first page, normalised like an opened file. +pub fn design_system_document(spec: &DsSpec) -> DocResult<(Document, DesignSystem)> { + let mut doc = Document::new("Design system"); + let page = doc.first_page().ok_or(DocError::Invalid("new document has no page".into()))?; + set_node(&mut doc, page, |n| n.name = "Components".into()); + let ds = design_system(&mut doc, page, spec)?; + crate::normalize(&mut doc, &Document::new("")); + Ok((doc, ds)) +} + +/// The layer at name path `path` below `root` (each step a child's name). +pub fn find_path(doc: &Document, root: NodeId, path: &[&str]) -> Option { + let mut cur = root; + for step in path { + cur = doc.node(cur)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.name == *step))?; + } + Some(cur) +} + +/// The variant of set `set` named exactly `props` (in the set's property order), else its first. +fn variant(doc: &Document, set: NodeId, props: &[(&str, &str)]) -> NodeId { + let wanted: BTreeMap = props.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + crate::components::find_variant(doc, set, &wanted).or_else(|| doc.node(set).and_then(|s| s.children.first().copied())).unwrap_or(set) +} + +/// Override `group` on layer `id` of an instance as the app records it (a value bound to a variable +/// is detached from it first). +fn record(doc: &mut Document, id: Option, group: &str, f: impl FnOnce(&mut Node)) { + if let Some(id) = id { + set_node(doc, id, |n| { + if group == "fills" { + n.bound.retain(|k, _| k != "fill"); + } + f(n); + n.overrides.insert(group.to_string()); + }); + } +} + +fn instance_props(doc: &mut Document, id: Option, props: &[(&str, Value)], as_override: bool) { + if let Some(id) = id { + set_node(doc, id, |n| { + if let NodeKind::Instance { props: p, .. } = &mut n.kind { + for (k, v) in props { + p.insert(k.to_string(), v.clone()); + } + } + if as_override { + n.overrides.insert("props".into()); + } + }); + } +} + +fn swap_nested(doc: &mut Document, id: Option, to: NodeId) { + if let Some(id) = id { + set_node(doc, id, |n| { + if let NodeKind::Instance { component, .. } = &mut n.kind { + *component = to; + } + n.overrides.insert("component".into()); + }); + } +} + +fn hue(i: usize) -> Paint { + let h = (i * 37 % 360) as f32 / 360.0; + Paint::solid(Color::new(h, 0.4, 1.0 - h, 1.0)) +} + +/// Place `count` instances of the design system on new pages of `doc` ("Screens N", in frames of +/// 25), with overrides at every nesting level. Call [`crate::normalize`] afterwards. Returns the +/// top-level instances. +pub fn place_instances(doc: &mut Document, ds: &DesignSystem, count: usize) -> DocResult> { + let per_frame = 25; + let per_page = 500; + let icon = |k: usize| ds.icons.get(k % ds.icons.len().max(1)).copied().unwrap_or(NodeId(0)); + let mut out = Vec::with_capacity(count); + let mut page = NodeId(0); + let mut frame = NodeId(0); + for i in 0..count { + if i % per_page == 0 { + page = doc.add_page(&format!("Screens {}", i / per_page + 1)); + } + if i % per_frame == 0 { + let k = (i % per_page) / per_frame; + frame = doc.create( + page, + NodeKind::Frame, + &format!("Screen {}", i / per_frame + 1), + (k % 5) as f64 * 1400.0, + (k / 5) as f64 * 1000.0, + 1320.0, + 920.0, + )?; + if (i / per_frame) % 2 == 1 { + // Every other screen shows the dark theme. + set_node(doc, frame, |n| { + n.var_modes.insert(ds.theme.clone(), ds.dark.clone()); + }); + } + } + let slot = i % per_frame; + let (x, y) = (20.0 + (slot % 5) as f64 * 260.0, 20.0 + (slot / 5) as f64 * 180.0); + let size = SIZES.get(i % 3).copied().unwrap_or("Medium"); + let inst = match i % 10 { + // Buttons: variant, label and icon through properties; a swapped icon with a recoloured + // glyph (nested overrides); renamed now and then. + 0..=2 => { + let main = variant(doc, ds.button, &[("Size", size), ("Type", BUTTON_TYPES.get(i % 3).copied().unwrap_or("Primary"))]); + let inst = create_instance(doc, main, frame, x, y)?; + instance_props(doc, Some(inst), &[("Label", Value::from(format!("Action {i}"))), ("Show icon", Value::Bool(i % 5 != 0))], false); + if i % 2 == 0 { + instance_props(doc, Some(inst), &[("Icon", Value::from(icon(i).0))], false); + sync_instance(doc, inst); + } else { + sync_instance(doc, inst); + let nested = find_path(doc, inst, &["Icon"]); + swap_nested(doc, nested, icon(i + 3)); + sync_instance(doc, inst); + } + let glyph = find_path(doc, inst, &["Icon", "Glyph"]); + record(doc, glyph, "fills", |n| n.fills = vec![hue(i)]); + if i % 7 == 0 { + record(doc, Some(inst), "name", |n| n.name = format!("Button {i}")); + } + inst + } + // Inputs: label through a property, the value typed over, the leading icon swapped. + 3 | 4 => { + let main = variant(doc, ds.input, &[("Size", size), ("State", INPUT_STATES.get(i % 4).copied().unwrap_or("Default"))]); + let inst = create_instance(doc, main, frame, x, y)?; + instance_props(doc, Some(inst), &[("Label", Value::from(format!("Field {i}")))], false); + sync_instance(doc, inst); + let lead = find_path(doc, inst, &["Field", "Leading icon"]); + swap_nested(doc, lead, icon(i)); + sync_instance(doc, inst); + let value = find_path(doc, inst, &["Field", "Value"]); + record(doc, value, "characters", |n| { + if let NodeKind::Text(t) = &mut n.kind { + let len = t.char_count(); + t.replace_range(0, len, &format!("Value {i}")); + } + }); + inst + } + // Cards: title through a property; the footer button's label (nested property) and its + // icon (swapped, recoloured: four levels down); the background recoloured. + 5 | 6 => { + let main = variant(doc, ds.card, &[("Size", size), ("Type", CARD_TYPES.get(i % 2).copied().unwrap_or("Elevated"))]); + let inst = create_instance(doc, main, frame, x, y)?; + instance_props(doc, Some(inst), &[("Title", Value::from(format!("Card {i}")))], false); + sync_instance(doc, inst); + let footer = find_path(doc, inst, &["Footer button"]); + instance_props(doc, footer, &[("Label", Value::from(format!("Open {i}")))], true); + let ficon = find_path(doc, inst, &["Footer button", "Icon"]); + swap_nested(doc, ficon, icon(i + 1)); + sync_instance(doc, inst); + let glyph = find_path(doc, inst, &["Footer button", "Icon", "Glyph"]); + record(doc, glyph, "fills", |n| n.fills = vec![hue(i + 1)]); + record(doc, Some(inst), "fills", |n| n.fills = vec![hue(i)]); + inst + } + // Navs: an item switched to another type (nested property), the profile card's title + // and footer label typed over, its footer icon recoloured. + 7 => { + let main = variant(doc, ds.nav, &[("Type", NAV_TYPES.get(i % 2).copied().unwrap_or("Top"))]); + let inst = create_instance(doc, main, frame, x, y)?; + let item = find_path(doc, inst, &["Item 2"]); + instance_props(doc, item, &[("Label", Value::from("Settings"))], true); + sync_instance(doc, inst); + let title = find_path(doc, inst, &["Profile", "Title"]); + record(doc, title, "characters", |n| { + if let NodeKind::Text(t) = &mut n.kind { + let len = t.char_count(); + t.replace_range(0, len, &format!("Profile {i}")); + } + }); + let label = find_path(doc, inst, &["Profile", "Footer button", "Label"]); + record(doc, label, "characters", |n| { + if let NodeKind::Text(t) = &mut n.kind { + let len = t.char_count(); + t.replace_range(0, len, "View"); + } + }); + let glyph = find_path(doc, inst, &["Profile", "Footer button", "Icon", "Glyph"]); + record(doc, glyph, "fills", |n| n.fills = vec![hue(i)]); + inst + } + // Badges: text through a property, recoloured. + 8 => { + let main = variant(doc, ds.badge, &[("Type", BADGE_TYPES.get(i % 4).copied().unwrap_or("Info"))]); + let inst = create_instance(doc, main, frame, x, y)?; + instance_props(doc, Some(inst), &[("Text", Value::from(format!("{}", i % 100)))], false); + sync_instance(doc, inst); + record(doc, Some(inst), "fills", |n| n.fills = vec![hue(i)]); + inst + } + // Icons, recoloured. + _ => { + let inst = create_instance(doc, icon(i), frame, x, y)?; + let glyph = find_path(doc, inst, &["Glyph"]); + record(doc, glyph, "fills", |n| n.fills = vec![hue(i)]); + inst + } + }; + out.push(inst); + } + Ok(out) +} + +/// The design system and `count` instances of it in one document, normalised like an opened file. +pub fn design_system_with_instances(spec: &DsSpec, count: usize) -> DocResult<(Document, DesignSystem, Vec)> { + let mut doc = Document::new("Design system in use"); + let page = doc.first_page().ok_or(DocError::Invalid("new document has no page".into()))?; + set_node(&mut doc, page, |n| n.name = "Components".into()); + let ds = design_system(&mut doc, page, spec)?; + crate::normalize(&mut doc, &Document::new("")); + let instances = place_instances(&mut doc, &ds, count)?; + crate::normalize(&mut doc, &Document::new("")); + doc.current_page = doc.first_page(); + Ok((doc, ds, instances)) +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 1848be9..05e98e1 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -213,6 +213,8 @@ pub struct Overlay { pub drop_target: Option, /// Auto layout insertion indicator (x0, y0, x1, y1). pub insert_line: Option<[f64; 4]>, + /// The instance an Alt-drop of a component from the Assets panel would swap (highlighted). + pub swap_target: Option, /// Auto layout grid cell a drag would drop into (x0, y0, x1, y1). pub drop_cell: Option<[f64; 4]>, /// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 22b03f7..e7d756a 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -691,7 +691,7 @@ impl<'a> Ctx<'a> { self.warn("instance swap properties whose default component isn't in the file were skipped"); continue; }; - let def = PropDef::InstanceSwap { default: c }; + let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() }; if let Some(defs) = self.set_defs.get_mut(&owner) { defs.insert(name, def); } else if let Some(NodeKind::Component { props, .. }) = self.doc.node_mut(owner).map(|n| &mut n.kind) { diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 4239043..6a1aa0e 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -437,7 +437,7 @@ fn components_sets_instances_and_properties() { let NodeKind::Component { description, props } = &def.kind else { panic!() }; assert_eq!(description, "Primary button"); assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "OK".into() }), "{props:?}"); - assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: icon.id })); + assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: icon.id, preferred: Vec::new() })); assert!(!props.contains_key("State"), "variant properties come from the variant names"); let NodeKind::Component { props: hover_props, .. } = &named(&doc, "State=Hover").kind else { panic!() }; assert!(hover_props.contains_key("Label"), "variants share the set's definitions"); diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 3b158e5..cb23d46 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -1,8 +1,12 @@ //! The Assets tab: components as thumbnail tiles (search, click to place, drag onto the canvas to -//! drop an instance into the frame or auto layout slot under the pointer) and styles (click to -//! apply to the selection), for this file and for every enabled library, with a "Library updates -//! available" banner. The libraries view publishes this file as a library and enables library -//! files. +//! drop an instance into the frame or auto layout slot under the pointer, or with Alt held onto an +//! instance to swap it) and styles (click to apply to the selection), for this file and for every +//! enabled library, with a "Library updates available" banner. The libraries view publishes this +//! file as a library and enables library files. +//! +//! Thousands of assets stay smooth: only the tiles in view are laid out, and thumbnails render +//! when their tile first shows (a few per frame), are cached, and re-render only when their +//! component's layers change (compared by the document's shared layer snapshots). use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -18,9 +22,15 @@ use crate::icons::{self, Icon}; use crate::theme::{COMPONENT, Tokens, regular, semibold}; const TILE_LABEL: f32 = 22.0; +const TILE_GAP: f32 = 8.0; /// Re-check thumbnails at most this often (seconds) while the document changes. const THUMB_REFRESH: f64 = 0.4; -const MAX_THUMBS: usize = 200; +/// Thumbnails kept (those out of view go first beyond this). +const MAX_THUMBS: usize = 600; +/// Thumbnails rendered per frame (the rest follow on the next frames). +const THUMBS_PER_FRAME: usize = 8; +/// Layers compared per component when checking whether its thumbnail is stale. +const MAX_COMPARED: usize = 20_000; /// Look for changed library files this often (seconds) while the panel shows. const LIBRARY_REFRESH: f64 = 2.0; const PAD: f32 = 16.0; @@ -73,12 +83,13 @@ struct Listing { updates: usize, } -/// Rendered thumbnails keyed by source and component, with the fingerprint they were made from. +/// Rendered thumbnails keyed by source and component (None: it renders to nothing), and the +/// documents they were rendered from (this file's, each library's snapshot). #[derive(Clone, Default)] struct Thumbs { - map: HashMap<(Source, NodeId), (u64, Option)>, + map: HashMap<(Source, NodeId), Option>, + docs: HashMap>, checked_at: f64, - key: (u64, u64), } /// A component being dragged out of the panel. @@ -101,23 +112,25 @@ fn drag_id() -> Id { Id::new("asset_drag") } -/// Content fingerprint of a layer subtree (re-render its thumbnail when it changes). -fn fingerprint(doc: &Document, id: NodeId) -> u64 { - let mut h = std::collections::hash_map::DefaultHasher::new(); +/// Whether component `id` has the same layers in two versions of a document (layers an edit +/// didn't touch stay shared between the versions, so this compares pointers, not contents). +fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { let mut stack = vec![id]; - let mut n = 0; + let mut seen = 0; while let Some(i) = stack.pop() { - n += 1; - if n > 5000 { - break; + seen += 1; + if seen > MAX_COMPARED { + return false; } - let Some(node) = doc.node(i) else { continue }; - if let Ok(bytes) = serde_json::to_vec(node) { - bytes.hash(&mut h); + match (a.node_arc(i), b.node_arc(i)) { + (Some(x), Some(y)) if Arc::ptr_eq(x, y) => {} + _ => return false, + } + if let Some(n) = b.node(i) { + stack.extend(n.children.iter().copied()); } - stack.extend(node.children.iter().copied()); } - h.finish() + true } pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32) -> Option { @@ -307,16 +320,25 @@ fn hint(ui: &mut Ui, text: &str) { ui.painter().galley(pos2(r.min.x + PAD, r.min.y + 2.0), galley, t.text_secondary); } -/// Two-column thumbnail tiles. -fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, act: &mut Option) { +/// Two-column thumbnail tiles; only the rows in view are laid out. The tiles in (and next to) the +/// view go to `wanted` for their thumbnails. +fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, wanted: &mut Vec<(Source, NodeId)>, act: &mut Option) { let t = Tokens::get(ui.ctx()); - let gap = 8.0; - let tile = ((w - PAD * 2.0 - gap) / 2.0).max(40.0); - for pair in list.chunks(2) { - let (row, _) = ui.allocate_exact_size(vec2(w, tile + TILE_LABEL + gap), Sense::hover()); - for (k, item) in pair.iter().enumerate() { - let x = row.min.x + PAD + k as f32 * (tile + gap); - let r = Rect::from_min_size(pos2(x, row.min.y), vec2(tile, tile)); + let tile = ((w - PAD * 2.0 - TILE_GAP) / 2.0).max(40.0); + let row_h = tile + TILE_LABEL + TILE_GAP; + let rows = list.len().div_ceil(2); + let (all, _) = ui.allocate_exact_size(vec2(w, rows as f32 * row_h), Sense::hover()); + let clip = ui.clip_rect(); + let first = (((clip.min.y - all.min.y) / row_h).floor().max(0.0) as usize).min(rows); + let last = (((clip.max.y - all.min.y) / row_h).ceil().max(0.0) as usize).min(rows); + // Thumbnails for one row more on each side, so scrolling finds them ready. + let near = first.saturating_sub(1)..(last + 1).min(rows); + wanted.extend(list.iter().skip(near.start * 2).take((near.end - near.start) * 2).map(|i| (src.clone(), i.id))); + for row in first..last { + let y = all.min.y + row as f32 * row_h; + for (k, item) in list.iter().skip(row * 2).take(2).enumerate() { + let x = all.min.x + PAD + k as f32 * (tile + TILE_GAP); + let r = Rect::from_min_size(pos2(x, y), vec2(tile, tile)); let resp = ui.interact( r.union(Rect::from_min_size(r.left_bottom(), vec2(tile, TILE_LABEL))), Id::new(("asset", src, item.id.0)), @@ -326,7 +348,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, act: if resp.hovered() { ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, COMPONENT), egui::StrokeKind::Inside); } - match thumbs.map.get(&(src.clone(), item.id)).and_then(|(_, tex)| tex.as_ref()) { + match thumbs.map.get(&(src.clone(), item.id)).and_then(Option::as_ref) { Some(tex) => { let size = tex.size_vec2(); // Fit the tile, never past 1:1 physical pixels (small components stay crisp). @@ -344,7 +366,10 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, act: } let label = Rect::from_min_size(pos2(x, r.max.y + 2.0), vec2(tile, TILE_LABEL - 4.0)); crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.name, regular(11.0), t.text, tile); - let resp = resp.on_hover_text(format!("{}\nDrag onto the canvas, or click to place in the middle of the view", item.name)); + let resp = resp.on_hover_text(format!( + "{}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it.", + item.name + )); if resp.drag_started() { *act = Some(Act::Drag(AssetDrag { src: src.clone(), component: item.id, set: item.set })); } @@ -385,45 +410,56 @@ fn style_rows(ui: &mut Ui, w: f32, src: &Source, list: &[StyleRow], act: &mut Op } } -/// Render missing thumbnails now; re-check changed ones (throttled while the document changes). -fn update_thumbs(ui: &Ui, w: f32, doc: &Document, revision: u64, listing: &Listing, thumbs: &mut Thumbs) { - let mut live: Vec<(Source, NodeId, &Document)> = listing.local.iter().map(|t| (Source::Local, t.id, doc)).collect(); - for sec in &listing.libraries { - if let Some(d) = &sec.doc { - live.extend(sec.tiles.iter().map(|t| (Source::Library(sec.id.clone()), t.id, d.as_ref()))); - } - } - live.truncate(MAX_THUMBS); +/// Drop the thumbnails whose components changed since they were rendered: this file's are +/// re-checked at most every [`THUMB_REFRESH`] seconds while it changes, a library's all go when its +/// file is read again. +fn invalidate_thumbs(ui: &Ui, thumbs: &mut Thumbs, current: &[(Source, Arc)]) { let now = ui.input(|i| i.time); - let key = (revision, listing.key.2); - let changed = thumbs.key != key; - let due = changed && now - thumbs.checked_at >= THUMB_REFRESH; - let missing = live.iter().any(|(s, id, _)| !thumbs.map.contains_key(&(s.clone(), *id))); - if !(due || missing) { - if changed { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(THUMB_REFRESH)); - } - return; - } - let px = ((w - 40.0) / 2.0).max(32.0) * ui.ctx().pixels_per_point(); - if due { - let keep: HashSet<(Source, NodeId)> = live.iter().map(|(s, id, _)| (s.clone(), *id)).collect(); - thumbs.map.retain(|k, _| keep.contains(k)); - thumbs.checked_at = now; - thumbs.key = key; - } - for (src, id, d) in live { - let k = (src, id); - if !due && thumbs.map.contains_key(&k) { + for (src, doc) in current { + let Some(old) = thumbs.docs.get(src).cloned() else { + thumbs.docs.insert(src.clone(), doc.clone()); + continue; + }; + if Arc::ptr_eq(&old, doc) { continue; } - // Library snapshots only change when the file is read again. - let f = match &k.0 { - Source::Local => fingerprint(d, id), - Source::Library(_) => std::ptr::from_ref(d) as usize as u64, - }; - if thumbs.map.get(&k).is_none_or(|(old, _)| *old != f) { - thumbs.map.insert(k, (f, thumbnail(ui.ctx(), d, id, px))); + match src { + Source::Local if now - thumbs.checked_at < THUMB_REFRESH => { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(THUMB_REFRESH)); + continue; + } + Source::Local => { + thumbs.checked_at = now; + thumbs.map.retain(|(s, id), _| *s != Source::Local || same_layers(&old, doc, *id)); + } + Source::Library(_) => thumbs.map.retain(|(s, _), _| s != src), + } + thumbs.docs.insert(src.clone(), doc.clone()); + } +} + +/// Render the missing thumbnails of `wanted` (a few per frame; the rest follow), and keep the +/// cache bounded, dropping thumbnails out of view first. +fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId)]) { + let px = ((w - PAD * 2.0 - TILE_GAP) / 2.0).max(32.0) * ui.ctx().pixels_per_point(); + let mut rendered = 0; + for key in wanted { + if thumbs.map.contains_key(key) { + continue; + } + if rendered >= THUMBS_PER_FRAME { + ui.ctx().request_repaint(); + break; + } + let Some(doc) = thumbs.docs.get(&key.0).cloned() else { continue }; + thumbs.map.insert(key.clone(), thumbnail(ui.ctx(), &doc, key.1, px)); + rendered += 1; + } + if thumbs.map.len() > MAX_THUMBS { + let keep: HashSet<&(Source, NodeId)> = wanted.iter().collect(); + let extra: Vec<(Source, NodeId)> = thumbs.map.keys().filter(|k| !keep.contains(k)).take(thumbs.map.len() - MAX_THUMBS).cloned().collect(); + for k in extra { + thumbs.map.remove(&k); } } } @@ -453,7 +489,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { updates_banner(app, ui, w, listing.updates); } let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); - update_thumbs(ui, w, &doc, revision, &listing, &mut thumbs); + let mut sources: Vec<(Source, Arc)> = vec![(Source::Local, doc.clone())]; + sources.extend(listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (Source::Library(l.id.clone()), d)))); + invalidate_thumbs(ui, &mut thumbs, &sources); + let mut wanted = Vec::new(); let collapsed_id = Id::new("asset_collapsed_libraries"); let collapsed: HashSet = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default(); let mut act = None; @@ -467,7 +506,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { hint(ui, "No matching components"); } } - tiles(ui, w, &Source::Local, &listing.local, &thumbs, &mut act); + tiles(ui, w, &Source::Local, &listing.local, &thumbs, &mut wanted, &mut act); crate::widgets::separator(ui); ui.add_space(4.0); title(ui, w, "Local styles"); @@ -494,12 +533,13 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { continue; } let src = Source::Library(sec.id.clone()); - tiles(ui, w, &src, &sec.tiles, &thumbs, &mut act); + tiles(ui, w, &src, &sec.tiles, &thumbs, &mut wanted, &mut act); style_rows(ui, w, &src, &sec.styles, &mut act); } }); + render_thumbs(ui, w, &mut thumbs, &wanted); let drag = ui.data(|d| d.get_temp::(drag_id())); - let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).and_then(|(_, t)| t.clone())); + let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).cloned().flatten()); ui.data_mut(|d| d.insert_temp(Id::new("asset_thumbs"), thumbs)); match act { Some(Act::Drag(d)) => ui.data_mut(|m| { @@ -519,7 +559,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } None => {} } - drag_feedback(app, ui, &doc, tex); + let libs: HashMap> = listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (l.id.clone(), d))).collect(); + drag_feedback(app, ui, &doc, &libs, tex); } /// Place component `id` of `src` centred on page point (`x`, `y`). @@ -541,18 +582,45 @@ fn apply_style(app: &mut FigApp, src: &Source, style: &str) { }; } +/// Swap the instance at page point (`x`, `y`) to component `id` of `src`. +fn swap_at(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { + match src { + Source::Local => app.run("instance.swapAt", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => app.run("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), + }; +} + +/// The component set (by name) a dragged asset belongs to, in the document it comes from: the +/// instances an Alt-drop prefers to swap. A set's tile is its own family. +fn drag_family(doc: &Document, libs: &HashMap>, drag: &AssetDrag) -> Option { + let d = match &drag.src { + Source::Local => doc, + Source::Library(lib) => libs.get(lib)?.as_ref(), + }; + let n = d.node(drag.component)?; + if matches!(n.kind, NodeKind::ComponentSet { .. }) { + return Some(n.name.clone()); + } + figcraft_engine::drop::swap_family(d, drag.component) +} + /// While a component is dragged: a ghost at the pointer and, over the canvas, the frame or auto -/// layout slot it would drop into. Releasing over the canvas places the instance. -fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, tex: Option) { +/// layout slot it would drop into, or with Alt held the instance it would swap. Releasing over the +/// canvas places the instance (or swaps that one). +fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap>, tex: Option) { let Some(drag) = ui.data(|d| d.get_temp::(drag_id())) else { return }; - let (pointer, released) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.any_released())); + let (pointer, released, alt) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.any_released(), i.modifiers.alt)); let over_canvas = pointer.filter(|p| app.canvas.rect.contains(*p)); let page = app.session.ds().map(|d| d.page).ok(); - let slot = over_canvas.zip(page).map(|(p, page)| { - let pt = app.to_page(p); - (pt, figcraft_engine::drop::slot(doc, page, pt)) + let at = over_canvas.zip(page).map(|(p, page)| (app.to_page(p), page)); + // With Alt: the instance under the pointer (never one inside the dragged component itself). + let swap = at.filter(|_| alt).and_then(|(pt, page)| { + let own = matches!(drag.src, Source::Local).then_some(drag.component); + figcraft_engine::drop::swap_target(doc, page, pt, drag_family(doc, libs, &drag).as_deref(), own) }); + let slot = at.filter(|_| swap.is_none()).map(|(pt, page)| (pt, figcraft_engine::drop::slot(doc, page, pt))); let ov = &mut app.session.tool.overlay; + ov.swap_target = swap; ov.drop_target = slot.as_ref().and_then(|(_, s)| (Some(s.parent) != page).then_some(s.parent)); ov.insert_line = slot.as_ref().and_then(|(_, s)| s.line); if let Some(p) = pointer { @@ -563,8 +631,11 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, tex: Option swap_at(app, &drag.src, drag.component, pt.x, pt.y), + (None, _, Some((pt, _))) => place(app, &drag.src, drag.component, pt.x, pt.y), + _ => {} } } ui.ctx().request_repaint(); @@ -773,3 +844,12 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } } } + +/// Tests: the components whose thumbnails are cached (for this file's assets). +#[cfg(test)] +pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec { + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let mut out: Vec = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect(); + out.sort(); + out +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 930bf4e..3a1a629 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -508,6 +508,28 @@ fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[Nod } } +/// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in +/// the component colour, with a "Swap instance" label above its top-left corner. +fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId) { + let Some(q) = node_quad(app, doc, id) else { return }; + // White on the component colour, in both themes (like the canvas's selection labels). + let ink = Color32::WHITE; + painter.add(Shape::convex_polygon(q.to_vec(), COMPONENT.gamma_multiply(0.12), Stroke::NONE)); + quad(painter, q, stroke(COMPONENT, 2.0)); + let galley = painter.layout_no_wrap("Swap instance".to_string(), semibold(10.0), ink); + let size = vec2(galley.size().x + 26.0, 18.0); + let top = Rect::from_points(&q).min; + let pill = Rect::from_min_size(pos2(top.x, top.y - size.y - 4.0), size); + painter.rect_filled(pill, CornerRadius::same(4), COMPONENT); + crate::icons::paint( + painter, + Rect::from_center_size(pos2(pill.min.x + 11.0, pill.center().y), vec2(10.0, 10.0)), + crate::icons::Icon::Instance, + ink, + ); + painter.galley(pos2(pill.min.x + 20.0, pill.center().y - galley.size().y / 2.0), galley, ink); +} + /// The outline colour for a layer: purple for components, sets and instances and every layer in /// them, blue otherwise. fn outline_colour(doc: &Document, id: NodeId) -> Color32 { @@ -687,6 +709,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); + if let Some(t) = ov.swap_target { + swap_highlight(app, painter, &doc, t); + } if let Some([x0, y0, x1, y1]) = ov.insert_line { painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); } diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 84a5195..cd48644 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -64,8 +64,24 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) ui.add_space(8.0); } -/// Local components (and component sets, not their variants), for swap menus: (id, label). -fn local_components(doc: &Document) -> Vec<(NodeId, String)> { +/// Local components (and component sets, not their variants), for swap menus: (id, label), page +/// by page. Listed once per document revision (a file with thousands of layers isn't walked every +/// frame). +fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { + type Cached = ((u64, u64), std::sync::Arc>); + let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); + let id = Id::new("component_panel_local_components"); + if let Some((k, list)) = ui.data(|d| d.get_temp::(id)) + && k == key + { + return list; + } + let list = std::sync::Arc::new(list_components(doc)); + ui.data_mut(|d| d.insert_temp::(id, (key, list.clone()))); + list +} + +fn list_components(doc: &Document) -> Vec<(NodeId, String)> { let mut out: Vec<(NodeId, String)> = Vec::new(); for page in &doc.pages { for d in doc.subtree(*page) { @@ -112,7 +128,7 @@ fn type_icon(d: &PropDef) -> Icon { /// A selected slot inside an instance: its fill menu. fn slot_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, name: &str, preferred: &[NodeId]) { let t = Tokens::get(ui.ctx()); - let comps = local_components(doc); + let comps = local_components(app, ui, doc); widgets::separator(ui); row(ui, 36.0, |ui| { ui.label(egui::RichText::new("Slot").font(semibold(11.0)).color(t.text)); @@ -189,7 +205,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }; let defs = first.map(|c| prop_defs(doc, c)).unwrap_or_default(); let variants = if is_set { variant_options(doc, owner) } else { Vec::new() }; - let comps = local_components(doc); + let comps = local_components(app, ui, doc); widgets::separator(ui); let existing: Vec = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect(); let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into()); @@ -282,11 +298,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); } } - PropDef::InstanceSwap { default } => { + PropDef::InstanceSwap { default, .. } => { let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| "Missing".into()); let resp = widgets::dropdown(ui, &label, 84.0, None); menu_popup(&resp).show(|ui| { - for (c, name) in &comps { + for (c, name) in comps.iter() { if menu_item(ui, name, "") { app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); ui.close(); @@ -302,8 +318,13 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } }); }); - // A slot's preferred components, on a row of their own. - if let PropDef::Slot { preferred } = d { + // An instance swap or slot property's preferred components, on a row of their own. + if let PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. } = d { + let tip = if matches!(d, PropDef::Slot { .. }) { + "Offered first when filling the slot (any content is allowed)" + } else { + "Offered first when swapping the instance (any component is allowed)" + }; row(ui, 30.0, |ui| { ui.add_space(22.0); let label = match preferred.len() { @@ -312,7 +333,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { k => format!("{k} preferred components"), }; let w = (ui.available_width() - 8.0).max(60.0); - let resp = widgets::dropdown(ui, &label, w, None).on_hover_text("Offered first when filling the slot (any content is allowed)"); + let resp = widgets::dropdown(ui, &label, w, None).on_hover_text(tip); menu_popup(&resp).width(w.max(200.0)).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (c, name) in comps.iter().filter(|(c, _)| *c != owner && first != Some(*c)) { @@ -475,7 +496,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let missing = !doc.contains(*component); let set = variant_set(doc, *component); let comp_name = set.or(Some(*component)).and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| "Main component deleted".into()); - let comps = local_components(doc); + let comps = local_components(app, ui, doc); let nested = inside_instance(doc, id); widgets::separator(ui); row(ui, 36.0, |ui| { @@ -519,7 +540,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { menu_popup(&resp).width(w.max(200.0)).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { let mut page = None; - for (c, name) in &comps { + for (c, name) in comps.iter() { let pg = doc.page_of(*c); if pg != page { page = pg; @@ -639,17 +660,33 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } }); } - PropDef::InstanceSwap { .. } => { + PropDef::InstanceSwap { preferred, .. } => { let cur = figcraft_engine::params::id_of(&v); let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| "None".into()); let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); menu_popup(&resp).width(200.0).show(|ui| { + let t = Tokens::get(ui.ctx()); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { + let current = cur == Some(c); + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); + } + ui.close(); + } + }; + let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); + if !preferred.is_empty() { + heading(ui, "Preferred"); + for (c, name) in &preferred { + pick(ui, *c, name); + } + heading(ui, "All components"); + } egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (c, name) in comps { - if menu_item(ui, name, "") { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); - ui.close(); - } + pick(ui, *c, name); } }); }); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 837cde8..b13ac64 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,8 +9,10 @@ //! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}` //! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas //! - `ui.type {text}` — type into the text being edited -//! - `ui.input {events: [{move|down|up: [x, y], button?} | {key, shift?, alt?, cmd?} | {text} | {leave}]}` -//! — raw pointer and key events for the next frame, as if from the window (panels, popovers) +//! - `ui.input {events: [{move|down|up: [x, y], button?} | {key, shift?, alt?, cmd?} | {text} | {leave} +//! | {hold: {alt?, shift?, cmd?}}]}` — raw pointer and key events for the next frame, as if from the +//! window (panels, popovers); `hold` keeps modifier keys down for the frames that follow (all +//! false: released) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) //! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, preview?, presentScroll?, …}` //! - `ui.screenshot {path}` — PNG of the window (needs a visible window) @@ -219,6 +221,15 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul }); } else if b(e, "leave") { events.push(egui::Event::PointerGone); + } else if let Some(h) = e.get("hold") { + let held = egui::Modifiers { + alt: b(h, "alt"), + ctrl: b(h, "cmd") || b(h, "ctrl"), + shift: b(h, "shift"), + mac_cmd: false, + command: b(h, "cmd") || b(h, "ctrl"), + }; + events.push(egui::Event::ModifiersChanged(held)); } else { return Err(format!("unknown input event {e}")); } diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 3c56454..152cad0 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -24,8 +24,12 @@ struct Bench { impl Bench { fn new(layers: usize) -> Self { - figcraft_text::scan_system_fonts(); let (doc, _) = figcraft_engine::synthetic::large_document(layers).unwrap(); + Self::with_document(doc) + } + + fn with_document(doc: Document) -> Self { + figcraft_text::scan_system_fonts(); let mut b = Self { ctx: egui::Context::default(), app: FigApp::new(Session::with_document(doc), Box::new(NoServices)), time: 0.0 }; // Fonts install on the first frame; the next ones lay out and settle. for _ in 0..4 { @@ -106,6 +110,60 @@ fn report(layers: usize) { } } +/// The synthetic design system with `icons` icon components (plus its component sets) and +/// `instances` instances using it. +fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine::synthetic_ds::DesignSystem, Vec) { + let spec = figcraft_engine::synthetic_ds::DsSpec { icons, ..figcraft_engine::synthetic_ds::DsSpec::FULL }; + figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() +} + +fn wheel(d: f32) -> Event { + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default() } +} + +/// UI frames with a large design system: the Assets panel listing its components and the Design +/// panel of an instance with many properties. +fn report_components(icons: usize, instances: usize) { + let (doc, ds, inst) = design_system(icons, instances); + let assets = icons + ds.sets().len(); + let mut b = Bench::with_document(doc); + b.app.ui.left_tab = crate::LeftTab::Assets; + let mut rows: Vec<(&str, f64)> = Vec::new(); + rows.push(("Assets panel: first frame (thumbnails in view)", b.frame(vec![]))); + for _ in 0..20 { + b.frame(vec![]); + } + rows.push(("Assets panel: idle frame", b.mean(30, |_| vec![]))); + let panel = Pos2::new(150.0, 600.0); + rows.push(("Assets panel: scrolling", b.mean(120, |i| vec![Event::PointerMoved(panel), wheel(if i % 60 < 30 { -400.0 } else { 400.0 })]))); + // Edit one component: only its thumbnail renders again. + let glyph = b.app.session.doc().ok().and_then(|d| d.node(*ds.icons.first()?)?.children.first().copied()); + let _ = b.app.session.execute("node.set", &json!({"ids": [glyph], "props": {"opacity": 0.5}})); + rows.push(("Assets panel: frame after editing a component", b.mean(30, |_| vec![]))); + // An instance with many properties selected (a nav: variants, booleans, exposed instances), the + // layers panel showing. + b.app.ui.left_tab = crate::LeftTab::File; + let nav = inst.iter().copied().find(|i| { + b.app.session.doc().ok().and_then(|d| match &d.node(*i)?.kind { + figcraft_doc::NodeKind::Instance { component, .. } => figcraft_engine::components::variant_set(d, *component), + _ => None, + }) == Some(ds.nav) + }); + if let Some(p) = nav.and_then(|n| b.app.session.doc().ok()?.page_of(n)) { + let _ = b.app.session.execute("page.select", &json!({"id": p})); + } + b.app.session.set_selection(nav.into_iter().collect()); + b.frame(vec![]); + rows.push(("Design panel: an instance with many properties", b.mean(30, |_| vec![]))); + b.app.session.set_selection(vec![ds.button]); + b.frame(vec![]); + rows.push(("Design panel: a 72-variant component set", b.mean(30, |_| vec![]))); + println!("UI frame ms — {assets} assets, {instances} instances ({})", if cfg!(debug_assertions) { "debug" } else { "release" }); + for (name, v) in rows { + println!("| {name:<48} | {v:>8.2} |"); + } +} + #[test] #[ignore = "benchmark: cargo xtask bench"] fn perf_ui_frames() { @@ -116,6 +174,38 @@ fn perf_ui_frames() { for n in sizes { report(n); } + report_components(2000, 2000); +} + +#[test] +fn the_assets_panel_renders_thumbnails_in_view_and_only_changed_ones_again() { + let (doc, ds, _) = design_system(300, 20); + let mut b = Bench::with_document(doc); + b.app.ui.left_tab = crate::LeftTab::Assets; + for _ in 0..12 { + b.frame(vec![]); + } + let first = crate::assets_panel::cached_thumbnails(&b.ctx); + assert!(!first.is_empty(), "thumbnails in view render"); + assert!(first.len() < 60, "only the tiles in view (and a row around): {} of {}", first.len(), 300 + ds.sets().len()); + // Scrolling down renders the newly shown tiles. + let panel = Pos2::new(150.0, 600.0); + for _ in 0..20 { + b.frame(vec![Event::PointerMoved(panel), wheel(-600.0)]); + } + let scrolled = crate::assets_panel::cached_thumbnails(&b.ctx); + assert!(scrolled.iter().any(|id| !first.contains(id)), "tiles further down got thumbnails"); + // Editing one component drops just its thumbnail (after the refresh interval). + // (An icon no component set uses: those show it too.) + let icon = *scrolled.iter().find(|id| ds.icons.iter().skip(4).any(|i| i == *id)).unwrap(); + let glyph = b.app.session.doc().unwrap().node(icon).unwrap().children[0]; + b.app.session.execute("node.set", &json!({"ids": [glyph], "props": {"opacity": 0.5}})).unwrap(); + for _ in 0..40 { + b.frame(vec![]); + } + let after = crate::assets_panel::cached_thumbnails(&b.ctx); + let kept = scrolled.iter().filter(|id| **id != icon && after.contains(id)).count(); + assert_eq!(kept, scrolled.len() - 1, "unchanged thumbnails stay cached"); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b767809..c2d68ad 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -115,8 +115,11 @@ popovers and fields (`ui.pointer` and `ui.key` drive the canvas tools and shortc `{up: [x, y], button?}`, `{key, shift?, alt?, cmd?}` (pressed and released; egui key names such as `ArrowLeft`, `Escape`, `A`), `{text}` (typed text), `{scroll: [dx, dy], shift?, alt?, cmd?}` (a mouse wheel at the pointer: the content moves by that many points, so a negative `dy` scrolls down) -and `{leave: true}` (the pointer left the window); positions are window points. Events of one call arrive in the same frame, so send a press -and its release (or a double-click's two clicks) as separate calls. Returns `{queued}`. +`{leave: true}` (the pointer left the window) and `{hold: {alt?, shift?, cmd?}}` (keep those modifier +keys down for the frames that follow, e.g. Alt while dragging a component from the Assets panel onto +an instance to swap it; all false releases them); positions are window points. Events of one call +arrive in the same frame, so send a press and its release (or a double-click's two clicks) as +separate calls. Returns `{queued}`. ```json {"id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}} diff --git a/docs/mcp.md b/docs/mcp.md index c4b1430..c1a694b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -292,7 +292,9 @@ read library files return an error). - **Use**: `library.place {library, component, x?, y?}` (a component, set or variant by id or name; page point, default the view's centre) copies the component, with the components and styles it uses, into the file as a read-only main component and places an instance; `library.import - {library, component}` only copies it (then `instance.create`, `instance.swap`…). + {library, component}` only copies it (then `instance.create`, `instance.swap`…); + `library.swapAt {library, component, x, y}` copies it and swaps the instance at that page point to + it (as `instance.swapAt`, below). `library.applyStyle {library, style, ids?, target?}` copies a style in (linked, read-only) and applies it. Instances of library components behave like any instance: overrides, properties, variants, swap, reset, detach. Library components keep their variable bindings and explicit modes @@ -310,6 +312,24 @@ name and sets them this way); - **Update**: `library.updates` lists the components, styles, variable collections and variables with a newer published version; `library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides; bound layers, aliases and explicit modes follow the updated values. +- **Paste between files**: instances copied from another open file (`edit.copy` there, `edit.paste` + here) follow their mains: library components join (or bring along) this file's copies of the + same library, components the other file publishes as a library are used from it (and that + library is enabled when the other file is saved), and other mains stay behind: the instance shows + a missing main (`component.restoreMain` makes it a main here). + +### Swapping instances + +- `instance.swap {ids?, component}` shows another component in the instances (overrides on layers + with matching names stay; property values with the same names too). Nested instances can be + swapped on their own (an override of the instance they're in). +- `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under + it (innermost first), the first showing a variant of the same component set, else the innermost + (never one inside the dropped component). The Assets panel does this when a component is dropped + onto an instance with Alt (Option) held, highlighting that instance while dragging. +- Instance swap properties offer **preferred values**: `component.addProperty {type: + "instanceSwap", default, preferred? [component or set ids]}` and `component.editProperty {name, + preferred}`; `component.properties` lists them, and the Design panel's swap menus show them first. ### Slots diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 68d6d9c..cacb3af 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,7 +7,8 @@ //! - `version` print the version, or `version set X.Y.Z` //! - `icon` regenerate the app icon PNGs/ICO from code (original artwork) //! - `wasm` check the core crates and web app for wasm32 -//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) +//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and +//! with a synthetic design system used by 2,000 instances mod icon; mod layers;