From f5a52ea528e3e597e753f458d4fde92422b4583c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 21:20:56 -0500 Subject: [PATCH] Components: several layers selected, the component button offers Create multiple components (#2088) With two or more layers selected, the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Headless UI tests cover both cases (three frames: menu order, names kept, all selected, one undo; one frame: plain click), and an engine test pins createMultiple's names, positions and single undo step. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- .../engine/src/components_behaviour_tests.rs | 33 +++++ .../ui-egui/src/create_multiple_menu_tests.rs | 115 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/right.rs | 13 ++ docs/components-parity.md | 5 +- 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/create_multiple_menu_tests.rs diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 3458b0b22..198151768 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use crate::Session; +use crate::automation::params::id_of; use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node}; use figcraft_doc::*; @@ -1000,3 +1001,35 @@ fn deleting_a_layer_inside_an_instance_hides_it_instead() { s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap(); assert!(s.doc().unwrap().node(k.inst).is_none()); } + +/// Create multiple components (#2088): each selected layer becomes its own component in place, +/// keeping its name and position; all stay selected and one undo reverts them all. +#[test] +fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() { + let mut s = Session::new(); + let mut frames = Vec::new(); + for (i, name) in ["Card", "Badge", "Tile"].into_iter().enumerate() { + let x = 150.0 * i as f64; + let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap(); + let f = id_of(&v["id"]).unwrap(); + s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap(); + frames.push(f); + } + let before = undo_len(&s); + let out = s.execute("component.createMultiple", &json!({"ids": frames})).unwrap(); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + let made: Vec = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect(); + assert_eq!(made.len(), 3); + assert_eq!(s.selection(), made, "all of them stay selected"); + let doc = s.doc().unwrap(); + for (i, (c, name)) in made.iter().zip(["Card", "Badge", "Tile"]).enumerate() { + let n = doc.node(*c).unwrap(); + assert!(matches!(n.kind, NodeKind::Component { .. }), "{name} is a component"); + assert_eq!(n.name, name); + let b = doc.world_bounds(*c).unwrap(); + assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "{name} stays in place"); + } + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames"); +} diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs new file mode 100644 index 000000000..a8c2b3a56 --- /dev/null +++ b/crates/ui-egui/src/create_multiple_menu_tests.rs @@ -0,0 +1,115 @@ +//! With two or more layers selected the Design panel's component button opens a menu (Create +//! component, Create multiple components); one layer keeps the plain click (#2088): drawn headless. + +use egui::accesskit::Node; +use egui::{Event, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +const NAMES: [&str; 3] = ["Card", "Badge", "Tile"]; + +fn id(v: serde_json::Value) -> NodeId { + serde_json::from_value(v["id"].clone()).unwrap() +} + +/// Frames named `NAMES[..n]`, all selected, headless. +fn frames(n: usize) -> (Ui, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for (i, name) in NAMES.into_iter().take(n).enumerate() { + let f = id(s.execute("frame.create", &json!({"x": 150 * i, "y": 0, "width": 100, "height": 60})).unwrap()); + s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap(); + ids.push(f); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + (u, ids) +} + +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn bounds(n: &Node) -> Rect { + let b = n.bounds().unwrap(); + Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +fn click(u: &mut Ui, at: Pos2) { + for pressed in [true, false] { + u.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } +} + +fn settle(u: &mut Ui) { + for _ in 0..10 { + u.frame(vec![]); + } +} + +/// The header's component button (its tooltip may carry the shortcut). +fn click_button(u: &mut Ui) { + let all = nodes(u); + let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button"); + click(u, bounds(button).center()); + settle(u); +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +#[test] +fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step() { + let (mut u, ids) = frames(3); + let before = undo_len(&u); + click_button(&mut u); + assert_eq!(undo_len(&u), before, "opening the menu changes nothing"); + let all = nodes(&mut u); + // The menu's rows share an x with Create multiple components: exactly these two, in this order. + let multiple = all.iter().find(|n| n.label() == Some("Create multiple components")).expect("the menu's second entry"); + let mb = bounds(multiple); + let mut rows: Vec<(f32, String)> = all + .iter() + .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 60.0) + .map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned())) + .collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect(); + assert_eq!(labels, ["Create component", "Create multiple components"], "the menu's entries"); + click(&mut u, bounds(multiple).center()); + settle(&mut u); + let sel = u.app.session.selection(); + assert_eq!(sel.len(), 3); + let doc = u.app.session.doc().unwrap(); + let names: Vec<&str> = sel.iter().map(|i| doc.node(*i).unwrap().name.as_str()).collect(); + assert_eq!(names, NAMES); + assert!(sel.iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Component { .. }))); + assert_eq!(undo_len(&u), before + 1); + u.app.session.undo().unwrap(); + let doc = u.app.session.doc().unwrap(); + assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts all three"); +} + +#[test] +fn one_layer_keeps_the_plain_button() { + let (mut u, ids) = frames(1); + click_button(&mut u); + let all = nodes(&mut u); + assert!(all.iter().all(|n| n.label() != Some("Create multiple components")), "no menu opens"); + let sel = u.app.session.selection(); + assert_eq!(sel.len(), 1); + let doc = u.app.session.doc().unwrap(); + assert!(matches!(doc.node(sel[0]).unwrap().kind, NodeKind::Component { .. })); + assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]); + let _ = ids; +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11c96da74..b47f05fbb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,8 @@ mod context_menu_tests; #[cfg(test)] mod corner_individual_tests; #[cfg(test)] +mod create_multiple_menu_tests; +#[cfg(test)] mod crop_image_tests; #[cfg(test)] mod cursors_tests; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index bba57c778..b408491a2 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -530,6 +530,19 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.close(); } }); + } else if cmd == "component.create" && sel.len() > 1 { + // Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click. + // TODO: "Create component set" is the third entry, to add once its naming is measured. + menu_popup(&bresp).width(220.0).show(|ui| { + let multiple = crate::i18n::command("component.createMultiple", "Create multiple components"); + for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] { + let key = crate::shortcuts::shortcut(c).unwrap_or(""); + if menu_icon_item(ui, None, None, label, key) { + app.run(c, json!({})); + ui.close(); + } + } + }); } else if bresp.clicked() { app.run(cmd, json!({})); } diff --git a/docs/components-parity.md b/docs/components-parity.md index d6b1ee5e6..3ecdae292 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-11-06 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; 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 @@ -12,7 +12,7 @@ accounts). | Feature | Status | Notes | |---|---|---| -| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main | +| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet | | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints | @@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. Grade unchanged. | | 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged | | 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | | 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |