M1: stroke styles: each side's stroke weight binds to a number variable

Colour styles already applied to strokes (the stroke section's style picker,
style.apply target stroke, edits following, detach). What was missing for
strokes: the per-side weights as their own bindable fields
(strokeTopWeight/Right/Bottom/Left: node.set keys, STROKE_FLOAT scope,
stroke override group, REST boundVariables import). Setting or binding one
side turns on individual strokes with the other sides at the weight. The
Design panel's per-side fields are bindable, two to a row so a variable's
name fits.
This commit is contained in:
bflatastic
2026-10-11 19:31:02 +07:00
parent d01d18cd88
commit d3ebb39706
6 changed files with 112 additions and 21 deletions
+16 -2
View File
@@ -8,9 +8,12 @@ use serde_json::{Map, Value, json};
use crate::params::*;
use crate::{Result, bad};
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@@ -341,6 +344,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"strokeSides" => {
doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) }
}
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => {
let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0);
let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let st = &mut doc.get_mut(id)?.stroke;
// One side set on its own turns on individual strokes, the others at the weight.
let mut sides = st.sides.unwrap_or([st.weight; 4]);
if let Some(slot) = sides.get_mut(i) {
*slot = w;
}
st.sides = Some(sides);
}
"strokeCap" => {
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
let n = doc.get_mut(id)?;
@@ -718,7 +732,7 @@ pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "widthProfile" | "brush" | "strokeBrush" => "strokes",
| "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "widthProfile" | "brush" | "strokeBrush" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
+1 -1
View File
@@ -364,7 +364,7 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
| "paddingBottom" | "paddingLeft" => "GAP",
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "CORNER_RADIUS",
"opacity" => "OPACITY",
"strokeWeight" => "STROKE_FLOAT",
"strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => "STROKE_FLOAT",
"fontSize" => "FONT_SIZE",
"lineHeight" => "LINE_HEIGHT",
"letterSpacing" => "LETTER_SPACING",
+2
View File
@@ -1309,6 +1309,8 @@ mod shapebuilder_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod stroke_style_tests;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod svg_import_tests;
+77
View File
@@ -0,0 +1,77 @@
//! Styles and variables on strokes: a colour style applied to a layer's strokes (linked, following
//! edits of the style, one undo step, detached), and stroke weights, each side on its own too,
//! bound to number variables that follow the frame's mode.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_color::Color;
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
s.doc().unwrap().node(id).unwrap().clone()
}
fn stroke_hex(s: &Session, id: NodeId) -> Option<String> {
node(s, id).strokes.first().and_then(|p| p.solid_color()).map(Color::to_hex)
}
#[test]
fn a_colour_style_on_strokes_links_follows_edits_and_detaches() {
let mut s = Session::new();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let fill = node(&s, r).fills;
let st = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand/Line", "value": ["#ff0000"]}));
let st = st["id"].as_str().unwrap().to_string();
ex(&mut s, "style.apply", json!({"ids": [r], "style": "Brand/Line", "target": "stroke"}));
assert_eq!(node(&s, r).styles.stroke.as_deref(), Some(st.as_str()));
assert!(node(&s, r).styles.fill.is_none());
assert_eq!(node(&s, r).fills, fill, "the fill keeps its own paint");
let red = stroke_hex(&s, r);
assert!(red.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("FF0000")), "{red:?}");
// One undo step takes the style off again.
ex(&mut s, "edit.undo", json!({}));
assert!(node(&s, r).styles.stroke.is_none());
ex(&mut s, "edit.redo", json!({}));
ex(&mut s, "style.update", json!({"style": st, "value": ["#0000ff"]}));
let blue = stroke_hex(&s, r);
assert!(blue.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("0000FF")), "linked strokes follow the style: {blue:?}");
ex(&mut s, "style.detach", json!({"ids": [r], "which": "stroke"}));
assert!(node(&s, r).styles.stroke.is_none());
assert_eq!(stroke_hex(&s, r), blue, "detaching keeps the colour");
}
#[test]
fn stroke_weights_and_each_side_bind_to_number_variables() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
let r = id_of(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": f, "x": 10, "y": 10, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2}}));
let c = ex(&mut s, "variable.createCollection", json!({"name": "Borders", "modes": ["Thin", "Thick"]}));
let c = c["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": c, "name": "border/top", "type": "number", "values": {"Thin": 1, "Thick": 6}}));
ex(&mut s, "variable.create", json!({"collection": c, "name": "border/all", "type": "number", "values": {"Thin": 3, "Thick": 5}}));
// One side: individual strokes turn on, the other sides keep the weight.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
assert_eq!(node(&s, r).stroke.sides, Some([1.0, 2.0, 2.0, 2.0]));
ex(&mut s, "variable.setMode", json!({"ids": [f], "collection": c, "mode": "Thick"}));
assert_eq!(node(&s, r).stroke.sides, Some([6.0, 2.0, 2.0, 2.0]), "the frame's mode picks the value");
// The whole weight binds too; per-side keys set one side.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeWeight", "variable": "border/all"}));
assert_eq!(node(&s, r).stroke.weight, 5.0);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokeLeftWeight": 4}}));
assert_eq!(node(&s, r).stroke.sides.map(|x| x[3]), Some(4.0));
assert!(s.execute("node.set", &json!({"ids": [r], "props": {"strokeRightWeight": "thick"}})).is_err());
// Only stroke-weight variables are offered for these fields, and instances count them as
// stroke overrides.
let doc = s.doc().unwrap();
for field in crate::cmd::props::STROKE_SIDES.into_iter().chain(["strokeWeight"]) {
assert_eq!(crate::cmd::variables::field_scope(doc, r, field), Some("STROKE_FLOAT"), "{field}");
assert_eq!(crate::cmd::props::override_group(field), "strokes", "{field}");
}
}
+3 -2
View File
@@ -326,8 +326,9 @@ fn node_binding<'v>(k: &str, v: &'v Value, n: &Node, out: &mut Vec<(String, &'v
}
"itemSpacing" => push("gap", alias(v)),
"counterAxisSpacing" => push("crossGap", alias(v)),
"characters" | "visible" | "opacity" | "strokeWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius"
| "topRightRadius" | "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)),
"characters" | "visible" | "opacity" | "strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight"
| "strokeLeftWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius" | "topRightRadius"
| "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)),
"rectangleCornerRadii" => {
for (key, f) in [
("RECTANGLE_TOP_LEFT_CORNER_RADIUS", "topLeftRadius"),
+13 -16
View File
@@ -1100,23 +1100,20 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
ui.data_mut(|d| d.insert_temp(adv_id, !open));
}
});
// Per-side weights.
// Per-side weights, two to a row (like the corner radii) so a bound variable's name fits:
// each side binds to a number variable on its own.
if let Some(sides) = common(doc, sel, |n| n.stroke.sides).flatten() {
let quarter = (ui.available_width() - PAD * 2.0 - 3.0 * 6.0) / 4.0;
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (k, label) in ["T", "R", "B", "L"].iter().enumerate() {
let v = sides.get(k).copied();
let e = widgets::number_field(ui, Id::new(("stroke_side", k)), Lead::Text(label), v, "", quarter, 1.0);
app.field(&ids, "strokeSides", e, |x| {
let mut n = sides;
if let Some(slot) = n.get_mut(k) {
*slot = x.max(0.0);
}
json!(n)
});
}
});
let col = widgets::col_width(ui.available_width());
let fields = ["T", "R", "B", "L"].into_iter().zip(figcraft_engine::cmd::props::STROKE_SIDES);
for first in [0, 2] {
row(ui, 32.0, |ui| {
for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) {
let v = sides.get(k).copied();
let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col);
app.field(&ids, field, e, |x| json!(x.max(0.0)));
}
});
}
}
if ui.data(|d| d.get_temp::<bool>(Id::new("stroke_adv"))).unwrap_or(false) {
stroke_settings(app, ui, doc, sel);