1use crate::app::{App, Focus};
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Action {
12 None,
13 Send,
14 Quit,
15}
16
17const 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 if key.kind == KeyEventKind::Release {
25 return Action::None;
26 }
27
28 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
29
30 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 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 app.state.input(key);
72 app.mark_stale();
73 return Action::None;
74 }
75
76 handle_shortcut(app, key)
77}
78
79const PAGE: i32 = 10;
81
82fn handle_shortcut(app: &mut App, key: KeyEvent) -> Action {
83 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 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 #[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 #[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 #[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 #[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 #[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 #[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}