diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e5..fea0be8e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -549,3 +549,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); } + +/// The middle of the field with response `id`. +fn at(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +/// Type `text` into the field whose response is `id` and press Enter. +fn type_at(u: &mut Ui, id: Id, text: &str) { + let p = at(u, id); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A frame with one layout grid, its settings open in the Design panel. +fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.frame(vec![]); + (u, f) +} + +/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the +/// grid settings sit below the fold. +fn scroll_to(u: &mut Ui, id: Id) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..10 { + let p = at(u, id); + if p.y < 860.0 { + break; + } + u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } + } +} + +/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count +/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's +/// placeholder said 0 everywhere, which turned the gap's mode off. +#[test] +fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let count_id = Id::new(("grid_field", "count", 0usize)); + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); + let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; + scroll_to(&mut u, count_id); + type_at(&mut u, count_id, "Auto"); + assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0"); + type_at(&mut u, count_id, "4"); + assert_eq!(count(&u), 4, "and a number is that count"); + // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); + let size_id = Id::new(("grid_field", "size", 0usize)); + scroll_to(&mut u, size_id); + type_at(&mut u, size_id, "40"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + // Nor its count: a stretched grid has at least one band, so the word commits nothing. + type_at(&mut u, count_id, "Auto"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); + + // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap(); + s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; + let before = gap(&u); + type_at(&mut u, Id::new("al_gap"), "Auto"); + assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index d807a694..747dbaf6 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -926,7 +926,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { let shown = offset.and_then(|o| o.get(i).copied()); - let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + // "Auto" is no offset (right below the layer), not 0: the word isn't taken + // as a value here. + let Some(e) = widgets::number_field_bindable( + ui, + slot.id(key), + Lead::Text(name), + shown, + "", + w, + 1.0, + None, + widgets::Empty::Text(tr("Auto")), + ) + .0 else { continue; }; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index f2834080..afa884a8 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2056,8 +2056,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = - crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, ""); + let id = Id::new(("grid_field", "count", i)); + // A stretched grid has at least one band, so 0 (Auto) isn't a count it can take. + let e = if g.align == GridAlign::Stretch { + crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "") + } else { + crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "") + }; app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); @@ -2090,11 +2095,17 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let span = if cols { tr("Width") } else { tr("Height") }; match g.align { GridAlign::Stretch => { - widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter"))); + widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { + // The frame, divided by the count: shown as Auto and not editable. + ui.add_enabled_ui(false, |ui| { + let auto = widgets::Empty::Text(tr("Auto")); + widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto) + }); field(app, ui, "offset", g.offset, &offset); - field(app, ui, "gutter", g.gutter, &gutter); }); + widgets::sublabels(ui, tr("Gutter"), None); + row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter)); } GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index e6cc6860..39c5396e 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -17,7 +17,7 @@ use crate::a11y::Tip; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -258,7 +258,23 @@ pub fn number_field_or( lead: Lead, suffix: &str, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled() + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled() +} + +/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or +/// count): typing the word for Auto sets 0 ([`Empty::Auto`]). +pub fn number_field_auto( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + lead: Lead, + suffix: &str, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled() } /// [`number_field`] over several layers: a relative edit of mixed values comes back as one for @@ -274,7 +290,7 @@ pub fn number_field_mixed( suffix: &str, width: f32, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix) } fn number_field_edit( @@ -285,7 +301,7 @@ fn number_field_edit( id: Id, value: Option, width: f32, - empty: &str, + empty: Empty<'_>, lead: Lead, suffix: &str, ) -> Option { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ca7504ae..2d986c64 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -37,6 +37,25 @@ impl Lead<'_> { } } +/// What a number field shows when it has no single value, and whether that word types 0. +#[derive(Clone, Copy)] +pub enum Empty<'a> { + /// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else). + Text(&'a str), + /// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0. + Auto, +} + +impl<'a> Empty<'a> { + /// The text shown when the field has no value. + fn text(self) -> &'a str { + match self { + Empty::Text(s) => s, + Empty::Auto => tr("Auto"), + } + } +} + /// Result of a field edit. pub enum FieldEdit { /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). @@ -157,18 +176,19 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { - number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0 + number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0 } /// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there /// is no value to edit relative to, so `+4` is 4. pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the -/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"). +/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes +/// the word for Auto as 0. pub fn number_field_bindable( ui: &mut Ui, id: Id, @@ -178,7 +198,7 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -192,7 +212,7 @@ fn number_field_impl( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -210,7 +230,7 @@ fn number_field_impl( let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); - a11y::number(&field_resp, lead.name(), value, suffix, empty); + a11y::number(&field_resp, lead.name(), value, suffix, empty.text()); a11y::ring_around(ui, rect, &field_resp); // The label scrubs with the pointer; the keyboard stops at the value only. let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG); @@ -259,10 +279,21 @@ fn number_field_impl( }; // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); - let typed = |text: &str| match (parse(text, suffix == "%")?, value) { - (Typed::Value(v), _) => Some(set(v)), - (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), - (Typed::Rel(r), None) => Some(Edit::Each(r)), + let typed = |text: &str| { + // A field where 0 is its automatic value takes the word for Auto as input too: the + // placeholder's own text (so it is what the field shows, in the UI's language) or the + // English word. + if let Empty::Auto = empty { + let t = text.trim(); + if t == empty.text() || t.eq_ignore_ascii_case("auto") { + return Some(set(0.0)); + } + } + match (parse(text, suffix == "%")?, value) { + (Typed::Value(v), _) => Some(set(v)), + (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), + (Typed::Rel(r), None) => Some(Edit::Each(r)), + } }; // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. if editing.is_none() @@ -349,7 +380,7 @@ fn number_field_impl( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => empty.to_string(), + None => empty.text().to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; // A value too long for its room ends in "…", so a cut number never reads as another.