Files
figcraft/crates/engine/src/cmd/props.rs
T
BFlatandClaude Opus 5.5 340b673666 M3: text depth: RTL names, text style browser, axis previews (#72)
Right-to-left and mixed-direction names in the panels: layers, pages,
Assets, tab titles, the file header and canvas frame titles show in
visual order (Unicode bidi, joined Arabic letters, mirrored brackets),
elided at their end (the left end of a right-to-left name). egui shapes
each font run itself, so shown names are laid out a cluster at a time.
Rename fields keep egui's editing and draw the shown text over it: the
caret, selection, clicks, drags and arrow keys follow what's on screen.
System fonts are added for scripts the built-in UI fonts lack.

The Typography section's text style browser: list and grid views with
each style in its own font, search, folders, this file's and enabled
libraries' styles, apply, detach, edit the typography, create from the
selected chars, hover to preview on the canvas, and "Mixed" when the
chars use several styles. Text styles now link chars (style_id on text
styles), so a range can take a style; editing what a style sets
unlinks the chars edited; files from before link on open.

Live text previews (text.preview / text.endPreview): a hovered text
style, font style or named instance and a dragged axis slider show on
the canvas without an undo step; release commits one step, Escape
cancels. ui.input sends raw window input over the control channel.

Visual QA: list markers keep the paragraph's font, renames start with
the name selected and centred, Max lines shows while truncating.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:03:09 +07:00

888 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Property editing: `node.set` and the shorthand keys every surface uses.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, Point};
use serde_json::{Map, Value, json};
use crate::params::*;
use crate::{Result, bad};
/// 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], \
strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] 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|spaceBetween), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap, \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, sizingH / sizingV (fixed|hug|fill), \
absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(
m.iter()
.map(|(k, v)| {
let mut out = String::new();
for c in k.chars() {
if c.is_ascii_uppercase() {
out.push('_');
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
// Paint/effect type tags stay as they are.
let key = if matches!(k.as_str(), "type" | "kind") { k.clone() } else { out };
(key, if matches!(k.as_str(), "type" | "kind") { v.clone() } else { snake(v) })
})
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(snake).collect()),
other => other.clone(),
}
}
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
let norm: String = {
let mut out = String::new();
let mut up = false;
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
continue;
}
if i == 0 {
out.extend(ch.to_lowercase());
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
out
};
serde_json::from_value(Value::String(norm)).ok()
}
pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
match v.as_str()?.to_ascii_lowercase().as_str() {
"fixed" => Some(Sizing::Fixed),
"hug" | "auto" | "hug contents" => Some(Sizing::Hug),
"fill" | "fill container" | "stretch" => Some(Sizing::Fill),
_ => None,
}
}
pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
match v {
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LineHeight::Pixels),
}
}
pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
match v {
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LetterSpacing::Pixels),
}
}
fn four(v: &Value) -> Option<[f64; 4]> {
match v {
Value::Array(a) => {
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
match f.as_slice() {
[x] => Some([*x; 4]),
[a, b] => Some([*a, *b, *a, *b]),
[a, b, c, d] => Some([*a, *b, *c, *d]),
_ => None,
}
}
other => f64_of(other).map(|x| [x; 4]),
}
}
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)], ...] or [{t, scale}], {preset} | {points}, or null (uniform)";
/// A width profile from a preset name, points (`[[t, scale], ...]` or `[{t, scale}]`), `{preset}`
/// / `{points}`, or null. `Some(None)` = uniform; `None` = not a profile.
pub fn width_profile_of(v: &Value) -> Option<Option<WidthProfile>> {
match v {
Value::Null => Some(None),
Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile),
Value::Array(a) => {
let pts = a
.iter()
.map(|p| match p {
Value::Array(xy) => Some((xy.first().and_then(f64_of)?, xy.get(1).and_then(f64_of)?)),
Value::Object(o) => Some((o.get("t").and_then(f64_of)?, o.get("scale").or_else(|| o.get("width")).and_then(f64_of)?)),
_ => None,
})
.collect::<Option<Vec<_>>>()?;
Some(Some(WidthProfile::new(pts)).filter(|p| !p.points.is_empty()))
}
Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of),
_ => None,
}
}
const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
/// or `{sizing, value}`.
pub fn track_of(v: &Value) -> Option<GridTrack> {
match v {
Value::Number(_) => f64_of(v).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
Value::String(s) => {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"auto" | "hug" => Some(GridTrack::hug()),
"fill" => Some(GridTrack::fill(1.0)),
_ => match s.strip_suffix("fr") {
Some(fr) => fr.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))),
None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
},
}
}
Value::Object(o) => {
let s = o.get("sizing").or_else(|| o.get("type")).and_then(sizing)?;
let val = o.get("value").or_else(|| o.get("size")).and_then(f64_of);
Some(match s {
Sizing::Fixed => GridTrack::fixed(val?.clamp(0.0, 1e6)),
Sizing::Hug => GridTrack::hug(),
Sizing::Fill => GridTrack::fill(val.unwrap_or(1.0).clamp(0.0, 1e4)),
})
}
_ => None,
}
}
/// Set one min/max limit (`None` removes it), keeping min ≤ max by moving the other limit.
pub fn set_limit(cl: &mut ChildLayout, key: &str, val: Option<f64>) {
let (target, other, is_min) = match key {
"minWidth" => (&mut cl.min_w, &mut cl.max_w, true),
"maxWidth" => (&mut cl.max_w, &mut cl.min_w, false),
"minHeight" => (&mut cl.min_h, &mut cl.max_h, true),
_ => (&mut cl.max_h, &mut cl.min_h, false),
};
*target = val;
if let (Some(v), Some(o)) = (val, *other)
&& (is_min && o < v || !is_min && o > v)
{
*other = Some(v);
}
}
fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> {
let id = n.id;
n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text")))
}
fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
_ => 1,
};
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| order(k));
for (k, v) in keys {
let k = k.as_str();
match k {
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let n = doc.get_mut(id)?;
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
let c = n.transform.as_coeffs();
// Figma's X/Y are the node's own origin (its rotated top-left) for rotated nodes.
let (mut tx, mut ty) = (c[4], c[5]);
let _ = b;
if k == "x" {
tx = val;
} else {
ty = val;
}
n.transform = Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
}
"width" | "height" | "w" | "h" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let (ow, oh, keep) = {
let n = doc.get(id)?;
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
};
let _ = keep;
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if k.starts_with('w') {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if k.starts_with('w') {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
"name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?,
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
"opacity" => {
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
let o = if o > 1.0 { o / 100.0 } else { o };
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
}
"blend" | "blendMode" => {
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
}
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
"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]"))?) }
}
"strokeCap" => {
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
let n = doc.get_mut(id)?;
n.stroke.cap_start = c;
n.stroke.cap_end = c;
}
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
"dashes" => {
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
doc.get_mut(id)?.stroke.dashes = d;
}
"widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
"cornerRadius" => {
let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?;
doc.get_mut(id)?.corners.radii = r.map(|x| x.max(0.0));
}
"topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => {
let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0);
let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) {
*slot = r;
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"devStatus" => {
let st = if v.is_null() {
Some(DevStatus::None)
} else {
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
};
doc.get_mut(id)?.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
}
"maskType" => {
let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?;
}
"layout" | "layoutMode" => {
let n = doc.get_mut(id)?;
if !n.kind.is_frame_like() {
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => n.layout = None,
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")),
};
ensure_layout(n).mode = mode;
}
Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?),
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
}
}
"gap" | "itemSpacing" | "columnGap" => {
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"crossGap" | "counterAxisSpacing" | "rowGap" => {
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
*slot = val;
}
}
"paddingHorizontal" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
l.padding[1] = val;
l.padding[3] = val;
}
"paddingVertical" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
l.padding[0] = val;
l.padding[2] = val;
}
"mainAlign" | "primaryAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))?
}
"crossAlign" | "counterAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = ensure_layout(doc.get_mut(id)?);
// Tracks past the new count go away (new tracks are 1fr).
let (n, tracks) =
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, true)?;
}
"sizingV" | "layoutSizingVertical" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, false)?;
}
"absolute" | "ignoreAutoLayout" => {
let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?;
let cl = &mut doc.get_mut(id)?.child_layout;
cl.absolute = on;
// An absolute child can't fill: it keeps its current size.
if on {
for s in [&mut cl.sizing_h, &mut cl.sizing_v] {
if *s == Sizing::Fill {
*s = Sizing::Fixed;
}
}
}
}
"minWidth" | "maxWidth" | "minHeight" | "maxHeight" => {
let val = match v {
Value::Null => None,
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)),
};
set_limit(&mut doc.get_mut(id)?.child_layout, k, val);
}
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
let l = ensure_layout(doc.get_mut(id)?);
if k.contains("olumn") {
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
l.column_tracks = tracks;
} else {
l.grid_rows = (tracks.len() as u32).clamp(1, 1000);
l.row_tracks = tracks;
}
}
"gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => {
let val = match v {
Value::Null => None,
Value::String(s) if s.eq_ignore_ascii_case("auto") => None,
other => {
Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0)
as u32)
}
};
let cl = &mut doc.get_mut(id)?.child_layout;
if k.starts_with("gridColumn") {
cl.grid_column = val;
} else {
cl.grid_row = val;
}
}
"gridColumnSpan" | "gridRowSpan" => {
let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let cl = &mut doc.get_mut(id)?.child_layout;
if k == "gridColumnSpan" {
cl.grid_column_span = span;
} else {
cl.grid_row_span = span;
}
}
"constraints" => {
let n = doc.get_mut(id)?;
if let Some(h) = v.get("horizontal") {
n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?;
}
if let Some(vv) = v.get("vertical") {
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
}
}
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?,
"exports" | "exportSettings" => {
doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?
}
"interactions" => {
doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?
}
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
"characters" | "text" => {
let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?;
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &s);
n.overrides.insert("characters".into());
}
"fontFamily" | "fontStyle" | "fontWeight" | "fontSize" | "lineHeight" | "letterSpacing" | "textDecoration" | "textCase" | "hyperlink"
| "textFills" | "fontVariations" | "variationSettings" => {
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let (start, end) = range_of(props, t.char_count());
apply_text_style(cmd, t, start, end, k, v)?;
}
"textAlign" | "textAlignHorizontal" => {
text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))?
}
"textAlignVertical" => {
text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))?
}
"textAutoResize" => {
text_mut(cmd, doc.get_mut(id)?)?.auto_resize =
enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?
}
"paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0),
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
"listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
"features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => {
let f = features_of(cmd, v)?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
t.features.clear();
for (k, on) in f {
if let Some(on) = on {
t.features.insert(k, on);
}
}
}
"start" | "end" => {}
"pointCount" => {
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
match &mut doc.get_mut(id)?.kind {
NodeKind::Polygon { count } | NodeKind::Star { count, .. } => *count = c,
_ => return Err(bad(cmd, "pointCount applies to polygons and stars")),
}
}
"innerRadius" | "ratio" => {
let r = f64_of(v).unwrap_or(0.38);
let r = if r > 1.0 { r / 100.0 } else { r };
match &mut doc.get_mut(id)?.kind {
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
_ => return Err(bad(cmd, "innerRadius applies to stars")),
}
}
"arcStart" | "arcSweep" | "arcInner" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Ellipse { start, sweep, inner } => match k {
"arcStart" => *start = val.to_radians(),
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
},
_ => return Err(bad(cmd, "arc applies to ellipses")),
}
}
"description" => match &mut doc.get_mut(id)?.kind {
NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(),
_ => return Err(bad(cmd, "description applies to components")),
},
"pluginData" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (pk, pv) in o {
if pv.is_null() {
n.plugin_data.remove(pk);
} else {
n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string()));
}
}
}
}
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => {
let sid = string_val(v);
crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?;
}
"boundVariables" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (field, var) in o {
match var.as_str() {
Some(vid) => {
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
}
}
}
}
}
"variableModes" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (coll, mode) in o {
match mode.as_str() {
Some(m) => {
n.var_modes.insert(coll.clone(), m.to_string());
}
None => {
n.var_modes.remove(coll);
}
}
}
}
}
"booleanOperation" => match &mut doc.get_mut(id)?.kind {
NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?,
_ => return Err(bad(cmd, "not a boolean group")),
},
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);
}
}
"background" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Page { background, .. } => *background = c,
_ => return Err(bad(cmd, "background applies to pages")),
}
}
"componentProperties" => {
if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind
&& let Some(o) = v.as_object()
{
for (pk, pv) in o {
p.insert(pk.clone(), pv.clone());
}
}
}
other => unknown.push(other.to_string()),
}
}
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden.
let n = doc.get_mut(id)?;
if n.source.is_some() {
for k in props.keys() {
n.overrides.insert(override_group(k).to_string());
}
}
Ok(())
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "widthProfile" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" | "variationSettings" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => {
"textStyle"
}
_ => "other",
}
}
fn string_val(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn range_of(props: &Map<String, Value>, len: usize) -> (usize, usize) {
let s = props.get("start").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(0);
let e = props.get("end").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(len);
(s.min(len), e.min(len))
}
/// Set one text style property on chars `start..end`.
pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k: &str, v: &Value) -> Result<()> {
match k {
"fontFamily" => {
let fam = string_val(v).ok_or_else(|| bad(cmd, "`fontFamily` must be a string"))?;
// Keep the style if the new family has it, else the nearest.
let styles = figcraft_text::family_styles(&fam);
t.restyle(start, end, |s| {
s.font_family = fam.clone();
if !styles.is_empty() && !styles.contains(&s.font_style) {
let want = s.weight();
if let Some(best) = styles.iter().min_by_key(|st| (i32::from(style_weight(st)) - i32::from(want)).abs()) {
s.font_style = best.clone();
}
}
});
}
"fontStyle" => {
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
t.restyle(start, end, |s| s.set_font_style(st.clone()));
}
"fontWeight" => {
let w = f64_of(v).ok_or_else(|| bad(cmd, "`fontWeight` must be a number"))?;
let name = weight_name(w.clamp(1.0, 1000.0) as u16);
t.restyle(start, end, |s| {
let italic = s.italic();
s.set_font_style(styled_name(name, italic));
});
}
"fontVariations" | "variationSettings" => {
// Replaces the custom axis values (null or {} = back to what the style name implies).
let vars = variations_of(cmd, v)?;
t.restyle(start, end, |s| {
s.variations.clear();
for (k, val) in &vars {
if let Some(val) = val {
s.variations.insert(k.clone(), *val);
}
}
});
}
"fontSize" => {
let sz = f64_of(v).ok_or_else(|| bad(cmd, "`fontSize` must be a number"))?.clamp(1.0, 5000.0);
t.restyle(start, end, |s| s.font_size = sz);
}
"lineHeight" => {
let lh = line_height(v).ok_or_else(|| bad(cmd, "lineHeight: auto | px | \"150%\""))?;
t.restyle(start, end, |s| s.line_height = lh);
}
"letterSpacing" => {
let ls = letter_spacing(v).ok_or_else(|| bad(cmd, "letterSpacing: px | \"2%\""))?;
t.restyle(start, end, |s| s.letter_spacing = ls);
}
"textDecoration" => {
let d: TextDecoration = enum_of(v).ok_or_else(|| bad(cmd, "textDecoration: none|underline|strikethrough"))?;
t.restyle(start, end, |s| s.decoration = d);
}
"textCase" => {
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title|smallCaps"))?;
t.restyle(start, end, |s| s.case = c);
}
"hyperlink" => {
let l = string_val(v);
t.restyle(start, end, |s| s.link = l.clone());
}
"textFills" => {
let p = if v.is_null() { None } else { Some(paints_of(v).ok_or_else(|| bad(cmd, "textFills: paints"))?) };
t.restyle(start, end, |s| s.fills = p.clone());
}
_ => {}
}
Ok(())
}
/// OpenType feature toggles from `{tag: bool | null}` (null = back to the font's default).
pub fn features_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<bool>)>> {
let Value::Object(m) = v else { return Err(bad(cmd, "features: {tag: bool}, e.g. {\"liga\": false}")) };
let mut out = Vec::with_capacity(m.len());
for (k, on) in m {
if !valid_feature_tag(k) {
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType feature tag")));
}
let on = match on {
Value::Null => None,
Value::Bool(b) => Some(*b),
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("feature `{k}` must be true, false or null")))? != 0.0),
};
out.push((k.clone(), on));
}
Ok(out)
}
/// Variable font axis values from `{tag: number | null}` (null = back to what the style implies).
/// `null` alone means "no custom values".
pub fn variations_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<f64>)>> {
let m = match v {
Value::Null => return Ok(Vec::new()),
Value::Object(m) => m,
_ => return Err(bad(cmd, "variations: {axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}")),
};
let mut out = Vec::with_capacity(m.len());
for (k, val) in m {
if !valid_axis_tag(k) {
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType axis tag")));
}
let val = match val {
Value::Null => None,
other => Some(f64_of(other).filter(|x| x.abs() <= 1e6).ok_or_else(|| bad(cmd, format!("axis `{k}` must be a number or null")))?),
};
out.push((k.clone(), val));
}
Ok(out)
}
/// "Bold" + italic → "Bold Italic"; "Regular" + italic → "Italic".
pub fn styled_name(weight: &str, italic: bool) -> String {
match (italic, weight) {
(false, w) => w.to_string(),
(true, "Regular") => "Italic".into(),
(true, w) => format!("{w} Italic"),
}
}
pub fn weight_name(w: u16) -> &'static str {
match w {
0..=149 => "Thin",
150..=249 => "Extra Light",
250..=349 => "Light",
350..=449 => "Regular",
450..=549 => "Medium",
550..=649 => "Semi Bold",
650..=749 => "Bold",
750..=849 => "Extra Bold",
_ => "Black",
}
}
fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal: bool) -> Result<()> {
let parent = doc.get(id)?.parent.filter(|p| doc.node(*p).is_some_and(|p| p.layout.is_some()));
match s {
Sizing::Fill if parent.is_none() => return Err(bad(cmd, "Fill needs an auto layout parent")),
Sizing::Hug if doc.get(id)?.layout.is_none() && !matches!(doc.get(id)?.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, "Hug needs auto layout or text"));
}
_ => {}
}
// A child can't fill an axis its parent hugs: the parent becomes fixed at its current size.
// (The other way round, a parent that starts to hug fixes the children that filled it: see
// `figcraft_layout::coherent_sizing`, applied after every edit.)
if s == Sizing::Fill
&& let Some(p) = parent
{
let cl = &mut doc.get_mut(p)?.child_layout;
let ps = if horizontal { &mut cl.sizing_h } else { &mut cl.sizing_v };
if *ps == Sizing::Hug {
*ps = Sizing::Fixed;
}
}
// Start from the sizing the layer has in effect (an auto-width text hugs both axes), so the
// other axis keeps its meaning: an auto-width text set to fill its width wraps (auto height).
figcraft_layout::coherent_sizing(doc, id);
let n = doc.get_mut(id)?;
if horizontal {
n.child_layout.sizing_h = s;
} else {
n.child_layout.sizing_v = s;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
}
Ok(())
}
/// Resize a node's box from its top-left, applying constraints / group scaling to children.
pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> {
let n = doc.get(id)?;
let (ow, oh) = (n.width, n.height);
let group = n.kind.bounds_follow_children();
figcraft_layout::set_size(doc, id, w, h);
if group {
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
figcraft_layout::scale_group_children(doc, id, kx, ky);
} else {
// In an auto layout frame this moves only the children that ignore auto layout.
figcraft_layout::apply_constraints(doc, id, ow, oh);
}
Ok(())
}
/// The design-panel view of a node (used by the UI and inspect).
pub fn describe(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let c = n.transform.as_coeffs();
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
"x": c[4], "y": c[5],
"width": n.width, "height": n.height,
"rotation": figcraft_geom::round_to(figcraft_geom::rotation_degrees(n.transform), 3),
"visible": n.visible, "locked": n.locked,
"opacity": n.opacity, "blend": n.blend,
});
if let Some(b) = doc.world_bounds(id) {
v["absoluteBounds"] = json!({"x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()});
}
let _ = Point::ZERO;
let _ = Color::BLACK;
v
}