M3: components at scale; Alt-drag to swap instances (#91)

* M3: components at scale; Alt-drag to swap instances

- Synthetic design system (engine `synthetic_ds`): ~300 components (icons; button, input,
  card, nav and badge sets of size x state x type x theme variants), boolean/text/instance
  swap/slot properties, instances nested four deep, text and paint styles, light/dark
  variables bound across it, and 2,000 instances with overrides at every level.
- Propagation: value, size and added/removed-layer edits of mains reach their copies
  directly; other edits rebuild each affected instance once, inner mains first; rebuilds keep
  unchanged layers shared; bound variables no longer touch unchanged layers; auto layout
  re-flows only where sizes or places changed.
- Fixes: values from variables (theme switches, bound radii) are no longer recorded as
  overrides; a detached variable on an instance layer is an override that survives main
  edits; exposed nested instances stay exposed; swapped nested instances keep their layer
  names and the ids/overrides of layers their own main adds; renamed nested instances keep
  their name when detached out.
- Instance swap properties have preferred values. `instance.swapAt` / `library.swapAt` and
  Alt-drop from the Assets panel onto an instance swap it (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 missing main).
- Assets panel lays out only visible tiles and renders thumbnails lazily with a cache
  invalidated per changed component; instance panel lists components once per revision.
- `figcraft-cli perf --instances`, UI frame bench and tests in components_scale_tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: the swap perf case drops on a glyph the hit test reaches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge origin/main into feat/components-scale; weighted depth 79%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 21:07:43 +07:00 committed by GitHub
parent fe550fc14a
commit 3c63e6d7c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 3014 additions and 286 deletions

View File

@ -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

View File

@ -23,7 +23,7 @@ USAGE:
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
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<ExitCode> {
mcp(a.value("attach").as_deref())
}
"perf" => {
let a = Args::parse(rest, &["layers"], &[])?;
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
perf::run(&sizes)
let instances: Vec<usize> = 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}`"))),
}

View File

@ -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<ExitCode> {
/// 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<ExitCode> {
// 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<ExitCode> {
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<ExitCode> {
std::env::consts::OS,
if cfg!(debug_assertions) { "debug build" } else { "release build" }
);
print_table(&table, &sizes.iter().map(|n| format!("{n} layers")).collect::<Vec<_>>());
if !instances.is_empty() {
println!();
print_table(&comps, &instances.iter().map(|n| format!("{n} inst.")).collect::<Vec<_>>());
}
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!("| {:<w$} |", "operation");
let mut rule = format!("|{}|", "-".repeat(w + 2));
for n in sizes {
head.push_str(&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:<w$} |");
for v in vals {
@ -272,7 +289,174 @@ pub fn run(sizes: &[usize]) -> CliResult<ExitCode> {
}
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<u64> =
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() };

View File

@ -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<Node>, 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))
}

View File

@ -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<NodeId>,
},
Variant {
options: Vec<String>,

View File

@ -161,6 +161,13 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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> {
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<Value> {
ok()
}
/// Swap the instance under a page point to a component (`instance.swapAt`).
pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
})
.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<Value> {
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}),
}

View File

@ -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)
}

View File

@ -64,6 +64,18 @@ pub fn specs() -> Vec<CommandSpec> {
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",

View File

@ -651,11 +651,19 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<NodeId>>) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
return;
}
let bound: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
let bound: Vec<NodeId> = 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);

View File

@ -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<Node>, 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<Node>)>) {
}
}
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<NodeId, std::sync::Arc<Node>>) {
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<NodeId, Option<Node>> = BTreeMap::new();
let mut by_path: BTreeMap<String, Node> = 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<NodeId, std::sync::Arc<Node>> = spare;
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = 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<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = 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<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let main_paths = path_map(doc, component, false);
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut olds: BTreeMap<NodeId, Node> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = 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<NodeId> = BTreeSet::new();
let mut skipped: BTreeSet<NodeId> = 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<NodeId, Vec<NodeId>> = 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<NodeId, std::sync::Arc<Node>> = 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<NodeId, std::sync::Arc<Node>> = 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<NodeId> {
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<Item = (&'a String, &'a String)> + '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<NodeId> = 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<NodeId> = Vec::new();
let mut covered: BTreeSet<NodeId> = 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<NodeId> = BTreeSet::new();
let mut frontier: Vec<NodeId> = 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<bool> = const { std::cell::Cell::new(false) };
/// Tests: edits carried to the copies without rebuilding instances.
pub(crate) static PATCHED: std::cell::Cell<usize> = 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<Vec<&'static str>> {
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<NodeId, Vec<NodeId>> {
let mut copies: BTreeMap<NodeId, Vec<NodeId>> = 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<Vec<NodeId>> {
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<NodeId> = 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<NodeId, NodeId> = 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<NodeId> {
let sub = doc.subtree(src);
let map: BTreeMap<NodeId, NodeId> = 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<NodeId>) {
if comps.is_empty() {
return;
}
// Outermost instances by the components their layers instantiate.
let mut roots_of: BTreeMap<NodeId, BTreeSet<NodeId>> = 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<NodeId, usize> = comps.iter().map(|c| (*c, 0)).collect();
let mut outer: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
let mut frontier: Vec<NodeId> = 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<NodeId> = 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<String> = 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::<Vec<_>>(),
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
let mut groups: Vec<String> = 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) {

View File

@ -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<NodeId>) {
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<NodeId, (Vec<String>, 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<NodeId> {
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<NodeId, NodeId> = 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<String> = 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::<Vec<_>>()));
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<NodeId> = 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<String>)> = 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>, 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>, 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<NodeId>) {
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>, 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>, 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);
}

View File

@ -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<String> {
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<NodeId>) -> Option<NodeId> {
let hit = doc.hit_test(page, p, 0.0)?;
let chain: Vec<NodeId> = 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())
}

View File

@ -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<figcraft_geom::Rect>,
pub source_doc: u64,
/// The document copied from (pastes into other files follow its mains) and its file path.
pub source: Option<Arc<Document>>,
pub source_path: Option<String>,
}
/// 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<String> = 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<NodeId> =
@ -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<NodeId> = 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<NodeId> = 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<Node>, bool)> = doc
.node(f)
.map(|n| std::iter::once(f).chain(n.children.iter().copied()).collect::<Vec<_>>())
.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<NodeId>) -> Option<BTreeSet<NodeId>> {
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<No
let ready = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev);
let mut changed: BTreeSet<NodeId> = 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;

View File

@ -68,8 +68,9 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet<NodeId> {
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<NodeId, NodeId>, 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<NodeId,
return Ok(());
}
if other.library_store().is_some() {
merge_components(doc, other, map)?;
merge_components(doc, other, map, None)?;
}
let styles = new_records(&doc.libraries.styles, &other.libraries.styles, |l| doc.styles.iter().any(|s| s.id == l));
let colls = new_records(&doc.libraries.collections, &other.libraries.collections, |l| doc.variables.collections.iter().any(|c| c.id == l));
@ -971,11 +973,17 @@ pub fn merge_store(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId,
Ok(())
}
/// The component copies of [`merge_store`].
fn merge_components(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId, NodeId>) -> 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<NodeId, NodeId>,
only: Option<&BTreeSet<NodeId>>,
) -> Result<BTreeMap<NodeId, NodeId>> {
let mut remote: BTreeMap<NodeId, NodeId> = 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<NodeId,
n.source = source;
}
}
Ok(remote)
}
/// When layers copied from another document `src` are pasted into `doc` (`map`: their ids → the
/// pasted ids), point the instances among them at mains of `doc`: library copies are shared with
/// (or brought along as) `doc`'s copies of the same library assets; components `src` publishes as
/// a library are used from that library (enabled when `src` is saved at `src_path`); other mains
/// stay behind, so those instances show a missing main (restorable). Links into `src` that can't
/// be followed never point at an unrelated layer of `doc`.
pub fn adopt_pasted(doc: &mut Document, src: &Document, src_path: Option<&str>, map: &BTreeMap<NodeId, NodeId>) -> 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<NodeId> = mains.iter().filter_map(|m| asset_root(src, *m)).collect::<BTreeSet<_>>().into_iter().collect();
let mut remote: BTreeMap<NodeId, NodeId> = 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<NodeId> = 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<NodeId> =
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<NodeId, NodeId> = 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) {

View File

@ -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<NodeId>,
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<NodeId> {
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<NodeId, NodeId>) -> 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<NodeId> {
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<NodeId> {
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<String, PropDef>) {
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<BTreeMap<&str, &str>> = 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::<Vec<_>>().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<DesignSystem> {
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<NodeId> = 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<NodeId> {
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<String, String> = 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<NodeId>, 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<NodeId>, 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<NodeId>, 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<Vec<NodeId>> {
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<NodeId>)> {
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))
}

View File

@ -213,6 +213,8 @@ pub struct Overlay {
pub drop_target: Option<NodeId>,
/// 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<NodeId>,
/// 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

View File

@ -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) {

View File

@ -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");

View File

@ -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<egui::TextureHandle>)>,
map: HashMap<(Source, NodeId), Option<egui::TextureHandle>>,
docs: HashMap<Source, Arc<Document>>,
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<egui::TextureHandle> {
@ -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<Act>) {
/// 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<Act>) {
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<Document>)]) {
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<Document>)> = 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<String> = 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::<AssetDrag>(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<String, Arc<Document>> = 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<String, Arc<Document>>, drag: &AssetDrag) -> Option<String> {
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<egui::TextureHandle>) {
/// 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<String, Arc<Document>>, tex: Option<egui::TextureHandle>) {
let Some(drag) = ui.data(|d| d.get_temp::<AssetDrag>(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<egui
let ov = &mut app.session.tool.overlay;
ov.drop_target = None;
ov.insert_line = None;
if let Some((pt, _)) = slot {
place(app, &drag.src, drag.component, pt.x, pt.y);
ov.swap_target = None;
match (swap, at, slot) {
(Some(_), Some((pt, _)), _) => 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<NodeId> {
let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default();
let mut out: Vec<NodeId> = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect();
out.sort();
out
}

View File

@ -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));
}

View File

@ -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<Vec<(NodeId, String)>> {
type Cached = ((u64, u64), std::sync::Arc<Vec<(NodeId, String)>>);
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::<Cached>(id))
&& k == key
{
return list;
}
let list = std::sync::Arc::new(list_components(doc));
ui.data_mut(|d| d.insert_temp::<Cached>(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<String> = 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);
}
});
});

View File

@ -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}"));
}

View File

@ -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<NodeId>) {
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]

View File

@ -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]}]}}

View File

@ -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

View File

@ -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;