figcraft/crates/engine/src/cmd/props.rs

649 lines
32 KiB
Rust

//! 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 [..], 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), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
absolute, minWidth, maxWidth, minHeight, maxHeight, 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, paragraphSpacing, listStyle, 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(),
}
}
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()
}
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,
}
}
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),
}
}
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]),
}
}
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;
}
"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),
"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" => ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?,
"crossGap" | "counterAxisSpacing" => {
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` 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" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
"gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
"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" => doc.get_mut(id)?.child_layout.absolute = v.as_bool().unwrap_or(false),
"minWidth" => doc.get_mut(id)?.child_layout.min_w = f64_of(v),
"maxWidth" => doc.get_mut(id)?.child_layout.max_w = f64_of(v),
"minHeight" => doc.get_mut(id)?.child_layout.min_h = f64_of(v),
"maxHeight" => doc.get_mut(id)?.child_layout.max_h = f64_of(v),
"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" => {
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,
"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" => {
"strokes"
}
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
"name" => "name",
"characters" | "text" => "characters",
"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.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 as u16);
t.restyle(start, end, |s| {
s.font_style = if s.italic() { format!("{name} Italic").replace("Regular Italic", "Italic") } else { name.to_string() }
});
}
"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"))?;
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(())
}
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_al = doc.get(id)?.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
let n = doc.get_mut(id)?;
match s {
Sizing::Fill if !parent_al => return Err(bad(cmd, "Fill needs an auto layout parent")),
Sizing::Hug if n.layout.is_none() && !matches!(n.kind, NodeKind::Text(_)) => return Err(bad(cmd, "Hug needs auto layout or text")),
_ => {}
}
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 if doc.get(id)?.layout.is_none() {
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
}