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 sim_kernel::Expr;
22use sim_lib_view::SurfaceCaps;
23
24/// Renders `scene` to deterministic terminal text for the surface `caps`.
25///
26/// The scene is first reduced with
27/// [`reduce_for_caps`](sim_lib_view::codec::reduce_for_caps) (display-density
28/// projection), then walked into newline-joined ASCII lines with no trailing
29/// newline. The transform is pure: equal inputs produce an equal `String`.
30pub fn render_scene(scene: &Expr, caps: &SurfaceCaps) -> String {
31    let reduced = sim_lib_view::codec::reduce_for_caps(scene, caps);
32    render_node(&reduced).join("\n")
33}
34
35/// Walks one scene node into zero or more text lines.
36fn render_node(node: &Expr) -> Vec<String> {
37    let Some(kind) = sim_lib_scene::node_kind(node) else {
38        // Not a kind-tagged map: render any atom as a single line.
39        return vec![atom_text(node)];
40    };
41    match &*kind.name {
42        "text" => vec![text_content(node)],
43        "stack" | "box" | "overlay" => render_children(node),
44        "grid" | "table" => render_rows(node),
45        "field" => vec![render_field(node)],
46        "button" => vec![format!("[{}]", field_text(node, "label"))],
47        "badge" => vec![format!(
48            "<{}: {}>",
49            field_text(node, "status"),
50            field_text(node, "label")
51        )],
52        // Known-but-unhandled or unknown kinds degrade to a visible marker.
53        other => vec![format!("[{other}]")],
54    }
55}
56
57/// Renders the ordered `children` of a container node, in declaration order.
58fn render_children(node: &Expr) -> Vec<String> {
59    let mut lines = Vec::new();
60    if let Some(Expr::List(items) | Expr::Vector(items)) =
61        sim_value::access::field(node, "children")
62    {
63        for child in items {
64            lines.extend(render_node(child));
65        }
66    }
67    lines
68}
69
70/// Renders a `scene/table` or `scene/grid`: an optional `columns` header line
71/// followed by one ` | `-joined line per row.
72fn render_rows(node: &Expr) -> Vec<String> {
73    let mut lines = Vec::new();
74    if let Some(Expr::List(cols) | Expr::Vector(cols)) = sim_value::access::field(node, "columns") {
75        lines.push(join_cells(cols));
76    }
77    if let Some(Expr::List(rows) | Expr::Vector(rows)) = sim_value::access::field(node, "rows") {
78        for row in rows {
79            lines.push(render_row(row));
80        }
81    }
82    lines
83}
84
85/// Renders one table row: a list/vector of cells, a map (values), or a lone
86/// atom.
87fn render_row(row: &Expr) -> String {
88    match row {
89        Expr::List(cells) | Expr::Vector(cells) => join_cells(cells),
90        Expr::Map(entries) => entries
91            .iter()
92            .map(|(_, value)| atom_text(value))
93            .collect::<Vec<_>>()
94            .join(" | "),
95        atom => atom_text(atom),
96    }
97}
98
99/// Joins a row of cell expressions with the stable ` | ` separator.
100fn join_cells(cells: &[Expr]) -> String {
101    cells.iter().map(atom_text).collect::<Vec<_>>().join(" | ")
102}
103
104/// Renders a `scene/field` as `label: value`, dropping the prefix when the node
105/// carries no `label`.
106fn render_field(node: &Expr) -> String {
107    let value = field_text(node, "value");
108    match sim_value::access::field_str(node, "label") {
109        Some(label) => format!("{label}: {value}"),
110        None => value,
111    }
112}
113
114/// Reads a node's text body from `text` then `content`, falling back to a
115/// rendered atom of whichever field is present.
116fn text_content(node: &Expr) -> String {
117    for key in ["text", "content"] {
118        if let Some(value) = sim_value::access::field(node, key) {
119            return atom_text(value);
120        }
121    }
122    String::new()
123}
124
125/// Reads a named field as display text, or the empty string when absent.
126fn field_text(node: &Expr, name: &str) -> String {
127    sim_value::access::field(node, name)
128        .map(atom_text)
129        .unwrap_or_default()
130}
131
132/// Renders a single value as compact, stable display text (no quoting).
133fn atom_text(value: &Expr) -> String {
134    match value {
135        Expr::Nil => "nil".to_owned(),
136        Expr::Bool(flag) => flag.to_string(),
137        Expr::Number(number) => number.canonical.clone(),
138        Expr::String(text) => text.clone(),
139        Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.as_qualified_str(),
140        Expr::Bytes(bytes) => format!("#bytes({})", bytes.len()),
141        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
142            items.iter().map(atom_text).collect::<Vec<_>>().join(" ")
143        }
144        Expr::Map(entries) => entries
145            .iter()
146            .map(|(key, value)| format!("{}={}", atom_text(key), atom_text(value)))
147            .collect::<Vec<_>>()
148            .join(" "),
149        other => format!("<{}>", sim_value::kind::expr_kind(other)),
150    }
151}