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}