sim_view_tty/render.rs
1//! Deterministic projection of a [`Scene`](sim_lib_scene) to terminal text.
2//!
3//! Rendering is a two-step, total, side-effect-free transform. First the scene
4//! is fit to the surface with [`reduce_for_caps`](sim_lib_view::codec::reduce_for_caps)
5//! so a glance/compact surface receives fewer rows than a dense terminal; then
6//! the reduced scene is walked into plain ASCII lines. The same `(scene, caps)`
7//! always yields the same `String`, which is what makes the output usable as a
8//! snapshot baseline.
9//!
10//! Each baseline scene kind has one stable spelling: a `scene/text` is its text,
11//! a `scene/button` is `[label]`, a `scene/badge` is `<status: label>`, a
12//! `scene/field` is `label: value`, and a `scene/table`/`scene/grid` is its rows
13//! joined by ` | `. Container kinds (`scene/stack`, `scene/box`,
14//! `scene/overlay`) emit their children in order. Any other kind degrades to a one-line `[<kind>]` marker so
15//! an unrecognized node is visible rather than dropped.
16//!
17//! Container kinds include `scene/overlay`, so a shared command palette or
18//! diagnostics overlay (see [`sim_lib_view::palette`]) renders its children
19//! through the same path.
20
21use crate::caps::cli_caps;
22use sim_kernel::Expr;
23use sim_lib_view::SurfaceCaps;
24use sim_lib_view::palette::{Command, palette_scene};
25
26/// Renders `scene` to deterministic terminal text for the surface `caps`.
27///
28/// The scene is first reduced with
29/// [`reduce_for_caps`](sim_lib_view::codec::reduce_for_caps) (display-density
30/// projection), then walked into newline-joined ASCII lines with no trailing
31/// newline. The transform is pure: equal inputs produce an equal `String`.
32pub fn render_scene(scene: &Expr, caps: &SurfaceCaps) -> String {
33 let reduced = sim_lib_view::codec::reduce_for_caps(scene, caps);
34 render_node(&reduced).join("\n")
35}
36
37/// Walks one scene node into zero or more text lines.
38fn render_node(node: &Expr) -> Vec<String> {
39 let Some(kind) = sim_lib_scene::node_kind(node) else {
40 // Not a kind-tagged map: render any atom as a single line.
41 return vec![atom_text(node)];
42 };
43 match &*kind.name {
44 "text" => vec![text_content(node)],
45 "stack" | "box" | "overlay" => render_children(node),
46 "grid" | "table" => render_rows(node),
47 "field" => vec![render_field(node)],
48 "button" => vec![format!("[{}]", field_text(node, "label"))],
49 "badge" => vec![format!(
50 "<{}: {}>",
51 field_text(node, "status"),
52 field_text(node, "label")
53 )],
54 // Known-but-unhandled or unknown kinds degrade to a visible marker.
55 other => vec![format!("[{other}]")],
56 }
57}
58
59/// Renders the ordered `children` of a container node, in declaration order.
60fn render_children(node: &Expr) -> Vec<String> {
61 let mut lines = Vec::new();
62 if let Some(Expr::List(items) | Expr::Vector(items)) =
63 sim_value::access::field(node, "children")
64 {
65 for child in items {
66 lines.extend(render_node(child));
67 }
68 }
69 lines
70}
71
72/// Renders a `scene/table` or `scene/grid`: an optional `columns` header line
73/// followed by one ` | `-joined line per row.
74fn render_rows(node: &Expr) -> Vec<String> {
75 let mut lines = Vec::new();
76 if let Some(Expr::List(cols) | Expr::Vector(cols)) = sim_value::access::field(node, "columns") {
77 lines.push(join_cells(cols));
78 }
79 if let Some(Expr::List(rows) | Expr::Vector(rows)) = sim_value::access::field(node, "rows") {
80 for row in rows {
81 lines.push(render_row(row));
82 }
83 }
84 lines
85}
86
87/// Renders one table row: a list/vector of cells, a map (values), or a lone
88/// atom.
89fn render_row(row: &Expr) -> String {
90 match row {
91 Expr::List(cells) | Expr::Vector(cells) => join_cells(cells),
92 Expr::Map(entries) => entries
93 .iter()
94 .map(|(_, value)| atom_text(value))
95 .collect::<Vec<_>>()
96 .join(" | "),
97 atom => atom_text(atom),
98 }
99}
100
101/// Joins a row of cell expressions with the stable ` | ` separator.
102fn join_cells(cells: &[Expr]) -> String {
103 cells.iter().map(atom_text).collect::<Vec<_>>().join(" | ")
104}
105
106/// Renders a `scene/field` as `label: value`, dropping the prefix when the node
107/// carries no `label`.
108fn render_field(node: &Expr) -> String {
109 let value = field_text(node, "value");
110 match sim_value::access::field_str(node, "label") {
111 Some(label) => format!("{label}: {value}"),
112 None => value,
113 }
114}
115
116/// Reads a node's text body from `text` then `content`, falling back to a
117/// rendered atom of whichever field is present.
118fn text_content(node: &Expr) -> String {
119 for key in ["text", "content"] {
120 if let Some(value) = sim_value::access::field(node, key) {
121 return atom_text(value);
122 }
123 }
124 String::new()
125}
126
127/// Reads a named field as display text, or the empty string when absent.
128fn field_text(node: &Expr, name: &str) -> String {
129 sim_value::access::field(node, name)
130 .map(atom_text)
131 .unwrap_or_default()
132}
133
134/// Renders a single value as compact, stable display text (no quoting).
135fn atom_text(value: &Expr) -> String {
136 match value {
137 Expr::Nil => "nil".to_owned(),
138 Expr::Bool(flag) => flag.to_string(),
139 Expr::Number(number) => number.canonical.clone(),
140 Expr::String(text) => text.clone(),
141 Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.as_qualified_str(),
142 Expr::Bytes(bytes) => format!("#bytes({})", bytes.len()),
143 Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
144 items.iter().map(atom_text).collect::<Vec<_>>().join(" ")
145 }
146 Expr::Map(entries) => entries
147 .iter()
148 .map(|(key, value)| format!("{}={}", atom_text(key), atom_text(value)))
149 .collect::<Vec<_>>()
150 .join(" "),
151 other => format!("<{}>", sim_value::kind::expr_kind(other)),
152 }
153}
154
155/// Renders the shared command palette overlay to deterministic terminal text.
156///
157/// Builds the surface-neutral [`palette_scene`] for `commands` filtered by
158/// `filter`, then walks it through the same
159/// [`render_scene`] path used for every other scene, so the palette is just
160/// another overlay on the terminal. Output is ASCII and deterministic: equal
161/// inputs yield an equal `String`.
162pub fn render_palette(commands: &[Command], filter: &str) -> String {
163 let scene = palette_scene(commands, filter);
164 render_scene(&scene, &cli_caps("tty.palette"))
165}