diff --git a/crates/openpencil-shell-core/src/codegen.rs b/crates/openpencil-shell-core/src/codegen.rs index d10543e31..1a0aa7f1e 100644 --- a/crates/openpencil-shell-core/src/codegen.rs +++ b/crates/openpencil-shell-core/src/codegen.rs @@ -1,22 +1,36 @@ //! Code generation — Rust ports of `packages/pen-codegen/src/`. //! v1 ships the simplest of the 9 TS generators: `css-variables`, -//! which emits the design's `Document.var_table` as CSS custom -//! properties. Future generators (React + Tailwind, HTML, Vue, -//! Svelte, Flutter, SwiftUI, Compose, React Native) live behind -//! the same `Codegen` trait so a single dispatcher (eventually a -//! `op codegen ` CLI) can fan out by target enum. +//! which emits the design's variable table as CSS custom properties. +//! Future generators (React + Tailwind, HTML, Vue, Svelte, Flutter, +//! SwiftUI, Compose, React Native) live behind the same `Codegen` +//! trait so a single dispatcher (eventually a `op codegen ` +//! CLI) can fan out by target enum. +//! +//! All generators consume the canonical `jian_ops_schema::PenDocument` +//! and walk `PenNode` via `op_editor_core::PenNodeExt`. The spine here +//! holds the `Codegen` trait, the `CssVariables` generator, shared +//! node-accessor helpers, and the test suite; per-framework markup +//! generators live in the sibling `codegen_targets` module. -use crate::document::{Document, Node, NodeKind, VariableScalar, VariableValue}; -use crate::Color; +pub use crate::codegen_targets::{ + Compose, Flutter, Html, React, ReactNative, Svelte, SwiftUi, Vue, +}; -/// One generator. Implementations are pure (read `Document`, return -/// `String`); the CLI / panel layer owns file-write side effects. +use jian_ops_schema::node::PenNode; +use jian_ops_schema::style::PenFill; +use jian_ops_schema::variable::{VariableScalar, VariableValue}; +use jian_ops_schema::PenDocument; +use op_editor_core::pen_node_ext::PenNodeExt; + +/// One generator. Implementations are pure (read `PenDocument`, +/// return `String`); the CLI / panel layer owns file-write side +/// effects. pub trait Codegen { fn target_label(&self) -> &'static str; - fn generate(&self, doc: &Document) -> String; + fn generate(&self, doc: &PenDocument) -> String; } -/// CSS custom properties generator. Walks `doc.var_table` and emits +/// CSS custom properties generator. Walks `doc.variables` and emits /// `:root { --name: value; }`. Color variables stay as their hex /// string; number/bool/string variables stringify their scalar. /// Themed values emit ONE entry per theme combination using a @@ -28,14 +42,17 @@ impl Codegen for CssVariables { fn target_label(&self) -> &'static str { "css-variables" } - fn generate(&self, doc: &Document) -> String { + fn generate(&self, doc: &PenDocument) -> String { let mut out = String::new(); out.push_str("/* Generated by OpenPencil — codegen::CssVariables */\n"); + let Some(variables) = doc.variables.as_ref() else { + return out; + }; // Pass 1: scalar (theme-agnostic) variables go under :root. let mut root_scalars: Vec<(String, String)> = Vec::new(); - for v in &doc.var_table.variables { - if let VariableValue::Scalar(s) = &v.value { - root_scalars.push((v.name.clone(), scalar_to_css(s))); + for (name, def) in variables { + if let VariableValue::Scalar(s) = &def.value { + root_scalars.push((name.clone(), scalar_to_css(s))); } } if !root_scalars.is_empty() { @@ -50,8 +67,8 @@ impl Codegen for CssVariables { // entry from variable A and another from B share one block. use std::collections::BTreeMap; let mut themed: BTreeMap, Vec<(String, String)>> = BTreeMap::new(); - for v in &doc.var_table.variables { - if let VariableValue::Themed(entries) = &v.value { + for (name, def) in variables { + if let VariableValue::Themed(entries) = &def.value { for e in entries { let key: Vec<(String, String)> = e .theme @@ -61,7 +78,7 @@ impl Codegen for CssVariables { themed .entry(key) .or_default() - .push((v.name.clone(), scalar_to_css(&e.value))); + .push((name.clone(), scalar_to_css(&e.value))); } } } @@ -85,484 +102,244 @@ impl Codegen for CssVariables { } } -/// HTML + inline-CSS generator. Walks the active page's node tree -/// and emits `
` per Rect/Frame/Group with absolute positioning -/// and inline style; ellipses get `border-radius: 50%`; text nodes -/// emit ``. Bare minimum useful — refining to semantic tags -/// + flex containers is a follow-up. -pub struct Html; +// --- Shared node-accessor helpers (canonical-schema flavour) ------- +// +// shell-core's flat `Node` exposed `bounds` / `fill` / `stroke` / +// `corner_radius` / `text` / `hidden` directly. The canonical +// `PenNode` is a 12-variant enum: these helpers give the markup +// generators the same flat view through `PenNodeExt`. -impl Codegen for Html { - fn target_label(&self) -> &'static str { - "html" - } - fn generate(&self, doc: &Document) -> String { - let mut out = String::new(); - out.push_str("\n"); - out.push_str("\n\n\n"); - if let Some(page) = doc.pages.get(doc.active_page_index) { - for node in &page.children { - emit_node_html(&mut out, node, 1); - } - } - out.push_str("\n"); - out +/// Top-level node forest the generators walk: the first page's +/// children when the document is paged, otherwise the bare +/// `children` fallback. +pub(crate) fn root_nodes(doc: &PenDocument) -> &[PenNode] { + match doc.pages.as_ref().and_then(|p| p.first()) { + Some(page) => &page.children, + None => &doc.children, } } -fn emit_node_html(out: &mut String, node: &Node, depth: usize) { - if node.hidden { - return; +/// True when the node is explicitly hidden (`visible == false`). +/// A missing `visible` flag means shown. +pub(crate) fn node_hidden(node: &PenNode) -> bool { + node.base().visible == Some(false) +} + +/// Document-space origin of the node (`base.x` / `base.y`, 0 default). +pub(crate) fn node_origin(node: &PenNode) -> (f64, f64) { + let base = node.base(); + (base.x.unwrap_or(0.0), base.y.unwrap_or(0.0)) +} + +/// Literal pixel size of the node. Keyword / expression sizing is not +/// a concrete number, so it reads as 0 — same degrade the TS +/// generators apply for `fit_content` / `$var` widths. +pub(crate) fn node_size(node: &PenNode) -> (f64, f64) { + match node { + PenNode::Line(n) => { + // A line has no width/height field — derive its box from + // the (x,y)→(x2,y2) span so absolute layout still works. + let base = node.base(); + let x1 = base.x.unwrap_or(0.0); + let y1 = base.y.unwrap_or(0.0); + let x2 = n.x2.unwrap_or(x1); + let y2 = n.y2.unwrap_or(y1); + ((x2 - x1).abs(), (y2 - y1).abs()) + } + _ => ( + node.width_px().unwrap_or(0.0), + node.height_px().unwrap_or(0.0), + ), } - let indent = " ".repeat(depth); - let tag = match node.kind { - NodeKind::Text => "span", - _ => "div", +} + +/// Node rotation in radians (`base.rotation`, 0 default). The +/// canonical schema stores rotation in degrees; the editor / TS +/// renderer treat that field as degrees too, so emit it verbatim and +/// let the per-target helper pick `deg` vs `rad`. +pub(crate) fn node_rotation_deg(node: &PenNode) -> f64 { + node.base().rotation.unwrap_or(0.0) +} + +/// Corner radius in document px. Uniform `CornerRadius` reads as-is; +/// per-corner radii collapse to their first value (CSS shorthand is +/// a follow-up). Variants without a corner-radius field read 0. +pub(crate) fn node_corner_radius(node: &PenNode) -> f64 { + use jian_ops_schema::node::CornerRadius; + let uniform = |c: &Option| match c { + Some(CornerRadius::Uniform(r)) => *r, + Some(CornerRadius::PerCorner(arr)) => arr.first().copied().unwrap_or(0.0), + None => 0.0, }; - let mut style = format!( - "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px", - node.bounds.origin.x, - node.bounds.origin.y, - node.bounds.size.x, - node.bounds.size.y, - ); - if let Some(fill) = node.fill { - style.push_str(&format!(";background:{}", color_to_css(fill))); - } - if let Some(stroke) = node.stroke { - style.push_str(&format!( - ";border:{}px solid {}", - stroke.width, - color_to_css(stroke.color) - )); - } - if node.corner_radius > 0.0 { - style.push_str(&format!(";border-radius:{}px", node.corner_radius)); - } - if matches!(node.kind, NodeKind::Ellipse) { - style.push_str(";border-radius:50%"); - } - if node.rotation.abs() > f32::EPSILON { - style.push_str(&format!(";transform:rotate({}rad)", node.rotation)); - } - let body = node.text.clone().unwrap_or_default(); - if node.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 !node.children.is_empty() { - out.push('\n'); - for c in &node.children { - emit_node_html(out, c, depth + 1); - } - out.push_str(&indent); - } - out.push_str(&format!("\n")); + match node { + PenNode::Frame(n) => uniform(&n.container.corner_radius), + PenNode::Group(n) => uniform(&n.container.corner_radius), + PenNode::Rectangle(n) => uniform(&n.container.corner_radius), + PenNode::Ellipse(n) => n.corner_radius.unwrap_or(0.0), + PenNode::Polygon(n) => n.corner_radius.unwrap_or(0.0), + _ => 0.0, } } -/// Vue 3 Single File Component generator. Wraps the page's node -/// tree in a `")); @@ -721,7 +623,7 @@ mod tests { #[test] fn svelte_emits_script_then_markup_then_style() { - let doc = Document::empty(); + let doc = empty_doc(); let s = Svelte.generate(&doc); let script_pos = s.find("").unwrap(); @@ -731,26 +633,17 @@ mod tests { #[test] fn vue_includes_variable_css_in_style_block() { - use crate::document::{Variable, VariableKind, VariableScalar, VariableTable, VariableValue}; - let mut doc = Document::empty(); - doc.var_table = VariableTable { - variables: vec![Variable { - name: "primary".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#abc".into())), - }], - themes: Vec::new(), - active_theme: std::collections::BTreeMap::new(), - fill_refs: HashMap::new(), - stroke_refs: HashMap::new(), - }; + let mut doc = empty_doc(); + let mut vars = BTreeMap::new(); + vars.insert("primary".to_string(), color_def("#abc")); + doc.variables = Some(vars); let s = Vue.generate(&doc); assert!(s.contains("--primary: #abc;")); } #[test] fn react_emits_functional_component_with_jsx_fragment() { - let doc = Document::empty(); + let doc = empty_doc(); let s = React.generate(&doc); assert!(s.contains("import React from 'react';")); assert!(s.contains("export default function Page()")); @@ -760,15 +653,7 @@ mod tests { #[test] fn flutter_emits_stack_with_positioned_children() { - 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("n10", 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 doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]); let s = Flutter.generate(&doc); assert!(s.contains("import 'package:flutter/material.dart';")); assert!(s.contains("Stack(children:")); @@ -779,14 +664,7 @@ mod tests { #[test] fn swiftui_emits_zstack_with_positioned_views() { - 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("n10", NodeKind::Rect, "r"); - n.bounds = Rect::xywh(0.0, 0.0, 100.0, 50.0); - page.children.push(n); + let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 100.0, 50.0, None)]); let s = SwiftUi.generate(&doc); assert!(s.contains("import SwiftUI")); assert!(s.contains("ZStack {")); @@ -796,29 +674,14 @@ mod tests { #[test] fn swiftui_emits_ellipse_for_ellipse_kind() { - 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("n10", NodeKind::Ellipse, "e"); - n.bounds = Rect::xywh(0.0, 0.0, 20.0, 20.0); - page.children.push(n); + let doc = doc_with_nodes(vec![ellipse("n10", 0.0, 0.0, 20.0, 20.0)]); let s = SwiftUi.generate(&doc); 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("n10", 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 doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]); let s = Compose.generate(&doc); assert!(s.contains("@Composable")); assert!(s.contains("Modifier.offset(x = 5.dp, y = 10.dp)")); @@ -828,14 +691,7 @@ mod tests { #[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("n10", NodeKind::Rect, "r"); - n.bounds = Rect::xywh(0.0, 0.0, 80.0, 40.0); - page.children.push(n); + let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 80.0, 40.0, None)]); let s = ReactNative.generate(&doc); assert!(s.contains("import { View, Text } from 'react-native';")); assert!(s.contains("export default function Page()")); @@ -846,22 +702,28 @@ mod tests { #[test] fn html_skips_hidden_nodes() { - use crate::document::{Node, NodeKind}; - let mut doc = Document::empty(); - let page = doc.pages.get_mut(0).unwrap(); - page.children.clear(); - let mut n = Node::leaf("n10", NodeKind::Rect, "hidden"); - n.hidden = true; - page.children.push(n); + let mut hidden = rect("n10", 0.0, 0.0, 10.0, 10.0, None); + hidden.base_mut().visible = Some(false); + hidden.base_mut().name = Some("hidden".into()); + let doc = doc_with_nodes(vec![hidden]); let html = Html.generate(&doc); assert!(!html.contains("hidden")); } #[test] fn css_variables_empty_doc_emits_header_only() { - let doc = Document::empty(); + let doc = empty_doc(); let css = CssVariables.generate(&doc); assert!(css.contains("Generated by OpenPencil")); assert!(!css.contains(":root")); } + + #[test] + fn falls_back_to_bare_children_when_unpaged() { + let mut doc = empty_doc(); + doc.children = vec![rect("n10", 1.0, 2.0, 30.0, 40.0, None)]; + let html = Html.generate(&doc); + assert!(html.contains("left:1px")); + assert!(html.contains("width:30px")); + } } diff --git a/crates/openpencil-shell-core/src/codegen_targets.rs b/crates/openpencil-shell-core/src/codegen_targets.rs new file mode 100644 index 000000000..727021842 --- /dev/null +++ b/crates/openpencil-shell-core/src/codegen_targets.rs @@ -0,0 +1,496 @@ +//! Per-framework markup generators — the React / Vue / Svelte / HTML +//! / Flutter / SwiftUI / Compose / React Native targets carved off +//! `codegen.rs` to keep both files under the 800-line cap. +//! +//! All generators consume the canonical `jian_ops_schema::PenDocument` +//! and walk `PenNode` through the flat-view accessors in the parent +//! `codegen` module (`node_origin` / `node_size` / `node_fill_css` / +//! …) so the per-target emit logic stays variant-agnostic. + +use crate::codegen::{ + color_to_css, fmt_num, html_escape, node_children, node_corner_radius, node_fill_css, + node_hidden, node_is_ellipse, node_is_text, node_origin, node_rotation_deg, node_size, + node_stroke_css, node_text, parse_color, root_nodes, Codegen, CssVariables, +}; +use jian_ops_schema::node::PenNode; +use jian_ops_schema::PenDocument; + +/// HTML + inline-CSS generator. Walks the document's node tree and +/// emits `
` per Rect/Frame/Group with absolute positioning and +/// inline style; ellipses get `border-radius: 50%`; text nodes emit +/// ``. Bare minimum useful — refining to semantic tags + flex +/// containers is a follow-up. +pub struct Html; + +impl Codegen for Html { + fn target_label(&self) -> &'static str { + "html" + } + fn generate(&self, doc: &PenDocument) -> String { + let mut out = String::new(); + out.push_str("\n"); + out.push_str("\n\n\n"); + for node in root_nodes(doc) { + emit_node_html(&mut out, node, 1); + } + out.push_str("\n"); + out + } +} + +fn emit_node_html(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 mut style = format!( + "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px", + fmt_num(x), + fmt_num(y), + fmt_num(w), + fmt_num(h), + ); + if let Some(fill) = node_fill_css(node) { + style.push_str(&format!(";background:{}", color_to_css(&fill))); + } + if let Some((color, width)) = node_stroke_css(node) { + style.push_str(&format!( + ";border:{}px solid {}", + width, + color_to_css(&color) + )); + } + let radius = node_corner_radius(node); + if radius > 0.0 { + style.push_str(&format!(";border-radius:{}px", fmt_num(radius))); + } + if node_is_ellipse(node) { + style.push_str(";border-radius:50%"); + } + let rotation = node_rotation_deg(node); + if rotation.abs() > f64::EPSILON { + style.push_str(&format!(";transform:rotate({}deg)", fmt_num(rotation))); + } + 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_html(out, c, depth + 1); + } + out.push_str(&indent); + } + out.push_str(&format!("\n")); + } +} + +/// Vue 3 Single File Component generator. Wraps the node tree in a +/// `