mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user