evault_tui/event.rs
1//! Translation from raw [`KeyEvent`] to high-level [`Action`].
2
3use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
4
5/// High-level UI intent.
6///
7/// `Action` decouples view code from crossterm internals. Every
8/// keybinding lives in exactly one place ([`Action::from_key`]) so the
9/// keymap is auditable and testable without a terminal.
10///
11/// Unknown / unbound keys translate to [`Action::Noop`] so callers can
12/// route them to view-local handlers (e.g. a text-input widget).
13///
14/// **Help overlay parity:** every binding here must have a matching
15/// row in `crate::views::help`. The two tables are maintained by
16/// hand; keep them in sync when adding or renaming keys.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum Action {
19 /// Request the program to exit immediately.
20 Quit,
21 /// Close the topmost overlay, or quit if none is open.
22 Dismiss,
23 /// Move selection up by one row.
24 MoveUp,
25 /// Move selection down by one row.
26 MoveDown,
27 /// Jump to the first row.
28 MoveTop,
29 /// Jump to the last row.
30 MoveBottom,
31 /// Scroll one viewport up.
32 PageUp,
33 /// Scroll one viewport down.
34 PageDown,
35 /// Open the detail view for the selected row.
36 OpenDetail,
37 /// Start the "new variable" flow.
38 NewVar,
39 /// Edit the selected variable.
40 EditVar,
41 /// Delete the selected variable (with confirm).
42 DeleteVar,
43 /// Link the selected variable to a project.
44 LinkVar,
45 /// Copy the selected variable's value to the clipboard.
46 CopyValue,
47 /// Reveal the selected variable's value in a centered modal.
48 ViewValue,
49 /// Toggle showing / masking secret values.
50 ToggleSecretVisibility,
51 /// Open the run-in-project form.
52 RunInProject,
53 /// Open the fuzzy-search overlay.
54 StartFuzzy,
55 /// Switch the active profile.
56 SwitchProfile,
57 /// Move to the next top-level view.
58 NextView,
59 /// Toggle the help overlay.
60 ToggleHelp,
61 /// Re-read data from the provider.
62 Refresh,
63 /// Unbound / unrecognised key.
64 Noop,
65}
66
67impl Action {
68 /// Translate a [`KeyEvent`] into the corresponding [`Action`].
69 ///
70 /// Filters out non-`Press` events so Windows — which reports
71 /// `Press` *and* `Release` for every key — does not fire each
72 /// action twice. Keys with no binding return [`Action::Noop`].
73 ///
74 /// # Examples
75 /// ```
76 /// use evault_tui::Action;
77 /// use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
78 ///
79 /// let press = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
80 /// assert_eq!(Action::from_key(press), Action::Quit);
81 /// ```
82 #[must_use]
83 pub fn from_key(key: KeyEvent) -> Self {
84 if key.kind != KeyEventKind::Press {
85 return Self::Noop;
86 }
87 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
88 match key.code {
89 // Ctrl-C is the universal "I want out" gesture. Binding
90 // it to Quit also prevents the parent shell's SIGINT
91 // handler from delivering the signal in scenarios where
92 // crossterm has not captured Ctrl-C — which would leave
93 // raw mode enabled and corrupt the terminal.
94 KeyCode::Char('c') if ctrl => Self::Quit,
95 KeyCode::Char('q') => Self::Quit,
96 KeyCode::Esc => Self::Dismiss,
97 KeyCode::Char('j') | KeyCode::Down => Self::MoveDown,
98 KeyCode::Char('k') | KeyCode::Up => Self::MoveUp,
99 KeyCode::Char('g') => Self::MoveTop,
100 KeyCode::Char('G') => Self::MoveBottom,
101 KeyCode::PageDown => Self::PageDown,
102 KeyCode::PageUp => Self::PageUp,
103 KeyCode::Enter => Self::OpenDetail,
104 KeyCode::Char('n') => Self::NewVar,
105 KeyCode::Char('e') => Self::EditVar,
106 KeyCode::Char('d') => Self::DeleteVar,
107 KeyCode::Char('l') => Self::LinkVar,
108 KeyCode::Char('y') => Self::CopyValue,
109 KeyCode::Char('v') => Self::ViewValue,
110 KeyCode::Char('s') => Self::ToggleSecretVisibility,
111 // Shift-R = "Run in project" (lower-case `r` is bound to
112 // Refresh; capitalising it follows the `g`/`G` precedent
113 // for related-but-distinct actions).
114 KeyCode::Char('R') => Self::RunInProject,
115 KeyCode::Char('f') if ctrl => Self::StartFuzzy,
116 KeyCode::Char('p') => Self::SwitchProfile,
117 KeyCode::Tab => Self::NextView,
118 KeyCode::Char('?') => Self::ToggleHelp,
119 KeyCode::Char('r') => Self::Refresh,
120 _ => Self::Noop,
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn press(code: KeyCode) -> KeyEvent {
130 KeyEvent::new(code, KeyModifiers::NONE)
131 }
132
133 fn release(code: KeyCode) -> KeyEvent {
134 let mut e = KeyEvent::new(code, KeyModifiers::NONE);
135 e.kind = KeyEventKind::Release;
136 e
137 }
138
139 #[test]
140 fn release_events_are_dropped() {
141 // Critical Windows correctness: release events must not fire
142 // any action, otherwise every keypress fires twice.
143 assert_eq!(Action::from_key(release(KeyCode::Char('q'))), Action::Noop);
144 assert_eq!(Action::from_key(release(KeyCode::Down)), Action::Noop);
145 }
146
147 #[test]
148 fn basic_navigation_keys() {
149 assert_eq!(
150 Action::from_key(press(KeyCode::Char('j'))),
151 Action::MoveDown
152 );
153 assert_eq!(Action::from_key(press(KeyCode::Down)), Action::MoveDown);
154 assert_eq!(Action::from_key(press(KeyCode::Char('k'))), Action::MoveUp);
155 assert_eq!(Action::from_key(press(KeyCode::Up)), Action::MoveUp);
156 assert_eq!(Action::from_key(press(KeyCode::Char('g'))), Action::MoveTop);
157 assert_eq!(
158 Action::from_key(press(KeyCode::Char('G'))),
159 Action::MoveBottom
160 );
161 }
162
163 #[test]
164 fn quit_and_dismiss_are_distinct() {
165 assert_eq!(Action::from_key(press(KeyCode::Char('q'))), Action::Quit);
166 assert_eq!(Action::from_key(press(KeyCode::Esc)), Action::Dismiss);
167 }
168
169 #[test]
170 fn ctrl_f_starts_fuzzy_search() {
171 let ctrl_f = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
172 assert_eq!(Action::from_key(ctrl_f), Action::StartFuzzy);
173 // Plain 'f' without ctrl is unbound.
174 assert_eq!(Action::from_key(press(KeyCode::Char('f'))), Action::Noop);
175 }
176
177 #[test]
178 fn ctrl_c_quits() {
179 // Universal "I want out" gesture. Must always quit cleanly
180 // through the regular state path so raw mode and the
181 // alternate screen are properly torn down.
182 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
183 assert_eq!(Action::from_key(ctrl_c), Action::Quit);
184 // Plain 'c' is still unbound.
185 assert_eq!(Action::from_key(press(KeyCode::Char('c'))), Action::Noop);
186 }
187
188 #[test]
189 fn shift_r_is_run_in_project_lowercase_r_is_refresh() {
190 // The two are deliberately separate actions: `r` refreshes
191 // the dashboard, `R` launches a child process with the
192 // project's env overlay.
193 assert_eq!(Action::from_key(press(KeyCode::Char('r'))), Action::Refresh);
194 assert_eq!(
195 Action::from_key(press(KeyCode::Char('R'))),
196 Action::RunInProject
197 );
198 }
199
200 #[test]
201 fn unknown_key_is_noop() {
202 assert_eq!(Action::from_key(press(KeyCode::Char('z'))), Action::Noop);
203 assert_eq!(Action::from_key(press(KeyCode::F(7))), Action::Noop);
204 }
205}