diff --git a/Cargo.lock b/Cargo.lock index 3eae0bb..7b59389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1519,6 +1519,7 @@ dependencies = [ "figcraft-text", "image", "log", + "regex", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 3c42b15..56c8f9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ kurbo = { version = "0.13", features = ["serde"] } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } +regex = "1.13" log = "0.4" proptest = "1" imbl = { version = "7", features = ["serde"] } diff --git a/ROADMAP.md b/ROADMAP.md index 621d876..1373676 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -74,7 +74,7 @@ Figma's own languages on the landed localization layer (15–25 h). |---|---|---|---|---| | Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | | Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | -| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) | +| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | @@ -128,6 +128,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land + together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers + at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start + value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, + and hidden-from-publishing names that decide both ways. - **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per @@ -217,6 +222,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 664f7ef..2ba2f3c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -396,6 +396,17 @@ impl Document { self.pages.first().copied() } + /// Whether page `id` is a divider in the pages list: an empty page whose name starts with a + /// hyphen. Dividers separate pages; they aren't opened. + pub fn is_divider(&self, id: NodeId) -> bool { + self.node(id).is_some_and(|n| n.is_page() && n.children.is_empty() && n.name.starts_with('-')) + } + + /// The first page that isn't a divider. + pub fn first_open_page(&self) -> Option { + self.pages.iter().copied().find(|p| !self.is_divider(*p)) + } + /// Insert `node` (its id must come from [`alloc_id`](Self::alloc_id)) as a child of `parent` at /// `index` (None = on top). pub fn insert(&mut self, mut node: Node, parent: NodeId, index: Option) -> DocResult { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 26bb92c..f610c38 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } base64 = { workspace = true } +regex = { workspace = true } web-time = "1.1.0" [dev-dependencies] diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 3a7ef49..9dc7d5d 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -201,13 +201,19 @@ pub fn specs() -> Vec { let i = targets(s, p).first().copied().ok_or_else(|| bad("component.properties", "select a component or instance"))?; Ok(describe_props(s.doc()?, i)) }), - spec!("component.list", "List components", "{}", false, |s, _| { - let doc = s.doc()?; - Ok(Value::Array( + spec!( + "component.list", + "List components", + "{} → [{id, name, type, page, instances, folder (the Assets panel's folders: page, frames, slash names), label, library?}]", + false, + |s, _| { + let doc = s.doc()?; + Ok(Value::Array( doc.nodes() .filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) .map(|n| { - let mut v = json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "page": doc.page_of(n.id), "instances": instances_of(doc, n.id).len()}); + let (folder, label) = crate::library::asset_folder(doc, n.id); + let mut v = json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "page": doc.page_of(n.id), "instances": instances_of(doc, n.id).len(), "folder": folder, "label": label}); if let Some(lib) = crate::library::library_of(doc, n.id) { v["library"] = json!(lib); v["page"] = Value::Null; @@ -216,7 +222,8 @@ pub fn specs() -> Vec { }) .collect(), )) - }), + } + ), ] } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 22459f0..6224276 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -161,7 +161,7 @@ pub fn specs() -> Vec { spec!( "object.batchRename", "Batch rename", - "{ids?, find?, replace?, name? (pattern: $& = current name, $N or $n = number asc/desc)}", + "{ids?, find? (match; empty: none), regex? (find is a regular expression), replace? (for each match), name? (pattern for the whole name), start? (where $n starts and $N stops; default 1)} codes in replace and name: $& current name (or the match), $` and $' text before and after the match, $1…$9 groups, $n up, $N down, $nnn padded; layers are numbered in stacking order, bottom first", true, batch_rename ), @@ -926,27 +926,20 @@ fn hide_others(s: &mut Session, _p: &Value) -> Result { } fn batch_rename(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.batchRename", s, p)?; + let cmd = "object.batchRename"; + let ids = req_targets(cmd, s, p)?; let find = string(p, "find"); let replace = string(p, "replace").unwrap_or_default(); let pattern = string(p, "name"); + let rename = + crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) + .map_err(|e| bad(cmd, e))?; let doc = s.doc_mut()?; - let ids = z_sorted(doc, &ids); - let n = ids.len(); - for (k, i) in ids.iter().enumerate() { - let node = doc.get_mut(*i)?; - let mut name = node.name.clone(); - if let Some(f) = &find - && !f.is_empty() - { - name = name.replace(f.as_str(), &replace); - } - if let Some(pat) = &pattern { - name = pat.replace("$&", &name).replace("$N", &(k + 1).to_string()).replace("$n", &(n - k).to_string()); - } - node.name = name; + let names = rename.plan(doc, &ids); + for (i, name) in &names { + doc.get_mut(*i)?.name = name.clone(); } - ok() + Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } fn find_replace(s: &mut Session, p: &Value) -> Result { @@ -1052,8 +1045,13 @@ fn reparent(s: &mut Session, p: &Value) -> Result { let index = num(p, "index").map(|i| i.max(0.0) as usize); let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - for (k, i) in ids.iter().enumerate() { - doc.reparent(*i, parent, index.map(|x| x + k))?; + // The layers land together under the first sibling at or above `index` that stays put (none: on + // top), wherever they came from. + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied()); + for i in &ids { + // Counted with the layer still in place, as `reparent` expects. + let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); + doc.reparent(*i, parent, at)?; } ok() } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index af7a202..e33367e 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -7,12 +7,31 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{Session, bad, spec}; +/// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the +/// first) when its page was deleted or became a divider. +fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { + let doc = s.doc()?; + let Some(open) = doc.first_open_page() else { return Err(bad(cmd, "a file needs a page that isn't a divider")) }; + let page = s.page()?; + if !doc.pages.contains(&page) || doc.is_divider(page) { + let ds = s.ds_mut()?; + ds.page = open; + ds.selection.clear(); + ds.text_edit = None; + ds.zoom_to_fit(); + } + Ok(()) +} + pub fn specs() -> Vec { vec![ - spec!("page.add", "Add page", "{name?}", true, |s, p| { + spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { let doc = s.doc_mut()?; let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1)); let id = doc.add_page(&name); + if doc.is_divider(id) { + return Ok(json!({"id": id, "divider": true})); + } let ds = s.ds_mut()?; ds.page = id; ds.selection.clear(); @@ -31,6 +50,9 @@ pub fn specs() -> Vec { if !doc.pages.contains(&id) { return Err(bad("page.select", format!("{id} is not a page"))); } + if doc.is_divider(id) { + return Err(bad("page.select", "a divider isn't a page to open")); + } let ds = s.ds_mut()?; ds.page = id; ds.selection.clear(); @@ -38,30 +60,23 @@ pub fn specs() -> Vec { ds.zoom_to_fit(); ok() }), - spec!("page.rename", "Rename page", "{id?, name}", true, |s, p| { + spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| { let name = req_string("page.rename", p, "name")?; let id = id(p, "id").unwrap_or(s.page()?); s.doc_mut()?.get_mut(id)?.name = name; + settle(s, "page.rename")?; ok() }), spec!("page.delete", "Delete page", "{id?}", true, |s, p| { let id = id(p, "id").unwrap_or(s.page()?); - let doc = s.doc_mut()?; - doc.remove(id)?; - let first = doc.first_page(); - let ds = s.ds_mut()?; - if ds.page == id - && let Some(f) = first - { - ds.page = f; - ds.selection.clear(); - } + s.doc_mut()?.remove(id)?; + settle(s, "page.delete")?; ok() }), spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| { let src = id(p, "id").unwrap_or(s.page()?); let doc = s.doc_mut()?; - let name = format!("{} (Copy)", doc.get(src)?.name); + let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone() && let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind @@ -103,7 +118,7 @@ pub fn specs() -> Vec { doc.pages .iter() .filter_map(|p| doc.node(*p)) - .map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len()})) + .map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len(), "divider": doc.is_divider(n.id)})) .collect(), )) }), diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 522969c..ae4acc9 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -426,6 +426,9 @@ fn move_to_page(s: &mut Session, p: &Value) -> Result { if !doc.node(page).is_some_and(Node::is_page) { return Err(bad("object.moveToPage", format!("{page} is not a page"))); } + if doc.is_divider(page) { + return Err(bad("object.moveToPage", "a divider holds no layers")); + } for i in z_sorted(doc, &roots(doc, &ids)) { doc.reparent(i, page, None)?; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ee0f848..13b9ad7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod measure; pub mod params; pub mod plugins; pub mod proto_run; +pub mod rename; pub mod shape_handles; pub mod shapebuilder; pub mod snap; @@ -1331,6 +1332,8 @@ mod paint_reorder_tests; #[cfg(test)] mod paint_tests; #[cfg(test)] +mod panels_behaviour_tests; +#[cfg(test)] mod pdf_print_tests; #[cfg(test)] mod pixel_preview_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 3f0df86..52edd6f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -46,6 +46,18 @@ pub fn hidden_name(name: &str) -> bool { name.starts_with('.') || name.starts_with('_') } +/// Where the Assets panel files asset `id`: under its page, the frames, sections and groups +/// holding it, then the folders its name makes with slashes (`Buttons/Primary` is `Primary` in +/// `Buttons`). Returns the folders, outermost first, and the name shown on its tile. +pub fn asset_folder(doc: &Document, id: NodeId) -> (Vec, String) { + let Some(n) = doc.node(id) else { return (Vec::new(), String::new()) }; + let mut folders: Vec = doc.ancestors(id).iter().rev().filter_map(|a| doc.node(*a)).map(|a| a.name.trim().to_string()).collect(); + let mut parts: Vec = n.name.split('/').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect(); + let label = parts.pop().unwrap_or_else(|| n.name.clone()); + folders.extend(parts); + (folders, label) +} + /// The asset a layer belongs to: its outermost component or component set (itself included). pub fn asset_root(doc: &Document, id: NodeId) -> Option { let mut chain = vec![id]; @@ -179,25 +191,36 @@ pub fn publishable(doc: &Document) -> Selection { } /// What a publish offers when nothing is chosen: the assets offered last time (by `prev`, the -/// version published last) plus new ones, never assets named with a leading `.` or `_`. +/// version published last) plus new ones, never assets named with a leading `.` or `_`. The name +/// decides both ways: adding the prefix takes a published asset out, removing it brings the asset +/// back. pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Selection { let all = publishable(doc); - let pick = |offered: Option, name: Option<&str>| offered.unwrap_or_else(|| name.is_some_and(|n| !hidden_name(n))); + // `last`: whether it was offered last time, and under which name. + let pick = |last: Option<(bool, &str)>, name: Option<&str>| { + name.is_some_and(|n| !hidden_name(n)) + && match last { + Some((offered, was)) if !hidden_name(was) => offered, + _ => true, + } + }; Selection { components: all .components .into_iter() - .filter(|c| pick(prev.and_then(|m| m.component(*c)).map(|a| a.published), doc.node(*c).map(|n| n.name.as_str()))) + .filter(|c| pick(prev.and_then(|m| m.component(*c)).map(|a| (a.published, a.name.as_str())), doc.node(*c).map(|n| n.name.as_str()))) .collect(), styles: all .styles .into_iter() - .filter(|s| pick(prev.and_then(|m| m.style(s)).map(|x| x.published), doc.style(s).map(|x| x.name.as_str()))) + .filter(|s| pick(prev.and_then(|m| m.style(s)).map(|x| (x.published, x.name.as_str())), doc.style(s).map(|x| x.name.as_str()))) .collect(), collections: all .collections .into_iter() - .filter(|c| pick(prev.and_then(|m| m.collection(c)).map(|x| x.published), doc.variables.collection(c).map(|x| x.name.as_str()))) + .filter(|c| { + pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str())) + }) .collect(), } } diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs new file mode 100644 index 0000000..f280246 --- /dev/null +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -0,0 +1,388 @@ +//! Layers panel and Assets panel behaviour suite, written from the public documentation of design +//! tools: what the panels' gestures do to the document (reordering and moving layers, renaming +//! several at once, showing, hiding and locking, pages and dividers, the Assets panel's listing, +//! hidden names and drops). Each rule is a table row with a one-line derivation; where the +//! documentation is silent the row says so and pins today's behaviour. The gestures themselves +//! (clicks, Shift ranges, drags in the panel) are covered in the UI crate's +//! `layers_behaviour_tests`. + +// The tables read best as tuples. +#![allow(clippy::type_complexity)] + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn rect(s: &mut Session, name: &str, x: f64, y: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": 40, "height": 40, "props": {"name": name}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "shape.rectangle", p)) +} + +fn frame(s: &mut Session, name: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id(&ex(s, "frame.create", json!({"x": x, "y": y, "width": w, "height": h, "name": name}))) +} + +fn kids(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().children.clone() +} + +fn name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().name.clone() +} + +fn world(s: &Session, id: NodeId) -> [f64; 6] { + s.doc().unwrap().world_transform(id).as_coeffs() +} + +fn same(a: [f64; 6], b: [f64; 6]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn click(s: &mut Session, x: f64, y: f64) { + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +/// Frame `f` (0,0 400×300) holding `a`, `b`, `c`, `d` bottom to top; the group `g` of `e` on the +/// page; the auto layout frame `al` (500,0) of `x` and `y`; an instance `inst` (0,400) of a +/// component; a section `sec` (900,0). +#[allow(dead_code)] +struct Scene { + f: NodeId, + a: NodeId, + b: NodeId, + c: NodeId, + d: NodeId, + g: NodeId, + e: NodeId, + al: NodeId, + x: NodeId, + y: NodeId, + inst: NodeId, + sec: NodeId, +} + +fn scene(s: &mut Session) -> Scene { + let f = frame(s, "F", 0.0, 0.0, 400.0, 300.0); + let a = rect(s, "a", 10.0, 10.0, Some(f)); + let b = rect(s, "b", 60.0, 10.0, Some(f)); + let c = rect(s, "c", 110.0, 10.0, Some(f)); + let d = rect(s, "d", 160.0, 10.0, Some(f)); + let e = rect(s, "e", 0.0, 600.0, None); + let g = id(&ex(s, "object.group", json!({"ids": [e]}))); + let al = frame(s, "AL", 500.0, 0.0, 300.0, 100.0); + let x = rect(s, "x", 0.0, 0.0, Some(al)); + let y = rect(s, "y", 50.0, 0.0, Some(al)); + ex(s, "layout.add", json!({"ids": [al], "direction": "horizontal"})); + let main = frame(s, "Main", 0.0, 800.0, 100.0, 40.0); + rect(s, "Bg", 0.0, 0.0, Some(main)); + let comp = id(&ex(s, "component.create", json!({"ids": [main]}))); + let inst = id(&ex(s, "instance.create", json!({"component": comp, "x": 0, "y": 400}))); + let sec = id(&ex(s, "node.create", json!({"type": "section", "x": 900, "y": 0, "width": 300, "height": 300}))); + ex(s, "select.none", json!({})); + Scene { f, a, b, c, d, g, e, al, x, y, inst, sec } +} + +#[test] +fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() { + // The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the + // parent's children bottom first, as they are before the move. + // (moved, index in F, F's children after (bottom first), derivation) + let rows: [(&[&str], usize, [&str; 4], &str); 6] = [ + (&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + (&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"), + (&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"), + (&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), + (&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"), + (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"), + ]; + for (moved, index, after, why) in rows { + let mut s = Session::new(); + let k = scene(&mut s); + let by = |n: &str| match n { + "a" => k.a, + "b" => k.b, + "c" => k.c, + _ => k.d, + }; + let ids: Vec = moved.iter().map(|n| by(n)).collect(); + let before: Vec<[f64; 6]> = ids.iter().map(|i| world(&s, *i)).collect(); + ex(&mut s, "node.reparent", json!({"ids": ids, "parent": k.f, "index": index})); + let names: Vec = kids(&s, k.f).iter().map(|i| name(&s, *i)).collect(); + assert_eq!(names, after, "{why}"); + for (i, w) in ids.iter().zip(before) { + assert!(same(world(&s, *i), w), "{why}: reordering leaves the layers in place on the canvas"); + } + } +} + +#[test] +fn dragging_into_frames_groups_and_auto_layout() { + let mut s = Session::new(); + let k = scene(&mut s); + // Into a group: the layer keeps its place on the canvas (silent, pinned). + let w = world(&s, k.a); + ex(&mut s, "node.reparent", json!({"ids": [k.a], "parent": k.g})); + assert_eq!(kids(&s, k.g).last(), Some(&k.a), "a layer dropped on a group goes in it, on top"); + assert!(same(world(&s, k.a), w), "silent: a layer moved into a group keeps its place on the canvas (pinned)"); + // Out to the page. + let page = s.page().unwrap(); + ex(&mut s, "node.reparent", json!({"ids": [k.b], "parent": page})); + assert_eq!(s.doc().unwrap().node(k.b).unwrap().parent, Some(page), "a layer dragged out of a frame goes to the page"); + // Into auto layout: the layer joins the flow where it is dropped. + ex(&mut s, "node.reparent", json!({"ids": [k.c], "parent": k.al, "index": 0})); + assert_eq!(kids(&s, k.al).first(), Some(&k.c), "dropped first in an auto layout frame"); + let doc = s.doc().unwrap(); + let lx = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4]; + assert!(lx(k.c) < lx(k.x) && lx(k.x) < lx(k.y), "the auto layout frame places it first in its flow"); + // Refused drops. + let refused: [(NodeId, NodeId, &str); 3] = [ + (k.f, k.d, "a layer can't go into its own child"), + (k.d, k.inst, "an instance's layers come from its main: nothing can be dropped into it"), + (k.sec, k.f, "sections go on the page or in other sections, not in frames"), + ]; + for (what, into, why) in refused { + let before = s.doc().unwrap().clone(); + assert!(s.execute("node.reparent", &json!({"ids": [what], "parent": into})).is_err(), "{why}"); + assert_eq!(*s.doc().unwrap(), before, "{why}: nothing changed"); + } + // Silent (pinned): hidden and locked layers move like any other; a locked frame takes drops. + ex(&mut s, "object.toggleVisible", json!({"ids": [k.d], "visible": false})); + ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": true})); + ex(&mut s, "node.reparent", json!({"ids": [k.b, k.d], "parent": k.f})); + // `b` was above the frame on the page, so it stays above `d` (stacking order kept). + assert_eq!( + kids(&s, k.f).iter().rev().take(2).copied().collect::>(), + [k.b, k.d], + "silent: hidden layers move and locked frames take drops (pinned)" + ); +} + +#[test] +fn renaming_several_layers_by_the_documented_codes() { + // Three layers named `names`, bottom to top; (names, params, new names, derivation). + let rows: Vec<([&str; 3], Value, [&str; 3], &str)> = vec![ + (["A", "B", "C"], json!({"name": "Icon"}), ["Icon", "Icon", "Icon"], "a plain new name goes to every layer"), + (["A", "B", "C"], json!({"name": "Icon_$n"}), ["Icon_1", "Icon_2", "Icon_3"], "the up number counts up from layer to layer"), + (["A", "B", "C"], json!({"name": "Icon_$N"}), ["Icon_3", "Icon_2", "Icon_1"], "the down number counts down"), + (["A", "B", "C"], json!({"name": "Icon_$nnn"}), ["Icon_001", "Icon_002", "Icon_003"], "repeating the n pads the number (three digits)"), + (["A", "B", "C"], json!({"name": "$NNN"}), ["003", "002", "001"], "padding works counting down too"), + (["A", "B", "C"], json!({"name": "$n $&"}), ["1 A", "2 B", "3 C"], "the current name code keeps each name; the number can go first"), + (["A", "B", "C"], json!({"name": "$&-$n"}), ["A-1", "B-2", "C-3"], "or the number goes at the end"), + (["A", "B", "C"], json!({"name": "$n", "start": 5}), ["5", "6", "7"], "up numbers start from the value given"), + (["A", "B", "C"], json!({"name": "$N", "start": 0}), ["2", "1", "0"], "down numbers stop at the value given"), + ( + ["Old A", "Old B", "Old C"], + json!({"find": "Old", "replace": "New"}), + ["New A", "New B", "New C"], + "a match replaces only the matched part", + ), + (["A", "B", "C"], json!({"find": "zz", "replace": "y"}), ["A", "B", "C"], "layers without the match keep their names"), + ( + ["a_a", "b", "c"], + json!({"find": "a", "replace": "<$&>"}), + ["_", "b", "c"], + "every occurrence is replaced; the current name code is the matched text", + ), + ( + ["Icon_003", "Logo_7", "x"], + json!({"find": "([a-zA-Z]+)_(\\d+)", "regex": true, "replace": "$2_$1"}), + ["003_Icon", "7_Logo", "x"], + "a regular expression's groups swap parts of the name", + ), + ( + ["ab", "b", "c"], + json!({"find": "b", "regex": true, "replace": "[$`|$']"}), + ["a[a|]", "[|]", "c"], + "the before and after codes are the text around the match", + ), + (["A", "B", "C"], json!({"find": "B", "replace": "$n"}), ["A", "2", "C"], "numbers count every layer, matched or not"), + (["A", "B", "C"], json!({"name": "$$$n"}), ["$1", "$2", "$3"], "two dollar signs make one"), + ( + ["A", "B", "C"], + json!({"name": "$x $1"}), + ["$x $1", "$x $1", "$x $1"], + "unknown codes stay as typed (a group without a regular expression too)", + ), + ]; + for (names, mut params, after, why) in rows { + let mut s = Session::new(); + let ids: Vec = names.iter().enumerate().map(|(k, n)| rect(&mut s, n, k as f64 * 50.0, 0.0, None)).collect(); + // Given top first, as a range picked in the panel: the numbering follows the stacking + // order, bottom first (silent, pinned). + params["ids"] = json!(ids.iter().rev().collect::>()); + ex(&mut s, "object.batchRename", params); + let got: Vec = ids.iter().map(|i| name(&s, *i)).collect(); + assert_eq!(got, after, "{why}"); + } + // One undo step; a bad regular expression changes nothing. + let mut s = Session::new(); + let a = rect(&mut s, "A", 0.0, 0.0, None); + let steps = s.undo_depth().0; + ex(&mut s, "object.batchRename", json!({"ids": [a], "name": "$n"})); + assert_eq!(s.undo_depth().0, steps + 1, "a batch rename is one undo step"); + assert!( + s.execute("object.batchRename", &json!({"ids": [a], "find": "(", "regex": true, "replace": "x"})).is_err(), + "an invalid pattern is refused" + ); + assert_eq!(name(&s, a), "1"); + // Odd input stays bounded: a lone dollar sign, a very wide pad, a huge start, other scripts. + let r = |find: Option<&str>, to: &str, start: Option, name: &str| { + crate::rename::BatchRename::new(find, false, to, find.is_none().then_some(to), start).unwrap().apply(name, 0, 1) + }; + assert_eq!(r(None, "x$", None, "a"), "x$"); + assert_eq!(r(None, &format!("${}", "n".repeat(40)), None, "a"), format!("{:012}", 1)); + assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000"); + assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1"); + assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e"); +} + +#[test] +fn showing_hiding_and_locking_by_the_documented_rules() { + let mut s = Session::new(); + let k = scene(&mut s); + // Hidden layers can still be moved and changed. + ex(&mut s, "object.toggleVisible", json!({"ids": [k.a]})); + assert!(!s.doc().unwrap().node(k.a).unwrap().visible, "the eye hides a layer"); + ex(&mut s, "node.set", json!({"id": k.a, "props": {"opacity": 0.5}})); + ex(&mut s, "transform.move", json!({"ids": [k.a], "dx": 5, "dy": 0})); + let n = s.doc().unwrap().node(k.a).unwrap().clone(); + assert!((n.opacity - 0.5).abs() < 1e-6 && (n.transform.as_coeffs()[4] - 15.0).abs() < 1e-9, "a hidden layer can still be moved and changed"); + // Mixed visibility (silent, pinned): one toggle shows them all. + ex(&mut s, "object.toggleVisible", json!({"ids": [k.a, k.b]})); + let doc = s.doc().unwrap(); + assert!( + doc.node(k.a).unwrap().visible && doc.node(k.b).unwrap().visible, + "silent: toggling hidden and shown layers together shows them all (pinned)" + ); + // Locking a frame locks its layers on the canvas; a child can't be unlocked on its own. + ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": true})); + click(&mut s, 180.0, 30.0); + assert!(s.selection().is_empty(), "a locked frame's layers can't be picked on the canvas"); + ex(&mut s, "object.toggleLock", json!({"ids": [k.d], "locked": false})); + click(&mut s, 180.0, 30.0); + assert!(s.selection().is_empty(), "a layer stays locked while its parent is"); + ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": false})); + click(&mut s, 180.0, 30.0); + assert_eq!(s.selection(), [k.d], "unlocking the parent frees its layers"); + // Silent (pinned): locking deselects; the panel can still select a locked layer. + ex(&mut s, "object.toggleLock", json!({"ids": [k.d]})); + assert!(s.selection().is_empty(), "silent: locking a selected layer deselects it (pinned)"); + ex(&mut s, "select.set", json!({"ids": [k.d]})); + assert_eq!(s.selection(), [k.d], "silent: a locked layer can still be selected from the panel (pinned)"); +} + +fn pages(s: &mut Session) -> Vec { + ex(s, "page.list", json!({})).as_array().unwrap().clone() +} + +#[test] +fn pages_by_the_documented_rules() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + rect(&mut s, "r", 30.0, 40.0, None); + let p2 = id(&ex(&mut s, "page.add", json!({}))); + assert_eq!(s.page().unwrap(), p2, "silent: a new page opens (pinned)"); + assert_eq!(name(&s, p2), "Page 2", "silent: new pages are numbered (pinned)"); + ex(&mut s, "page.rename", json!({"id": p2, "name": "Flows"})); + assert_eq!(name(&s, p2), "Flows", "a page is renamed in place"); + ex(&mut s, "page.move", json!({"id": p2, "index": 0})); + assert_eq!(pages(&mut s)[0]["id"], json!(p2), "dragging a page up the list reorders the pages"); + // Duplicating: the copy is named "Copy of …", next to the source, with copies of its layers. + let dup = id(&ex(&mut s, "page.duplicate", json!({"id": p1}))); + assert_eq!(name(&s, dup), format!("Copy of {}", name(&s, p1)), "a duplicated page's name starts with Copy of"); + let ids: Vec = pages(&mut s).iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!(p2), json!(p1), json!(dup)], "silent: the copy comes right after its source (pinned)"); + assert_eq!(kids(&s, dup).len(), 1, "the copy has the page's layers"); + // Moving layers to another page keeps their coordinates. + let r = kids(&s, p1)[0]; + let w = world(&s, r); + ex(&mut s, "object.moveToPage", json!({"ids": [r], "page": p2})); + assert_eq!(s.doc().unwrap().page_of(r), Some(p2), "Move to page moves the layers"); + assert!(same(world(&s, r), w), "layers moved to a page land at the same coordinates"); + // Dividers: a blank page named with a leading hyphen. + ex(&mut s, "page.select", json!({"id": p2})); + let div = id(&ex(&mut s, "page.add", json!({"name": "-"}))); + let list = pages(&mut s); + let row = list.iter().find(|p| p["id"] == json!(div)).unwrap(); + assert_eq!(row["divider"], json!(true), "a blank page named with a hyphen first is a divider"); + assert_eq!(s.page().unwrap(), p2, "adding a divider leaves the canvas where it was"); + assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open"); + assert!(s.execute("object.moveToPage", &json!({"ids": [r], "page": div})).is_err(), "a divider holds no layers"); + ex(&mut s, "page.rename", json!({"id": p2, "name": "-Flows"})); + assert!(!s.doc().unwrap().is_divider(p2), "a page with content stays a page whatever its name"); + ex(&mut s, "page.rename", json!({"id": p2, "name": "Flows"})); + // Deleting. + let mut t = Session::new(); + let only = t.page().unwrap(); + assert!(t.execute("page.delete", &json!({"id": only})).is_err(), "silent: the only page can't be deleted (pinned)"); + let d = id(&ex(&mut t, "page.add", json!({"name": "- - -"}))); + assert!(t.execute("page.delete", &json!({"id": only})).is_err(), "the last page that isn't a divider can't be deleted"); + ex(&mut t, "page.delete", json!({"id": d})); + ex(&mut s, "page.delete", json!({"id": p2})); + assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); +} + +#[test] +fn assets_panel_lists_files_and_drops_by_the_documented_rules() { + let mut s = Session::new(); + let page = s.page().unwrap(); + ex(&mut s, "page.rename", json!({"name": "Kit"})); + let holder = frame(&mut s, "Controls", 0.0, 0.0, 600.0, 200.0); + let b = id(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 80, "height": 30, "name": "Buttons / Primary", "parent": holder}))); + let comp = id(&ex(&mut s, "component.create", json!({"ids": [b]}))); + let loose = frame(&mut s, "Badge", 800.0, 0.0, 40.0, 20.0); + let badge = id(&ex(&mut s, "component.create", json!({"ids": [loose]}))); + // (asset, folders, tile name, derivation) + let rows: [(NodeId, &[&str], &str, &str); 2] = [ + (comp, &["Kit", "Controls", "Buttons"], "Primary", "assets file under their page and frame, then a folder per slash in the name"), + (badge, &["Kit"], "Badge", "an asset on the page files under the page alone"), + ]; + let listed = ex(&mut s, "component.list", json!({})); + for (asset, folders, label, why) in rows { + let want = (folders.iter().map(|f| f.to_string()).collect::>(), label.to_string()); + assert_eq!(crate::library::asset_folder(s.doc().unwrap(), asset), want, "{why}"); + let row = listed.as_array().unwrap().iter().find(|r| r["id"] == json!(asset)).unwrap(); + assert_eq!((row["folder"].clone(), row["label"].clone()), (json!(folders), json!(label)), "{why} (as listed for agents)"); + } + assert!(crate::library::local_components(s.doc().unwrap()).contains(&comp), "local components are listed"); + // Hidden from publishing: a leading period (or underscore) in the name, both ways. + let rows: [(&str, bool, &str); 4] = [ + ("Badge", true, "a component is offered for publishing"), + (".Badge", false, "a leading period keeps a component out of the library, even one published before"), + ("_Badge", false, "silent beyond variable collections: a leading underscore does the same (pinned)"), + ("Badge", true, "removing the prefix brings it back"), + ]; + let first = crate::library::publish(s.doc().unwrap(), None, &crate::library::default_selection(s.doc().unwrap(), None), None, 1).unwrap(); + for (n, offered, why) in rows { + ex(&mut s, "object.rename", json!({"ids": [badge], "name": n})); + let sel = crate::library::default_selection(s.doc().unwrap(), Some(&first.manifest)); + assert_eq!(sel.components.contains(&badge), offered, "{why}"); + } + // Dropping an asset: into the frame under the pointer, into an auto layout flow by position. + let target = frame(&mut s, "Target", 0.0, 400.0, 300.0, 200.0); + let placed = id(&ex(&mut s, "instance.place", json!({"component": comp, "x": 100, "y": 500}))); + assert_eq!(s.doc().unwrap().node(placed).unwrap().parent, Some(target), "an asset dropped on a frame goes into it"); + let row = frame(&mut s, "Row", 400.0, 400.0, 400.0, 60.0); + rect(&mut s, "one", 0.0, 0.0, Some(row)); + rect(&mut s, "two", 50.0, 0.0, Some(row)); + ex(&mut s, "layout.add", json!({"ids": [row], "direction": "horizontal"})); + let first_x = s.doc().unwrap().world_transform(kids(&s, row)[0]).as_coeffs()[4]; + let into = id(&ex(&mut s, "instance.place", json!({"component": comp, "x": first_x + 1.0, "y": 420}))); + assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped"); + assert_eq!(s.doc().unwrap().page_of(into), Some(page)); +} diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs new file mode 100644 index 0000000..8d44f5a --- /dev/null +++ b/crates/engine/src/rename.rs @@ -0,0 +1,147 @@ +//! Renaming several layers at once (`object.batchRename`, the Rename layers dialog): a match +//! (plain text, or a regular expression) whose occurrences are replaced, and/or a pattern for the +//! whole name. Both understand the codes: `$&` the current name (the matched text when matching), +//! `` $` `` / `$'` the text before / after the match, `$1`…`$9` a regular expression's groups, +//! `$n` a number counting up and `$N` one counting down (`$nnn` pads it to three digits), `$$` a +//! dollar sign. + +use figcraft_doc::{Document, NodeId}; + +/// Longest match pattern accepted (characters). +const MAX_FIND: usize = 1_000; +/// Longest name a rename produces (characters). +const MAX_NAME: usize = 10_000; +/// Widest zero padding of a number code. +const MAX_PAD: usize = 12; + +enum Find { + Text(String), + Regex(regex::Regex), +} + +/// One batch rename's settings. +pub struct BatchRename { + find: Option, + replace: String, + pattern: Option, + /// Where `$n` starts and `$N` stops. + start: u64, +} + +/// What the codes of one replacement stand for. +struct Ctx<'a> { + matched: &'a str, + before: &'a str, + after: &'a str, + groups: Option<&'a regex::Captures<'a>>, + up: u64, + down: u64, +} + +impl BatchRename { + /// `find` (empty: none) as text or, with `regex`, a regular expression, replaced by `replace`; + /// then `pattern` for the whole name; numbers from `start` (default 1). + pub fn new(find: Option<&str>, regex: bool, replace: &str, pattern: Option<&str>, start: Option) -> Result { + let find = match find.filter(|f| !f.is_empty()) { + None => None, + Some(f) if f.chars().count() > MAX_FIND => return Err(format!("the match is longer than {MAX_FIND} characters")), + Some(f) if regex => { + Some(Find::Regex(regex::RegexBuilder::new(f).size_limit(1 << 20).build().map_err(|e| format!("invalid regular expression: {e}"))?)) + } + Some(f) => Some(Find::Text(f.to_string())), + }; + let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64); + Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start }) + } + + /// The new name of layer `k` of `n` (stacking order, bottom first), now `name`. + pub fn apply(&self, name: &str, k: usize, n: usize) -> String { + let up = self.start.saturating_add(k as u64); + let down = self.start.saturating_add(n.saturating_sub(k + 1) as u64); + let mut out = match &self.find { + None => name.to_string(), + Some(Find::Text(f)) => { + let mut out = String::new(); + let mut last = 0; + for (i, _) in name.match_indices(f.as_str()) { + let end = i + f.len(); + out.push_str(name.get(last..i).unwrap_or_default()); + let (before, after) = (name.get(..i).unwrap_or_default(), name.get(end..).unwrap_or_default()); + expand(&self.replace, &Ctx { matched: f, before, after, groups: None, up, down }, &mut out); + last = end; + } + out.push_str(name.get(last..).unwrap_or_default()); + out + } + Some(Find::Regex(re)) => { + let mut out = String::new(); + let mut last = 0; + for caps in re.captures_iter(name) { + let Some(m) = caps.get(0) else { continue }; + out.push_str(name.get(last..m.start()).unwrap_or_default()); + let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default()); + expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out); + last = m.end(); + } + out.push_str(name.get(last..).unwrap_or_default()); + out + } + }; + if let Some(p) = &self.pattern { + let mut whole = String::new(); + expand(p, &Ctx { matched: &out, before: "", after: "", groups: None, up, down }, &mut whole); + out = whole; + } + match out.char_indices().nth(MAX_NAME) { + Some((cut, _)) => out.get(..cut).unwrap_or_default().to_string(), + None => out, + } + } + + /// The new names of `ids` (taken in stacking order, bottom first). + pub fn plan(&self, doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, String)> { + let ids = crate::cmd::edit::z_sorted(doc, ids); + let n = ids.len(); + ids.iter().enumerate().filter_map(|(k, i)| doc.node(*i).map(|node| (*i, self.apply(&node.name, k, n)))).collect() + } +} + +/// Append `template` with its codes filled in from `c`. +fn expand(template: &str, c: &Ctx, out: &mut String) { + let mut it = template.chars().peekable(); + while let Some(ch) = it.next() { + if ch != '$' { + out.push(ch); + continue; + } + match it.peek().copied() { + Some('&') => out.push_str(c.matched), + Some('`') => out.push_str(c.before), + Some('\'') => out.push_str(c.after), + Some('$') => out.push('$'), + Some(d @ ('n' | 'N')) => { + let mut width = 0; + while it.peek() == Some(&d) { + it.next(); + width += 1; + } + let v = if d == 'n' { c.up } else { c.down }; + out.push_str(&format!("{v:0w$}", w = width.min(MAX_PAD))); + continue; + } + Some(d @ '1'..='9') => match c.groups.filter(|g| (d as usize - '0' as usize) < g.len()) { + Some(g) => out.push_str(g.get(d as usize - '0' as usize).map_or("", |m| m.as_str())), + // Not a group of this match: the code stays as typed. + None => { + out.push('$'); + out.push(d); + } + }, + _ => { + out.push('$'); + continue; + } + } + it.next(); + } +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 69e9b82..bb8622d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -299,6 +299,7 @@ name = "English" "Ctrl+arrows" = "Ctrl+arrows" "Ctrl+click" = "Ctrl+click" "Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Current name" "Current version" = "Current version" "Custom" = "Custom" "Cut" = "Cut" @@ -356,6 +357,7 @@ name = "English" "Distribute and tidy" = "Distribute and tidy" "Distribute horizontal spacing" = "Distribute horizontal spacing" "Distribute vertical spacing" = "Distribute vertical spacing" +"Divider" = "Divider" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -462,6 +464,8 @@ name = "English" "Flows" = "Flows" "Focus view" = "Focus view" "Fold autosaves" = "Fold autosaves" +"Folder: click to collapse" = "Folder: click to collapse" +"Folder: click to expand" = "Folder: click to expand" "Follow {name}" = "Follow {name}" "Following" = "Following" "Following {name} · {key} to stop" = "Following {name} · {key} to stop" @@ -649,6 +653,7 @@ name = "English" "Mark as unresolved" = "Mark as unresolved" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Mass · Stiffness · Damping" +"Match (empty: the whole name)" = "Match (empty: the whole name)" "Max H" = "Max H" "Max W" = "Max W" "Max lines" = "Max lines" @@ -763,6 +768,8 @@ name = "English" "Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)" "Nudge amount" = "Nudge amount" "Number" = "Number" +"Number ↑" = "Number ↑" +"Number ↓" = "Number ↓" "Numbered list" = "Numbered list" "Object" = "Object" "Off" = "Off" @@ -899,6 +906,7 @@ name = "English" "Red" = "Red" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" +"Regular expression" = "Regular expression" "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" @@ -924,8 +932,10 @@ name = "English" "Rename duplicated layers" = "Rename duplicated layers" "Rename folder" = "Rename folder" "Rename group" = "Rename group" +"Rename layers" = "Rename layers" "Rename mode" = "Rename mode" "Rename page" = "Rename page" +"Rename to" = "Rename to" "Replace all" = "Replace all" "Replace mixed export settings" = "Replace mixed export settings" "Replace with" = "Replace with" @@ -1085,6 +1095,7 @@ name = "English" "Star" = "Star" "Stars" = "Stars" "Start" = "Start" +"Start ascending from / stop descending at" = "Start ascending from / stop descending at" "Start from a blank canvas" = "Start from a blank canvas" "Start live session" = "Start live session" "Start on path" = "Start on path" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 68f7edc..cd9c015 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Ctrl+arrows" = "Ctrl+flechas" "Ctrl+click" = "Ctrl+clic" "Ctrl+scroll" = "Ctrl+desplazar" +"Current name" = "Nombre actual" "Current version" = "Versión actual" "Custom" = "Personalizado" "Cut" = "Cortar" @@ -357,6 +358,7 @@ status = "draft — needs native review" "Distribute and tidy" = "Distribuir y ordenar" "Distribute horizontal spacing" = "Distribuir espacio horizontal" "Distribute vertical spacing" = "Distribuir espacio vertical" +"Divider" = "Separador" "Don't truncate" = "No truncar" "Done" = "Listo" "Done editing" = "Terminar edición" @@ -463,6 +465,8 @@ status = "draft — needs native review" "Flows" = "Flujos" "Focus view" = "Vista de enfoque" "Fold autosaves" = "Plegar autoguardados" +"Folder: click to collapse" = "Carpeta: clic para contraer" +"Folder: click to expand" = "Carpeta: clic para expandir" "Follow {name}" = "Seguir a {name}" "Following" = "Siguiendo" "Following {name} · {key} to stop" = "Siguiendo a {name} · {key} para parar" @@ -650,6 +654,7 @@ status = "draft — needs native review" "Mark as unresolved" = "Marcar como no resuelto" "Mask" = "Máscara" "Mass · Stiffness · Damping" = "Masa · Rigidez · Amortiguación" +"Match (empty: the whole name)" = "Coincidencia (vacío: el nombre entero)" "Max H" = "Máx. H" "Max W" = "Máx. W" "Max lines" = "Máx. de líneas" @@ -764,6 +769,8 @@ status = "draft — needs native review" "Nudge (Shift: big nudge)" = "Desplazar (Mayús: desplazamiento grande)" "Nudge amount" = "Cantidad de desplazamiento" "Number" = "Número" +"Number ↑" = "Número ↑" +"Number ↓" = "Número ↓" "Numbered list" = "Lista numerada" "Object" = "Objeto" "Off" = "No" @@ -900,6 +907,7 @@ status = "draft — needs native review" "Red" = "Rojo" "Reduce motion" = "Reducir movimiento" "Refraction" = "Refracción" +"Regular expression" = "Expresión regular" "Remove" = "Quitar" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" @@ -925,8 +933,10 @@ status = "draft — needs native review" "Rename duplicated layers" = "Renombrar capas duplicadas" "Rename folder" = "Cambiar nombre de la carpeta" "Rename group" = "Cambiar nombre del grupo" +"Rename layers" = "Cambiar nombre de capas" "Rename mode" = "Cambiar nombre del modo" "Rename page" = "Cambiar nombre de página" +"Rename to" = "Nuevo nombre" "Replace all" = "Reemplazar todo" "Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" "Replace with" = "Reemplazar con" @@ -1086,6 +1096,7 @@ status = "draft — needs native review" "Star" = "Estrella" "Stars" = "Estrellas" "Start" = "Inicio" +"Start ascending from / stop descending at" = "Contar hacia arriba desde / hacia abajo hasta" "Start from a blank canvas" = "Empieza con un lienzo en blanco" "Start live session" = "Iniciar sesión en vivo" "Start on path" = "Inicio en el trazado" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index fd1b863..108ad29 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -1,9 +1,11 @@ -//! 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, 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 (to the file, or to its server for the team, with how other files use it), -//! enables library files and team libraries (by a share link), and swaps one library for another. +//! The Assets tab: components as thumbnail tiles filed in folders (their page, the frames holding +//! them, then the slashes in their names; a folder folds away), searched, clicked to place or +//! dragged onto the canvas to drop an instance into the frame or auto layout slot under the pointer +//! (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 (to the file, or to its server for the team, +//! with how other files use it), enables library files and team libraries (by a share link), and +//! swaps one library for another. //! //! 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 @@ -55,10 +57,46 @@ enum Source { #[derive(Clone)] struct Tile { id: NodeId, + /// The full name (the tooltip's). name: String, + /// The name on the tile: its last slash part. + label: String, set: bool, } +impl Tile { + /// Asset `id` of `doc` with its folder (the folders joined). + fn new(doc: &Document, id: NodeId, name: &str, set: bool) -> (String, Self) { + let (folders, label) = library::asset_folder(doc, id); + let label = if label.is_empty() { name.to_string() } else { label }; + (folders.join(" / "), Self { id, name: name.to_string(), label, set }) + } +} + +/// Tiles under one folder heading (an empty name: no heading). +#[derive(Clone)] +struct Folder { + /// Remembers whether the folder is folded away. + key: String, + name: String, + tiles: Vec, +} + +/// `tiles` (with their folders) sorted and gathered by folder, for the source keyed `src`. +fn foldered(src: &str, mut tiles: Vec<(String, Tile)>) -> Vec { + tiles.sort_by(|(fa, a), (fb, b)| { + fa.to_lowercase().cmp(&fb.to_lowercase()).then(a.label.to_lowercase().cmp(&b.label.to_lowercase())).then(a.id.cmp(&b.id)) + }); + let mut out: Vec = Vec::new(); + for (folder, t) in tiles { + match out.last_mut() { + Some(f) if f.name == folder => f.tiles.push(t), + _ => out.push(Folder { key: format!("{src}/{folder}"), name: folder, tiles: vec![t] }), + } + } + out +} + #[derive(Clone)] struct StyleRow { id: String, @@ -79,14 +117,14 @@ struct Section { name: String, doc: Option>, error: Option, - tiles: Vec, + tiles: Vec, styles: Vec, } /// What the panel lists; rebuilt only when the document, the search or a library changes. struct Listing { key: (u64, String, u64), - local: Vec, + local: Vec, local_styles: Vec, libraries: Vec
, /// Library components and styles in this file with a newer published version. @@ -159,11 +197,6 @@ fn matches(name: &str, q: &str) -> bool { q.is_empty() || name.to_lowercase().contains(q) } -fn sorted(mut tiles: Vec) -> Vec { - tiles.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then(a.id.cmp(&b.id))); - tiles -} - /// A fingerprint of what the session read for the enabled libraries. fn libraries_key(app: &FigApp, doc: &Document) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -181,8 +214,9 @@ fn libraries_key(app: &FigApp, doc: &Document) -> u64 { fn build_listing(app: &FigApp, doc: &Document, q: &str, key: (u64, String, u64)) -> Listing { let local = library::local_components(doc) .into_iter() - .filter_map(|id| doc.node(id).map(|n| Tile { id, name: n.name.clone(), set: matches!(n.kind, NodeKind::ComponentSet { .. }) })) - .filter(|t| matches(&t.name, q)) + .filter_map(|id| { + doc.node(id).filter(|n| matches(&n.name, q)).map(|n| Tile::new(doc, id, &n.name, matches!(n.kind, NodeKind::ComponentSet { .. }))) + }) .collect(); let local_styles = library::local_styles(doc).filter(|s| matches(&s.name, q)).map(StyleRow::of).collect(); let mut updates = 0; @@ -194,12 +228,10 @@ fn build_listing(app: &FigApp, doc: &Document, q: &str, key: (u64, String, u64)) updates += library::pending(doc, lib).count(); let m = &lib.manifest; sec.name = m.name.clone(); - sec.tiles = m - .components - .iter() - .filter(|a| a.published && matches(&a.name, q)) - .map(|a| Tile { id: a.id, name: a.name.clone(), set: a.set }) - .collect(); + sec.tiles = foldered( + &l.id, + m.components.iter().filter(|a| a.published && matches(&a.name, q)).map(|a| Tile::new(&lib.doc, a.id, &a.name, a.set)).collect(), + ); sec.styles = lib.doc.styles.iter().filter(|s| m.style(&s.id).is_some_and(|x| x.published) && matches(&s.name, q)).map(StyleRow::of).collect(); sec.doc = Some(lib.doc.clone()); @@ -210,11 +242,10 @@ fn build_listing(app: &FigApp, doc: &Document, q: &str, key: (u64, String, u64)) } // While searching, libraries without a match stay out of the way. if q.is_empty() || !sec.tiles.is_empty() || !sec.styles.is_empty() { - sec.tiles = sorted(sec.tiles); libraries.push(sec); } } - Listing { key, local: sorted(local), local_styles, libraries, updates } + Listing { key, local: foldered("", local), local_styles, libraries, updates } } /// Re-read enabled library files that changed on disk (throttled). @@ -309,21 +340,49 @@ fn title(ui: &mut Ui, w: f32, text: &str) { ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, text, semibold(11.0), t.text); } -/// A library's title row with its open/closed chevron; returns whether it was clicked. -fn library_title(ui: &mut Ui, w: f32, name: &str, open: bool) -> bool { +/// A library's (or, with `folder`, a folder's) title row with its open/closed chevron; returns +/// whether it was clicked. +fn fold_title(ui: &mut Ui, w: f32, name: &str, open: bool, folder: bool) -> bool { let t = Tokens::get(ui.ctx()); - let (hr, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); + let (hr, resp) = ui.allocate_exact_size(vec2(w, if folder { 26.0 } else { 32.0 }), Sense::click()); let chevron = Rect::from_center_size(pos2(hr.min.x + PAD + 5.0, hr.center().y), vec2(12.0, 12.0)); icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); - crate::bidi::paint_name( - ui.painter(), - pos2(chevron.max.x + 6.0, hr.center().y), - name, - semibold(11.0), - t.text, - hr.max.x - chevron.max.x - 6.0 - PAD, - ); - resp.on_hover_text(if open { tr("Library: click to collapse") } else { tr("Library: click to expand") }).clicked() + let (font, col) = if folder { (regular(11.0), t.text_secondary) } else { (semibold(11.0), t.text) }; + crate::bidi::paint_name(ui.painter(), pos2(chevron.max.x + 6.0, hr.center().y), name, font, col, hr.max.x - chevron.max.x - 6.0 - PAD); + let tip = match (folder, open) { + (false, true) => tr("Library: click to collapse"), + (false, false) => tr("Library: click to expand"), + (true, true) => tr("Folder: click to collapse"), + (true, false) => tr("Folder: click to expand"), + }; + resp.on_hover_text(tip).clicked() +} + +/// Folders of tiles under their headings; a folded folder shows only its heading (all open while +/// searching). +fn folders( + ui: &mut Ui, + w: f32, + src: &Source, + list: &[Folder], + searching: bool, + collapsed: &HashSet, + thumbs: &Thumbs, + wanted: &mut Vec<(Source, NodeId)>, + act: &mut Option, +) { + for f in list { + if !f.name.is_empty() { + let open = searching || !collapsed.contains(&f.key); + if fold_title(ui, w, &f.name, open, true) { + *act = Some(Act::Toggle(f.key.clone())); + } + if !open { + continue; + } + } + tiles(ui, w, src, &f.tiles, thumbs, wanted, act); + } } /// Secondary text, wrapped to the panel. @@ -380,7 +439,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want ), } 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); + crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.label, regular(11.0), t.text, tile); let resp = resp.on_hover_text(trf( "{name}\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.", &[("name", &item.name)], @@ -526,7 +585,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { hint(ui, tr("No matching components")); } } - tiles(ui, w, &Source::Local, &listing.local, &thumbs, &mut wanted, &mut act); + folders(ui, w, &Source::Local, &listing.local, !q.is_empty(), &collapsed, &thumbs, &mut wanted, &mut act); crate::widgets::separator(ui); ui.add_space(4.0); title(ui, w, tr("Local styles")); @@ -538,7 +597,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { crate::widgets::separator(ui); ui.add_space(4.0); let open = !q.is_empty() || !collapsed.contains(&sec.id); - if library_title(ui, w, &sec.name, open) { + if fold_title(ui, w, &sec.name, open, false) { act = Some(Act::Toggle(sec.id.clone())); } if !open { @@ -553,7 +612,7 @@ 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 wanted, &mut act); + folders(ui, w, &src, &sec.tiles, !q.is_empty(), &collapsed, &thumbs, &mut wanted, &mut act); style_rows(ui, w, &src, &sec.styles, &mut act); } }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 29253a6..ec15d70 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,9 @@ -//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, variables, export. +//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables, +//! export. use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; -use figcraft_doc::{ExportFormat, ExportSetting, ExportSize}; +use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; use serde_json::json; @@ -102,11 +103,18 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { /// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. pub fn keys(app: &mut FigApp, ctx: &Context) { let u = &mut app.ui; - let open = u.color_profile.is_some() || u.find.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel || u.preferences || u.about; + let open = u.color_profile.is_some() + || u.find.is_some() + || u.batch_rename.is_some() + || u.confirm_close + || u.export_preview + || u.shortcuts_panel + || u.preferences + || u.about; if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return; } - if u.color_profile.take().is_some() || u.find.take().is_some() { + if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { return; } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { @@ -254,6 +262,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } app.ui.find = if open { Some((find, replace)) } else { None }; } + batch_rename_dialog(app, ctx); crate::variables_panel::show(app, ctx); crate::dev_compare::show(app, ctx); crate::branch_ui::show(app, ctx); @@ -267,6 +276,116 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::windows::confirm_close(app, ctx); } +/// The Rename layers dialog's fields. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BatchRenameUi { + /// The layers, in stacking order (bottom first), as numbered. + pub ids: Vec, + /// Match: empty renames the whole name. + pub find: String, + pub regex: bool, + /// Rename to. + pub to: String, + /// Where up numbers start and down numbers stop. + pub start: u32, +} + +/// Layers whose new names the dialog previews. +const PREVIEW_ROWS: usize = 5; + +/// Open the Rename layers dialog for `ids` (several layers: Cmd/Ctrl+R, F2, the context menu). +pub(crate) fn open_batch_rename(app: &mut FigApp, ids: &[NodeId]) { + let Ok(doc) = app.session.doc() else { return }; + let ids = figcraft_engine::cmd::edit::z_sorted(doc, ids); + app.ui.batch_rename = Some(BatchRenameUi { ids, start: 1, ..Default::default() }); +} + +/// The rename the fields describe: a match replaced, or (no match) the whole name. +pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { + let mut p = json!({"ids": b.ids, "regex": b.regex, "start": b.start}); + if b.find.is_empty() { + p["name"] = json!(b.to); + } else { + p["find"] = json!(b.find); + p["replace"] = json!(b.to); + } + p +} + +/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers +/// start, a preview of the first new names, then one `object.batchRename`. +fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut b) = app.ui.batch_rename.clone() else { return }; + let t = Tokens::get(ctx); + let find = (!b.find.is_empty()).then_some(b.find.as_str()); + let rename = figcraft_engine::rename::BatchRename::new(find, b.regex, &b.to, find.is_none().then_some(b.to.as_str()), Some(f64::from(b.start))); + let n = b.ids.len(); + let ready = rename.is_ok() && !(b.find.is_empty() && b.to.is_empty()); + let preview: Vec<(String, String)> = match (&rename, app.session.doc()) { + (Ok(r), Ok(doc)) if ready => b + .ids + .iter() + .enumerate() + .take(PREVIEW_ROWS) + .filter_map(|(k, i)| doc.node(*i).map(|node| (node.name.clone(), r.apply(&node.name, k, n)))) + .collect(), + _ => Vec::new(), + }; + let mut open = true; + let mut cancel = false; + let mut apply = false; + window(tr("Rename layers"), ctx).id(egui::Id::new("Rename layers")).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); + ui.add_space(6.0); + ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) { + b.regex = !b.regex; + } + let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0)); + ui.horizontal(|ui| { + for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] { + if ui.button(label).clicked() { + b.to.push_str(code); + to.request_focus(); + } + } + }); + ui.horizontal(|ui| { + ui.label(tr("Start ascending from / stop descending at")); + ui.add(egui::DragValue::new(&mut b.start).range(0..=1_000_000)); + }); + ui.add_space(8.0); + match &rename { + Err(e) => { + ui.label(RichText::new(e).color(t.danger)); + } + Ok(_) => { + for (old, new) in &preview { + ui.horizontal(|ui| { + ui.label(RichText::new(old).color(t.text_secondary)); + ui.label("→"); + ui.label(new); + }); + } + if ready && n > PREVIEW_ROWS { + ui.label(RichText::new(trn("and {n} more", n - PREVIEW_ROWS, &[])).color(t.text_secondary)); + } + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + let enter = to.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + apply = (crate::widgets::button(ui, tr("Rename"), true, ready).clicked() || enter) && ready; + }); + }); + if apply { + app.run("object.batchRename", batch_rename_params(&b)); + } + app.ui.batch_rename = (open && !cancel && !apply).then_some(b); +} + /// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the /// values) or keep the values (reinterpret them in the new space). fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index d44e619..eec2595 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -203,7 +203,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { return; } let Ok(ds) = app.session.ds() else { return }; - let pages: Vec<(NodeId, String)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone()))).collect(); + let pages: Vec<(NodeId, String, bool)> = + ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); let cur = ds.page; let sel = ds.selection.clone(); let mut select = None; @@ -212,16 +213,24 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let dragging: Option = ui.data(|d| d.get_temp(layer_drag_id())); let pointer = ui.input(|i| i.pointer.hover_pos()); let mut move_to: Option<(NodeId, String)> = None; - for (id, name) in pages { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); - a11y::list_item(&resp, &name, id == cur); + // Pages are dragged up and down the list to reorder them. + let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); let r = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, r, &resp); - if resp.hovered() || id == cur { + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } if dragging.is_some() && id != cur + && !divider // The gap between rows counts too. && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) { @@ -230,18 +239,18 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { move_to = Some((id, name.clone())); } } - if app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id) { + if renaming { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) { rename_done = Some((id, new)); } - } else { + } else if !divider { let font = if id == cur { semibold(11.0) } else { regular(11.0) }; crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); } - if resp.clicked() { + if resp.clicked() && !divider { select = Some(id); } if resp.double_clicked() { @@ -263,6 +272,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } }); } + if let Some((from, to)) = reorder.finish(ui) + && let Some((id, ..)) = pages.get(from) + { + app.run("page.move", json!({"id": id, "index": to})); + } if let Some((id, new)) = rename_done { app.ui.rename_page = None; app.run("page.rename", json!({"id": id, "name": new})); @@ -286,6 +300,20 @@ fn layer_drag_id() -> Id { Id::new("layer_drag") } +/// A press on a row's eye or padlock dragged across other rows: every row passed gets the state the +/// first one switched to, in one step on release (temp data). +#[derive(Clone)] +struct Sweep { + lock: bool, + /// Hidden for the eye, locked for the padlock. + on: bool, + ids: Vec, +} + +fn sweep_id() -> Id { + Id::new("layer_toggle_sweep") +} + /// What a drag of row `dragging` moves: the selection when the row is in it, else the row. fn dragged_layers(dragging: Option, sel: &[NodeId]) -> Vec { match dragging { @@ -302,6 +330,8 @@ pub(crate) struct Row { has_kids: bool, /// Not shown on the canvas because of an ancestor (hidden, or a section hiding its contents). dimmed: bool, + /// Locked because an ancestor is. + locked_above: bool, } /// The layer rows of the last frame and what they were built from, so a frame where neither the @@ -340,7 +370,7 @@ pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BT fn listed_rows(doc: &Document, page: NodeId, expanded: &BTreeSet, filter: &str) -> Vec { let mut rows = Vec::new(); if filter.is_empty() { - collect(doc, page, 0, false, expanded, &mut rows); + collect(doc, page, 0, false, false, expanded, &mut rows); } else { matching(doc, page, filter, &mut rows); } @@ -352,16 +382,17 @@ fn hides_children(n: &figcraft_doc::Node, dimmed: bool) -> bool { dimmed || !n.visible || n.hides_contents() } -fn collect(doc: &Document, id: NodeId, depth: usize, dimmed: bool, expanded: &BTreeSet, out: &mut Vec) { +fn collect(doc: &Document, id: NodeId, depth: usize, dimmed: bool, locked: bool, expanded: &BTreeSet, out: &mut Vec) { let Some(n) = doc.node(id) else { return }; let dim_kids = !n.is_page() && hides_children(n, dimmed); + let lock_kids = locked || n.locked; // Layers panel lists top of the stack first. for c in n.children.iter().rev() { let Some(cn) = doc.node(*c) else { continue }; let has_kids = !cn.children.is_empty() || matches!(cn.kind, NodeKind::BooleanOp { .. }); - out.push(Row { id: *c, depth, has_kids, dimmed: dim_kids }); + out.push(Row { id: *c, depth, has_kids, dimmed: dim_kids, locked_above: lock_kids }); if expanded.contains(c) && depth < 64 { - collect(doc, *c, depth + 1, dim_kids, expanded, out); + collect(doc, *c, depth + 1, dim_kids, lock_kids, expanded, out); } } } @@ -373,8 +404,9 @@ fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec) { let Some(cn) = doc.node(*c) else { continue }; let name = if cn.name.is_empty() { cn.kind.label() } else { cn.name.as_str() }; if name.to_lowercase().contains(q) { - let dimmed = doc.ancestors(*c).iter().filter_map(|a| doc.node(*a)).any(|a| !a.is_page() && hides_children(a, false)); - out.push(Row { id: *c, depth: 0, has_kids: false, dimmed }); + let above: Vec<&figcraft_doc::Node> = doc.ancestors(*c).iter().filter_map(|a| doc.node(*a)).filter(|a| !a.is_page()).collect(); + let dimmed = above.iter().any(|a| hides_children(a, false)); + out.push(Row { id: *c, depth: 0, has_kids: false, dimmed, locked_above: above.iter().any(|a| a.locked) }); } if out.len() < 5000 { matching(doc, *c, q, out); @@ -470,6 +502,13 @@ pub(crate) fn start_rename(app: &mut FigApp, ids: &[NodeId]) { rename_layer(app, id); } +/// Collapse layers: every row closes except those leading to the selection. +pub(crate) fn collapse_all(app: &mut FigApp) { + app.ui.expanded.clear(); + let Ok(ds) = app.session.ds() else { return }; + app.ui.expanded.extend(ds.selection.iter().flat_map(|s| ds.doc.ancestors(*s)).filter(|a| *a != ds.page)); +} + /// Rename `id` in its row, selected (the selection follows a rename that moves with Tab). fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.rename = Some(Rename { id, start: true }); @@ -617,6 +656,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.data_mut(|d| d.remove::(drag_id)); } let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + let mut sweep: Option = ui.data(|d| d.get_temp(sweep_id())); let moving = dragged_layers(dragging, &sel); // A layer can't go into itself or its own descendants. let blocked = |id: NodeId| moving.contains(&id) || doc.ancestors(id).iter().any(|a| moving.contains(a)); @@ -684,11 +724,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); let rr = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, rr, &resp); + // Over the row, its eye and padlock included (they sit on top of it). + let over = resp.contains_pointer(); if selected { ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row }); } else if in_selected { ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child); - } else if resp.hovered() || hover == Some(row.id) { + } else if over || hover == Some(row.id) { ui.painter().rect_stroke( rr, CornerRadius::same(5), @@ -696,7 +738,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { egui::StrokeKind::Inside, ); } - if resp.hovered() { + if over { hovered_row = Some(row.id); } let x0 = row_x0(rr, row.depth); @@ -750,26 +792,43 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let max_w = (rr.max.x - 56.0 - name_x).max(20.0); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } - // Lock / eye on hover (or when set). - let show_lock = resp.hovered() || n.locked; - let show_eye = resp.hovered() || !n.visible; + // A sweep across the eyes or padlocks takes in the rows the pointer passes. + if let Some(sw) = sweep.as_mut() + && !sw.ids.contains(&row.id) + && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y)) + { + sw.ids.push(row.id); + } + let swept = |lock: bool| sweep.as_ref().filter(|sw| sw.lock == lock && sw.ids.contains(&row.id)).map(|sw| sw.on); + // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock. + let hidden = swept(false).unwrap_or(!n.visible); + let locked = swept(true).unwrap_or(n.locked); + let show_lock = over || locked || row.locked_above; + let show_eye = over || hidden; let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { - let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click()); - a11y::toggle(&eresp, tr("Hidden"), !n.visible); - icons::paint(ui.painter(), er.shrink(3.0), if n.visible { Icon::Eye } else { Icon::EyeOff }, t.icon_secondary); + let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); + a11y::toggle(&eresp, tr("Hidden"), hidden); + icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); if eresp.clicked() { vis = Some(row.id); } + if eresp.drag_started() { + sweep = Some(Sweep { lock: false, on: n.visible, ids: vec![row.id] }); + } } if show_lock { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click()); - a11y::toggle(&lresp, tr("Locked"), n.locked); - icons::paint(ui.painter(), lr.shrink(3.0), if n.locked { Icon::Lock } else { Icon::Unlock }, t.icon_secondary); + let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); + let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary }; + icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); if lresp.clicked() { lock = Some(row.id); } + if lresp.drag_started() { + sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] }); + } } if resp.clicked() { clicked = Some((row.id, ui.input(|i| i.modifiers))); @@ -859,6 +918,19 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let galley = crate::bidi::name_galley(ui.painter(), &label, regular(11.0), t.text, 240.0); crate::widgets::drag_ghost(ui.ctx(), p, galley); } + if let Some(sw) = sweep { + let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down())); + if released { + let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") }; + app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + } + // A sweep whose release the panel never saw (it wasn't shown) is dropped. + if released || !down { + ui.data_mut(|d| d.remove::(sweep_id())); + } else { + ui.data_mut(|d| d.insert_temp(sweep_id(), sw)); + } + } if ui.input(|i| i.pointer.any_released()) { ui.data_mut(|d| { d.remove::(drag_id); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs new file mode 100644 index 0000000..e1fdecc --- /dev/null +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -0,0 +1,228 @@ +//! Layers panel behaviour suite (the gestures), written from the public documentation of design +//! tools and driven through egui headless: clicking, Shift ranges and Cmd/Ctrl toggles, expanding +//! and collapsing, sweeping across the eyes and padlocks, renaming several layers, and the pages +//! list (dragging pages, dividers). Rows the documentation is silent on say so and pin today's +//! behaviour. What the gestures do to the document is in the engine's `panels_behaviour_tests`. + +use egui::{Event, Modifiers, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +const SHIFT: Modifiers = Modifiers { alt: false, ctrl: false, shift: true, mac_cmd: false, command: false }; +const CMD: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; +const ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: false, mac_cmd: false, command: false }; + +/// A click at `p` with `m` held, then a pause (so the next click is no double click). +fn click(u: &mut Ui, p: Pos2, m: Modifiers) { + let button = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: m }; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![button(true)], m); + u.frame_with(vec![button(false)], m); + for _ in 0..30 { + u.frame(vec![]); + } +} + +fn rect(s: &mut Session, name: &str, x: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 0, "width": 20, "height": 20, "props": {"name": name}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +/// Four rectangles `a`…`d` on the page (`d` on top: the panel's first row). +fn four() -> (Ui, [NodeId; 4]) { + let mut s = Session::new(); + let ids = ["a", "b", "c", "d"].map(|n| rect(&mut s, n, 0.0, None)); + s.execute("select.none", &json!({})).unwrap(); + (Ui::new(s), ids) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[test] +fn clicking_rows_selects_by_the_documented_rules() { + let (mut u, [a, b, c, d]) = four(); + let rows = [a, b, c, d].map(|i| u.row_at(i)); + let [ra, rb, rc, rd] = rows; + // (row, modifiers, selection after, derivation) + let steps: [(Pos2, Modifiers, Vec, &str); 5] = [ + (rc, Modifiers::NONE, vec![c], "a click on a layer's row selects it (on the canvas too)"), + (ra, SHIFT, vec![a, b, c], "Shift-click selects every row between the first and the last"), + (rd, CMD, vec![a, b, c, d], "Cmd/Ctrl-click adds a row that isn't next to the others"), + (rb, CMD, vec![a, c, d], "silent: Cmd/Ctrl-click on a selected row takes it out (pinned)"), + (rb, Modifiers::NONE, vec![b], "a plain click selects only that row"), + ]; + for (p, m, after, why) in steps { + click(&mut u, p, m); + assert_eq!(sorted(u.app.session.selection()), sorted(after), "{why}"); + } +} + +#[test] +fn expanding_collapsing_and_revealing() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200, "name": "F"})).unwrap()); + let inner = rect(&mut s, "inner", 10.0, Some(f)); + let g = id(s.execute("object.group", &json!({"ids": [inner]})).unwrap()); + let h = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "name": "H"})).unwrap()); + rect(&mut s, "h1", 10.0, Some(h)); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.clear(); + u.frame(vec![]); + // Alt-click on a disclosure arrow opens everything inside (silent, pinned); again closes it. + let row = u.row_at(f); + // The arrow sits at the row's left, after the panel's inset. + let arrow = Pos2::new(74.0, row.y); + click(&mut u, arrow, ALT); + assert!(u.app.ui.expanded.contains(&f) && u.app.ui.expanded.contains(&g), "silent: Alt-click on an arrow expands every layer inside (pinned)"); + click(&mut u, arrow, ALT); + assert!(!u.app.ui.expanded.contains(&f) && !u.app.ui.expanded.contains(&g), "silent: Alt-click again collapses them all (pinned)"); + // Selecting on the canvas opens the rows down to the layer (silent, pinned). + u.app.run("select.set", json!({"ids": [inner]})); + u.frame(vec![]); + assert!( + u.app.ui.expanded.contains(&f) && u.app.ui.expanded.contains(&g), + "silent: a layer selected on the canvas is revealed in the panel (pinned)" + ); + // Collapse layers: everything closes except the way to the selection. + u.app.ui.expanded.insert(h); + u.app.run("layers.collapseAll", json!({})); + u.frame(vec![]); + assert!(!u.app.ui.expanded.contains(&h), "Collapse layers closes the expanded layers"); + assert!(u.app.ui.expanded.contains(&f) && u.app.ui.expanded.contains(&g), "the selected layer's rows stay open"); + u.app.run("select.none", json!({})); + u.app.run("layers.collapseAll", json!({})); + u.frame(vec![]); + assert!(u.app.ui.expanded.is_empty(), "with nothing selected every layer collapses"); +} + +#[test] +fn sweeping_across_eyes_and_padlocks_sets_several_layers() { + let (mut u, [a, b, c, d]) = four(); + // The eye and padlock are at the row's right end (the left panel spans 56…296). + let (eye, padlock) = (272.0, 250.0); + let (rd, rb) = (u.row_at(d), u.row_at(b)); + let steps = u.app.session.undo_depth().0; + let from = Pos2::new(eye, rd.y); + u.frame(vec![Event::PointerMoved(from)]); + u.drag_to(from, Pos2::new(eye, rb.y)); + u.button(Pos2::new(eye, rb.y), false); + let doc = u.app.session.doc().unwrap(); + let hidden: Vec = [a, b, c, d].iter().map(|i| !doc.node(*i).unwrap().visible).collect(); + assert_eq!(hidden, [false, true, true, true], "dragging across the eyes hides every layer passed"); + assert_eq!(u.app.session.undo_depth().0, steps + 1, "silent: one undo step for the sweep (pinned)"); + for _ in 0..30 { + u.frame(vec![]); + } + let from = Pos2::new(padlock, rd.y); + u.frame(vec![Event::PointerMoved(from)]); + u.drag_to(from, Pos2::new(padlock, rb.y)); + u.button(Pos2::new(padlock, rb.y), false); + let doc = u.app.session.doc().unwrap(); + let locked: Vec = [a, b, c, d].iter().map(|i| doc.node(*i).unwrap().locked).collect(); + assert_eq!(locked, [false, true, true, true], "dragging across the padlocks locks every layer passed"); + // A plain click on an eye still toggles one layer. + for _ in 0..30 { + u.frame(vec![]); + } + let ra = u.row_at(a); + click(&mut u, Pos2::new(eye, ra.y), Modifiers::NONE); + assert!(!u.app.session.doc().unwrap().node(a).unwrap().visible, "a click on the eye hides that layer"); +} + +#[test] +fn renaming_several_layers_opens_the_rename_dialog() { + let (mut u, [a, b, c, _d]) = four(); + // Cmd/Ctrl+R (F2, the context menu's Rename) with several layers selected. + u.app.run("select.set", json!({"ids": [c, a, b]})); + u.app.run("object.rename", json!({})); + u.frame(vec![]); + let dialog = u.app.ui.batch_rename.clone().expect("the Rename layers dialog opens for several layers"); + assert_eq!(dialog.ids, [a, b, c], "silent: the layers are numbered in stacking order, bottom first (pinned)"); + assert_eq!(u.app.ui.rename, None, "no single row is renamed in place"); + // Match empty: Rename to renames the whole name; Number up counts up. + let mut d = dialog.clone(); + d.to = "Icon $n".into(); + u.app.ui.batch_rename = Some(d.clone()); + u.frame(vec![]); + u.app.run("object.batchRename", crate::dialogs::batch_rename_params(&d)); + let doc = u.app.session.doc().unwrap(); + let names: Vec<&str> = [a, b, c].iter().map(|i| doc.node(*i).unwrap().name.as_str()).collect(); + assert_eq!(names, ["Icon 1", "Icon 2", "Icon 3"], "Rename to with Number up"); + // With a match, only the matched part changes. + d.find = "Icon".into(); + d.to = "Logo".into(); + u.app.run("object.batchRename", crate::dialogs::batch_rename_params(&d)); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().name, "Logo 1", "a match replaces only that part"); + // Escape closes the dialog; one layer still renames in its row. + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.ui.batch_rename.is_none(), "Escape closes the dialog"); + u.app.run("select.set", json!({"ids": [a]})); + u.app.run("object.rename", json!({})); + u.frame(vec![]); + assert_eq!(u.app.ui.rename.map(|r| r.id), Some(a), "a single layer renames in its row"); + assert!(u.app.ui.batch_rename.is_none()); +} + +/// The middle of page `page`'s row in the pages list, found by clicking down the list from page +/// `back` (each click opens the page under it); `back` is open again afterwards. +fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + u.frame(vec![]); + for y in (90..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + u.app.ui.rename_page = None; + if u.app.session.page().unwrap() == page { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + for _ in 0..30 { + u.frame(vec![]); + } + // Rows are 32 high: aim at the middle. + return p + egui::vec2(0.0, 12.0); + } + } + panic!("no row for page {page:?}"); +} + +#[test] +fn pages_drag_to_reorder_and_dividers_open_nothing() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + let p3 = id(s.execute("page.add", &json!({})).unwrap()); + s.execute("page.select", &json!({"id": p1})).unwrap(); + let mut u = Ui::new(s); + let (r1, r3) = (page_row(&mut u, p1, p2), page_row(&mut u, p3, p2)); + u.drag_to(r3, r1 - egui::vec2(0.0, 10.0)); + u.button(r1 - egui::vec2(0.0, 10.0), false); + assert_eq!(u.app.session.doc().unwrap().pages, [p3, p1, p2], "a page dragged up the list moves there"); + // A divider between two pages: a line that opens nothing. + let div = id(u.app.session.execute("page.add", &json!({"name": "-"})).unwrap()); + u.app.session.execute("page.move", &json!({"id": div, "index": 1})).unwrap(); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + for _ in 0..30 { + u.frame(vec![]); + } + let (top, below) = (page_row(&mut u, p3, p2), page_row(&mut u, p1, p2)); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + u.frame(vec![]); + let mid = Pos2::new(top.x, (top.y + below.y) / 2.0); + click(&mut u, mid, Modifiers::NONE); + assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 990869e..1090068 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -30,7 +30,13 @@ impl Ui { /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { + self.frame_with(events, Default::default()) + } + + /// [`Self::frame_output`] with `modifiers` held. + pub(crate) fn frame_with(&mut self, mut events: Vec, modifiers: egui::Modifiers) -> egui::FullOutput { self.time += 1.0 / 60.0; + events.insert(0, Event::ModifiersChanged(modifiers)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; @@ -65,7 +71,7 @@ impl Ui { } /// Press on `from` and move to `to` in small steps (a drag, not yet released). - fn drag_to(&mut self, from: Pos2, to: Pos2) { + pub(crate) fn drag_to(&mut self, from: Pos2, to: Pos2) { self.button(from, true); for k in 1..=10 { self.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index f5ae7ae..652522d 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -121,12 +121,17 @@ fn cmd_r_renames_the_selection_and_opens_its_parents() { } #[test] -fn rename_without_a_name_picks_the_topmost_selected_row() { +fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { let (mut u, a, _, _, c) = setup(); let before = name(&u, a); - u.app.run("object.rename", json!({"ids": [a, c]})); - assert_eq!(renaming(&u), Some(c), "the topmost listed of the layers"); + u.app.run("object.rename", json!({"ids": [c]})); + assert_eq!(renaming(&u), Some(c), "one layer renames in its row"); assert_eq!(u.app.session.selection(), vec![c]); + u.app.ui.rename = None; + // Several layers: the Rename layers dialog instead (layers_behaviour_tests). + u.app.run("object.rename", json!({"ids": [a, c]})); + assert_eq!(renaming(&u), None); + assert!(u.app.ui.batch_rename.is_some(), "several layers open the Rename layers dialog"); assert_eq!(name(&u, a), before, "nothing renamed yet"); // Headless, the command changes nothing and leaves no undo step. let mut s = Session::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa6cce6..7ae5779 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,8 @@ mod frame_label_tests; #[cfg(test)] mod idle_tests; #[cfg(test)] +mod layers_behaviour_tests; +#[cfg(test)] mod layers_drag_tests; #[cfg(test)] mod layers_keyboard_tests; @@ -290,6 +292,8 @@ pub struct UiState { pub export_thumb: bool, pub zoom_menu: bool, pub find: Option<(String, String)>, + /// The Rename layers dialog (several layers), while open. + pub batch_rename: Option, pub left_collapsed: bool, pub comments: comments::CommentUi, pub versions: versions::VersionUi, @@ -373,6 +377,7 @@ impl Default for UiState { export_thumb: false, zoom_menu: false, find: None, + batch_rename: None, left_collapsed: false, comments: comments::CommentUi::default(), versions: versions::VersionUi::default(), @@ -572,13 +577,17 @@ impl FigApp { } "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), - "layers.collapseAll" => self.ui.expanded.clear(), + "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); - layers::start_rename(self, &ids); + if ids.len() > 1 { + dialogs::open_batch_rename(self, &ids); + } else { + layers::start_rename(self, &ids); + } } "comment.open" => { if let Some(id) = r.data.get("id").and_then(Value::as_u64) { diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/list_drag.rs index 466aa7e..7315503 100644 --- a/crates/ui-egui/src/list_drag.rs +++ b/crates/ui-egui/src/list_drag.rs @@ -1,9 +1,10 @@ -//! Drag to reorder the Design panel's lists (fills, strokes, effects, layout grids). A row is -//! dragged by its grip, the empty strip left of its fields (a handle shows over the row); while -//! dragging, a line shows where it will land and its label follows the pointer, as for layers -//! dragged in the layers panel; dropping moves it with one command (one undo step). The Design -//! panel shows the topmost item first while the model keeps lists bottom first; other lists (an -//! interaction's actions) show their items in model order. +//! Drag to reorder the Design panel's lists (fills, strokes, effects, layout grids) and the pages +//! list. A row is dragged by its grip, the empty strip left of its fields (a handle shows over the +//! row), a page by its whole row; while dragging, a line shows where it will land and its label +//! follows the pointer, as for layers dragged in the layers panel; dropping moves it with one +//! command (one undo step). The Design panel shows the topmost item first while the model keeps +//! lists bottom first; other lists (an interaction's actions, pages) show their items in model +//! order. use egui::{AsId, Id, Rect, Sense, Ui, pos2, vec2}; @@ -91,8 +92,14 @@ impl ListDrag { if resp.hovered() && self.dragging.is_none() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } + self.row_by(&resp, index, extent, label); + } + + /// Item `index` laid out over `extent`, dragged by the whole row (`resp` senses drags; the + /// pages list). + pub fn row_by(&mut self, resp: &egui::Response, index: usize, extent: Rect, label: impl FnOnce() -> String) { if resp.drag_started() { - ui.data_mut(|d| d.insert_temp(drag_id(self.list), index)); + resp.ctx.data_mut(|d| d.insert_temp(drag_id(self.list), index)); self.dragging = Some(index); } if self.dragging == Some(index) { diff --git a/docs/components-parity.md b/docs/components-parity.md index 4a81173..78ef2c3 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -25,7 +25,8 @@ accounts). | Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | | Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' | -| Assets panel: thumbnails, search, drag into frames and auto layout slots | done | virtualized | +| Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` | +| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); no Hide when publishing menu item yet | | Instances pasted into other files follow their mains | done | missing main restorable | | Component playground (try properties without placing) | missing | 1–2 h | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | @@ -75,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | | 2026-10-11 | minor | Styles and variables behaviour suite from the public documentation (18 tests) and its fixes (styled properties edited away unlink, deleted variables unbind with values kept, type-checked bindings, alias checks on create, text styles on text only); style folders, order and duplicates with a Local styles list; mode duplicate and default; code syntax. Found missing: variables inside styles. Styles and variables breadth ~92% → ~94%, ready ~72% → ~76%; remaining here 7–14 h → 9–17 h | | 2026-10-11 | minor | Behaviour suite from the public documentation and its fixes: overrides of padding, gap, alignment, layout grids, export settings, interactions and every text property kept (they were lost when the main changed); geometry, constraints and resizing of layers inside instances refused; variant switches keep only overrides both variants agreed on (text layers keep theirs); Alt-drops from Assets keep only text overrides; top-left default variant; hidden exposed instances hide their properties; push from nested layers goes to the outer main; variant property reordering and conflict warnings. Components ~95%/~68% → ~96%/~72%; remaining here 7–14 h → 5–11 h | diff --git a/docs/gaps.md b/docs/gaps.md index af61353..d6c058f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -93,8 +93,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe (e.g. Ctrl to ignore snapping on macOS), hover states, popovers, menus, snapping thresholds. - **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline - mode and measuring, then (2026-10-12) comments and version history - (`crates/engine/src/*_behaviour_tests.rs`), each with its fixes. + mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets + panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), + each with its fixes. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -323,6 +324,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | diff --git a/docs/mcp.md b/docs/mcp.md index 507832f..7a5462a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -245,7 +245,20 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless). - `object.rename {ids?}` without a `name` (`Cmd+R`, or `F2`): rename the layer inline in the app's layers panel, also from a layer row that has the keyboard; when the rename ends the row gets the - keyboard back (Escape cancels it there). Headless it changes nothing and leaves no undo step. + keyboard back (Escape cancels it there). With several layers it opens the Rename layers dialog. + Headless it changes nothing and leaves no undo step. +- `object.batchRename {ids?, find?, regex?, replace?, name?, start?}`: rename several layers in one + step. `find` (plain text, or a regular expression with `regex: true`) has each match replaced by + `replace`; `name` renames the whole name. Both take codes: `$&` the current name (the match when + matching), `` $` `` and `$'` the text before and after the match, `$1`…`$9` groups, `$n` a number + counting up from `start` (default 1), `$N` one counting down to it, `$nnn` padded to three digits, + `$$` a dollar sign. Layers are numbered in stacking order, bottom first. Returns `{names}`. +- Pages: `page.add {name?}` (a name starting with `-` adds a divider: an empty page shown as a line + in the pages list, never opened and holding no layers), `page.list` (each page's `divider`), + `page.rename`, `page.move {id, index}`, `page.duplicate` ("Copy of …", after its source), + `page.delete` (the last page that isn't a divider stays). +- `component.list`: each component's `folder` (the Assets panel's folders: its page, the frames + holding it, then its name's slash parts) and `label` (the name on its tile). - `view.contextMenu` (`Shift+F10`): the selection's context menu, at its layer row when the layers panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the menu takes the keyboard and gives it back when it closes (no-op headless). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 433d10a..56cb279 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -96,7 +96,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | -| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 | +| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | @@ -612,6 +612,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a082c34..d10e233 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -27,6 +27,20 @@ interaction feel not yet compared. | Multiplayer avatars, Share button | missing | waits for multiplayer | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | +## Layers and Assets panels + +| Item | Status | Notes | +|---|---|---| +| Rows: click, Shift-click range, Cmd/Ctrl-click toggle; the selection follows on the canvas and a canvas selection is revealed | done | `layers_behaviour_tests` | +| Expand and collapse: Alt-click opens or closes everything inside; Collapse layers (Alt+L) keeps the way to the selection open | done | Alt-click pinned (the documentation is silent) | +| Drag to reorder and into frames, groups and auto layout: insertion line, spring-loaded open, several layers together, Escape cancels | done | several layers dropped together land together (they split apart before); instances and a layer's own children refuse drops | +| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) | +| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned | +| Find layers (filter by name) | done | not covered by the documentation | +| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | | +| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | | +| Hide when publishing from the Assets panel's context menu | missing | a leading `.` or `_` in the name only | + ## Selection and transform | Item | Status | Notes | @@ -104,6 +118,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes: several layers dropped together land together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers in one step, the toggles stay under the pointer, Rename layers dialog for several layers, pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite and fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame; a guide can be selected, deleted, nudged and removed from the context menu, Alt-dragged into a copy, and measured from with Alt while dragging; Auto column and row counts; layout grids hidden on rotated frames; outline mode clicks reach layers behind. Outline mode back to partial (two documented options missing) | | 2026-10-11 | minor | Shapes and vector behaviour suite and fixes: corner radius and star ratio handles on the canvas, Count and Ratio fields, Alt draws lines from the centre, paint bucket and cut drags, delete and heal along the curve, rounded vector points drawn, Shift-click deselects a point at a box corner, the miter field shows degrees, individual corners only for rectangles and frames |