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);