From f2c4fb21e1232c34092e2f06cc273b408bc213dc Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 07:24:19 +0700 Subject: [PATCH] M3: bind line height and letter spacing to variables (#53) The Typography section's line height and letter spacing fields now take number variables (in pixels) like the other number fields: a variable button on hover opens the picker (scoped to line height or letter spacing), and a bound field shows the variable's chip to change or detach it. While a text range is selected they keep taking plain values. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/variable_tests.rs | 16 +++++++++++++++ crates/ui-egui/src/typography.rs | 30 +++++++++++++++++++++++++++-- crates/ui-egui/src/variable_ui.rs | 27 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 52eea1f9..5d4751df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,7 +171,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 | | Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 | | Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 | -| Styles & variables | 5 | 75% | binding effects, line height and letter spacing | 2–4 | +| Styles & variables | 5 | 78% | binding effects | 1–3 | | Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | | Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 | diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index d01db161..ab0799ce 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -66,3 +66,19 @@ fn opacity_padding_text_and_visibility_follow_their_variables() { assert_eq!(doc.node(f).unwrap().opacity, 1.0); assert!(doc.node(t).unwrap().visible); } + +#[test] +fn line_height_and_letter_spacing_follow_number_variables_in_pixels() { + let mut s = Session::new(); + let t = id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "leading", "type": "number", "value": 24})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "tracking", "type": "number", "value": 2})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "lineHeight", "variable": "leading"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "letterSpacing", "variable": "tracking"})).unwrap(); + let style = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().style.clone(); + assert_eq!(style(&s).line_height, figcraft_doc::LineHeight::Pixels(24.0)); + assert_eq!(style(&s).letter_spacing, figcraft_doc::LetterSpacing::Pixels(2.0)); + s.execute("variable.set", &json!({"variable": "leading", "value": 30})).unwrap(); + assert_eq!(style(&s).line_height, figcraft_doc::LineHeight::Pixels(30.0)); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 5d30d544..19227e5a 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -200,14 +200,16 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) row(ui, 32.0, |ui| { let lh = uniform(&styles, |s| s.line_height); let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| MIXED.into()); - if let Some(v) = lead_field(ui, Id::new("ty_lh"), LeadGlyph::LineHeight, &lh_label, half, "Line height") + let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: "Line height" }; + if let Some(v) = spacing_field(app, ui, &ids, &field, &lh_label, half, editing) && let Some(v) = parse_line_height(&v, lh) { apply_style(app, "lineHeight", v); } let ls = uniform(&styles, |s| s.letter_spacing); let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| MIXED.into()); - if let Some(v) = lead_field(ui, Id::new("ty_ls"), LeadGlyph::LetterSpacing, &ls_label, half, "Letter spacing") + let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: "Letter spacing" }; + if let Some(v) = spacing_field(app, ui, &ids, &field, &ls_label, half, editing) && let Some(v) = parse_letter_spacing(&v, ls) { apply_style(app, "letterSpacing", v); @@ -624,6 +626,30 @@ fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32) { } /// A text field with a leading glyph (line height, letter spacing…). Returns the committed text. +/// Line height or letter spacing: which property, its field id, glyph and tooltip. +struct SpacingField { + field: &'static str, + id: Id, + glyph: LeadGlyph, + tip: &'static str, +} + +/// A spacing field that whole layers can bind to a number variable (in pixels): the variable's +/// chip when bound, else the editable field with a variable button on hover. While a text range is +/// selected it only takes values. Returns the typed text. +fn spacing_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], f: &SpacingField, value: &str, width: f32, range: bool) -> Option { + let rect = Rect::from_min_size(ui.cursor().min, vec2(width, FIELD_H)); + if !range && crate::variable_ui::bound_chip(app, ui, ids, f.field, VariableType::Number, rect) { + ui.allocate_rect(rect, Sense::hover()); + return None; + } + let out = lead_field(ui, f.id, f.glyph, value, width, f.tip); + if !range && ui.data(|d| d.get_temp::(f.id.with("editing")).is_none()) { + crate::variable_ui::bind_button(ui, rect, ids, f.field, VariableType::Number); + } + out +} + fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, tip: &str) -> Option { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index c833216c..1414cff3 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -91,6 +91,33 @@ pub fn number_field( edit } +/// When `field` of `ids` is bound: the variable's chip in `rect` (change or detach it) and true; +/// otherwise nothing and false. +pub fn bound_chip(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kind: VariableType, rect: Rect) -> bool { + let Some(name) = app.session.doc().ok().and_then(|d| bound_name(d, ids, field)) else { return false }; + let ev = widgets::bound_field(ui, Id::new(("bound_chip", field)), Lead::None, rect, &name); + handle(app, ui, ids, field, kind, ev); + true +} + +/// A variable button at the right end of `rect` while the pointer is over it: opens the picker +/// to bind `field` of `ids`. +pub fn bind_button(ui: &mut Ui, rect: Rect, ids: &[NodeId], field: &str, kind: VariableType) { + if !ui.rect_contains_pointer(rect) { + return; + } + let t = Tokens::get(ui.ctx()); + let b = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0)); + let resp = ui.interact(b, Id::new(("bind_button", field)), Sense::click()).on_hover_text("Apply variable"); + if resp.hovered() { + ui.painter().rect_filled(b, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), b.shrink(3.0), Icon::Variable, t.icon_secondary); + if resp.clicked() { + open(ui, ids, field, kind, rect.left_bottom()); + } +} + /// A field showing the variable `field` of `ids` is bound to, for bindings without a value field /// of their own (text content, visibility): `width` wide, at most the row. Nothing when unbound. pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kind: VariableType, lead: Lead, width: f32) {