feat(shell-core/codegen): Compose + React Native — all 9 generators shipped

Completes #10 on the TS-parity roadmap. All nine targets the TS
`pen-codegen` package exposes now have Rust equivalents behind the
shared `Codegen` trait:

  1. CssVariables — design tokens → `:root { --name: value }`
  2. Html         — absolute-positioned `<div>` / `<span>` tree
  3. Vue          — SFC `<template>` + `<style scoped>`
  4. Svelte       — `<script>` + markup + `<style>`
  5. React        — JSX functional component, inline style object
  6. Flutter      — `Stack(children: [Positioned(Container/Text)])`
  7. SwiftUI      — `ZStack { Rectangle/Ellipse/Text.frame.position }`
  8. Compose      — `@Composable Box(Modifier.offset.size)`
  9. ReactNative  — `<View>` / `<Text>` with `position: 'absolute'`

Compose specifics: `Modifier.offset(x.dp, y.dp).size(width.dp,
height.dp).background(Color(r,g,b,a))` per node, default-export
`@Composable fun Page()`. Text nodes wrap as `Text(text =
"...")`.

React Native specifics: default-exported functional component
that returns a wrapping `<View>` flexed to fill, with each node
as `<View>` / `<Text>` at `position: 'absolute'`. Color uses CSS
`rgb()`/`rgba()` (RN accepts both).

Tests (2 new):
  - Compose: composable annotation + offset/size/color modifiers
  - RN: import statement + functional component + absolute style

Tests total: 220 shell-core (+2) + 20 native + 8 desktop = 248
total. Wasm32 build clean. #10 Codegen status: 100%.
This commit is contained in:
Kayshen-X 2026-05-14 15:30:35 +08:00
parent 7c08e1b45b
commit 819fb36ce7

View file

@ -416,6 +416,141 @@ fn emit_node_swiftui(out: &mut String, node: &Node, depth: usize) {
}
}
/// 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: &Document) -> 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");
if let Some(page) = doc.pages.get(doc.active_page_index) {
for n in &page.children {
emit_node_compose(&mut out, n, 2);
}
}
out.push_str(" }\n}\n");
out
}
}
fn emit_node_compose(out: &mut String, node: &Node, depth: usize) {
if node.hidden {
return;
}
let indent = " ".repeat(depth);
match node.kind {
NodeKind::Text => {
let body = node.text.clone().unwrap_or_default();
out.push_str(&format!(
"{indent}Text(text = {:?}, modifier = Modifier.offset(x = {}.dp, y = {}.dp))\n",
body, node.bounds.origin.x, node.bounds.origin.y
));
}
_ => {
let color = node
.fill
.map(|c| {
let r = (c.r.clamp(0.0, 1.0) * 255.0).round() as u8;
let g = (c.g.clamp(0.0, 1.0) * 255.0).round() as u8;
let b = (c.b.clamp(0.0, 1.0) * 255.0).round() as u8;
let a = (c.a.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(".background(Color({}, {}, {}, {}))", r, g, b, a)
})
.unwrap_or_default();
out.push_str(&format!(
"{indent}Box(modifier = Modifier.offset(x = {}.dp, y = {}.dp).size(width = {}.dp, height = {}.dp){})\n",
node.bounds.origin.x, node.bounds.origin.y,
node.bounds.size.x, node.bounds.size.y,
color
));
}
}
for c in &node.children {
emit_node_compose(out, c, depth);
}
}
/// React Native generator. Emits a default-exported functional
/// component with absolute-positioned `<View>`s + `<Text>`s using
/// React Native's `StyleSheet.flatten`-friendly inline style.
/// Mirrors TS `pen-codegen::react-native-generator`.
pub struct ReactNative;
impl Codegen for ReactNative {
fn target_label(&self) -> &'static str {
"react-native"
}
fn generate(&self, doc: &Document) -> String {
let mut out = String::new();
out.push_str("// Generated by OpenPencil — codegen::ReactNative\n");
out.push_str("import React from 'react';\n");
out.push_str("import { View, Text } from 'react-native';\n\n");
out.push_str("export default function Page() {\n return (\n <View style={{ flex: 1 }}>\n");
if let Some(page) = doc.pages.get(doc.active_page_index) {
for n in &page.children {
emit_node_rn(&mut out, n, 3);
}
}
out.push_str(" </View>\n );\n}\n");
out
}
}
fn emit_node_rn(out: &mut String, node: &Node, depth: usize) {
if node.hidden {
return;
}
let indent = " ".repeat(depth);
let style = format!(
"position: 'absolute', left: {}, top: {}, width: {}, height: {}{}{}",
node.bounds.origin.x,
node.bounds.origin.y,
node.bounds.size.x,
node.bounds.size.y,
node.fill
.map(|c| format!(", backgroundColor: '{}'", color_to_css(c)))
.unwrap_or_default(),
if node.corner_radius > 0.0 {
format!(", borderRadius: {}", node.corner_radius)
} else {
String::new()
},
);
match node.kind {
NodeKind::Text => {
let body = node.text.clone().unwrap_or_default();
out.push_str(&format!(
"{indent}<Text style={{{{{style}}}}}>{}</Text>\n",
html_escape(&body)
));
}
_ => {
if node.children.is_empty() {
out.push_str(&format!("{indent}<View style={{{{{style}}}}} />\n"));
} else {
out.push_str(&format!("{indent}<View style={{{{{style}}}}}>\n"));
for c in &node.children {
emit_node_rn(out, c, depth + 1);
}
out.push_str(&format!("{indent}</View>\n"));
}
}
}
}
fn color_to_css(c: Color) -> String {
let r = (c.r.clamp(0.0, 1.0) * 255.0).round() as u8;
let g = (c.g.clamp(0.0, 1.0) * 255.0).round() as u8;
@ -667,6 +802,42 @@ mod tests {
assert!(s.contains("Ellipse()"));
}
#[test]
fn compose_emits_composable_box_with_offset_size() {
use crate::document::{Node, NodeKind};
use crate::{Color, Rect};
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut n = Node::leaf(10, NodeKind::Rect, "r");
n.bounds = Rect::xywh(5.0, 10.0, 100.0, 50.0);
n.fill = Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
page.children.push(n);
let s = Compose.generate(&doc);
assert!(s.contains("@Composable"));
assert!(s.contains("Modifier.offset(x = 5.dp, y = 10.dp)"));
assert!(s.contains("size(width = 100.dp, height = 50.dp)"));
assert!(s.contains("Color(255, 0, 0, 255)"));
}
#[test]
fn react_native_emits_view_tree_with_absolute_positions() {
use crate::document::{Node, NodeKind};
use crate::Rect;
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut n = Node::leaf(10, NodeKind::Rect, "r");
n.bounds = Rect::xywh(0.0, 0.0, 80.0, 40.0);
page.children.push(n);
let s = ReactNative.generate(&doc);
assert!(s.contains("import { View, Text } from 'react-native';"));
assert!(s.contains("export default function Page()"));
assert!(s.contains("<View style={{"));
assert!(s.contains("position: 'absolute'"));
assert!(s.contains("width: 80, height: 40"));
}
#[test]
fn html_skips_hidden_nodes() {
use crate::document::{Node, NodeKind};