diff --git a/ROADMAP.md b/ROADMAP.md index 111ee6d7..b0e6b2b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%**. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dcdb4ca9..fc041b3f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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() { diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs new file mode 100644 index 00000000..34e0307a --- /dev/null +++ b/crates/engine/src/codegen.rs @@ -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 { + 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::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 { + 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 { + 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 { + 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 = 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 = 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 = 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()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d58c737..72717aa3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod catalog; pub mod cmd; +pub mod codegen; pub mod components; pub mod drop; pub mod guard; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a99efaf0..43b3180a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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"); } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index ab6047e8..c99deb30 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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 = 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 = 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::(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())); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 85ea53b9..16ee14ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. |