mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge pull request #2025 from TrevorBrown-dev/feat/variables-multi-select-2002
M3: the Local variables window selects several variables with Shift and Cmd/Ctrl, and acts on them at once (#2002)
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -407,6 +407,12 @@ name = "English"
|
||||
"Decoration style" = "Decoration style"
|
||||
"Decoration" = "Decoration"
|
||||
"Deep select" = "Deep select"
|
||||
"Delete {n} variables" = { one = "Delete {n} variable", other = "Delete {n} variables" }
|
||||
"Move out of any group" = "Move out of any group"
|
||||
"Move to group {group}" = "Move to group {group}"
|
||||
"Scope: all supported properties" = "Scope: all supported properties"
|
||||
"Scope: hidden from pickers" = "Scope: hidden from pickers"
|
||||
"Scope: only {scope}" = "Scope: only {scope}"
|
||||
"Wrap style" = "Wrap style"
|
||||
"Text direction" = "Text direction"
|
||||
"Auto: by the first letter with a direction" = "Auto: by the first letter with a direction"
|
||||
|
||||
@@ -53,6 +53,113 @@ pub struct Editor {
|
||||
details: Option<String>,
|
||||
/// The group the table shows ("space" or "color/brand"); None = every variable.
|
||||
group: Option<String>,
|
||||
/// The selected variables' ids (this file's), kept to the rows the table shows.
|
||||
pub(crate) selected: Vec<String>,
|
||||
/// The row a Shift+click extends from.
|
||||
pub(crate) anchor: Option<String>,
|
||||
/// The variable ids the table shows now, in row order (the range a Shift+click covers).
|
||||
pub(crate) order: Vec<String>,
|
||||
}
|
||||
|
||||
/// A click on row `id`: plain selects only it, Shift extends from the anchor to it through the
|
||||
/// rows shown (added to the selection with Cmd/Ctrl), Cmd/Ctrl (or Shift without an anchor) toggles it.
|
||||
pub(crate) fn select_clicked(st: &mut Editor, id: &str, m: egui::Modifiers) {
|
||||
let pos = |x: &str| st.order.iter().position(|o| o == x);
|
||||
let anchor = st.anchor.as_deref().filter(|a| st.selected.iter().any(|s| s == a)).and_then(pos);
|
||||
if m.shift
|
||||
&& let (Some(ai), Some(bi)) = (anchor, pos(id))
|
||||
{
|
||||
let (lo, hi) = (ai.min(bi), ai.max(bi));
|
||||
let mut s: Vec<String> = if m.command { st.selected.clone() } else { vec![] };
|
||||
for x in st.order.get(lo..=hi).unwrap_or_default() {
|
||||
if !s.contains(x) {
|
||||
s.push(x.clone());
|
||||
}
|
||||
}
|
||||
st.selected = s;
|
||||
} else if m.shift || m.command {
|
||||
match st.selected.iter().position(|x| x == id) {
|
||||
Some(i) => {
|
||||
st.selected.remove(i);
|
||||
}
|
||||
None => st.selected.push(id.to_string()),
|
||||
}
|
||||
st.anchor = Some(id.to_string());
|
||||
} else {
|
||||
st.selected = vec![id.to_string()];
|
||||
st.anchor = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The variables a row's menu acts on: the whole selection when the row is part of a selection of
|
||||
/// several (in table order, leaving out inherited ones, which are changed in their own collection),
|
||||
/// else None.
|
||||
fn bulk_ids<'a>(view: &View<'a>, st: &Editor, v: &Variable) -> Option<Vec<&'a Variable>> {
|
||||
if !view.editable() || st.selected.len() < 2 || !st.selected.contains(&v.id) {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<&Variable> =
|
||||
st.order.iter().filter(|id| st.selected.contains(id)).filter_map(|id| view.vars.get_by_id(id)).filter(|x| x.extends.is_none()).collect();
|
||||
(list.len() > 1).then_some(list)
|
||||
}
|
||||
|
||||
/// Run one `variable.*` command per variable as a single undo step.
|
||||
fn bulk_run(app: &mut FigApp, label: &str, vars: &[&Variable], step: impl Fn(&Variable) -> Option<(&'static str, Value)>) {
|
||||
let steps: Vec<Value> = vars.iter().filter_map(|v| step(v)).map(|(command, params)| json!({"command": command, "params": params})).collect();
|
||||
if !steps.is_empty() {
|
||||
app.run("command.batch", json!({"label": label, "commands": steps}));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
let n = vars.len();
|
||||
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();
|
||||
}
|
||||
// Many groups make the menu taller than the window: the rows after Delete scroll.
|
||||
let max_h = ui.ctx().content_rect().height() * 0.6;
|
||||
egui::ScrollArea::vertical().id_salt("variables_bulk_menu_scroll").max_height(max_h).show(ui, |ui| {
|
||||
let moves = std::iter::once(None).chain(coll_groups.iter().map(|g| Some(g.as_str())));
|
||||
for g in moves {
|
||||
let label = g.map_or_else(|| tr("Move out of any group").to_string(), |g| trf("Move to group {group}", &[("group", &g)]));
|
||||
if menu_item(ui, &label, "") {
|
||||
bulk_run(app, "Move variables", vars, |v| {
|
||||
let short = v.name.rsplit('/').next().unwrap_or(&v.name);
|
||||
let name = g.map_or_else(|| short.to_string(), |g| format!("{g}/{short}"));
|
||||
(name != v.name).then(|| ("variable.update", json!({"variable": v.id, "name": name})))
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let mut scopes: Vec<(&'static str, &'static str)> = Vec::new();
|
||||
for v in vars {
|
||||
for s in scopes_for(v.kind) {
|
||||
if !scopes.contains(s) {
|
||||
scopes.push(*s);
|
||||
}
|
||||
}
|
||||
}
|
||||
let set = |app: &mut FigApp, label: &str, value: Option<&'static str>, ui: &mut Ui| {
|
||||
if menu_item(ui, label, "") {
|
||||
bulk_run(app, "Change scope", vars, |v| match value {
|
||||
None => Some(("variable.update", json!({"variable": v.id, "scopes": Value::Null}))),
|
||||
Some("") => Some(("variable.update", json!({"variable": v.id, "scopes": []}))),
|
||||
Some(s) => {
|
||||
scopes_for(v.kind).iter().any(|(id, _)| *id == s).then(|| ("variable.update", json!({"variable": v.id, "scopes": [s]})))
|
||||
}
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
set(app, tr("Scope: all supported properties"), None, ui);
|
||||
set(app, tr("Scope: hidden from pickers"), Some(""), ui);
|
||||
for (id, label) in scopes {
|
||||
set(app, &trf("Scope: only {scope}", &[("scope", &tr(label))]), Some(id), ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
@@ -575,6 +682,17 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
||||
let mut list: Vec<&Variable> =
|
||||
view.in_collection(&coll.id).filter(|v| in_group(v) && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect();
|
||||
list.sort_by(|a, b| group_of(&a.name).cmp(group_of(&b.name)));
|
||||
st.order = list.iter().map(|v| v.id.clone()).collect();
|
||||
let order = st.order.clone();
|
||||
st.selected.retain(|id| order.contains(id));
|
||||
// Escape clears the selection (unless a field has the keyboard).
|
||||
if !st.selected.is_empty()
|
||||
&& st.rename.is_none()
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
|
||||
{
|
||||
st.selected.clear();
|
||||
}
|
||||
let width = (NAME_W + coll.modes.len() as f32 * MODE_W + 40.0).max(ui.available_width());
|
||||
egui::ScrollArea::both().id_salt("vars_table").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
@@ -597,6 +715,11 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
||||
}
|
||||
if view.editable() {
|
||||
create_row(app, ui, view, coll, st);
|
||||
// A click on the empty space below the rows clears the selection.
|
||||
let (_, empty) = ui.allocate_exact_size(vec2(width, ui.available_height().max(24.0)), Sense::click());
|
||||
if empty.clicked() {
|
||||
st.selected.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -686,7 +809,10 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let editable = view.editable();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click());
|
||||
if resp.hovered() || (editable && st.details.as_deref() == Some(v.id.as_str())) {
|
||||
let selected = editable && st.selected.contains(&v.id);
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row);
|
||||
} else if resp.hovered() || (editable && st.details.as_deref() == Some(v.id.as_str())) {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
|
||||
}
|
||||
kind_glyph(ui, view.vars, v, coll, Rect::from_center_size(pos2(r.min.x + 24.0, r.center().y), vec2(14.0, 14.0)));
|
||||
@@ -709,10 +835,19 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
||||
value_cell(app, ui, view, v, coll, &m.id, cell);
|
||||
}
|
||||
if editable {
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
select_clicked(st, &v.id, m);
|
||||
}
|
||||
if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) {
|
||||
start_rename(st, target.clone());
|
||||
}
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if menu_item(ui, tr("Edit variable"), "") {
|
||||
st.details = Some(v.id.clone());
|
||||
ui.close();
|
||||
|
||||
@@ -13,11 +13,13 @@ pub(crate) struct Ui {
|
||||
pub(crate) ctx: egui::Context,
|
||||
pub(crate) app: FigApp,
|
||||
time: f64,
|
||||
/// The test screen's size (1440 x 900 unless a test shrinks it).
|
||||
pub(crate) screen: egui::Vec2,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
pub(crate) fn new(s: Session) -> Self {
|
||||
let mut u = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 };
|
||||
let mut u = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0, screen: vec2(1440.0, 900.0) };
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
@@ -44,7 +46,7 @@ impl Ui {
|
||||
pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput {
|
||||
self.time += 1.0 / 60.0;
|
||||
self.app.clock.set_test_now(Some(self.time));
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw };
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), time: Some(self.time), ..raw };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
|
||||
@@ -261,6 +261,8 @@ mod variables_export_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_select_tests;
|
||||
#[cfg(test)]
|
||||
mod variants_discovery_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_keys_tests;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Selecting several variables in the Local variables window (#2002): a click selects one row,
|
||||
//! Shift+click the range from the anchor, Ctrl/Cmd+click toggles a row, Escape and a click on the
|
||||
//! empty space clear; delete, move to a group and scope then act on the whole selection in one
|
||||
//! undo step.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, has_text, text_center};
|
||||
|
||||
const NAMES: [&str; 5] = ["alpha", "bravo", "charlie", "delta", "echo"];
|
||||
|
||||
/// The window open on a collection of five number variables.
|
||||
fn window() -> 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
|
||||
}
|
||||
|
||||
fn row(u: &mut Ui, name: &str) -> Pos2 {
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
// The name column, left of the value cells.
|
||||
let p = text_center(&shapes, name);
|
||||
Pos2::new(p.x, p.y)
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2, button: PointerButton, m: Modifiers) {
|
||||
u.frame_with(vec![Event::PointerMoved(p)], m);
|
||||
for pressed in [true, false] {
|
||||
u.frame_with(vec![Event::PointerButton { pos: p, button, pressed, modifiers: m }], m);
|
||||
}
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn click_row(u: &mut Ui, name: &str, m: Modifiers) {
|
||||
let p = row(u, name);
|
||||
click(u, p, PointerButton::Primary, m);
|
||||
}
|
||||
|
||||
fn selected(u: &Ui) -> Vec<String> {
|
||||
let st = u.app.ui.variables.as_ref().unwrap();
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let mut names: Vec<String> = st.selected.iter().filter_map(|id| doc.variables.get_by_id(id)).map(|v| v.name.clone()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn names(u: &Ui) -> Vec<String> {
|
||||
u.app.session.doc().unwrap().variables.variables.iter().map(|v| v.name.clone()).collect()
|
||||
}
|
||||
|
||||
const SHIFT: Modifiers = Modifiers { shift: true, ..Modifiers::NONE };
|
||||
const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
|
||||
#[test]
|
||||
fn a_click_selects_one_variable_and_replaces_the_selection() {
|
||||
let mut u = window();
|
||||
click_row(&mut u, "bravo", Modifiers::NONE);
|
||||
assert_eq!(selected(&u), ["bravo"]);
|
||||
click_row(&mut u, "delta", Modifiers::NONE);
|
||||
assert_eq!(selected(&u), ["delta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_selects_the_range_from_the_anchor() {
|
||||
let mut u = window();
|
||||
click_row(&mut u, "bravo", Modifiers::NONE);
|
||||
click_row(&mut u, "delta", SHIFT);
|
||||
assert_eq!(selected(&u), ["bravo", "charlie", "delta"]);
|
||||
// A range upward from the same anchor.
|
||||
click_row(&mut u, "alpha", SHIFT);
|
||||
assert_eq!(selected(&u), ["alpha", "bravo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_click_toggles_one_variable() {
|
||||
let mut u = window();
|
||||
click_row(&mut u, "alpha", Modifiers::NONE);
|
||||
click_row(&mut u, "charlie", CTRL);
|
||||
assert_eq!(selected(&u), ["alpha", "charlie"]);
|
||||
click_row(&mut u, "alpha", CTRL);
|
||||
assert_eq!(selected(&u), ["charlie"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_clears_the_selection() {
|
||||
let mut u = window();
|
||||
click_row(&mut u, "alpha", Modifiers::NONE);
|
||||
click_row(&mut u, "bravo", SHIFT);
|
||||
assert_eq!(selected(&u).len(), 2);
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
assert!(selected(&u).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_rows_are_painted_with_the_selected_row_colour() {
|
||||
let mut u = window();
|
||||
let colour = crate::theme::Tokens::get(&u.ctx).selected_row;
|
||||
let count = |u: &mut Ui| flat(&u.frame_output(vec![]).shapes).iter().filter(|s| matches!(s, egui::Shape::Rect(r) if r.fill == colour)).count();
|
||||
let none = count(&mut u);
|
||||
click_row(&mut u, "alpha", Modifiers::NONE);
|
||||
let one = count(&mut u);
|
||||
click_row(&mut u, "charlie", SHIFT);
|
||||
let three = count(&mut u);
|
||||
assert!(one > none, "the selected row is highlighted");
|
||||
assert_eq!(three - one, 2, "two more rows highlighted by the range");
|
||||
}
|
||||
|
||||
/// Select bravo..delta, then right-click delta.
|
||||
fn select_three_and_open_menu(u: &mut Ui) -> Vec<egui::Shape> {
|
||||
click_row(u, "bravo", Modifiers::NONE);
|
||||
click_row(u, "delta", SHIFT);
|
||||
assert_eq!(selected(u), ["bravo", "charlie", "delta"]);
|
||||
let p = row(u, "delta");
|
||||
click(u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_delete_removes_exactly_the_selected_variables_in_one_undo_step() {
|
||||
let mut u = window();
|
||||
let menu = select_three_and_open_menu(&mut u);
|
||||
assert!(has_text(&menu, "Delete 3 variables"));
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&menu, "Delete 3 variables"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(names(&u), ["alpha", "echo"]);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert_eq!(names(&u), NAMES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_move_to_a_group_renames_every_selected_variable_in_one_step() {
|
||||
let mut u = window();
|
||||
u.app.session.execute("variable.create", &json!({"collection": "Theme", "name": "space/zero", "type": "number", "value": 0})).unwrap();
|
||||
let menu = select_three_and_open_menu(&mut u);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&menu, "Move to group space"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
let mut now = names(&u);
|
||||
now.sort();
|
||||
assert_eq!(now, ["alpha", "echo", "space/bravo", "space/charlie", "space/delta", "space/zero"]);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert!(names(&u).contains(&"bravo".to_string()) && names(&u).contains(&"delta".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_scope_change_reaches_every_selected_variable_in_one_step() {
|
||||
let mut u = window();
|
||||
let menu = select_three_and_open_menu(&mut u);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&menu, "Scope: only Corner radius"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
let scoped: Vec<String> = u
|
||||
.app
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| v.scopes.as_deref() == Some(&["CORNER_RADIUS".to_string()][..]))
|
||||
.map(|v| v.name.clone())
|
||||
.collect();
|
||||
assert_eq!(scoped, ["bravo", "charlie", "delta"]);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_outside_the_selection_keeps_the_single_variable_menu() {
|
||||
let mut u = window();
|
||||
click_row(&mut u, "alpha", Modifiers::NONE);
|
||||
click_row(&mut u, "bravo", SHIFT);
|
||||
let p = row(&mut u, "echo");
|
||||
click(&mut u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
let menu = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&menu, "Delete variable") && !has_text(&menu, "Delete 2 variables"));
|
||||
}
|
||||
|
||||
/// The bounding rect of every popup layer (the menu's area).
|
||||
fn popup_rect(u: &Ui) -> egui::Rect {
|
||||
u.ctx.memory(|m| {
|
||||
let rects: Vec<egui::Rect> =
|
||||
m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).filter_map(|l| m.area_rect(l.id)).collect();
|
||||
assert!(!rects.is_empty(), "no popup open");
|
||||
rects.into_iter().reduce(|a, b| a.union(b)).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bulk_menu_scrolls_when_forty_groups_make_it_taller_than_the_window() {
|
||||
let mut u = window();
|
||||
for g in 0..40 {
|
||||
u.app
|
||||
.session
|
||||
.execute("variable.create", &json!({"collection": "Theme", "name": format!("g{g:02}/leaf"), "type": "number", "value": 0}))
|
||||
.unwrap();
|
||||
}
|
||||
u.screen = egui::vec2(1000.0, 500.0);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
click_row(&mut u, "bravo", Modifiers::NONE);
|
||||
click_row(&mut u, "charlie", SHIFT);
|
||||
let p = row(&mut u, "charlie");
|
||||
click(&mut u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
let menu = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&menu, "Delete 2 variables"));
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, u.screen);
|
||||
let area = popup_rect(&u);
|
||||
assert!(screen.contains_rect(area), "the menu area {area:?} runs past the screen {screen:?}");
|
||||
let last = "Move to group g39";
|
||||
let screen_h = u.screen.y;
|
||||
let visible = |shapes: &[egui::Shape]| crate::live_tests::texts(shapes).iter().any(|(t, p, s)| t == last && p.y + s.y <= screen_h);
|
||||
assert!(!visible(&menu), "the last group row should start out of view");
|
||||
let at = Pos2::new(area.center().x, area.center().y);
|
||||
let mut shapes = menu;
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta: egui::vec2(0.0, -400.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
},
|
||||
]);
|
||||
shapes = flat(&u.frame_output(vec![Event::PointerMoved(at)]).shapes);
|
||||
if visible(&shapes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(visible(&shapes), "scrolling the menu never brought {last:?} into view");
|
||||
assert!(screen.contains_rect(popup_rect(&u)));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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 (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 |
|
||||
| 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 |
|
||||
| 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-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. |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user