Skip to main content

rich_interact/components/
form.rs

1//! Forms (#288): several fields answered together, with each field's
2//! error shown under it (#472).
3//!
4//! Fields are text (an [`Input`], with its validation, placeholder and
5//! default), masked fields for passwords, a choice among options (fixed,
6//! or worked out from the answers above it), several options at once, a
7//! file or directory picked with a [`FilePicker`], yes/no toggles, and
8//! notes (read-only text). Each may have a description under it. Tab and
9//! the arrows move between fields; Enter moves to the next field and, on
10//! the last, submits. Submitting checks every field and focuses the first
11//! that fails, with its message under it.
12//!
13//! [`Form::group`] splits a form into pages (0.0.21), shown one at a time
14//! with a [`Paginator`] under them: Enter on a page's last field, or Page
15//! Down, checks that page's fields and moves on only when they pass; Page
16//! Up goes back.
17
18use std::ops::Range;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use rich::Segment;
23
24use crate::component::{Component, Context, Flow, View};
25use crate::components::{
26    FilePicker, Input, Paginator, PaginatorStyle, Theme, display_path, fit, plain, question, text,
27};
28use crate::event::{Event, Key, KeyCode};
29use crate::keymap::{Keymap, keys};
30use crate::policy::{LineIo, NotInteractive};
31
32/// A field's answer.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum Value {
35    /// A text field's or a choice's answer.
36    Text(String),
37    /// A toggle's answer.
38    Flag(bool),
39    /// A multi-select's answer: the options chosen, in their order.
40    List(Vec<String>),
41    /// A file field's answer.
42    Path(PathBuf),
43}
44
45/// A submitted form's answers, by field name, in field order. Notes have
46/// none.
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct Answers(pub Vec<(String, Value)>);
49
50impl Answers {
51    pub fn get(&self, name: &str) -> Option<&Value> {
52        self.0
53            .iter()
54            .find(|(key, _)| key == name)
55            .map(|(_, value)| value)
56    }
57
58    /// A text or choice field's answer.
59    pub fn text(&self, name: &str) -> Option<&str> {
60        match self.get(name)? {
61            Value::Text(text) => Some(text),
62            _ => None,
63        }
64    }
65
66    /// A toggle's answer.
67    pub fn flag(&self, name: &str) -> Option<bool> {
68        match self.get(name)? {
69            Value::Flag(flag) => Some(*flag),
70            _ => None,
71        }
72    }
73
74    /// A multi-select's answer.
75    pub fn list(&self, name: &str) -> Option<&[String]> {
76        match self.get(name)? {
77            Value::List(list) => Some(list),
78            _ => None,
79        }
80    }
81
82    /// A file field's answer.
83    pub fn path(&self, name: &str) -> Option<&Path> {
84        match self.get(name)? {
85            Value::Path(path) => Some(path),
86            _ => None,
87        }
88    }
89}
90
91/// Works out a dynamic choice's options from the answers above it.
92type Options = Box<dyn Fn(&Answers) -> Vec<String>>;
93
94enum Kind {
95    Text(Box<Input>),
96    Choice {
97        options: Vec<String>,
98        index: usize,
99        /// For a dynamic choice: its options from the answers so far.
100        source: Option<Options>,
101    },
102    Multi {
103        options: Vec<String>,
104        chosen: Vec<bool>,
105        /// The option the cursor is on.
106        cursor: usize,
107        /// At most this many may be chosen.
108        limit: Option<usize>,
109    },
110    Toggle(bool),
111    Note(String),
112    File {
113        /// Always `Some`, but for a moment while a builder method runs.
114        picker: Option<Box<FilePicker>>,
115        path: Option<PathBuf>,
116    },
117}
118
119struct Field {
120    name: String,
121    label: String,
122    description: Option<String>,
123    /// The page (group) the field is on.
124    page: usize,
125    kind: Kind,
126    error: Option<String>,
127}
128
129impl Field {
130    fn value(&self) -> Result<Value, String> {
131        match &self.kind {
132            Kind::Text(input) => input.resolve().map(Value::Text),
133            Kind::Choice { options, index, .. } => Ok(Value::Text(
134                options.get(*index).cloned().unwrap_or_default(),
135            )),
136            Kind::Multi {
137                options, chosen, ..
138            } => Ok(Value::List(
139                options
140                    .iter()
141                    .zip(chosen)
142                    .filter(|(_, on)| **on)
143                    .map(|(option, _)| option.clone())
144                    .collect(),
145            )),
146            Kind::Toggle(on) => Ok(Value::Flag(*on)),
147            Kind::Note(_) => Err(String::new()),
148            Kind::File { .. } => self
149                .file_path()
150                .0
151                .map(Value::Path)
152                .ok_or_else(|| "nothing picked yet".to_string()),
153        }
154    }
155
156    /// A file field's path, and whether it was picked (not its default).
157    fn file_path(&self) -> (Option<PathBuf>, bool) {
158        match &self.kind {
159            Kind::File {
160                path: Some(path), ..
161            } => (Some(path.clone()), true),
162            Kind::File { picker, .. } => (picker.as_ref().and_then(|p| p.default_value()), false),
163            _ => (None, false),
164        }
165    }
166
167    fn is_note(&self) -> bool {
168        matches!(self.kind, Kind::Note(_))
169    }
170
171    /// The answer as shown once submitted.
172    fn summary(&self) -> String {
173        match (&self.kind, self.value()) {
174            (Kind::Text(input), Ok(Value::Text(text))) => input.display_value(&text),
175            (_, Ok(Value::Text(text))) => text,
176            (_, Ok(Value::Flag(on))) => if on { "yes" } else { "no" }.to_string(),
177            (_, Ok(Value::List(list))) => list.join(", "),
178            (_, Ok(Value::Path(path))) => display_path(&path),
179            (_, Err(_)) => String::new(),
180        }
181    }
182
183    fn picker(&mut self) -> Option<&mut FilePicker> {
184        match &mut self.kind {
185            Kind::File { picker, .. } => picker.as_deref_mut(),
186            _ => None,
187        }
188    }
189}
190
191/// A form: named fields answered together, on one page or several.
192/// Returns [`Answers`].
193///
194/// ```
195/// use rich_interact::headless::{self, Script};
196/// use rich_interact::{Form, Input};
197///
198/// let form = Form::new("New service")
199///     .note("Welcome", "Two pages: the service, then where it runs.")
200///     .input("name", Input::new("Name"))
201///     .describe("Lower case, no spaces")
202///     .multi("features", "Features", ["tls", "metrics", "logs"])
203///     .group("Where")
204///     .choice("cloud", "Cloud", ["aws", "gcp"])
205///     .dynamic_choice("region", "Region", |answers| match answers.text("cloud") {
206///         Some("gcp") => vec!["europe-west1".into(), "us-central1".into()],
207///         _ => vec!["eu-west-1".into(), "us-east-1".into()],
208///     });
209/// // Page one: a name and two features; Enter on its last field moves to
210/// // page two, where GCP's regions are offered.
211/// let script = Script::new()
212///     .text("api")
213///     .keys("tab space right space enter right tab right enter");
214/// let (outcome, _) = headless::run(form, script, 60, 16);
215/// let answers = outcome.unwrap().value().unwrap();
216/// assert_eq!(answers.list("features").unwrap(), ["tls", "metrics"]);
217/// assert_eq!(answers.text("region"), Some("us-central1"));
218/// ```
219#[must_use = "a component does nothing until it is run or mounted"]
220pub struct Form {
221    title: String,
222    fields: Vec<Field>,
223    focus: usize,
224    theme: Theme,
225    answer: Option<bool>,
226    mouse: bool,
227    /// Actions rebound on this form ([`Form::rebind`]), with their keys.
228    rebound: Vec<(String, Vec<Key>)>,
229    /// Each page's title (the first page's may be empty).
230    groups: Vec<String>,
231    /// Which page shows; its page count is the number of groups.
232    pager: Paginator,
233    /// The focused file field's picker is open.
234    browsing: bool,
235}
236
237/// What a row of the form is, for a click.
238#[derive(Clone, Debug, PartialEq, Eq)]
239enum Hit {
240    None,
241    Field(usize),
242    /// A multi-select's row: each option's columns.
243    Options(usize, Vec<Range<usize>>),
244    /// An open file picker, whose view starts at this row.
245    Picker(usize),
246    Buttons,
247    Indicator,
248}
249
250/// What [`Form::layout`] draws.
251struct Layout {
252    lines: Vec<Vec<Segment>>,
253    hits: Vec<Hit>,
254    cursor: Option<(usize, usize)>,
255}
256
257impl Layout {
258    fn push(&mut self, line: Vec<Segment>, hit: Hit) {
259        self.lines.push(line);
260        self.hits.push(hit);
261    }
262}
263
264const SUBMIT: &str = " Submit ";
265const CANCEL: &str = " Cancel ";
266
267impl Form {
268    pub fn new(title: impl Into<String>) -> Form {
269        Form {
270            title: title.into(),
271            fields: Vec::new(),
272            focus: 0,
273            theme: Theme::default(),
274            answer: None,
275            mouse: false,
276            rebound: Vec::new(),
277            groups: vec![String::new()],
278            pager: Paginator::new(1),
279            browsing: false,
280        }
281    }
282
283    /// Make `keys` do `action` on this form only; no keys unbinds it. The
284    /// actions are the form's (`submit`, `cancel`, `next`, `previous`, and
285    /// with pages `next-page` and `previous-page`), a choice field's
286    /// (`next-option`, `previous-option`), a multi-select's
287    /// (`next-choice`, `previous-choice`, `choose`), a file field's
288    /// (`browse`) and a toggle's (`toggle`, `yes`, `no`); see
289    /// [`keymap`](Component::keymap). A text field's keys are its
290    /// [`Input`]'s, and an open picker's its [`FilePicker`]'s.
291    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
292        self.rebound
293            .push((action.to_string(), keys.into_iter().collect()));
294        self
295    }
296
297    /// `keymap` with this form's rebinding.
298    fn rebound(&self, mut keymap: Keymap) -> Keymap {
299        for (action, keys) in &self.rebound {
300            keymap.rebind(action, keys.clone());
301        }
302        keymap
303    }
304
305    /// The form's own keys, without the focused field's.
306    fn form_keymap(&self) -> Keymap {
307        let mut keymap = Keymap::new("form")
308            .bind("submit", keys("ctrl+s"), "submit (Enter on the last field)")
309            .bind("cancel", keys("escape"), "cancel")
310            .bind("next", keys("enter tab down"), "next field")
311            .bind("previous", keys("shift+tab up"), "previous field");
312        if self.groups.len() > 1 {
313            keymap = keymap
314                .bind(
315                    "next-page",
316                    keys("pagedown"),
317                    "next page, once this one passes",
318                )
319                .bind("previous-page", keys("pageup"), "previous page");
320        }
321        self.rebound(keymap)
322    }
323
324    fn choice_keymap(&self) -> Keymap {
325        self.rebound(
326            Keymap::new("form-choice")
327                .bind("next-option", keys("right space"), "next option")
328                .bind("previous-option", keys("left"), "previous option"),
329        )
330    }
331
332    fn multi_keymap(&self) -> Keymap {
333        self.rebound(
334            Keymap::new("form-multi")
335                .bind("next-choice", keys("right"), "next option")
336                .bind("previous-choice", keys("left"), "previous option")
337                .bind("choose", keys("space x"), "choose or unchoose the option"),
338        )
339    }
340
341    fn file_keymap(&self) -> Keymap {
342        self.rebound(Keymap::new("form-file").bind(
343            "browse",
344            keys("space ctrl+o"),
345            "browse for the path",
346        ))
347    }
348
349    fn toggle_keymap(&self) -> Keymap {
350        self.rebound(
351            Keymap::new("form-toggle")
352                .bind("toggle", keys("space left right"), "switch")
353                .bind("yes", keys("y"), "on")
354                .bind("no", keys("n"), "off"),
355        )
356    }
357
358    /// Report the mouse (#476): a click focuses a field (and flips a
359    /// toggle, moves a choice on, chooses a multi-select's option or opens
360    /// a file field's picker), a click on a page's dot goes to that page,
361    /// and Submit and Cancel buttons show under the fields.
362    pub fn with_mouse(mut self, on: bool) -> Self {
363        self.mouse = on;
364        for field in &mut self.fields {
365            if let Kind::File { picker, .. } = &mut field.kind {
366                *picker = picker.take().map(|p| Box::new(p.with_mouse(on)));
367            }
368        }
369        self
370    }
371
372    fn push(mut self, name: impl Into<String>, label: String, kind: Kind) -> Self {
373        self.fields.push(Field {
374            name: name.into(),
375            label,
376            description: None,
377            page: self.groups.len() - 1,
378            kind,
379            error: None,
380        });
381        self
382    }
383
384    /// A text field.
385    pub fn text(self, name: impl Into<String>, label: impl Into<String>) -> Self {
386        let label = label.into();
387        let input = Input::new(label.clone());
388        self.push(name, label, Kind::Text(Box::new(input)))
389    }
390
391    /// A text field from a configured [`Input`] (validation, placeholder,
392    /// default, suggestions); its prompt is the label.
393    pub fn input(self, name: impl Into<String>, input: Input) -> Self {
394        let label = input.label().to_string();
395        self.push(name, label, Kind::Text(Box::new(input)))
396    }
397
398    /// A masked text field, for passwords and tokens (see
399    /// [`Input::masked`]).
400    pub fn masked(self, name: impl Into<String>, label: impl Into<String>) -> Self {
401        let label = label.into();
402        let input = Input::masked(label.clone());
403        self.push(name, label, Kind::Text(Box::new(input)))
404    }
405
406    /// One of `options`, changed with Left and Right (or Space).
407    pub fn choice<I, S>(self, name: impl Into<String>, label: impl Into<String>, options: I) -> Self
408    where
409        I: IntoIterator<Item = S>,
410        S: Into<String>,
411    {
412        let options = options.into_iter().map(Into::into).collect();
413        let kind = Kind::Choice {
414            options,
415            index: 0,
416            source: None,
417        };
418        self.push(name, label.into(), kind)
419    }
420
421    /// A choice whose options depend on the answers above it: `options`
422    /// gets the answers of the fields before this one (those that have an
423    /// answer yet) and returns the options. They are worked out again after
424    /// every key; the option chosen stays chosen while it is still offered.
425    pub fn dynamic_choice(
426        self,
427        name: impl Into<String>,
428        label: impl Into<String>,
429        options: impl Fn(&Answers) -> Vec<String> + 'static,
430    ) -> Self {
431        let kind = Kind::Choice {
432            options: Vec::new(),
433            index: 0,
434            source: Some(Box::new(options)),
435        };
436        let mut form = self.push(name, label.into(), kind);
437        form.refresh();
438        form
439    }
440
441    /// Any number of `options` (Huh's multi-select): Left and Right move
442    /// between them, Space (or `x`) chooses one. Answers a
443    /// [`Value::List`], in the options' order.
444    pub fn multi<I, S>(self, name: impl Into<String>, label: impl Into<String>, options: I) -> Self
445    where
446        I: IntoIterator<Item = S>,
447        S: Into<String>,
448    {
449        let options: Vec<String> = options.into_iter().map(Into::into).collect();
450        let kind = Kind::Multi {
451            chosen: vec![false; options.len()],
452            options,
453            cursor: 0,
454            limit: None,
455        };
456        self.push(name, label.into(), kind)
457    }
458
459    /// Choose these options of the multi-select just added, to begin with.
460    pub fn chosen<I, S>(mut self, options: I) -> Self
461    where
462        I: IntoIterator<Item = S>,
463        S: AsRef<str>,
464    {
465        let wanted: Vec<S> = options.into_iter().collect();
466        if let Some(Kind::Multi {
467            options, chosen, ..
468        }) = self.fields.last_mut().map(|field| &mut field.kind)
469        {
470            for (option, on) in options.iter().zip(chosen.iter_mut()) {
471                *on = wanted.iter().any(|w| w.as_ref() == option);
472            }
473        }
474        self
475    }
476
477    /// Let at most `count` options of the multi-select just added be
478    /// chosen.
479    pub fn limit(mut self, count: usize) -> Self {
480        if let Some(Kind::Multi { limit, .. }) = self.fields.last_mut().map(|field| &mut field.kind)
481        {
482            *limit = Some(count);
483        }
484        self
485    }
486
487    /// Yes or no, changed with Space, Left, Right, `y` and `n`.
488    pub fn toggle(self, name: impl Into<String>, label: impl Into<String>, on: bool) -> Self {
489        self.push(name, label.into(), Kind::Toggle(on))
490    }
491
492    /// Read-only text: a title and a body, wrapped to the width. Focus
493    /// passes over it and it has no answer.
494    pub fn note(self, title: impl Into<String>, body: impl Into<String>) -> Self {
495        self.push(String::new(), title.into(), Kind::Note(body.into()))
496    }
497
498    /// A path picked with `picker` (a [`FilePicker`]: its root, mode,
499    /// extensions and jail apply). Space (or a click) opens it under the
500    /// field; picking closes it, and Escape closes it with no change.
501    /// Answers a [`Value::Path`]; with nothing picked, the picker's
502    /// [`default`](FilePicker::default), and without one the field fails.
503    pub fn file(
504        self,
505        name: impl Into<String>,
506        label: impl Into<String>,
507        picker: FilePicker,
508    ) -> Self {
509        let mouse = self.mouse;
510        let kind = Kind::File {
511            picker: Some(Box::new(picker.with_mouse(mouse))),
512            path: None,
513        };
514        self.push(name, label.into(), kind)
515    }
516
517    /// A line of help under the field just added, shown dim.
518    pub fn describe(mut self, description: impl Into<String>) -> Self {
519        if let Some(field) = self.fields.last_mut() {
520            field.description = Some(description.into());
521        }
522        self
523    }
524
525    /// Start a new page titled `title`: the fields added after it are
526    /// shown together, and the form's pages one at a time. Before any
527    /// field it titles the first page.
528    pub fn group(mut self, title: impl Into<String>) -> Self {
529        let current = self.groups.len() - 1;
530        if self.fields.iter().any(|field| field.page == current) {
531            self.groups.push(title.into());
532        } else {
533            self.groups[current] = title.into();
534        }
535        self.pager.set_pages(self.groups.len());
536        self
537    }
538
539    /// How the page indicator looks (default: dots).
540    pub fn page_style(mut self, style: PaginatorStyle) -> Self {
541        self.pager = Paginator::new(self.groups.len())
542            .style(style)
543            .theme(self.theme.clone());
544        self
545    }
546
547    pub fn theme(mut self, theme: Theme) -> Self {
548        self.pager = std::mem::replace(&mut self.pager, Paginator::new(1)).theme(theme.clone());
549        self.theme = theme;
550        self
551    }
552
553    /// The page showing, from 0.
554    pub fn page(&self) -> usize {
555        self.pager.page()
556    }
557
558    /// The fields on page `page`.
559    fn on_page(&self, page: usize) -> Range<usize> {
560        match self.fields.iter().position(|f| f.page == page) {
561            Some(start) => {
562                let end = self.fields[start..]
563                    .iter()
564                    .position(|f| f.page != page)
565                    .map_or(self.fields.len(), |n| start + n);
566                start..end
567            }
568            None => 0..0,
569        }
570    }
571
572    /// The focus stops on the current page: its fields but notes, or the
573    /// first field when it has only notes.
574    fn stops(&self) -> Vec<usize> {
575        let range = self.on_page(self.pager.page());
576        let stops: Vec<usize> = range
577            .clone()
578            .filter(|&index| !self.fields[index].is_note())
579            .collect();
580        if stops.is_empty() {
581            range.take(1).collect()
582        } else {
583            stops
584        }
585    }
586
587    fn move_focus(&mut self, delta: isize) {
588        let stops = self.stops();
589        if stops.is_empty() {
590            return;
591        }
592        let at = stops.iter().position(|&s| s == self.focus).unwrap_or(0);
593        let next = (at as isize + delta).rem_euclid(stops.len() as isize) as usize;
594        self.focus = stops[next];
595    }
596
597    /// Show `page`, focused on its first stop.
598    fn show_page(&mut self, page: usize) {
599        self.pager.set_page(page);
600        self.focus = self.stops().first().copied().unwrap_or(0);
601        self.browsing = false;
602    }
603
604    /// Whether the focus is on the current page's last stop.
605    fn on_last_stop(&self) -> bool {
606        self.stops().last() == Some(&self.focus)
607    }
608
609    /// Check the fields in `range`, marking those that fail; the first
610    /// that fails.
611    fn check(&mut self, range: Range<usize>) -> Option<usize> {
612        let mut first = None;
613        for index in range {
614            let field = &mut self.fields[index];
615            if field.is_note() {
616                continue;
617            }
618            match field.value() {
619                Ok(_) => field.error = None,
620                Err(message) => {
621                    field.error = Some(message);
622                    first.get_or_insert(index);
623                }
624            }
625        }
626        first
627    }
628
629    /// Move on a page if this one's fields pass.
630    fn next_page(&mut self) -> Flow<Answers> {
631        if self.pager.on_last() {
632            return Flow::Continue;
633        }
634        match self.check(self.on_page(self.pager.page())) {
635            Some(index) => self.focus = index,
636            None => self.show_page(self.pager.page() + 1),
637        }
638        Flow::Continue
639    }
640
641    /// Go to `page` from a click: back freely, forward through every
642    /// page's checks.
643    fn go_to(&mut self, page: usize) {
644        while self.pager.page() < page {
645            let at = self.pager.page();
646            self.next_page();
647            if self.pager.page() == at {
648                return;
649            }
650        }
651        if page < self.pager.page() {
652            self.show_page(page);
653        }
654    }
655
656    /// The answers of the fields before `end` that have one now.
657    fn answers_before(&self, end: usize) -> Answers {
658        Answers(
659            self.fields[..end]
660                .iter()
661                .filter(|field| !field.is_note())
662                .filter_map(|field| Some((field.name.clone(), field.value().ok()?)))
663                .collect(),
664        )
665    }
666
667    /// Work out the dynamic choices' options again.
668    fn refresh(&mut self) {
669        for at in 0..self.fields.len() {
670            if !matches!(
671                &self.fields[at].kind,
672                Kind::Choice {
673                    source: Some(_),
674                    ..
675                }
676            ) {
677                continue;
678            }
679            let answers = self.answers_before(at);
680            if let Kind::Choice {
681                options,
682                index,
683                source: Some(source),
684            } = &mut self.fields[at].kind
685            {
686                let fresh = source(&answers);
687                if fresh != *options {
688                    let current = options.get(*index).cloned();
689                    *index = current
690                        .and_then(|c| fresh.iter().position(|o| *o == c))
691                        .unwrap_or(0);
692                    *options = fresh;
693                }
694            }
695        }
696    }
697
698    fn submit(&mut self) -> Flow<Answers> {
699        if let Some(index) = self.check(0..self.fields.len()) {
700            let page = self.fields[index].page;
701            if page != self.pager.page() {
702                self.show_page(page);
703            }
704            self.focus = index;
705            return Flow::Continue;
706        }
707        let answers = self
708            .fields
709            .iter()
710            .filter(|field| !field.is_note())
711            .filter_map(|field| Some((field.name.clone(), field.value().ok()?)))
712            .collect();
713        self.answer = Some(true);
714        Flow::Done(Answers(answers))
715    }
716
717    fn open_picker(&mut self) {
718        if let Some(picker) = self.fields[self.focus].picker() {
719            picker.reopen();
720            self.browsing = true;
721        }
722    }
723
724    fn click(&mut self, context: &Context<'_>, row: usize, column: usize) -> Flow<Answers> {
725        let layout = self.layout(context);
726        match layout.hits.get(row).cloned().unwrap_or(Hit::None) {
727            Hit::Buttons => {
728                let submit = 2..2 + SUBMIT.len();
729                let cancel = submit.end + 2..submit.end + 2 + CANCEL.len();
730                if submit.contains(&column) {
731                    return self.submit();
732                } else if cancel.contains(&column) {
733                    self.answer = Some(false);
734                    return Flow::Cancel;
735                }
736            }
737            Hit::Indicator => {
738                if let Some(page) = column
739                    .checked_sub(2)
740                    .and_then(|c| self.pager.page_at(context.width.saturating_sub(2), c))
741                {
742                    self.go_to(page);
743                }
744            }
745            Hit::Options(index, columns) => {
746                self.focus = index;
747                self.browsing = false;
748                if let Some(option) = columns.iter().position(|r| r.contains(&column)) {
749                    if let Kind::Multi { cursor, .. } = &mut self.fields[index].kind {
750                        *cursor = option;
751                    }
752                    self.choose();
753                }
754            }
755            Hit::Field(index) => {
756                let again = index == self.focus;
757                self.focus = index;
758                self.browsing = false;
759                let mut browse = false;
760                match &mut self.fields[index].kind {
761                    Kind::Toggle(on) => *on = !*on,
762                    Kind::Choice { options, index, .. } if again => {
763                        *index = (*index + 1) % options.len().max(1)
764                    }
765                    Kind::File { .. } => browse = true,
766                    _ => {}
767                }
768                if browse {
769                    self.open_picker();
770                }
771            }
772            Hit::Picker(_) | Hit::None => {}
773        }
774        Flow::Continue
775    }
776
777    /// Choose or unchoose the focused multi-select's option, within its
778    /// limit.
779    fn choose(&mut self) {
780        if let Kind::Multi {
781            chosen,
782            cursor,
783            limit,
784            ..
785        } = &mut self.fields[self.focus].kind
786            && let Some(on) = chosen.get(*cursor).copied()
787        {
788            let count = chosen.iter().filter(|c| **c).count();
789            if on || limit.is_none_or(|limit| count < limit) {
790                chosen[*cursor] = !on;
791            }
792        }
793    }
794
795    /// Events for the open picker. A click above it closes it and lands
796    /// on the form.
797    fn browse(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
798        let mut event = event.clone();
799        if let Some(mouse) = event.mouse() {
800            let start = self.layout(context).hits.iter().find_map(|hit| match hit {
801                Hit::Picker(start) => Some(*start),
802                _ => None,
803            });
804            match start {
805                Some(start) if mouse.row as usize >= start => {
806                    let mut moved = mouse;
807                    moved.row -= start as u16;
808                    event = Event::Mouse(moved);
809                }
810                _ if mouse.is_click() => {
811                    self.browsing = false;
812                    return self.click(context, mouse.row as usize, mouse.column as usize);
813                }
814                _ => return Flow::Continue,
815            }
816        }
817        let field = &mut self.fields[self.focus];
818        let Kind::File {
819            picker: Some(picker),
820            path,
821        } = &mut field.kind
822        else {
823            self.browsing = false;
824            return Flow::Continue;
825        };
826        match picker.handle(&event, context) {
827            Flow::Done(picked) => {
828                *path = Some(picked);
829                field.error = None;
830                self.browsing = false;
831            }
832            Flow::Cancel => self.browsing = false,
833            _ => {}
834        }
835        Flow::Continue
836    }
837
838    /// The view, and what each row is for a click.
839    fn layout(&self, context: &Context<'_>) -> Layout {
840        let width = context.width;
841        let theme = &self.theme;
842        let mut title = question(theme, &self.title);
843        title.truncate(title.len() - 3);
844        let mut out = Layout {
845            lines: Vec::new(),
846            hits: Vec::new(),
847            cursor: None,
848        };
849        let label_width = self
850            .fields
851            .iter()
852            .filter(|field| !field.is_note())
853            .map(|field| rich::cells::cell_len(&field.label))
854            .max()
855            .unwrap_or(0);
856        if let Some(submitted) = self.answer {
857            if !submitted {
858                title.push(text(" › cancelled", &theme.hint));
859                out.push(fit(title, width), Hit::None);
860                return out;
861            }
862            out.push(fit(title, width), Hit::None);
863            for field in self.fields.iter().filter(|field| !field.is_note()) {
864                let label = format!("  {:width$}  ", field.label, width = label_width);
865                let line = vec![
866                    text(label, &theme.hint),
867                    text(field.summary(), &theme.answer),
868                ];
869                out.push(fit(line, width), Hit::None);
870            }
871            return out;
872        }
873        out.push(fit(title, width), Hit::None);
874        let page = self.pager.page();
875        let paged = self.groups.len() > 1;
876        if !self.groups[page].is_empty() {
877            let line = vec![plain("  "), text(self.groups[page].clone(), &theme.prompt)];
878            out.push(fit(line, width), Hit::None);
879        }
880        let indent = " ".repeat(label_width + 4);
881        for index in self.on_page(page) {
882            let field = &self.fields[index];
883            let focused = index == self.focus;
884            if let Kind::Note(body) = &field.kind {
885                let line = vec![plain("  "), text(field.label.clone(), &theme.focused)];
886                out.push(fit(line, width), Hit::None);
887                let body = rich::Text::new(body.clone());
888                for line in context.lines_at(&body, width.saturating_sub(4)) {
889                    let mut row = vec![plain("    ")];
890                    row.extend(line);
891                    out.push(fit(row, width), Hit::None);
892                }
893                continue;
894            }
895            let mut row: Vec<Segment> = if focused {
896                vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
897            } else {
898                vec![plain("  ")]
899            };
900            let padding = label_width - rich::cells::cell_len(&field.label);
901            row.push(if focused {
902                text(field.label.clone(), &theme.focused)
903            } else {
904                plain(field.label.clone())
905            });
906            row.push(plain(format!("{}  ", " ".repeat(padding))));
907            let start = crate::components::width(&row);
908            let mut hit = Hit::Field(index);
909            match &field.kind {
910                Kind::Text(input) => {
911                    let (segments, column) = input.field(width.saturating_sub(start));
912                    row.extend(segments);
913                    if focused {
914                        let column = (start + column).min(width.saturating_sub(1));
915                        out.cursor = Some((out.lines.len(), column));
916                    }
917                }
918                Kind::Choice { options, index, .. } => {
919                    let option = options.get(*index).cloned().unwrap_or_default();
920                    if focused {
921                        row.push(text("◂ ", &theme.hint));
922                        row.push(text(option, &theme.answer));
923                        row.push(text(" ▸", &theme.hint));
924                    } else {
925                        row.push(text(option, &theme.answer));
926                    }
927                }
928                Kind::Multi {
929                    options,
930                    chosen,
931                    cursor,
932                    ..
933                } => {
934                    let current = theme
935                        .focused
936                        .combine(&rich::Style::parse("underline").expect("style"));
937                    let mut columns = Vec::new();
938                    let mut at = start;
939                    for (k, (option, on)) in options.iter().zip(chosen).enumerate() {
940                        if k > 0 {
941                            row.push(plain("  "));
942                            at += 2;
943                        }
944                        let (mark, mark_style) = if *on {
945                            (&theme.checked, &theme.checked_style)
946                        } else {
947                            (&theme.unchecked, &theme.hint)
948                        };
949                        row.push(text(format!("{mark} "), mark_style));
950                        let style = if focused && k == *cursor {
951                            &current
952                        } else if *on {
953                            &theme.answer
954                        } else {
955                            &theme.prompt
956                        };
957                        row.push(text(option.clone(), style));
958                        let end =
959                            at + rich::cells::cell_len(mark) + 1 + rich::cells::cell_len(option);
960                        columns.push(at..end);
961                        at = end;
962                    }
963                    hit = Hit::Options(index, columns);
964                }
965                Kind::Toggle(on) => row.push(if *on {
966                    text(format!("{} yes", theme.checked), &theme.checked_style)
967                } else {
968                    text(format!("{} no", theme.unchecked), &theme.hint)
969                }),
970                Kind::File { .. } => row.push(match field.file_path() {
971                    (Some(path), true) => text(display_path(&path), &theme.answer),
972                    (Some(path), false) => text(format!("({})", display_path(&path)), &theme.hint),
973                    (None, _) => text("(space to browse)", &theme.hint),
974                }),
975                Kind::Note(_) => {}
976            }
977            out.push(fit(row, width), hit);
978            if let (true, Kind::Text(input)) = (focused, &field.kind) {
979                for line in input.suggestion_rows(width, &" ".repeat(label_width + 2)) {
980                    out.push(line, Hit::Field(index));
981                }
982            }
983            if let (
984                true,
985                true,
986                Kind::File {
987                    picker: Some(picker),
988                    ..
989                },
990            ) = (focused, self.browsing, &field.kind)
991            {
992                let start = out.lines.len();
993                let view = picker.render(context);
994                if let Some((row, column)) = view.cursor {
995                    out.cursor = Some((start + row, column));
996                }
997                for line in view.lines {
998                    out.push(line, Hit::Picker(start));
999                }
1000            }
1001            if let Some(description) = &field.description {
1002                let line = vec![text(format!("{indent}{description}"), &theme.hint)];
1003                out.push(fit(line, width), Hit::Field(index));
1004            }
1005            if let Some(error) = &field.error {
1006                let line = vec![text(format!("{indent}✗ {error}"), &theme.error)];
1007                out.push(fit(line, width), Hit::Field(index));
1008            }
1009        }
1010        if paged {
1011            let mut line = vec![plain("  ")];
1012            line.extend(self.pager.indicator(width.saturating_sub(2)));
1013            out.push(line, Hit::Indicator);
1014        }
1015        if self.mouse {
1016            let button = theme
1017                .focused
1018                .combine(&rich::Style::parse("reverse").expect("style"));
1019            let line = vec![
1020                plain("  "),
1021                text(SUBMIT, &button),
1022                plain("  "),
1023                text(CANCEL, &button),
1024            ];
1025            out.push(fit(line, width), Hit::Buttons);
1026        }
1027        let hints = if self.browsing {
1028            "  enter pick · esc close the picker".to_string()
1029        } else {
1030            let enter = match (self.on_last_stop(), self.pager.on_last()) {
1031                (true, true) => "enter submit",
1032                (true, false) => "enter next page",
1033                _ => "enter next",
1034            };
1035            let mut hints = format!("  tab/↑↓ move · {enter}");
1036            if paged {
1037                hints.push_str(" · pgup/pgdn page");
1038            }
1039            if let Some(Kind::File { .. }) = self.fields.get(self.focus).map(|f| &f.kind) {
1040                hints.push_str(" · space browse");
1041            }
1042            hints.push_str(" · ctrl+s submit · esc cancel");
1043            hints
1044        };
1045        out.push(fit(vec![text(hints, &theme.hint)], width), Hit::None);
1046        out
1047    }
1048
1049    fn handle_event(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
1050        if self.browsing {
1051            return self.browse(event, context);
1052        }
1053        if let Some(mouse) = event.mouse() {
1054            if mouse.is_click() {
1055                return self.click(context, mouse.row as usize, mouse.column as usize);
1056            }
1057            return Flow::Continue;
1058        }
1059        if let Event::Tick = event {
1060            // Provider results for every text field, not only the focused.
1061            for field in &mut self.fields {
1062                if let Kind::Text(input) = &mut field.kind {
1063                    input.handle(event, context);
1064                }
1065            }
1066            return Flow::Continue;
1067        }
1068        // While a text field shows suggestions, the keys that pick one are
1069        // its own: `complete` and `up` and `down` (Tab and the arrows), and
1070        // `submit` (Enter) with one selected.
1071        let field = &mut self.fields[self.focus];
1072        if let (Some(key), Kind::Text(input)) = (event.key(), &mut field.kind)
1073            && input.suggesting()
1074        {
1075            match input.keymap().action(key) {
1076                Some("complete" | "up" | "down") => {
1077                    input.handle(event, context);
1078                    field.error = None;
1079                    return Flow::Continue;
1080                }
1081                Some("submit") if input.has_selection() => {
1082                    input.accept_selected();
1083                    field.error = None;
1084                    return Flow::Continue;
1085                }
1086                _ => {}
1087            }
1088        }
1089        if let Some(key) = event.key() {
1090            let last = self.on_last_stop();
1091            match self.form_keymap().action(key) {
1092                Some("cancel") => {
1093                    self.answer = Some(false);
1094                    return Flow::Cancel;
1095                }
1096                Some("submit") => return self.submit(),
1097                // Enter on a page's last field moves to the next page, and
1098                // on the last page's submits.
1099                Some("next") if last && key.code == KeyCode::Enter => {
1100                    return if self.pager.on_last() {
1101                        self.submit()
1102                    } else {
1103                        self.next_page()
1104                    };
1105                }
1106                Some("next") => {
1107                    self.move_focus(1);
1108                    return Flow::Continue;
1109                }
1110                Some("previous") => {
1111                    self.move_focus(-1);
1112                    return Flow::Continue;
1113                }
1114                Some("next-page") => return self.next_page(),
1115                Some("previous-page") => {
1116                    if !self.pager.on_first() {
1117                        self.show_page(self.pager.page() - 1);
1118                    }
1119                    return Flow::Continue;
1120                }
1121                _ => {}
1122            }
1123        }
1124        let action = |keymap: Keymap| {
1125            event
1126                .key()
1127                .and_then(|key| keymap.action(key).map(str::to_string))
1128        };
1129        match &self.fields[self.focus].kind {
1130            Kind::Multi { .. } => {
1131                let action = action(self.multi_keymap());
1132                if let Kind::Multi {
1133                    options, cursor, ..
1134                } = &mut self.fields[self.focus].kind
1135                {
1136                    let count = options.len().max(1);
1137                    match action.as_deref() {
1138                        Some("next-choice") => *cursor = (*cursor + 1) % count,
1139                        Some("previous-choice") => *cursor = (*cursor + count - 1) % count,
1140                        Some("choose") => self.choose(),
1141                        _ => return Flow::Ignored,
1142                    }
1143                }
1144                return Flow::Continue;
1145            }
1146            Kind::File { .. } => {
1147                return match action(self.file_keymap()).as_deref() {
1148                    Some("browse") => {
1149                        self.open_picker();
1150                        Flow::Continue
1151                    }
1152                    _ => Flow::Ignored,
1153                };
1154            }
1155            Kind::Note(_) => return Flow::Ignored,
1156            _ => {}
1157        }
1158        let choice = action(self.choice_keymap());
1159        let toggle = action(self.toggle_keymap());
1160        let field = &mut self.fields[self.focus];
1161        match &mut field.kind {
1162            Kind::Text(input) => {
1163                let before = input.text().to_string();
1164                // A key the field does not use is left to the container.
1165                if let Flow::Ignored = input.handle(event, context) {
1166                    return Flow::Ignored;
1167                }
1168                if input.text() != before {
1169                    field.error = None;
1170                }
1171            }
1172            Kind::Choice { options, index, .. } => {
1173                let count = options.len().max(1);
1174                match choice.as_deref() {
1175                    Some("next-option") => *index = (*index + 1) % count,
1176                    Some("previous-option") => *index = (*index + count - 1) % count,
1177                    _ => return Flow::Ignored,
1178                }
1179            }
1180            Kind::Toggle(on) => match toggle.as_deref() {
1181                Some("toggle") => *on = !*on,
1182                Some("yes") => *on = true,
1183                Some("no") => *on = false,
1184                _ => return Flow::Ignored,
1185            },
1186            Kind::Multi { .. } | Kind::File { .. } | Kind::Note(_) => {}
1187        }
1188        Flow::Continue
1189    }
1190
1191    /// Ask for a path line by line: what was typed, or the picker's
1192    /// default.
1193    fn prompt_path(
1194        io: &mut dyn LineIo,
1195        label: &str,
1196        default: Option<PathBuf>,
1197    ) -> Result<Value, NotInteractive> {
1198        match &default {
1199            Some(path) => io.write(&format!("{label} [{}]: ", display_path(path))),
1200            None => io.write(&format!("{label}: ")),
1201        }
1202        let line = io.read_line();
1203        let typed = line.as_deref().map(str::trim).unwrap_or_default();
1204        match (typed.is_empty(), default) {
1205            (false, _) => Ok(Value::Path(PathBuf::from(typed))),
1206            (true, Some(path)) => Ok(Value::Path(path)),
1207            (true, None) if line.is_none() => Err(NotInteractive::Ended),
1208            (true, None) => Err(NotInteractive::Invalid(format!("{label} needs a path"))),
1209        }
1210    }
1211
1212    /// Ask for a multi-select's options line by line, comma-separated.
1213    fn prompt_list(
1214        io: &mut dyn LineIo,
1215        label: &str,
1216        options: &[String],
1217        chosen: &[bool],
1218        limit: Option<usize>,
1219    ) -> Result<Value, NotInteractive> {
1220        let current: Vec<String> = options
1221            .iter()
1222            .zip(chosen)
1223            .filter(|(_, on)| **on)
1224            .map(|(option, _)| option.clone())
1225            .collect();
1226        io.write(&format!(
1227            "{label}: {} (comma-separated) [{}]: ",
1228            options.join(" / "),
1229            current.join(", ")
1230        ));
1231        let line = io.read_line().unwrap_or_default();
1232        let mut picked = Vec::new();
1233        for word in line.split(',').map(str::trim).filter(|w| !w.is_empty()) {
1234            let found = options
1235                .iter()
1236                .find(|option| option.eq_ignore_ascii_case(word))
1237                .ok_or_else(|| NotInteractive::Invalid(format!("not an option: {word:?}")))?;
1238            picked.push(found.clone());
1239        }
1240        if line.trim().is_empty() {
1241            return Ok(Value::List(current));
1242        }
1243        // In the options' order, as the terminal form gives them.
1244        let picked: Vec<String> = options
1245            .iter()
1246            .filter(|option| picked.contains(option))
1247            .cloned()
1248            .collect();
1249        match limit {
1250            Some(limit) if picked.len() > limit => Err(NotInteractive::Invalid(format!(
1251                "{label}: at most {limit} options"
1252            ))),
1253            _ => Ok(Value::List(picked)),
1254        }
1255    }
1256}
1257
1258impl Component for Form {
1259    type Output = Answers;
1260
1261    /// A form with no fields has nothing to ask: done at once.
1262    fn start(&mut self, _: &Context<'_>) -> Flow<Answers> {
1263        if self.fields.is_empty() {
1264            self.answer = Some(true);
1265            return Flow::Done(Answers::default());
1266        }
1267        self.refresh();
1268        if !self.stops().contains(&self.focus) {
1269            self.show_page(self.pager.page());
1270        }
1271        Flow::Continue
1272    }
1273
1274    fn keymap(&self) -> Keymap {
1275        let kind = self.fields.get(self.focus).map(|field| &field.kind);
1276        if let (
1277            true,
1278            Some(Kind::File {
1279                picker: Some(picker),
1280                ..
1281            }),
1282        ) = (self.browsing, kind)
1283        {
1284            return picker.keymap();
1285        }
1286        let mut keymap = self.form_keymap();
1287        match kind {
1288            Some(Kind::Text(input)) => keymap.extend(input.keymap()),
1289            Some(Kind::Choice { .. }) => keymap.extend(self.choice_keymap()),
1290            Some(Kind::Multi { .. }) => keymap.extend(self.multi_keymap()),
1291            Some(Kind::Toggle(_)) => keymap.extend(self.toggle_keymap()),
1292            Some(Kind::File { .. }) => keymap.extend(self.file_keymap()),
1293            Some(Kind::Note(_)) | None => {}
1294        }
1295        keymap
1296    }
1297
1298    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
1299        if self.fields.is_empty() {
1300            return Flow::Done(Answers::default());
1301        }
1302        let flow = self.handle_event(event, context);
1303        self.refresh();
1304        flow
1305    }
1306
1307    fn render(&self, context: &Context<'_>) -> View {
1308        let layout = self.layout(context);
1309        let view = View::new(layout.lines);
1310        match layout.cursor {
1311            Some((row, column)) => view.with_cursor(row, column),
1312            None => view,
1313        }
1314    }
1315
1316    fn mouse(&self) -> bool {
1317        self.mouse
1318    }
1319
1320    /// Ticks for text fields with a suggestion provider, so its results
1321    /// arrive, and for an open picker's previews.
1322    fn tick(&self) -> Option<Duration> {
1323        self.fields
1324            .iter()
1325            .filter_map(|field| match &field.kind {
1326                Kind::Text(input) => input.tick(),
1327                Kind::File {
1328                    picker: Some(picker),
1329                    ..
1330                } if self.browsing => picker.tick(),
1331                _ => None,
1332            })
1333            .min()
1334    }
1335
1336    /// Without a terminal: a line a field, in order, each page's title
1337    /// before its fields and each note's text where it is.
1338    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<Answers>, NotInteractive> {
1339        io.write(&format!("{}\n", self.title));
1340        let mut answers = Vec::new();
1341        let mut page = None;
1342        for at in 0..self.fields.len() {
1343            if page != Some(self.fields[at].page) {
1344                page = Some(self.fields[at].page);
1345                let title = &self.groups[self.fields[at].page];
1346                if !title.is_empty() {
1347                    io.write(&format!("{title}\n"));
1348                }
1349            }
1350            let so_far = Answers(answers.clone());
1351            let field = &mut self.fields[at];
1352            let value = match &mut field.kind {
1353                Kind::Text(input) => match input.prompt(io)? {
1354                    Some(text) => Value::Text(text),
1355                    None => return Ok(None),
1356                },
1357                Kind::Note(body) => {
1358                    io.write(&format!("{}\n{body}\n", field.label));
1359                    continue;
1360                }
1361                Kind::Choice {
1362                    options,
1363                    index,
1364                    source,
1365                } => {
1366                    if let Some(source) = source {
1367                        *options = source(&so_far);
1368                        *index = 0;
1369                    }
1370                    let current = options.get(*index).cloned().unwrap_or_default();
1371                    io.write(&format!(
1372                        "{}: {} [{current}]: ",
1373                        field.label,
1374                        options.join(" / ")
1375                    ));
1376                    // At the end of input, the current option answers, as
1377                    // for an empty line.
1378                    let line = io.read_line().unwrap_or_default();
1379                    let line = line.trim();
1380                    if line.is_empty() {
1381                        Value::Text(current)
1382                    } else {
1383                        let found = options
1384                            .iter()
1385                            .find(|option| option.eq_ignore_ascii_case(line))
1386                            .ok_or_else(|| {
1387                                NotInteractive::Invalid(format!("not an option: {line:?}"))
1388                            })?;
1389                        Value::Text(found.clone())
1390                    }
1391                }
1392                Kind::Multi {
1393                    options,
1394                    chosen,
1395                    limit,
1396                    ..
1397                } => Form::prompt_list(io, &field.label, options, chosen, *limit)?,
1398                Kind::Toggle(on) => {
1399                    io.write(&format!(
1400                        "{} [{}]: ",
1401                        field.label,
1402                        if *on { "Y/n" } else { "y/N" }
1403                    ));
1404                    let line = io.read_line().unwrap_or_default();
1405                    Value::Flag(match line.trim().to_lowercase().as_str() {
1406                        "" => *on,
1407                        "y" | "yes" => true,
1408                        "n" | "no" => false,
1409                        other => {
1410                            return Err(NotInteractive::Invalid(format!(
1411                                "not yes or no: {other:?}"
1412                            )));
1413                        }
1414                    })
1415                }
1416                Kind::File { .. } => {
1417                    let default = field.file_path().0;
1418                    Form::prompt_path(io, &field.label, default)?
1419                }
1420            };
1421            answers.push((field.name.clone(), value));
1422        }
1423        Ok(Some(Answers(answers)))
1424    }
1425}