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}