mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user