M3: bind effects to variables (#55)

Shadow X, Y, blur, spread and colour, and layer/background blur radius
take variables from the Effects section like other fields (variable
button on hover, a chip to change or detach once bound). Bindings are
stored as `effects/<index>/<offsetX|offsetY|radius|spread|color>` and
applied in normalisation; pickers offer effect-scoped variables.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 07:52:38 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2105faec4e
commit ec5da02a35
5 changed files with 95 additions and 6 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |
| Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 |
| Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 |
| Styles & variables | 5 | 78% | binding effects | 1–3 |
| Styles & variables | 5 | 82% | bindings that follow effects when they are reordered, grid and component property bindings | 1–2 |
| Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 |
| Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 |
| Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 |
+37
View File
@@ -467,8 +467,16 @@ pub fn apply_bindings(doc: &mut Document) {
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
for (field, var) in binds {
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// `effects/<index>/<offsetX|offsetY|radius|spread|color>`: one effect's value.
if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/'))
&& let Ok(i) = i.parse::<usize>()
{
effect_sets.push((i, prop.to_string(), val));
continue;
}
let jv = match &val {
VariableValue::Color(c) => json!(c.to_css_hex()),
// Opacity variables hold percentages.
@@ -486,6 +494,35 @@ pub fn apply_bindings(doc: &mut Document) {
sets.insert(key.to_string(), jv);
}
let _ = super::props::apply("variables", doc, id, &sets);
if !effect_sets.is_empty() {
apply_effect_bindings(doc, id, &effect_sets);
}
}
}
/// Write bound values into a node's effects (only what changed, so unchanged layers stay shared).
fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) {
let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return };
for (i, prop, val) in sets {
let Some(e) = effects.get_mut(*i) else { continue };
let num = match val {
VariableValue::Number(x) if x.is_finite() => Some(*x),
_ => None,
};
match (&mut e.kind, prop.as_str(), num, val) {
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetX", Some(x), _) => s.offset.0 = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetY", Some(x), _) => s.offset.1 = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0),
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius }, "radius", Some(x), _) => *radius = x.max(0.0),
_ => {}
}
}
if doc.node(id).is_some_and(|n| n.effects != effects)
&& let Ok(n) = doc.get_mut(id)
{
n.effects = effects;
}
}
+2
View File
@@ -235,6 +235,8 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
"fontFamily" => "FONT_FAMILY",
"fontStyle" => "FONT_STYLE",
"stroke" | "strokes/0/color" => "STROKE_COLOR",
f if f.starts_with("effects/") && f.ends_with("/color") => "EFFECT_COLOR",
f if f.starts_with("effects/") => "EFFECT_FLOAT",
"fill" | "fills" | "fills/0" | "fills/0/color" => match doc.node(node).map(|n| &n.kind) {
Some(NodeKind::Text(_)) => "TEXT_FILL",
Some(k) if k.is_frame_like() => "FRAME_FILL",
+35
View File
@@ -82,3 +82,38 @@ fn line_height_and_letter_spacing_follow_number_variables_in_pixels() {
s.execute("variable.set", &json!({"variable": "leading", "value": 30})).unwrap();
assert_eq!(style(&s).line_height, figcraft_doc::LineHeight::Pixels(30.0));
}
#[test]
fn effects_follow_their_variables() {
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("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap();
s.execute("variable.createCollection", &json!({"name": "Fx"})).unwrap();
for (name, kind, value) in [("y", "number", json!(6)), ("blur", "number", json!(12)), ("shadow", "color", json!("#FF000080"))] {
s.execute("variable.create", &json!({"collection": "Fx", "name": name, "type": kind, "value": value})).unwrap();
}
let bind = |s: &mut Session, field: &str, var: &str| {
s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap();
};
bind(&mut s, "effects/0/offsetY", "y");
bind(&mut s, "effects/0/color", "shadow");
bind(&mut s, "effects/1/radius", "blur");
bind(&mut s, "effects/9/radius", "blur");
let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone();
match &fx(&s)[0].kind {
figcraft_doc::EffectKind::DropShadow(sh) => {
assert_eq!(sh.offset.1, 6.0);
assert_eq!(sh.color.to_hex(), "FF0000");
assert!((sh.color.a - 0.5).abs() < 0.01);
}
k => panic!("{k:?}"),
}
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 12.0));
s.execute("variable.set", &json!({"variable": "blur", "value": -3})).unwrap();
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 0.0), "clamped");
// Pickers offer effect-scoped variables for these fields.
let doc = s.doc().unwrap();
assert_eq!(crate::cmd::variables::field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR"));
assert_eq!(crate::cmd::variables::field_scope(doc, r, "effects/0/spread"), Some("EFFECT_FLOAT"));
}
+20 -5
View File
@@ -1045,6 +1045,11 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let _ = t;
}
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::new(("fx", prop, i)), lead, Some(value), "", width)
}
fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) {
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
let set = |app: &mut FigApp, e2: Effect, scrub: Option<bool>| {
@@ -1072,18 +1077,27 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
let inner = matches!(e.kind, EffectKind::InnerShadow(_));
let mk = |s2: Shadow| Effect { kind: if inner { EffectKind::InnerShadow(s2) } else { EffectKind::DropShadow(s2) }, visible: e.visible };
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_x", i)), Lead::Text("X"), Some(s.offset.0), "", half, 1.0);
let ed = fx_field(app, ui, ids, i, "offsetX", Lead::Text("X"), s.offset.0, half);
edit(app, ed, &|v| mk(Shadow { offset: (v, s.offset.1), ..s.clone() }));
let ed = widgets::number_field(ui, Id::new(("fx_y", i)), Lead::Text("Y"), Some(s.offset.1), "", half, 1.0);
let ed = fx_field(app, ui, ids, i, "offsetY", Lead::Text("Y"), s.offset.1, half);
edit(app, ed, &|v| mk(Shadow { offset: (s.offset.0, v), ..s.clone() }));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_b", i)), Lead::Text("Blur"), Some(s.radius), "", half, 1.0);
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), s.radius, half);
edit(app, ed, &|v| mk(Shadow { radius: v.max(0.0), ..s.clone() }));
let ed = widgets::number_field(ui, Id::new(("fx_s", i)), Lead::Text("Spread"), Some(s.spread), "", half, 1.0);
let ed = fx_field(app, ui, ids, i, "spread", Lead::Text("Spread"), s.spread, half);
edit(app, ed, &|v| mk(Shadow { spread: v, ..s.clone() }));
});
// The colour: a bound variable's chip, else swatch, hex and alpha with a variable button.
let color_field = format!("effects/{i}/color");
let bound = app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, &color_field)).is_some();
row(ui, 32.0, |ui| {
let row_rect = Rect::from_min_size(ui.cursor().min, vec2(half * 2.0 + 8.0, FIELD_H));
if bound {
let (r, _) = ui.allocate_exact_size(row_rect.size(), Sense::hover());
crate::variable_ui::bound_chip(app, ui, ids, &color_field, VariableType::Color, r);
return;
}
let p = Paint::solid(s.color);
if widgets::swatch(ui, &p, 16.0).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i });
@@ -1095,6 +1109,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
}
let ed = widgets::number_field(ui, Id::new(("fx_a", i)), Lead::None, Some((f64::from(s.color.a) * 100.0).round()), "%", 56.0, 1.0);
edit(app, ed, &|v| mk(Shadow { color: s.color.with_alpha((v / 100.0) as f32), ..s.clone() }));
crate::variable_ui::bind_button(ui, row_rect, ids, &color_field, VariableType::Color);
});
if !inner {
row(ui, 32.0, |ui| {
@@ -1107,7 +1122,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => {
let bg = matches!(e.kind, EffectKind::BackgroundBlur { .. });
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_r", i)), Lead::Text("Blur"), Some(*radius), "", half * 2.0 + 8.0, 1.0);
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), *radius, half * 2.0 + 8.0);
edit(app, ed, &|v| Effect {
kind: if bg { EffectKind::BackgroundBlur { radius: v.max(0.0) } } else { EffectKind::LayerBlur { radius: v.max(0.0) } },
visible: e.visible,