M6: SwiftUI and Compose code name a bound variable by its code syntax (#1716)

Bound variables with an iOS or Android code syntax are named in generated SwiftUI and Compose in place of their value: paints, stroke weight, sizes, corner radius, gaps, padding, opacity and text. A variable with none for the platform keeps the value.
This commit is contained in:
BFlat
2026-10-31 06:49:41 +07:00
committed by GitHub
parent a1a198239e
commit 2e92d36af0
9 changed files with 181 additions and 60 deletions
+24 -2
View File
@@ -2,8 +2,9 @@
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
//! whose properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties, and
//! their iOS and Android code syntax in SwiftUI and Compose. The output is a starting point, not a
//! pixel-exact translation.
use figcraft_color::{Color, PaintKind};
use figcraft_doc::{
@@ -373,6 +374,27 @@ fn drop_shadow(n: &Node) -> Option<&Shadow> {
})
}
/// The fields a padding's top, right, bottom and left can be bound through, closest first.
const PADDING: [[&str; 3]; 4] = [
["paddingTop", "paddingVertical", "padding"],
["paddingRight", "paddingHorizontal", "padding"],
["paddingBottom", "paddingVertical", "padding"],
["paddingLeft", "paddingHorizontal", "padding"],
];
/// What code for `platform` (a [`figcraft_doc::CODE_PLATFORMS`] key) writes for a value of `n`: the
/// code syntax of the variable bound to the first of `fields` that has one, else `value`. Without a
/// code syntax the value stays: a name made up here wouldn't exist in the code it lands in.
fn named(doc: &Document, n: &Node, platform: &str, fields: &[&str], value: String) -> String {
fields
.iter()
.find_map(|f| crate::devinspect::bound_variable(doc, n, f))
.and_then(|v| v.code_syntax.get(platform))
.map(|name| name.trim().chars().filter(|c| !c.is_control()).collect::<String>())
.filter(|name| !name.is_empty())
.unwrap_or(value)
}
fn radius(n: &Node) -> f64 {
n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max))
}
+43 -21
View File
@@ -1,5 +1,8 @@
use super::swift::one_line;
use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
};
use figcraft_doc::Document;
// ------------------------------------------------------------------ Jetpack Compose
@@ -9,25 +12,40 @@ pub(crate) fn kotlin_color(c: Color) -> String {
format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
}
/// A bound variable's Android code syntax, else `value`.
fn bound(doc: &Document, n: &Node, fields: &[&str], value: String) -> String {
named(doc, n, "ANDROID", fields, value)
}
/// [`bound`] for a length: `value` in `dp`.
fn bound_dp(doc: &Document, n: &Node, fields: &[&str], value: f64) -> String {
bound(doc, n, fields, format!("{}.dp", num(value)))
}
/// 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));
fn modifier(doc: &Document, n: &Node, shape: &str, padding: bool) -> String {
let mut m = format!("Modifier.size({}, {})", bound_dp(doc, n, &["width"], n.width), bound_dp(doc, n, &["height"], 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)));
m.push_str(&format!(".background({}, {shape})", bound(doc, n, &["fill"], kotlin_color(c))));
}
if let Some(c) = stroke_color(n) {
m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
m.push_str(&format!(
".border({}, {}, {shape})",
bound_dp(doc, n, &["strokeWeight"], n.stroke.weight),
bound(doc, n, &["stroke"], kotlin_color(c))
));
}
if n.opacity < 1.0 {
m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
m.push_str(&format!(".alpha({})", bound(doc, n, &["opacity"], format!("{}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)));
let side = |at: usize, v: f64| bound_dp(doc, n, &PADDING[at], v);
m.push_str(&format!(".padding(start = {}, top = {}, end = {}, bottom = {})", side(3, lft), side(0, t), side(1, r), side(2, b)));
}
}
m
@@ -65,33 +83,37 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
out.push_str(&call);
return;
}
let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
let shape = if radius(n) > 0.0 {
format!("RoundedCornerShape({})", bound_dp(doc, n, &["cornerRadius"], 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());
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
let color = first_fill(n).map_or_else(|| "Color.Unspecified".into(), |c| bound(doc, n, &["fill"], kotlin_color(c)));
let text = ctx.param(n, "characters").map_or_else(|| bound(doc, n, &["characters"], literal(&t.characters, true)), |p| p.ident.clone());
out.push_str(&format!(
"{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
num(t.style.font_size),
"{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {},\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
bound(doc, n, &["fontSize"], format!("{}.sp", 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))),
NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(doc, n, "CircleShape", false))),
NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(doc, n, &shape, false))),
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(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)
"Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}),\n{pad}) {{",
modifier(doc, n, &shape, true),
bound_dp(doc, n, &["gap"], 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)
"Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
modifier(doc, n, &shape, true),
bound_dp(doc, n, &["gap"], l.gap)
),
None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
None => format!("Box(modifier = {}) {{", modifier(doc, n, &shape, false)),
};
out.push_str(&format!("{pad}{open}\n"));
// A slot shows the instance's content, or else the main's.
+6 -6
View File
@@ -1,6 +1,6 @@
use super::{
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH,
TextCase, TextDecoration, Variable, css_borders, num,
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PADDING, PaintKind,
TextAlignH, TextCase, TextDecoration, Variable, css_borders, num,
};
// ------------------------------------------------------------------ CSS
@@ -146,10 +146,10 @@ fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut
put("gap", tok(doc, n, &["gap"], px(l.gap), t));
let [pt, pr, pb, pl] = l.padding;
let sides = [
tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t),
tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t),
tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t),
tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t),
tok(doc, n, &PADDING[0], px(pt), t),
tok(doc, n, &PADDING[1], px(pr), t),
tok(doc, n, &PADDING[2], px(pb), t),
tok(doc, n, &PADDING[3], px(pl), t),
];
put("padding", sides.join(" "));
let justify = match l.main_align {
+39 -26
View File
@@ -1,4 +1,6 @@
use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
};
// ------------------------------------------------------------------ SwiftUI
@@ -60,40 +62,45 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
}
return;
}
// A bound variable's iOS code syntax in place of the value.
let bound = |fields: &[&str], value: String| named(doc, n, "iOS", fields, value);
let fill = first_fill(n).map(|c| bound(&["fill"], swift_color(c)));
let (w, h) = (bound(&["width"], num(n.width)), bound(&["height"], num(n.height)));
let corner = bound(&["cornerRadius"], num(radius(n)));
let mut mods: Vec<String> = Vec::new();
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone());
let text = ctx.param(n, "characters").map_or_else(|| bound(&["characters"], literal(&t.characters, false)), |p| p.ident.clone());
out.push_str(&format!("{pad}Text({text})\n"));
mods.push(format!(
".font(.custom({}, size: {}).weight({}))",
literal(&t.style.font_family, false),
num(t.style.font_size),
bound(&["fontFamily"], literal(&t.style.font_family, false)),
bound(&["fontSize"], num(t.style.font_size)),
swift_weight(t.style.weight())
));
if let Some(c) = first_fill(n) {
mods.push(format!(".foregroundColor({})", swift_color(c)));
if let Some(c) = &fill {
mods.push(format!(".foregroundColor({c})"));
}
}
NodeKind::Ellipse { .. } => {
out.push_str(&format!("{pad}Ellipse()\n"));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", swift_color(c)));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
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)));
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {corner})\n"));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let open = 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)),
Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", bound(&["gap"], num(l.gap))),
Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", bound(&["gap"], num(l.gap))),
None => "ZStack(alignment: .topLeading) {".to_string(),
};
out.push_str(&format!("{pad}{open}\n"));
@@ -124,29 +131,35 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
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)));
let side = |at: usize, v: f64| bound(&PADDING[at], num(v));
mods.push(format!(
".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))",
side(0, t),
side(3, lft),
side(2, b),
side(1, 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)));
mods.push(format!(".frame(width: {w}, height: {h}, alignment: .topLeading)"));
if let Some(c) = &fill {
mods.push(format!(".background({c})"));
}
if radius(n) > 0.0 {
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {corner}))"));
}
}
_ => {
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)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
}
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)
".overlay(RoundedRectangle(cornerRadius: {corner}).stroke({}, lineWidth: {}))",
bound(&["stroke"], swift_color(c)),
bound(&["strokeWeight"], num(n.stroke.weight))
));
}
if let Some(s) = drop_shadow(n) {
@@ -159,7 +172,7 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
));
}
if n.opacity < 1.0 {
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
mods.push(format!(".opacity({})", bound(&["opacity"], num(f64::from(n.opacity)))));
}
mods.extend(extra.iter().cloned());
for m in mods {