mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge pull request #881 from yelintong/feat/grid-auto-width
Layout grids: Auto line width for columns and rows
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user