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:
parent
7c08e1b45b
commit
819fb36ce7
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Reference in a new issue