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