Skip to main content

sim_view_tty/
input.rs

1//! Reduce normalized terminal key events to validated [`Intent`] values.
2//!
3//! This is the testable boundary of the tty surface: raw termios bytes are
4//! decoded elsewhere into a [`KeyInput`], and this module turns that normalized
5//! event into a checked Intent on the bus. Keeping the reduction pure (a key
6//! plus the focused pane/target/field plus a tick in, an `Option<Expr>` out)
7//! lets the whole input surface be exercised without a terminal.
8//!
9//! Every returned Intent is built through [`sim_lib_intent::intent`] and then
10//! re-checked with [`sim_lib_intent::validate_intent`]; a key that cannot form a
11//! well-formed Intent yields `None` rather than an invalid value.
12
13use sim_kernel::Expr;
14use sim_lib_intent::{Origin, intent, validate_intent};
15use sim_lib_view::palette::{Command, filter_commands, palette_intent};
16
17/// A normalized terminal key event, decoded from raw input upstream.
18///
19/// This is the reduction layer the surface tests target: it names the keys the
20/// tty surface acts on, not raw escape sequences. `Colon` carries the text of a
21/// command-line entry (the part after the `:` prompt).
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum KeyInput {
24    /// The Enter/Return key: activate the focused target.
25    Enter,
26    /// The Up arrow: select toward the previous item.
27    Up,
28    /// The Down arrow: select toward the next item.
29    Down,
30    /// The Left arrow: move the focused node left.
31    Left,
32    /// The Right arrow: move the focused node right.
33    Right,
34    /// A printable character typed into the focused field. The field key is
35    /// supplied to [`intent_from_key`]; with no focused field the keystroke is
36    /// dropped rather than overwriting the whole resource.
37    Char(char),
38    /// The Backspace key (no Intent mapping; deletion is handled in the field).
39    Backspace,
40    /// A submitted command line (`:`-prompt), carrying the command text.
41    Colon(String),
42    /// The Escape key: cancel the active pane.
43    Escape,
44}
45
46/// Reduces a key event to a validated Intent for `pane`/`target` at `tick`.
47///
48/// `field` names the focused field key within `target`'s resource; an
49/// `intent/edit-field` is scoped to that field path so a keystroke edits one
50/// field rather than overwriting the whole resource at the root path `[]`. When
51/// `field` is empty there is no focused field to bind to, so [`KeyInput::Char`]
52/// yields `None` instead of clobbering the root value.
53///
54/// Returns `None` for keys with no Intent mapping (currently [`KeyInput::Backspace`]),
55/// for a [`KeyInput::Char`] with no focused `field`, and, defensively, for any
56/// event that fails to form a valid Intent. The mappings are:
57///
58/// - [`KeyInput::Enter`] -> `intent/invoke` activating the focused target.
59/// - [`KeyInput::Up`]/[`KeyInput::Down`] -> `intent/select` of the target.
60/// - [`KeyInput::Left`]/[`KeyInput::Right`] -> `intent/move` of the target node.
61/// - [`KeyInput::Char`] -> `intent/edit-field` typing into the focused `field`.
62/// - [`KeyInput::Colon`] -> `intent/invoke` carrying the command string.
63/// - [`KeyInput::Escape`] -> `intent/cancel` of the pane.
64pub fn intent_from_key(
65    key: &KeyInput,
66    pane: &str,
67    target: &str,
68    field: &str,
69    tick: u64,
70) -> Option<Expr> {
71    let origin = Origin::human(tick);
72    let target_ref = Expr::String(target.to_owned());
73    let built = match key {
74        KeyInput::Enter => intent(
75            "invoke",
76            origin,
77            vec![
78                ("target", target_ref),
79                ("op", sym("activate")),
80                ("args", Expr::List(Vec::new())),
81            ],
82        ),
83        KeyInput::Up | KeyInput::Down => {
84            let dir = if matches!(key, KeyInput::Up) {
85                "up"
86            } else {
87                "down"
88            };
89            intent(
90                "select",
91                origin,
92                vec![("targets", Expr::List(vec![target_ref])), ("dir", sym(dir))],
93            )
94        }
95        KeyInput::Left | KeyInput::Right => {
96            let dir = if matches!(key, KeyInput::Left) {
97                "left"
98            } else {
99                "right"
100            };
101            intent("move", origin, vec![("node", target_ref), ("at", sym(dir))])
102        }
103        KeyInput::Char(typed) => {
104            if field.is_empty() {
105                // No focused field: refuse to edit rather than overwriting the
106                // entire resource at the root path `[]`.
107                return None;
108            }
109            intent(
110                "edit-field",
111                origin,
112                vec![
113                    ("target", target_ref),
114                    ("path", field_path(field)),
115                    ("value", Expr::String(typed.to_string())),
116                ],
117            )
118        }
119        KeyInput::Colon(command) => intent(
120            "invoke",
121            origin,
122            vec![
123                ("target", target_ref),
124                ("op", sym("command")),
125                ("args", Expr::List(vec![Expr::String(command.clone())])),
126            ],
127        ),
128        KeyInput::Escape => intent(
129            "cancel",
130            origin,
131            vec![("pane", Expr::String(pane.to_owned()))],
132        ),
133        // Backspace has no Intent mapping at the reduction layer.
134        KeyInput::Backspace => return None,
135    };
136    validate_intent(&built).ok().map(|()| built)
137}
138
139/// Builds an unqualified symbol value.
140fn sym(name: &str) -> Expr {
141    sim_value::build::sym(name)
142}
143
144/// Builds the single-key edit path to the focused `field` within the target,
145/// in the shared `k`/`i` wire form the universal editor consumes.
146fn field_path(field: &str) -> Expr {
147    sim_value::path::Path::new()
148        .key(Expr::String(field.to_owned()))
149        .to_expr()
150}
151
152/// Drives the shared command palette from a `:`-prompt entry.
153///
154/// A [`KeyInput::Colon`] line is treated as a palette filter: the `command_line`
155/// selects matching commands through [`filter_commands`] (the same predicate the
156/// Web UI uses), and the first match is reduced to a validated Intent with
157/// [`palette_intent`]. This is why the TUI and Web UI agree exactly: both reach
158/// the same shared model.
159///
160/// Returns `None` when no command matches `command_line` (so the TUI can keep
161/// the prompt open) or, defensively, when the chosen command fails to reduce.
162pub fn palette_intent_from_colon(
163    commands: &[Command],
164    command_line: &str,
165    pane: &str,
166    tick: u64,
167) -> Option<Expr> {
168    let command = filter_commands(commands, command_line).into_iter().next()?;
169    palette_intent(command, pane, tick).ok()
170}