mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:12:28 +00:00
Variables: Duplicate group copies a group's variables as "<name> 2", in one undo step (#2084)
The group row's context menu in the Local variables window gets Duplicate group, as in the reference: a copy named after the group plus " 2", placed after the original, holding a copy of every variable with its values in every mode, scopes and settings, undone in one step. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove and briefed from a Chrome measurement; Sonnet 5.5 wrote at medium
This commit is contained in:
1 parent
3635c23070
commit
4313a60fb6
4 files changed
+93
-2
No files matched your search
@@ -521,6 +521,7 @@ name = "English"
|
||||
"Duo" = "Duo"
|
||||
"Duplicate" = "Duplicate"
|
||||
"Duplicate by dragging" = "Duplicate by dragging"
|
||||
"Duplicate group" = "Duplicate group"
|
||||
"Duplicate mode" = "Duplicate mode"
|
||||
"Duplicate page" = "Duplicate page"
|
||||
"Edit" = "Edit"
|
||||
|
||||
@@ -564,6 +564,10 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
start_rename(st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, tr("Duplicate group"), "") {
|
||||
duplicate_group(app, &members, &list, g);
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, tr("Delete group"), "") {
|
||||
let steps: Vec<serde_json::Value> = members
|
||||
.iter()
|
||||
@@ -604,6 +608,28 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st:
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy group `g` (its variables `members`, every mode, scope and setting) as the group named
|
||||
/// after it plus " 2" (the next free number among the collection's groups `list`), in one undo
|
||||
/// step. Each copy is `variable.duplicate` then a rename; an alias keeps pointing at its target.
|
||||
fn duplicate_group(app: &mut FigApp, members: &[&Variable], list: &[String], g: &str) {
|
||||
let (parent, leaf) = g.rsplit_once('/').map_or(("", g), |(p, l)| (p, l));
|
||||
let leaf = unique(leaf, |n| {
|
||||
let full = if parent.is_empty() { n.to_string() } else { format!("{parent}/{n}") };
|
||||
full == g || list.contains(&full) || members.iter().any(|v| v.name.starts_with(&format!("{full}/")))
|
||||
});
|
||||
let copy = if parent.is_empty() { leaf } else { format!("{parent}/{leaf}") };
|
||||
let prefix = format!("{g}/");
|
||||
let mut steps: Vec<serde_json::Value> = Vec::new();
|
||||
for (k, v) in members.iter().filter(|v| v.name.starts_with(&prefix)).enumerate() {
|
||||
let tag = format!("copy{k}");
|
||||
steps.push(json!({"command": "variable.duplicate", "params": {"variable": v.id}, "as": tag}));
|
||||
steps.push(
|
||||
json!({"command": "variable.update", "params": {"variable": {"$ref": tag}, "name": format!("{copy}/{}", &v.name[prefix.len()..])}}),
|
||||
);
|
||||
}
|
||||
app.run("command.batch", json!({"label": "Duplicate group", "commands": steps}));
|
||||
}
|
||||
|
||||
fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) {
|
||||
let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
|
||||
if let Some(id) =
|
||||
|
||||
@@ -250,3 +250,66 @@ fn the_bulk_menu_scrolls_when_forty_groups_make_it_taller_than_the_window() {
|
||||
assert!(visible(&shapes), "scrolling the menu never brought {last:?} into view");
|
||||
assert!(screen.contains_rect(popup_rect(&u)));
|
||||
}
|
||||
|
||||
/// A collection with a group `space` of two number variables with distinct values in two modes.
|
||||
fn grouped_window() -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
||||
for (n, light, dark) in [("space/sm", 4, 8), ("space/md", 16, 32)] {
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "number", "value": light})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": n, "mode": "Dark", "value": dark})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": n, "scopes": ["GAP"], "description": n})).unwrap();
|
||||
}
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.variables = Some(Default::default());
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
/// Right-click the group row `name` and choose Duplicate group.
|
||||
fn duplicate_group(u: &mut Ui, name: &str) {
|
||||
let p = row(u, name);
|
||||
click(u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
let menu = flat(&u.frame_output(vec![]).shapes);
|
||||
click(u, text_center(&menu, "Duplicate group"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_group_copies_every_variable_in_both_modes_in_one_undo_step() {
|
||||
let mut u = grouped_window();
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
duplicate_group(&mut u, "space");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let vars = &doc.variables;
|
||||
for (leaf, light, dark) in [("sm", 4, 8), ("md", 16, 32)] {
|
||||
let old = vars.variables.iter().find(|v| v.name == format!("space/{leaf}")).unwrap();
|
||||
let new = vars.variables.iter().find(|v| v.name == format!("space 2/{leaf}")).unwrap_or_else(|| panic!("no space 2/{leaf}"));
|
||||
assert_ne!(old.id, new.id);
|
||||
assert_eq!(new.values, old.values, "{leaf}: values in both modes");
|
||||
assert_eq!(new.scopes, old.scopes);
|
||||
assert_eq!(new.description, old.description);
|
||||
let _ = (light, dark);
|
||||
}
|
||||
assert_eq!(names(&u).len(), 4);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
assert_eq!(u.app.session.undo_labels(1), ["Duplicate group"]);
|
||||
// The new group is listed directly after the original.
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&shapes, "space 2"));
|
||||
assert!(text_center(&shapes, "space 2").y > text_center(&shapes, "space").y);
|
||||
u.app.session.undo().unwrap();
|
||||
assert_eq!(names(&u), ["space/sm", "space/md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_duplicate_group_takes_the_next_free_name() {
|
||||
let mut u = grouped_window();
|
||||
duplicate_group(&mut u, "space");
|
||||
duplicate_group(&mut u, "space");
|
||||
let n = names(&u);
|
||||
assert!(n.contains(&"space 2/sm".to_string()) && n.contains(&"space 3/md".to_string()), "{n:?}");
|
||||
assert_eq!(n.len(), 6);
|
||||
}
|
||||
@@ -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-06 · **Change:** minor (a group's context menu in the Local variables window has Duplicate group, a copy named after it plus " 2" holding a copy of every variable, in one undo step (#2084); 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
|
||||
@@ -64,7 +64,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
|
||||
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
|
||||
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
|
||||
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once |
|
||||
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once; a group can be duplicated from its row's menu (Duplicate group, one undo step) |
|
||||
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
|
||||
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
|
||||
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
|
||||
@@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | M3, #2084: a group's context menu in the Local variables window has Duplicate group (after Rename group, before Delete group). It adds a group named after the original plus " 2" (the next free number when taken: " 3"), whose variables are copies of the originals with the same leaf names, values in every mode, description, scopes, hidden-from-publishing and code syntax, each sent as `variable.duplicate` plus a rename in one `command.batch` labelled Duplicate group, so one undo step. Measured in the incumbent with one mode; an alias to a variable of the same group keeps pointing at the original. Ungroup is not built. Tests: `variables_select_tests` (headless UI). 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. |
|
||||
|
||||
Reference in new issue
Block a user