1use serde_json::{Map, Value};
12
13use crate::ui::{Node, View, node::spans_text};
14
15const BAR: usize = 10;
17
18const SLIDER_UNITS: [&str; 9] = ["%", "‰", "°", "×", "x", "′", "″", "'", "\""];
20
21const CURRENCY: [&str; 4] = ["$", "€", "£", "¥"];
23
24const BLOCK_TAGS: [&str; 30] = [
26 "div",
27 "p",
28 "section",
29 "header",
30 "footer",
31 "nav",
32 "aside",
33 "main",
34 "article",
35 "figure",
36 "blockquote",
37 "ul",
38 "ol",
39 "li",
40 "dl",
41 "dt",
42 "dd",
43 "h1",
44 "h2",
45 "h3",
46 "h4",
47 "pre",
48 "hr",
49 "table",
50 "thead",
51 "tbody",
52 "tr",
53 "form",
54 "label",
55 "button",
56];
57
58pub fn plain(view: impl Into<View>, cols: usize) -> String {
70 view.into().plain(cols)
71}
72
73impl<M> View<M> {
74 pub fn plain(&self, cols: usize) -> String {
77 let mut lines = Vec::new();
78 for node in self.regions().into_iter().flatten() {
79 block(node, cols, &mut lines);
80 }
81 let mut out = String::new();
82 for line in lines {
83 out.push_str(line.trim_end());
84 out.push('\n');
85 }
86 out
87 }
88}
89
90const SPARKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
92
93fn sparkline(values: &[f64], min: Option<f64>, max: Option<f64>) -> String {
96 let lo = min.unwrap_or_else(|| values.iter().copied().fold(f64::INFINITY, f64::min));
97 let hi = max.unwrap_or_else(|| values.iter().copied().fold(f64::NEG_INFINITY, f64::max));
98 let span = hi - lo;
99 let top = (SPARKS.len() - 1) as f64;
100 values
101 .iter()
102 .map(|v| {
103 let at = if span > 0.0 {
104 ((v - lo) / span * top).round()
105 } else {
106 0.0
107 };
108 SPARKS[at.clamp(0.0, top) as usize]
109 })
110 .collect()
111}
112
113fn text<M>(node: &Node<M>, key: &str) -> Option<String> {
115 node.text_prop(key).filter(|t| !t.is_empty())
116}
117
118fn string<'a, M>(node: &'a Node<M>, key: &str) -> Option<&'a str> {
120 node.props.get(key).and_then(Value::as_str)
121}
122
123fn number<M>(node: &Node<M>, key: &str) -> Option<f64> {
125 node.props.get(key).and_then(Value::as_f64)
126}
127
128fn value_text(value: &Value) -> String {
130 match value {
131 Value::String(s) => s.clone(),
132 Value::Array(spans) => spans_text(spans),
133 Value::Number(n) => n.to_string(),
134 Value::Bool(b) => b.to_string(),
135 _ => String::new(),
136 }
137}
138
139fn list<'a, M>(node: &'a Node<M>, key: &str) -> &'a [Value] {
141 node
142 .props
143 .get(key)
144 .and_then(Value::as_array)
145 .map_or_default(Vec::as_slice)
146}
147
148fn primary<M>(node: &Node<M>) -> Option<String> {
151 match node.props.get("spans") {
152 Some(Value::Array(spans)) => Some(cited_text(spans)),
153 _ => text(node, "text"),
154 }
155}
156
157fn cites(span: &Value) -> Vec<&Map<String, Value>> {
159 let all: Vec<&Value> = match span.get("cite") {
160 Some(Value::Array(list)) => list.iter().collect(),
161 Some(one) => vec![one],
162 None => Vec::new(),
163 };
164 all.into_iter()
165 .filter_map(Value::as_object)
166 .filter(|c| c.get("href").is_some_and(Value::is_string))
167 .collect()
168}
169
170fn cited_text(spans: &[Value]) -> String {
173 use std::fmt::Write as _;
174 fn href(c: &Map<String, Value>) -> &str {
175 c.get("href").and_then(Value::as_str).unwrap_or_default()
176 }
177 let mut numbers: Vec<(String, u64)> = Vec::new();
178 for c in spans.iter().flat_map(cites) {
179 if let Some(n) = c.get("n").and_then(Value::as_f64).filter(|n| *n >= 1.0)
180 && !numbers.iter().any(|(h, _)| h == href(c))
181 {
182 numbers.push((href(c).to_owned(), n.floor() as u64));
183 }
184 }
185 let mut taken: Vec<u64> = numbers.iter().map(|(_, n)| *n).collect();
186 let mut next = 1;
187 let mut out = String::new();
188 for span in spans {
189 out.push_str(&spans_text(std::slice::from_ref(span)));
190 let mut marks: Vec<u64> = Vec::new();
191 for c in cites(span) {
192 let n = if let Some((_, n)) = numbers.iter().find(|(h, _)| h == href(c)) {
193 *n
194 } else {
195 while taken.contains(&next) {
196 next += 1;
197 }
198 taken.push(next);
199 numbers.push((href(c).to_owned(), next));
200 next
201 };
202 if !marks.contains(&n) {
203 marks.push(n);
204 }
205 }
206 for n in marks {
207 let _ = write!(out, "[{n}]");
208 }
209 }
210 out
211}
212
213fn num_text(value: &Value) -> String {
215 value
216 .as_f64()
217 .map_or_else(|| value_text(value), |n| n.to_string())
218}
219
220fn with_unit(value: &str, unit: &str, attached: bool) -> String {
222 if unit.is_empty() {
223 value.to_owned()
224 } else if attached {
225 format!("{value}{unit}")
226 } else {
227 format!("{value} {unit}")
228 }
229}
230
231fn labeled(label: Option<String>, shown: String) -> String {
233 match label {
234 Some(label) => format!("{label}: {shown}"),
235 None => shown,
236 }
237}
238
239fn date_text(value: &Value) -> String {
242 match value {
243 Value::String(day) => day.clone(),
244 Value::Array(ends) => {
245 let from = ends.first().and_then(Value::as_str).unwrap_or_default();
246 match ends.get(1).and_then(Value::as_str) {
247 Some(to) => format!("{from} – {to}"),
248 None => from.to_owned(),
249 }
250 },
251 _ => String::new(),
252 }
253}
254
255fn chart_values(entries: &[Value]) -> Vec<f64> {
257 entries
258 .iter()
259 .filter_map(|e| e.get("value").unwrap_or(e).as_f64())
260 .collect()
261}
262
263fn chart<M>(node: &Node<M>) -> Vec<String> {
266 if let Some(summary) = text(node, "summary") {
267 return vec![summary];
268 }
269 let series = list(node, "series");
270 match string(node, "kind") {
271 Some("line" | "area") => {
272 let several: Vec<&Value> = series
273 .iter()
274 .filter(|s| s.get("values").is_some_and(Value::is_array))
275 .collect();
276 if several.is_empty() {
277 return vec![sparkline(&chart_values(series), None, None)];
278 }
279 several
280 .into_iter()
281 .map(|s| {
282 let values = s
283 .get("values")
284 .and_then(Value::as_array)
285 .map_or_default(Vec::as_slice);
286 let spark = sparkline(&chart_values(values), None, None);
287 match s.get("label").map(value_text) {
288 Some(label) if !label.is_empty() => format!("{label} {spark}"),
289 _ => spark,
290 }
291 })
292 .collect()
293 },
294 Some("donut") => {
295 let unit = string(node, "unit").unwrap_or_default();
296 let attached = unit.starts_with(['%', '×', '°']);
297 vec![
298 series
299 .iter()
300 .filter_map(|entry| {
301 let value = entry.get("value").unwrap_or(entry).as_f64()?;
302 let shown = if CURRENCY.contains(&unit) {
303 let sign = if value < 0.0 { "−" } else { "" };
306 format!("{sign}{unit}{}", value.abs())
307 } else {
308 with_unit(&value.to_string(), unit, attached)
309 };
310 Some(match entry.get("label").map(value_text) {
311 Some(label) if !label.is_empty() => format!("{label} {shown}"),
312 _ => shown,
313 })
314 })
315 .collect::<Vec<_>>()
316 .join(" "),
317 ]
318 },
319 _ => Vec::new(),
320 }
321}
322
323fn bar(value: f64) -> String {
325 let v = value.clamp(0.0, 1.0);
326 let filled = (v * BAR as f64).round() as usize;
327 format!("[{}{}] {:.0}%", "#".repeat(filled), "-".repeat(BAR - filled), v * 100.0)
328}
329
330fn duration(ms: f64) -> String {
332 let s = (ms.abs() / 1000.0).floor() as u64;
333 let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
334 if h > 0 {
335 format!("{h}h {m}m")
336 } else if m > 0 {
337 format!("{m}m {s}s")
338 } else {
339 format!("{s}s")
340 }
341}
342
343fn strip(text: &str) -> String {
346 let mut out = String::with_capacity(text.len());
347 let mut chars = text.chars().peekable();
348 while let Some(c) = chars.next() {
349 match c {
350 '\x1b' => match chars.next() {
351 Some('[') => {
352 for c in chars.by_ref() {
353 if ('\x40'..='\x7e').contains(&c) {
354 break;
355 }
356 }
357 },
358 Some(']' | '_' | 'P' | '^') => {
359 while let Some(c) = chars.next() {
360 if c == '\x07' || (c == '\x1b' && chars.next_if_eq(&'\\').is_some()) {
361 break;
362 }
363 }
364 },
365 _ => {},
366 },
367 '\n' | '\t' => out.push(c),
368 '\r' => {},
369 c if c.is_control() => {},
370 c => out.push(c),
371 }
372 }
373 out
374}
375
376fn push_text(text: &str, out: &mut Vec<String>) {
378 out.extend(
379 text
380 .strip_suffix('\n')
381 .unwrap_or(text)
382 .split('\n')
383 .map(str::to_owned),
384 );
385}
386
387fn columns(rows: &[Vec<String>], right: &[bool], out: &mut Vec<String>) {
390 let n = rows.iter().map(Vec::len).max().unwrap_or(0);
391 let mut widths = vec![0; n];
392 for row in rows {
393 for (w, cell) in widths.iter_mut().zip(row) {
394 *w = (*w).max(cell.chars().count());
395 }
396 }
397 for row in rows {
398 let mut line = String::new();
399 for (i, cell) in row.iter().enumerate() {
400 if i > 0 {
401 line.push_str(" ");
402 }
403 let pad = widths[i] - cell.chars().count();
404 if right.get(i).copied().unwrap_or(false) {
405 line.push_str(&" ".repeat(pad));
406 line.push_str(cell);
407 } else {
408 line.push_str(cell);
409 line.push_str(&" ".repeat(pad));
410 }
411 }
412 out.push(line);
413 }
414}
415
416fn indented(inner: Vec<String>, out: &mut Vec<String>) {
418 out.extend(
419 inner
420 .into_iter()
421 .map(|l| if l.is_empty() { l } else { format!(" {l}") }),
422 );
423}
424
425fn headed(head: String, body: Vec<String>, out: &mut Vec<String>) {
427 if head.is_empty() {
428 out.extend(body);
429 } else {
430 out.push(head);
431 indented(body, out);
432 }
433}
434
435fn block<M>(node: &Node<M>, cols: usize, out: &mut Vec<String>) {
437 if node.props.get("hidden") == Some(&Value::Bool(true)) {
438 return;
439 }
440 match node.kind.as_str() {
441 "col" => node.children.iter().for_each(|c| block(c, cols, out)),
442 "status" => out.push(inline(node)),
443 "row" => {
444 let line = inline(node);
445 if !line.is_empty() {
446 out.push(line);
447 }
448 },
449 "card" | "section" | "overlay" => {
450 let mut head = panel_head(node);
451 if let Some(status) = string(node, "status") {
452 head = if head.is_empty() {
453 format!("({status})")
454 } else {
455 format!("{head} ({status})")
456 };
457 }
458 let mut inner = Vec::new();
459 node
460 .children
461 .iter()
462 .filter(|c| slot_of(node, c).is_none())
463 .for_each(|c| block(c, cols, &mut inner));
464 if let Some(foot) = slot(node, "foot") {
465 block(foot, cols, &mut inner);
466 }
467 if head.is_empty() {
468 out.extend(inner);
469 } else {
470 out.push(head);
471 indented(inner, out);
472 }
473 },
474 "rule" => {
475 let width = cols.clamp(4, 80);
476 let label = [text(node, "label"), text(node, "time")]
477 .into_iter()
478 .flatten()
479 .collect::<Vec<_>>()
480 .join(" · ");
481 if label.is_empty() {
482 out.push("-".repeat(width));
483 } else {
484 let side = width.saturating_sub(label.chars().count() + 2) / 2;
485 out.push(format!("{} {label} {}", "-".repeat(side), "-".repeat(side)));
486 }
487 },
488 "spacer" => out.push(String::new()),
489 "ansi" => push_text(&strip(&text(node, "text").unwrap_or_default()), out),
490 "rows" => {
491 for line in list(node, "lines") {
492 if let Some(line) = line.as_str() {
493 out.push(strip(line));
494 }
495 }
496 },
497 "diff" => match node.props.get("hunks") {
498 Some(Value::Array(hunks)) => {
499 for hunk in hunks {
500 for line in hunk
501 .get("lines")
502 .and_then(Value::as_array)
503 .into_iter()
504 .flatten()
505 {
506 out.push(line.as_str().unwrap_or_default().to_owned());
507 }
508 }
509 },
510 _ => push_text(&text(node, "text").unwrap_or_default(), out),
511 },
512 "editor" | "input" => {
513 let prompt = text(node, "prompt").unwrap_or_default();
514 let secret = node.props.get("secret") == Some(&Value::Bool(true));
515 let body = text(node, "text")
516 .map(|t| {
517 if secret {
518 "•".repeat(t.encode_utf16().count())
519 } else {
520 t
521 }
522 })
523 .or_else(|| text(node, "placeholder"))
524 .unwrap_or_default();
525 push_text(&format!("{prompt}{body}"), out);
526 },
527 "chart" => out.extend(chart(node)),
528 "kv" => {
529 let rows: Vec<Vec<String>> = list(node, "items")
530 .iter()
531 .map(|i| {
532 vec![i.get("k").map_or_default(value_text), i.get("v").map_or_default(value_text)]
533 })
534 .collect();
535 if string(node, "layout") == Some("inline") {
536 if !rows.is_empty() {
537 out.push(
538 rows
539 .iter()
540 .map(|row| row.join(" "))
541 .collect::<Vec<_>>()
542 .join(" · "),
543 );
544 }
545 } else {
546 columns(&rows, &[], out);
547 }
548 },
549 "table" => table(node, out),
550 "tree" => tree(list(node, "nodes"), 0, out),
551 "json" => {
552 let value = node.props.get("value").unwrap_or(&Value::Null);
553 if let Ok(text) = serde_json::to_string_pretty(value) {
554 out.extend(text.lines().map(str::to_owned));
555 }
556 },
557 "list" => {
558 for child in &node.children {
559 out.push(format!("- {}", inline(child)));
560 }
561 if node.children.is_empty()
562 && let Some(empty) = text(node, "empty")
563 {
564 out.push(empty);
565 }
566 },
567 "item" => out.push(format!("- {}", inline(node))),
568 "picker" => {
569 if let Some(title) = text(node, "title") {
570 out.push(title);
571 }
572 for item in list(node, "items") {
573 let label = item.get("label").map_or_default(value_text);
574 out.push(format!("- {label}"));
575 }
576 },
577 "checklist" => {
578 for phase in list(node, "phases") {
579 if let Some(title) = phase.get("title").map(value_text) {
580 out.push(title);
581 }
582 for item in phase
583 .get("items")
584 .and_then(Value::as_array)
585 .into_iter()
586 .flatten()
587 {
588 let done = match item.get("status").and_then(Value::as_str) {
589 Some("done") => "[x]",
590 Some("active") => "[>]",
591 Some("dropped") => "[-]",
592 Some("blocked") => "[!]",
593 _ => "[ ]",
594 };
595 let text = item.get("text").map_or_default(value_text);
596 out.push(format!(" {done} {text}"));
597 }
598 }
599 },
600 "prefs" => prefs(node, out),
601 "tool" | "agent" => {
602 out.push(inline(node));
603 let mut inner = Vec::new();
604 node
605 .children
606 .iter()
607 .for_each(|c| block(c, cols, &mut inner));
608 indented(inner, out);
609 },
610 "select" => {
611 let shown = match node.props.get("value") {
612 None => text(node, "placeholder").unwrap_or_default(),
613 Some(value) => list(node, "options")
614 .iter()
615 .find(|o| o.get("value") == Some(value))
616 .and_then(|o| o.get("label").map(value_text))
617 .filter(|l| !l.is_empty())
618 .unwrap_or_else(|| value_text(value)),
619 };
620 out.push(labeled(text(node, "label"), shown));
621 },
622 "switch" => {
623 let on = node.props.get("on") == Some(&Value::Bool(true));
624 let label = text(node, "label").unwrap_or_default();
625 out.push(format!("[{}] {label}", if on { 'x' } else { ' ' }));
626 },
627 "slider" => {
628 let label = text(node, "label");
629 let shown = match node.props.get("value") {
630 None => None,
631 Some(Value::Array(range)) => Some(
632 range
633 .iter()
634 .filter_map(Value::as_f64)
635 .map(|v| v.to_string())
636 .collect::<Vec<_>>()
637 .join("–"),
638 ),
639 Some(value) => Some(num_text(value)),
640 };
641 out.push(match shown {
642 Some(shown) => {
643 let unit = string(node, "unit").unwrap_or_default();
644 labeled(label, with_unit(&shown, unit, SLIDER_UNITS.contains(&unit)))
645 },
646 None => label.unwrap_or_default(),
647 });
648 },
649 "field" => {
650 let mut head = text(node, "label").unwrap_or_default();
651 if !head.is_empty() && node.props.get("required") == Some(&Value::Bool(true)) {
652 head.push_str(" *");
653 }
654 let flagged = ["error", "warning"].iter().find_map(|k| {
655 node
656 .props
657 .get(*k)
658 .filter(|v| !matches!(v, Value::Null | Value::Bool(false)) && v.as_str() != Some(""))
659 });
660 let note = match flagged {
661 None => text(node, "hint").unwrap_or_default(),
662 Some(Value::Bool(true)) => String::new(),
663 Some(flag) => value_text(flag),
664 };
665 if !head.is_empty() {
666 out.push(head);
667 }
668 node.children.iter().for_each(|c| block(c, cols, out));
669 if !note.is_empty() {
670 out.push(note);
671 }
672 },
673 "otp" => {
674 let length = number(node, "length").unwrap_or(6.0).clamp(1.0, 12.0) as usize;
675 let typed: Vec<char> = string(node, "text")
676 .unwrap_or_default()
677 .chars()
678 .take(length)
679 .collect();
680 let dot = match node.props.get("mask") {
681 Some(Value::Bool(true)) => Some('•'),
682 Some(Value::String(mask)) => mask.chars().next(),
683 _ => None,
684 };
685 let mut code: String = typed.iter().map(|c| dot.unwrap_or(*c)).collect();
686 code.extend(std::iter::repeat_n('_', length - typed.len()));
687 out.push(code);
688 },
689 "buttons" => out.push(
690 node
691 .children
692 .iter()
693 .map(inline)
694 .filter(|s| !s.is_empty())
695 .collect::<Vec<_>>()
696 .join(" "),
697 ),
698 "avatar" => out.push(text(node, "text").unwrap_or_default()),
699 "avatars" => {
700 let names = list(node, "items")
701 .iter()
702 .filter_map(|i| i.get("text").map(value_text))
703 .filter(|t| !t.is_empty())
704 .collect::<Vec<_>>()
705 .join(", ");
706 out.push(
707 [names, text(node, "label").unwrap_or_default()]
708 .into_iter()
709 .filter(|s| !s.is_empty())
710 .collect::<Vec<_>>()
711 .join(" "),
712 );
713 },
714 "breadcrumb" => {
715 let sep = if string(node, "variant") == Some("path") {
716 " / "
717 } else {
718 " › "
719 };
720 out.push(
721 list(node, "items")
722 .iter()
723 .filter_map(|crumb| {
724 let id = crumb
725 .get("id")
726 .filter(|id| id.is_string() || id.is_number())?;
727 Some(
728 crumb
729 .get("label")
730 .map(value_text)
731 .filter(|l| !l.is_empty())
732 .unwrap_or_else(|| value_text(id)),
733 )
734 })
735 .collect::<Vec<_>>()
736 .join(sep),
737 );
738 },
739 "pager" => {
740 let pages = number(node, "pages").filter(|p| *p != 0.0).or_else(|| {
741 let total = number(node, "total")?;
742 let size = number(node, "size").filter(|s| *s != 0.0)?;
743 Some((total / size).ceil().max(1.0))
744 });
745 let page = number(node, "page")
746 .unwrap_or(1.0)
747 .min(pages.unwrap_or(f64::INFINITY))
748 .max(1.0);
749 out.push(match pages {
750 Some(pages) => format!("Page {page} of {pages}"),
751 None => format!("Page {page}"),
752 });
753 },
754 "empty" => {
755 let mut lines: Vec<String> = [text(node, "title"), text(node, "text")]
756 .into_iter()
757 .flatten()
758 .collect();
759 node
760 .children
761 .iter()
762 .for_each(|c| block(c, cols, &mut lines));
763 out.extend(lines.into_iter().filter(|l| !l.is_empty()));
764 },
765 "skeleton" => {},
766 "calendar" | "date" => match node.props.get("value") {
767 Some(value) => out.push(date_text(value)),
768 None => {
769 if node.kind == "date"
770 && let Some(placeholder) = text(node, "placeholder")
771 {
772 out.push(placeholder);
773 }
774 },
775 },
776 "menu" => out.push(format!(
777 "[{}]",
778 text(node, "text")
779 .or_else(|| text(node, "title"))
780 .unwrap_or_default()
781 )),
782 "menubar" => out.push(
783 list(node, "menus")
784 .iter()
785 .filter_map(|menu| {
786 let id = menu.get("id")?.as_str()?;
787 Some(
788 menu
789 .get("label")
790 .map(value_text)
791 .filter(|l| !l.is_empty())
792 .unwrap_or_else(|| id.to_owned()),
793 )
794 })
795 .collect::<Vec<_>>()
796 .join(" "),
797 ),
798 "nav" => {
799 let active = node.props.get("active");
800 out.push(
801 list(node, "items")
802 .iter()
803 .filter_map(|item| {
804 let id = item.get("id").filter(|id| id.is_string())?;
805 let label = item
806 .get("label")
807 .map(value_text)
808 .filter(|l| !l.is_empty())
809 .unwrap_or_else(|| value_text(id));
810 let on = active == Some(id)
811 || item
812 .get("items")
813 .and_then(Value::as_array)
814 .is_some_and(|subs| subs.iter().any(|s| s.get("id") == active));
815 Some(if on { format!("[{label}]") } else { label })
816 })
817 .collect::<Vec<_>>()
818 .join(" "),
819 );
820 },
821 "split" | "scroll" => node.children.iter().for_each(|c| block(c, cols, out)),
822 "carousel" => {
823 if let Some(last) = node.children.len().checked_sub(1) {
824 let at = number(node, "index").unwrap_or(0.0).clamp(0.0, last as f64) as usize;
825 block(&node.children[at], cols, out);
826 }
827 },
828 "message" => {
829 let head = [text(node, "author"), text(node, "time")]
830 .into_iter()
831 .flatten()
832 .collect::<Vec<_>>()
833 .join(" ");
834 let mut rest = Vec::new();
835 node
836 .children
837 .iter()
838 .filter(|c| slot_of(node, c).is_none())
839 .for_each(|c| block(c, cols, &mut rest));
840 let reactions = list(node, "reactions")
841 .iter()
842 .filter_map(|r| {
843 let text = r.get("text").map(value_text).filter(|t| !t.is_empty())?;
844 Some(match r.get("count").and_then(Value::as_f64) {
845 Some(count) if count > 0.0 => format!("{text} {count}"),
846 _ => text,
847 })
848 })
849 .collect::<Vec<_>>()
850 .join(" ");
851 if !reactions.is_empty() {
852 rest.push(reactions);
853 }
854 if let Some(status) = text(node, "status") {
855 rest.push(status);
856 }
857 if !head.is_empty() {
858 out.push(head);
859 }
860 indented(rest, out);
861 },
862 "attachment" => {
863 out.push(format!("[attachment: {}]", string(node, "name").unwrap_or_default()));
864 },
865 "trace" => {
866 let status = string(node, "status").map_or_default(|s| format!("({s})"));
867 let title = text(node, "title")
868 .or_else(|| text(node, "summary"))
869 .unwrap_or_default();
870 let head = [title, status]
871 .into_iter()
872 .filter(|s| !s.is_empty())
873 .collect::<Vec<_>>()
874 .join(" ");
875 if !head.is_empty() {
876 out.push(head);
877 }
878 let key = |c: &Node<M>| c.props.get("key").map(value_text);
879 let mut ids = Vec::new();
880 for (i, line) in list(node, "lines").iter().enumerate() {
881 let id = line
882 .get("id")
883 .map(value_text)
884 .filter(|id| !id.is_empty())
885 .unwrap_or_else(|| i.to_string());
886 let words = [line.get("title"), line.get("target")]
887 .into_iter()
888 .flatten()
889 .map(value_text)
890 .filter(|s| !s.is_empty())
891 .collect::<Vec<_>>()
892 .join(" ");
893 out.push(match line.get("status").and_then(Value::as_str) {
894 Some(state) => format!("- {words} ({state})"),
895 None => format!("- {words}"),
896 });
897 if let Some(content) = node.children.iter().find(|c| key(c).as_ref() == Some(&id)) {
898 let mut under = Vec::new();
899 block(content, cols, &mut under);
900 out.extend(
901 under
902 .into_iter()
903 .map(|l| if l.is_empty() { l } else { format!(" {l}") }),
904 );
905 }
906 ids.push(id);
907 }
908 node
909 .children
910 .iter()
911 .filter(|c| !key(c).is_some_and(|k| ids.contains(&k)))
912 .for_each(|c| block(c, cols, out));
913 },
914 "stacktrace" => {
915 let error = node.props.get("error");
916 let head = ["type", "message"]
917 .iter()
918 .filter_map(|k| error?.get(*k).map(value_text))
919 .filter(|s| !s.is_empty())
920 .collect::<Vec<_>>()
921 .join(": ");
922 if !head.is_empty() {
923 out.push(head);
924 }
925 for frame in list(node, "frames") {
926 let func = frame
927 .get("fn")
928 .map(value_text)
929 .filter(|f| !f.is_empty())
930 .unwrap_or_else(|| "<anonymous>".to_owned());
931 let file = frame.get("file").map_or_default(value_text);
932 out.push(if file.is_empty() {
933 format!(" at {func}")
934 } else {
935 let at =
936 [Some(file), frame.get("line").map(num_text), frame.get("col").map(num_text)]
937 .into_iter()
938 .flatten()
939 .collect::<Vec<_>>()
940 .join(":");
941 format!(" at {func} ({at})")
942 });
943 }
944 },
945 "suggestions" => {
946 for item in list(node, "items") {
947 let text = item.get("text").map_or_default(value_text);
948 if !text.is_empty() {
949 out.push(format!("- {text}"));
950 }
951 }
952 },
953 "questions" => {
954 for item in list(node, "items") {
955 if !item.get("id").is_some_and(Value::is_string) {
956 continue;
957 }
958 let choices: Vec<String> = item
959 .get("choices")
960 .and_then(Value::as_array)
961 .map_or_default(Vec::as_slice)
962 .iter()
963 .map(|choice| {
964 let label = choice
965 .get("label")
966 .map(value_text)
967 .filter(|l| !l.is_empty())
968 .unwrap_or_else(|| {
969 choice
970 .get("value")
971 .map_or_else(|| value_text(choice), value_text)
972 });
973 format!("- {label}")
974 })
975 .collect();
976 headed(item.get("prompt").map_or_default(value_text), choices, out);
977 }
978 },
979 "el" => el_block(node, cols, out),
980 _ => {
981 let line = inline(node);
982 if !line.is_empty() {
983 push_text(&line, out);
984 }
985 node.children.iter().for_each(|c| block(c, cols, out));
986 },
987 }
988}
989
990fn inline<M>(node: &Node<M>) -> String {
992 if node.props.get("hidden") == Some(&Value::Bool(true)) {
993 return String::new();
994 }
995 let joined = |sep: &str| {
996 node
997 .children
998 .iter()
999 .map(inline)
1000 .filter(|s| !s.is_empty())
1001 .collect::<Vec<_>>()
1002 .join(sep)
1003 };
1004 let parts: Vec<String> = match node.kind.as_str() {
1005 "card" | "section" | "overlay" => {
1006 let body = node
1007 .children
1008 .iter()
1009 .filter(|c| slot_of(node, c).is_none())
1010 .map(inline)
1011 .filter(|s| !s.is_empty())
1012 .collect::<Vec<_>>()
1013 .join(" ");
1014 return [panel_head(node), body]
1015 .into_iter()
1016 .filter(|s| !s.is_empty())
1017 .collect::<Vec<_>>()
1018 .join(" ");
1019 },
1020 "row" | "col" => {
1021 let head = text(node, "head").unwrap_or_default();
1022 return [head, joined(" ")]
1023 .into_iter()
1024 .filter(|s| !s.is_empty())
1025 .collect::<Vec<_>>()
1026 .join(" ");
1027 },
1028 "status" => return joined(" "),
1029 "badge" | "button" => vec![text(node, "text").map_or_default(|t| format!("[{t}]"))],
1030 "stats" => {
1031 return list(node, "items")
1032 .iter()
1033 .map(|item| {
1034 let label = item.get("label").map_or_default(value_text);
1035 let value = item.get("value").map_or_default(value_text);
1036 match item.get("sub").map(value_text) {
1037 Some(sub) if !sub.is_empty() => format!("{label}: {value} ({sub})"),
1038 _ => format!("{label}: {value}"),
1039 }
1040 })
1041 .collect::<Vec<_>>()
1042 .join(" · ");
1043 },
1044 "kbd" => vec![
1045 list(node, "keys")
1046 .iter()
1047 .map(value_text)
1048 .collect::<Vec<_>>()
1049 .join("+"),
1050 ],
1051 "icon" => vec![string(node, "aria").unwrap_or_default().to_owned()],
1052 "image" => vec![format!("[image: {}]", string(node, "alt").unwrap_or("image"))],
1053 "media" => vec![match string(node, "rev") {
1054 Some(rev) => format!("[media: {} @ {rev}]", string(node, "path").unwrap_or("media")),
1055 None => format!("[media: {}]", string(node, "path").unwrap_or("media")),
1056 }],
1057 "spinner" => vec![text(node, "label").unwrap_or_else(|| "...".into())],
1058 "elapsed" => {
1059 vec![duration(
1060 number(node, "stopped")
1061 .or_else(|| number(node, "age"))
1062 .unwrap_or(0.0),
1063 )]
1064 },
1065 "rate" => {
1066 let v = number(node, "value").unwrap_or(0.0);
1067 vec![format!("{v}"), string(node, "unit").unwrap_or_default().to_owned()]
1068 },
1069 "progress" | "meter" => {
1070 let value = number(node, "value").or_else(|| {
1071 let parts = list(node, "parts");
1072 (!parts.is_empty()).then(|| parts.iter().filter_map(|p| p.get("value")?.as_f64()).sum())
1073 });
1074 vec![
1075 value.map_or_else(|| "[..........]".to_owned(), bar),
1076 text(node, "label").unwrap_or_default(),
1077 text(node, "total").unwrap_or_default(),
1078 ]
1079 },
1080 "chart" | "select" | "switch" | "slider" | "field" | "otp" | "buttons" | "avatar"
1081 | "avatars" | "breadcrumb" | "pager" | "empty" | "skeleton" | "calendar" | "date"
1082 | "menu" | "menubar" | "nav" | "split" | "scroll" | "carousel" | "message" | "attachment"
1083 | "trace" | "stacktrace" | "suggestions" | "questions" => {
1084 let mut lines = Vec::new();
1085 block(node, 80, &mut lines);
1086 return lines
1087 .iter()
1088 .map(|l| l.trim())
1089 .filter(|l| !l.is_empty())
1090 .collect::<Vec<_>>()
1091 .join(" ");
1092 },
1093 "effort" => vec![string(node, "level").unwrap_or_default().to_owned()],
1094 "item" => vec![
1095 text(node, "label").unwrap_or_default(),
1096 text(node, "detail").unwrap_or_default(),
1097 text(node, "value").unwrap_or_default(),
1098 ],
1099 "tabs" => {
1100 let active = string(node, "active");
1101 vec![
1102 list(node, "items")
1103 .iter()
1104 .map(|t| {
1105 let label = t.get("label").map_or_default(value_text);
1106 if t.get("id").and_then(Value::as_str) == active {
1107 format!("[{label}]")
1108 } else {
1109 label
1110 }
1111 })
1112 .collect::<Vec<_>>()
1113 .join(" "),
1114 ]
1115 },
1116 "seg" => vec![primary(node).unwrap_or_default(), joined(" ")],
1117 "toast" => {
1118 vec![text(node, "text").unwrap_or_default(), text(node, "sub").unwrap_or_default()]
1119 },
1120 "tool" => {
1121 let mut parts = vec![
1122 text(node, "title")
1123 .or_else(|| text(node, "name"))
1124 .unwrap_or_default(),
1125 text(node, "target").unwrap_or_default(),
1126 ];
1127 parts.extend(list(node, "meta").iter().map(value_text));
1128 parts.push(string(node, "status").map_or_default(|s| format!("({s})")));
1129 parts
1130 },
1131 "agent" => vec![
1132 text(node, "name").unwrap_or_default(),
1133 text(node, "task").unwrap_or_default(),
1134 string(node, "status").map_or_default(|s| format!("({s})")),
1135 ],
1136 "el" => return el_inline(node),
1137 _ => vec![primary(node).unwrap_or_default(), joined(" ")],
1138 };
1139 parts
1140 .into_iter()
1141 .filter(|s| !s.is_empty())
1142 .collect::<Vec<_>>()
1143 .join(" ")
1144}
1145
1146fn table<M>(node: &Node<M>, out: &mut Vec<String>) {
1148 let cols: Vec<&Map<String, Value>> = list(node, "cols")
1149 .iter()
1150 .filter_map(Value::as_object)
1151 .collect();
1152 let right: Vec<bool> = cols
1153 .iter()
1154 .map(|c| c.get("align").and_then(Value::as_str) == Some("end"))
1155 .collect();
1156 let mut rows = Vec::new();
1157 if cols
1158 .iter()
1159 .any(|c| c.get("head").is_some_and(|h| !value_text(h).is_empty()))
1160 {
1161 rows.push(
1162 cols
1163 .iter()
1164 .map(|c| c.get("head").map_or_default(value_text))
1165 .collect(),
1166 );
1167 }
1168 for row in list(node, "rows") {
1169 let cells = row.get("cells");
1170 rows.push(
1171 cols
1172 .iter()
1173 .map(|c| {
1174 let id = c.get("id").and_then(Value::as_str).unwrap_or_default();
1175 cells
1176 .and_then(|cells| cells.get(id))
1177 .map_or_default(cell_text)
1178 })
1179 .collect(),
1180 );
1181 }
1182 columns(&rows, &right, out);
1183 if list(node, "rows").is_empty()
1184 && let Some(empty) = text(node, "empty")
1185 {
1186 out.push(empty);
1187 }
1188}
1189
1190fn cell_text(cell: &Value) -> String {
1193 let Value::Object(cell) = cell else {
1194 return value_text(cell);
1195 };
1196 if let Some(meter) = cell.get("meter") {
1197 return meter
1198 .get("value")
1199 .and_then(Value::as_f64)
1200 .map_or_default(bar);
1201 }
1202 if let Some(spark) = cell.get("spark") {
1203 let values: Vec<f64> = spark
1204 .get("values")
1205 .and_then(Value::as_array)
1206 .map_or_default(|v| v.iter().filter_map(Value::as_f64).collect());
1207 let bound = |k| spark.get(k).and_then(Value::as_f64);
1208 return sparkline(&values, bound("min"), bound("max"));
1209 }
1210 if let Some(chip) = cell.get("badge").or_else(|| cell.get("button")) {
1211 return chip
1212 .get("text")
1213 .map_or_default(|t| format!("[{}]", value_text(t)));
1214 }
1215 cell.get("text").map_or_default(value_text)
1216}
1217
1218fn slot<'a, M>(node: &'a Node<M>, name: &str) -> Option<&'a Node<M>> {
1220 if !node.slots.contains(&name) {
1221 return None;
1222 }
1223 node
1224 .children
1225 .iter()
1226 .find(|c| c.props.get("key").and_then(Value::as_str) == Some(name))
1227}
1228
1229fn slot_of<'a, M>(node: &'a Node<M>, child: &Node<M>) -> Option<&'a str> {
1231 let key = child.props.get("key").and_then(Value::as_str)?;
1232 node.slots.iter().copied().find(|s| *s == key)
1233}
1234
1235fn panel_head<M>(node: &Node<M>) -> String {
1238 let head = slot(node, "head").map_or_else(|| text(node, "head").unwrap_or_default(), inline);
1239 [head, text(node, "meta").unwrap_or_default(), slot(node, "end").map_or_default(inline)]
1240 .into_iter()
1241 .filter(|s| !s.is_empty())
1242 .collect::<Vec<_>>()
1243 .join(" ")
1244}
1245
1246fn tree(items: &[Value], depth: usize, out: &mut Vec<String>) {
1248 for item in items {
1249 let label = item.get("label").map_or_default(value_text);
1250 out.push(format!("{}- {label}", " ".repeat(depth)));
1251 if let Some(Value::Array(children)) = item.get("children") {
1252 tree(children, depth + 1, out);
1253 }
1254 }
1255}
1256
1257fn prefs<M>(node: &Node<M>, out: &mut Vec<String>) {
1259 out.push(string(node, "title").unwrap_or("Settings").to_owned());
1260 for section in list(node, "sections") {
1261 if let Some(title) = section.get("title").and_then(Value::as_str) {
1262 out.push(format!(" {title}"));
1263 }
1264 for row in section
1265 .get("rows")
1266 .and_then(Value::as_array)
1267 .into_iter()
1268 .flatten()
1269 {
1270 let id = row.get("id").and_then(Value::as_str).unwrap_or_default();
1271 let label = row.get("label").and_then(Value::as_str).unwrap_or(id);
1272 let value = row.get("control").map_or_default(|c| {
1273 ["on", "value", "values", "label"]
1274 .iter()
1275 .find_map(|k| c.get(*k))
1276 .map_or_default(|v| match v {
1277 Value::Array(vs) => vs.iter().map(value_text).collect::<Vec<_>>().join(", "),
1278 Value::Bool(b) => if *b { "on" } else { "off" }.to_owned(),
1279 other => value_text(other),
1280 })
1281 });
1282 out.push(format!(" {label}: {value}"));
1283 }
1284 }
1285}
1286
1287fn tag<M>(node: &Node<M>) -> &str {
1289 string(node, "tag").unwrap_or("div")
1290}
1291
1292fn is_block<M>(node: &Node<M>) -> bool {
1294 node.kind != "el" || BLOCK_TAGS.contains(&tag(node))
1295}
1296
1297fn el_inline<M>(node: &Node<M>) -> String {
1299 match tag(node) {
1300 "input" => {
1301 let on = node.props.get("checked") == Some(&Value::Bool(true));
1302 let radio = string(node, "type") == Some("radio");
1303 return match (radio, on) {
1304 (true, true) => "(*)",
1305 (true, false) => "( )",
1306 (false, true) => "[x]",
1307 (false, false) => "[ ]",
1308 }
1309 .to_owned();
1310 },
1311 "hr" => return "---".to_owned(),
1312 _ => {},
1313 }
1314 let mut parts = Vec::new();
1315 if let Some(t) = text(node, "text") {
1316 parts.push(t);
1317 }
1318 parts.extend(node.children.iter().map(inline).filter(|s| !s.is_empty()));
1319 let body = parts.join(" ");
1320 if tag(node) == "button" {
1321 format!("[ {body} ]")
1322 } else {
1323 body
1324 }
1325}
1326
1327fn el_block<M>(node: &Node<M>, cols: usize, out: &mut Vec<String>) {
1329 if node.props.get("hidden") == Some(&Value::Bool(true)) {
1330 return;
1331 }
1332 match tag(node) {
1333 "table" | "thead" | "tbody" => {
1334 let mut rows = Vec::new();
1335 collect_rows(node, &mut rows);
1336 columns(&rows, &[], out);
1337 return;
1338 },
1339 "tr" => {
1340 out.push(
1341 node
1342 .children
1343 .iter()
1344 .map(inline)
1345 .collect::<Vec<_>>()
1346 .join(" "),
1347 );
1348 return;
1349 },
1350 "hr" => {
1351 out.push("-".repeat(cols.clamp(4, 80)));
1352 return;
1353 },
1354 "label" | "button" => {
1355 out.push(el_inline(node));
1356 return;
1357 },
1358 _ => {},
1359 }
1360 let prefix = if tag(node) == "li" { "- " } else { "" };
1361 let mut inner = Vec::new();
1362 let mut line = text(node, "text").unwrap_or_default();
1363 for child in &node.children {
1364 if is_block(child) {
1365 if !line.is_empty() {
1366 inner.push(std::mem::take(&mut line));
1367 }
1368 if child.kind == "el" {
1369 el_block(child, cols, &mut inner);
1370 } else {
1371 block(child, cols, &mut inner);
1372 }
1373 } else {
1374 let part = inline(child);
1375 if !part.is_empty() {
1376 if !line.is_empty() && !line.ends_with(' ') {
1377 line.push(' ');
1378 }
1379 line.push_str(&part);
1380 }
1381 }
1382 }
1383 if !line.is_empty() {
1384 inner.push(line);
1385 }
1386 if matches!(tag(node), "ul" | "ol" | "blockquote" | "dd") {
1387 indented(inner, out);
1388 } else {
1389 for (i, l) in inner.into_iter().enumerate() {
1390 out.push(if i == 0 { format!("{prefix}{l}") } else { l });
1391 }
1392 }
1393}
1394
1395fn collect_rows<M>(node: &Node<M>, rows: &mut Vec<Vec<String>>) {
1397 for child in &node.children {
1398 match (child.kind.as_str(), tag(child)) {
1399 ("el", "tr") => rows.push(child.children.iter().map(inline).collect()),
1400 ("el", "thead" | "tbody") => collect_rows(child, rows),
1401 _ => {},
1402 }
1403 }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::*;
1409 use crate::ui::{self, Column, TableRow, TextAlign, html};
1410
1411 #[test]
1412 fn cards_tables_and_lists_read_as_text() {
1413 let view: View = View::new().main(crate::nodes![
1414 ui::card()
1415 .head("Build")
1416 .status(ui::Status::Done)
1417 .child(ui::text("ok")),
1418 ui::table()
1419 .col(Column::new("n", "Name"))
1420 .col(Column::new("s", "Size").align(TextAlign::End))
1421 .row(TableRow::new("a").cell("n", "a.rs").cell("s", "12"))
1422 .row(TableRow::new("b").cell("n", "build.rs").cell("s", "3")),
1423 ui::list()
1424 .child(ui::item("one"))
1425 .child(ui::item("two").value("2")),
1426 ]);
1427 assert_eq!(
1428 plain(view, 40),
1429 "Build (done)\n ok\nName Size\na.rs 12\nbuild.rs 3\n- one\n- two 2\n"
1430 );
1431 }
1432
1433 #[test]
1434 fn headed_statuses_use_parentheses() {
1435 for kind in ["card", "section", "overlay"] {
1436 assert_eq!(
1437 plain(
1438 ui::node(kind)
1439 .prop("head", "cargo test")
1440 .prop("status", "done"),
1441 80
1442 ),
1443 "cargo test (done)\n"
1444 );
1445 assert_eq!(plain(ui::node(kind).prop("status", "done"), 80), "(done)\n");
1446 }
1447 for kind in ["tool", "agent"] {
1448 assert_eq!(
1449 plain(
1450 ui::node(kind)
1451 .prop("name", "cargo test")
1452 .prop("status", "done"),
1453 80
1454 ),
1455 "cargo test (done)\n"
1456 );
1457 }
1458 }
1459
1460 #[test]
1461 fn el_blocks_break_lines_and_ansi_loses_escapes() {
1462 let view: View = View::new()
1463 .main([html::form()
1464 .child(html::p("Which size?"))
1465 .child(
1466 html::label()
1467 .child(html::radio("size", "s").checked(true))
1468 .child(html::span("Small")),
1469 )
1470 .child(html::button("Create"))])
1471 .dock([ui::ansi("\x1b[1;32mok\x1b[0m done\r\n")]);
1472 assert_eq!(plain(view, 40), "Which size?\n(*) Small\n[ Create ]\nok done\n");
1473 }
1474
1475 #[test]
1476 fn bare_builder_infers_a_message_free_view() {
1477 assert_eq!(plain(ui::text("hello"), 80), "hello\n");
1478 }
1479
1480 #[test]
1481 fn meters_inline_pairs_and_blocked_items_read_as_text() {
1482 let view = View::new().main(crate::nodes![
1483 ui::table().col(Column::new("usage", "Usage")).row(
1484 TableRow::new("a")
1485 .cell("usage", ui::MeterCell { value: Some(0.5), ..ui::MeterCell::default() })
1486 ),
1487 ui::kv()
1488 .layout(ui::KvLayout::Inline)
1489 .item("branch", "main")
1490 .item("ahead", "2"),
1491 ui::checklist().prop(
1492 "phases",
1493 serde_json::json!([{"items": [
1494 {"text": "waiting", "status": "pending"},
1495 {"text": "working", "status": "active"},
1496 {"text": "stuck", "status": "blocked"},
1497 {"text": "finished", "status": "done"},
1498 {"text": "removed", "status": "dropped"}
1499 ]}])
1500 ),
1501 ]);
1502 assert_eq!(
1503 plain(view, 80),
1504 "Usage\n[#####-----] 50%\nbranch main · ahead 2\n [ ] waiting\n [>] working\n [!] \
1505 stuck\n [x] finished\n [-] removed\n"
1506 );
1507 }
1508
1509 #[test]
1510 fn panels_buttons_stats_and_rich_cells_read_as_text() {
1511 let view: View = View::new().main(crate::nodes![
1512 ui::card()
1513 .head("Jobs")
1514 .meta("3 running")
1515 .end(ui::button("Refresh").keys(["r"]))
1516 .child(ui::stats([
1517 ui::StatItem::new("Queued", "4").sub("2 new"),
1518 ui::StatItem::new("Failed", "1"),
1519 ])),
1520 ui::table()
1521 .col(Column::new("n", "Name"))
1522 .col(Column::new("s", "State"))
1523 .col(Column::new("a", "Act").align(TextAlign::End))
1524 .col(Column::new("t", "Load"))
1525 .row(
1526 TableRow::new("a")
1527 .cell("t", ui::SparkCell::new(vec![0.0, 1.0, 2.0]).range(0.0, 2.0))
1528 .cell("n", ui::TextCell::new("api").dot(ui::Tone::Success))
1529 .cell("s", ui::BadgeSpec::new("live"))
1530 .cell("a", ui::ButtonCell::new("Approve", "approve")),
1531 ),
1532 ui::table().col(Column::new("n", "Name")).empty("No jobs"),
1533 ui::overlay().head("Delete?").foot(ui::button("Delete")),
1534 ]);
1535 assert_eq!(
1536 plain(view, 80),
1537 "Jobs 3 running [Refresh]\n Queued: 4 (2 new) · Failed: 1\nName State Act \
1538 Load\napi [live] [Approve] ▁▅█\nName\nNo jobs\nDelete?\n [Delete]\n"
1539 );
1540 }
1541
1542 #[test]
1543 fn form_controls_read_as_text() {
1544 let view: View = View::new().main(crate::nodes![
1545 ui::select()
1546 .label("Model")
1547 .value("haiku")
1548 .option(ui::SelectOption::new("haiku", "Haiku")),
1549 ui::select().placeholder("Pick one"),
1550 ui::switch("Wrap").on(true),
1551 ui::switch("Follow"),
1552 ui::slider().label("Opacity").value(72.0).unit("%"),
1553 ui::slider()
1554 .label("Latency")
1555 .value((200.0, 1200.0))
1556 .unit("ms"),
1557 ui::field("Name")
1558 .required(true)
1559 .hint("Shown to others")
1560 .error("Taken")
1561 .child(ui::input().text("cara")),
1562 ui::otp(6).text("123"),
1563 ui::otp(4).text("12").mask(true),
1564 ui::calendar().value(ui::DateValue::Range("2026-10-19".into(), Some("2026-10-30".into()))),
1565 ui::date().placeholder("Pick a date"),
1566 ui::menu().text("Release"),
1567 ui::input().text("hunter2").secret(true).reveal(true),
1568 ]);
1569 assert_eq!(
1570 plain(view, 80),
1571 "Model: Haiku\nPick one\n[x] Wrap\n[ ] Follow\nOpacity: 72%\nLatency: 200–1200 ms\nName \
1572 *\ncara\nTaken\n123___\n••__\n2026-10-19 – 2026-10-30\nPick a date\n[Release]\n•••••••\n"
1573 );
1574 }
1575
1576 #[test]
1577 fn navigation_people_and_groups_read_as_text() {
1578 let view: View = View::new().main(crate::nodes![
1579 ui::breadcrumb()
1580 .crumb(ui::Crumb::new("home", "Home"))
1581 .crumb(ui::Crumb::new("src", "src")),
1582 ui::nav()
1583 .active("logs")
1584 .item(ui::NavItem::new("home", "Home"))
1585 .item(ui::NavItem::new("ops", "Ops").item(ui::NavSubItem::new("logs", "Logs"))),
1586 ui::menubar()
1587 .menu(ui::MenubarMenu::new("file", "File"))
1588 .menu(ui::MenubarMenu::new("edit", "Edit")),
1589 ui::pager().page(2u64).total(312u64).size(50u64),
1590 ui::avatars([ui::AvatarItem::new("Ann"), ui::AvatarItem::new("Bo")]).label("2 reviewers"),
1591 ui::buttons()
1592 .child(ui::button("Undo"))
1593 .child(ui::button("Redo")),
1594 ui::empty("No runs")
1595 .text("Start one")
1596 .child(ui::button("Run")),
1597 ui::skeleton(),
1598 ui::split().child(ui::text("left")).child(ui::text("right")),
1599 ui::carousel()
1600 .index(1u32)
1601 .child(ui::text("one"))
1602 .child(ui::text("two")),
1603 ui::rule().label("Resumed").time("14:02"),
1604 ]);
1605 assert_eq!(
1606 plain(view, 24),
1607 "Home › src\nHome [Ops]\nFile Edit\nPage 2 of 7\nAnn, Bo 2 reviewers\n[Undo] \
1608 [Redo]\nNo runs\nStart one\n[Run]\nleft\nright\ntwo\n--- Resumed · 14:02 ---\n"
1609 );
1610 }
1611
1612 #[test]
1613 fn chat_and_work_kinds_read_as_text() {
1614 let view: View = View::new().main(crate::nodes![
1615 ui::message()
1616 .author("Cara")
1617 .time("14:02")
1618 .avatar(ui::avatar("Cara"))
1619 .reactions(vec![ui::Reaction::new("👍", 2), ui::Reaction::new("🎉", 0)])
1620 .status("Read")
1621 .child(ui::text("Shipped")),
1622 ui::attachment("report.pdf"),
1623 ui::trace()
1624 .title("Research")
1625 .status(ui::Status::Done)
1626 .line(
1627 ui::TraceLine::new("s1", "Searched")
1628 .target("tsp")
1629 .status(ui::Status::Done)
1630 )
1631 .child(ui::text("3 hits").key("s1")),
1632 ui::stacktrace(ui::StackError::new("TypeError", "x is undefined"))
1633 .frame(ui::Frame::new("main", "src/a.ts").at(3, 7))
1634 .frame(ui::Frame::new("", "")),
1635 ui::suggestions().item(ui::Suggestion::new("Explain this")),
1636 ui::questions().question(
1637 ui::Question::new("db", "Which database?")
1638 .choice("postgres")
1639 .choice(ui::QuestionChoice::new("sqlite", "SQLite"))
1640 ),
1641 ]);
1642 assert_eq!(
1643 plain(view, 80),
1644 "Cara 14:02\n Shipped\n 👍 2 🎉\n Read\n[attachment: report.pdf]\nResearch (done)\n- \
1645 Searched tsp (done)\n 3 hits\nTypeError: x is undefined\n at main (src/a.ts:3:7)\n \
1646 at <anonymous>\n- Explain this\nWhich database?\n - postgres\n - SQLite\n"
1647 );
1648 }
1649
1650 #[test]
1651 fn charts_and_cites_read_as_text() {
1652 let view: View = View::new().main(crate::nodes![
1653 ui::chart(ui::ChartKind::Line).series(vec![
1654 ui::Point { value: 0.0, ..ui::Point::default() },
1655 ui::Point { value: 7.0, ..ui::Point::default() },
1656 ]),
1657 ui::chart(ui::ChartKind::Area).multi_series(vec![
1658 ui::Series::new(vec![Some(0.0), None, Some(1.0)]).label("p50"),
1659 ui::Series::new(vec![Some(1.0), Some(0.0)]),
1660 ]),
1661 ui::chart(ui::ChartKind::Donut).unit("ms").series(vec![
1662 ui::Point { value: 120.0, label: Some("api".into()), ..ui::Point::default() },
1663 ui::Point { value: 30.0, ..ui::Point::default() },
1664 ]),
1665 ui::chart(ui::ChartKind::Donut)
1666 .unit("%")
1667 .series(vec![ui::Point { value: 64.0, ..ui::Point::default() }]),
1668 ui::chart(ui::ChartKind::Line).summary("p50 182 ms"),
1669 ui::text(crate::spans![
1670 ui::span("Rust"),
1671 ui::span(" ships").cite(ui::Cite::new("https://a.dev")),
1672 ui::span(" yearly")
1673 .cite(ui::Cite::new("https://b.dev").n(1))
1674 .cite(ui::Cite::new("https://a.dev")),
1675 ]),
1676 ]);
1677 assert_eq!(
1678 plain(view, 80),
1679 "▁█\np50 ▁█\n█▁\napi 120 ms 30 ms\n64%\np50 182 ms\nRust ships[2] yearly[1][2]\n"
1680 );
1681 }
1682
1683 #[test]
1684 fn bars_clamp_and_round() {
1685 assert_eq!(bar(-1.0), "[----------] 0%");
1686 assert_eq!(bar(0.44), "[####------] 44%");
1687 assert_eq!(bar(2.0), "[##########] 100%");
1688 }
1689}