Skip to main content

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}