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", sim_value::build::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![
93 ("targets", Expr::List(vec![target_ref])),
94 ("dir", sim_value::build::sym(dir)),
95 ],
96 )
97 }
98 KeyInput::Left | KeyInput::Right => {
99 let dir = if matches!(key, KeyInput::Left) {
100 "left"
101 } else {
102 "right"
103 };
104 intent(
105 "move",
106 origin,
107 vec![("node", target_ref), ("at", sim_value::build::sym(dir))],
108 )
109 }
110 KeyInput::Char(typed) => {
111 if field.is_empty() {
112 // No focused field: refuse to edit rather than overwriting the
113 // entire resource at the root path `[]`.
114 return None;
115 }
116 intent(
117 "edit-field",
118 origin,
119 vec![
120 ("target", target_ref),
121 ("path", field_path(field)),
122 ("value", Expr::String(typed.to_string())),
123 ],
124 )
125 }
126 KeyInput::Colon(command) => intent(
127 "invoke",
128 origin,
129 vec![
130 ("target", target_ref),
131 ("op", sim_value::build::sym("command")),
132 ("args", Expr::List(vec![Expr::String(command.clone())])),
133 ],
134 ),
135 KeyInput::Escape => intent(
136 "cancel",
137 origin,
138 vec![("pane", Expr::String(pane.to_owned()))],
139 ),
140 // Backspace has no Intent mapping at the reduction layer.
141 KeyInput::Backspace => return None,
142 };
143 validate_intent(&built).ok().map(|()| built)
144}
145
146/// Builds the single-key edit path to the focused `field` within the target,
147/// in the shared `k`/`i` wire form the universal editor consumes.
148fn field_path(field: &str) -> Expr {
149 sim_value::path::Path::new()
150 .key(Expr::String(field.to_owned()))
151 .to_expr()
152}
153
154/// Drives the shared command palette from a `:`-prompt entry.
155///
156/// A [`KeyInput::Colon`] line is treated as a palette filter: the `command_line`
157/// selects matching commands through [`filter_commands`] (the same predicate the
158/// Web UI uses), and the first match is reduced to a validated Intent with
159/// [`palette_intent`]. This is why the TUI and Web UI agree exactly: both reach
160/// the same shared model.
161///
162/// Returns `None` when no command matches `command_line` (so the TUI can keep
163/// the prompt open) or, defensively, when the chosen command fails to reduce.
164pub fn palette_intent_from_colon(
165 commands: &[Command],
166 command_line: &str,
167 pane: &str,
168 tick: u64,
169) -> Option<Expr> {
170 let command = filter_commands(commands, command_line).into_iter().next()?;
171 palette_intent(command, pane, tick).ok()
172}