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:
parent
1147dce395
commit
3f2c1b4ae6
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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%").
|
||||
|
|
|
|||
Loading…
Reference in New Issue