`s + text spans. Same per-node mapping as the
/// HTML target; the wrapper is JSX. Mirrors TS
/// `pen-codegen::react-generator` minus Tailwind class generation.
pub struct React;
impl Codegen for React {
fn target_label(&self) -> &'static str {
"react"
}
fn generate(&self, doc: &PenDocument) -> String {
let mut out = String::new();
out.push_str("// Generated by OpenPencil — codegen::React\n");
out.push_str("import React from 'react';\n\n");
out.push_str("export default function Page() {\n");
out.push_str(" return (\n <>\n");
for n in root_nodes(doc) {
emit_node_jsx(&mut out, n, 3);
}
out.push_str(" >\n );\n}\n");
out
}
}
fn emit_node_jsx(out: &mut String, node: &PenNode, depth: usize) {
if node_hidden(node) {
return;
}
let indent = " ".repeat(depth);
let tag = if node_is_text(node) { "span" } else { "div" };
let (x, y) = node_origin(node);
let (w, h) = node_size(node);
let radius = node_corner_radius(node);
// JSX style is an object literal — semicolons → commas, camelCase keys.
let style = format!(
"position: 'absolute', left: {}, top: {}, width: {}, height: {}{}{}{}{}",
fmt_num(x),
fmt_num(y),
fmt_num(w),
fmt_num(h),
node_fill_css(node)
.map(|c| format!(", background: '{}'", color_to_css(&c)))
.unwrap_or_default(),
node_stroke_css(node)
.map(|(c, sw)| format!(", border: '{}px solid {}'", sw, color_to_css(&c)))
.unwrap_or_default(),
if radius > 0.0 {
format!(", borderRadius: {}", fmt_num(radius))
} else {
String::new()
},
if node_is_ellipse(node) {
", borderRadius: '50%'".to_string()
} else {
String::new()
},
);
// First-class widgets win over the generic div/span heuristic. The
// shared form-element markup is JSX-valid (self-closed voids, bare
// boolean attributes), so it nests directly inside the positioned
// wrapper. `tabs` also recurses its panel children.
if let Some(widget) = widget_markup(node) {
out.push_str(&format!(
"{indent}
{widget}
\n"
));
return;
}
if let Some(nav) = tabs_nav_markup(node) {
out.push_str(&format!("{indent}
{nav}"));
let children = node_children(node);
if children.is_empty() {
out.push_str("
\n");
} else {
out.push('\n');
for c in children {
emit_node_jsx(out, c, depth + 1);
}
out.push_str(&format!("{indent}
\n"));
}
return;
}
let body = node_text(node).unwrap_or_default();
let children = node_children(node);
if children.is_empty() && body.is_empty() {
out.push_str(&format!("{indent}<{tag} style={{{{{style}}}}} />\n"));
} else {
out.push_str(&format!("{indent}<{tag} style={{{{{style}}}}}>"));
if !body.is_empty() {
out.push_str(&html_escape(&body));
}
if !children.is_empty() {
out.push('\n');
for c in children {
emit_node_jsx(out, c, depth + 1);
}
out.push_str(&indent);
}
out.push_str(&format!("{tag}>\n"));
}
}
/// Flutter / Dart generator. Emits a `Stack` whose children are
/// `Positioned`-wrapped `Container`s sized + coloured per node.
/// Text uses `Text(...)` with a `TextStyle`. Mirrors TS
/// `pen-codegen::flutter-generator`.
pub struct Flutter;
impl Codegen for Flutter {
fn target_label(&self) -> &'static str {
"flutter"
}
fn generate(&self, doc: &PenDocument) -> String {
let mut out = String::new();
out.push_str("// Generated by OpenPencil — codegen::Flutter\n");
out.push_str("import 'package:flutter/material.dart';\n\n");
out.push_str("Widget buildPage() {\n return Stack(children: [\n");
for n in root_nodes(doc) {
emit_node_flutter(&mut out, n, 2);
}
out.push_str(" ]);\n}\n");
out
}
}
/// Flatten a colour string into `Color.fromARGB(a,r,g,b)`. Falls
/// back to `null` when the colour is not a parseable hex / rgba.
fn flutter_color(color: &str) -> String {
match parse_color(color) {
Some((r, g, b, a)) => {
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(
"Color.fromARGB({},{},{},{})",
to_u8(a),
to_u8(r),
to_u8(g),
to_u8(b)
)
}
None => "null".into(),
}
}
fn emit_node_flutter(out: &mut String, node: &PenNode, depth: usize) {
if node_hidden(node) {
return;
}
let indent = " ".repeat(depth);
let (x, y) = node_origin(node);
let (w, h) = node_size(node);
let inner = if node_is_text(node) {
let body = node_text(node).unwrap_or_default();
format!("Text({:?})", body)
} else {
let color = node_fill_css(node)
.map(|c| flutter_color(&c))
.unwrap_or_else(|| "null".into());
format!(
"Container(width: {}, height: {}, decoration: BoxDecoration(color: {}))",
fmt_num(w),
fmt_num(h),
color
)
};
out.push_str(&format!(
"{indent}Positioned(left: {}, top: {}, child: {}),\n",
fmt_num(x),
fmt_num(y),
inner
));
for c in node_children(node) {
emit_node_flutter(out, c, depth);
}
}
/// SwiftUI generator. Emits a `ZStack` of `Rectangle`/`Ellipse`/
/// `Text` views with `.frame` + `.position` modifiers. Mirrors TS
/// `pen-codegen::swiftui-generator`.
pub struct SwiftUi;
impl Codegen for SwiftUi {
fn target_label(&self) -> &'static str {
"swiftui"
}
fn generate(&self, doc: &PenDocument) -> String {
let mut out = String::new();
out.push_str("// Generated by OpenPencil — codegen::SwiftUi\n");
out.push_str("import SwiftUI\n\nstruct Page: View {\n var body: some View {\n");
out.push_str(" ZStack {\n");
for n in root_nodes(doc) {
emit_node_swiftui(&mut out, n, 3);
}
out.push_str(" }\n }\n}\n");
out
}
}
fn emit_node_swiftui(out: &mut String, node: &PenNode, depth: usize) {
if node_hidden(node) {
return;
}
let indent = " ".repeat(depth);
let view = if node_is_text(node) {
let body = node_text(node).unwrap_or_default();
format!("Text({:?})", body)
} else if node_is_ellipse(node) {
"Ellipse()".to_string()
} else {
"Rectangle()".to_string()
};
let color = node_fill_css(node)
.and_then(|c| parse_color(&c))
.map(|(r, g, b, a)| {
format!(
".foregroundColor(Color(red: {:.3}, green: {:.3}, blue: {:.3}, opacity: {:.3}))",
r, g, b, a
)
})
.unwrap_or_default();
let (x, y) = node_origin(node);
let (w, h) = node_size(node);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
out.push_str(&format!(
"{indent}{view}{color}.frame(width: {}, height: {}).position(x: {}, y: {})\n",
fmt_num(w),
fmt_num(h),
fmt_num(cx),
fmt_num(cy)
));
for c in node_children(node) {
emit_node_swiftui(out, c, depth);
}
}
/// Android Jetpack Compose generator. Emits a `Box` with
/// `Modifier.offset`/`size` per node + a single fill color.
/// Mirrors TS `pen-codegen::compose-generator`.
pub struct Compose;
impl Codegen for Compose {
fn target_label(&self) -> &'static str {
"compose"
}
fn generate(&self, doc: &PenDocument) -> String {
let mut out = String::new();
out.push_str("// Generated by OpenPencil — codegen::Compose\n");
out.push_str("import androidx.compose.foundation.*\n");
out.push_str("import androidx.compose.foundation.layout.*\n");
out.push_str("import androidx.compose.material3.Text\n");
out.push_str("import androidx.compose.runtime.Composable\n");
out.push_str("import androidx.compose.ui.Modifier\n");
out.push_str("import androidx.compose.ui.graphics.Color\n");
out.push_str("import androidx.compose.ui.unit.dp\n\n");
out.push_str("@Composable\nfun Page() {\n Box(modifier = Modifier.fillMaxSize()) {\n");
for n in root_nodes(doc) {
emit_node_compose(&mut out, n, 2);
}
out.push_str(" }\n}\n");
out
}
}
fn emit_node_compose(out: &mut String, node: &PenNode, depth: usize) {
if node_hidden(node) {
return;
}
let indent = " ".repeat(depth);
let (x, y) = node_origin(node);
let (w, h) = node_size(node);
if node_is_text(node) {
let body = node_text(node).unwrap_or_default();
out.push_str(&format!(
"{indent}Text(text = {:?}, modifier = Modifier.offset(x = {}.dp, y = {}.dp))\n",
body,
fmt_num(x),
fmt_num(y)
));
} else {
let color = node_fill_css(node)
.and_then(|c| parse_color(&c))
.map(|(r, g, b, a)| {
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(
".background(Color({}, {}, {}, {}))",
to_u8(r),
to_u8(g),
to_u8(b),
to_u8(a)
)
})
.unwrap_or_default();
out.push_str(&format!(
"{indent}Box(modifier = Modifier.offset(x = {}.dp, y = {}.dp).size(width = {}.dp, height = {}.dp){})\n",
fmt_num(x),
fmt_num(y),
fmt_num(w),
fmt_num(h),
color
));
}
for c in node_children(node) {
emit_node_compose(out, c, depth);
}
}
/// React Native generator. Emits a default-exported functional
/// component with absolute-positioned `