diff --git a/crates/openpencil-shell-core/src/codegen.rs b/crates/openpencil-shell-core/src/codegen.rs index 377b2e7d4..c3477046f 100644 --- a/crates/openpencil-shell-core/src/codegen.rs +++ b/crates/openpencil-shell-core/src/codegen.rs @@ -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 ``s + ``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 \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(" \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}{}\n", + html_escape(&body) + )); + } + _ => { + if node.children.is_empty() { + out.push_str(&format!("{indent}\n")); + } else { + out.push_str(&format!("{indent}\n")); + for c in &node.children { + emit_node_rn(out, c, depth + 1); + } + out.push_str(&format!("{indent}\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("