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 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 07:24:19 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0edb62ff7d
commit f2c4fb21e1
4 changed files with 72 additions and 3 deletions
+1 -1
View File
@@ -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 |
+16
View File
@@ -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));
}
+28 -2
View File
@@ -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<String> {
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::<String>(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<String> {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
+27
View File
@@ -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) {