mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user