diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 137daa64..276f79b6 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -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 { 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", diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 75afeb49..a71e8b0f 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5221f472..c6aa16c6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/stroke_style_tests.rs b/crates/engine/src/stroke_style_tests.rs new file mode 100644 index 00000000..d7409b66 --- /dev/null +++ b/crates/engine/src/stroke_style_tests.rs @@ -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 { + 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}"); + } +} diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 41ee4d47..af363c45 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -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"), diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index b7e25349..c3e2ddef 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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::(Id::new("stroke_adv"))).unwrap_or(false) { stroke_settings(app, ui, doc, sel);