mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 14:22:45 +00:00
Variables: renaming a group to a path with / nests it (#2353)
Renaming a group in the Local variables window replaced every slash in the typed name with a hyphen, so "Palette/Orange" gave a flat Palette-Orange group and there was no way to move a group under a new parent. The typed path now replaces the group's last part, so a slash nests the group as in Figma: Orange/100 becomes Palette/Orange/100 and the sidebar shows Orange under Palette. Each part is trimmed and empty parts are dropped; a name that comes out empty or unchanged does nothing. The renames go deepest name first, so a group moved into its own new subgroup (Orange to Orange/Warm) never collides with a variable that is still to move. A rename onto a variable name that stays is refused by variable.update and the batch rolls back; it stays one undo step.
This commit is contained in:
3 files changed
+152
-12
No files matched your search
@@ -594,23 +594,40 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the last part of group `g` of a collection (its variables `members`) to `new`: every
|
||||
/// variable under it moves along (one undo step).
|
||||
fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: &mut Editor) {
|
||||
let new = new.trim().replace('/', "-");
|
||||
/// What renaming group `g` (of the variables `members`) to `new` does: the group's new path and
|
||||
/// each moved variable's (id, new name). `new` replaces the group's last part; a `/` in it nests
|
||||
/// the group, as in Figma ("Palette/Orange" puts it in a group Palette, made if there is none).
|
||||
/// Each part is trimmed and empty parts are dropped. The renames come deepest name first, so a
|
||||
/// variable moving into the group's own new path ("Orange" to "Orange/Warm") never takes a name
|
||||
/// another moved variable still holds. None when nothing changes.
|
||||
fn group_renames(members: &[&Variable], g: &str, new: &str) -> Option<(String, Vec<(String, String)>)> {
|
||||
let new = new.split('/').map(str::trim).filter(|p| !p.is_empty()).collect::<Vec<_>>().join("/");
|
||||
if new.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let renamed = match g.rsplit_once('/') {
|
||||
Some((parent, _)) => format!("{parent}/{new}"),
|
||||
None => new,
|
||||
};
|
||||
if renamed == g {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{g}/");
|
||||
let mut moved: Vec<(&Variable, &str)> = members.iter().filter_map(|v| Some((*v, v.name.strip_prefix(&prefix)?))).collect();
|
||||
// A longer name can only be one a shorter one moves to: rename those out of the way first.
|
||||
moved.sort_by_key(|(_, rest)| std::cmp::Reverse(rest.len()));
|
||||
Some((renamed.clone(), moved.into_iter().map(|(v, rest)| (v.id.clone(), format!("{renamed}/{rest}"))).collect()))
|
||||
}
|
||||
|
||||
/// Rename group `g` of a collection (its variables `members`) to `new` (see [`group_renames`]):
|
||||
/// every variable under it moves along, in one undo step.
|
||||
fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: &mut Editor) {
|
||||
let Some((renamed, moves)) = group_renames(members, g, new) else {
|
||||
return;
|
||||
};
|
||||
let steps: Vec<serde_json::Value> =
|
||||
moves.iter().map(|(id, name)| json!({"command": "variable.update", "params": {"variable": id, "name": name}})).collect();
|
||||
let prefix = format!("{g}/");
|
||||
let steps: Vec<serde_json::Value> = members
|
||||
.iter()
|
||||
.filter(|v| v.name.starts_with(&prefix))
|
||||
.map(|v| json!({"command": "variable.update", "params": {"variable": v.id, "name": format!("{renamed}/{}", &v.name[prefix.len()..])}}))
|
||||
.collect();
|
||||
if app.run("command.batch", json!({"label": "Rename group", "commands": steps})).is_some()
|
||||
&& st.group.as_deref().is_some_and(|s| s == g || s.starts_with(&prefix))
|
||||
{
|
||||
@@ -1308,7 +1325,7 @@ fn bulk_details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Edit
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{groups, tri};
|
||||
use super::{group_renames, groups, tri};
|
||||
|
||||
#[test]
|
||||
fn tri_is_checked_empty_or_mixed() {
|
||||
@@ -1330,4 +1347,47 @@ mod tests {
|
||||
assert_eq!(groups(names.into_iter()), ["color", "color/brand", "space"]);
|
||||
assert!(groups(["a", "b"].into_iter()).is_empty());
|
||||
}
|
||||
|
||||
/// The renames of group `g` among `names` (numbers in one collection) typed as `new`, by name.
|
||||
fn renames(names: &[&str], g: &str, new: &str) -> Option<(String, Vec<(String, String)>)> {
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
s.execute("variable.createCollection", &serde_json::json!({"name": "Theme"})).unwrap();
|
||||
for n in names {
|
||||
s.execute("variable.create", &serde_json::json!({"collection": "Theme", "name": n, "type": "number", "value": 1})).unwrap();
|
||||
}
|
||||
let vars = &s.doc().unwrap().variables.variables;
|
||||
let members: Vec<_> = vars.iter().collect();
|
||||
let (renamed, moves) = group_renames(&members, g, new)?;
|
||||
let by_name = |id: &str| vars.iter().find(|v| v.id == id).unwrap().name.clone();
|
||||
Some((renamed, moves.into_iter().map(|(id, n)| (by_name(&id), n)).collect()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slash_in_a_group_rename_nests_the_group_keeping_its_parent() {
|
||||
let pair = |a: &str, b: &str| (a.to_string(), b.to_string());
|
||||
assert_eq!(
|
||||
renames(&["Orange/100", "Orange/200", "Blue/100"], "Orange", "Palette/Orange"),
|
||||
Some(("Palette/Orange".into(), vec![pair("Orange/100", "Palette/Orange/100"), pair("Orange/200", "Palette/Orange/200")]))
|
||||
);
|
||||
assert_eq!(
|
||||
renames(&["a/b/x"], "a/b", " / Warm // c / "),
|
||||
Some(("a/Warm/c".into(), vec![pair("a/b/x", "a/Warm/c/x")])),
|
||||
"parts are trimmed, empty parts dropped, and the parent stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_rename_to_nothing_or_to_itself_does_nothing() {
|
||||
for new in ["", " ", "/", " / // ", "Orange", " Orange ", "/Orange/"] {
|
||||
assert_eq!(renames(&["Orange/100"], "Orange", new), None, "{new:?}");
|
||||
}
|
||||
assert_eq!(renames(&["a/b/x"], "a/b", "b/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_moved_into_its_own_subgroup_renames_the_deeper_names_first() {
|
||||
let (_, moves) = renames(&["Orange/100", "Orange/Warm/100"], "Orange", "Orange/Warm").unwrap();
|
||||
let order: Vec<&str> = moves.iter().map(|(old, _)| old.as_str()).collect();
|
||||
assert_eq!(order, ["Orange/Warm/100", "Orange/100"]);
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,85 @@ fn a_second_duplicate_group_takes_the_next_free_name() {
|
||||
assert_eq!(n.len(), 6);
|
||||
}
|
||||
|
||||
/// A collection with the variables `names` (numbers).
|
||||
fn named_window(names: &[&str]) -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
for n in names {
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "number", "value": 1})).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`, choose Rename group, type `to` and press Enter.
|
||||
fn rename_group(u: &mut Ui, name: &str, to: &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, "Rename group"), PointerButton::Primary, Modifiers::NONE);
|
||||
u.frame(vec![Event::Text(to.into())]);
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_names(u: &Ui) -> Vec<String> {
|
||||
let mut n = names(u);
|
||||
n.sort();
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_group_with_a_slash_nests_it_in_a_new_parent_group_in_one_undo_step() {
|
||||
let mut u = named_window(&["Orange/100", "Orange/200", "Blue/100"]);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
rename_group(&mut u, "Orange", "Palette/Orange");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(sorted_names(&u), ["Blue/100", "Palette/Orange/100", "Palette/Orange/200"]);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
assert_eq!(u.app.session.undo_labels(1), ["Rename group"]);
|
||||
// The sidebar lists Palette with Orange indented under it.
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let (parent, child) = (text_center(&shapes, "Palette"), text_center(&shapes, "Orange"));
|
||||
assert!(child.y > parent.y && child.x > parent.x, "Orange is indented under Palette: {parent:?} {child:?}");
|
||||
assert!(!has_text(&shapes, "Palette-Orange"));
|
||||
u.app.session.undo().unwrap();
|
||||
assert_eq!(sorted_names(&u), ["Blue/100", "Orange/100", "Orange/200"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_groups_new_path_stays_in_its_parent_and_drops_empty_parts() {
|
||||
let mut u = named_window(&["Palette/Orange/100", "Palette/Orange/200"]);
|
||||
rename_group(&mut u, "Orange", " /Warm // Orange / ");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(sorted_names(&u), ["Palette/Warm/Orange/100", "Palette/Warm/Orange/200"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_group_into_its_own_new_subgroup_moves_the_deeper_names_first() {
|
||||
// Orange/100 takes Orange/Warm/100, which itself moves on to Orange/Warm/Warm/100.
|
||||
let mut u = named_window(&["Orange/100", "Orange/Warm/100"]);
|
||||
rename_group(&mut u, "Orange", "Orange/Warm");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(sorted_names(&u), ["Orange/Warm/100", "Orange/Warm/Warm/100"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_rename_onto_a_variable_name_that_stays_changes_nothing() {
|
||||
let mut u = named_window(&["Orange/100", "Orange/200", "Palette/Orange/100"]);
|
||||
rename_group(&mut u, "Orange", "Palette/Orange");
|
||||
assert!(u.app.ui.last_error.as_deref().is_some_and(|e| e.contains("Palette/Orange/100")), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(sorted_names(&u), ["Orange/100", "Orange/200", "Palette/Orange/100"]);
|
||||
}
|
||||
|
||||
fn open_edit_variables(u: &mut Ui) -> Vec<egui::Shape> {
|
||||
let menu = select_three_and_open_menu(u);
|
||||
assert!(has_text(&menu, "Edit variables"), "the bulk menu offers Edit variables");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **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); pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (renaming a variable group to a path with `/` nests it under a parent group instead of turning the slash into a hyphen (#2353); 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); pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); 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
|
||||
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-14 | patch | M3, #2353: renaming a group in the Local variables window to a path with `/` nests it, as in Figma: `Orange` renamed to `Palette/Orange` moves its variables to `Palette/Orange/100`… and the sidebar shows Orange indented under a new Palette group, where the slash used to become a hyphen (`Palette-Orange`). The typed path replaces the group's last part (a nested group stays in its parent), each part is trimmed and empty parts are dropped; a rename onto a variable name that stays is refused with nothing changed, and it is one undo step. Tests: `variables_select_tests::renaming_a_group_with_a_slash_nests_it_in_a_new_parent_group_in_one_undo_step` and the group rename tests beside it, `variables_panel::tests`. Grade unchanged. |
|
||||
| 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-07 | minor | M3, #2082: Edit variables in the context menu of a selection of several local variables opens the reference's compact editor with two tabs: Details holds only Hide from publishing, Scope the scopes every selected variable's type has (All supported properties and one box per scope; a selection that shares none says so). Each box is checked when all selected variables have it, empty when none, and shows a dash when they differ; a click sets it on all (or clears it when checked on all), each change one `command.batch`, so one undo step. Names, descriptions and code syntax stay single-variable, as in the reference. The Scope: menu entries stay. A tri-state `widgets::checkbox_tri` draws the dash. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-12 | minor | M7, #2215: pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215). The same section the frames' Variable modes use (`variable.setMode` without layers sets the page's mode), one undo step per pick. Binding the page background to a variable is not in the reference and is not offered |
|
||||
|
||||
Reference in new issue
Block a user