Skip to main content

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}