mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 01:00:39 +00:00
- Background blur sees the whole backdrop under the layer: the renderer keeps the stack of isolated surfaces it draws into (clipping frames, rounded clips, faded groups, masks) and flattens it for the layer's area, reading past the layer's own box. A blur inside a clipping frame now shows the frame's fill instead of the sharp layers below, on the canvas and in PNG exports. The PDF export draws background blur and glass through the same code from a render of what lies under the node (`backdrop::render_beneath`, which the PDF's backdrop now shares), and its images match the PNG's pixels. - Shadow spread offsets the outline: the shape filled and stroked twice the spread wide with mitred joins (grow) or cut away (shrink), the stroked bands cached per outline. Ellipses stay ellipses, stars keep sharp points, text keeps its letterforms, rounded corners grow their radius. Inner shadows shrink the outline the same way. Canvas, PNG and PDF share the shadow code; SVG writes spread shadows as the same geometry and now exports inner shadows. - Glass effect (light angle and intensity, refraction, depth, dispersion, frost, splay): the frosted backdrop bent towards the inside within `depth` of the edge, colour fringes, light on the rim, from an exact distance transform with smoothed normals. - Noise: mono, duo and multi colour modes, grain size, density, opacity and blend mode; grains live in the layer's coordinates (stable when panning and zooming). Texture is now drawn (pixels scattered by the radius, grain size, clip to shape). - Design panel rows for noise mode/colours/opacity, texture clip to shape and every glass value; `effect.add` takes `glass`; REST import of NOISE, TEXTURE and GLASS; Inspect rows and CSS; the scale tool scales grain sizes, texture radius, glass depth and frost. - Tests in new modules: render backdrop/spread/grain tests, PDF backdrop tests, SVG, doc, engine and REST effect tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
14 KiB
Rust
350 lines
14 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, 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>,
|
||
}
|
||
|
||
/// A layer in the inspected selection that has export settings.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct Asset {
|
||
pub id: NodeId,
|
||
pub name: String,
|
||
/// Its export settings as shown ("PNG 2x", "SVG 1x"…).
|
||
pub exports: Vec<String>,
|
||
}
|
||
|
||
/// Most assets listed for one selection.
|
||
const MAX_ASSETS: usize = 200;
|
||
|
||
/// The layers in and under `ids` that have export settings, in layer order.
|
||
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.exports.is_empty() && !out.iter().any(|a: &Asset| a.id == id) {
|
||
let exports = n.exports.iter().map(|e| format!("{} {}", e.format.label(), e.size.label())).collect();
|
||
out.push(Asset { id, name: n.name.clone(), exports });
|
||
}
|
||
stack.extend(n.children.iter().rev().copied());
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
spec!(
|
||
"inspect.assets",
|
||
"Inspect assets",
|
||
"{ids? (default the selection)} → {assets: [{id, name, exports}]} the layers in and under them with export settings (export them with `file.exportLayers`)",
|
||
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?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names",
|
||
false,
|
||
|s, p| {
|
||
let id = match id(p, "id") {
|
||
Some(i) => i,
|
||
None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?,
|
||
};
|
||
let doc = s.doc()?;
|
||
doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?;
|
||
Ok(json!({"sections": serde_json::to_value(properties(doc, id)).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() }
|
||
}
|
||
|
||
/// `#RRGGBB` plus the opacity when it isn't 100%.
|
||
fn color_text(c: Color) -> String {
|
||
if c.a < 0.999 { format!("#{} · {}%", c.to_hex(), (c.a * 100.0).round()) } else { format!("#{}", c.to_hex()) }
|
||
}
|
||
|
||
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 `field` on `n`, by name.
|
||
fn variable(doc: &Document, n: &Node, field: &str) -> Option<String> {
|
||
n.bound.get(field).and_then(|v| doc.variables.get(v)).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; empty when it doesn't exist.
|
||
pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
||
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));
|
||
}
|
||
let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec<Prop> {
|
||
paints
|
||
.iter()
|
||
.filter(|p| p.visible)
|
||
.map(|p| match &p.kind {
|
||
PaintKind::Solid { color } => {
|
||
let c = color.with_alpha(color.a * p.opacity);
|
||
prop("Solid", color_text(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!("#{} {}%", s.color.to_hex(), (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())),
|
||
})
|
||
.collect()
|
||
};
|
||
let fills = paint_rows(&n.fills);
|
||
if !fills.is_empty() {
|
||
let token = style_name(doc, n.styles.fill.as_ref()).or_else(|| variable(doc, n, "fill"));
|
||
out.push(Section { title: "Fills", rows: tag_first(fills, token) });
|
||
}
|
||
let mut strokes = paint_rows(&n.strokes);
|
||
if !strokes.is_empty() {
|
||
let token = style_name(doc, n.styles.stroke.as_ref()).or_else(|| variable(doc, n, "stroke"));
|
||
strokes = tag_first(strokes, token);
|
||
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 { radius } => prop("Layer blur", num(*radius)),
|
||
EffectKind::BackgroundBlur { radius } => prop("Background blur", num(*radius)),
|
||
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.
|
||
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 parent frame's top-left (from the page origin at the top level).
|
||
if let Some(b) = doc.world_bounds(id) {
|
||
let origin = n
|
||
.parent
|
||
.filter(|p| !doc.node(*p).is_some_and(|pn| pn.is_page()))
|
||
.and_then(|p| doc.world_bounds(p))
|
||
.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 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, _) => "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 = if l.main_align == MainAlign::SpaceBetween { "Auto (space between)".to_string() } else { num(l.gap) };
|
||
rows.push(prop("Gap", gap).token(variable(doc, n, "gap")));
|
||
if l.wrap {
|
||
rows.push(prop("Row gap", 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",
|
||
};
|
||
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)));
|
||
}
|
||
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 {
|
||
rows.push(prop("Decoration", if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" }));
|
||
}
|
||
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 }
|
||
}
|