M6: Inspect code in CSS, SwiftUI and Jetpack Compose (#15)

- Code generation moves from the UI into the engine (`codegen.rs`): CSS for the layer as before,
  plus SwiftUI and Compose for its subtree (stacks with spacing and padding for auto layout,
  offsets inside a ZStack/Box otherwise, text with font and colour, shapes, fills, strokes,
  shadows, opacity; other layers become image-asset placeholders). String literals are escaped
  per language; deep trees are capped.
- New command `inspect.code {id?, language?}` for the CLI, control channel and MCP.
- Inspect tab: a language picker and Copy for the shown code; Copy as CSS uses the same code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 03:45:29 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 912cae6eeb
commit 076c25f6df
7 changed files with 536 additions and 122 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 |
| Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6–10 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
Weighted feature depth ≈ **52%**.
+1
View File
@@ -75,6 +75,7 @@ fn registry() -> &'static Registry {
list.extend(proto::specs());
list.extend(comment::specs());
list.extend(crate::vectoredit::specs());
list.extend(crate::codegen::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {
+507
View File
@@ -0,0 +1,507 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
//! layer itself, and SwiftUI or Jetpack Compose for its subtree (stacks for auto layout, absolute
//! offsets otherwise). The output is a starting point, not a pixel-exact translation.
use figcraft_color::{Color, PaintKind};
use figcraft_doc::*;
use serde_json::json;
use crate::cmd::CommandSpec;
use crate::params::*;
use crate::{bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![spec!("inspect.code", "Code for layer", "{id?, language?: css|swiftui|compose (default css)} → {code}", false, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?;
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad("inspect.code", "language: css|swiftui|compose"))?,
None => CodeLang::Css,
};
let doc = s.doc()?;
if !doc.contains(id) {
return Err(bad("inspect.code", "no such layer"));
}
Ok(json!({"code": code_for(doc, id, lang), "language": lang.id()}))
})]
}
/// Languages the Inspect panel offers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum CodeLang {
#[default]
Css,
SwiftUi,
Compose,
}
impl CodeLang {
pub const ALL: [CodeLang; 3] = [CodeLang::Css, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
}
}
pub fn label(self) -> &'static str {
match self {
CodeLang::Css => "CSS",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
}
}
pub fn from_id(s: &str) -> Option<CodeLang> {
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
/// Layers deeper than this (or past this many children) are summarised.
const MAX_DEPTH: usize = 12;
const MAX_CHILDREN: usize = 200;
/// `v` without trailing zeros (2 decimals).
fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
}
pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::SwiftUi => {
let mut out = String::new();
swift(doc, id, 0, &mut out);
out
}
CodeLang::Compose => {
let mut out = String::new();
compose(doc, id, 0, &mut out);
out
}
}
}
fn first_fill(n: &Node) -> Option<Color> {
n.fills.iter().filter(|p| p.visible).find_map(|p| match &p.kind {
PaintKind::Solid { color } => Some(color.with_alpha(color.a * p.opacity)),
PaintKind::Gradient(g) => g.sorted_stops().first().map(|s| s.color),
PaintKind::Image(_) => None,
})
}
fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
}
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind {
EffectKind::DropShadow(s) => Some(s),
_ => None,
})
}
fn radius(n: &Node) -> f64 {
n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max))
}
/// Text content as a string literal (escaped for Swift; Kotlin also escapes `$`).
fn literal(s: &str, kotlin: bool) -> String {
let mut out = String::from("\"");
for c in s.chars().take(10_000) {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' | '\u{2028}' => out.push_str("\\n"),
'\r' => {}
'\t' => out.push_str("\\t"),
'$' if kotlin => out.push_str("\\$"),
c if c.is_control() => {}
c => out.push(c),
}
}
out.push('"');
out
}
/// Visible children in paint order (bottom first), capped.
fn kids(doc: &Document, n: &Node) -> Vec<NodeId> {
n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|k| k.visible)).take(MAX_CHILDREN).collect()
}
fn indent(depth: usize) -> String {
" ".repeat(depth)
}
// ------------------------------------------------------------------ CSS
fn css(doc: &Document, id: NodeId) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let mut out = format!("/* {} */\n", n.name.replace("*/", "* /"));
out.push_str(&format!("width: {}px;\nheight: {}px;\n", num(n.width), num(n.height)));
if let Some(l) = &n.layout {
out.push_str("display: flex;\n");
out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" }));
if l.wrap {
out.push_str("flex-wrap: wrap;\n");
}
out.push_str(&format!("gap: {}px;\n", num(l.gap)));
let [pt, pr, pb, pl] = l.padding;
out.push_str(&format!("padding: {}px {}px {}px {}px;\n", num(pt), num(pr), num(pb), num(pl)));
out.push_str(&format!(
"justify-content: {};\nalign-items: {};\n",
match l.main_align {
MainAlign::Min => "flex-start",
MainAlign::Center => "center",
MainAlign::Max => "flex-end",
MainAlign::SpaceBetween => "space-between",
},
match l.cross_align {
CrossAlign::Min => "flex-start",
CrossAlign::Center => "center",
CrossAlign::Max => "flex-end",
CrossAlign::Baseline => "baseline",
}
));
}
if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
out.push_str(&format!("border-radius: {}px;\n", num(r)));
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
let [a, b, c, d] = n.corners.radii;
out.push_str(&format!("border-radius: {}px {}px {}px {}px;\n", num(a), num(b), num(c), num(d)));
}
if n.opacity < 1.0 {
out.push_str(&format!("opacity: {};\n", num(f64::from(n.opacity))));
}
for p in n.fills.iter().filter(|p| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
out.push_str(&format!("{key}: {};\n", color.with_alpha(color.a * p.opacity).to_css()));
}
PaintKind::Gradient(g) => {
let stops: Vec<String> = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect();
out.push_str(&format!("background: linear-gradient({});\n", stops.join(", ")));
}
PaintKind::Image(_) => out.push_str("background: url(image.png) center / cover;\n"),
}
}
if let Some(c) = stroke_color(n) {
out.push_str(&format!("border: {}px solid {};\n", num(n.stroke.weight), c.to_css()));
}
let shadows: Vec<String> = n
.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::DropShadow(s) => {
Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
}
EffectKind::InnerShadow(s) => {
Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
}
_ => None,
})
.collect();
if !shadows.is_empty() {
out.push_str(&format!("box-shadow: {};\n", shadows.join(", ")));
}
for e in n.effects.iter().filter(|e| e.visible) {
match e.kind {
EffectKind::LayerBlur { radius } => out.push_str(&format!("filter: blur({}px);\n", num(radius / 2.0))),
EffectKind::BackgroundBlur { radius } => out.push_str(&format!("backdrop-filter: blur({}px);\n", num(radius / 2.0))),
_ => {}
}
}
if let NodeKind::Text(t) = &n.kind {
out.push_str(&format!(
"font-family: \"{}\";\nfont-size: {}px;\nfont-weight: {};\n",
t.style.font_family,
num(t.style.font_size),
t.style.weight()
));
if t.style.italic() {
out.push_str("font-style: italic;\n");
}
match t.style.line_height {
LineHeight::Pixels(v) => out.push_str(&format!("line-height: {}px;\n", num(v))),
LineHeight::Percent(p) => out.push_str(&format!("line-height: {}%;\n", num(p))),
LineHeight::Auto => {}
}
match t.style.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => out.push_str(&format!("letter-spacing: {}px;\n", num(v))),
LetterSpacing::Percent(p) if p != 0.0 => out.push_str(&format!("letter-spacing: {}em;\n", num(p / 100.0))),
_ => {}
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {
format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a)))
}
fn swift_weight(w: u16) -> &'static str {
match w {
0..=150 => ".thin",
151..=250 => ".ultraLight",
251..=350 => ".light",
351..=450 => ".regular",
451..=550 => ".medium",
551..=650 => ".semibold",
651..=750 => ".bold",
751..=850 => ".heavy",
_ => ".black",
}
}
fn swift(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
let Some(n) = doc.node(id) else { return };
let pad = indent(depth);
if depth > MAX_DEPTH {
out.push_str(&format!("{pad}// {} …\n", n.name));
return;
}
let mut mods: Vec<String> = Vec::new();
match &n.kind {
NodeKind::Text(t) => {
out.push_str(&format!("{pad}Text({})\n", literal(&t.characters, false)));
mods.push(format!(
".font(.custom({}, size: {}).weight({}))",
literal(&t.style.font_family, false),
num(t.style.font_size),
swift_weight(t.style.weight())
));
if let Some(c) = first_fill(n) {
mods.push(format!(".foregroundColor({})", swift_color(c)));
}
}
NodeKind::Ellipse { .. } => {
out.push_str(&format!("{pad}Ellipse()\n"));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", swift_color(c)));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
}
NodeKind::Rectangle => {
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n))));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", swift_color(c)));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = kids(doc, n);
let (open, close) = match n.layout.as_ref().map(|l| (l.mode, l)) {
Some((LayoutMode::Vertical, l)) => (format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)), "}"),
Some((_, l)) => (format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)), "}"),
None => ("ZStack(alignment: .topLeading) {".to_string(), "}"),
};
out.push_str(&format!("{pad}{open}\n"));
for c in &children {
swift(doc, *c, depth + 1, out);
// Free children sit at their offset inside a ZStack.
if n.layout.is_none()
&& let Some(cn) = doc.node(*c)
{
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
out.push_str(&format!("{} .offset(x: {}, y: {})\n", indent(depth + 1), num(x), num(y)));
}
}
out.push_str(&format!("{pad}{close}\n"));
if let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r)));
}
}
mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height)));
if let Some(c) = first_fill(n) {
mods.push(format!(".background({})", swift_color(c)));
}
if radius(n) > 0.0 {
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
}
}
_ => {
out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false)));
mods.push(".resizable()".into());
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
}
}
if let Some(c) = stroke_color(n) {
mods.push(format!(
".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))",
num(radius(n)),
swift_color(c),
num(n.stroke.weight)
));
}
if let Some(s) = drop_shadow(n) {
mods.push(format!(
".shadow(color: {}, radius: {}, x: {}, y: {})",
swift_color(s.color),
num(s.radius / 2.0),
num(s.offset.0),
num(s.offset.1)
));
}
if n.opacity < 1.0 {
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
}
for m in mods {
out.push_str(&format!("{pad} {m}\n"));
}
}
// ------------------------------------------------------------------ Jetpack Compose
fn kotlin_color(c: Color) -> String {
let [r, g, b, a] = c.to_rgba8();
format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
}
/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
fn modifier(n: &Node, shape: &str, padding: bool) -> String {
let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
if let Some(s) = drop_shadow(n) {
m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
}
if let Some(c) = first_fill(n) {
m.push_str(&format!(".background({}, {shape})", kotlin_color(c)));
}
if let Some(c) = stroke_color(n) {
m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
}
if n.opacity < 1.0 {
m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
}
if padding && let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
}
}
m
}
fn compose(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
let Some(n) = doc.node(id) else { return };
let pad = indent(depth);
if depth > MAX_DEPTH {
out.push_str(&format!("{pad}// {} …\n", n.name));
return;
}
let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
match &n.kind {
NodeKind::Text(t) => {
let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into());
out.push_str(&format!(
"{pad}Text(\n{pad} text = {},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
literal(&t.characters, true),
num(t.style.font_size),
(t.style.weight().clamp(100, 900) / 100) * 100
));
}
NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))),
NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))),
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = kids(doc, n);
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
Some((LayoutMode::Vertical, l)) => format!("Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{", modifier(n, &shape, true), num(l.gap)),
Some((_, l)) => format!(
"Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
modifier(n, &shape, true),
num(l.gap)
),
None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
};
out.push_str(&format!("{pad}{open}\n"));
for c in &children {
if n.layout.is_none()
&& let Some(cn) = doc.node(*c)
{
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(depth + 1), num(x), num(y)));
compose(doc, *c, depth + 2, out);
out.push_str(&format!("{}}}\n", indent(depth + 1)));
} else {
compose(doc, *c, depth + 1, out);
}
}
out.push_str(&format!("{pad}}}\n"));
}
_ => out.push_str(&format!(
"{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n",
n.kind.label(),
literal(&n.name, true),
num(n.width),
num(n.height)
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use crate::params::id_of;
use serde_json::json;
fn card(s: &mut Session) -> NodeId {
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap();
s.execute("text.create", &json!({"text": "Say \"hi\" $5\nnow", "x": 0, "y": 0, "parent": f})).unwrap();
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
f
}
#[test]
fn swiftui_and_compose_for_an_auto_layout_card() {
let mut s = Session::new();
let f = card(&mut s);
let doc = s.doc().unwrap();
let sw = code_for(doc, f, CodeLang::SwiftUi);
assert!(sw.contains("Stack(alignment:"), "{sw}");
assert!(sw.contains("Text(\"Say \\\"hi\\\" $5\\nnow\")"), "escaped: {sw}");
assert!(sw.contains("Ellipse()"));
assert!(sw.contains(".clipShape(RoundedRectangle(cornerRadius: 12))"));
let kt = code_for(doc, f, CodeLang::Compose);
assert!(kt.contains("Arrangement.spacedBy("), "{kt}");
assert!(kt.contains("\\$5"), "Kotlin escapes $: {kt}");
assert!(kt.contains("CircleShape"));
assert!(kt.contains("RoundedCornerShape(12.dp)"));
let css = code_for(doc, f, CodeLang::Css);
assert!(css.contains("display: flex;") && css.contains("border-radius: 12px;"));
// Through the command.
let v = s.execute("inspect.code", &json!({"id": f, "language": "compose"})).unwrap();
assert_eq!(v["code"].as_str().unwrap(), kt);
assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err());
}
#[test]
fn free_children_get_offsets_and_deep_trees_are_capped() {
let mut s = Session::new();
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
let root = parent;
s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
for _ in 0..20 {
parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
}
let doc = s.doc().unwrap();
let sw = code_for(doc, root, CodeLang::SwiftUi);
assert!(sw.contains(".offset(x: 7, y: 9)"), "{sw}");
assert!(sw.contains("…"), "depth capped");
let kt = code_for(doc, root, CodeLang::Compose);
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod catalog;
pub mod cmd;
pub mod codegen;
pub mod components;
pub mod drop;
pub mod guard;
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"edit.copyAsCss" => {
let ids = app.session.selection();
if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) {
let css = crate::right::css_for(doc, *first);
let css = figcraft_engine::codegen::code_for(doc, *first, figcraft_engine::codegen::CodeLang::Css);
app.services.clipboard_set_text(&css);
app.toast("Copied as CSS");
}
+24 -119
View File
@@ -1287,118 +1287,7 @@ fn export(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
}
/// CSS for a node (Inspect / Copy as CSS).
pub fn css_for(doc: &Document, id: NodeId) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let mut out = format!("/* {} */\n", n.name);
out.push_str(&format!("width: {}px;\nheight: {}px;\n", crate::canvas::fmt(n.width), crate::canvas::fmt(n.height)));
if let Some(l) = &n.layout {
out.push_str("display: flex;\n");
out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" }));
if l.wrap {
out.push_str("flex-wrap: wrap;\n");
}
out.push_str(&format!("gap: {}px;\n", crate::canvas::fmt(l.gap)));
let [pt, pr, pb, pl] = l.padding;
out.push_str(&format!(
"padding: {}px {}px {}px {}px;\n",
crate::canvas::fmt(pt),
crate::canvas::fmt(pr),
crate::canvas::fmt(pb),
crate::canvas::fmt(pl)
));
out.push_str(&format!(
"justify-content: {};\nalign-items: {};\n",
match l.main_align {
MainAlign::Min => "flex-start",
MainAlign::Center => "center",
MainAlign::Max => "flex-end",
MainAlign::SpaceBetween => "space-between",
},
match l.cross_align {
CrossAlign::Min => "flex-start",
CrossAlign::Center => "center",
CrossAlign::Max => "flex-end",
CrossAlign::Baseline => "baseline",
}
));
}
if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
out.push_str(&format!("border-radius: {}px;\n", crate::canvas::fmt(r)));
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
let [a, b, c, d] = n.corners.radii;
out.push_str(&format!("border-radius: {}px {}px {}px {}px;\n", a, b, c, d));
}
if n.opacity < 1.0 {
out.push_str(&format!("opacity: {};\n", n.opacity));
}
for p in n.fills.iter().filter(|p| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
out.push_str(&format!("{key}: {};\n", color.with_alpha(color.a * p.opacity).to_css()));
}
PaintKind::Gradient(g) => {
let stops: Vec<String> = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect();
out.push_str(&format!("background: linear-gradient({});\n", stops.join(", ")));
}
PaintKind::Image(_) => out.push_str("background: url(image.png) center / cover;\n"),
}
}
if n.has_visible_stroke()
&& let Some(c) = n.strokes.first().and_then(|p| p.solid_color())
{
out.push_str(&format!("border: {}px solid {};\n", crate::canvas::fmt(n.stroke.weight), c.to_css()));
}
let shadows: Vec<String> = n
.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::DropShadow(s) => Some(format!("{}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())),
EffectKind::InnerShadow(s) => Some(format!("inset {}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())),
_ => None,
})
.collect();
if !shadows.is_empty() {
out.push_str(&format!("box-shadow: {};\n", shadows.join(", ")));
}
for e in n.effects.iter().filter(|e| e.visible) {
match e.kind {
EffectKind::LayerBlur { radius } => out.push_str(&format!("filter: blur({}px);\n", radius / 2.0)),
EffectKind::BackgroundBlur { radius } => out.push_str(&format!("backdrop-filter: blur({}px);\n", radius / 2.0)),
_ => {}
}
}
if let NodeKind::Text(t) = &n.kind {
out.push_str(&format!(
"font-family: \"{}\";\nfont-size: {}px;\nfont-weight: {};\n",
t.style.font_family,
t.style.font_size,
t.style.weight()
));
if t.style.italic() {
out.push_str("font-style: italic;\n");
}
match t.style.line_height {
LineHeight::Pixels(v) => out.push_str(&format!("line-height: {v}px;\n")),
LineHeight::Percent(p) => out.push_str(&format!("line-height: {p}%;\n")),
LineHeight::Auto => {}
}
if let LetterSpacing::Pixels(v) = t.style.letter_spacing
&& v != 0.0
{
out.push_str(&format!("letter-spacing: {v}px;\n"));
}
if let LetterSpacing::Percent(p) = t.style.letter_spacing
&& p != 0.0
{
out.push_str(&format!("letter-spacing: {}em;\n", p / 100.0));
}
}
out
}
/// The Inspect tab: size and position, and code for the selected layer.
fn inspect(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(ds) = app.session.ds() else { return };
@@ -1425,21 +1314,37 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
});
}
widgets::separator(ui);
widgets::section_header(ui, "CSS", &[(Icon::Export, "Copy")], false);
let css = css_for(&doc, first);
use figcraft_engine::codegen::{CodeLang, code_for};
let lang_id = Id::new("inspect_lang");
let lang = ui.data(|d| d.get_temp::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();
let copy = widgets::section_header(ui, "Code", &[(Icon::Export, "Copy")], false).is_some();
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
egui::Popup::menu(&resp).show(|ui| {
for (i, l) in CodeLang::ALL.iter().enumerate() {
if menu_item(ui, l.label(), "") {
ui.data_mut(|d| d.insert_temp(lang_id, i));
ui.close();
}
}
});
});
let code = code_for(&doc, first, lang);
egui::Frame::NONE
.fill(t.field)
.corner_radius(CornerRadius::same(6))
.inner_margin(egui::Margin::same(10))
.outer_margin(egui::Margin::symmetric(16, 0))
.show(ui, |ui| {
ui.label(egui::RichText::new(&css).monospace().color(t.text));
ui.label(egui::RichText::new(&code).monospace().color(t.text));
});
ui.add_space(8.0);
let mut copy_clicked = copy;
row(ui, 32.0, |ui| {
if ui.button("Copy CSS").clicked() {
app.services.clipboard_set_text(&css);
app.toast("Copied CSS");
}
copy_clicked |= ui.button(format!("Copy {}", lang.label())).clicked();
});
if copy_clicked {
app.services.clipboard_set_text(&code);
app.toast(format!("Copied {}", lang.label()));
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ Or in a project's `.mcp.json`:
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `style.create`, `variable.bind`, `proto.interaction.add`). |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, text…); returns the updated layers. |