diff --git a/ROADMAP.md b/ROADMAP.md index ad8307e..0519375 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 | | Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 | -| Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 | +| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 | | Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6–10 | | Import/export | 6 | 45% | PDF (in progress), .fig import, export panel polish | 20–35 | | Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e628a5f..921a532 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,7 +652,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { pub fn normalize(doc: &mut Document, before: &Document) { // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet = doc.take_dirty(); - if touched.is_empty() { + // A variable edit changes no layer directly, but bound layers must follow it. + if touched.is_empty() && doc.variables == before.variables { return; } let m = measure::TextMeasure; @@ -734,3 +735,5 @@ pub fn normalize(doc: &mut Document, before: &Document) { mod frame_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod variable_tests; diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs new file mode 100644 index 0000000..0042be3 --- /dev/null +++ b/crates/engine/src/variable_tests.rs @@ -0,0 +1,32 @@ +//! Variable bindings through the session: binding, value edits and modes reach the layers. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +#[test] +fn bound_layers_follow_variable_values_and_unbind() { + let mut s = Session::new(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap(); + s.execute("variable.createCollection", &json!({"name": "Tokens", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Tokens", "name": "size/lg", "type": "number", "value": 48})).unwrap(); + s.execute("variable.create", &json!({"collection": "Tokens", "name": "brand", "type": "color", "value": "#0D99FF"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": "size/lg"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "brand"})).unwrap(); + let node = |s: &Session| s.doc().unwrap().node(r).unwrap().clone(); + assert_eq!(node(&s).width, 48.0); + assert_eq!(node(&s).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("0D99FF".into())); + // Editing the variable updates the bound layer. + s.execute("variable.set", &json!({"variable": "size/lg", "value": 64})).unwrap(); + assert_eq!(node(&s).width, 64.0); + // A mode with another value applies through the page. + s.execute("variable.set", &json!({"variable": "size/lg", "mode": "Dark", "value": 80})).unwrap(); + s.execute("variable.setMode", &json!({"collection": "Tokens", "mode": "Dark"})).unwrap(); + assert_eq!(node(&s).width, 80.0); + // Unbinding keeps the value and stops following. + s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": null})).unwrap(); + s.execute("variable.set", &json!({"variable": "size/lg", "mode": "Dark", "value": 99})).unwrap(); + assert_eq!(node(&s).width, 80.0); + assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": "nope"})).is_err()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fc8c2ad..1610c3a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,7 @@ pub enum Icon { Gap, Reset, Detach, + Variable, Swap, Link, Prototype, @@ -636,6 +637,17 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { p.arc(8.0, 8.0, 5.0, -2.2, 2.6); p.poly(&[(2.8, 2.8), (3.3, 5.8), (6.3, 5.3)], false); } + Variable => { + // A hexagon with a dot: an original mark for "bind a value". + let pts: Vec<(f32, f32)> = (0..6) + .map(|i| { + let a = std::f32::consts::FRAC_PI_3 * i as f32 + std::f32::consts::FRAC_PI_6; + (8.0 + 5.8 * a.cos(), 8.0 + 5.8 * a.sin()) + }) + .collect(); + p.poly(&pts, true); + p.dot(8.0, 8.0, 1.6); + } Detach => { p.poly(&[(8.0, 2.5), (13.5, 8.0), (8.0, 13.5), (2.5, 8.0)], true); p.line(4.0, 12.0, 12.0, 4.0); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 74fece6..dae3559 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 = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0); + 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 = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0); + 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() { @@ -288,7 +288,16 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } let auto = l.main_align == MainAlign::SpaceBetween; let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; - let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(gap_icon), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "gap", + 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)); ui.add_space(8.0); let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37382f9..ad78098 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod variable_ui; pub mod vector_panel; pub mod widgets; @@ -365,6 +366,7 @@ impl FigApp { }); shortcuts::handle(self, &ctx); menus::popups(self, &ctx); + variable_ui::picker(self, &ctx); dialogs::show(self, &ctx); self.toast_ui(&ctx); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index b6d0adf..12d0874 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -519,7 +519,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col, 1.0); app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { - let e = widgets::number_field(ui, Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), "", col, 1.0); + 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() { @@ -636,7 +637,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } } }); - let e = widgets::number_field(ui, Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, "", half - 32.0, 1.0); + let e = crate::variable_ui::number_field(app, ui, &ids, "strokeWeight", Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, half - 32.0); app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); let more = icons::button(ui, Icon::Dots, 24.0, false, "Advanced stroke"); egui::Popup::menu(&more).width(240.0).show(|ui| { @@ -688,7 +689,31 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, } let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); + let bound = (i == 0) + .then(|| app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, &ids, if fills { "fill" } else { "stroke" }))) + .flatten(); match &p.kind { + PaintKind::Solid { .. } if bound.is_some() => { + let name = bound.clone().unwrap_or_default(); + let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0)); + child.painter().rect_filled(chip, CornerRadius::same(4), t.hover); + child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let short = name.rsplit('/').next().unwrap_or(&name).to_string(); + child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text( + pos2(chip.min.x + 5.0, r.center().y), + Align2::LEFT_CENTER, + short, + regular(11.0), + t.text, + ); + let _ = ui.interact(chip, Id::new(("bound_paint", fills)), Sense::hover()).on_hover_text(&name); + let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let dresp = ui.interact(dr, Id::new(("detach_paint", fills)), Sense::click()).on_hover_text("Detach variable"); + icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); + if dresp.clicked() { + app.run("variable.bind", json!({"ids": ids, "field": if fills { "fill" } else { "stroke" }, "variable": null})); + } + } PaintKind::Solid { color } => { if let Some(h) = widgets::text_field_inline(&mut child, Id::new(("hex", fills, i)), &color.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) @@ -770,6 +795,31 @@ fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], f } }); } + // Colour variables bind the first paint's colour. + let modes = std::collections::BTreeMap::new(); + let colors: Vec<(String, String, figcraft_color::Color)> = doc + .variables + .variables + .iter() + .filter(|v| v.kind == VariableType::Color) + .filter_map(|v| match doc.variables.resolve(&v.id, &modes) { + Some(VariableValue::Color(c)) => Some((v.id.clone(), v.name.clone(), c)), + _ => None, + }) + .collect(); + if !colors.is_empty() { + ui.add_space(4.0); + ui.label(egui::RichText::new("Color variables").font(semibold(11.0))); + for (vid, name, c) in &colors { + ui.horizontal(|ui| { + widgets::swatch(ui, &Paint::solid(*c), 14.0); + if ui.selectable_label(false, name).clicked() { + app.run("variable.bind", json!({"ids": ids, "field": if fills { "fill" } else { "stroke" }, "variable": vid})); + close = true; + } + }); + } + } }); if close { ui.data_mut(|d| d.remove::(key)); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs new file mode 100644 index 0000000..7f00908 --- /dev/null +++ b/crates/ui-egui/src/variable_ui.rs @@ -0,0 +1,166 @@ +//! Binding variables to properties from the Design panel: number fields show a variable button on +//! hover and the bound variable's name once bound; colour fills bind through the style picker. The +//! picker popover lists the document's variables of the matching type, by collection. + +use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::{Document, NodeId, VariableType}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets::{self, BindEvent, FieldEdit, Lead}; + +/// The open variable picker: which layers and field it binds, the variable type, where it opens. +#[derive(Clone)] +struct Picker { + ids: Vec, + field: String, + kind: VariableType, + at: Pos2, +} + +fn picker_id() -> Id { + Id::new("variable_picker") +} + +/// The variable `field` is bound to on every one of `ids` (its name), if they agree. +pub fn bound_name(doc: &Document, ids: &[NodeId], field: &str) -> Option { + let mut it = ids.iter().map(|i| doc.node(*i).and_then(|n| n.bound.get(field)).cloned()); + let first = it.next().flatten()?; + if !it.all(|v| v.as_ref() == Some(&first)) { + return None; + } + doc.variables.get(&first).map(|v| v.name.clone()) +} + +/// Open the picker for `field` of `ids` at `at`. +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. +pub fn number_field( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + lead: Lead, + value: Option, + 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 => {} + } + edit +} + +/// 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 }; + let Ok(doc) = app.session.doc().cloned() else { return }; + let t = Tokens::get(ctx); + let query_id = Id::new("variable_picker_query"); + let mut query: String = ctx.data(|d| d.get_temp(query_id)).unwrap_or_default(); + let q = query.trim().to_lowercase(); + let mut chosen: Option = None; + let mut close = false; + let area = egui::Area::new(Id::new("variable_picker_area")).order(egui::Order::Foreground).fixed_pos(p.at + vec2(0.0, 4.0)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(240.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Variables").font(semibold(11.0)).color(t.menu_text)); + }); + ui.add_space(4.0); + let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search").font(regular(11.0)).desired_width(224.0)); + if !r.has_focus() && ui.memory(|m| m.focused().is_none()) { + r.request_focus(); + } + ui.add_space(4.0); + let mut any = false; + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for c in &doc.variables.collections { + let vars: Vec<_> = doc + .variables + .variables + .iter() + .filter(|v| v.collection == c.id && v.kind == p.kind && (q.is_empty() || v.name.to_lowercase().contains(&q))) + .collect(); + if vars.is_empty() { + continue; + } + any = true; + ui.label(egui::RichText::new(&c.name).font(regular(10.0)).color(t.menu_dim)); + for v in vars { + let (r, resp) = ui.allocate_exact_size(vec2(224.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + let modes = std::collections::BTreeMap::new(); + let value = doc.variables.resolve(&v.id, &modes); + match &value { + Some(figcraft_doc::VariableValue::Color(c)) => { + let sw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)); + widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); + } + _ => icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), + Icon::Variable, + t.menu_text, + ), + } + ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, &v.name, regular(11.0), t.menu_text); + if let Some(val) = value.filter(|v| !matches!(v, figcraft_doc::VariableValue::Color(_))) { + ui.painter().text(pos2(r.max.x - 6.0, r.center().y), Align2::RIGHT_CENTER, val.display(), regular(11.0), t.menu_dim); + } + if resp.clicked() { + chosen = Some(v.id.clone()); + } + } + } + }); + if !any { + let kind = match p.kind { + VariableType::Color => "colour", + VariableType::Number => "number", + VariableType::String => "string", + VariableType::Boolean => "boolean", + }; + ui.label( + egui::RichText::new(format!("No {kind} variables yet. Create them in Local variables.")).font(regular(11.0)).color(t.menu_dim), + ); + } + }); + }); + let rect = area.response.rect; + ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("variable_picker_border"))).rect_stroke( + rect, + CornerRadius::same(10), + Stroke::new(1.0, Color32::from_black_alpha(40)), + egui::StrokeKind::Inside, + ); + let clicked_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos))); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || clicked_outside { + close = true; + } + if let Some(var) = chosen { + app.run("variable.bind", json!({"ids": p.ids, "field": p.field, "variable": var})); + close = true; + } + if close { + ctx.data_mut(|d| { + d.remove::(picker_id()); + d.remove::(query_id); + }); + } else { + ctx.data_mut(|d| d.insert_temp(query_id, query)); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3440981..9449bff 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -34,11 +34,38 @@ fn fmt_num(v: f64) -> String { if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } } +/// What a number field's variable button did. +pub enum BindEvent { + /// Open the variable picker below this point. + Open(egui::Pos2), + /// Detach the bound variable. + Detach, +} + /// A number field `width` wide. `value` None shows "Mixed". `suffix` (e.g. "%" or "°") is shown /// after the number. Typing supports simple math (`10+5`, `*2`). pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { + number_field_bindable(ui, id, lead, value, suffix, width, step, None).0 +} + +/// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows +/// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the +/// detach button unbinds). +pub fn number_field_bindable( + ui: &mut Ui, + id: Id, + lead: Lead, + value: Option, + suffix: &str, + width: f32, + step: f64, + binding: Option>, +) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + if let Some(Some(name)) = binding { + return (None, bound_field(ui, id, lead, rect, name)); + } let lead_w = match lead { Lead::None => 6.0, Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0), @@ -150,7 +177,65 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); } } - out + // The variable button shows while the pointer is over a bindable field. + let mut bind = None; + if binding.is_some() && ui.data(|d| d.get_temp::(editing_id)).is_none() { + let br = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0)); + let bresp = ui.interact(br, id.with("bind"), Sense::click()); + if rect.contains(ui.input(|i| i.pointer.hover_pos()).unwrap_or(egui::Pos2::new(f32::NAN, f32::NAN))) { + if bresp.hovered() { + ui.painter().rect_filled(br, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), br.shrink(3.0), Icon::Variable, t.icon_secondary); + } + let bresp = bresp.on_hover_text("Apply variable"); + if bresp.clicked() { + bind = Some(BindEvent::Open(rect.left_bottom())); + } + } + (out, bind) +} + +/// 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 { + 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); + if resp.hovered() { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + let lead_w = match lead { + Lead::None => 4.0, + Lead::Text(s) => { + ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary); + (12.0 + s.chars().count() as f32 * 7.0).max(24.0) + } + Lead::Icon(i) => { + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary); + 24.0 + } + }; + let dr = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0)); + let chip = Rect::from_min_max(pos2(rect.min.x + lead_w - 2.0, rect.min.y + 3.0), pos2(dr.min.x - 2.0, rect.max.y - 3.0)); + ui.painter().rect_filled(chip, CornerRadius::same(4), t.hover); + ui.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside); + // Grouped names ("size/card") show their last part; the tooltip has the full name. + let short = name.rsplit('/').next().unwrap_or(name); + let galley = ui.painter().layout_no_wrap(short.to_string(), regular(11.0), t.text); + ui.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).galley(pos2(chip.min.x + 5.0, chip.center().y - galley.size().y / 2.0), galley, t.text); + let dresp = ui.interact(dr, id.with("detach"), Sense::click()).on_hover_text("Detach variable"); + if resp.hovered() || dresp.hovered() { + if dresp.hovered() { + ui.painter().rect_filled(dr, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); + } + if dresp.clicked() { + return Some(BindEvent::Detach); + } + let resp = resp.on_hover_text(name); + resp.clicked().then(|| BindEvent::Open(rect.left_bottom())) } /// Evaluate a typed number: plain, or simple arithmetic relative to `cur` ("+10", "*2", "50%").