1use serde_json::{Map, Value};
10
11use crate::ui::{Node, View, node::spans_text};
12
13const BAR: usize = 10;
15
16const BLOCK_TAGS: [&str; 30] = [
18 "div",
19 "p",
20 "section",
21 "header",
22 "footer",
23 "nav",
24 "aside",
25 "main",
26 "article",
27 "figure",
28 "blockquote",
29 "ul",
30 "ol",
31 "li",
32 "dl",
33 "dt",
34 "dd",
35 "h1",
36 "h2",
37 "h3",
38 "h4",
39 "pre",
40 "hr",
41 "table",
42 "thead",
43 "tbody",
44 "tr",
45 "form",
46 "label",
47 "button",
48];
49
50pub fn plain(view: impl Into<View>, cols: usize) -> String {
62 let view = view.into();
63 let mut lines = Vec::new();
64 for node in view.regions().into_iter().flatten() {
65 block(node, cols, &mut lines);
66 }
67 let mut out = String::new();
68 for line in lines {
69 out.push_str(line.trim_end());
70 out.push('\n');
71 }
72 out
73}
74
75fn text<M>(node: &Node<M>, key: &str) -> Option<String> {
77 node.text_prop(key).filter(|t| !t.is_empty())
78}
79
80fn string<'a, M>(node: &'a Node<M>, key: &str) -> Option<&'a str> {
82 node.props.get(key).and_then(Value::as_str)
83}
84
85fn number<M>(node: &Node<M>, key: &str) -> Option<f64> {
87 node.props.get(key).and_then(Value::as_f64)
88}
89
90fn value_text(value: &Value) -> String {
92 match value {
93 Value::String(s) => s.clone(),
94 Value::Array(spans) => spans_text(spans),
95 Value::Number(n) => n.to_string(),
96 Value::Bool(b) => b.to_string(),
97 _ => String::new(),
98 }
99}
100
101fn list<'a, M>(node: &'a Node<M>, key: &str) -> &'a [Value] {
103 node
104 .props
105 .get(key)
106 .and_then(Value::as_array)
107 .map_or(&[], Vec::as_slice)
108}
109
110fn primary<M>(node: &Node<M>) -> Option<String> {
112 match node.props.get("spans") {
113 Some(Value::Array(spans)) => Some(spans_text(spans)),
114 _ => text(node, "text"),
115 }
116}
117
118fn bar(value: f64) -> String {
120 let v = value.clamp(0.0, 1.0);
121 let filled = (v * BAR as f64).round() as usize;
122 format!("[{}{}] {:.0}%", "#".repeat(filled), "-".repeat(BAR - filled), v * 100.0)
123}
124
125fn duration(ms: f64) -> String {
127 let s = (ms.abs() / 1000.0).floor() as u64;
128 let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
129 if h > 0 {
130 format!("{h}h {m}m")
131 } else if m > 0 {
132 format!("{m}m {s}s")
133 } else {
134 format!("{s}s")
135 }
136}
137
138fn strip(text: &str) -> String {
141 let mut out = String::with_capacity(text.len());
142 let mut chars = text.chars().peekable();
143 while let Some(c) = chars.next() {
144 match c {
145 '\x1b' => match chars.next() {
146 Some('[') => {
147 for c in chars.by_ref() {
148 if ('\x40'..='\x7e').contains(&c) {
149 break;
150 }
151 }
152 },
153 Some(']' | '_' | 'P' | '^') => {
154 while let Some(c) = chars.next() {
155 if c == '\x07' || (c == '\x1b' && chars.next_if_eq(&'\\').is_some()) {
156 break;
157 }
158 }
159 },
160 _ => {},
161 },
162 '\n' | '\t' => out.push(c),
163 '\r' => {},
164 c if c.is_control() => {},
165 c => out.push(c),
166 }
167 }
168 out
169}
170
171fn push_text(text: &str, out: &mut Vec<String>) {
173 out.extend(
174 text
175 .strip_suffix('\n')
176 .unwrap_or(text)
177 .split('\n')
178 .map(str::to_owned),
179 );
180}
181
182fn columns(rows: &[Vec<String>], right: &[bool], out: &mut Vec<String>) {
185 let n = rows.iter().map(Vec::len).max().unwrap_or(0);
186 let mut widths = vec![0; n];
187 for row in rows {
188 for (w, cell) in widths.iter_mut().zip(row) {
189 *w = (*w).max(cell.chars().count());
190 }
191 }
192 for row in rows {
193 let mut line = String::new();
194 for (i, cell) in row.iter().enumerate() {
195 if i > 0 {
196 line.push_str(" ");
197 }
198 let pad = widths[i] - cell.chars().count();
199 if right.get(i).copied().unwrap_or(false) {
200 line.push_str(&" ".repeat(pad));
201 line.push_str(cell);
202 } else {
203 line.push_str(cell);
204 line.push_str(&" ".repeat(pad));
205 }
206 }
207 out.push(line);
208 }
209}
210
211fn indented(inner: Vec<String>, out: &mut Vec<String>) {
213 out.extend(
214 inner
215 .into_iter()
216 .map(|l| if l.is_empty() { l } else { format!(" {l}") }),
217 );
218}
219
220fn block<M>(node: &Node<M>, cols: usize, out: &mut Vec<String>) {
222 if node.props.get("hidden") == Some(&Value::Bool(true)) {
223 return;
224 }
225 match node.kind.as_str() {
226 "col" => node.children.iter().for_each(|c| block(c, cols, out)),
227 "status" => out.push(inline(node)),
228 "row" => {
229 let line = inline(node);
230 if !line.is_empty() {
231 out.push(line);
232 }
233 },
234 "card" | "section" | "overlay" => {
235 let mut head = text(node, "head").unwrap_or_default();
236 if let Some(status) = string(node, "status") {
237 head = if head.is_empty() {
238 format!("({status})")
239 } else {
240 format!("{head} ({status})")
241 };
242 }
243 let mut inner = Vec::new();
244 node
245 .children
246 .iter()
247 .for_each(|c| block(c, cols, &mut inner));
248 if head.is_empty() {
249 out.extend(inner);
250 } else {
251 out.push(head);
252 indented(inner, out);
253 }
254 },
255 "rule" => {
256 let width = cols.clamp(4, 80);
257 match text(node, "label") {
258 Some(label) => {
259 let side = width.saturating_sub(label.chars().count() + 2) / 2;
260 out.push(format!("{} {label} {}", "-".repeat(side), "-".repeat(side)));
261 },
262 None => out.push("-".repeat(width)),
263 }
264 },
265 "spacer" => out.push(String::new()),
266 "ansi" => push_text(&strip(&text(node, "text").unwrap_or_default()), out),
267 "rows" => {
268 for line in list(node, "lines") {
269 if let Some(line) = line.as_str() {
270 out.push(strip(line));
271 }
272 }
273 },
274 "diff" => match node.props.get("hunks") {
275 Some(Value::Array(hunks)) => {
276 for hunk in hunks {
277 for line in hunk
278 .get("lines")
279 .and_then(Value::as_array)
280 .into_iter()
281 .flatten()
282 {
283 out.push(line.as_str().unwrap_or_default().to_owned());
284 }
285 }
286 },
287 _ => push_text(&text(node, "text").unwrap_or_default(), out),
288 },
289 "editor" | "input" => {
290 let prompt = text(node, "prompt").unwrap_or_default();
291 let body = text(node, "text")
292 .or_else(|| text(node, "placeholder"))
293 .unwrap_or_default();
294 push_text(&format!("{prompt}{body}"), out);
295 },
296 "kv" => {
297 let rows: Vec<Vec<String>> = list(node, "items")
298 .iter()
299 .map(|i| {
300 vec![
301 i.get("k").map(value_text).unwrap_or_default(),
302 i.get("v").map(value_text).unwrap_or_default(),
303 ]
304 })
305 .collect();
306 if string(node, "layout") == Some("inline") {
307 if !rows.is_empty() {
308 out.push(
309 rows
310 .iter()
311 .map(|row| row.join(" "))
312 .collect::<Vec<_>>()
313 .join(" · "),
314 );
315 }
316 } else {
317 columns(&rows, &[], out);
318 }
319 },
320 "table" => table(node, out),
321 "tree" => tree(list(node, "nodes"), 0, out),
322 "list" => {
323 for child in &node.children {
324 out.push(format!("- {}", inline(child)));
325 }
326 if node.children.is_empty()
327 && let Some(empty) = text(node, "empty")
328 {
329 out.push(empty);
330 }
331 },
332 "item" => out.push(format!("- {}", inline(node))),
333 "picker" => {
334 if let Some(title) = text(node, "title") {
335 out.push(title);
336 }
337 for item in list(node, "items") {
338 let label = item.get("label").map(value_text).unwrap_or_default();
339 out.push(format!("- {label}"));
340 }
341 },
342 "checklist" => {
343 for phase in list(node, "phases") {
344 if let Some(title) = phase.get("title").map(value_text) {
345 out.push(title);
346 }
347 for item in phase
348 .get("items")
349 .and_then(Value::as_array)
350 .into_iter()
351 .flatten()
352 {
353 let done = match item.get("status").and_then(Value::as_str) {
354 Some("done") => "[x]",
355 Some("active") => "[>]",
356 Some("dropped") => "[-]",
357 Some("blocked") => "[!]",
358 _ => "[ ]",
359 };
360 let text = item.get("text").map(value_text).unwrap_or_default();
361 out.push(format!(" {done} {text}"));
362 }
363 }
364 },
365 "prefs" => prefs(node, out),
366 "tool" | "agent" => {
367 out.push(inline(node));
368 let mut inner = Vec::new();
369 node
370 .children
371 .iter()
372 .for_each(|c| block(c, cols, &mut inner));
373 indented(inner, out);
374 },
375 "el" => el_block(node, cols, out),
376 _ => {
377 let line = inline(node);
378 if !line.is_empty() {
379 push_text(&line, out);
380 }
381 node.children.iter().for_each(|c| block(c, cols, out));
382 },
383 }
384}
385
386fn inline<M>(node: &Node<M>) -> String {
388 if node.props.get("hidden") == Some(&Value::Bool(true)) {
389 return String::new();
390 }
391 let joined = |sep: &str| {
392 node
393 .children
394 .iter()
395 .map(inline)
396 .filter(|s| !s.is_empty())
397 .collect::<Vec<_>>()
398 .join(sep)
399 };
400 let parts: Vec<String> = match node.kind.as_str() {
401 "row" | "col" | "card" | "section" | "overlay" => {
402 let head = text(node, "head").unwrap_or_default();
403 return [head, joined(" ")]
404 .into_iter()
405 .filter(|s| !s.is_empty())
406 .collect::<Vec<_>>()
407 .join(" ");
408 },
409 "status" => return joined(" "),
410 "badge" => vec![
411 text(node, "text")
412 .map(|t| format!("[{t}]"))
413 .unwrap_or_default(),
414 ],
415 "kbd" => vec![
416 list(node, "keys")
417 .iter()
418 .map(value_text)
419 .collect::<Vec<_>>()
420 .join("+"),
421 ],
422 "icon" => vec![string(node, "aria").unwrap_or_default().to_owned()],
423 "image" => vec![format!("[image: {}]", string(node, "alt").unwrap_or("image"))],
424 "spinner" => vec![text(node, "label").unwrap_or_else(|| "...".into())],
425 "elapsed" => {
426 vec![duration(
427 number(node, "stopped")
428 .or_else(|| number(node, "age"))
429 .unwrap_or(0.0),
430 )]
431 },
432 "rate" => {
433 let v = number(node, "value").unwrap_or(0.0);
434 vec![format!("{v}"), string(node, "unit").unwrap_or_default().to_owned()]
435 },
436 "progress" | "meter" => {
437 let value = number(node, "value").or_else(|| {
438 let parts = list(node, "parts");
439 (!parts.is_empty()).then(|| parts.iter().filter_map(|p| p.get("value")?.as_f64()).sum())
440 });
441 vec![
442 value.map_or_else(|| "[..........]".to_owned(), bar),
443 text(node, "label").unwrap_or_default(),
444 text(node, "total").unwrap_or_default(),
445 ]
446 },
447 "chart" => vec![text(node, "summary").unwrap_or_default()],
448 "effort" => vec![string(node, "level").unwrap_or_default().to_owned()],
449 "item" => vec![
450 text(node, "label").unwrap_or_default(),
451 text(node, "detail").unwrap_or_default(),
452 text(node, "value").unwrap_or_default(),
453 ],
454 "tabs" => {
455 let active = string(node, "active");
456 vec![
457 list(node, "items")
458 .iter()
459 .map(|t| {
460 let label = t.get("label").map(value_text).unwrap_or_default();
461 if t.get("id").and_then(Value::as_str) == active {
462 format!("[{label}]")
463 } else {
464 label
465 }
466 })
467 .collect::<Vec<_>>()
468 .join(" "),
469 ]
470 },
471 "seg" => vec![primary(node).unwrap_or_default(), joined(" ")],
472 "toast" => {
473 vec![text(node, "text").unwrap_or_default(), text(node, "sub").unwrap_or_default()]
474 },
475 "tool" => {
476 let mut parts = vec![
477 text(node, "title")
478 .or_else(|| text(node, "name"))
479 .unwrap_or_default(),
480 text(node, "target").unwrap_or_default(),
481 ];
482 parts.extend(list(node, "meta").iter().map(value_text));
483 parts.push(
484 string(node, "status")
485 .map(|s| format!("({s})"))
486 .unwrap_or_default(),
487 );
488 parts
489 },
490 "agent" => vec![
491 text(node, "name").unwrap_or_default(),
492 text(node, "task").unwrap_or_default(),
493 string(node, "status")
494 .map(|s| format!("({s})"))
495 .unwrap_or_default(),
496 ],
497 "el" => return el_inline(node),
498 _ => vec![primary(node).unwrap_or_default(), joined(" ")],
499 };
500 parts
501 .into_iter()
502 .filter(|s| !s.is_empty())
503 .collect::<Vec<_>>()
504 .join(" ")
505}
506
507fn table<M>(node: &Node<M>, out: &mut Vec<String>) {
509 let cols: Vec<&Map<String, Value>> = list(node, "cols")
510 .iter()
511 .filter_map(Value::as_object)
512 .collect();
513 let right: Vec<bool> = cols
514 .iter()
515 .map(|c| c.get("align").and_then(Value::as_str) == Some("end"))
516 .collect();
517 let mut rows = Vec::new();
518 if cols
519 .iter()
520 .any(|c| c.get("head").is_some_and(|h| !value_text(h).is_empty()))
521 {
522 rows.push(
523 cols
524 .iter()
525 .map(|c| c.get("head").map(value_text).unwrap_or_default())
526 .collect(),
527 );
528 }
529 for row in list(node, "rows") {
530 let cells = row.get("cells");
531 rows.push(
532 cols
533 .iter()
534 .map(|c| {
535 let id = c.get("id").and_then(Value::as_str).unwrap_or_default();
536 match cells.and_then(|cells| cells.get(id)) {
537 Some(Value::Object(cell)) => cell
538 .get("meter")
539 .and_then(|meter| meter.get("value"))
540 .and_then(Value::as_f64)
541 .map(bar)
542 .unwrap_or_default(),
543 Some(cell) => value_text(cell),
544 None => String::new(),
545 }
546 })
547 .collect(),
548 );
549 }
550 columns(&rows, &right, out);
551}
552
553fn tree(items: &[Value], depth: usize, out: &mut Vec<String>) {
555 for item in items {
556 let label = item.get("label").map(value_text).unwrap_or_default();
557 out.push(format!("{}- {label}", " ".repeat(depth)));
558 if let Some(Value::Array(children)) = item.get("children") {
559 tree(children, depth + 1, out);
560 }
561 }
562}
563
564fn prefs<M>(node: &Node<M>, out: &mut Vec<String>) {
566 out.push(string(node, "title").unwrap_or("Settings").to_owned());
567 for section in list(node, "sections") {
568 if let Some(title) = section.get("title").and_then(Value::as_str) {
569 out.push(format!(" {title}"));
570 }
571 for row in section
572 .get("rows")
573 .and_then(Value::as_array)
574 .into_iter()
575 .flatten()
576 {
577 let id = row.get("id").and_then(Value::as_str).unwrap_or_default();
578 let label = row.get("label").and_then(Value::as_str).unwrap_or(id);
579 let value = row.get("control").map_or_else(String::new, |c| {
580 ["on", "value", "values", "label"]
581 .iter()
582 .find_map(|k| c.get(*k))
583 .map(|v| match v {
584 Value::Array(vs) => vs.iter().map(value_text).collect::<Vec<_>>().join(", "),
585 Value::Bool(b) => if *b { "on" } else { "off" }.to_owned(),
586 other => value_text(other),
587 })
588 .unwrap_or_default()
589 });
590 out.push(format!(" {label}: {value}"));
591 }
592 }
593}
594
595fn tag<M>(node: &Node<M>) -> &str {
597 string(node, "tag").unwrap_or("div")
598}
599
600fn is_block<M>(node: &Node<M>) -> bool {
602 node.kind != "el" || BLOCK_TAGS.contains(&tag(node))
603}
604
605fn el_inline<M>(node: &Node<M>) -> String {
607 match tag(node) {
608 "input" => {
609 let on = node.props.get("checked") == Some(&Value::Bool(true));
610 let radio = string(node, "type") == Some("radio");
611 return match (radio, on) {
612 (true, true) => "(*)",
613 (true, false) => "( )",
614 (false, true) => "[x]",
615 (false, false) => "[ ]",
616 }
617 .to_owned();
618 },
619 "hr" => return "---".to_owned(),
620 _ => {},
621 }
622 let mut parts = Vec::new();
623 if let Some(t) = text(node, "text") {
624 parts.push(t);
625 }
626 parts.extend(node.children.iter().map(inline).filter(|s| !s.is_empty()));
627 let body = parts.join(" ");
628 if tag(node) == "button" {
629 format!("[ {body} ]")
630 } else {
631 body
632 }
633}
634
635fn el_block<M>(node: &Node<M>, cols: usize, out: &mut Vec<String>) {
637 if node.props.get("hidden") == Some(&Value::Bool(true)) {
638 return;
639 }
640 match tag(node) {
641 "table" | "thead" | "tbody" => {
642 let mut rows = Vec::new();
643 collect_rows(node, &mut rows);
644 columns(&rows, &[], out);
645 return;
646 },
647 "tr" => {
648 out.push(
649 node
650 .children
651 .iter()
652 .map(inline)
653 .collect::<Vec<_>>()
654 .join(" "),
655 );
656 return;
657 },
658 "hr" => {
659 out.push("-".repeat(cols.clamp(4, 80)));
660 return;
661 },
662 "label" | "button" => {
663 out.push(el_inline(node));
664 return;
665 },
666 _ => {},
667 }
668 let prefix = if tag(node) == "li" { "- " } else { "" };
669 let mut inner = Vec::new();
670 let mut line = text(node, "text").unwrap_or_default();
671 for child in &node.children {
672 if is_block(child) {
673 if !line.is_empty() {
674 inner.push(std::mem::take(&mut line));
675 }
676 if child.kind == "el" {
677 el_block(child, cols, &mut inner);
678 } else {
679 block(child, cols, &mut inner);
680 }
681 } else {
682 let part = inline(child);
683 if !part.is_empty() {
684 if !line.is_empty() && !line.ends_with(' ') {
685 line.push(' ');
686 }
687 line.push_str(&part);
688 }
689 }
690 }
691 if !line.is_empty() {
692 inner.push(line);
693 }
694 if matches!(tag(node), "ul" | "ol" | "blockquote" | "dd") {
695 indented(inner, out);
696 } else {
697 for (i, l) in inner.into_iter().enumerate() {
698 out.push(if i == 0 { format!("{prefix}{l}") } else { l });
699 }
700 }
701}
702
703fn collect_rows<M>(node: &Node<M>, rows: &mut Vec<Vec<String>>) {
705 for child in &node.children {
706 match (child.kind.as_str(), tag(child)) {
707 ("el", "tr") => rows.push(child.children.iter().map(inline).collect()),
708 ("el", "thead" | "tbody") => collect_rows(child, rows),
709 _ => {},
710 }
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::ui::{self, Column, TableRow, TextAlign, html};
718
719 #[test]
720 fn cards_tables_and_lists_read_as_text() {
721 let view: View = View::new().main(crate::nodes![
722 ui::card()
723 .head("Build")
724 .status(ui::Status::Done)
725 .child(ui::text("ok")),
726 ui::table()
727 .col(Column::new("n", "Name"))
728 .col(Column::new("s", "Size").align(TextAlign::End))
729 .row(TableRow::new("a").cell("n", "a.rs").cell("s", "12"))
730 .row(TableRow::new("b").cell("n", "build.rs").cell("s", "3")),
731 ui::list()
732 .child(ui::item("one"))
733 .child(ui::item("two").value("2")),
734 ]);
735 assert_eq!(
736 plain(view, 40),
737 "Build (done)\n ok\nName Size\na.rs 12\nbuild.rs 3\n- one\n- two 2\n"
738 );
739 }
740
741 #[test]
742 fn headed_statuses_use_parentheses() {
743 for kind in ["card", "section", "overlay"] {
744 assert_eq!(
745 plain(
746 ui::node(kind)
747 .prop("head", "cargo test")
748 .prop("status", "done"),
749 80
750 ),
751 "cargo test (done)\n"
752 );
753 assert_eq!(plain(ui::node(kind).prop("status", "done"), 80), "(done)\n");
754 }
755 for kind in ["tool", "agent"] {
756 assert_eq!(
757 plain(
758 ui::node(kind)
759 .prop("name", "cargo test")
760 .prop("status", "done"),
761 80
762 ),
763 "cargo test (done)\n"
764 );
765 }
766 }
767
768 #[test]
769 fn el_blocks_break_lines_and_ansi_loses_escapes() {
770 let view: View = View::new()
771 .main([html::form()
772 .child(html::p("Which size?"))
773 .child(
774 html::label()
775 .child(html::radio("size", "s").checked(true))
776 .child(html::span("Small")),
777 )
778 .child(html::button("Create"))])
779 .dock([ui::ansi("\x1b[1;32mok\x1b[0m done\r\n")]);
780 assert_eq!(plain(view, 40), "Which size?\n(*) Small\n[ Create ]\nok done\n");
781 }
782
783 #[test]
784 fn bare_builder_infers_a_message_free_view() {
785 assert_eq!(plain(ui::text("hello"), 80), "hello\n");
786 }
787
788 #[test]
789 fn meters_inline_pairs_and_blocked_items_read_as_text() {
790 let view = View::new().main(crate::nodes![
791 ui::table().col(Column::new("usage", "Usage")).row(
792 TableRow::new("a")
793 .cell("usage", ui::MeterCell { value: Some(0.5), ..ui::MeterCell::default() })
794 ),
795 ui::kv()
796 .layout(ui::KvLayout::Inline)
797 .item("branch", "main")
798 .item("ahead", "2"),
799 ui::checklist().prop(
800 "phases",
801 serde_json::json!([{"items": [
802 {"text": "waiting", "status": "pending"},
803 {"text": "working", "status": "active"},
804 {"text": "stuck", "status": "blocked"},
805 {"text": "finished", "status": "done"},
806 {"text": "removed", "status": "dropped"}
807 ]}])
808 ),
809 ]);
810 assert_eq!(
811 plain(view, 80),
812 "Usage\n[#####-----] 50%\nbranch main · ahead 2\n [ ] waiting\n [>] working\n [!] \
813 stuck\n [x] finished\n [-] removed\n"
814 );
815 }
816
817 #[test]
818 fn bars_clamp_and_round() {
819 assert_eq!(bar(-1.0), "[----------] 0%");
820 assert_eq!(bar(0.44), "[####------] 44%");
821 assert_eq!(bar(2.0), "[##########] 100%");
822 }
823}