M10: SwiftUI, Compose and Flutter code export writes every colour and gradient fill, in paint order (#2271)

Part of #2271. The native targets took ONE colour from a layer (`first_fill`: the bottom visible solid, or a gradient's first stop) and dropped every other fill, so a frame with white, a gradient and 50% black came out as flat white.

Now SwiftUI, Compose and Flutter write every visible solid and gradient fill of a frame, group, rectangle or ellipse, in paint order, and a gradient as a gradient. A shared `visible_fills` gives each target the same list (document order, bottom first); each target only formats it.

- SwiftUI: chained `.background(...)`, top fill first, since each one goes behind the ones before it. A rectangle or ellipse fills with the bottom paint and lays a copy of the shape over it (`.overlay`) for each paint above, bottom first.
- Compose: chained `Modifier.background(...)`, bottom fill first, since each one draws over the ones before it. A linear gradient snaps to one of eight directions (a modifier doesn't know pixels); radial and angular ones sit at the box's centre.
- Flutter: a `BoxDecoration` paints one colour or gradient, so the Container keeps the top fill and each fill under it becomes a `DecoratedBox` around the widget, the bottom fill outermost. The drop shadow goes on the outermost box so it never lies over a lower fill. An instance's call is now made once, in `flutter_placed`.

A layer with one solid fill gives the same code as before, variable names included (only the paint at index 0, when solid, takes the `fill` variable's name). A lone gradient used to come out as its first stop's colour and is now a gradient.

Still not written: image and pattern fills in the native targets, ArkUI (one background and one gradient per component, so a stack needs extra nodes) and text with several fills. Figma's iOS and Android output was not measured: the order follows how each platform paints.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.2 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-12 17:04:23 -05:00
1 parent 0d6ce1124c
commit e276b1e4ff
5 files changed
+423 -33

No files matched your search

+214 -1
View File
@@ -6,7 +6,7 @@
//! 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_color::{Color, Gradient, GradientKind, Paint, PaintKind};
use figcraft_doc::{
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing,
TextAlignH, TextCase, TextDecoration, Variable,
@@ -345,6 +345,35 @@ fn first_fill(n: &Node) -> Option<Color> {
})
}
/// A layer's visible fills with their place in the document. Document order is bottom first, as
/// the renderer paints them; the index tells a target which paint is the bottom one.
fn visible_fills(n: &Node) -> impl DoubleEndedIterator<Item = (usize, &Paint)> {
n.fills.iter().enumerate().filter(|(_, p)| p.visible)
}
/// A gradient's sorted stops as (position, colour), the paint's opacity folded into each colour.
/// A lone stop is written twice (at 1 as well), since Android and Flutter need two colours.
fn gradient_stops(g: &Gradient, opacity: f32) -> Vec<(f64, Color)> {
let mut stops: Vec<(f64, Color)> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color.with_alpha(s.color.a * opacity))).collect();
if let [(_, only)] = stops[..] {
stops.push((1.0, only));
}
stops
}
/// A gradient's main axis in pixels on a `w` x `h` box.
fn gradient_axis(g: &Gradient, w: f64, h: f64) -> (f64, f64) {
((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h)
}
/// The kind a native target writes: a diamond reads as a radial gradient.
fn native_gradient_kind(g: &Gradient) -> GradientKind {
match g.kind {
GradientKind::Diamond => GradientKind::Radial,
k => k,
}
}
fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
@@ -765,4 +794,188 @@ mod tests {
}
}
}
/// White, a 20% default linear gradient of black, then 50% black: bottom to top.
fn three_fills() -> Vec<figcraft_color::Paint> {
let mut gradient = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK));
gradient.opacity = 0.2;
let mut top = figcraft_color::Paint::solid(Color::BLACK);
top.opacity = 0.5;
vec![figcraft_color::Paint::solid(Color::WHITE), gradient, top]
}
/// A 200 x `h` layer of `kind` (`frame`, `rectangle` or `ellipse`, a rectangle with corner
/// radius 8) with exactly `fills`.
fn fill_layer(s: &mut Session, kind: &str, h: f64, fills: Vec<figcraft_color::Paint>) -> NodeId {
let id = match kind {
"frame" => id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": h})).unwrap()["id"]).unwrap(),
_ => id_of(&s.execute(&format!("shape.{kind}"), &json!({"x": 0, "y": 0, "width": 200, "height": h})).unwrap()["id"]).unwrap(),
};
if kind == "rectangle" {
s.execute("node.set", &json!({"id": id, "props": {"cornerRadius": 8}})).unwrap();
}
s.doc_mut().unwrap().get_mut(id).unwrap().fills = fills;
id
}
fn one_gradient(s: &mut Session, g: figcraft_color::Gradient) -> NodeId {
fill_layer(s, "frame", 120.0, vec![figcraft_color::Paint::gradient(g)])
}
const SWIFT_WHITE: &str = "Color(red: 1, green: 1, blue: 1, opacity: 1)";
const SWIFT_HALF: &str = "Color(red: 0, green: 0, blue: 0, opacity: 0.5)";
const SWIFT_GRADIENT: &str = "LinearGradient(stops: [Gradient.Stop(color: Color(red: 0, green: 0, blue: 0, opacity: 0.2), location: 0), Gradient.Stop(color: Color(red: 0, green: 0, blue: 0, opacity: 0), location: 1)], startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 1))";
#[test]
fn swiftui_writes_every_fill_top_first() {
let mut s = Session::new();
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), f, CodeLang::SwiftUi);
let backgrounds: Vec<&str> = code.lines().map(str::trim).filter(|l| l.starts_with(".background(")).collect();
let want = [format!(".background({SWIFT_HALF})"), format!(".background({SWIFT_GRADIENT})"), format!(".background({SWIFT_WHITE})")];
assert_eq!(backgrounds, want.iter().map(String::as_str).collect::<Vec<_>>(), "{code}");
}
#[test]
fn swiftui_shapes_fill_with_the_bottom_paint_and_overlay_the_rest() {
let mut s = Session::new();
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), r, CodeLang::SwiftUi);
let mods: Vec<&str> = code.lines().map(str::trim).filter(|l| l.starts_with('.')).collect();
let want = [
format!(".fill({SWIFT_WHITE})"),
format!(".overlay(RoundedRectangle(cornerRadius: 8).fill({SWIFT_GRADIENT}))"),
format!(".overlay(RoundedRectangle(cornerRadius: 8).fill({SWIFT_HALF}))"),
".frame(width: 200, height: 120)".to_string(),
];
assert_eq!(mods, want.iter().map(String::as_str).collect::<Vec<_>>(), "{code}");
assert!(!code.contains(".background("), "{code}");
let mut half = figcraft_color::Paint::solid(Color::BLACK);
half.opacity = 0.5;
let e = fill_layer(&mut s, "ellipse", 120.0, vec![figcraft_color::Paint::solid(Color::WHITE), half]);
let code = code_for(s.doc().unwrap(), e, CodeLang::SwiftUi);
assert!(code.contains(&format!(".overlay(Ellipse().fill({SWIFT_HALF}))")), "{code}");
}
#[test]
fn compose_writes_every_fill_bottom_first() {
let mut s = Session::new();
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), f, CodeLang::Compose);
let want = "Modifier.size(200.dp, 120.dp).background(Color(0xFFFFFFFF), RectangleShape).background(Brush.linearGradient(0f to Color(0x33000000), 1f to Color(0x00000000), start = Offset(0f, 0f), end = Offset(0f, Float.POSITIVE_INFINITY)), RectangleShape).background(Color(0x80000000), RectangleShape)";
assert!(code.contains(want), "{code}");
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), r, CodeLang::Compose);
assert_eq!(code.matches(".background(").count(), 3, "{code}");
assert_eq!(code.matches(", RoundedCornerShape(8.dp))").count(), 3, "{code}");
assert!(code.contains(".background(Color(0xFFFFFFFF), RoundedCornerShape(8.dp)).background(Brush."), "{code}");
}
#[test]
fn flutter_nests_a_box_per_fill_bottom_outermost() {
let mut s = Session::new();
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), f, CodeLang::Flutter);
let want = "DecoratedBox(
decoration: BoxDecoration(
color: Color(0xFFFFFFFF),
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(begin: Alignment(0, -1), end: Alignment(0, 1), colors: [Color(0x33000000), Color(0x00000000)], stops: [0, 1]),
),
child: Container(
width: 200,
height: 120,
decoration: BoxDecoration(
color: Color(0x80000000),
),
)
)
)";
assert_eq!(code, want);
}
#[test]
fn flutter_keeps_the_shadow_under_every_fill() {
let mut s = Session::new();
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
s.execute("effect.add", &json!({"ids": [f], "type": "dropShadow"})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Flutter);
assert_eq!(code.matches("boxShadow:").count(), 1, "{code}");
let (shadow, gradient) = (code.find("boxShadow:").unwrap(), code.find("gradient:").unwrap());
assert!(shadow < gradient, "the shadow is on the outermost box: {code}");
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
let code = code_for(s.doc().unwrap(), r, CodeLang::Flutter);
assert_eq!(code.matches("borderRadius: BorderRadius.circular(8)").count(), 3, "{code}");
}
#[test]
fn native_gradients_are_written_as_gradients() {
use figcraft_color::{Gradient, GradientKind};
let mut s = Session::new();
let radial = one_gradient(&mut s, Gradient::default_for(GradientKind::Radial, Color::BLACK));
let doc = s.doc().unwrap();
let sw = code_for(doc, radial, CodeLang::SwiftUi);
assert!(sw.contains("RadialGradient(stops: ["), "{sw}");
assert!(sw.contains("center: UnitPoint(x: 0.5, y: 0.5), startRadius: 0, endRadius: 60)"), "{sw}");
let kt = code_for(doc, radial, CodeLang::Compose);
assert!(kt.contains(".background(Brush.radialGradient(0f to Color(0xFF000000), 1f to Color(0x00000000)), RectangleShape)"), "{kt}");
let dart = code_for(doc, radial, CodeLang::Flutter);
assert!(
dart.contains(
"gradient: RadialGradient(center: Alignment(0, 0), radius: 0.5, colors: [Color(0xFF000000), Color(0x00000000)], stops: [0, 1])"
),
"{dart}"
);
let angular = one_gradient(&mut s, Gradient::default_for(GradientKind::Angular, Color::BLACK));
let doc = s.doc().unwrap();
assert!(code_for(doc, angular, CodeLang::SwiftUi).contains("angle: .degrees(90))"));
assert!(code_for(doc, angular, CodeLang::Compose).contains("Brush.sweepGradient("));
assert!(code_for(doc, angular, CodeLang::Flutter).contains("transform: GradientRotation(1.57))"));
// Compose snaps a linear gradient to one of eight directions.
let mut directed = |from: (f64, f64), to: (f64, f64)| {
let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK);
(g.from, g.to) = (from, to);
let id = one_gradient(&mut s, g);
code_for(s.doc().unwrap(), id, CodeLang::Compose)
};
let up = directed((0.5, 1.0), (0.5, 0.0));
assert!(up.contains("start = Offset(0f, Float.POSITIVE_INFINITY), end = Offset(0f, 0f)"), "{up}");
let right = directed((0.0, 0.5), (1.0, 0.5));
assert!(right.contains("end = Offset(Float.POSITIVE_INFINITY, 0f)"), "{right}");
let corner = directed((0.0, 0.0), (1.0, 1.0));
assert!(corner.contains("end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)"), "{corner}");
// A single stop is written twice, since a brush needs two colours.
let mut one = Gradient::default_for(GradientKind::Linear, Color::BLACK);
one.stops.truncate(1);
let id = one_gradient(&mut s, one);
let kt = code_for(s.doc().unwrap(), id, CodeLang::Compose);
assert_eq!(kt.matches(" to Color(").count(), 2, "{kt}");
}
#[test]
fn one_solid_fill_is_unchanged_in_native_code() {
let mut hidden = figcraft_color::Paint::solid(Color::BLACK);
hidden.visible = false;
let mut s = Session::new();
for fills in [vec![figcraft_color::Paint::solid(Color::WHITE)], vec![figcraft_color::Paint::solid(Color::WHITE), hidden]] {
let f = fill_layer(&mut s, "frame", 100.0, fills);
let doc = s.doc().unwrap();
let sw = code_for(doc, f, CodeLang::SwiftUi);
let backgrounds: Vec<&str> = sw.lines().map(str::trim).filter(|l| l.starts_with(".background(")).collect();
assert_eq!(backgrounds, [format!(".background({SWIFT_WHITE})")], "{sw}");
assert!(!sw.contains("Gradient"), "{sw}");
let kt = code_for(doc, f, CodeLang::Compose);
assert!(kt.contains("Modifier.size(200.dp, 100.dp).background(Color(0xFFFFFFFF), RectangleShape))"), "{kt}");
assert!(!kt.contains("Brush"), "{kt}");
let dart = code_for(doc, f, CodeLang::Flutter);
assert!(
dart.starts_with("Container(\n width: 200,\n height: 100,\n decoration: BoxDecoration(\n color: Color(0xFFFFFFFF),\n ),\n)"),
"{dart}"
);
assert!(!dart.contains("DecoratedBox"), "{dart}");
assert!(code_for(doc, f, CodeLang::ArkUi).contains(".backgroundColor('#FFFFFF')"));
}
}
}
+56 -3
View File
@@ -1,6 +1,7 @@
use super::swift::one_line;
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
CodeLang, Color, Ctx, GradientKind, LayoutMode, Node, NodeId, NodeKind, PADDING, Paint, PaintKind, drop_shadow, first_fill, gradient_axis,
gradient_stops, indent, literal, named, native_gradient_kind, num, radius, stroke_color, visible_fills,
};
use figcraft_doc::Document;
@@ -22,14 +23,66 @@ fn bound_dp(doc: &Document, n: &Node, fields: &[&str], value: f64) -> String {
bound(doc, n, fields, format!("{}.dp", num(value)))
}
/// One end of a linear brush's axis along one direction: `sign` is 0 for none, 1 for with the
/// axis and -1 against it. `Float.POSITIVE_INFINITY` stands for the far edge, which a modifier
/// can't know in pixels.
fn compose_edge(sign: i8) -> (&'static str, &'static str) {
match sign {
0 => ("0f", "0f"),
s if s > 0 => ("0f", "Float.POSITIVE_INFINITY"),
_ => ("Float.POSITIVE_INFINITY", "0f"),
}
}
/// A direction's sign: its component counts as none when it is at most tan 22.5 degrees of the other.
fn compose_sign(v: f64, other: f64) -> i8 {
if v.abs() <= 0.4142 * other.abs() {
0
} else if v > 0.0 {
1
} else {
-1
}
}
/// A gradient fill as a Compose `Brush` for a `w` x `h` layer. A linear gradient snaps to the
/// nearest of eight directions; radial and angular ones sit at the box's centre.
fn compose_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
let stops = gradient_stops(g, opacity).into_iter().map(|(at, c)| format!("{}f to {}", num(at), kotlin_color(c))).collect::<Vec<_>>().join(", ");
match native_gradient_kind(g) {
GradientKind::Linear => {
let (dx, dy) = gradient_axis(g, w, h);
let (sx, sy) = if dx == 0.0 && dy == 0.0 { (0, 1) } else { (compose_sign(dx, dy), compose_sign(dy, dx)) };
let ((x0, x1), (y0, y1)) = (compose_edge(sx), compose_edge(sy));
format!("Brush.linearGradient({stops}, start = Offset({x0}, {y0}), end = Offset({x1}, {y1}))")
}
GradientKind::Angular => format!("Brush.sweepGradient({stops})"),
_ => format!("Brush.radialGradient({stops})"),
}
}
/// One paint (the one at document index `i`) as the first argument of `background`; `None` for an
/// image or pattern. Only a solid at index 0 takes the `fill` variable's name.
fn compose_paint(doc: &Document, n: &Node, i: usize, p: &Paint) -> Option<String> {
match &p.kind {
PaintKind::Solid { .. } => {
let c = kotlin_color(p.solid_color()?);
Some(if i == 0 { bound(doc, n, &["fill"], c) } else { c })
}
PaintKind::Gradient(g) => Some(compose_gradient(g, n.width, n.height, p.opacity)),
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
}
}
/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
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})", bound(doc, n, &["fill"], kotlin_color(c))));
// Each background draws and lets the rest of the chain draw over it: bottom fill first.
for b in visible_fills(n).filter_map(|(i, p)| compose_paint(doc, n, i, p)) {
m.push_str(&format!(".background({b}, {shape})"));
}
if let Some(c) = stroke_color(n) {
m.push_str(&format!(
+103 -17
View File
@@ -2,8 +2,9 @@ use figcraft_doc::AutoLayout;
use super::swift::one_line;
use super::{
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
CodeLang, CrossAlign, Ctx, GradientKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Paint, PaintKind, Param,
Sizing, TextAlignH, TextDecoration, drop_shadow, first_fill, gradient_axis, gradient_stops, kotlin_color, literal, native_gradient_kind, num,
radius, stroke_color, visible_fills,
};
// ------------------------------------------------------------------ Flutter (Dart)
@@ -74,22 +75,72 @@ fn flutter_padding(n: &Node) -> Option<String> {
})
}
/// A gradient's colours and stops, the tail of every Flutter gradient constructor.
fn flutter_colors(g: &figcraft_color::Gradient, opacity: f32) -> String {
let stops = gradient_stops(g, opacity);
let colors = stops.iter().map(|(_, c)| kotlin_color(*c)).collect::<Vec<_>>().join(", ");
let at = stops.iter().map(|(at, _)| num(*at)).collect::<Vec<_>>().join(", ");
format!("colors: [{colors}], stops: [{at}]")
}
/// A point of the unit box as Flutter's `Alignment`, which runs from -1 to 1.
fn flutter_alignment(p: (f64, f64)) -> String {
format!("Alignment({}, {})", num(2.0 * p.0 - 1.0), num(2.0 * p.1 - 1.0))
}
/// A gradient fill on a layer of `w` x `h` as a Flutter gradient.
fn flutter_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
let (dx, dy) = gradient_axis(g, w, h);
let (tail, from) = (flutter_colors(g, opacity), flutter_alignment(g.from));
match native_gradient_kind(g) {
GradientKind::Linear => format!("LinearGradient(begin: {from}, end: {}, {tail})", flutter_alignment(g.to)),
GradientKind::Angular => format!("SweepGradient(center: {from}, {tail}, transform: GradientRotation({}))", num(dy.atan2(dx))),
_ => {
// Flutter's radius is a fraction of the box's shorter side.
let (reach, side) = (dx.hypot(dy), w.min(h));
let radius = if reach > 0.0 && side > 0.0 { reach / side } else { 0.5 };
format!("RadialGradient(center: {from}, radius: {}, {tail})", num(radius))
}
}
}
/// One paint as a `BoxDecoration` part (`color: ...` or `gradient: ...`); `None` for an image or
/// pattern, which no native target writes yet.
fn flutter_paint(n: &Node, p: &Paint) -> Option<String> {
match &p.kind {
PaintKind::Solid { .. } => Some(format!("color: {}", kotlin_color(p.solid_color()?))),
PaintKind::Gradient(g) => Some(format!("gradient: {}", flutter_gradient(g, n.width, n.height, p.opacity))),
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
}
}
/// Whether a layer is written as a `Container`: the layers with a box to paint.
fn paints_a_box(n: &Node) -> bool {
matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Rectangle | NodeKind::Group) || n.kind.is_frame_like()
}
/// A layer's fills as `BoxDecoration` parts, bottom first.
fn flutter_fill_parts(n: &Node) -> Vec<String> {
visible_fills(n).filter_map(|(_, p)| flutter_paint(n, p)).collect()
}
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
/// none of them. `pad` is the indentation of the constructor itself.
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
/// none of them. `pad` is the indentation of the constructor itself; `fill` is the one fill part
/// this decoration paints, and `stroke` and `shadow` say whether it carries those.
fn box_decoration(n: &Node, pad: &str, fill: Option<&str>, stroke: bool, shadow: bool) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = first_fill(n) {
parts.push(format!("color: {}", kotlin_color(c)));
if let Some(f) = fill {
parts.push(f.to_string());
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
parts.push("shape: BoxShape.circle".into());
} else if radius(n) > 0.0 {
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
}
if let Some(c) = stroke_color(n) {
if let Some(c) = stroke_color(n).filter(|_| stroke) {
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
}
if let Some(s) = drop_shadow(n) {
if let Some(s) = drop_shadow(n).filter(|_| shadow) {
parts.push(format!(
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
kotlin_color(s.color),
@@ -105,6 +156,42 @@ fn box_decoration(n: &Node, pad: &str) -> Option<String> {
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
}
/// What a `Container` paints itself: its own fill (the top one), and whether it carries the shadow
/// (only when no `DecoratedBox` does).
struct Painted {
fill: Option<String>,
shadow: bool,
}
/// Opens a `DecoratedBox` for each fill under the top one, the bottom fill outermost, and returns
/// what is left for the `Container`. The outermost box carries the drop shadow: a decoration paints
/// its shadow first, so on the `Container` it would lie over the lower fills.
fn flutter_fill_boxes(n: &Node, depth: &mut usize, close: &mut Vec<String>, out: &mut String) -> Painted {
let mut parts = flutter_fill_parts(n);
let top = parts.pop();
let shadow = parts.is_empty();
for (at, part) in parts.iter().enumerate() {
let (pad, inner) = (flutter_indent(*depth), flutter_indent(*depth + 1));
if close.is_empty() {
out.push_str(&pad);
}
let deco = box_decoration(n, &inner, Some(part), false, at == 0).unwrap_or_default();
out.push_str(&format!("DecoratedBox(\n{inner}decoration: {deco},\n{inner}child: "));
close.push(format!("\n{pad})"));
*depth += 1;
}
Painted { fill: top, shadow }
}
/// The call an instance below the root is (an instance swap parameter is a widget); `None` for
/// every other layer.
fn flutter_call(ctx: &Ctx, n: &Node, depth: usize) -> Option<String> {
match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(p.ident.clone()),
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
}
}
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
@@ -150,9 +237,13 @@ fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Pl
close.push(format!("\n{pad})"));
depth += 1;
}
// An instance is a call, made once: it gets no fill boxes, so this is its final depth.
let call = flutter_call(ctx, n, depth);
let painted =
if call.is_none() && paints_a_box(n) { flutter_fill_boxes(n, &mut depth, &mut close, out) } else { Painted { fill: None, shadow: true } };
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
let mut body = String::new();
flutter_widget(ctx, n, depth, &mut body);
flutter_widget(ctx, n, depth, call, &painted, &mut body);
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
for c in close.iter().rev() {
out.push_str(c);
@@ -161,13 +252,8 @@ fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Pl
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
/// closing parenthesis sits at `depth`.
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, call: Option<String>, painted: &Painted, out: &mut String) {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Nested instances are calls; an instance swap parameter is a widget.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(p.ident.clone()),
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(call.trim_end_matches('\n'));
return;
@@ -216,7 +302,7 @@ fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
}
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if let Some(d) = box_decoration(n, &inner) {
if let Some(d) = box_decoration(n, &inner, painted.fill.as_deref(), true, painted.shadow) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
out.push_str(&format!("{pad})"));
@@ -232,7 +318,7 @@ fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
if !padded_inside && let Some(p) = flutter_padding(n) {
out.push_str(&format!("{inner}padding: {p},\n"));
}
if let Some(d) = box_decoration(n, &inner) {
if let Some(d) = box_decoration(n, &inner, painted.fill.as_deref(), true, painted.shadow) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
if children.is_empty() && slot.is_none() {
+47 -10
View File
@@ -1,5 +1,6 @@
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
CodeLang, Color, Ctx, Document, GradientKind, LayoutMode, Node, NodeId, NodeKind, PADDING, Paint, PaintKind, drop_shadow, first_fill,
gradient_axis, gradient_stops, indent, literal, named, native_gradient_kind, num, radius, stroke_color, visible_fills,
};
// ------------------------------------------------------------------ SwiftUI
@@ -8,6 +9,47 @@ 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_point(p: (f64, f64)) -> String {
format!("UnitPoint(x: {}, y: {})", num(p.0), num(p.1))
}
/// A gradient fill on a layer of `w` x `h` as a SwiftUI gradient.
fn swift_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
let stops = gradient_stops(g, opacity)
.into_iter()
.map(|(at, c)| format!("Gradient.Stop(color: {}, location: {})", swift_color(c), num(at)))
.collect::<Vec<_>>()
.join(", ");
let (dx, dy) = gradient_axis(g, w, h);
match native_gradient_kind(g) {
GradientKind::Linear => format!("LinearGradient(stops: [{stops}], startPoint: {}, endPoint: {})", swift_point(g.from), swift_point(g.to)),
GradientKind::Angular => {
format!("AngularGradient(stops: [{stops}], center: {}, angle: .degrees({}))", swift_point(g.from), num(dy.atan2(dx).to_degrees()))
}
_ => format!("RadialGradient(stops: [{stops}], center: {}, startRadius: 0, endRadius: {})", swift_point(g.from), num(dx.hypot(dy))),
}
}
/// One paint (the one at document index `i`) as SwiftUI; `None` for an image or pattern, which no
/// native target writes yet. Only a solid at index 0 takes the `fill` variable's name.
fn swift_paint(doc: &Document, n: &Node, i: usize, p: &Paint) -> Option<String> {
match &p.kind {
PaintKind::Solid { .. } => {
let c = swift_color(p.solid_color()?);
Some(if i == 0 { named(doc, n, "iOS", &["fill"], c) } else { c })
}
PaintKind::Gradient(g) => Some(swift_gradient(g, n.width, n.height, p.opacity)),
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
}
}
/// The modifiers filling a rectangle or ellipse: the bottom paint fills the `shape`, and each paint
/// above it is a copy of the shape laid over it.
fn swift_shape_fills(doc: &Document, n: &Node, shape: &str) -> Vec<String> {
let paints = visible_fills(n).filter_map(|(i, p)| swift_paint(doc, n, i, p));
paints.enumerate().map(|(at, c)| if at == 0 { format!(".fill({c})") } else { format!(".overlay({shape}.fill({c}))") }).collect()
}
fn swift_weight(w: u16) -> &'static str {
match w {
0..=150 => ".thin",
@@ -84,16 +126,12 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
}
NodeKind::Ellipse { .. } => {
out.push_str(&format!("{pad}Ellipse()\n"));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.extend(swift_shape_fills(doc, n, "Ellipse()"));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
NodeKind::Rectangle => {
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {corner})\n"));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.extend(swift_shape_fills(doc, n, &format!("RoundedRectangle(cornerRadius: {corner})")));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
@@ -142,9 +180,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
}
}
mods.push(format!(".frame(width: {w}, height: {h}, alignment: .topLeading)"));
if let Some(c) = &fill {
mods.push(format!(".background({c})"));
}
// Each background goes behind the ones before it, so the top fill is written first.
mods.extend(visible_fills(n).rev().filter_map(|(i, p)| swift_paint(doc, n, i, p)).map(|c| format!(".background({c})")));
if radius(n) > 0.0 {
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {corner}))"));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** patch (SwiftUI, Compose and Flutter code write every visible colour and gradient fill of a frame, group, rectangle or ellipse in paint order, gradients as gradients, instead of one colour per layer (chained `.background` in SwiftUI, chained `Modifier.background` in Compose, a `DecoratedBox` for each fill under the top one in Flutter); image and pattern fills, ArkUI and text still take one colour (#2271); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -10,10 +10,11 @@ repository, third-party integrations. Remaining: 1–2 h.
| Feature | Status | Notes |
|---|---|---|
| 2026-11-12 | patch | SwiftUI, Compose and Flutter code export kept one colour per layer (the bottom visible solid, or a gradient's first stop) and dropped every other fill (#2271). Now each writes every visible solid and gradient fill of a frame, group, rectangle or ellipse, in paint order, and a gradient as a gradient. SwiftUI chains `.background(...)` with the top fill first, since each one goes behind the ones before it (a rectangle or ellipse fills with the bottom paint and lays a copy of the shape over it for each paint above). Compose chains `Modifier.background(...)` with the bottom fill first, since each one draws over the ones before it (a linear gradient snaps to eight directions, radial and angular ones sit at the box's centre). Flutter keeps the top fill in the `Container` and wraps each fill under it in a `DecoratedBox`, the bottom fill outermost and the drop shadow on the outermost box. A layer with one solid fill gives the same code as before; a lone gradient is now a gradient, not its first stop's colour. Still not written: image and pattern fills in the native targets, ArkUI (one background and one gradient per component, so a stack needs extra nodes) and text with several fills. The incumbent's iOS and Android output wasn't measured: the order follows how each platform paints. Percentages and hours unchanged |
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235); SwiftUI, Compose and Flutter write every colour and gradient fill of a layer in paint order, gradients as gradients (chained `.background` in SwiftUI, top fill first; chained `Modifier.background` in Compose, bottom fill first, a linear gradient snapped to eight directions and radial and angular ones at the box's centre; in Flutter a `DecoratedBox` for each fill under the top one); the native targets don't write image or pattern fills yet, and ArkUI and text still take one colour (#2271) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |