From cb8151c999b76d020e360f0d09edf6b7ecc320dc Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 04:57:36 +0700 Subject: [PATCH] M3: bind variables to opacity, padding, gaps, sizes, font size, text and visibility (#31) Number fields for opacity (variables hold percentages), individual corner radii, auto layout padding and gaps, min/max sizes and font size now take variables like width and corner radius already did. Text content binds to string variables and visibility to boolean ones from the Typography and Appearance section headers; bound ones show as a variable chip that changes or detaches the binding. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/styles.rs | 4 +- crates/engine/src/variable_tests.rs | 36 ++++++++++++++++++ crates/ui-egui/src/layout_panel.rs | 57 +++++++++++++++++++++++++---- crates/ui-egui/src/right.rs | 52 ++++++++++++++++++++++---- crates/ui-egui/src/typography.rs | 13 ++++++- crates/ui-egui/src/variable_ui.rs | 42 ++++++++++++++++----- crates/ui-egui/src/widgets.rs | 3 +- 8 files changed, 180 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 26e8843..b42a10b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Auto layout & constraints | 9 | 65% | nested hug/fill edge cases, drag-reorder across nested auto layout frames | 4–7 | | Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 | | Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 | -| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 | +| Styles & variables | 5 | 66% | aliasing UI, scoping, binding effects, line height and letter spacing | 4–7 | | Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 | | Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 | | Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 | diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 57b55d3..d414b90 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { spec!( "variable.bind", "Bind variable", - "{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}", + "{ids?, field (fill|stroke|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…), variable (null unbinds)}", true, |s, p| { let field = req_string("variable.bind", p, "field")?; @@ -453,6 +453,8 @@ pub fn apply_bindings(doc: &mut Document) { let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; let jv = match &val { VariableValue::Color(c) => json!(c.to_css_hex()), + // Opacity variables hold percentages. + VariableValue::Number(x) if field == "opacity" => json!((x / 100.0).clamp(0.0, 1.0)), VariableValue::Number(x) => json!(x), VariableValue::String(x) => json!(x), VariableValue::Boolean(b) => json!(b), diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index 0042be3..d01db16 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -30,3 +30,39 @@ fn bound_layers_follow_variable_values_and_unbind() { assert_eq!(node(&s).width, 80.0); assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": "nope"})).is_err()); } + +#[test] +fn opacity_padding_text_and_visibility_follow_their_variables() { + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap(); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 200, "y": 0})).unwrap()["id"]).unwrap(); + s.execute("variable.createCollection", &json!({"name": "Tokens"})).unwrap(); + for (name, kind, value) in + [("fade", "number", json!(40)), ("pad", "number", json!(12)), ("label", "string", json!("Sign in")), ("shown", "boolean", json!(false))] + { + s.execute("variable.create", &json!({"collection": "Tokens", "name": name, "type": kind, "value": value})).unwrap(); + } + let bind = |s: &mut Session, id, field: &str, var: &str| { + s.execute("variable.bind", &json!({"ids": [id], "field": field, "variable": var})).unwrap(); + }; + bind(&mut s, f, "opacity", "fade"); + bind(&mut s, f, "paddingHorizontal", "pad"); + bind(&mut s, t, "characters", "label"); + bind(&mut s, t, "visible", "shown"); + let doc = s.doc().unwrap(); + let frame = doc.node(f).unwrap(); + // Opacity variables are percentages. + assert!((frame.opacity - 0.4).abs() < 1e-6, "{}", frame.opacity); + let pad = frame.layout.as_ref().unwrap().padding; + assert_eq!((pad[1], pad[3]), (12.0, 12.0)); + let text = doc.node(t).unwrap(); + assert_eq!(text.kind.text().map(|x| x.characters.as_str()), Some("Sign in")); + assert!(!text.visible); + // Out-of-range percentages clamp; booleans flip visibility back. + s.execute("variable.set", &json!({"variable": "fade", "value": 250})).unwrap(); + s.execute("variable.set", &json!({"variable": "shown", "value": true})).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(f).unwrap().opacity, 1.0); + assert!(doc.node(t).unwrap().visible); +} diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index dae3559..5514cc3 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -114,9 +114,9 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, "Dimensions", None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, col); + let e = crate::variable_ui::number_field(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); app.field(&ids, "width", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, col); + let e = crate::variable_ui::number_field(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); app.field(&ids, "height", e, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() { @@ -210,7 +210,7 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori let lead = format!("{} {}", if is_min { "Min" } else { "Max" }, axis.letter); if has(is_min) { let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); - let e = widgets::number_field(ui, Id::new(("lay_limit", key)), Lead::Text(&lead), v, "", col, 1.0); + let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(&lead), v, "", col); app.field(&ids, key, e, |v| json!(v.max(0.0))); } else if add_slot(ui, &lead, col).clicked() { app.set_props(&ids, json!({key: current})); @@ -279,10 +279,20 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui.vertical(|ui| { if grid_mode { // Grid: gap between columns and between rows. - let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), Some(l.gap), "", 96.0, 1.0); + let e = crate::variable_ui::number_field(app, ui, ids, "columnGap", Id::new("al_gap"), Lead::Icon(Icon::Gap), Some(l.gap), "", 96.0); app.field(ids, "columnGap", e, |v| json!(v.max(0.0))); ui.add_space(8.0); - let e = widgets::number_field(ui, Id::new("al_rowgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", 96.0, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "rowGap", + Id::new("al_rowgap"), + Lead::Icon(Icon::DistributeV), + Some(l.cross_gap), + "", + 96.0, + ); app.field(ids, "rowGap", e, |v| json!(v.max(0.0))); return; } @@ -296,6 +306,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { Id::new("al_gap"), Lead::Icon(gap_icon), if auto { None } else { Some(l.gap) }, + "", 96.0, ); app.field(ids, "gap", e, |v| json!(v)); @@ -328,16 +339,46 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), (pl == pr).then_some(pl), "", col, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + Lead::Icon(Icon::PaddingH), + (pl == pr).then_some(pl), + "", + col, + ); app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), (pt == pb).then_some(pt), "", col, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + Lead::Icon(Icon::PaddingV), + (pt == pb).then_some(pt), + "", + col, + ); app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); }); if l.mode == LayoutMode::Horizontal && l.wrap { // Wrapped rows: the gap between rows. widgets::sublabels(ui, "Row gap", None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_crossgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", col, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "crossGap", + Id::new("al_crossgap"), + Lead::Icon(Icon::DistributeV), + Some(l.cross_gap), + "", + col, + ); app.field(ids, "crossGap", e, |v| json!(v.max(0.0))); }); } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index fbd6e34..20908b5 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -504,16 +504,24 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let hdr = widgets::section_header( ui, "Appearance", - &[(if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H"), (Icon::Blend, "Blend mode")], + &[ + (if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H"), + (Icon::Variable, "Apply variable to visibility"), + (Icon::Blend, "Blend mode"), + ], false, ); if hdr == Some(0) { app.run("object.toggleVisible", json!({"ids": ids})); } if hdr == Some(1) { + crate::variable_ui::open_at_pointer(ui, &ids, "visible", VariableType::Boolean); + } + if hdr == Some(2) { ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); } let col = widgets::col_width(ui.available_width()); + crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye), col * 2.0 + 8.0); // Masks: how they mask. if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) { let mt = common(doc, sel, |n| n.mask_type); @@ -535,11 +543,20 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, "Opacity", radius_ok.then_some("Corner radius")); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col, 1.0); + let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col); app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { - let e = - crate::variable_ui::number_field(app, ui, &ids, "cornerRadius", Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), col); + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + "cornerRadius", + Id::new("ap_rad"), + Lead::Icon(Icon::CornerRadius), + r.flatten(), + "", + col, + ); app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Individual corners").clicked() { @@ -552,13 +569,33 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let keys = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"]; row(ui, 32.0, |ui| { for (k, key) in keys.iter().enumerate().take(2) { - let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::new(("corner", k)), + Lead::Icon(Icon::CornerRadius), + radii.map(|r| r[k]), + "", + col, + ); app.field(&ids, key, e, |v| json!(v.max(0.0))); } }); row(ui, 32.0, |ui| { for (k, key) in keys.iter().enumerate().skip(2) { - let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::new(("corner", k)), + Lead::Icon(Icon::CornerRadius), + radii.map(|r| r[k]), + "", + col, + ); app.field(&ids, key, e, |v| json!(v.max(0.0))); } }); @@ -704,7 +741,8 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: }); let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle))); let weight_w = half - if sided { 60.0 } else { 32.0 }; - let e = crate::variable_ui::number_field(app, ui, &ids, "strokeWeight", Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, weight_w); + let e = + crate::variable_ui::number_field(app, ui, &ids, "strokeWeight", Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, "", weight_w); app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); if sided { let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, "Individual strokes"); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 1b68a2d..dc0260b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -121,7 +121,11 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) } }; - widgets::section_header(ui, "Typography", &[(Icon::Styles, "Text styles")], false); + let hdr = widgets::section_header(ui, "Typography", &[(Icon::Variable, "Apply variable to text"), (Icon::Styles, "Text styles")], false); + if hdr == Some(0) { + crate::variable_ui::open_at_pointer(ui, &ids, "characters", VariableType::String); + } + crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text), f32::INFINITY); // Font family. let family = uniform(&styles, |s| s.font_family.clone()); @@ -163,7 +167,12 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) }); let size = uniform(&styles, |s| s.font_size); ui.spacing_mut().item_spacing.x = 0.0; - let e = widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half - 20.0, 1.0); + // The whole layer's size can follow a variable; a selected range just takes a value. + let e = if editing { + widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half - 20.0, 1.0) + } else { + crate::variable_ui::number_field(app, ui, &ids, "fontSize", Id::new("ty_size"), Lead::None, size, "", half - 20.0) + }; match e { Some(FieldEdit::Set(v)) => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))), // The text editing session already groups undo; just apply. diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 7f00908..d3281ed 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -39,7 +39,25 @@ pub fn open(ui: &Ui, ids: &[NodeId], field: &str, kind: VariableType, at: Pos2) ui.data_mut(|d| d.insert_temp(picker_id(), Picker { ids: ids.to_vec(), field: field.to_string(), kind, at })); } -/// A number field whose value can be bound to a number variable. +/// Open the picker for `field` of `ids` under the pointer (from a section header button). +pub fn open_at_pointer(ui: &Ui, ids: &[NodeId], field: &str, kind: VariableType) { + let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or_default(); + open(ui, ids, field, kind, at); +} + +/// Act on a bound field's event: change the variable, or detach it. +fn handle(app: &mut FigApp, ui: &Ui, ids: &[NodeId], field: &str, kind: VariableType, ev: Option) { + match ev { + Some(BindEvent::Open(at)) => open(ui, ids, field, kind, at), + Some(BindEvent::Detach) => { + app.run("variable.bind", json!({"ids": ids, "field": field, "variable": null})); + } + None => {} + } +} + +/// A number field whose value can be bound to a number variable. `suffix` follows the number +/// ("%" for opacity, whose variables hold percentages). pub fn number_field( app: &mut FigApp, ui: &mut Ui, @@ -48,20 +66,26 @@ pub fn number_field( id: Id, lead: Lead, value: Option, + suffix: &str, width: f32, ) -> Option { let name = app.session.doc().ok().and_then(|d| bound_name(d, ids, field)); - let (edit, ev) = widgets::number_field_bindable(ui, id, lead, value, "", width, 1.0, Some(name.as_deref())); - match ev { - Some(BindEvent::Open(at)) => open(ui, ids, field, VariableType::Number, at), - Some(BindEvent::Detach) => { - app.run("variable.bind", json!({"ids": ids, "field": field, "variable": null})); - } - None => {} - } + let (edit, ev) = widgets::number_field_bindable(ui, id, lead, value, suffix, width, 1.0, Some(name.as_deref())); + handle(app, ui, ids, field, VariableType::Number, ev); edit } +/// 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) { + let Some(name) = app.session.doc().ok().and_then(|d| bound_name(d, ids, field)) else { return }; + crate::right::row(ui, 32.0, |ui| { + let (rect, _) = ui.allocate_exact_size(vec2(width.min(ui.available_width()), widgets::FIELD_H), Sense::hover()); + let ev = widgets::bound_field(ui, Id::new(("bound_row", field)), lead, rect, &name); + handle(app, ui, ids, field, kind, ev); + }); +} + /// The picker popover (call once per frame). pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7859357..34f97cd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -198,7 +198,8 @@ pub fn number_field_bindable( /// A field bound to a variable: the lead, then the variable's name in a chip; the chip opens the /// picker and the detach button unbinds. -fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> Option { +/// A field showing the variable `name` bound to it in `rect`: click to change, detach to unbind. +pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> Option { let t = Tokens::get(ui.ctx()); let resp = ui.interact(rect, id.with("bound"), Sense::click()); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);