Skip to main content

cerno_tui/
keys.rs

1//! Turning key presses into state changes.
2//!
3//! Kept apart from the event loop so it can be exercised directly: the loop owns the terminal
4//! and the network, this owns the decisions.
5
6use crate::app::{App, Focus};
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
8
9/// Something the event loop has to do that this module cannot: talk to the network, or stop.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Action {
12    None,
13    Send,
14    Quit,
15}
16
17/// Calibration is stepped rather than typed. Exploring how flat a model needs to be is a
18/// nudge-and-look loop, and a number field would make each nudge a five-key affair.
19const TEMPERATURE_STEP: f64 = 0.25;
20const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.25..=8.0;
21
22pub fn handle(app: &mut App, key: KeyEvent) -> Action {
23    // Windows terminals report press and release; acting on both would double every keystroke.
24    if key.kind == KeyEventKind::Release {
25        return Action::None;
26    }
27
28    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
29
30    // Ctrl+C always quits, whatever has focus.
31    if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')) {
32        return Action::Quit;
33    }
34
35    if app.show_help {
36        app.show_help = false;
37        return Action::None;
38    }
39
40    if app.editor.is_some() {
41        return handle_editor(app, key);
42    }
43
44    if ctrl && matches!(key.code, KeyCode::Char('s')) {
45        return if app.is_sending() {
46            Action::None
47        } else {
48            Action::Send
49        };
50    }
51
52    match key.code {
53        // Esc abandons a request in flight; there is nothing else for it to close here.
54        KeyCode::Esc => {
55            app.cancel_send();
56            return Action::None;
57        }
58        KeyCode::Tab => {
59            app.focus = app.focus.next();
60            return Action::None;
61        }
62        KeyCode::BackTab => {
63            app.focus = app.focus.previous();
64            return Action::None;
65        }
66        _ => {}
67    }
68
69    if app.focus == Focus::State {
70        // Everything else belongs to the text box, including `q` and `a`.
71        app.state.input(key);
72        app.mark_stale();
73        return Action::None;
74    }
75
76    handle_shortcut(app, key)
77}
78
79/// Lines a page key moves the answers pane.
80const PAGE: i32 = 10;
81
82fn handle_shortcut(app: &mut App, key: KeyEvent) -> Action {
83    // The answers pane has one job, being read, so the list's keys scroll it there rather than
84    // moving, editing or deleting a question that is not what has focus.
85    if app.focus == Focus::Answers {
86        let scrolled = match key.code {
87            KeyCode::Up | KeyCode::Char('k') => Some(-1),
88            KeyCode::Down | KeyCode::Char('j') => Some(1),
89            KeyCode::PageUp => Some(-PAGE),
90            KeyCode::PageDown | KeyCode::Char(' ') => Some(PAGE),
91            KeyCode::Home | KeyCode::Char('g') => Some(i32::MIN / 2),
92            KeyCode::End | KeyCode::Char('G') => Some(i32::MAX / 2),
93            KeyCode::Char('a' | 'e' | 'd') | KeyCode::Enter => Some(0),
94            _ => None,
95        };
96        if let Some(lines) = scrolled {
97            app.scroll_answers(lines);
98            return Action::None;
99        }
100    }
101
102    match key.code {
103        KeyCode::Char('q') => return Action::Quit,
104        KeyCode::Char('?') => app.show_help = true,
105
106        KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
107        KeyCode::Down | KeyCode::Char('j') => app.select_next(),
108
109        KeyCode::Char('a') => app.add_question(),
110        KeyCode::Char('e') | KeyCode::Enter => app.edit_selected(),
111        KeyCode::Char('d') => app.delete_selected(),
112
113        KeyCode::Char('m') => app.cycle_model(),
114
115        KeyCode::Char('t') => {
116            let next = app.calibration.unwrap_or(1.0) + TEMPERATURE_STEP;
117            app.calibration = Some(next.min(*TEMPERATURE_RANGE.end()));
118            app.mark_stale();
119        }
120        KeyCode::Char('T') => {
121            let next = app.calibration.unwrap_or(1.0) - TEMPERATURE_STEP;
122            app.calibration = Some(next.max(*TEMPERATURE_RANGE.start()));
123            app.mark_stale();
124        }
125        // Back to whatever the service or the model alias says.
126        KeyCode::Char('c') => {
127            app.calibration = None;
128            app.mark_stale();
129        }
130
131        KeyCode::Char('1') => app.focus = Focus::State,
132        KeyCode::Char('2') => app.focus = Focus::Questions,
133        KeyCode::Char('3') => app.focus = Focus::Answers,
134
135        _ => {}
136    }
137    Action::None
138}
139
140fn handle_editor(app: &mut App, key: KeyEvent) -> Action {
141    match key.code {
142        KeyCode::Esc => {
143            app.cancel_editor();
144            Action::None
145        }
146        KeyCode::Enter => {
147            app.commit_editor();
148            Action::None
149        }
150        _ => {
151            if let Some(editor) = app.editor.as_mut() {
152                editor.input(key);
153            }
154            Action::None
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::draft::{Kind, QuestionDraft};
163    use crate::session::Session;
164
165    fn app() -> App {
166        App::new(Session::default(), "http://cerno.test".into())
167    }
168
169    fn key(code: KeyCode) -> KeyEvent {
170        KeyEvent::new(code, KeyModifiers::NONE)
171    }
172
173    fn ctrl(code: KeyCode) -> KeyEvent {
174        KeyEvent::new(code, KeyModifiers::CONTROL)
175    }
176
177    fn with_questions() -> App {
178        let mut app = app();
179        app.questions = vec![
180            QuestionDraft {
181                id: "a".into(),
182                ..Default::default()
183            },
184            QuestionDraft {
185                id: "b".into(),
186                kind: Kind::Choice,
187                ..Default::default()
188            },
189        ];
190        app.focus = Focus::Questions;
191        app
192    }
193
194    #[test]
195    fn ctrl_c_quits_from_anywhere() {
196        let mut app = app();
197        assert_eq!(handle(&mut app, ctrl(KeyCode::Char('c'))), Action::Quit);
198
199        app.focus = Focus::State;
200        assert_eq!(handle(&mut app, ctrl(KeyCode::Char('c'))), Action::Quit);
201
202        app.add_question();
203        assert_eq!(handle(&mut app, ctrl(KeyCode::Char('c'))), Action::Quit);
204    }
205
206    /// The whole reason `Focus::is_text_input` exists.
207    #[test]
208    fn q_types_a_letter_in_the_state_box_and_quits_outside_it() {
209        let mut app = app();
210        app.focus = Focus::State;
211
212        assert_eq!(handle(&mut app, key(KeyCode::Char('q'))), Action::None);
213        assert_eq!(app.state_text(), "q");
214
215        app.focus = Focus::Questions;
216        assert_eq!(handle(&mut app, key(KeyCode::Char('q'))), Action::Quit);
217    }
218
219    /// Likewise `a` — it must not open the editor while someone is typing a word.
220    #[test]
221    fn shortcuts_do_not_fire_while_typing_the_state() {
222        let mut app = app();
223        app.focus = Focus::State;
224
225        for ch in "against".chars() {
226            handle(&mut app, key(KeyCode::Char(ch)));
227        }
228
229        assert_eq!(app.state_text(), "against");
230        assert!(app.editor.is_none());
231    }
232
233    #[test]
234    fn ctrl_s_asks_the_loop_to_send() {
235        let mut app = with_questions();
236
237        assert_eq!(handle(&mut app, ctrl(KeyCode::Char('s'))), Action::Send);
238    }
239
240    /// A second Ctrl+S while one request is in flight would queue a duplicate.
241    #[test]
242    fn ctrl_s_is_ignored_while_a_request_is_in_flight() {
243        let mut app = with_questions();
244        app.begin_send();
245
246        assert_eq!(handle(&mut app, ctrl(KeyCode::Char('s'))), Action::None);
247    }
248
249    #[test]
250    fn esc_cancels_a_request_in_flight() {
251        let mut app = with_questions();
252        app.begin_send();
253        assert!(app.is_sending());
254
255        handle(&mut app, key(KeyCode::Esc));
256
257        assert!(!app.is_sending());
258    }
259
260    #[test]
261    fn tab_moves_between_panes_in_both_directions() {
262        let mut app = app();
263        assert_eq!(app.focus, Focus::State);
264
265        handle(&mut app, key(KeyCode::Tab));
266        assert_eq!(app.focus, Focus::Questions);
267
268        handle(&mut app, key(KeyCode::BackTab));
269        assert_eq!(app.focus, Focus::State);
270    }
271
272    #[test]
273    fn the_question_list_navigates_and_edits() {
274        let mut app = with_questions();
275
276        handle(&mut app, key(KeyCode::Down));
277        assert_eq!(app.selected, 1);
278
279        handle(&mut app, key(KeyCode::Char('e')));
280        assert!(app.editor.is_some());
281
282        handle(&mut app, key(KeyCode::Esc));
283        assert!(app.editor.is_none());
284
285        handle(&mut app, key(KeyCode::Char('d')));
286        assert_eq!(app.questions.len(), 1);
287    }
288
289    #[test]
290    fn down_past_the_last_question_lands_on_the_add_row_and_enter_opens_the_editor() {
291        let mut app = with_questions();
292
293        handle(&mut app, key(KeyCode::Down));
294        handle(&mut app, key(KeyCode::Down));
295        assert!(app.on_add_row());
296
297        handle(&mut app, key(KeyCode::Enter));
298        assert!(app.editor.as_ref().is_some_and(|e| !e.is_editing()));
299
300        handle(&mut app, key(KeyCode::Enter));
301        assert_eq!(app.questions.len(), 3);
302    }
303
304    #[test]
305    fn a_opens_the_editor_and_enter_commits_it() {
306        let mut app = with_questions();
307
308        handle(&mut app, key(KeyCode::Char('a')));
309        assert!(app.editor.is_some());
310
311        handle(&mut app, key(KeyCode::Enter));
312
313        assert!(app.editor.is_none());
314        assert_eq!(app.questions.len(), 3);
315    }
316
317    /// Keys typed into the modal must reach its fields, not the shortcuts underneath.
318    #[test]
319    fn the_editor_swallows_shortcut_keys_while_it_is_open() {
320        let mut app = with_questions();
321        app.add_question();
322
323        handle(&mut app, key(KeyCode::Char('d')));
324
325        assert_eq!(app.questions.len(), 2, "`d` must not delete while editing");
326        assert!(app.editor.is_some());
327    }
328
329    #[test]
330    fn temperature_steps_up_and_down_and_clears() {
331        let mut app = with_questions();
332
333        handle(&mut app, key(KeyCode::Char('t')));
334        assert_eq!(app.calibration, Some(1.25));
335
336        handle(&mut app, key(KeyCode::Char('t')));
337        assert_eq!(app.calibration, Some(1.5));
338
339        handle(&mut app, key(KeyCode::Char('T')));
340        assert_eq!(app.calibration, Some(1.25));
341
342        handle(&mut app, key(KeyCode::Char('c')));
343        assert_eq!(app.calibration, None, "back to the alias default");
344    }
345
346    /// The service rejects a temperature of zero or below; the form should never send one.
347    #[test]
348    fn temperature_stays_inside_a_usable_range() {
349        let mut app = with_questions();
350
351        for _ in 0..40 {
352            handle(&mut app, key(KeyCode::Char('T')));
353        }
354        assert_eq!(app.calibration, Some(0.25));
355
356        for _ in 0..80 {
357            handle(&mut app, key(KeyCode::Char('t')));
358        }
359        assert_eq!(app.calibration, Some(8.0));
360    }
361
362    #[test]
363    fn help_opens_and_any_key_closes_it() {
364        let mut app = with_questions();
365
366        handle(&mut app, key(KeyCode::Char('?')));
367        assert!(app.show_help);
368
369        handle(&mut app, key(KeyCode::Char('x')));
370        assert!(!app.show_help);
371    }
372
373    /// Key releases arrive on Windows terminals and would otherwise double every press.
374    #[test]
375    fn key_releases_are_ignored() {
376        let mut app = with_questions();
377        let mut release = key(KeyCode::Char('d'));
378        release.kind = KeyEventKind::Release;
379
380        handle(&mut app, release);
381
382        assert_eq!(app.questions.len(), 2);
383    }
384
385    #[test]
386    fn the_number_keys_jump_to_a_pane() {
387        let mut app = with_questions();
388
389        handle(&mut app, key(KeyCode::Char('3')));
390        assert_eq!(app.focus, Focus::Answers);
391
392        handle(&mut app, key(KeyCode::Char('1')));
393        assert_eq!(app.focus, Focus::State);
394    }
395
396    #[test]
397    fn the_answers_pane_scrolls_and_leaves_the_questions_alone() {
398        let mut app = with_questions();
399        app.focus = Focus::Answers;
400        app.answers_max_scroll.set(30);
401
402        handle(&mut app, key(KeyCode::Char('j')));
403        handle(&mut app, key(KeyCode::Down));
404        assert_eq!(app.answers_scroll, 2);
405        assert_eq!(app.selected, 0, "the question list did not move");
406
407        handle(&mut app, key(KeyCode::PageDown));
408        assert_eq!(app.answers_scroll, 12);
409        handle(&mut app, key(KeyCode::End));
410        assert_eq!(app.answers_scroll, 30);
411        handle(&mut app, key(KeyCode::Home));
412        assert_eq!(app.answers_scroll, 0);
413
414        for code in [KeyCode::Char('d'), KeyCode::Char('e'), KeyCode::Enter] {
415            handle(&mut app, key(code));
416        }
417        assert_eq!(app.questions.len(), 2, "nothing was deleted from here");
418        assert!(app.editor.is_none(), "no editor opened from here");
419    }
420}