Files
figcraft/crates/doc/src/node.rs
T
BFlat f665765128 M4: text behaviour suite: metrics, line height, CJK (#134)
## What

**A text behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (Auto / % / px line height and how the leading is split, letter spacing in % and px, paragraph spacing and indent, auto width / auto height / fixed / truncate with max lines, vertical trim, decorations from the font's metrics) is a table-driven case with the numbers the rule implies and a one-line derivation, read from the face actually used so it holds on any machine with a font. CJK line breaking follows UAX #14 and the CJK line-start / line-end rules; caret steps follow UAX #29 grapheme clusters. Where the documentation is silent the case says so and pins today's behaviour.

**Fixes (6 of the 13 text cases fail on the old layout; the break and caret rules are new functions)**
- **CJK line breaking**: lines break between ideographs, kana and Hangul and before or after a Latin word next to them (before: no break before an ideograph that follows Latin, none after `。`, full-width forms, CJK punctuation or Extension B ideographs), never inside a Latin word, with kinsoku: closing brackets and quotes, `、。,.!?`, small kana, the prolonged sound mark and iteration marks never start a line (the character before moves down with them), opening brackets never end one. The ideographic space hangs at the line's end like a space. One small break-class table in `crates/text/src/breaks.rs`, no new crate.
- **Fallback fonts no longer move the baseline**: a style's ascent, descent, line height and cap height come from its own font, not the fallback face that drew some of its characters, so a line with CJK or emoji keeps the height and baseline of a Latin line (with a fixed line height the mixed line's baseline used to shift by the CJK face's taller metrics; with Auto the line grew). Decorations likewise use the style's font and size, so an underline runs at one height and weight through mixed scripts (and isn't thinned by synthesised small caps).
- **Paragraph indent applies to left-aligned text only** (documented); right-to-left paragraphs keep it when right-aligned (their start). The Type settings field is disabled with a hint otherwise.
- **Carets by grapheme cluster**: Left / Right, Delete and Backspace step over a whole emoji ZWJ sequence, flag, skin-tone sequence or letter with combining marks (they moved one code point at a time, leaving the caret inside an emoji and deleting half of it). A cluster is also shaped with one font, so a joiner or variation selector no longer splits an emoji sequence across faces.
- **Words by script**: double-click and word moves (Alt on macOS, Ctrl on Windows and Linux) take one script's run in text without spaces (`日本語` | `の` | `テキスト`), Hangul words end at spaces, and a Latin word keeps its combining marks (before: a whole CJK sentence was one word, and `cafe` + combining acute split at the accent).

**Missing documented setting, added**
- **Vertical trim** (cap height to baseline): `leadingTrim: none | capHeight` (`CAP_HEIGHT` from REST JSON) through `node.set`, a Standard / cap-height segmented control in Type settings, `inspect_document` and Dev Mode properties, CSS `text-box: trim-both cap alphabetic` in generated code. The box runs from the first line's cap height to the last baseline (auto height shrinks, auto layout parents follow); truncation counts the trimmed bottom; exports reach past the box so descenders below the last baseline aren't clipped. Canvas, PNG, SVG and PDF all draw from the same layout.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries; the new segmented control reuses the Type settings one (accessible toggle names). Each edit is one undo step. `unicode-segmentation` (already in `Cargo.lock` through egui and winit) became a direct dependency of `figcraft-text`.

## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): a 200 px auto-height box of Japanese wraps with no line starting with `、。」` and `「` never ending a line (`…テスト` / `を折り返す、テスト` / `です。「括弧」は行` / `末に来ません。漢` / `字とかなとカタカ` / `ナ。`, the last `ナ` carried down with `。`); a Latin / CJK / Latin paragraph at 36 px line height is 108 px with evenly spaced baselines; the same text with vertical trim is 89.45 px (cap height 17.45 + 2 × 36), its box running from the cap height to the last baseline, and the Type settings control turns it off (108 px) and undo brings it back in one step; one Right arrow from after `a` in `a👨‍👩‍👧b` lands after the whole family emoji and Shift+Right then selects `b`; a double click selects the katakana run テキスト.
- **MCP**: `figcraft-cli mcp` (headless) built the same three texts; `inspect_document` reports 108 px for the 36 px paragraph and 89.42 px with `leadingTrim: capHeight` for the trimmed one (cap height of the headless font), the PNG export wraps the Japanese by the same rules and keeps the trimmed text's descenders, and `inspect.code` emits `text-box: trim-both cap alphabetic`.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: text layout of a 2,250-char Latin and a 2,000-char Japanese paragraph wrapped at 300 px (release, median of 7 × 30 runs) before and after on the same machine: Latin 0.70 → 0.42–0.50 ms, Japanese 0.46 → 0.37–0.47 ms (within noise or faster: line metrics are now computed once per style change instead of per glyph). All-ASCII text skips grapheme segmentation.
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.

## Tests
- `text` (`behaviour_tests`, 13 tests): line height Auto / % / px; leading split above and below every line (positive and negative); letter spacing % / px / negative; paragraph spacing and indent by alignment; resizing and truncation with max lines; vertical trim (heights, first baseline, glyph spacing kept, truncation); decorations from the font's metrics through mixed scripts; fallback fonts keep line height and baseline (Auto, px, %); 22 break-opportunity cases; CJK wrapping with kinsoku, Latin words in CJK and Korean; the ideographic space hanging; grapheme steps over ZWJ families, flags, combining marks, skin tones and emoji presentation; one face per cluster. Font-dependent cases skip on a machine without fonts.
- `engine` (`text_behaviour_tests`, 5): arrows, Shift+arrows, Delete and Backspace by cluster; double-click and word moves by script (and through `keymap::text_motion` for Ctrl and Alt); IME composition with CJK wrapping and growing the box before commit; vertical trim through `node.set` (box size, PNG ink at the top, export bounds below the box, SVG and PDF, CSS, inspect, undo, junk refused, REST spelling); paragraph indent only on left-aligned text.
- `format` REST import test covers `leadingTrim: CAP_HEIGHT`.

## Not in this PR
- Documented but not modelled yet: list spacing, hanging quotes and hanging lists, underline style / thickness / offset / skip ink.
- Documentation silent, behaviour pinned: Korean wraps by syllable (no word mode); Han compounds next to each other are one word (no dictionary segmentation); letter spacing is added after the last character too; a fallback font's taller glyphs may overflow the line rather than grow it.
- `.fig` import doesn't read vertical trim yet (the field wasn't checked against real files).
- Spell check (on hold, #113).
- Progress docs: target-app-parity area 10 85%/64% → 87%/68%, 7–13 h → 5–10 h with the suite listed under text editing depth; feature breadth and ready for real work unchanged at this rounding; full parity ~323–621 h → ~321–618 h (target-app-parity, ROADMAP); ui-parity text row.
2026-10-11 22:09:46 +07:00

729 lines
26 KiB
Rust

//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
use crate::NodeId;
use crate::props::*;
use crate::proto::{Interaction, Overflow, ScrollPosition};
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
use crate::text::TextData;
use crate::vector::VectorNetwork;
use figcraft_color::{BlendMode, Color, Paint};
use figcraft_geom::Affine;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
/// Boolean operation of a boolean group.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum BoolOp {
#[default]
Union,
Subtract,
Intersect,
Exclude,
}
impl BoolOp {
pub fn label(self) -> &'static str {
match self {
BoolOp::Union => "Union",
BoolOp::Subtract => "Subtract",
BoolOp::Intersect => "Intersect",
BoolOp::Exclude => "Exclude",
}
}
}
/// A component property definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PropDef {
Boolean {
default: bool,
},
Text {
default: String,
},
/// A nested instance's main; `preferred` components (or sets) are offered first when
/// swapping it.
InstanceSwap {
default: NodeId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
preferred: Vec<NodeId>,
},
Variant {
options: Vec<String>,
default: String,
},
/// A frame (bound through `prop_refs["slot"]`) whose instances take any content of their own;
/// `preferred` components are offered first when filling it.
Slot {
#[serde(default)]
preferred: Vec<NodeId>,
},
}
/// What a node is, with per-type data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NodeKind {
Page {
#[serde(default = "page_bg")]
background: Color,
#[serde(default)]
flows: Vec<crate::proto::Flow>,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
#[serde(default)]
guides: Vec<Guide>,
},
Frame,
Group,
Section {
#[serde(default)]
contents_hidden: bool,
},
Rectangle,
Ellipse {
#[serde(default)]
start: f64,
#[serde(default = "tau")]
sweep: f64,
#[serde(default)]
inner: f64,
},
Polygon {
count: u32,
},
Star {
count: u32,
inner_ratio: f64,
},
Line,
Vector {
network: VectorNetwork,
},
Text(TextData),
BooleanOp {
op: BoolOp,
},
Component {
#[serde(default)]
description: String,
#[serde(default)]
props: BTreeMap<String, PropDef>,
},
ComponentSet {
#[serde(default)]
description: String,
},
Instance {
component: NodeId,
/// Values for the component's properties.
#[serde(default)]
props: BTreeMap<String, serde_json::Value>,
},
Slice,
}
fn page_bg() -> Color {
Color::DEFAULT_PAGE
}
fn tau() -> f64 {
std::f64::consts::TAU
}
/// How a mask layer masks the layers above it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum MaskType {
/// The mask's rendered alpha (soft edges, gradients, opacity).
#[default]
Alpha,
/// The mask's outline geometry, fully opaque (fills and opacity don't matter).
Vector,
/// The mask's brightness: white shows, black hides.
Luminance,
}
impl MaskType {
pub const ALL: [MaskType; 3] = [MaskType::Alpha, MaskType::Vector, MaskType::Luminance];
pub fn is_alpha(&self) -> bool {
*self == MaskType::Alpha
}
pub fn id(self) -> &'static str {
match self {
MaskType::Alpha => "alpha",
MaskType::Vector => "vector",
MaskType::Luminance => "luminance",
}
}
pub fn label(self) -> &'static str {
match self {
MaskType::Alpha => "Alpha",
MaskType::Vector => "Vector",
MaskType::Luminance => "Luminance",
}
}
}
/// Handoff status of a frame or section.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum DevStatus {
#[default]
None,
ReadyForDev,
Completed,
}
impl DevStatus {
pub const ALL: [DevStatus; 3] = [DevStatus::None, DevStatus::ReadyForDev, DevStatus::Completed];
pub fn is_none(&self) -> bool {
*self == DevStatus::None
}
pub fn id(self) -> &'static str {
match self {
DevStatus::None => "none",
DevStatus::ReadyForDev => "readyForDev",
DevStatus::Completed => "completed",
}
}
pub fn label(self) -> &'static str {
match self {
DevStatus::None => "No status",
DevStatus::ReadyForDev => "Ready for dev",
DevStatus::Completed => "Completed",
}
}
}
/// A note for developers on a layer (a Dev Mode annotation): its text and the properties it pins,
/// which Dev Mode shows with their current values.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Annotation {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
/// Pinned property ids ("width", "fill", "fontSize"…; `figcraft_engine::annotations` lists
/// them). Unknown ids are kept and ignored.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<String>,
/// The id of its category ([`AnnotationCategory`]; `None`: uncategorised). An id the file
/// doesn't define reads as uncategorised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// A main component (or component set) linked to the code component it is built as, in one
/// language: Dev Mode shows the snippet its template makes instead of generated code
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
/// `react`, `swiftui` or `compose`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub import_path: String,
/// The code component's name.
pub name: String,
/// The snippet with `{property}` and `{property ? "a" : "b"}` placeholders.
pub template: String,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnnotationCategory {
pub id: String,
pub name: String,
pub color: Color,
}
impl AnnotationCategory {
/// The colours offered for categories (the defaults take the first four).
pub const COLORS: [Color; 8] = [
Color::from_rgb8_const(0x2F, 0x6F, 0xEB),
Color::from_rgb8_const(0x8A, 0x4F, 0xE0),
Color::from_rgb8_const(0x1E, 0x9E, 0x6A),
Color::from_rgb8_const(0xD9, 0x7A, 0x06),
Color::from_rgb8_const(0xD6, 0x3A, 0x3A),
Color::from_rgb8_const(0xD1, 0x3F, 0x9B),
Color::from_rgb8_const(0x11, 0x92, 0xA8),
Color::from_rgb8_const(0x6B, 0x72, 0x80),
];
pub fn new(id: &str, name: &str, color: Color) -> Self {
Self { id: id.to_string(), name: name.to_string(), color }
}
/// The categories a file starts with.
pub fn defaults() -> Vec<AnnotationCategory> {
let [dev, interaction, a11y, content, ..] = Self::COLORS;
vec![
Self::new("development", "Development", dev),
Self::new("interaction", "Interaction", interaction),
Self::new("accessibility", "Accessibility", a11y),
Self::new("content", "Content", content),
]
}
}
/// A ruler guide on a page or frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
/// `true` for a vertical guide (x position), `false` for horizontal (y).
pub vertical: bool,
pub offset: f64,
}
impl NodeKind {
pub fn type_name(&self) -> &'static str {
match self {
NodeKind::Page { .. } => "PAGE",
NodeKind::Frame => "FRAME",
NodeKind::Group => "GROUP",
NodeKind::Section { .. } => "SECTION",
NodeKind::Rectangle => "RECTANGLE",
NodeKind::Ellipse { .. } => "ELLIPSE",
NodeKind::Polygon { .. } => "POLYGON",
NodeKind::Star { .. } => "STAR",
NodeKind::Line => "LINE",
NodeKind::Vector { .. } => "VECTOR",
NodeKind::Text(_) => "TEXT",
NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION",
NodeKind::Component { .. } => "COMPONENT",
NodeKind::ComponentSet { .. } => "COMPONENT_SET",
NodeKind::Instance { .. } => "INSTANCE",
NodeKind::Slice => "SLICE",
}
}
/// The label the design panel's type dropdown shows.
pub fn label(&self) -> &'static str {
match self {
NodeKind::Page { .. } => "Page",
NodeKind::Frame => "Frame",
NodeKind::Group => "Group",
NodeKind::Section { .. } => "Section",
NodeKind::Rectangle => "Rectangle",
NodeKind::Ellipse { .. } => "Ellipse",
NodeKind::Polygon { .. } => "Polygon",
NodeKind::Star { .. } => "Star",
NodeKind::Line => "Line",
NodeKind::Vector { .. } => "Vector",
NodeKind::Text(_) => "Text",
NodeKind::BooleanOp { op } => op.label(),
NodeKind::Component { .. } => "Component",
NodeKind::ComponentSet { .. } => "Component set",
NodeKind::Instance { .. } => "Instance",
NodeKind::Slice => "Slice",
}
}
/// Frame-like nodes: can have auto layout, clip content, layout grids, and act as artboards.
pub fn is_frame_like(&self) -> bool {
matches!(self, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. })
}
/// Nodes that may contain children.
pub fn is_container(&self) -> bool {
matches!(
self,
NodeKind::Page { .. }
| NodeKind::Frame
| NodeKind::Group
| NodeKind::Section { .. }
| NodeKind::BooleanOp { .. }
| NodeKind::Component { .. }
| NodeKind::ComponentSet { .. }
| NodeKind::Instance { .. }
)
}
/// Group-like containers whose bounds follow their children.
pub fn bounds_follow_children(&self) -> bool {
matches!(self, NodeKind::Group | NodeKind::BooleanOp { .. })
}
/// Shapes with a geometric outline of their own.
pub fn is_shape(&self) -> bool {
matches!(
self,
NodeKind::Rectangle
| NodeKind::Ellipse { .. }
| NodeKind::Polygon { .. }
| NodeKind::Star { .. }
| NodeKind::Line
| NodeKind::Vector { .. }
)
}
/// Kinds whose corners can be rounded from the panel.
pub fn has_corner_radius(&self) -> bool {
matches!(
self,
NodeKind::Rectangle
| NodeKind::Frame
| NodeKind::Component { .. }
| NodeKind::ComponentSet { .. }
| NodeKind::Instance { .. }
| NodeKind::Polygon { .. }
| NodeKind::Star { .. }
| NodeKind::Vector { .. }
| NodeKind::Section { .. }
)
}
pub fn text(&self) -> Option<&TextData> {
match self {
NodeKind::Text(t) => Some(t),
_ => None,
}
}
pub fn text_mut(&mut self) -> Option<&mut TextData> {
match self {
NodeKind::Text(t) => Some(t),
_ => None,
}
}
}
/// One node of the scene graph.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub name: String,
#[serde(flatten)]
pub kind: NodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<NodeId>,
#[serde(default, skip_serializing_if = "is_default")]
pub children: Vec<NodeId>,
#[serde(default = "yes", skip_serializing_if = "is_yes")]
pub visible: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub locked: bool,
#[serde(default = "one", skip_serializing_if = "is_one")]
pub opacity: f32,
#[serde(default = "pass", skip_serializing_if = "is_pass")]
pub blend: BlendMode,
/// Transform relative to the parent (the page for top-level nodes).
#[serde(default = "identity", skip_serializing_if = "is_identity")]
pub transform: Affine,
pub width: f64,
pub height: f64,
#[serde(default, skip_serializing_if = "is_default")]
pub fills: Vec<Paint>,
#[serde(default, skip_serializing_if = "is_default")]
pub strokes: Vec<Paint>,
#[serde(default, skip_serializing_if = "is_default")]
pub stroke: StrokeProps,
#[serde(default, skip_serializing_if = "is_default")]
pub effects: Vec<Effect>,
#[serde(default, skip_serializing_if = "is_default")]
pub corners: Corners,
#[serde(default, skip_serializing_if = "is_default")]
pub constraints: Constraints,
/// Auto layout (frame-like nodes only).
#[serde(default, skip_serializing_if = "is_default")]
pub layout: Option<AutoLayout>,
#[serde(default, skip_serializing_if = "is_default")]
pub child_layout: ChildLayout,
/// Clip content (frames).
#[serde(default, skip_serializing_if = "is_default")]
pub clip: bool,
/// This node masks the siblings above it.
#[serde(default, skip_serializing_if = "is_default")]
pub is_mask: bool,
/// How a mask masks: by its alpha, its outline, or its luminance.
#[serde(default, skip_serializing_if = "MaskType::is_alpha")]
pub mask_type: MaskType,
#[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec<LayoutGrid>,
/// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
#[serde(default, skip_serializing_if = "is_default")]
pub guides: Vec<Guide>,
#[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec<ExportSetting>,
#[serde(default, skip_serializing_if = "is_default")]
pub interactions: Vec<Interaction>,
#[serde(default, skip_serializing_if = "is_default")]
pub overflow: Overflow,
#[serde(default, skip_serializing_if = "is_default")]
pub scroll_position: ScrollPosition,
#[serde(default, skip_serializing_if = "is_default")]
pub styles: StyleRefs,
/// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id.
#[serde(default, skip_serializing_if = "is_default")]
pub bound: BTreeMap<String, VariableId>,
/// Explicit variable modes (collection → mode) set on this container.
#[serde(default, skip_serializing_if = "is_default")]
pub var_modes: BTreeMap<CollectionId, ModeId>,
/// In a boolean group: how this operand combines with the operands below it (`None`: the
/// group's operation). Ignored for the bottom operand and outside boolean groups.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bool_op: Option<BoolOp>,
/// Lock aspect ratio when resizing from the panel.
#[serde(default, skip_serializing_if = "is_default")]
pub constrain_proportions: bool,
/// Inside an instance: the node of the main component this one mirrors.
#[serde(default, skip_serializing_if = "is_default")]
pub source: Option<NodeId>,
/// A nested instance inside a main component whose properties show on the main's instances.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exposed: bool,
/// Handoff status of a frame or section (shown as a badge next to its name).
#[serde(default, skip_serializing_if = "DevStatus::is_none")]
pub dev_status: DevStatus,
/// Ready for dev, but its contents changed since it was marked (shown as "Changed" until the
/// change is resolved or the layer is marked again).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dev_changed: bool,
/// Why the last change to a ready-for-dev layer was made (entered when resolving it).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub dev_note: String,
/// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot":
/// this slot holds the instance's own content).
#[serde(default, skip_serializing_if = "is_default")]
pub overrides: BTreeSet<String>,
/// Component property references: which property controls this node's
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot
/// property this frame is ("slot").
#[serde(default, skip_serializing_if = "is_default")]
pub prop_refs: BTreeMap<String, String>,
/// Free-form plugin / agent data.
#[serde(default, skip_serializing_if = "is_default")]
pub plugin_data: BTreeMap<String, String>,
/// Dev Mode annotations, in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
/// Main components and component sets: the code components they are linked to, one per
/// language.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_links: Vec<CodeLink>,
}
pub(crate) fn yes() -> bool {
true
}
fn one() -> f32 {
1.0
}
fn pass() -> BlendMode {
BlendMode::PassThrough
}
fn identity() -> Affine {
Affine::IDENTITY
}
// Fields at their default value are left out of the JSON (the file is several times smaller and
// faster to write and read); reading fills them back in from the same defaults.
/// Whether `v` is its type's default (what `#[serde(default)]` reads back for a missing field).
pub(crate) fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
fn is_yes(v: &bool) -> bool {
*v == yes()
}
fn is_one(v: &f32) -> bool {
*v == one()
}
fn is_pass(v: &BlendMode) -> bool {
*v == pass()
}
fn is_identity(v: &Affine) -> bool {
*v == identity()
}
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
let fills = match &kind {
NodeKind::Frame | NodeKind::Component { .. } => vec![Paint::solid(Color::WHITE)],
NodeKind::Section { .. } => vec![Paint::solid(Color::WHITE)],
NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } => {
vec![Paint::solid(Color::DEFAULT_SHAPE)]
}
NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)],
_ => vec![],
};
let strokes = match &kind {
NodeKind::Line => vec![Paint::solid(Color::BLACK)],
NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))],
_ => vec![],
};
let stroke = match &kind {
NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() },
NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() },
_ => StrokeProps::default(),
};
let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal };
let clip = matches!(kind, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let corners = match &kind {
NodeKind::ComponentSet { .. } => Corners { radii: [5.0; 4], smoothing: 0.0 },
_ => Corners::default(),
};
Self {
id,
name: name.into(),
kind,
parent: None,
children: Vec::new(),
visible: true,
locked: false,
opacity: 1.0,
blend,
transform: Affine::IDENTITY,
width: width.max(0.0),
height: height.max(0.0),
fills,
strokes,
stroke,
effects: Vec::new(),
corners,
constraints: Constraints::default(),
layout: None,
child_layout: ChildLayout::default(),
clip,
is_mask: false,
mask_type: MaskType::Alpha,
grids: Vec::new(),
guides: Vec::new(),
exports: Vec::new(),
interactions: Vec::new(),
overflow: Default::default(),
scroll_position: Default::default(),
styles: StyleRefs::default(),
bound: BTreeMap::new(),
var_modes: BTreeMap::new(),
bool_op: None,
constrain_proportions: false,
source: None,
exposed: false,
dev_status: DevStatus::None,
dev_changed: false,
dev_note: String::new(),
overrides: BTreeSet::new(),
prop_refs: BTreeMap::new(),
plugin_data: BTreeMap::new(),
annotations: Vec::new(),
code_links: Vec::new(),
}
}
/// A new section with the look sections are created with (white, a light grey border, slightly
/// rounded, contents unclipped).
pub fn new_section(id: NodeId, name: impl Into<String>, width: f64, height: f64) -> Self {
let mut n = Self::new(id, NodeKind::Section { contents_hidden: false }, name, width, height);
n.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
n.corners.radii = [2.0; 4];
n.clip = false;
n
}
pub fn size(&self) -> (f64, f64) {
(self.width, self.height)
}
pub fn local_rect(&self) -> figcraft_geom::Rect {
figcraft_geom::Rect::new(0.0, 0.0, self.width, self.height)
}
pub fn is_page(&self) -> bool {
matches!(self.kind, NodeKind::Page { .. })
}
/// A section whose contents are hidden: only its name and outline show; its children are
/// skipped when rendering and hit testing.
pub fn hides_contents(&self) -> bool {
matches!(self.kind, NodeKind::Section { contents_hidden: true })
}
/// Whether `self` and `other` differ only in handoff status and whether a section's contents
/// are hidden (neither counts as a change to the design).
pub fn same_design(&self, other: &Node) -> bool {
if self == other {
return true;
}
let plain = |n: &Node| {
let mut n = n.clone();
n.dev_status = DevStatus::None;
n.dev_changed = false;
n.dev_note.clear();
if let NodeKind::Section { contents_hidden } = &mut n.kind {
*contents_hidden = false;
}
n
};
plain(self) == plain(other)
}
/// The ruler guides on this page or frame, in its own coordinates.
pub fn guides(&self) -> &[Guide] {
match &self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &self.guides,
}
}
pub fn guides_mut(&mut self) -> &mut Vec<Guide> {
match &mut self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &mut self.guides,
}
}
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
/// whose first child paints on top (canvas stacking; the layer order stays as it is).
pub fn paint_order(&self) -> impl DoubleEndedIterator<Item = NodeId> + '_ {
let rev = self.layout.as_ref().is_some_and(|l| l.reverse_z);
let n = self.children.len();
(0..n).filter_map(move |i| self.children.get(if rev { n - 1 - i } else { i }).copied())
}
/// Whether painted strokes are present.
pub fn has_visible_stroke(&self) -> bool {
self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0
}
/// Maximum distance (in local units) painting can reach outside the node box.
pub fn paint_reach(&self) -> f64 {
let stroke = if self.has_visible_stroke() {
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight());
let base = match self.stroke.align {
StrokeAlign::Inside => 0.0,
StrokeAlign::Center => w / 2.0,
StrokeAlign::Outside => w,
};
// Arrowheads are sized by the weight, whatever a width profile does.
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w.max(self.stroke.weight) * 3.0 } else { w };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * self.stroke.miter_limit.clamp(1.0, 4.0)
} else {
0.0
};
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() + t.trim_reach() } else { 0.0 };
stroke + fx + self.spread_miter_reach() + glyphs
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
fn spread_miter_reach(&self) -> f64 {
if matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() {
return 0.0;
}
self.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::DropShadow(s) if s.spread.is_finite() => Some(s.spread.max(0.0) * (SPREAD_MITER_LIMIT - 1.0)),
_ => None,
})
.fold(0.0, f64::max)
}
}