Skip to main content

tern_sdk/
plain.rs

1//! The plain-text fallback: a view rendered as readable text without
2//! escapes, for output outside Tern. Text kinds are their text, rows join
3//! on one line, cards and sections are a head line over an indented body,
4//! tables and `kv` are aligned columns, lists are bullets, progress and
5//! meters are `[####------] 40%`, motion kinds are their label, and `el` is
6//! its text with block tags on lines of their own. The controls, menus,
7//! navigation, chat and work kinds read as a line or a few (`[x] Wrap`,
8//! `Page 2 of 7`, `- Searched tsp (done)`). It is not a copy of Tern's
9//! layout.
10
11use serde_json::{Map, Value};
12
13use crate::ui::{Node, View, node::spans_text};
14
15/// The width of a plain progress bar, in cells.
16const BAR: usize = 10;
17
18/// Units a slider writes against its number (`72%`); others follow a space.
19const SLIDER_UNITS: [&str; 9] = ["%", "‰", "°", "×", "x", "′", "″", "'", "\""];
20
21/// Currency symbols a donut writes before its numbers (`$51.4`).
22const CURRENCY: [&str; 4] = ["$", "€", "£", "¥"];
23
24/// `el` tags that start a line of their own.
25const 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
58/// Renders a message-free view or builder as plain text `cols` columns
59/// wide (rules span it; text isn't wrapped). Lines end with `\n`.
60///
61/// # Example
62/// ```
63/// use tern_sdk::ui::{self, View};
64///
65/// let view: View = View::new().main([ui::progress().value(0.5).label("half")]);
66/// assert_eq!(tern_sdk::plain(view, 80), "[#####-----] 50% half\n");
67/// assert_eq!(tern_sdk::plain(ui::text("hello"), 80), "hello\n");
68/// ```
69pub fn plain(view: impl Into<View>, cols: usize) -> String {
70	view.into().plain(cols)
71}
72
73impl<M> View<M> {
74	/// The view as plain text `cols` wide: what a program prints outside
75	/// Tern. Works for views of any message type (see [`plain`]).
76	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
90/// The eighths blocks a sparkline is drawn with, low to high.
91const SPARKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
92
93/// Values as a block sparkline, scaled to `min`..`max` (default: their own
94/// range).
95fn 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
113/// A prop as text (a string, or spans joined).
114fn text<M>(node: &Node<M>, key: &str) -> Option<String> {
115	node.text_prop(key).filter(|t| !t.is_empty())
116}
117
118/// A prop as a string.
119fn string<'a, M>(node: &'a Node<M>, key: &str) -> Option<&'a str> {
120	node.props.get(key).and_then(Value::as_str)
121}
122
123/// A prop as a number.
124fn number<M>(node: &Node<M>, key: &str) -> Option<f64> {
125	node.props.get(key).and_then(Value::as_f64)
126}
127
128/// A JSON value (string or spans) as text.
129fn 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
139/// A list prop.
140fn 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
148/// A node's primary text: `spans` (with their cite marks) when they are a
149/// list, else `text`.
150fn 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
157/// The cites of a span that have an `href` (one cite, or a list).
158fn 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
170/// Spans joined, each cited run followed by its sources' numbers (`[1][2]`):
171/// a cite's own `n`, else the next free number by first appearance.
172fn 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
213/// A number as JavaScript prints it (`72`, `0.6`), or the value as text.
214fn num_text(value: &Value) -> String {
215	value
216		.as_f64()
217		.map_or_else(|| value_text(value), |n| n.to_string())
218}
219
220/// `value` then `unit`: against it when `attached`, else after a space.
221fn 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
231/// `label: shown`, or `shown` without a label.
232fn labeled(label: Option<String>, shown: String) -> String {
233	match label {
234		Some(label) => format!("{label}: {shown}"),
235		None => shown,
236	}
237}
238
239/// A `calendar`'s or `date`'s value: a day, or a range as `from – to` (an
240/// open range as its start).
241fn 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
255/// The numbers of chart series entries (bare or `{value}`), gaps dropped.
256fn 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
263/// A chart's lines: its `summary`, else a `line`/`area` chart as a
264/// sparkline per series and a `donut` as its segments.
265fn 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							// A currency symbol leads the number, after any minus
304							// sign.
305							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
323/// `[####------] 40%` for `value` in 0–1.
324fn 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
330/// A duration in ms as `1h 2m`, `3m 4s` or `5s`.
331fn 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
343/// Removes escape sequences and other control characters but `\n` and
344/// `\t`.
345fn 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
376/// Pushes `text`'s lines (a final newline ends the last line).
377fn 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
387/// Rows of cells aligned in columns separated by two spaces; `right` marks
388/// columns aligned to the end.
389fn 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
416/// Pushes `inner` indented by two spaces.
417fn 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
425/// Pushes `head` over `body` indented, or `body` alone without a head.
426fn 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
435/// A node's lines.
436fn 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
990/// A node as one line of text.
991fn 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
1146/// A `table` as aligned columns.
1147fn 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
1190/// A table cell as text: meters as bars, sparklines as blocks, badges and
1191/// buttons bracketed.
1192fn 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
1218/// The child filling slot `name` (`head`, `end`, `foot`) of a panel.
1219fn 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
1229/// The slot `child` fills in `node`, if any.
1230fn 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
1235/// A panel's head line: the head (text or slotted node), its `meta` and
1236/// its `end` node.
1237fn 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
1246/// `tree` items as nested bullets.
1247fn 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
1257/// A `prefs` page: its title, then each section's rows as `label: value`.
1258fn 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
1287/// The tag of an `el` (`div` when absent).
1288fn tag<M>(node: &Node<M>) -> &str {
1289	string(node, "tag").unwrap_or("div")
1290}
1291
1292/// Whether a child of an `el` starts a line of its own.
1293fn is_block<M>(node: &Node<M>) -> bool {
1294	node.kind != "el" || BLOCK_TAGS.contains(&tag(node))
1295}
1296
1297/// An `el` (and its children) as one line.
1298fn 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
1327/// An `el` as lines: inline content joins, block children start lines.
1328fn 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
1395/// The rows (`tr`) of an `el` table as cells.
1396fn 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}