mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
Merge pull request #2089 from TrevorBrown-dev/feat/variables-edit-several-2082
Variables: edit Hide from publishing and scopes of several variables at once
This commit is contained in:
7 files changed
+390
-7
No files matched your search
@@ -534,6 +534,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"
|
||||
|
||||
@@ -883,9 +883,17 @@ pub fn tab(resp: &Response, label: &str, selected: bool) {
|
||||
}
|
||||
|
||||
pub fn checkbox(resp: &Response, label: &str, on: bool) {
|
||||
checkbox_tri(resp, label, Some(on));
|
||||
}
|
||||
|
||||
/// A checkbox that can be mixed (`None`, announced as "mixed" rather than unchecked).
|
||||
pub fn checkbox_tri(resp: &Response, label: &str, state: Option<bool>) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::CheckBox);
|
||||
n.set_toggled(toggled(on));
|
||||
n.set_toggled(match state {
|
||||
Some(on) => toggled(on),
|
||||
None => Toggled::Mixed,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -431,3 +431,80 @@ fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
|
||||
}
|
||||
assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_is_announced_as_mixed_and_a_checked_one_as_true() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, None, "Mixed one");
|
||||
crate::widgets::checkbox_tri(ui, Some(true), "Checked one");
|
||||
crate::widgets::checkbox_tri(ui, Some(false), "Plain one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let nodes: Vec<Node> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Mixed one").toggled(), Some(Toggled::Mixed));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Checked one").toggled(), Some(Toggled::True));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Plain one").toggled(), Some(Toggled::False));
|
||||
}
|
||||
|
||||
/// Runs one headless frame of a lone tri-state checkbox; returns whether it was clicked.
|
||||
fn tri_frame(ctx: &egui::Context, state: Option<bool>, events: Vec<Event>) -> bool {
|
||||
let mut clicked = false;
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
clicked = crate::widgets::checkbox_tri(ui, state, "Keyed one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
clicked
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkbox_is_keyboard_operable_tab_to_it_then_space_or_enter_toggles_it() {
|
||||
for activate in [Key::Space, Key::Enter] {
|
||||
let ctx = egui::Context::default();
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
tri_frame(&ctx, Some(false), vec![key(Key::Tab, true), key(Key::Tab, false)]);
|
||||
assert!(ctx.memory(|m| m.focused()).is_some(), "Tab focuses the checkbox");
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
let clicked = tri_frame(&ctx, Some(false), vec![key(activate, true), key(activate, false)]);
|
||||
assert!(clicked, "{activate:?} on the focused checkbox toggles it");
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks (paths, meshes, segments) painted inside the 14-point box of a lone checkbox.
|
||||
fn box_marks(state: Option<bool>) -> Vec<egui::Rect> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::Rect>) {
|
||||
match s {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } => out.push(s.visual_bounding_rect()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut o = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, state, "Painted one");
|
||||
});
|
||||
o.textures_delta.clear();
|
||||
out.clear();
|
||||
o.shapes.iter().for_each(|c| walk(&c.shape, &mut out));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_paints_a_dash_and_a_checked_one_a_tick() {
|
||||
// Icon::Minus is one flat bar; Icon::Check is a taller path.
|
||||
let mixed = box_marks(None);
|
||||
assert!(mixed.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "a flat dash is painted for the mixed state: {mixed:?}");
|
||||
let checked = box_marks(Some(true));
|
||||
assert!(checked.iter().any(|r| r.width() >= 5.0 && r.height() > 3.5), "a tick is painted for the checked state: {checked:?}");
|
||||
assert!(!checked.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "no dash on a checked box: {checked:?}");
|
||||
assert!(box_marks(Some(false)).is_empty(), "an empty box paints no mark");
|
||||
}
|
||||
@@ -59,6 +59,10 @@ pub struct Editor {
|
||||
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>,
|
||||
/// The variables the compact "Edit variables" editor is open on (empty = closed).
|
||||
bulk_edit: Vec<String>,
|
||||
/// 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,115 @@ 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).
|
||||
/// With no variables nothing is on: Some(false), never a checked box for an empty list.
|
||||
fn tri<T>(vars: &[&T], f: impl Fn(&T) -> bool) -> Option<bool> {
|
||||
if vars.is_empty() {
|
||||
return Some(false);
|
||||
}
|
||||
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();
|
||||
// The menu offers Edit variables only for two or more editable, non-inherited variables of a
|
||||
// file collection (`bulk_ids`), so this closes only when they have since gone (deleted,
|
||||
// undone) or a library was opened: there is nothing left to edit.
|
||||
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 tri_of_no_variables_is_not_checked() {
|
||||
let none: [&bool; 0] = [];
|
||||
assert_eq!(tri(&none, |v| *v), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_include_parents_once_and_sorted() {
|
||||
|
||||
@@ -250,3 +250,175 @@ 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<egui::Shape> {
|
||||
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));
|
||||
}
|
||||
|
||||
/// Bravo, charlie and delta of the given types (a value fitting each).
|
||||
fn typed_window(types: [&str; 3]) -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
for (n, t) in ["bravo", "charlie", "delta"].into_iter().zip(types) {
|
||||
let value = match t {
|
||||
"number" => json!(1),
|
||||
"boolean" => json!(true),
|
||||
"string" => json!("x"),
|
||||
_ => json!("#ff0000"),
|
||||
};
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": t, "value": value})).unwrap();
|
||||
}
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.variables = Some(Default::default());
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn scope_tab(u: &mut Ui) -> Vec<egui::Shape> {
|
||||
let shapes = open_edit_variables(u);
|
||||
click(u, text_center(&shapes, "Scope"), PointerButton::Primary, Modifiers::NONE);
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
fn scopes(u: &Ui) -> Vec<Option<Vec<String>>> {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
["bravo", "charlie", "delta"].iter().map(|n| doc.variables.variables.iter().find(|v| v.name == *n).unwrap().scopes.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_supported_properties_turns_on_for_every_selected_variable_and_off_again_one_undo_step_each() {
|
||||
let mut u = colour_window();
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(scopes(&u).iter().all(|s| s.is_some()), "each variable starts limited");
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&shapes, "All supported properties"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(scopes(&u).iter().all(|s| s.is_none()), "on: no variable is limited: {:?}", scopes(&u));
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&shapes, "All supported properties"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(scopes(&u).iter().all(|s| s.as_ref().is_some_and(|l| l.is_empty())), "off: hidden from pickers: {:?}", scopes(&u));
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 2, "one undo step each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_checked_scope_clears_it_on_every_selected_variable_in_one_undo_step() {
|
||||
let mut u = colour_window();
|
||||
let shapes = scope_tab(&mut u);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
// Frame fill is on for all three: checked, so the click clears it.
|
||||
click(&mut u, text_center(&shapes, "Frame fill"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(["bravo", "charlie", "delta"].iter().all(|n| !doc.variables.variables.iter().find(|v| v.name == *n).unwrap().in_scope("FRAME_FILL")));
|
||||
assert_eq!(flags(&u), [(false, true), (false, true), (false, false)], "the other scopes stay");
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert!(scopes(&u).iter().all(|s| s.as_ref().is_some_and(|l| l.iter().any(|x| x == "FRAME_FILL"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_share_no_scope_and_the_scope_tab_says_so() {
|
||||
let mut u = typed_window(["boolean", "boolean", "boolean"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
assert!(!has_text(&shapes, "All supported properties"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_types_list_only_the_scopes_every_type_has() {
|
||||
// Number and string share only Text content; a colour shares nothing with them.
|
||||
let mut u = typed_window(["number", "number", "string"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "Text content"), "the shared scope is shown");
|
||||
for other in ["Corner radius", "Font family", "Font size"] {
|
||||
assert!(!has_text(&shapes, other), "{other} is not common");
|
||||
}
|
||||
assert!(!has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
let mut u = typed_window(["number", "color", "string"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_variables_is_not_offered_when_every_selected_variable_is_inherited() {
|
||||
let mut u = window();
|
||||
u.app.session.execute("variable.extendCollection", &json!({"collection": "Theme"})).unwrap();
|
||||
u.app.ui.variables = Some(crate::dialogs::variables_panel::Editor::on(Some("Theme (extended)".into())));
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
click_row(&mut u, "bravo", Modifiers::NONE);
|
||||
click_row(&mut u, "delta", SHIFT);
|
||||
assert_eq!(selected(&u), ["bravo", "charlie", "delta"]);
|
||||
let p = row(&mut u, "delta");
|
||||
click(&mut u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
let menu = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(!has_text(&menu, "Edit variables"), "inherited variables are edited in their own collection");
|
||||
}
|
||||
@@ -1295,13 +1295,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<bool>, 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_tri(&resp, label, state);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,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-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
|
||||
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. 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 |
|
||||
|
||||
Reference in new issue
Block a user