Skip to main content

datui_lib/
form.rs

1//! One focus convention for every form and dialog.
2//!
3//! A form is an ordered list of fields, each of a [`FieldKind`]. A modal says which
4//! fields it shows right now and which one has focus ([`Form`]); this module moves
5//! focus and decides what a key means, so every dialog answers the same keys the
6//! same way:
7//!
8//! | Key | Does |
9//! |---|---|
10//! | Tab / Shift+Tab, ↓ / ↑ | next / previous field, wrapping |
11//! | ← / → | step a choice; move the cursor in a text field |
12//! | Space | toggle a checkbox, next value of a choice, open a picker, press a button |
13//! | Enter | submit, from any field (a multiline field types it; Ctrl+J submits) |
14//! | Esc | cancel the form (an open picker closes first: [`picker_key`]) |
15//! | Ctrl+P / Ctrl+N | history in a text field |
16//!
17//! `h` `j` `k` `l` are the arrows on a field that does not type. What a submit, a
18//! step or an action does stays with the modal: [`key`] says which happened, to
19//! which field.
20
21use crate::widgets::ui::PickerState;
22use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
23
24/// How a field takes keys.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FieldKind {
27    /// One line of text. The arrows across and every editing key are its own;
28    /// Ctrl+P / Ctrl+N recall its history.
29    Text,
30    /// Several lines: Enter breaks the line, and ↑ / ↓ move between lines,
31    /// leaving the field only from its first or last line ([`Form::text_edge`]).
32    MultilineText,
33    /// A value from a short list: ← / → step it, Space takes the next, wrapping.
34    Choice,
35    /// On or off: Space, ← or → flip it.
36    Checkbox,
37    /// A value from a list too long to step through: Space opens a picker. A
38    /// pick-one picker also steps with ← / →; `multi` marks one that toggles
39    /// several values, which does not.
40    Picker { multi: bool },
41    /// An action: Space does it.
42    Button,
43}
44
45impl FieldKind {
46    /// Whether the field takes typed characters, so letters are not keys there.
47    pub fn types(self) -> bool {
48        matches!(self, Self::Text | Self::MultilineText)
49    }
50}
51
52/// What a key did to a form, for the modal to carry out.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum FormKey<F> {
55    /// Focus moved; nothing else to do.
56    Moved,
57    /// Enter (or Ctrl+J): apply the form.
58    Submit,
59    /// Esc: close the form, discarding its edits.
60    Cancel,
61    /// Step the field's value by `delta` (-1 or 1): ← / → on a choice or a pick-one
62    /// picker, Space on a choice.
63    Step(F, i8),
64    /// Act on the field: flip a checkbox, open a picker, press a button.
65    Act(F),
66    /// The key belongs to the focused text field: hand it to its input.
67    Text(F),
68    /// Not a form key here; the modal's own keys (or none) take it.
69    Other,
70}
71
72/// A form: its fields in order and the one with focus. The modal owns both; the
73/// default methods move focus the same way for every form.
74pub trait Form {
75    /// The modal's name for a field.
76    type Field: Copy + Eq + std::fmt::Debug;
77
78    /// The fields on screen, top to bottom, each with its kind. A field the current
79    /// state hides or disables is left out, so focus never lands on it.
80    fn fields(&self) -> Vec<(Self::Field, FieldKind)>;
81
82    fn focused(&self) -> Self::Field;
83
84    /// Put focus on `field`. Called only with a field of [`Form::fields`]; a modal
85    /// that shows a text cursor in the focused field syncs it here.
86    fn set_focused(&mut self, field: Self::Field);
87
88    /// For a multiline field: whether its cursor is on the first line, and on the
89    /// last, where ↑ and ↓ leave it.
90    fn text_edge(&self, _field: Self::Field) -> (bool, bool) {
91        (true, true)
92    }
93
94    /// Whether `field` is a row of a list (a sort, a filter, a column) rather than a
95    /// setting. A click on a list row only focuses it, so it can be picked to move or
96    /// remove without changing it; a click on it once focused acts. A setting (a
97    /// checkbox, a choice, a button) acts on the first click.
98    fn list_row(&self, _field: Self::Field) -> bool {
99        false
100    }
101
102    /// The focused field's kind; `None` when focus is on a field not shown.
103    fn focused_kind(&self) -> Option<FieldKind> {
104        let focused = self.focused();
105        self.fields()
106            .into_iter()
107            .find(|(field, _)| *field == focused)
108            .map(|(_, kind)| kind)
109    }
110
111    /// Focus `field` if the form shows it: a click on a row, or a modal moving
112    /// focus itself. Returns whether it did.
113    fn focus(&mut self, field: Self::Field) -> bool {
114        let shown = self.fields().iter().any(|(f, _)| *f == field);
115        if shown {
116            self.set_focused(field);
117        }
118        shown
119    }
120
121    /// Move focus `delta` fields along, wrapping. A focus the form no longer shows
122    /// counts from the top.
123    fn move_focus(&mut self, delta: isize) {
124        let fields = self.fields();
125        if fields.is_empty() {
126            return;
127        }
128        let focused = self.focused();
129        let n = fields.len() as isize;
130        let next = match fields.iter().position(|(f, _)| *f == focused) {
131            Some(at) => (at as isize + delta).rem_euclid(n),
132            None if delta < 0 => n - 1,
133            None => 0,
134        };
135        self.set_focused(fields[next as usize].0);
136    }
137
138    fn focus_next(&mut self) {
139        self.move_focus(1);
140    }
141
142    fn focus_prev(&mut self) {
143        self.move_focus(-1);
144    }
145
146    /// After a change that hides fields: when the focused one went, focus the first
147    /// field instead, so it never points at nothing.
148    fn settle_focus(&mut self) {
149        if self.focused_kind().is_none()
150            && let Some((first, _)) = self.fields().first().copied()
151        {
152            self.set_focused(first);
153        }
154    }
155}
156
157fn plain(event: &KeyEvent) -> bool {
158    event
159        .modifiers
160        .intersection(KeyModifiers::CONTROL | KeyModifiers::ALT)
161        .is_empty()
162}
163
164/// What `event` does to `form` while no picker is open. Focus moves happen here;
165/// everything else is returned for the modal to do.
166pub fn key<T: Form + ?Sized>(form: &mut T, event: &KeyEvent) -> FormKey<T::Field> {
167    let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
168    let shift = event.modifiers.contains(KeyModifiers::SHIFT);
169    let Some(kind) = form.focused_kind() else {
170        // Focus on nothing (a form with no fields, or one just emptied): only the
171        // ways in and out work.
172        form.settle_focus();
173        return match event.code {
174            KeyCode::Esc => FormKey::Cancel,
175            KeyCode::Enter => FormKey::Submit,
176            _ => FormKey::Other,
177        };
178    };
179    let field = form.focused();
180    // A field that does not type reads hjkl as the arrows.
181    let code = match event.code {
182        KeyCode::Char('h') if !kind.types() && plain(event) => KeyCode::Left,
183        KeyCode::Char('j') if !kind.types() && plain(event) => KeyCode::Down,
184        KeyCode::Char('k') if !kind.types() && plain(event) => KeyCode::Up,
185        KeyCode::Char('l') if !kind.types() && plain(event) => KeyCode::Right,
186        code => code,
187    };
188    match code {
189        KeyCode::Esc => FormKey::Cancel,
190        // Ctrl+J beside Ctrl+Enter: some terminals send one as the other.
191        KeyCode::Enter | KeyCode::Char('j' | 'J') if ctrl => FormKey::Submit,
192        KeyCode::Enter if kind == FieldKind::MultilineText => FormKey::Text(field),
193        KeyCode::Enter => FormKey::Submit,
194        KeyCode::BackTab => {
195            form.focus_prev();
196            FormKey::Moved
197        }
198        KeyCode::Tab if shift => {
199            form.focus_prev();
200            FormKey::Moved
201        }
202        KeyCode::Tab => {
203            form.focus_next();
204            FormKey::Moved
205        }
206        KeyCode::Up | KeyCode::Down if event.modifiers.is_empty() => {
207            let up = code == KeyCode::Up;
208            if kind == FieldKind::MultilineText {
209                let (first, last) = form.text_edge(field);
210                if (up && !first) || (!up && !last) {
211                    return FormKey::Text(field);
212                }
213            }
214            form.move_focus(if up { -1 } else { 1 });
215            FormKey::Moved
216        }
217        KeyCode::Left | KeyCode::Right => {
218            let delta = if code == KeyCode::Left { -1 } else { 1 };
219            match kind {
220                FieldKind::Text | FieldKind::MultilineText => FormKey::Text(field),
221                FieldKind::Choice | FieldKind::Picker { multi: false } => {
222                    FormKey::Step(field, delta)
223                }
224                FieldKind::Checkbox => FormKey::Act(field),
225                FieldKind::Picker { multi: true } | FieldKind::Button => FormKey::Other,
226            }
227        }
228        KeyCode::Char(' ') if plain(event) => match kind {
229            FieldKind::Text | FieldKind::MultilineText => FormKey::Text(field),
230            FieldKind::Choice => FormKey::Step(field, 1),
231            FieldKind::Checkbox | FieldKind::Picker { .. } | FieldKind::Button => {
232                FormKey::Act(field)
233            }
234        },
235        _ if kind.types() => FormKey::Text(field),
236        _ => FormKey::Other,
237    }
238}
239
240/// What a key did in an open picker.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum PickerKey {
243    /// Moved or narrowed the list; nothing else to do.
244    Handled,
245    /// Esc: close the picker, choosing nothing.
246    Close,
247    /// Enter, or Space on a pick-one list: take the cursor's item and close.
248    Choose,
249    /// Space on a list that takes several: flip the cursor's item, staying open.
250    Toggle,
251    /// Tab / Shift+Tab: choose, close, and move focus on (`true`) or back.
252    ChooseAndMove(bool),
253    /// Not a picker key.
254    Other,
255}
256
257/// What `event` does in an open picker: type to narrow, ↑↓ move, Space chooses
258/// (or toggles in a list of several), Enter chooses, Esc closes the picker only.
259/// A space never reaches the narrowing filter: it would match nothing and blank
260/// the list under the key that opened it.
261pub fn picker_key(picker: &mut PickerState, multi: bool, event: &KeyEvent) -> PickerKey {
262    let shift = event.modifiers.contains(KeyModifiers::SHIFT);
263    match event.code {
264        KeyCode::Esc => PickerKey::Close,
265        KeyCode::Enter => PickerKey::Choose,
266        KeyCode::BackTab => PickerKey::ChooseAndMove(false),
267        KeyCode::Tab => PickerKey::ChooseAndMove(!shift),
268        KeyCode::Up => {
269            picker.move_up();
270            PickerKey::Handled
271        }
272        KeyCode::Down => {
273            picker.move_down();
274            PickerKey::Handled
275        }
276        KeyCode::Char(' ') if multi => PickerKey::Toggle,
277        KeyCode::Char(' ') => PickerKey::Choose,
278        KeyCode::Backspace => {
279            picker.backspace();
280            PickerKey::Handled
281        }
282        KeyCode::Char(c) => {
283            picker.filter_key(c, event.modifiers);
284            PickerKey::Handled
285        }
286        _ => PickerKey::Other,
287    }
288}
289
290/// Index `at` stepped by `delta` through `len` values, wrapping.
291pub fn step_index(at: usize, len: usize, delta: i8) -> usize {
292    if len == 0 {
293        return 0;
294    }
295    (at as isize + delta as isize).rem_euclid(len as isize) as usize
296}
297
298/// `current` stepped by `delta` through `all`, wrapping; the first value when
299/// `current` is not among them.
300pub fn step_value<T: Copy + PartialEq>(all: &[T], current: T, delta: i8) -> T {
301    let at = all.iter().position(|v| *v == current);
302    match at {
303        Some(at) => all[step_index(at, all.len(), delta)],
304        None => all[0],
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
313    enum F {
314        Name,
315        Notes,
316        Format,
317        Header,
318        Column,
319        Tags,
320        Go,
321    }
322
323    struct T {
324        focus: F,
325        show_header: bool,
326        edge: (bool, bool),
327    }
328
329    impl Form for T {
330        type Field = F;
331        fn fields(&self) -> Vec<(F, FieldKind)> {
332            let mut fields = vec![
333                (F::Name, FieldKind::Text),
334                (F::Notes, FieldKind::MultilineText),
335                (F::Format, FieldKind::Choice),
336            ];
337            if self.show_header {
338                fields.push((F::Header, FieldKind::Checkbox));
339            }
340            fields.extend([
341                (F::Column, FieldKind::Picker { multi: false }),
342                (F::Tags, FieldKind::Picker { multi: true }),
343                (F::Go, FieldKind::Button),
344            ]);
345            fields
346        }
347        fn focused(&self) -> F {
348            self.focus
349        }
350        fn set_focused(&mut self, field: F) {
351            self.focus = field;
352        }
353        fn text_edge(&self, _: F) -> (bool, bool) {
354            self.edge
355        }
356    }
357
358    fn form() -> T {
359        T {
360            focus: F::Name,
361            show_header: true,
362            edge: (true, true),
363        }
364    }
365
366    fn press(code: KeyCode) -> KeyEvent {
367        KeyEvent::new(code, KeyModifiers::NONE)
368    }
369
370    fn ctrl(c: char) -> KeyEvent {
371        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
372    }
373
374    #[test]
375    fn tab_and_the_arrows_walk_every_field_and_wrap() {
376        let mut f = form();
377        let mut walked = vec![f.focus];
378        for _ in 0..7 {
379            assert_eq!(key(&mut f, &press(KeyCode::Down)), FormKey::Moved);
380            walked.push(f.focus);
381        }
382        assert_eq!(
383            walked,
384            [
385                F::Name,
386                F::Notes,
387                F::Format,
388                F::Header,
389                F::Column,
390                F::Tags,
391                F::Go,
392                F::Name
393            ]
394        );
395        key(&mut f, &press(KeyCode::Up));
396        assert_eq!(f.focus, F::Go, "↑ from the first field wraps to the last");
397        key(&mut f, &press(KeyCode::Tab));
398        assert_eq!(f.focus, F::Name);
399        key(&mut f, &press(KeyCode::BackTab));
400        assert_eq!(f.focus, F::Go);
401        key(&mut f, &KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
402        assert_eq!(
403            f.focus,
404            F::Tags,
405            "Shift+Tab as Tab with Shift goes back too"
406        );
407    }
408
409    #[test]
410    fn hidden_fields_are_skipped_and_a_hidden_focus_settles() {
411        let mut f = form();
412        f.show_header = false;
413        f.focus = F::Format;
414        key(&mut f, &press(KeyCode::Down));
415        assert_eq!(f.focus, F::Column);
416        f.focus = F::Header;
417        f.settle_focus();
418        assert_eq!(f.focus, F::Name);
419        assert!(!f.focus(F::Header), "a hidden field cannot take focus");
420        assert!(f.focus(F::Tags));
421        assert_eq!(f.focus, F::Tags);
422    }
423
424    #[test]
425    fn the_arrows_across_step_choices_and_edit_text() {
426        let mut f = form();
427        assert_eq!(
428            key(&mut f, &press(KeyCode::Left)),
429            FormKey::Text(F::Name),
430            "a text field keeps ← for its cursor"
431        );
432        f.focus = F::Format;
433        assert_eq!(
434            key(&mut f, &press(KeyCode::Left)),
435            FormKey::Step(F::Format, -1)
436        );
437        assert_eq!(
438            key(&mut f, &press(KeyCode::Right)),
439            FormKey::Step(F::Format, 1)
440        );
441        f.focus = F::Column;
442        assert_eq!(
443            key(&mut f, &press(KeyCode::Right)),
444            FormKey::Step(F::Column, 1)
445        );
446        f.focus = F::Tags;
447        assert_eq!(key(&mut f, &press(KeyCode::Right)), FormKey::Other);
448        f.focus = F::Header;
449        assert_eq!(key(&mut f, &press(KeyCode::Right)), FormKey::Act(F::Header));
450    }
451
452    #[test]
453    fn space_acts_on_the_field() {
454        let mut f = form();
455        let space = press(KeyCode::Char(' '));
456        assert_eq!(key(&mut f, &space), FormKey::Text(F::Name), "text types it");
457        f.focus = F::Format;
458        assert_eq!(key(&mut f, &space), FormKey::Step(F::Format, 1));
459        for field in [F::Header, F::Column, F::Tags, F::Go] {
460            f.focus = field;
461            assert_eq!(key(&mut f, &space), FormKey::Act(field));
462        }
463    }
464
465    #[test]
466    fn enter_submits_from_any_field_but_types_in_a_multiline_one() {
467        let mut f = form();
468        for field in [F::Name, F::Format, F::Header, F::Column, F::Tags, F::Go] {
469            f.focus = field;
470            assert_eq!(key(&mut f, &press(KeyCode::Enter)), FormKey::Submit);
471        }
472        f.focus = F::Notes;
473        assert_eq!(key(&mut f, &press(KeyCode::Enter)), FormKey::Text(F::Notes));
474        assert_eq!(key(&mut f, &ctrl('j')), FormKey::Submit);
475        assert_eq!(
476            key(
477                &mut f,
478                &KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)
479            ),
480            FormKey::Submit
481        );
482    }
483
484    #[test]
485    fn esc_cancels_from_any_field() {
486        let mut f = form();
487        for field in [F::Name, F::Notes, F::Format, F::Go] {
488            f.focus = field;
489            assert_eq!(key(&mut f, &press(KeyCode::Esc)), FormKey::Cancel);
490        }
491    }
492
493    #[test]
494    fn history_is_ctrl_p_and_n_and_the_arrows_never_recall() {
495        let mut f = form();
496        assert_eq!(key(&mut f, &ctrl('p')), FormKey::Text(F::Name));
497        assert_eq!(key(&mut f, &ctrl('n')), FormKey::Text(F::Name));
498        assert_eq!(key(&mut f, &press(KeyCode::Up)), FormKey::Moved);
499        assert_eq!(f.focus, F::Go);
500    }
501
502    #[test]
503    fn a_multiline_field_keeps_the_arrows_until_its_edge() {
504        let mut f = form();
505        f.focus = F::Notes;
506        f.edge = (false, false);
507        assert_eq!(key(&mut f, &press(KeyCode::Up)), FormKey::Text(F::Notes));
508        assert_eq!(key(&mut f, &press(KeyCode::Down)), FormKey::Text(F::Notes));
509        f.edge = (true, false);
510        assert_eq!(key(&mut f, &press(KeyCode::Up)), FormKey::Moved);
511        assert_eq!(f.focus, F::Name);
512    }
513
514    #[test]
515    fn vim_keys_move_only_where_nothing_types() {
516        let mut f = form();
517        assert_eq!(
518            key(&mut f, &press(KeyCode::Char('j'))),
519            FormKey::Text(F::Name)
520        );
521        f.focus = F::Format;
522        assert_eq!(
523            key(&mut f, &press(KeyCode::Char('l'))),
524            FormKey::Step(F::Format, 1)
525        );
526        key(&mut f, &press(KeyCode::Char('j')));
527        assert_eq!(f.focus, F::Header);
528        key(&mut f, &press(KeyCode::Char('k')));
529        assert_eq!(f.focus, F::Format);
530        assert_eq!(key(&mut f, &press(KeyCode::Char('x'))), FormKey::Other);
531    }
532
533    #[test]
534    fn the_picker_narrows_moves_and_never_types_a_space() {
535        let mut p = PickerState::new(vec!["alpha".into(), "beta".into(), "gamma".into()]);
536        assert_eq!(
537            picker_key(&mut p, false, &press(KeyCode::Down)),
538            PickerKey::Handled
539        );
540        assert_eq!(p.selected_original(), Some(1));
541        assert_eq!(
542            picker_key(&mut p, false, &press(KeyCode::Char('g'))),
543            PickerKey::Handled
544        );
545        assert_eq!(p.filter, "g");
546        assert_eq!(
547            picker_key(&mut p, false, &press(KeyCode::Char(' '))),
548            PickerKey::Choose
549        );
550        assert_eq!(
551            picker_key(&mut p, true, &press(KeyCode::Char(' '))),
552            PickerKey::Toggle
553        );
554        assert_eq!(p.filter, "g", "a space never narrows");
555        assert_eq!(
556            picker_key(&mut p, false, &press(KeyCode::Enter)),
557            PickerKey::Choose
558        );
559        assert_eq!(
560            picker_key(&mut p, false, &press(KeyCode::Esc)),
561            PickerKey::Close
562        );
563        assert_eq!(
564            picker_key(&mut p, false, &press(KeyCode::Tab)),
565            PickerKey::ChooseAndMove(true)
566        );
567        assert_eq!(
568            picker_key(&mut p, false, &press(KeyCode::BackTab)),
569            PickerKey::ChooseAndMove(false)
570        );
571    }
572
573    #[test]
574    fn steps_wrap_both_ways() {
575        assert_eq!(step_index(0, 3, -1), 2);
576        assert_eq!(step_index(2, 3, 1), 0);
577        assert_eq!(step_value(&['a', 'b', 'c'], 'b', 1), 'c');
578        assert_eq!(step_value(&['a', 'b', 'c'], 'z', 1), 'a');
579    }
580}