From a20415e1542ef9d3df5e929b67e5be7911eba9b3 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 20:47:09 -0500 Subject: [PATCH] Variables: Edit variables edits Hide from publishing and scopes of several variables at once, with mixed states (#2082) With several variables selected, the bulk menu's Edit variables opens the reference's compact editor: Hide from publishing and the scopes, each shown mixed when the selection differs, each change applied to every selected variable in one undo step. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed from a Chrome measurement; Sonnet 5.5 wrote at medium --- crates/ui-egui/locales/en.toml | 3 + crates/ui-egui/src/dialogs/variables_panel.rs | 109 +++++++++++++++++- crates/ui-egui/src/variables_select_tests.rs | 67 +++++++++++ crates/ui-egui/src/widgets.rs | 12 +- docs/components-parity.md | 1 + 5 files changed, 186 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index bb8546ee6..8a297ed78 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -533,6 +533,9 @@ name = "English" "Edit padding on all sides" = "Edit padding on all sides" "Edit style" = "Edit style" "Edit variable" = "Edit variable" +"Edit variables" = "Edit variables" +"Scope" = "Scope" +"No scope is shared by all the selected variables" = "No scope is shared by all the selected variables" "Edit with the builder" = "Edit with the builder" "Edited {when}" = "Edited {when}" "Effect settings" = "Effect settings" diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index a2a421052..4e1219591 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -59,6 +59,10 @@ pub struct Editor { pub(crate) anchor: Option, /// The variable ids the table shows now, in row order (the range a Shift+click covers). pub(crate) order: Vec, + /// The variables the compact "Edit variables" editor is open on (empty = closed). + bulk_edit: Vec, + /// That editor's tab: 0 Details, 1 Scope. + bulk_tab: usize, } /// A click on row `id`: plain selects only it, Shift extends from the anchor to it through the @@ -113,8 +117,13 @@ fn bulk_run(app: &mut FigApp, label: &str, vars: &[&Variable], step: impl Fn(&Va /// The context menu of a row that is part of a selection of several: delete, move to a group and /// scope act on all of them. -fn bulk_menu(app: &mut FigApp, ui: &mut Ui, coll_groups: &[String], vars: &[&Variable]) { +fn bulk_menu(app: &mut FigApp, ui: &mut Ui, st: &mut Editor, coll_groups: &[String], vars: &[&Variable]) { let n = vars.len(); + if menu_item(ui, tr("Edit variables"), "") { + st.bulk_edit = vars.iter().map(|v| v.id.clone()).collect(); + st.bulk_tab = 0; + ui.close(); + } if menu_item(ui, &trn("Delete {n} variables", n, &[]), "") { bulk_run(app, "Delete variables", vars, |v| Some(("variable.delete", json!({"variable": v.id})))); ui.close(); @@ -277,6 +286,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { }); }); details(app, ctx, &doc.variables, &mut st); + bulk_details(app, ctx, &doc.variables, &mut st); app.ui.variables = open.then_some(st); } @@ -848,7 +858,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va crate::right::context_popup(&resp).show(|ui| { if let Some(vars) = bulk_ids(view, st, v) { let coll_groups = groups(view.in_collection(&coll.id).map(|x| x.name.as_str())); - bulk_menu(app, ui, &coll_groups, &vars); + bulk_menu(app, ui, st, &coll_groups, &vars); return; } if menu_item(ui, tr("Edit variable"), "") { @@ -1178,9 +1188,102 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) { } } +/// Whether `f` holds for every variable (Some(true)), for none (Some(false)) or only some (None). +fn tri(vars: &[&T], f: impl Fn(&T) -> bool) -> Option { + let on = vars.iter().filter(|v| f(v)).count(); + if on == vars.len() { + Some(true) + } else if on == 0 { + Some(false) + } else { + None + } +} + +/// The compact editor of several selected variables: Details (only Hide from publishing) and Scope +/// (the scopes every selected variable's type has), each box mixed where the selection differs; a +/// click sets or clears it on all of them in one undo step. +fn bulk_details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) { + if st.bulk_edit.is_empty() { + return; + } + let list: Vec<&Variable> = st.bulk_edit.iter().filter_map(|id| vars.get_by_id(id)).filter(|v| v.extends.is_none()).collect(); + if list.is_empty() || st.library.is_some() { + st.bulk_edit.clear(); + return; + } + let t = Tokens::get(ctx); + let mut open = true; + egui::Window::new(tr("Edit variables")) + .id(Id::unique("variables_bulk_details")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(280.0) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(16))) + .show(ctx, |ui| { + ui.set_width(260.0); + if let Some(i) = widgets::segmented_text(ui, &[tr("Details"), tr("Scope")], st.bulk_tab) { + st.bulk_tab = i; + } + ui.add_space(8.0); + if st.bulk_tab == 0 { + let hidden = tri(&list, |v| v.hidden_from_publishing); + if widgets::checkbox_tri(ui, hidden, tr("Hide from publishing")) { + let to = hidden != Some(true); + bulk_run(app, "Edit variables", &list, |v| Some(("variable.update", json!({"variable": v.id, "hiddenFromPublishing": to})))); + } + return; + } + // The scopes every selected variable's type has. + let first = list.first().map_or(&[][..], |v| scopes_for(v.kind)); + let common: Vec<(&'static str, &'static str)> = + first.iter().filter(|(id, _)| list.iter().all(|v| scopes_for(v.kind).iter().any(|(k, _)| k == id))).copied().collect(); + if common.is_empty() { + widgets::label(ui, tr("No scope is shared by all the selected variables"), t.text_secondary); + return; + } + let all = tri(&list, |v| v.scopes.is_none()); + if widgets::checkbox_tri(ui, all, tr("All supported properties")) { + let to_all = all != Some(true); + bulk_run(app, "Edit variables", &list, |v| { + Some(("variable.update", json!({"variable": v.id, "scopes": if to_all { Value::Null } else { json!([]) }}))) + }); + } + for (sid, label) in common { + let state = tri(&list, |v| v.in_scope(sid)); + let clicked = ui + .horizontal(|ui| { + ui.add_space(16.0); + widgets::checkbox_tri(ui, state, tr(label)) + }) + .inner; + if clicked { + let to = state != Some(true); + bulk_run(app, "Edit variables", &list, |v| { + let next: Vec<&str> = + scopes_for(v.kind).iter().map(|(id, _)| *id).filter(|id| if *id == sid { to } else { v.in_scope(id) }).collect(); + Some(("variable.update", json!({"variable": v.id, "scopes": next}))) + }); + } + } + }); + if !open { + st.bulk_edit.clear(); + } +} + #[cfg(test)] mod tests { - use super::groups; + use super::{groups, tri}; + + #[test] + fn tri_is_checked_empty_or_mixed() { + let (a, b) = (true, false); + assert_eq!(tri(&[&a, &a], |v| *v), Some(true)); + assert_eq!(tri(&[&b, &b], |v| *v), Some(false)); + assert_eq!(tri(&[&a, &b], |v| *v), None); + } #[test] fn groups_include_parents_once_and_sorted() { diff --git a/crates/ui-egui/src/variables_select_tests.rs b/crates/ui-egui/src/variables_select_tests.rs index b391df326..f27aa3114 100644 --- a/crates/ui-egui/src/variables_select_tests.rs +++ b/crates/ui-egui/src/variables_select_tests.rs @@ -250,3 +250,70 @@ 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))); } + +/// Three colour variables: bravo and charlie also have Stroke in their scopes, delta does not. +fn colour_window() -> Ui { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + for (n, scopes) in + [("bravo", json!(["FRAME_FILL", "STROKE_COLOR"])), ("charlie", json!(["FRAME_FILL", "STROKE_COLOR"])), ("delta", json!(["FRAME_FILL"]))] + { + s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "color", "value": "#ff0000"})).unwrap(); + s.execute("variable.update", &json!({"variable": n, "scopes": scopes})).unwrap(); + } + let mut u = Ui::new(s); + u.app.ui.variables = Some(Default::default()); + for _ in 0..10 { + u.frame(vec![]); + } + u +} + +fn open_edit_variables(u: &mut Ui) -> Vec { + let menu = select_three_and_open_menu(u); + assert!(has_text(&menu, "Edit variables"), "the bulk menu offers Edit variables"); + click(u, text_center(&menu, "Edit variables"), PointerButton::Primary, Modifiers::NONE); + flat(&u.frame_output(vec![]).shapes) +} + +fn flags(u: &Ui) -> Vec<(bool, bool)> { + let doc = u.app.session.doc().unwrap(); + ["bravo", "charlie", "delta"] + .iter() + .map(|n| { + let v = doc.variables.variables.iter().find(|v| v.name == *n).unwrap(); + (v.hidden_from_publishing, v.in_scope("STROKE_COLOR")) + }) + .collect() +} + +#[test] +fn edit_variables_sets_stroke_on_all_selected_in_one_undo_step() { + let mut u = colour_window(); + let shapes = open_edit_variables(&mut u); + assert!(has_text(&shapes, "Hide from publishing") && has_text(&shapes, "Scope")); + click(&mut u, text_center(&shapes, "Scope"), PointerButton::Primary, Modifiers::NONE); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Stroke")); + assert_eq!(flags(&u), [(false, true), (false, true), (false, false)]); + let before = u.app.session.undo_labels(100).len(); + click(&mut u, text_center(&shapes, "Stroke"), PointerButton::Primary, Modifiers::NONE); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert_eq!(flags(&u), [(false, true), (false, true), (false, true)]); + assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step"); + u.app.session.undo().unwrap(); + assert_eq!(flags(&u), [(false, true), (false, true), (false, false)]); +} + +#[test] +fn edit_variables_hides_all_selected_from_publishing_in_one_undo_step() { + let mut u = colour_window(); + let shapes = open_edit_variables(&mut u); + let before = u.app.session.undo_labels(100).len(); + click(&mut u, text_center(&shapes, "Hide from publishing"), PointerButton::Primary, Modifiers::NONE); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert!(flags(&u).iter().all(|f| f.0), "all hidden"); + assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step"); + u.app.session.undo().unwrap(); + assert!(flags(&u).iter().all(|f| !f.0)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index eae46fd81..05dbff893 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1274,13 +1274,19 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color: /// A small checkbox with label. pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { + checkbox_tri(ui, Some(on), label) +} + +/// A checkbox that can show a mixed state (`None`: a dash, for a setting that differs across a +/// selection); returns whether it was clicked. +pub fn checkbox_tri(ui: &mut Ui, state: Option, label: &str) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(200.0), FIELD_H), Sense::click()); - a11y::checkbox(&resp, label, on); + a11y::checkbox(&resp, label, state == Some(true)); let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0)); - if on { + if state != Some(false) { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); + icons::paint(ui.painter(), b.shrink(1.5), if state.is_some() { Icon::Check } else { Icon::Minus }, t.accent_on); } else { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } diff --git a/docs/components-parity.md b/docs/components-parity.md index d6b1ee5e6..9e5aadb68 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 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-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. |