M3: variable binding in the Design panel; bound layers follow variable edits (#11)

- Width, height, corner radius, gap and stroke weight fields show a variable button on hover; it
  opens a picker of the document's number variables by collection (search, values shown). A
  bound field shows the variable's name in a chip (click to change, detach to unbind).
- The fill and stroke style picker lists colour variables; a bound first paint shows the
  variable's name with a detach button.
- Fix: editing a variable's value (or adding a mode value) now updates bound layers; before,
  normalisation skipped the change because no layer was touched directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 03:20:19 +07:00 committed by GitHub
parent 1147dce395
commit 3f2c1b4ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 367 additions and 8 deletions

View File

@ -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 |

View File

@ -652,7 +652,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
pub fn normalize(doc: &mut Document, before: &Document) {
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet<NodeId> = 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;

View File

@ -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());
}

View File

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

View File

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

View File

@ -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"))]

View File

@ -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::<bool>(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::<bool>(key));

View File

@ -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<NodeId>,
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<String> {
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<f64>,
width: f32,
) -> Option<FieldEdit> {
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>(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<String> = 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>(picker_id());
d.remove::<String>(query_id);
});
} else {
ctx.data_mut(|d| d.insert_temp(query_id, query));
}
}

View File

@ -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<f64>, suffix: &str, width: f32, step: f64) -> Option<FieldEdit> {
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<f64>,
suffix: &str,
width: f32,
step: f64,
binding: Option<Option<&str>>,
) -> (Option<FieldEdit>, Option<BindEvent>) {
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<f64>, 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::<String>(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<BindEvent> {
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%").