Skip to main content

sim_view_tty/
lib.rs

1//! Loadable terminal (CLI/TUI) view/edit surface for SIM.
2//!
3//! The thesis: a terminal is one *surface*, not a baked subcommand. The `sim`
4//! binary stays a bootloader; this crate is a library loaded at runtime that
5//! projects a [`Scene`](sim_lib_scene) to text and reduces terminal key input to
6//! [`Intent`](sim_lib_intent) values. Nothing here parses argv or owns the
7//! process. Both directions are pure and deterministic, so the whole surface is
8//! testable without a tty:
9//!
10//! - [`render_scene`] fits a scene to a [`SurfaceCaps`] (via the view crate's
11//!   density projection) and walks it to stable ASCII.
12//! - [`intent_from_key`] turns a normalized [`KeyInput`] into a validated Intent.
13//!
14//! The CLI and TUI presets differ only in advertised capabilities -- a `cli`
15//! surface is keyboard-only ANSI, a `tui` surface adds pointer input and a
16//! richer palette -- which the projection ranker reads. Build them with
17//! [`cli_caps`] and [`tui_caps`].
18//!
19//! # Example
20//!
21//! ```
22//! use sim_view_tty::{cli_caps, render_scene};
23//!
24//! let scene = sim_lib_scene::build::text_node("ready");
25//! let text = render_scene(&scene, &cli_caps("tty.local.1"));
26//! assert_eq!(text, "ready");
27//! ```
28
29#![forbid(unsafe_code)]
30#![deny(missing_docs)]
31
32mod input;
33mod render;
34
35pub use input::{KeyInput, intent_from_key, palette_intent_from_colon};
36pub use render::render_scene;
37
38use sim_lib_view::SurfaceCaps;
39use sim_lib_view::palette::{Command, palette_scene};
40
41/// Renders the shared command palette overlay to deterministic terminal text.
42///
43/// Builds the surface-neutral [`palette_scene`] for `commands` filtered by
44/// `filter`, then walks it through the same
45/// [`render_scene`] path used for every other scene, so the palette is just
46/// another overlay on the terminal. Output is ASCII and deterministic: equal
47/// inputs yield an equal `String`.
48pub fn render_palette(commands: &[Command], filter: &str) -> String {
49    let scene = palette_scene(commands, filter);
50    render_scene(&scene, &cli_caps("tty.palette"))
51}
52
53/// Builds the `cli` surface capabilities with `client_id` set.
54///
55/// A `cli` surface is the baseline keyboard-only ANSI terminal. Panics only if
56/// the built-in `cli` preset is missing, which is a build-time invariant of
57/// [`sim_lib_view::surface`].
58pub fn cli_caps(client_id: &str) -> SurfaceCaps {
59    SurfaceCaps::from_preset("cli", client_id).expect("cli is a built-in surface preset")
60}
61
62/// Builds the `tui` surface capabilities with `client_id` set.
63///
64/// A `tui` surface extends the `cli` baseline with pointer input and a richer
65/// (ansi256) palette. Panics only if the built-in `tui` preset is missing.
66pub fn tui_caps(client_id: &str) -> SurfaceCaps {
67    SurfaceCaps::from_preset("tui", client_id).expect("tui is a built-in surface preset")
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use sim_kernel::Expr;
74    use sim_lib_intent::{intent_kind_of, validate_intent};
75    use sim_lib_scene::build::{stack, text_node};
76    use sim_lib_scene::node;
77    use sim_value::build::{list, sym, text};
78
79    /// A composed scene: a column stacking a heading, a two-row table, and a
80    /// button.
81    fn composed_scene() -> Expr {
82        let table = node(
83            "table",
84            vec![
85                ("columns", list(vec![text("name"), text("kind")])),
86                (
87                    "rows",
88                    list(vec![
89                        list(vec![text("alpha"), sym("scene/text")]),
90                        list(vec![text("beta"), sym("scene/button")]),
91                    ]),
92                ),
93            ],
94        );
95        let button = node("button", vec![("label", text("Run"))]);
96        stack("column", vec![text_node("Surfaces"), table, button])
97    }
98
99    #[test]
100    fn renders_composed_scene_to_exact_text() {
101        let caps = cli_caps("tty.local.1");
102        let text = render_scene(&composed_scene(), &caps);
103        let expected = [
104            "Surfaces",
105            "name | kind",
106            "alpha | scene/text",
107            "beta | scene/button",
108            "[Run]",
109        ]
110        .join("\n");
111        assert_eq!(text, expected);
112    }
113
114    #[test]
115    fn renders_field_and_badge_spellings() {
116        let caps = cli_caps("tty.local.1");
117        let field = node(
118            "field",
119            vec![("label", text("name")), ("value", text("alpha"))],
120        );
121        assert_eq!(render_scene(&field, &caps), "name: alpha");
122        let badge = node(
123            "badge",
124            vec![("status", sym("ok")), ("label", text("done"))],
125        );
126        assert_eq!(render_scene(&badge, &caps), "<ok: done>");
127    }
128
129    #[test]
130    fn unknown_kind_degrades_to_marker() {
131        let caps = cli_caps("tty.local.1");
132        // `graph` is a known baseline kind this surface does not specialize.
133        let graph = node("graph", vec![("nodes", list(Vec::new()))]);
134        assert_eq!(render_scene(&graph, &caps), "[graph]");
135    }
136
137    fn intent_kind_name(intent: &Expr) -> String {
138        intent_kind_of(intent)
139            .expect("intent is kind-tagged")
140            .name
141            .to_string()
142    }
143
144    fn assert_valid(key: &KeyInput, expected_kind: &str) -> Expr {
145        let intent =
146            intent_from_key(key, "main", "node-1", "value", 7).expect("key maps to an intent");
147        validate_intent(&intent).expect("produced intent validates");
148        assert_eq!(intent_kind_name(&intent), expected_kind);
149        intent
150    }
151
152    #[test]
153    fn enter_maps_to_invoke() {
154        assert_valid(&KeyInput::Enter, "invoke");
155    }
156
157    #[test]
158    fn arrows_map_to_select_and_move() {
159        assert_valid(&KeyInput::Up, "select");
160        assert_valid(&KeyInput::Down, "select");
161        assert_valid(&KeyInput::Left, "move");
162        assert_valid(&KeyInput::Right, "move");
163    }
164
165    #[test]
166    fn char_maps_to_edit_field() {
167        assert_valid(&KeyInput::Char('x'), "edit-field");
168    }
169
170    #[test]
171    fn char_edit_targets_focused_field_not_root() {
172        let intent = intent_from_key(&KeyInput::Char('x'), "main", "node-1", "title", 7)
173            .expect("char with a focused field maps to an intent");
174        let path = sim_value::access::field(&intent, "path").expect("edit-field carries a path");
175        // The edit is scoped to the focused field, never the root resource.
176        assert_ne!(
177            path,
178            &Expr::List(Vec::new()),
179            "char edit must not overwrite the root path []"
180        );
181        let parsed = sim_value::path::Path::from_expr(path).expect("path parses");
182        assert_eq!(
183            parsed,
184            sim_value::path::Path::new().key(Expr::String("title".to_owned())),
185            "char edit binds to the focused field key"
186        );
187    }
188
189    #[test]
190    fn char_without_focused_field_does_not_edit_root() {
191        assert!(
192            intent_from_key(&KeyInput::Char('x'), "main", "node-1", "", 7).is_none(),
193            "a char with no focused field must not clobber the root value"
194        );
195    }
196
197    #[test]
198    fn colon_maps_to_invoke() {
199        assert_valid(&KeyInput::Colon("quit".to_owned()), "invoke");
200    }
201
202    #[test]
203    fn escape_maps_to_cancel() {
204        let intent = assert_valid(&KeyInput::Escape, "cancel");
205        assert_eq!(sim_value::access::field_str(&intent, "pane"), Some("main"));
206    }
207
208    #[test]
209    fn backspace_has_no_mapping() {
210        assert!(intent_from_key(&KeyInput::Backspace, "main", "node-1", "value", 7).is_none());
211    }
212
213    fn palette_commands() -> Vec<Command> {
214        use sim_kernel::Symbol;
215        use sim_lib_view::palette::CommandKind;
216        vec![
217            Command {
218                id: Symbol::new("run"),
219                label: "Run validation".to_owned(),
220                kind: CommandKind::Invoke,
221            },
222            Command {
223                id: Symbol::new("open-readme"),
224                label: "Open README".to_owned(),
225                kind: CommandKind::Open,
226            },
227        ]
228    }
229
230    #[test]
231    fn palette_render_is_deterministic_ascii() {
232        let commands = palette_commands();
233        let first = render_palette(&commands, "");
234        let second = render_palette(&commands, "");
235        assert_eq!(first, second, "palette render must be deterministic");
236        assert!(first.is_ascii(), "palette render must be ASCII");
237        assert_eq!(first, ["[Run validation]", "[Open README]"].join("\n"));
238        // Filtering narrows the rendered overlay deterministically.
239        assert_eq!(render_palette(&commands, "open"), "[Open README]");
240    }
241
242    #[test]
243    fn tui_and_web_palette_intent_are_identical() {
244        let commands = palette_commands();
245        // The TUI reaches the palette model through the `:`-prompt helper.
246        let via_tui = palette_intent_from_colon(&commands, "run", "main", 3)
247            .expect("colon entry selects a command");
248        // The Web UI reaches the SAME shared model directly.
249        let via_web = sim_lib_view::palette::palette_intent(&commands[0], "main", 3)
250            .expect("command reduces");
251        assert_eq!(via_tui, via_web, "both surfaces drive one palette model");
252        validate_intent(&via_tui).expect("shared palette intent validates");
253    }
254
255    #[test]
256    fn cli_and_tui_caps_differ_in_input_and_color() {
257        let cli = cli_caps("tty.local.1");
258        let tui = tui_caps("tty.local.1");
259        assert_eq!(cli.preset_name(), "cli");
260        assert_eq!(tui.preset_name(), "tui");
261        // The tui surface accepts pointer input; the cli surface does not.
262        assert!(!cli.input_flag("pointer"));
263        assert!(tui.input_flag("pointer"));
264        // Both are keyboard surfaces and carry the requested client id.
265        assert!(cli.input_flag("keyboard") && tui.input_flag("keyboard"));
266        assert_eq!(cli.client_id, "tty.local.1");
267    }
268}