mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-05 10:10:41 +00:00
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).
- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
625 lines
25 KiB
Rust
625 lines
25 KiB
Rust
//! Dev-mode inspection: a layer's properties grouped for handoff (layout, auto layout,
|
||
//! typography, fills, strokes, effects), each with the style or variable behind it when there is
|
||
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
|
||
|
||
use figcraft_color::{Color, GradientKind, Hsb, Hsl, PaintKind};
|
||
use figcraft_doc::*;
|
||
use serde::Serialize;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::cmd::CommandSpec;
|
||
use crate::params::*;
|
||
use crate::spec;
|
||
|
||
/// One group of properties ("Layout", "Typography"…).
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct Section {
|
||
pub title: &'static str,
|
||
pub rows: Vec<Prop>,
|
||
}
|
||
|
||
/// One property: its label and value as shown, an optional colour swatch (hex with alpha) and
|
||
/// the style or variable name it comes from.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct Prop {
|
||
pub label: String,
|
||
pub value: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub color: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub token: Option<String>,
|
||
}
|
||
|
||
/// Why a layer is listed as an asset.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum AssetKind {
|
||
/// It has export settings.
|
||
Export,
|
||
/// A small layer made only of vector shapes (exported as SVG).
|
||
Icon,
|
||
/// A layer filled with an image (exported as PNG).
|
||
Image,
|
||
}
|
||
|
||
/// A layer in the inspected selection to hand over as a file.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct Asset {
|
||
pub id: NodeId,
|
||
pub name: String,
|
||
pub kind: AssetKind,
|
||
/// How it exports, as shown ("PNG 2x", "SVG 1x"…).
|
||
pub exports: Vec<String>,
|
||
/// How it exports: its export settings, or SVG for an icon and PNG for an image.
|
||
#[serde(skip)]
|
||
pub settings: Vec<ExportSetting>,
|
||
}
|
||
|
||
/// Most assets listed for one selection.
|
||
const MAX_ASSETS: usize = 200;
|
||
/// Largest side of a layer listed as an icon.
|
||
const ICON_MAX: f64 = 128.0;
|
||
|
||
/// Whether `id` (visible parts only) is made of vector shapes alone: paths, booleans, stars,
|
||
/// polygons and lines, with rectangles and ellipses among them, in groups or frames. `paths` is
|
||
/// set when one of the former is in it.
|
||
fn vector_only(doc: &Document, id: NodeId, depth: usize, paths: &mut bool) -> bool {
|
||
let Some(n) = doc.node(id) else { return false };
|
||
if !n.visible {
|
||
return true;
|
||
}
|
||
if n.fills.iter().chain(&n.strokes).any(|p| matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_))) {
|
||
return false;
|
||
}
|
||
match n.kind {
|
||
NodeKind::Vector { .. } | NodeKind::BooleanOp { .. } | NodeKind::Star { .. } | NodeKind::Polygon { .. } | NodeKind::Line => {
|
||
*paths = true;
|
||
true
|
||
}
|
||
NodeKind::Rectangle | NodeKind::Ellipse { .. } => true,
|
||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||
depth < 16 && !n.children.is_empty() && n.children.iter().all(|c| vector_only(doc, *c, depth + 1, paths))
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Why `n` is an asset, if it is one.
|
||
fn asset_kind(doc: &Document, n: &Node) -> Option<AssetKind> {
|
||
let mut paths = false;
|
||
if !n.exports.is_empty() {
|
||
Some(AssetKind::Export)
|
||
} else if n.width <= ICON_MAX && n.height <= ICON_MAX && vector_only(doc, n.id, 0, &mut paths) && paths {
|
||
Some(AssetKind::Icon)
|
||
} else if n.fills.iter().any(|p| p.visible && matches!(p.kind, PaintKind::Image(_))) {
|
||
Some(AssetKind::Image)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The assets in and under `ids`, in layer order: layers with export settings, icons and images
|
||
/// (an icon's own shapes aren't listed again).
|
||
pub fn assets(doc: &Document, ids: &[NodeId]) -> Vec<Asset> {
|
||
let mut out = Vec::new();
|
||
let mut stack: Vec<NodeId> = ids.iter().rev().copied().collect();
|
||
// Bounded: the Inspect tab asks every frame.
|
||
let mut visited = 0usize;
|
||
while let Some(id) = stack.pop() {
|
||
visited += 1;
|
||
if out.len() >= MAX_ASSETS || visited > 20_000 {
|
||
break;
|
||
}
|
||
let Some(n) = doc.node(id) else { continue };
|
||
if !n.visible || n.is_page() {
|
||
if n.is_page() {
|
||
stack.extend(n.children.iter().rev().copied());
|
||
}
|
||
continue;
|
||
}
|
||
let kind = asset_kind(doc, n);
|
||
if let Some(kind) = kind
|
||
&& !out.iter().any(|a: &Asset| a.id == id)
|
||
{
|
||
let settings = match kind {
|
||
AssetKind::Export => n.exports.clone(),
|
||
AssetKind::Icon => vec![ExportSetting { format: ExportFormat::Svg, ..Default::default() }],
|
||
AssetKind::Image => vec![ExportSetting::default()],
|
||
};
|
||
let exports = settings.iter().map(|e| format!("{} {}", e.format.label(), e.size.label())).collect();
|
||
out.push(Asset { id, name: n.name.clone(), kind, exports, settings });
|
||
}
|
||
if kind != Some(AssetKind::Icon) {
|
||
stack.extend(n.children.iter().rev().copied());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The files exporting the assets in and under `ids` makes (see [`assets`]).
|
||
pub fn asset_jobs(doc: &Document, ids: &[NodeId]) -> Vec<crate::cmd::file::ExportJob> {
|
||
crate::cmd::file::jobs_for(assets(doc, ids).into_iter().map(|a| (a.id, a.settings)), doc)
|
||
}
|
||
|
||
/// How the Inspect tab writes colours.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||
pub enum ColorFormat {
|
||
/// `#FF8000`
|
||
#[default]
|
||
Hex,
|
||
/// `255, 128, 0`
|
||
Rgb,
|
||
/// `rgb(255, 128, 0)`, `rgba(255, 128, 0, 0.5)`
|
||
Css,
|
||
/// `30, 100%, 50%`
|
||
Hsl,
|
||
/// `30, 100%, 100%`
|
||
Hsb,
|
||
}
|
||
|
||
impl ColorFormat {
|
||
pub const ALL: [ColorFormat; 5] = [ColorFormat::Hex, ColorFormat::Rgb, ColorFormat::Css, ColorFormat::Hsl, ColorFormat::Hsb];
|
||
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
ColorFormat::Hex => "hex",
|
||
ColorFormat::Rgb => "rgb",
|
||
ColorFormat::Css => "css",
|
||
ColorFormat::Hsl => "hsl",
|
||
ColorFormat::Hsb => "hsb",
|
||
}
|
||
}
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
ColorFormat::Hex => "Hex",
|
||
ColorFormat::Rgb => "RGB",
|
||
ColorFormat::Css => "CSS",
|
||
ColorFormat::Hsl => "HSL",
|
||
ColorFormat::Hsb => "HSB",
|
||
}
|
||
}
|
||
|
||
pub fn from_id(s: &str) -> Option<ColorFormat> {
|
||
ColorFormat::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim()))
|
||
}
|
||
|
||
/// `c` in this format; the opacity follows as " · 50%" (CSS puts it in `rgba`).
|
||
pub fn format(self, c: Color) -> String {
|
||
let [r, g, b, _] = c.to_rgba8();
|
||
let alpha = c.a < 0.999;
|
||
let pct = |v: f32| format!("{}%", (v * 100.0).round());
|
||
let body = match self {
|
||
ColorFormat::Hex => format!("#{}", c.to_hex()),
|
||
ColorFormat::Rgb => format!("{r}, {g}, {b}"),
|
||
ColorFormat::Css if alpha => return format!("rgba({r}, {g}, {b}, {})", num(f64::from(c.a))),
|
||
ColorFormat::Css => return format!("rgb({r}, {g}, {b})"),
|
||
ColorFormat::Hsl => {
|
||
let x = Hsl::from_color(c);
|
||
format!("{}, {}, {}", x.h.round(), pct(x.s), pct(x.l))
|
||
}
|
||
ColorFormat::Hsb => {
|
||
let x = Hsb::from_color(c);
|
||
format!("{}, {}, {}", x.h.round(), pct(x.s), pct(x.b))
|
||
}
|
||
};
|
||
if alpha { format!("{body} · {}", pct(c.a)) } else { body }
|
||
}
|
||
}
|
||
|
||
/// A frame or section with a handoff status, for Dev Mode's list of what's ready.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct DevItem {
|
||
pub id: NodeId,
|
||
pub name: String,
|
||
pub page: NodeId,
|
||
pub status: DevStatus,
|
||
}
|
||
|
||
/// Most items listed.
|
||
const MAX_DEV_ITEMS: usize = 500;
|
||
|
||
/// Whether `id` can carry a handoff status: a section, or a frame at the top of a page or section.
|
||
pub fn has_status(doc: &Document, id: NodeId) -> bool {
|
||
let section = |n: &Node| matches!(n.kind, NodeKind::Section { .. });
|
||
doc.node(id).is_some_and(|n| {
|
||
section(n)
|
||
|| (n.kind.is_frame_like()
|
||
&& !matches!(n.kind, NodeKind::Instance { .. })
|
||
&& n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || section(p)))
|
||
})
|
||
}
|
||
|
||
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
|
||
/// in page and layer order (bounded).
|
||
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
|
||
let mut out = Vec::new();
|
||
let mut visited = 0usize;
|
||
for pg in doc.pages.iter().copied().filter(|pg| page.is_none_or(|p| p == *pg)) {
|
||
let mut stack = vec![pg];
|
||
while let Some(id) = stack.pop() {
|
||
visited += 1;
|
||
if out.len() >= MAX_DEV_ITEMS || visited > 200_000 {
|
||
return out;
|
||
}
|
||
let Some(n) = doc.node(id) else { continue };
|
||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
|
||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
|
||
}
|
||
stack.extend(n.children.iter().rev().copied());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
spec!(
|
||
"inspect.readyForDev",
|
||
"Ready for dev",
|
||
"{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
|
||
false,
|
||
|s, p| {
|
||
let cmd = "inspect.readyForDev";
|
||
let status = match string(p, "status").as_deref() {
|
||
None => Some(DevStatus::ReadyForDev),
|
||
Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None,
|
||
Some(v) => Some(
|
||
DevStatus::ALL
|
||
.into_iter()
|
||
.find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v))
|
||
.ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?,
|
||
),
|
||
};
|
||
let doc = s.doc()?;
|
||
let page = match get(p, "page") {
|
||
Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?),
|
||
None => None,
|
||
};
|
||
Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)}))
|
||
}
|
||
),
|
||
spec!(
|
||
"inspect.assets",
|
||
"Inspect assets",
|
||
"{ids? (default the selection)} → {assets: [{id, name, kind: export|icon|image, exports}]} the layers in and under them to hand over as files: those with export settings, icons (small, vector shapes only; SVG) and images (PNG). Export them with `file.exportLayers {assets: true}`",
|
||
false,
|
||
|s, p| {
|
||
let ids = targets(s, p);
|
||
Ok(json!({"assets": serde_json::to_value(assets(s.doc()?, &ids)).unwrap_or(Value::Null)}))
|
||
}
|
||
),
|
||
spec!(
|
||
"inspect.properties",
|
||
"Inspect properties",
|
||
"{id?, colorFormat?: hex|rgb|css|hsl|hsb (default hex)} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (positions from the frame around the layer), auto layout, typography, fills, strokes and effects, with style and variable names",
|
||
false,
|
||
|s, p| {
|
||
let cmd = "inspect.properties";
|
||
let id = match id(p, "id") {
|
||
Some(i) => i,
|
||
None => s.selection().first().copied().ok_or_else(|| crate::bad(cmd, "select a layer or pass `id`"))?,
|
||
};
|
||
let fmt = match string(p, "colorFormat") {
|
||
Some(f) => ColorFormat::from_id(&f).ok_or_else(|| crate::bad(cmd, "colorFormat: hex|rgb|css|hsl|hsb"))?,
|
||
None => ColorFormat::Hex,
|
||
};
|
||
let doc = s.doc()?;
|
||
doc.node(id).ok_or_else(|| crate::bad(cmd, "no such layer"))?;
|
||
Ok(json!({"sections": serde_json::to_value(properties_with(doc, id, fmt)).unwrap_or(Value::Null)}))
|
||
}
|
||
),
|
||
]
|
||
}
|
||
|
||
/// A number as a designer reads it: at most two decimals, no trailing zeros.
|
||
fn num(v: f64) -> String {
|
||
if !v.is_finite() {
|
||
return "0".into();
|
||
}
|
||
let s = format!("{:.2}", (v * 100.0).round() / 100.0);
|
||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||
if s == "-0" { "0".into() } else { s.to_string() }
|
||
}
|
||
|
||
fn prop(label: impl Into<String>, value: impl Into<String>) -> Prop {
|
||
Prop { label: label.into(), value: value.into(), color: None, token: None }
|
||
}
|
||
|
||
impl Prop {
|
||
fn token(mut self, t: Option<String>) -> Self {
|
||
self.token = t;
|
||
self
|
||
}
|
||
fn color(mut self, c: Color) -> Self {
|
||
self.color = Some(c.to_css_hex());
|
||
self
|
||
}
|
||
}
|
||
|
||
/// The variable bound to property `field` of `n` ("fill" and "stroke" also find bindings stored
|
||
/// under their list forms, like "fills/0/color").
|
||
pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'d Variable> {
|
||
let forms: &[&str] = match field {
|
||
"fill" => &["fill", "fills", "fills/0", "fills/0/color"],
|
||
"stroke" => &["stroke", "strokes/0/color"],
|
||
_ => &[],
|
||
};
|
||
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
|
||
found.and_then(|v| doc.variables.get(v))
|
||
}
|
||
|
||
/// The variable bound to `field` on `n`, by name.
|
||
fn variable(doc: &Document, n: &Node, field: &str) -> Option<String> {
|
||
bound_variable(doc, n, field).map(|v| v.name.clone())
|
||
}
|
||
|
||
fn style_name(doc: &Document, id: Option<&StyleId>) -> Option<String> {
|
||
id.and_then(|s| doc.style(s)).map(|s| s.name.clone())
|
||
}
|
||
|
||
/// The properties of `id`, grouped, colours in hex; empty when it doesn't exist.
|
||
pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
||
properties_with(doc, id, ColorFormat::Hex)
|
||
}
|
||
|
||
/// [`properties`] with colours written in `fmt`.
|
||
pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Section> {
|
||
let color_text = |c: Color| fmt.format(c);
|
||
let Some(n) = doc.node(id) else { return Vec::new() };
|
||
let mut out = vec![layout(doc, id, n)];
|
||
if let Some(l) = &n.layout {
|
||
out.push(auto_layout(doc, n, l));
|
||
}
|
||
if let Some(t) = n.kind.text() {
|
||
out.push(typography(doc, n, t));
|
||
}
|
||
// Each paint's row names the variable bound to its colour; a style names the first row.
|
||
let paint_rows = |fills: bool, style: Option<&StyleId>| -> Vec<Prop> {
|
||
let paints = if fills { &n.fills } else { &n.strokes };
|
||
let rows: Vec<Prop> = paints
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, p)| p.visible)
|
||
.map(|(i, p)| {
|
||
let token = crate::cmd::lists::paint_binding_key(n, fills, i).and_then(|k| variable(doc, n, k));
|
||
paint_row(p, fmt).token(token)
|
||
})
|
||
.collect();
|
||
match style_name(doc, style) {
|
||
Some(name) => tag_first(rows, Some(name)),
|
||
None => rows,
|
||
}
|
||
};
|
||
let fills = paint_rows(true, n.styles.fill.as_ref());
|
||
if !fills.is_empty() {
|
||
out.push(Section { title: "Fills", rows: fills });
|
||
}
|
||
let mut strokes = paint_rows(false, n.styles.stroke.as_ref());
|
||
if !strokes.is_empty() {
|
||
let align = match n.stroke.align {
|
||
StrokeAlign::Inside => "inside",
|
||
StrokeAlign::Center => "center",
|
||
StrokeAlign::Outside => "outside",
|
||
};
|
||
strokes.push(prop("Weight", format!("{} {align}", num(n.stroke.weight))).token(variable(doc, n, "strokeWeight")));
|
||
out.push(Section { title: "Strokes", rows: strokes });
|
||
}
|
||
// A grain size: one number, or across × down.
|
||
let grain = |(sx, sy): (f64, f64)| if sx == sy { num(sx) } else { format!("{}×{}", num(sx), num(sy)) };
|
||
let pct = |v: f64| (v * 100.0).round();
|
||
let effects: Vec<Prop> = n
|
||
.effects
|
||
.iter()
|
||
.filter(|e| e.visible)
|
||
.map(|e| match &e.kind {
|
||
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => {
|
||
let label = if matches!(e.kind, EffectKind::DropShadow(_)) { "Drop shadow" } else { "Inner shadow" };
|
||
let v = format!("{} {} {} {} {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), color_text(s.color));
|
||
prop(label, v).color(s.color)
|
||
}
|
||
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => {
|
||
let label = if matches!(e.kind, EffectKind::LayerBlur(_)) { "Layer blur" } else { "Background blur" };
|
||
let at = |(x, y): (f64, f64)| format!("{}% {}%", pct(x), pct(y));
|
||
let v = if b.is_progressive() {
|
||
format!("Progressive {} → {} from {} to {}", num(b.start_radius), num(b.radius), at(b.start), at(b.end))
|
||
} else {
|
||
num(b.radius)
|
||
};
|
||
prop(label, v)
|
||
}
|
||
EffectKind::Noise(nz) => {
|
||
let colors = match nz.mode {
|
||
NoiseMode::Mono => color_text(nz.color),
|
||
NoiseMode::Duo => format!("{} {}", color_text(nz.color), color_text(nz.secondary)),
|
||
NoiseMode::Multi => format!("{}%", pct(f64::from(nz.opacity))),
|
||
};
|
||
let row = prop("Noise", format!("{} {} {}% {colors}", nz.mode.label(), grain(nz.grain()), pct(nz.density)));
|
||
if nz.mode == NoiseMode::Multi { row } else { row.color(nz.color) }
|
||
}
|
||
EffectKind::Texture(t) => {
|
||
prop("Texture", format!("{} {}{}", grain(t.grain()), num(t.radius), if t.clip_to_shape { " clipped" } else { "" }))
|
||
}
|
||
EffectKind::Glass(g) => prop(
|
||
"Glass",
|
||
format!(
|
||
"{}° {}% refraction {}% depth {} dispersion {}% frost {} splay {}%",
|
||
num(g.light_angle),
|
||
pct(g.light_intensity),
|
||
pct(g.refraction),
|
||
num(g.depth),
|
||
pct(g.dispersion),
|
||
num(g.frost),
|
||
pct(g.splay)
|
||
),
|
||
),
|
||
})
|
||
.collect();
|
||
if !effects.is_empty() {
|
||
out.push(Section { title: "Effects", rows: tag_first(effects, style_name(doc, n.styles.effect.as_ref())) });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A style or variable applies to the whole list: show it on the first row.
|
||
/// One paint's row: its kind and colour (or stops, or opacity for images).
|
||
fn paint_row(p: &figcraft_color::Paint, fmt: ColorFormat) -> Prop {
|
||
match &p.kind {
|
||
PaintKind::Solid { color } => {
|
||
let c = color.with_alpha(color.a * p.opacity);
|
||
prop("Solid", fmt.format(c)).color(c)
|
||
}
|
||
PaintKind::Gradient(g) => {
|
||
let kind = match g.kind {
|
||
GradientKind::Linear => "Linear",
|
||
GradientKind::Radial => "Radial",
|
||
GradientKind::Angular => "Angular",
|
||
GradientKind::Diamond => "Diamond",
|
||
};
|
||
let stops: Vec<String> = g.stops.iter().map(|s| format!("{} {}%", fmt.format(s.color), (s.position * 100.0).round())).collect();
|
||
let r = prop(kind, stops.join(", "));
|
||
match g.stops.first() {
|
||
Some(s) => r.color(s.color),
|
||
None => r,
|
||
}
|
||
}
|
||
PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
|
||
PaintKind::Pattern(_) => prop("Pattern", format!("{}%", (p.opacity * 100.0).round())),
|
||
}
|
||
}
|
||
|
||
fn tag_first(mut rows: Vec<Prop>, token: Option<String>) -> Vec<Prop> {
|
||
if let Some(first) = rows.first_mut() {
|
||
first.token = token;
|
||
}
|
||
rows
|
||
}
|
||
|
||
fn layout(doc: &Document, id: NodeId, n: &Node) -> Section {
|
||
let mut rows =
|
||
vec![prop("Width", num(n.width)).token(variable(doc, n, "width")), prop("Height", num(n.height)).token(variable(doc, n, "height"))];
|
||
// Position from the top-left of the frame around it (groups aren't frames; the canvas
|
||
// origin at the top level).
|
||
if let Some(b) = doc.world_bounds(id) {
|
||
let origin = doc.containing_frame(id).and_then(|f| doc.world_bounds(f)).map_or((0.0, 0.0), |pb| (pb.x0, pb.y0));
|
||
rows.push(prop("Top", num(b.y0 - origin.1)));
|
||
rows.push(prop("Left", num(b.x0 - origin.0)));
|
||
}
|
||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||
if rot.abs() > 0.01 {
|
||
rows.push(prop("Rotation", format!("{}°", num(rot))));
|
||
}
|
||
if n.opacity < 0.999 {
|
||
rows.push(prop("Opacity", format!("{}%", (f64::from(n.opacity) * 100.0).round())).token(variable(doc, n, "opacity")));
|
||
}
|
||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||
rows.push(prop("Blend", n.blend.label()));
|
||
}
|
||
if n.kind.has_corner_radius() {
|
||
let r = match n.corners.uniform() {
|
||
Some(r) if r > 0.0 => Some(num(r)),
|
||
Some(_) => None,
|
||
None => Some(n.corners.radii.iter().map(|r| num(*r)).collect::<Vec<_>>().join(" ")),
|
||
};
|
||
if let Some(r) = r {
|
||
rows.push(prop("Radius", r).token(variable(doc, n, "cornerRadius")));
|
||
}
|
||
}
|
||
Section { title: "Layout", rows }
|
||
}
|
||
|
||
fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section {
|
||
let dir = match (l.mode, l.wrap) {
|
||
(LayoutMode::Horizontal, true) => "Horizontal, wrap",
|
||
(LayoutMode::Horizontal, false) => "Horizontal",
|
||
(LayoutMode::Vertical, true) => "Vertical, wrap",
|
||
(LayoutMode::Vertical, false) => "Vertical",
|
||
(LayoutMode::Grid, _) => "Grid",
|
||
};
|
||
let mut rows = vec![prop("Direction", dir)];
|
||
if l.mode == LayoutMode::Grid {
|
||
rows.push(prop("Grid", format!("{} × {}", l.grid_columns, l.grid_rows)));
|
||
rows.push(prop("Gap", format!("{} {}", num(l.gap), num(l.cross_gap))));
|
||
} else {
|
||
let gap = match l.main_align {
|
||
MainAlign::SpaceBetween => "Auto (space between)".to_string(),
|
||
MainAlign::SpaceAround => "Auto (space around)".to_string(),
|
||
MainAlign::SpaceEvenly => "Auto (space evenly)".to_string(),
|
||
_ => num(l.gap),
|
||
};
|
||
rows.push(prop("Gap", gap).token(variable(doc, n, "gap")));
|
||
if l.wrap {
|
||
let label = if l.mode == LayoutMode::Vertical { "Column gap" } else { "Row gap" };
|
||
rows.push(prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap")));
|
||
}
|
||
}
|
||
let [t, r, b, lft] = l.padding;
|
||
let padding = if t == r && r == b && b == lft {
|
||
num(t)
|
||
} else if t == b && r == lft {
|
||
format!("{} {}", num(t), num(r))
|
||
} else {
|
||
format!("{} {} {} {}", num(t), num(r), num(b), num(lft))
|
||
};
|
||
let pad_token = variable(doc, n, "padding").or_else(|| variable(doc, n, "paddingHorizontal")).or_else(|| variable(doc, n, "paddingVertical"));
|
||
rows.push(prop("Padding", padding).token(pad_token));
|
||
let main = match l.main_align {
|
||
MainAlign::Min => "start",
|
||
MainAlign::Center => "center",
|
||
MainAlign::Max => "end",
|
||
MainAlign::SpaceBetween => "space between",
|
||
MainAlign::SpaceAround => "space around",
|
||
MainAlign::SpaceEvenly => "space evenly",
|
||
};
|
||
let cross = match l.cross_align {
|
||
CrossAlign::Min => "start",
|
||
CrossAlign::Center => "center",
|
||
CrossAlign::Max => "end",
|
||
CrossAlign::Baseline => "baseline",
|
||
};
|
||
rows.push(prop("Align", format!("{main}, {cross}")));
|
||
Section { title: "Auto layout", rows }
|
||
}
|
||
|
||
fn typography(doc: &Document, n: &Node, t: &TextData) -> Section {
|
||
let s = &t.style;
|
||
let token = style_name(doc, n.styles.text.as_ref());
|
||
let mut rows = vec![
|
||
prop("Font", s.font_family.clone()).token(token),
|
||
prop("Style", s.font_style.clone()),
|
||
prop("Size", num(s.font_size)).token(variable(doc, n, "fontSize")),
|
||
prop("Line height", s.line_height.label()),
|
||
prop("Letter spacing", s.letter_spacing.label()),
|
||
];
|
||
if t.paragraph_spacing > 0.0 {
|
||
rows.push(prop("Paragraph spacing", num(t.paragraph_spacing)));
|
||
}
|
||
if t.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
|
||
rows.push(prop("Vertical trim", "cap height to baseline"));
|
||
}
|
||
let align = match t.align_h {
|
||
TextAlignH::Left => "left",
|
||
TextAlignH::Center => "center",
|
||
TextAlignH::Right => "right",
|
||
TextAlignH::Justified => "justified",
|
||
};
|
||
rows.push(prop("Align", align));
|
||
if s.decoration != TextDecoration::None {
|
||
let l = s.look();
|
||
let style = match l.style {
|
||
figcraft_doc::DecorationStyle::Solid => "",
|
||
figcraft_doc::DecorationStyle::Dotted => ", dotted",
|
||
figcraft_doc::DecorationStyle::Wavy => ", wavy",
|
||
};
|
||
let kind = if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" };
|
||
rows.push(prop("Decoration", format!("{kind}{style}")));
|
||
}
|
||
if t.list_spacing > 0.0 {
|
||
rows.push(prop("List spacing", num(t.list_spacing)));
|
||
}
|
||
if variable(doc, n, "characters").is_some() {
|
||
rows.push(prop("Content", t.characters.chars().take(80).collect::<String>()).token(variable(doc, n, "characters")));
|
||
}
|
||
Section { title: "Typography", rows }
|
||
}
|