Skip to main content

datui_lib/app/
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    /// The picker open over a field, and whether it takes several values; `None`
112    /// for a form with no picker open.
113    fn shown_picker(&mut self) -> Option<(&mut PickerState, bool)> {
114        None
115    }
116
117    /// Close the open picker, choosing nothing.
118    fn dismiss_picker(&mut self) {}
119
120    /// Take the picker's item and close it, or with `toggle`, flip it and stay open.
121    fn pick(&mut self, _toggle: bool) {}
122
123    /// Focus `field` if the form shows it: a click on a row, or a modal moving
124    /// focus itself. Returns whether it did.
125    fn focus(&mut self, field: Self::Field) -> bool {
126        let shown = self.fields().iter().any(|(f, _)| *f == field);
127        if shown {
128            self.set_focused(field);
129        }
130        shown
131    }
132
133    /// Move focus `delta` fields along, wrapping. A focus the form no longer shows
134    /// counts from the top.
135    fn move_focus(&mut self, delta: isize) {
136        let fields = self.fields();
137        if fields.is_empty() {
138            return;
139        }
140        let focused = self.focused();
141        let n = fields.len() as isize;
142        let next = match fields.iter().position(|(f, _)| *f == focused) {
143            Some(at) => (at as isize + delta).rem_euclid(n),
144            None if delta < 0 => n - 1,
145            None => 0,
146        };
147        self.set_focused(fields[next as usize].0);
148    }
149
150    fn focus_next(&mut self) {
151        self.move_focus(1);
152    }
153
154    fn focus_prev(&mut self) {
155        self.move_focus(-1);
156    }
157
158    /// After a change that hides fields: when the focused one went, focus the first
159    /// field instead, so it never points at nothing.
160    fn settle_focus(&mut self) {
161        if self.focused_kind().is_none()
162            && let Some((first, _)) = self.fields().first().copied()
163        {
164            self.set_focused(first);
165        }
166    }
167}
168
169fn plain(event: &KeyEvent) -> bool {
170    event
171        .modifiers
172        .intersection(KeyModifiers::CONTROL | KeyModifiers::ALT)
173        .is_empty()
174}
175
176/// What `event` does to `form` while no picker is open. Focus moves happen here;
177/// everything else is returned for the modal to do.
178pub fn key<T: Form + ?Sized>(form: &mut T, event: &KeyEvent) -> FormKey<T::Field> {
179    let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
180    let shift = event.modifiers.contains(KeyModifiers::SHIFT);
181    let Some(kind) = form.focused_kind() else {
182        // Focus on nothing (a form with no fields, or one just emptied): only the
183        // ways in and out work.
184        form.settle_focus();
185        return match event.code {
186            KeyCode::Esc => FormKey::Cancel,
187            KeyCode::Enter => FormKey::Submit,
188            _ => FormKey::Other,
189        };
190    };
191    let field = form.focused();
192    // A field that does not type reads hjkl as the arrows.
193    let code = match event.code {
194        KeyCode::Char('h') if !kind.types() && plain(event) => KeyCode::Left,
195        KeyCode::Char('j') if !kind.types() && plain(event) => KeyCode::Down,
196        KeyCode::Char('k') if !kind.types() && plain(event) => KeyCode::Up,
197        KeyCode::Char('l') if !kind.types() && plain(event) => KeyCode::Right,
198        code => code,
199    };
200    match code {
201        KeyCode::Esc => FormKey::Cancel,
202        // Ctrl+J beside Ctrl+Enter: some terminals send one as the other.
203        KeyCode::Enter | KeyCode::Char('j' | 'J') if ctrl => FormKey::Submit,
204        KeyCode::Enter if kind == FieldKind::MultilineText => FormKey::Text(field),
205        KeyCode::Enter => FormKey::Submit,
206        KeyCode::BackTab => {
207            form.focus_prev();
208            FormKey::Moved
209        }
210        KeyCode::Tab if shift => {
211            form.focus_prev();
212            FormKey::Moved
213        }
214        KeyCode::Tab => {
215            form.focus_next();
216            FormKey::Moved
217        }
218        KeyCode::Up | KeyCode::Down if event.modifiers.is_empty() => {
219            let up = code == KeyCode::Up;
220            if kind == FieldKind::MultilineText {
221                let (first, last) = form.text_edge(field);
222                if (up && !first) || (!up && !last) {
223                    return FormKey::Text(field);
224                }
225            }
226            form.move_focus(if up { -1 } else { 1 });
227            FormKey::Moved
228        }
229        KeyCode::Left | KeyCode::Right => {
230            let delta = if code == KeyCode::Left { -1 } else { 1 };
231            match kind {
232                FieldKind::Text | FieldKind::MultilineText => FormKey::Text(field),
233                FieldKind::Choice | FieldKind::Picker { multi: false } => {
234                    FormKey::Step(field, delta)
235                }
236                FieldKind::Checkbox => FormKey::Act(field),
237                FieldKind::Picker { multi: true } | FieldKind::Button => FormKey::Other,
238            }
239        }
240        KeyCode::Char(' ') if plain(event) => match kind {
241            FieldKind::Text | FieldKind::MultilineText => FormKey::Text(field),
242            FieldKind::Choice => FormKey::Step(field, 1),
243            FieldKind::Checkbox | FieldKind::Picker { .. } | FieldKind::Button => {
244                FormKey::Act(field)
245            }
246        },
247        _ if kind.types() => FormKey::Text(field),
248        _ => FormKey::Other,
249    }
250}
251
252/// `event` in a form with a picker open: the picker takes it ([`picker_key`]),
253/// choosing, toggling or closing through the form. Returns whether a picker was open.
254pub fn picker_form_key<T: Form + ?Sized>(form: &mut T, event: &KeyEvent) -> bool {
255    let Some((picker, multi)) = form.shown_picker() else {
256        return false;
257    };
258    match picker_key(picker, multi, event) {
259        PickerKey::Close => form.dismiss_picker(),
260        PickerKey::Choose => form.pick(false),
261        PickerKey::Toggle => form.pick(true),
262        PickerKey::ChooseAndMove(forward) => {
263            form.pick(false);
264            form.move_focus(if forward { 1 } else { -1 });
265        }
266        PickerKey::Handled | PickerKey::Other => {}
267    }
268    true
269}
270
271/// A move of a list's cursor, from the keys every list answers: ↑ / `k`, ↓ / `j`,
272/// PageUp, PageDown, Home and End.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum ListMove {
275    Up,
276    Down,
277    PageUp,
278    PageDown,
279    Home,
280    End,
281}
282
283impl ListMove {
284    /// The move `event` asks for, if it is a press of a list key.
285    pub fn from_key(event: &KeyEvent) -> Option<Self> {
286        if !event.is_press() {
287            return None;
288        }
289        Some(match event.code {
290            KeyCode::Up | KeyCode::Char('k') => Self::Up,
291            KeyCode::Down | KeyCode::Char('j') => Self::Down,
292            KeyCode::PageUp => Self::PageUp,
293            KeyCode::PageDown => Self::PageDown,
294            KeyCode::Home => Self::Home,
295            KeyCode::End => Self::End,
296            _ => return None,
297        })
298    }
299
300    /// How far it moves, `page` rows to a page; Home and End as far as a move goes.
301    pub fn delta(self, page: usize) -> isize {
302        let page = isize::try_from(page.max(1)).unwrap_or(isize::MAX);
303        match self {
304            Self::Up => -1,
305            Self::Down => 1,
306            Self::PageUp => -page,
307            Self::PageDown => page,
308            Self::Home => isize::MIN,
309            Self::End => isize::MAX,
310        }
311    }
312
313    /// `at` moved in a list of `len` rows, `page` rows to a page, kept in the list.
314    pub fn apply(self, at: usize, len: usize, page: usize) -> usize {
315        let last = len.saturating_sub(1);
316        at.saturating_add_signed(self.delta(page)).min(last)
317    }
318}
319
320/// What a key did in an open picker.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum PickerKey {
323    /// Moved or narrowed the list; nothing else to do.
324    Handled,
325    /// Esc: close the picker, choosing nothing.
326    Close,
327    /// Enter, or Space on a pick-one list with nothing typed: take the cursor's item
328    /// and close.
329    Choose,
330    /// Space on a list that takes several: flip the cursor's item, staying open.
331    Toggle,
332    /// Tab / Shift+Tab: choose, close, and move focus on (`true`) or back.
333    ChooseAndMove(bool),
334    /// Not a picker key.
335    Other,
336}
337
338/// What `event` does in an open picker: type to narrow, ↑↓ move, Space chooses
339/// (or toggles in a list of several), Enter chooses, Esc closes the picker only.
340/// In a pick-one list Space chooses only while the filter is empty, and types a
341/// space once it narrows, so a name of several words can be typed; with nothing
342/// typed it would match nothing and blank the list under the key that opened it.
343pub fn picker_key(picker: &mut PickerState, multi: bool, event: &KeyEvent) -> PickerKey {
344    let shift = event.modifiers.contains(KeyModifiers::SHIFT);
345    match event.code {
346        KeyCode::Esc => PickerKey::Close,
347        KeyCode::Enter => PickerKey::Choose,
348        KeyCode::BackTab => PickerKey::ChooseAndMove(false),
349        KeyCode::Tab => PickerKey::ChooseAndMove(!shift),
350        KeyCode::Up => {
351            picker.move_up();
352            PickerKey::Handled
353        }
354        KeyCode::Down => {
355            picker.move_down();
356            PickerKey::Handled
357        }
358        KeyCode::Char(' ') if multi => PickerKey::Toggle,
359        KeyCode::Char(' ') if picker.filter.is_empty() => PickerKey::Choose,
360        KeyCode::Backspace => {
361            picker.backspace();
362            PickerKey::Handled
363        }
364        KeyCode::Char(c) => {
365            picker.filter_key(c, event.modifiers);
366            PickerKey::Handled
367        }
368        _ => PickerKey::Other,
369    }
370}
371
372/// Index `at` stepped by `delta` through `len` values, wrapping.
373pub fn step_index(at: usize, len: usize, delta: i8) -> usize {
374    if len == 0 {
375        return 0;
376    }
377    (at as isize + delta as isize).rem_euclid(len as isize) as usize
378}
379
380/// `current` stepped by `delta` through `all`, wrapping; the first value when
381/// `current` is not among them.
382pub fn step_value<T: Copy + PartialEq>(all: &[T], current: T, delta: i8) -> T {
383    let at = all.iter().position(|v| *v == current);
384    match at {
385        Some(at) => all[step_index(at, all.len(), delta)],
386        None => all[0],
387    }
388}
389
390#[cfg(test)]
391mod tests;