mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
* M2: auto layout keyboard reordering, nested auto layout fixes, section hide contents and dev status - Arrow keys move auto layout children one place: along the flow (past hidden and absolute siblings), between wrapped rows, to the neighbouring grid cell, and into the neighbouring nested auto layout frame; no undo step when nothing moves. - Sections: section.toggleContents (hide contents), object.wrapInSection, frames in sections are top-level frames (labels, flows, navigation, PDF export), navigating to a section shows its frame shown last. - Ready for dev turns "Changed" when contents are edited; dev.resolveChange with a reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M2: README and ROADMAP for auto layout arrow keys and sections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: roadmap refresh after #68–#77 Parity table rows for canvas, selection and transform, layers, effects, text, prototyping and comments now reflect the merged work; weighted feature depth ~76%; README command count and hand-off features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M2: the dev change reason resolves only on Enter or Resolve Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
891 lines
43 KiB
Rust
891 lines
43 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 [..], 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)))
|
||
};
|
||
let n = doc.get_mut(id)?;
|
||
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||
// Marking a layer (again) starts from its current contents.
|
||
n.dev_changed = false;
|
||
}
|
||
"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
|
||
}
|