Skip to main content

crepuscularity_native/
codegen.rs

1use std::collections::BTreeSet;
2
3use crate::ir::{StackAxis, ViewIr, ViewNode, ViewStyle};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum NativeCodegenTarget {
7    SwiftUi,
8    Compose,
9}
10
11pub fn generate_native_source(ir: &ViewIr, target: NativeCodegenTarget, view_name: &str) -> String {
12    match target {
13        NativeCodegenTarget::SwiftUi => generate_swiftui(ir, view_name),
14        NativeCodegenTarget::Compose => generate_compose(ir, view_name),
15    }
16}
17
18fn generate_swiftui(ir: &ViewIr, view_name: &str) -> String {
19    let body = swiftui_nodes(&ir.root, 2);
20    let actions = collect_actions(&ir.root);
21    let known_actions = swift_known_actions(&actions);
22    format!(
23        "import SwiftUI\n\npublic enum CrepusActions {{\n    public static let knownActions: Set<String> = {known_actions}\n    public static var dispatch: (String) -> String = {{ _ in \"{{}}\" }}\n    public static var resultSink: (String) -> Void = {{ _ in }}\n\n    public static func perform(_ action: String) {{\n        guard knownActions.contains(action) else {{\n            resultSink(\"{{\\\"ok\\\":false,\\\"error\\\":\\\"unknown generated action\\\"}}\")\n            return\n        }}\n        resultSink(dispatch(action))\n    }}\n}}\n\npublic struct {view_name}: View {{\n    public init() {{}}\n\n    public var body: some View {{\n{body}\n    }}\n}}\n"
24    )
25}
26
27fn swiftui_nodes(nodes: &[ViewNode], indent: usize) -> String {
28    if nodes.len() == 1 {
29        swiftui_node(&nodes[0], indent)
30    } else {
31        let pad = indent_str(indent);
32        let inner = nodes
33            .iter()
34            .map(|node| swiftui_node(node, indent + 1))
35            .collect::<Vec<_>>()
36            .join("\n");
37        format!("{pad}Group {{\n{inner}\n{pad}}}")
38    }
39}
40
41fn swiftui_node(node: &ViewNode, indent: usize) -> String {
42    let pad = indent_str(indent);
43    match node {
44        ViewNode::Text { content, style } => {
45            let mut out = format!("{pad}Text(\"{}\")", swift_escape(content));
46            swiftui_style(&mut out, style.as_ref(), true, indent);
47            out
48        }
49        ViewNode::Stack {
50            axis,
51            spacing,
52            align_items,
53            style,
54            children,
55            ..
56        } => {
57            let view = match axis {
58                StackAxis::Row => "HStack",
59                StackAxis::Column => "VStack",
60            };
61            let align = swiftui_stack_alignment(*axis, align_items.as_deref());
62            let gap = spacing.unwrap_or(8.0);
63            let inner = swiftui_children(children, indent + 1);
64            let mut out =
65                format!("{pad}{view}(alignment: {align}, spacing: {gap:.1}) {{\n{inner}\n{pad}}}");
66            swiftui_style(&mut out, style.as_ref(), false, indent);
67            out
68        }
69        ViewNode::Button {
70            label,
71            on_click,
72            style,
73        } => {
74            let action = swiftui_action(on_click.as_deref());
75            let mut out = format!(
76                "{pad}Button(action: {{ {action} }}) {{\n{}Text(\"{}\")\n{pad}}}",
77                indent_str(indent + 1),
78                swift_escape(label)
79            );
80            swiftui_style(&mut out, style.as_ref(), false, indent);
81            out
82        }
83        ViewNode::Toggle {
84            label,
85            checked,
86            style,
87            ..
88        } => {
89            let mut out = format!(
90                "{pad}Toggle(\"{}\", isOn: .constant({}))",
91                swift_escape(label),
92                swift_bool(*checked)
93            );
94            swiftui_style(&mut out, style.as_ref(), false, indent);
95            out
96        }
97        ViewNode::Checkbox {
98            label,
99            checked,
100            style,
101            ..
102        } => {
103            let mut out = format!(
104                "{pad}Toggle(\"{}\", isOn: .constant({}))",
105                swift_escape(label),
106                swift_bool(*checked)
107            );
108            swiftui_style(&mut out, style.as_ref(), false, indent);
109            out
110        }
111        ViewNode::Slider {
112            label,
113            value,
114            min,
115            max,
116            step,
117            style,
118            ..
119        } => {
120            let step_arg = step.map(|v| format!(", step: {v:.1}")).unwrap_or_default();
121            let control =
122                format!("Slider(value: .constant({value:.3}), in: {min:.3}...{max:.3}{step_arg})");
123            let mut out = if let Some(label) = label {
124                format!(
125                    "{pad}VStack(alignment: .leading, spacing: 8.0) {{\n{}Text(\"{}\")\n{}{control}\n{pad}}}",
126                    indent_str(indent + 1),
127                    swift_escape(label),
128                    indent_str(indent + 1)
129                )
130            } else {
131                format!("{pad}{control}")
132            };
133            swiftui_style(&mut out, style.as_ref(), false, indent);
134            out
135        }
136        ViewNode::Progress {
137            label,
138            value,
139            max,
140            style,
141        } => {
142            let mut out = if let Some(label) = label {
143                format!(
144                    "{pad}ProgressView(\"{}\", value: {value:.3}, total: {max:.3})",
145                    swift_escape(label)
146                )
147            } else {
148                format!("{pad}ProgressView(value: {value:.3}, total: {max:.3})")
149            };
150            swiftui_style(&mut out, style.as_ref(), false, indent);
151            out
152        }
153        ViewNode::Meter {
154            label,
155            value,
156            max,
157            style,
158            ..
159        } => {
160            let text = label
161                .as_deref()
162                .map(|label| format!("{label}: {value:.1}/{max:.1}"))
163                .unwrap_or_else(|| format!("{value:.1}/{max:.1}"));
164            let mut out = format!("{pad}Text(\"{}\")", swift_escape(&text));
165            swiftui_style(&mut out, style.as_ref(), true, indent);
166            out
167        }
168        ViewNode::Badge { label, style, .. } => {
169            let mut out = format!("{pad}Text(\"{}\")", swift_escape(label));
170            swiftui_style(&mut out, style.as_ref(), true, indent);
171            out
172        }
173        ViewNode::Divider { style, .. } => {
174            let mut out = format!("{pad}Divider()");
175            swiftui_style(&mut out, style.as_ref(), false, indent);
176            out
177        }
178        ViewNode::Spacer { size, style } => {
179            let mut out = size
180                .map(|v| format!("{pad}Spacer(minLength: {v:.1})"))
181                .unwrap_or_else(|| format!("{pad}Spacer()"));
182            swiftui_style(&mut out, style.as_ref(), false, indent);
183            out
184        }
185        ViewNode::Dropzone {
186            label,
187            style,
188            children,
189            ..
190        } => {
191            let inner = if children.is_empty() {
192                format!(
193                    "{}Text(\"{}\")",
194                    indent_str(indent + 1),
195                    swift_escape(label)
196                )
197            } else {
198                swiftui_children(children, indent + 1)
199            };
200            let mut out =
201                format!("{pad}VStack(alignment: .leading, spacing: 8.0) {{\n{inner}\n{pad}}}");
202            swiftui_style(&mut out, style.as_ref(), false, indent);
203            out
204        }
205        ViewNode::Image {
206            src, alt, style, ..
207        } => {
208            let label = alt.as_deref().unwrap_or(src);
209            let mut out = format!("{pad}Text(\"{}\")", swift_escape(label));
210            swiftui_style(&mut out, style.as_ref(), true, indent);
211            out
212        }
213        ViewNode::Scroll {
214            axis,
215            style,
216            children,
217        } => {
218            let scroll_axis = match axis {
219                StackAxis::Row => ".horizontal",
220                StackAxis::Column => ".vertical",
221            };
222            let inner = swiftui_node(
223                &ViewNode::Stack {
224                    axis: *axis,
225                    spacing: Some(8.0),
226                    align_items: None,
227                    justify_content: None,
228                    style: None,
229                    children: children.clone(),
230                },
231                indent + 1,
232            );
233            let mut out = format!("{pad}ScrollView({scroll_axis}) {{\n{inner}\n{pad}}}");
234            swiftui_style(&mut out, style.as_ref(), false, indent);
235            out
236        }
237        ViewNode::List {
238            ordered,
239            style,
240            children,
241        } => {
242            let rows = children
243                .iter()
244                .enumerate()
245                .map(|(idx, child)| {
246                    let prefix = if *ordered {
247                        format!("{}. ", idx + 1)
248                    } else {
249                        String::new()
250                    };
251                    format!(
252                        "{}HStack {{\n{}Text(\"{}\")\n{}\n{}}}",
253                        indent_str(indent + 1),
254                        indent_str(indent + 2),
255                        swift_escape(&prefix),
256                        swiftui_node(child, indent + 2),
257                        indent_str(indent + 1)
258                    )
259                })
260                .collect::<Vec<_>>()
261                .join("\n");
262            let mut out =
263                format!("{pad}VStack(alignment: .leading, spacing: 8.0) {{\n{rows}\n{pad}}}");
264            swiftui_style(&mut out, style.as_ref(), false, indent);
265            out
266        }
267        ViewNode::ListItem { style, children } => {
268            let inner = swiftui_children(children, indent + 1);
269            let mut out =
270                format!("{pad}VStack(alignment: .leading, spacing: 4.0) {{\n{inner}\n{pad}}}");
271            swiftui_style(&mut out, style.as_ref(), false, indent);
272            out
273        }
274        ViewNode::SlotRotate { phrases, style, .. } => {
275            let mut out = format!(
276                "{pad}Text(\"{}\")",
277                swift_escape(phrases.first().map(String::as_str).unwrap_or(""))
278            );
279            swiftui_style(&mut out, style.as_ref(), true, indent);
280            out
281        }
282        ViewNode::Input {
283            placeholder,
284            multiline,
285            style,
286            ..
287        } => {
288            let mut out = if *multiline {
289                format!(
290                    "{pad}TextEditor(text: .constant(\"{}\"))",
291                    swift_escape(placeholder)
292                )
293            } else {
294                format!(
295                    "{pad}TextField(\"{}\", text: .constant(\"\"))",
296                    swift_escape(placeholder)
297                )
298            };
299            swiftui_style(&mut out, style.as_ref(), false, indent);
300            out
301        }
302        ViewNode::Picker { options, style, .. } => {
303            let first = options.first().map(|o| o.value.as_str()).unwrap_or("");
304            let rows = options
305                .iter()
306                .map(|option| {
307                    format!(
308                        "{}Text(\"{}\").tag(\"{}\")",
309                        indent_str(indent + 1),
310                        swift_escape(&option.label),
311                        swift_escape(&option.value)
312                    )
313                })
314                .collect::<Vec<_>>()
315                .join("\n");
316            let mut out = format!(
317                "{pad}Picker(\"\", selection: .constant(\"{}\")) {{\n{rows}\n{pad}}}",
318                swift_escape(first)
319            );
320            swiftui_style(&mut out, style.as_ref(), false, indent);
321            out
322        }
323    }
324}
325
326fn swiftui_action(on_click: Option<&str>) -> String {
327    on_click
328        .map(|action| format!("CrepusActions.perform(\"{}\")", swift_escape(action)))
329        .unwrap_or_default()
330}
331
332fn swiftui_children(children: &[ViewNode], indent: usize) -> String {
333    children
334        .iter()
335        .map(|child| swiftui_node(child, indent))
336        .collect::<Vec<_>>()
337        .join("\n")
338}
339
340fn swiftui_stack_alignment(axis: StackAxis, align_items: Option<&str>) -> &'static str {
341    match axis {
342        StackAxis::Column => match align_items {
343            Some("center") => ".center",
344            Some("end") => ".trailing",
345            _ => ".leading",
346        },
347        StackAxis::Row => match align_items {
348            Some("center") => ".center",
349            Some("end") => ".bottom",
350            _ => ".top",
351        },
352    }
353}
354
355fn swiftui_style(out: &mut String, style: Option<&ViewStyle>, is_text: bool, indent: usize) {
356    let Some(style) = style else {
357        return;
358    };
359    let pad = indent_str(indent + 1);
360    if is_text {
361        if let Some(size) = style.font_size {
362            out.push_str(&format!("\n{pad}.font(.system(size: {size:.1}))"));
363        }
364        if let Some(weight) = style.font_weight {
365            out.push_str(&format!(
366                "\n{pad}.fontWeight({})",
367                swiftui_font_weight(weight)
368            ));
369        }
370        if let Some(color) = &style.foreground_color {
371            out.push_str(&format!("\n{pad}.foregroundStyle({})", swift_color(color)));
372        }
373        if let Some(align) = swiftui_text_align(style.text_align.as_deref()) {
374            out.push_str(&format!("\n{pad}.multilineTextAlignment({align})"));
375        }
376        if style.italic == Some(true) {
377            out.push_str(&format!("\n{pad}.italic()"));
378        }
379        if style.underline == Some(true) {
380            out.push_str(&format!("\n{pad}.underline()"));
381        }
382        if style.strikethrough == Some(true) {
383            out.push_str(&format!("\n{pad}.strikethrough()"));
384        }
385        if let Some(lines) = style.line_clamp {
386            out.push_str(&format!("\n{pad}.lineLimit({lines})"));
387        }
388    }
389    swiftui_frame(out, style, &pad);
390    swiftui_spacing(out, style, &pad, "padding");
391    swiftui_spacing(out, style, &pad, "margin");
392    if let Some(opacity) = style.opacity {
393        out.push_str(&format!("\n{pad}.opacity({opacity:.3})"));
394    }
395    if style.hidden == Some(true) {
396        out.push_str(&format!("\n{pad}.opacity(0)"));
397    }
398    if let Some(background) = &style.background_color {
399        out.push_str(&format!("\n{pad}.background({})", swift_color(background)));
400    }
401    if let Some(radius) = style.corner_radius {
402        out.push_str(&format!(
403            "\n{pad}.clipShape(RoundedRectangle(cornerRadius: {radius:.1}))"
404        ));
405    }
406    if let Some(width) = style.border_width {
407        let color = style
408            .border_color
409            .as_deref()
410            .map(swift_color)
411            .unwrap_or_else(|| "Color.gray".to_string());
412        out.push_str(&format!("\n{pad}.border({color}, width: {width:.1})"));
413    }
414    if style.overflow_hidden == Some(true) {
415        out.push_str(&format!("\n{pad}.clipped()"));
416    }
417    if let Some(radius) = style.shadow_radius {
418        let color = style
419            .shadow_color
420            .as_deref()
421            .map(swift_color)
422            .unwrap_or_else(|| "Color.black.opacity(0.25)".to_string());
423        let x = style.shadow_offset_x.unwrap_or(0.0);
424        let y = style.shadow_offset_y.unwrap_or(0.0);
425        out.push_str(&format!(
426            "\n{pad}.shadow(color: {color}, radius: {radius:.1}, x: {x:.1}, y: {y:.1})"
427        ));
428    }
429    if style.translate_x.is_some() || style.translate_y.is_some() {
430        out.push_str(&format!(
431            "\n{pad}.offset(x: {:.1}, y: {:.1})",
432            style.translate_x.unwrap_or(0.0),
433            style.translate_y.unwrap_or(0.0)
434        ));
435    }
436    if let Some(rotate) = style.rotate {
437        out.push_str(&format!("\n{pad}.rotationEffect(.degrees({rotate:.1}))"));
438    }
439    if style.scale_x.is_some() || style.scale_y.is_some() {
440        out.push_str(&format!(
441            "\n{pad}.scaleEffect(x: {:.3}, y: {:.3})",
442            style.scale_x.unwrap_or(1.0),
443            style.scale_y.unwrap_or(1.0)
444        ));
445    }
446}
447
448fn swiftui_spacing(out: &mut String, style: &ViewStyle, pad: &str, kind: &str) {
449    let values = if kind == "padding" {
450        [
451            (style.padding, ""),
452            (style.padding_horizontal, ".horizontal"),
453            (style.padding_vertical, ".vertical"),
454            (style.padding_top, ".top"),
455            (style.padding_bottom, ".bottom"),
456            (style.padding_left, ".leading"),
457            (style.padding_right, ".trailing"),
458        ]
459    } else {
460        [
461            (style.margin, ""),
462            (style.margin_horizontal, ".horizontal"),
463            (style.margin_vertical, ".vertical"),
464            (style.margin_top, ".top"),
465            (style.margin_bottom, ".bottom"),
466            (style.margin_left, ".leading"),
467            (style.margin_right, ".trailing"),
468        ]
469    };
470    for (value, edge) in values {
471        if let Some(value) = value {
472            if edge.is_empty() {
473                out.push_str(&format!("\n{pad}.padding({value:.0})"));
474            } else {
475                out.push_str(&format!("\n{pad}.padding({edge}, {value:.0})"));
476            }
477        }
478    }
479}
480
481fn swiftui_frame(out: &mut String, style: &ViewStyle, pad: &str) {
482    if style.width == Some(-1.0) || style.height == Some(-1.0) {
483        let max_width = if style.width == Some(-1.0) {
484            ".infinity".to_string()
485        } else {
486            "nil".to_string()
487        };
488        let max_height = if style.height == Some(-1.0) {
489            ".infinity".to_string()
490        } else {
491            "nil".to_string()
492        };
493        out.push_str(&format!(
494            "\n{pad}.frame(maxWidth: {max_width}, maxHeight: {max_height}, alignment: .topLeading)"
495        ));
496        return;
497    }
498    if style.width.is_some() || style.height.is_some() {
499        let width = style
500            .width
501            .filter(|v| *v > 0.0)
502            .map(|v| format!("{v:.1}"))
503            .unwrap_or_else(|| "nil".to_string());
504        let height = style
505            .height
506            .filter(|v| *v > 0.0)
507            .map(|v| format!("{v:.1}"))
508            .unwrap_or_else(|| "nil".to_string());
509        out.push_str(&format!(
510            "\n{pad}.frame(width: {width}, height: {height}, alignment: .topLeading)"
511        ));
512    }
513}
514
515fn swiftui_text_align(value: Option<&str>) -> Option<&'static str> {
516    match value {
517        Some("center") => Some(".center"),
518        Some("right") | Some("end") | Some("trailing") => Some(".trailing"),
519        Some("justify") => Some(".leading"),
520        Some("left") | Some("start") | Some("leading") => Some(".leading"),
521        _ => None,
522    }
523}
524
525fn swiftui_font_weight(weight: u16) -> &'static str {
526    match weight {
527        0..=299 => ".thin",
528        300..=399 => ".light",
529        400..=499 => ".regular",
530        500..=599 => ".medium",
531        600..=699 => ".semibold",
532        700..=799 => ".bold",
533        _ => ".heavy",
534    }
535}
536
537fn generate_compose(ir: &ViewIr, view_name: &str) -> String {
538    let body = compose_nodes(&ir.root, 1);
539    let actions = collect_actions(&ir.root);
540    let known_actions = compose_known_actions(&actions);
541    format!(
542        "import androidx.compose.foundation.background\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.horizontalScroll\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.offset\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Divider\nimport androidx.compose.material3.LinearProgressIndicator\nimport androidx.compose.material3.Slider\nimport androidx.compose.material3.Switch\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextField\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.rotate\nimport androidx.compose.ui.draw.scale\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.font.FontStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextDecoration\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\n\nobject CrepusActions {{\n    val knownActions: Set<String> = {known_actions}\n    var dispatch: (String) -> String = {{ \"{{}}\" }}\n    var resultSink: (String) -> Unit = {{}}\n\n    fun perform(action: String) {{\n        if (!knownActions.contains(action)) {{\n            resultSink(\"{{\\\"ok\\\":false,\\\"error\\\":\\\"unknown generated action\\\"}}\")\n            return\n        }}\n        resultSink(dispatch(action))\n    }}\n}}\n\n@Composable\nfun {view_name}(modifier: Modifier = Modifier) {{\n{body}\n}}\n"
543    )
544}
545
546fn compose_nodes(nodes: &[ViewNode], indent: usize) -> String {
547    if nodes.len() == 1 {
548        compose_node_with_base(&nodes[0], indent, Some("modifier".to_string()))
549    } else {
550        let pad = indent_str(indent);
551        let inner = compose_children(nodes, indent + 1);
552        format!("{pad}Column {{\n{inner}\n{pad}}}")
553    }
554}
555
556fn compose_node(node: &ViewNode, indent: usize) -> String {
557    compose_node_with_base(node, indent, None)
558}
559
560fn compose_node_with_base(node: &ViewNode, indent: usize, base_modifier: Option<String>) -> String {
561    let pad = indent_str(indent);
562    match node {
563        ViewNode::Text { content, style } => {
564            let args = compose_text_args(style.as_ref());
565            format!("{pad}Text(\"{}\"{args})", kotlin_escape(content))
566        }
567        ViewNode::Stack {
568            axis,
569            spacing,
570            style,
571            children,
572            ..
573        } => {
574            let view = match axis {
575                StackAxis::Row => "Row",
576                StackAxis::Column => "Column",
577            };
578            let mut args = Vec::new();
579            if let Some(modifier) = compose_modifier_chain(base_modifier, style.as_ref()) {
580                args.push(format!("modifier = {modifier}"));
581            }
582            let arrangement = match axis {
583                StackAxis::Row => "horizontalArrangement",
584                StackAxis::Column => "verticalArrangement",
585            };
586            args.push(format!(
587                "{arrangement} = Arrangement.spacedBy({:.0}.dp)",
588                spacing.unwrap_or(8.0)
589            ));
590            let args = args.join(", ");
591            let inner = compose_children(children, indent + 1);
592            format!("{pad}{view}({args}) {{\n{inner}\n{pad}}}")
593        }
594        ViewNode::Button {
595            label,
596            on_click,
597            style,
598        } => {
599            let modifier = compose_modifier_param(style.as_ref());
600            let action = compose_action(on_click.as_deref());
601            format!(
602                "{pad}Button(onClick = {{ {action} }}{modifier}) {{\n{}Text(\"{}\")\n{pad}}}",
603                indent_str(indent + 1),
604                kotlin_escape(label)
605            )
606        }
607        ViewNode::Toggle {
608            label,
609            checked,
610            style,
611            ..
612        }
613        | ViewNode::Checkbox {
614            label,
615            checked,
616            style,
617            ..
618        } => {
619            let modifier = compose_modifier_call_args(style.as_ref());
620            format!(
621                "{pad}Row{modifier} {{\n{}Text(\"{}\")\n{}Switch(checked = {}, onCheckedChange = {{}})\n{pad}}}",
622                indent_str(indent + 1),
623                kotlin_escape(label),
624                indent_str(indent + 1),
625                kotlin_bool(*checked)
626            )
627        }
628        ViewNode::Slider {
629            label,
630            value,
631            min,
632            max,
633            style,
634            ..
635        } => {
636            let modifier = compose_modifier_call_args(style.as_ref());
637            let label = label
638                .as_deref()
639                .map(|label| {
640                    format!(
641                        "{}Text(\"{}\")\n",
642                        indent_str(indent + 1),
643                        kotlin_escape(label)
644                    )
645                })
646                .unwrap_or_default();
647            format!(
648                "{pad}Column{modifier} {{\n{label}{}Slider(value = {value:.3}f, onValueChange = {{}}, valueRange = {min:.3}f..{max:.3}f)\n{pad}}}",
649                indent_str(indent + 1)
650            )
651        }
652        ViewNode::Progress {
653            label,
654            value,
655            max,
656            style,
657        } => {
658            let modifier = compose_modifier_call_args(style.as_ref());
659            let label = label
660                .as_deref()
661                .map(|label| {
662                    format!(
663                        "{}Text(\"{}\")\n",
664                        indent_str(indent + 1),
665                        kotlin_escape(label)
666                    )
667                })
668                .unwrap_or_default();
669            format!(
670                "{pad}Column{modifier} {{\n{label}{}LinearProgressIndicator(progress = {value:.3}f / {max:.3}f)\n{pad}}}",
671                indent_str(indent + 1)
672            )
673        }
674        ViewNode::Meter {
675            label, value, max, ..
676        } => {
677            let text = label
678                .as_deref()
679                .map(|label| format!("{label}: {value:.1}/{max:.1}"))
680                .unwrap_or_else(|| format!("{value:.1}/{max:.1}"));
681            format!("{pad}Text(\"{}\")", kotlin_escape(&text))
682        }
683        ViewNode::Badge { label, style, .. } => {
684            let args = compose_text_args(style.as_ref());
685            format!("{pad}Text(\"{}\"{args})", kotlin_escape(label))
686        }
687        ViewNode::Divider { .. } => format!("{pad}Divider()"),
688        ViewNode::Spacer { size, .. } => {
689            format!(
690                "{pad}Spacer(modifier = Modifier.height({:.0}.dp))",
691                size.unwrap_or(8.0)
692            )
693        }
694        ViewNode::Dropzone {
695            label,
696            style,
697            children,
698            ..
699        } => {
700            let modifier = compose_modifier_call_args(style.as_ref());
701            let inner = if children.is_empty() {
702                format!(
703                    "{}Text(\"{}\")",
704                    indent_str(indent + 1),
705                    kotlin_escape(label)
706                )
707            } else {
708                compose_children(children, indent + 1)
709            };
710            format!("{pad}Column{modifier} {{\n{inner}\n{pad}}}")
711        }
712        ViewNode::Image {
713            src, alt, style, ..
714        } => {
715            let args = compose_text_args(style.as_ref());
716            format!(
717                "{pad}Text(\"{}\"{args})",
718                kotlin_escape(alt.as_deref().unwrap_or(src))
719            )
720        }
721        ViewNode::Scroll {
722            axis,
723            style,
724            children,
725        } => {
726            let base = match axis {
727                StackAxis::Row => "Modifier.horizontalScroll(rememberScrollState())",
728                StackAxis::Column => "Modifier.verticalScroll(rememberScrollState())",
729            };
730            let modifier = compose_modifier_chain(Some(base.to_string()), style.as_ref());
731            let view = match axis {
732                StackAxis::Row => "Row",
733                StackAxis::Column => "Column",
734            };
735            let inner = compose_children(children, indent + 1);
736            format!(
737                "{pad}{view}(modifier = {}) {{\n{inner}\n{pad}}}",
738                modifier.unwrap_or_else(|| "Modifier".to_string())
739            )
740        }
741        ViewNode::List { children, .. } | ViewNode::ListItem { children, .. } => {
742            let inner = compose_children(children, indent + 1);
743            format!("{pad}Column {{\n{inner}\n{pad}}}")
744        }
745        ViewNode::SlotRotate { phrases, style, .. } => {
746            let args = compose_text_args(style.as_ref());
747            format!(
748                "{pad}Text(\"{}\"{args})",
749                kotlin_escape(phrases.first().map(String::as_str).unwrap_or(""))
750            )
751        }
752        ViewNode::Input {
753            placeholder, style, ..
754        } => {
755            let modifier = compose_modifier_param(style.as_ref());
756            format!(
757                "{pad}TextField(value = \"\", onValueChange = {{}}, placeholder = {{ Text(\"{}\") }}{modifier})",
758                kotlin_escape(placeholder)
759            )
760        }
761        ViewNode::Picker { options, style, .. } => {
762            let modifier = compose_modifier_call_args(style.as_ref());
763            let inner = options
764                .iter()
765                .map(|option| {
766                    format!(
767                        "{}Text(\"{}\")",
768                        indent_str(indent + 1),
769                        kotlin_escape(&option.label)
770                    )
771                })
772                .collect::<Vec<_>>()
773                .join("\n");
774            format!("{pad}Column{modifier} {{\n{inner}\n{pad}}}")
775        }
776    }
777}
778
779fn compose_action(on_click: Option<&str>) -> String {
780    on_click
781        .map(|action| format!("CrepusActions.perform(\"{}\")", kotlin_escape(action)))
782        .unwrap_or_default()
783}
784
785fn compose_children(children: &[ViewNode], indent: usize) -> String {
786    children
787        .iter()
788        .map(|child| compose_node(child, indent))
789        .collect::<Vec<_>>()
790        .join("\n")
791}
792
793fn compose_text_args(style: Option<&ViewStyle>) -> String {
794    let mut args = Vec::new();
795    if let Some(modifier) = compose_modifier(style) {
796        args.push(format!("modifier = {modifier}"));
797    }
798    if let Some(style) = style {
799        if let Some(size) = style.font_size {
800            args.push(format!("fontSize = {size:.1}.sp"));
801        }
802        if let Some(weight) = style.font_weight {
803            args.push(format!("fontWeight = {}", compose_font_weight(weight)));
804        }
805        if let Some(color) = &style.foreground_color {
806            args.push(format!("color = Color(0x{})", compose_hex_argb(color)));
807        }
808        if let Some(align) = compose_text_align(style.text_align.as_deref()) {
809            args.push(format!("textAlign = {align}"));
810        }
811        if style.italic == Some(true) {
812            args.push("fontStyle = FontStyle.Italic".to_string());
813        }
814        if style.underline == Some(true) && style.strikethrough == Some(true) {
815            args.push(
816                "textDecoration = TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))"
817                    .to_string(),
818            );
819        } else if style.underline == Some(true) {
820            args.push("textDecoration = TextDecoration.Underline".to_string());
821        } else if style.strikethrough == Some(true) {
822            args.push("textDecoration = TextDecoration.LineThrough".to_string());
823        }
824        if let Some(line_height) = style.line_height {
825            args.push(format!("lineHeight = {:.1}.sp", line_height * 16.0));
826        }
827        if let Some(lines) = style.line_clamp {
828            args.push(format!("maxLines = {lines}"));
829        }
830    }
831    if args.is_empty() {
832        String::new()
833    } else {
834        format!(", {}", args.join(", "))
835    }
836}
837
838fn compose_modifier_call_args(style: Option<&ViewStyle>) -> String {
839    compose_modifier(style)
840        .map(|modifier| format!("(modifier = {modifier})"))
841        .unwrap_or_else(|| "()".to_string())
842}
843
844fn compose_modifier_param(style: Option<&ViewStyle>) -> String {
845    compose_modifier(style)
846        .map(|modifier| format!(", modifier = {modifier}"))
847        .unwrap_or_default()
848}
849
850fn compose_modifier(style: Option<&ViewStyle>) -> Option<String> {
851    compose_modifier_chain(None, style)
852}
853
854fn compose_modifier_chain(base: Option<String>, style: Option<&ViewStyle>) -> Option<String> {
855    let mut modifier = base.unwrap_or_else(|| "Modifier".to_string());
856    let mut used = modifier != "Modifier";
857    if let Some(style) = style {
858        for value in compose_spacing_values(style, "margin") {
859            modifier.push_str(&format!(".padding({value})"));
860            used = true;
861        }
862        if style.width == Some(-1.0) && style.height == Some(-1.0) {
863            modifier.push_str(".fillMaxSize()");
864            used = true;
865        } else {
866            if style.width == Some(-1.0) || style.max_width == Some(-1.0) {
867                modifier.push_str(".fillMaxWidth()");
868                used = true;
869            } else if let Some(width) = style.width.filter(|v| *v > 0.0) {
870                modifier.push_str(&format!(".width({width:.0}.dp)"));
871                used = true;
872            }
873            if style.height == Some(-1.0) || style.max_height == Some(-1.0) {
874                modifier.push_str(".fillMaxHeight()");
875                used = true;
876            } else if let Some(height) = style.height.filter(|v| *v > 0.0) {
877                modifier.push_str(&format!(".height({height:.0}.dp)"));
878                used = true;
879            }
880        }
881        if let Some(color) = &style.background_color {
882            if let Some(radius) = style.corner_radius {
883                modifier.push_str(&format!(".clip(RoundedCornerShape({radius:.0}.dp))"));
884            }
885            modifier.push_str(&format!(
886                ".background(Color(0x{}))",
887                compose_hex_argb(color)
888            ));
889            used = true;
890        }
891        if let Some(width) = style.border_width {
892            let color = style
893                .border_color
894                .as_deref()
895                .map(compose_hex_argb)
896                .unwrap_or_else(|| "FF888888".to_string());
897            let radius = style.corner_radius.unwrap_or(0.0);
898            modifier.push_str(&format!(
899                ".border({width:.0}.dp, Color(0x{color}), RoundedCornerShape({radius:.0}.dp))"
900            ));
901            used = true;
902        }
903        for value in compose_spacing_values(style, "padding") {
904            modifier.push_str(&format!(".padding({value})"));
905            used = true;
906        }
907        if let Some(opacity) = style.opacity {
908            modifier.push_str(&format!(".alpha({opacity:.3}f)"));
909            used = true;
910        }
911        if style.hidden == Some(true) {
912            modifier.push_str(".alpha(0f)");
913            used = true;
914        }
915        if style.translate_x.is_some() || style.translate_y.is_some() {
916            modifier.push_str(&format!(
917                ".offset(x = {:.0}.dp, y = {:.0}.dp)",
918                style.translate_x.unwrap_or(0.0),
919                style.translate_y.unwrap_or(0.0)
920            ));
921            used = true;
922        }
923        if let Some(rotate) = style.rotate {
924            modifier.push_str(&format!(".rotate({rotate:.1}f)"));
925            used = true;
926        }
927        if style.scale_x.is_some() || style.scale_y.is_some() {
928            modifier.push_str(&format!(
929                ".scale(scaleX = {:.3}f, scaleY = {:.3}f)",
930                style.scale_x.unwrap_or(1.0),
931                style.scale_y.unwrap_or(1.0)
932            ));
933            used = true;
934        }
935    }
936    used.then_some(modifier)
937}
938
939fn compose_spacing_values(style: &ViewStyle, kind: &str) -> Vec<String> {
940    let (all, horizontal, vertical, top, bottom, left, right) = if kind == "padding" {
941        (
942            style.padding,
943            style.padding_horizontal,
944            style.padding_vertical,
945            style.padding_top,
946            style.padding_bottom,
947            style.padding_left,
948            style.padding_right,
949        )
950    } else {
951        (
952            style.margin,
953            style.margin_horizontal,
954            style.margin_vertical,
955            style.margin_top,
956            style.margin_bottom,
957            style.margin_left,
958            style.margin_right,
959        )
960    };
961    let mut out = Vec::new();
962    if let Some(value) = all {
963        out.push(format!("{value:.0}.dp"));
964    }
965    if horizontal.is_some() || vertical.is_some() {
966        out.push(format!(
967            "horizontal = {:.0}.dp, vertical = {:.0}.dp",
968            horizontal.unwrap_or(0.0),
969            vertical.unwrap_or(0.0)
970        ));
971    }
972    if top.is_some() || bottom.is_some() || left.is_some() || right.is_some() {
973        out.push(format!(
974            "start = {:.0}.dp, top = {:.0}.dp, end = {:.0}.dp, bottom = {:.0}.dp",
975            left.unwrap_or(0.0),
976            top.unwrap_or(0.0),
977            right.unwrap_or(0.0),
978            bottom.unwrap_or(0.0)
979        ));
980    }
981    out
982}
983
984fn compose_text_align(value: Option<&str>) -> Option<&'static str> {
985    match value {
986        Some("center") => Some("TextAlign.Center"),
987        Some("right") | Some("end") | Some("trailing") => Some("TextAlign.End"),
988        Some("justify") => Some("TextAlign.Justify"),
989        Some("left") | Some("start") | Some("leading") => Some("TextAlign.Start"),
990        _ => None,
991    }
992}
993
994fn compose_font_weight(weight: u16) -> &'static str {
995    match weight {
996        0..=299 => "FontWeight.Thin",
997        300..=399 => "FontWeight.Light",
998        400..=499 => "FontWeight.Normal",
999        500..=599 => "FontWeight.Medium",
1000        600..=699 => "FontWeight.SemiBold",
1001        700..=799 => "FontWeight.Bold",
1002        _ => "FontWeight.ExtraBold",
1003    }
1004}
1005
1006fn compose_hex_argb(color: &str) -> String {
1007    let trimmed = color.trim_start_matches('#');
1008    match trimmed.len() {
1009        6 => format!("FF{}", trimmed.to_ascii_uppercase()),
1010        8 => trimmed.to_ascii_uppercase(),
1011        _ => "FF888888".to_string(),
1012    }
1013}
1014
1015fn swift_escape(s: &str) -> String {
1016    s.replace('\\', "\\\\").replace('"', "\\\"")
1017}
1018
1019fn swift_color(color: &str) -> String {
1020    let trimmed = color.trim_start_matches('#');
1021    let Some((r, g, b, a)) = parse_hex_rgba(trimmed) else {
1022        return "Color.gray".to_string();
1023    };
1024    format!(
1025        "Color(red: {:.3}, green: {:.3}, blue: {:.3}, opacity: {:.3})",
1026        r as f32 / 255.0,
1027        g as f32 / 255.0,
1028        b as f32 / 255.0,
1029        a as f32 / 255.0
1030    )
1031}
1032
1033fn parse_hex_rgba(hex: &str) -> Option<(u8, u8, u8, u8)> {
1034    if hex.len() != 6 && hex.len() != 8 {
1035        return None;
1036    }
1037    let value = u32::from_str_radix(hex, 16).ok()?;
1038    if hex.len() == 8 {
1039        Some((
1040            ((value & 0x00ff0000) >> 16) as u8,
1041            ((value & 0x0000ff00) >> 8) as u8,
1042            (value & 0x000000ff) as u8,
1043            ((value & 0xff000000) >> 24) as u8,
1044        ))
1045    } else {
1046        Some((
1047            ((value & 0xff0000) >> 16) as u8,
1048            ((value & 0x00ff00) >> 8) as u8,
1049            (value & 0x0000ff) as u8,
1050            255,
1051        ))
1052    }
1053}
1054
1055fn kotlin_escape(s: &str) -> String {
1056    s.replace('\\', "\\\\").replace('"', "\\\"")
1057}
1058
1059fn swift_bool(value: bool) -> &'static str {
1060    if value {
1061        "true"
1062    } else {
1063        "false"
1064    }
1065}
1066
1067fn kotlin_bool(value: bool) -> &'static str {
1068    if value {
1069        "true"
1070    } else {
1071        "false"
1072    }
1073}
1074
1075fn collect_actions(nodes: &[ViewNode]) -> BTreeSet<String> {
1076    let mut actions = BTreeSet::new();
1077    for node in nodes {
1078        collect_node_actions(node, &mut actions);
1079    }
1080    actions
1081}
1082
1083fn collect_node_actions(node: &ViewNode, actions: &mut BTreeSet<String>) {
1084    match node {
1085        ViewNode::Button {
1086            on_click: Some(action),
1087            ..
1088        } => {
1089            actions.insert(action.clone());
1090        }
1091        ViewNode::Toggle {
1092            on_change: Some(action),
1093            ..
1094        }
1095        | ViewNode::Checkbox {
1096            on_change: Some(action),
1097            ..
1098        } => {
1099            actions.insert(action.clone());
1100        }
1101        ViewNode::Dropzone {
1102            on_drop: Some(action),
1103            children,
1104            ..
1105        } => {
1106            actions.insert(action.clone());
1107            for child in children {
1108                collect_node_actions(child, actions);
1109            }
1110        }
1111        ViewNode::Dropzone {
1112            on_drop: None,
1113            children,
1114            ..
1115        } => {
1116            for child in children {
1117                collect_node_actions(child, actions);
1118            }
1119        }
1120        ViewNode::Stack { children, .. }
1121        | ViewNode::Scroll { children, .. }
1122        | ViewNode::List { children, .. }
1123        | ViewNode::ListItem { children, .. } => {
1124            for child in children {
1125                collect_node_actions(child, actions);
1126            }
1127        }
1128        _ => {}
1129    }
1130}
1131
1132fn swift_known_actions(actions: &BTreeSet<String>) -> String {
1133    if actions.is_empty() {
1134        "[]".to_string()
1135    } else {
1136        format!(
1137            "[{}]",
1138            actions
1139                .iter()
1140                .map(|action| format!("\"{}\"", swift_escape(action)))
1141                .collect::<Vec<_>>()
1142                .join(", ")
1143        )
1144    }
1145}
1146
1147fn compose_known_actions(actions: &BTreeSet<String>) -> String {
1148    if actions.is_empty() {
1149        "emptySet()".to_string()
1150    } else {
1151        format!(
1152            "setOf({})",
1153            actions
1154                .iter()
1155                .map(|action| format!("\"{}\"", kotlin_escape(action)))
1156                .collect::<Vec<_>>()
1157                .join(", ")
1158        )
1159    }
1160}
1161
1162fn indent_str(level: usize) -> String {
1163    "    ".repeat(level)
1164}