Skip to main content

hjkl_form/
fsm.rs

1//! Form-level FSM. Routes keys between focus navigation, field
2//! delegation, and submit firing.
3
4use crate::field::Field;
5use crate::form::{Form, FormEvent, FormMode};
6use crate::validate::validate_field;
7use hjkl_engine::{CoarseMode, Input, Key};
8
9impl Form {
10    /// Route a key event through the form. Returns an event if the
11    /// keystroke produced one (focus moved, value changed, submit
12    /// fired, etc.).
13    pub fn handle_input(&mut self, input: Input) -> Option<FormEvent> {
14        if self.fields.is_empty() {
15            return None;
16        }
17        match self.mode {
18            FormMode::Normal => self.handle_normal(input),
19            FormMode::Insert => self.handle_insert(input),
20        }
21    }
22
23    fn handle_normal(&mut self, input: Input) -> Option<FormEvent> {
24        // `gg` is the only two-key chord we model at the form level —
25        // every other input clears the pending state.
26        let was_pending_g = self.pending_g;
27        if input.key != Key::Char('g') || input.ctrl || input.alt {
28            self.pending_g = false;
29        }
30
31        // Esc cancels the form (host decides whether to close).
32        if input.key == Key::Esc {
33            return Some(FormEvent::Cancelled);
34        }
35
36        // Focus-navigation keys that work uniformly regardless of
37        // focused-field type.
38        if let Some(ev) = self.try_navigate(input, was_pending_g) {
39            return Some(ev);
40        }
41
42        // Per-field-type handling.
43        let field_kind = field_kind(&self.fields[self.focused]);
44        match field_kind {
45            FieldKind::Checkbox => self.handle_normal_checkbox(input),
46            FieldKind::Select => self.handle_normal_select(input),
47            FieldKind::Submit => self.handle_normal_submit(input),
48            FieldKind::SingleLineText | FieldKind::MultiLineText => {
49                self.handle_normal_text(input, field_kind == FieldKind::SingleLineText)
50            }
51        }
52    }
53
54    fn try_navigate(&mut self, input: Input, was_pending_g: bool) -> Option<FormEvent> {
55        let len = self.fields.len();
56        let mut moved = false;
57        let prev = self.focused;
58        match input.key {
59            // BackTab on most terminals comes through as Tab + shift;
60            // crossterm-bridged inputs use Key::Tab with shift. This arm
61            // must precede the forward Tab arm, whose guard does not
62            // exclude shift.
63            Key::Tab if input.shift => {
64                self.focused = self.focused.saturating_sub(1);
65                moved = true;
66            }
67            Key::Char('j') | Key::Down | Key::Tab if !input.ctrl && !input.alt => {
68                self.focused = (self.focused + 1) % len;
69                moved = true;
70            }
71            Key::Char('k') | Key::Up if !input.ctrl && !input.alt => {
72                self.focused = self.focused.saturating_sub(1);
73                moved = true;
74            }
75            Key::Char('g') if !input.ctrl && !input.alt && !input.shift => {
76                if was_pending_g {
77                    self.focused = 0;
78                    moved = true;
79                } else {
80                    self.pending_g = true;
81                    return None;
82                }
83            }
84            Key::Char('G') if !input.ctrl && !input.alt => {
85                self.focused = len - 1;
86                moved = true;
87            }
88            _ => {}
89        }
90        if moved {
91            if prev != self.focused {
92                // Blur: force the previous text field back to Normal so it
93                // isn't left stuck in Visual (e.g. after `v`/`V`) or Insert
94                // when the user navigates away with j/k.
95                if let Field::SingleLineText(f) | Field::MultiLineText(f) = &mut self.fields[prev] {
96                    f.enter_normal();
97                }
98                // Run blur validator on the previous field.
99                validate_field(&mut self.fields[prev]);
100                self.bump_dirty();
101            }
102            return Some(FormEvent::Changed);
103        }
104        None
105    }
106
107    fn handle_normal_checkbox(&mut self, input: Input) -> Option<FormEvent> {
108        match input.key {
109            Key::Char(' ') | Key::Enter => {
110                if let Field::Checkbox(c) = &mut self.fields[self.focused] {
111                    c.value = !c.value;
112                }
113                self.bump_dirty();
114                Some(FormEvent::Changed)
115            }
116            _ => None,
117        }
118    }
119
120    fn handle_normal_select(&mut self, input: Input) -> Option<FormEvent> {
121        match input.key {
122            Key::Char('l') | Key::Right if !input.ctrl && !input.alt => {
123                if let Field::Select(s) = &mut self.fields[self.focused]
124                    && !s.options.is_empty()
125                {
126                    s.index = (s.index + 1) % s.options.len();
127                }
128                self.bump_dirty();
129                Some(FormEvent::Changed)
130            }
131            Key::Char('h') | Key::Left if !input.ctrl && !input.alt => {
132                if let Field::Select(s) = &mut self.fields[self.focused]
133                    && !s.options.is_empty()
134                {
135                    s.index = if s.index == 0 {
136                        s.options.len() - 1
137                    } else {
138                        s.index - 1
139                    };
140                }
141                self.bump_dirty();
142                Some(FormEvent::Changed)
143            }
144            _ => None,
145        }
146    }
147
148    fn handle_normal_submit(&mut self, input: Input) -> Option<FormEvent> {
149        if input.key == Key::Enter || (input.key == Key::Char(' ') && !input.ctrl && !input.alt) {
150            return Some(self.try_submit_event());
151        }
152        None
153    }
154
155    fn handle_normal_text(&mut self, input: Input, _single_line: bool) -> Option<FormEvent> {
156        // Forward the key to the inner editor; if it lands in Insert
157        // (i/I/a/A, but also o/O, s/S, C, cw, ...), mirror that at the
158        // form level so subsequent keys are delegated instead of being
159        // treated as focus navigation.
160        if let Field::SingleLineText(f) | Field::MultiLineText(f) = &mut self.fields[self.focused] {
161            let prev_gen_before = f.editor.buffer().dirty_gen();
162            hjkl_vim::dispatch_input(&mut f.editor, input);
163            if f.editor.coarse_mode() == CoarseMode::Insert {
164                f.enter_gen = prev_gen_before;
165                self.mode = FormMode::Insert;
166                return Some(FormEvent::Changed);
167            }
168        }
169        // Other motion keys (h/l/w/b/etc) just forwarded; emit Changed
170        // so renderers refresh cursor.
171        Some(FormEvent::Changed)
172    }
173
174    fn handle_insert(&mut self, input: Input) -> Option<FormEvent> {
175        // Resync guard: if focus somehow landed on a non-text field while
176        // the form is in Insert (e.g. a host called `set_focus`), fall
177        // back to Normal so navigation and Esc keep working.
178        if !self.fields[self.focused].is_text() {
179            self.mode = FormMode::Normal;
180            return self.handle_normal(input);
181        }
182
183        // Single-line text: Enter jumps to next field instead of
184        // inserting a newline.
185        let single = matches!(self.fields[self.focused], Field::SingleLineText(_));
186        if single && input.key == Key::Enter {
187            let len = self.fields.len();
188            if self.focused + 1 < len {
189                let prev = self.focused;
190                self.focused += 1;
191                // Leave the previous field's editor in Normal so later
192                // form-Normal keys don't leak into its Insert mode.
193                if let Field::SingleLineText(f) | Field::MultiLineText(f) = &mut self.fields[prev] {
194                    f.enter_normal();
195                }
196                validate_field(&mut self.fields[prev]);
197                // Keep form-Insert only when the next field is text —
198                // and put its editor into Insert so keystrokes insert
199                // instead of running Normal-mode commands. Otherwise
200                // drop to form-Normal to match the focused field kind.
201                match &mut self.fields[self.focused] {
202                    Field::SingleLineText(f) | Field::MultiLineText(f) => {
203                        f.enter_gen = f.editor.buffer().dirty_gen();
204                        f.enter_insert_at_end();
205                    }
206                    _ => self.mode = FormMode::Normal,
207                }
208                self.bump_dirty();
209            }
210            return Some(FormEvent::Changed);
211        }
212
213        // Forward to the focused field's editor.
214        if let Field::SingleLineText(f) | Field::MultiLineText(f) = &mut self.fields[self.focused] {
215            let before_gen = f.editor.buffer().dirty_gen();
216            hjkl_vim::dispatch_input(&mut f.editor, input);
217            let after_mode = f.editor.coarse_mode();
218            let after_gen = f.editor.buffer().dirty_gen();
219            if after_mode == CoarseMode::Normal {
220                // User pressed Esc — leave insert mode.
221                self.mode = FormMode::Normal;
222                let prev_focus = self.focused;
223                validate_field(&mut self.fields[prev_focus]);
224                self.bump_dirty();
225                return Some(FormEvent::Changed);
226            }
227            if after_gen != before_gen {
228                self.bump_dirty();
229                return Some(FormEvent::Changed);
230            }
231        }
232        None
233    }
234
235    /// Run all validators; if all pass, fire the submit closure.
236    /// Returns the appropriate `FormEvent`.
237    fn try_submit_event(&mut self) -> FormEvent {
238        match self.try_submit() {
239            Some(outcome) => FormEvent::Submitted(outcome),
240            None => FormEvent::ValidationFailed,
241        }
242    }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246enum FieldKind {
247    SingleLineText,
248    MultiLineText,
249    Select,
250    Checkbox,
251    Submit,
252}
253
254fn field_kind(field: &Field) -> FieldKind {
255    match field {
256        Field::SingleLineText(_) => FieldKind::SingleLineText,
257        Field::MultiLineText(_) => FieldKind::MultiLineText,
258        Field::Select(_) => FieldKind::Select,
259        Field::Checkbox(_) => FieldKind::Checkbox,
260        Field::Submit(_) => FieldKind::Submit,
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::field::{CheckboxField, FieldMeta, SelectField, SubmitField, TextFieldEditor};
268    use crate::submit::SubmitOutcome;
269    use std::sync::Arc;
270    use std::sync::atomic::{AtomicBool, Ordering};
271
272    fn key(c: char) -> Input {
273        Input {
274            key: Key::Char(c),
275            ..Input::default()
276        }
277    }
278
279    fn special(k: Key) -> Input {
280        Input {
281            key: k,
282            ..Input::default()
283        }
284    }
285
286    fn make_form() -> Form {
287        Form::new()
288            .with_field(Field::SingleLineText(TextFieldEditor::with_meta(
289                FieldMeta::new("Name"),
290                1,
291            )))
292            .with_field(Field::SingleLineText(TextFieldEditor::with_meta(
293                FieldMeta::new("Email"),
294                1,
295            )))
296            .with_field(Field::Checkbox(CheckboxField::new(FieldMeta::new("Save"))))
297            .with_field(Field::Select(SelectField::new(
298                FieldMeta::new("Format"),
299                vec!["json".into(), "yaml".into(), "toml".into()],
300            )))
301            .with_field(Field::Submit(SubmitField::new(FieldMeta::new("Submit"))))
302    }
303
304    #[test]
305    fn j_advances_focus() {
306        let mut form = make_form();
307        form.handle_input(key('j'));
308        assert_eq!(form.focused(), 1);
309    }
310
311    #[test]
312    fn blur_resets_field_from_visual_to_normal() {
313        let mut form = make_form();
314        // Enter Visual on the focused (field 0) text editor.
315        form.handle_input(key('v'));
316        let in_visual = matches!(
317            &form.fields[0],
318            Field::SingleLineText(f) | Field::MultiLineText(f) if f.coarse_mode() != CoarseMode::Normal
319        );
320        assert!(
321            in_visual,
322            "`v` must put the field editor in a non-Normal mode"
323        );
324
325        // Navigate away with `j`.
326        form.handle_input(key('j'));
327        assert_eq!(form.focused(), 1);
328
329        // The blurred field must be reset to Normal, not left in Visual.
330        match &form.fields[0] {
331            Field::SingleLineText(f) | Field::MultiLineText(f) => {
332                assert_eq!(
333                    f.coarse_mode(),
334                    CoarseMode::Normal,
335                    "blurred field must reset to Normal"
336                );
337            }
338            _ => panic!("field 0 must be a text field"),
339        }
340    }
341
342    #[test]
343    fn j_past_end_wraps_to_zero() {
344        let mut form = make_form();
345        for _ in 0..form.fields.len() {
346            form.handle_input(key('j'));
347        }
348        assert_eq!(form.focused(), 0);
349    }
350
351    #[test]
352    fn k_past_zero_saturates() {
353        let mut form = make_form();
354        form.handle_input(key('k'));
355        assert_eq!(form.focused(), 0);
356    }
357
358    #[test]
359    fn gg_jumps_to_zero() {
360        let mut form = make_form();
361        form.handle_input(key('j'));
362        form.handle_input(key('j'));
363        assert_eq!(form.focused(), 2);
364        form.handle_input(key('g'));
365        form.handle_input(key('g'));
366        assert_eq!(form.focused(), 0);
367    }
368
369    #[test]
370    fn capital_g_jumps_to_last() {
371        let mut form = make_form();
372        let last = form.fields.len() - 1;
373        form.handle_input(Input {
374            key: Key::Char('G'),
375            shift: true,
376            ..Input::default()
377        });
378        assert_eq!(form.focused(), last);
379    }
380
381    #[test]
382    fn i_on_text_enters_insert() {
383        let mut form = make_form();
384        form.handle_input(key('i'));
385        assert_eq!(form.mode, FormMode::Insert);
386    }
387
388    #[test]
389    fn esc_after_insert_returns_to_normal() {
390        let mut form =
391            make_form().with_field(Field::Submit(SubmitField::new(FieldMeta::new("Sub"))));
392        // Enter insert, type a char, then Esc.
393        form.handle_input(key('i'));
394        assert_eq!(form.mode, FormMode::Insert);
395        form.handle_input(key('x'));
396        form.handle_input(special(Key::Esc));
397        assert_eq!(form.mode, FormMode::Normal);
398    }
399
400    #[test]
401    fn enter_on_submit_fires_submit_fn() {
402        let fired = Arc::new(AtomicBool::new(false));
403        let f2 = fired.clone();
404        let mut form = make_form().with_submit(Box::new(move || {
405            f2.store(true, Ordering::SeqCst);
406            SubmitOutcome::Ok
407        }));
408        // Jump to last (Submit) field.
409        form.handle_input(Input {
410            key: Key::Char('G'),
411            shift: true,
412            ..Input::default()
413        });
414        let ev = form.handle_input(special(Key::Enter));
415        assert!(matches!(ev, Some(FormEvent::Submitted(SubmitOutcome::Ok))));
416        assert!(fired.load(Ordering::SeqCst));
417    }
418
419    #[test]
420    fn submit_with_failing_validator_does_not_fire() {
421        let fired = Arc::new(AtomicBool::new(false));
422        let f2 = fired.clone();
423        let mut name = TextFieldEditor::with_meta(FieldMeta::new("Name").required(true), 1);
424        name.validator = Some(Box::new(|s: &str| {
425            if s.is_empty() {
426                Err("required".into())
427            } else {
428                Ok(())
429            }
430        }));
431        let mut form = Form::new()
432            .with_field(Field::SingleLineText(name))
433            .with_field(Field::Submit(SubmitField::new(FieldMeta::new("Submit"))))
434            .with_submit(Box::new(move || {
435                f2.store(true, Ordering::SeqCst);
436                SubmitOutcome::Ok
437            }));
438        form.handle_input(key('j'));
439        let ev = form.handle_input(special(Key::Enter));
440        assert!(matches!(ev, Some(FormEvent::ValidationFailed)));
441        assert!(!fired.load(Ordering::SeqCst));
442    }
443
444    #[test]
445    fn enter_in_insert_jumps_focus_to_next() {
446        let mut form = make_form();
447        form.handle_input(key('i'));
448        assert_eq!(form.mode, FormMode::Insert);
449        form.handle_input(special(Key::Enter));
450        assert_eq!(form.focused(), 1);
451        // Stays in Insert after the focus jump.
452        assert_eq!(form.mode, FormMode::Insert);
453    }
454
455    #[test]
456    fn shift_tab_moves_focus_backwards() {
457        let mut form = make_form();
458        form.handle_input(key('j'));
459        form.handle_input(key('j'));
460        assert_eq!(form.focused(), 2);
461        form.handle_input(Input {
462            key: Key::Tab,
463            shift: true,
464            ..Input::default()
465        });
466        assert_eq!(form.focused(), 1, "Shift-Tab must move focus backwards");
467    }
468
469    #[test]
470    fn enter_in_insert_puts_next_text_field_in_insert() {
471        let mut form = make_form();
472        form.handle_input(key('i'));
473        form.handle_input(key('a')); // type into Name
474        form.handle_input(special(Key::Enter)); // jump to Email
475        assert_eq!(form.focused(), 1);
476        assert_eq!(form.mode, FormMode::Insert);
477        // Typing must insert into Email, not run Normal-mode commands.
478        form.handle_input(key('x'));
479        if let Field::SingleLineText(f) = &form.fields[1] {
480            assert_eq!(f.text(), "x");
481        } else {
482            panic!("expected text field");
483        }
484        // The previous field's inner editor must be back in Normal.
485        if let Field::SingleLineText(f) = &form.fields[0] {
486            assert_eq!(f.coarse_mode(), CoarseMode::Normal);
487        } else {
488            panic!("expected text field");
489        }
490    }
491
492    #[test]
493    fn enter_in_insert_onto_non_text_field_returns_to_normal() {
494        let mut form = make_form();
495        // Focus Email (index 1) — the field before the checkbox.
496        form.handle_input(key('j'));
497        form.handle_input(key('i'));
498        assert_eq!(form.mode, FormMode::Insert);
499        form.handle_input(special(Key::Enter)); // jump to checkbox
500        assert_eq!(form.focused(), 2);
501        assert_eq!(
502            form.mode,
503            FormMode::Normal,
504            "form must not stay in Insert on a non-text field"
505        );
506        // Sanity: form still responds (no soft-lock) — Esc cancels.
507        let ev = form.handle_input(special(Key::Esc));
508        assert!(matches!(ev, Some(FormEvent::Cancelled)));
509    }
510
511    #[test]
512    fn o_on_text_field_enters_insert_mode() {
513        let mut form = make_form();
514        form.handle_input(key('o'));
515        assert_eq!(
516            form.mode,
517            FormMode::Insert,
518            "any Normal command landing in editor-Insert must switch the form to Insert"
519        );
520    }
521
522    #[test]
523    fn checkbox_toggles_on_space() {
524        let mut form = make_form();
525        // Move to checkbox at index 2.
526        form.handle_input(key('j'));
527        form.handle_input(key('j'));
528        assert_eq!(form.focused(), 2);
529        form.handle_input(key(' '));
530        if let Field::Checkbox(c) = &form.fields[2] {
531            assert!(c.value);
532        } else {
533            panic!("expected checkbox");
534        }
535    }
536
537    #[test]
538    fn select_cycles_on_h_l() {
539        let mut form = make_form();
540        // Move to select at index 3.
541        for _ in 0..3 {
542            form.handle_input(key('j'));
543        }
544        assert_eq!(form.focused(), 3);
545        form.handle_input(key('l'));
546        if let Field::Select(s) = &form.fields[3] {
547            assert_eq!(s.index, 1);
548        } else {
549            panic!("expected select");
550        }
551        form.handle_input(key('h'));
552        form.handle_input(key('h'));
553        if let Field::Select(s) = &form.fields[3] {
554            assert_eq!(s.index, 2); // wrapped
555        } else {
556            panic!("expected select");
557        }
558    }
559}