Merge pull request #881 from yelintong/feat/grid-auto-width

Layout grids: Auto line width for columns and rows
This commit is contained in:
BFlat
2026-10-20 05:35:13 +07:00
committed by GitHub
5 changed files with 176 additions and 20 deletions
+85
View File
@@ -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");
}
+14 -1
View File
@@ -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;
};
+15 -4
View File
@@ -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")));
+20 -4
View File
@@ -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<FieldEdit> {
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<f64>,
width: f32,
lead: Lead,
suffix: &str,
) -> Option<FieldEdit> {
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<Edit> {
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<f64>,
width: f32,
empty: &str,
empty: Empty<'_>,
lead: Lead,
suffix: &str,
) -> Option<Edit> {
+42 -11
View File
@@ -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<f64>, 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<f64>, suffix: &str, width: f32, step: f64) -> Option<Edit> {
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<f64>, empty: &str, width: f32, step: f64) -> Option<FieldEdit> {
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<Option<&str>>,
empty: &str,
empty: Empty<'_>,
) -> (Option<Edit>, Option<BindEvent>) {
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<Option<&str>>,
empty: &str,
empty: Empty<'_>,
) -> (Option<Edit>, Option<BindEvent>) {
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<String> = 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.