Skip to main content

urushi_prompt/
select.rs

1use std::any::Any;
2
3use unicode_segmentation::UnicodeSegmentation;
4use urushi::{Align, PrintableText, VerticalAlign, View};
5
6use crate::{
7    FieldConfigError, FieldKey,
8    runtime::{
9        self, Event, FieldAction, FieldEntry, FieldPresentation, FieldRegionKind, KeyCode,
10        KeyModifiers, PromptStyles, RuntimeField, TextSpan, clipped_line_view, field_line_view,
11        fixed_view, line_view, line_view_with_cursor, region, window_spans,
12    },
13};
14
15/// A labelled value offered by [`Select`].
16pub struct SelectOption<T> {
17    /// The text shown to the user.
18    pub label: String,
19    /// The typed value submitted when this option is selected.
20    pub value: T,
21}
22
23impl<T> SelectOption<T> {
24    /// Creates a selectable typed value with a display label.
25    /// `label` is plain text. Escape sequences in it are counted as ordinary
26    /// characters when the prompt measures its cells, so a pre-styled string
27    /// mis-aligns the field; style it through the prompt's theme instead.
28    pub fn new(label: impl Into<String>, value: T) -> Self {
29        Self {
30            label: label.into(),
31            value,
32        }
33    }
34}
35
36/// A field that selects one value from a non-empty list of options.
37pub struct Select<T> {
38    key: FieldKey<T>,
39    question: String,
40    description: Option<String>,
41    options: Vec<SelectOption<T>>,
42    selected: usize,
43    filtered: Vec<usize>,
44    filter: String,
45    filtering: bool,
46    visible_rows: usize,
47    help: String,
48    filtering_help: String,
49    filtered_help: String,
50    no_matches_message: String,
51}
52
53impl<T> Select<T> {
54    /// Creates a select field with the first option initially selected.
55    pub fn new(
56        key: FieldKey<T>,
57        question: impl Into<String>,
58        options: Vec<SelectOption<T>>,
59    ) -> Result<Self, FieldConfigError> {
60        if key.name().is_empty() {
61            return Err(FieldConfigError::EmptyName);
62        }
63        if options.is_empty() {
64            return Err(FieldConfigError::EmptyOptions);
65        }
66
67        let filtered = (0..options.len()).collect();
68        Ok(Self {
69            key,
70            question: question.into(),
71            description: None,
72            options,
73            selected: 0,
74            filtered,
75            filter: String::new(),
76            filtering: false,
77            visible_rows: 7,
78            help: "↑/↓ select • enter continue • shift+tab back • esc cancel".to_owned(),
79            filtering_help: "type to filter • ↑/↓ select • enter apply • esc close".to_owned(),
80            filtered_help: "↑/↓ select • enter continue • / edit filter • esc clear".to_owned(),
81            no_matches_message: "No matches".to_owned(),
82        })
83    }
84
85    /// Sets the navigation hint shown beneath the options.
86    /// `help` is plain text. Escape sequences in it are counted as ordinary
87    /// characters when the prompt measures its cells, so a pre-styled string
88    /// mis-aligns the field; style it through the prompt's theme instead.
89    #[must_use]
90    pub fn help(mut self, help: impl Into<String>) -> Self {
91        self.help = help.into();
92        self
93    }
94
95    /// Sets supporting text shown below the question.
96    /// `description` is plain text. Escape sequences in it are counted as ordinary
97    /// characters when the prompt measures its cells, so a pre-styled string
98    /// mis-aligns the field; style it through the prompt's theme instead.
99    #[must_use]
100    pub fn description(mut self, description: impl Into<String>) -> Self {
101        self.description = Some(description.into());
102        self
103    }
104
105    /// Sets the hints shown while editing and after applying a filter.
106    #[must_use]
107    pub fn filter_help(mut self, editing: impl Into<String>, applied: impl Into<String>) -> Self {
108        self.filtering_help = editing.into();
109        self.filtered_help = applied.into();
110        self
111    }
112
113    /// Sets the message shown when filtering removes every option.
114    #[must_use]
115    pub fn no_matches_message(mut self, message: impl Into<String>) -> Self {
116        self.no_matches_message = message.into();
117        self
118    }
119
120    /// Limits the number of option rows shown before the list scrolls.
121    #[must_use]
122    pub fn visible_rows(mut self, rows: usize) -> Self {
123        self.visible_rows = rows.max(1);
124        self
125    }
126
127    fn previous(&mut self) {
128        if let Some(position) = self.selected_position()
129            && self.filtered.len() > 1
130        {
131            let position = (position + self.filtered.len() - 1) % self.filtered.len();
132            self.selected = self.filtered[position];
133        }
134    }
135
136    fn next(&mut self) {
137        if let Some(position) = self.selected_position()
138            && self.filtered.len() > 1
139        {
140            self.selected = self.filtered[(position + 1) % self.filtered.len()];
141        }
142    }
143
144    fn selected_position(&self) -> Option<usize> {
145        self.filtered
146            .iter()
147            .position(|index| *index == self.selected)
148    }
149
150    fn update_filter(&mut self) {
151        let needle = self.filter.to_lowercase();
152        self.filtered = self
153            .options
154            .iter()
155            .enumerate()
156            .filter_map(|(index, option)| {
157                option
158                    .label
159                    .to_lowercase()
160                    .contains(&needle)
161                    .then_some(index)
162            })
163            .collect();
164        if !self.filtered.contains(&self.selected)
165            && let Some(first) = self.filtered.first()
166        {
167            self.selected = *first;
168        }
169    }
170
171    fn append_filter(&mut self, text: &str) {
172        self.filter
173            .extend(text.chars().filter(|character| !character.is_control()));
174        self.update_filter();
175    }
176
177    fn backspace_filter(&mut self) {
178        if let Some((start, _)) = self.filter.grapheme_indices(true).next_back() {
179            self.filter.truncate(start);
180            self.update_filter();
181        }
182    }
183
184    fn move_to(&mut self, position: usize) {
185        if let Some(index) = self.filtered.get(position) {
186            self.selected = *index;
187        }
188    }
189
190    fn page(&mut self, direction: isize) {
191        let Some(position) = self.selected_position() else {
192            return;
193        };
194        let distance = (self.visible_rows / 2).max(1);
195        let position = if direction.is_negative() {
196            position.saturating_sub(distance)
197        } else {
198            (position + distance).min(self.filtered.len().saturating_sub(1))
199        };
200        self.move_to(position);
201    }
202
203    fn current_help(&self) -> &str {
204        if self.filtering {
205            &self.filtering_help
206        } else if !self.filter.is_empty() {
207            &self.filtered_help
208        } else {
209            &self.help
210        }
211    }
212}
213
214impl<T: 'static> runtime::private::Sealed for Select<T> {
215    fn into_entry(self: Box<Self>) -> FieldEntry {
216        FieldEntry::new(self.key.name().to_owned(), self)
217    }
218}
219
220impl<T: 'static> RuntimeField for Select<T> {
221    fn event(&mut self, event: Event) -> FieldAction {
222        if let Event::Paste(text) = event {
223            if self.filtering {
224                self.append_filter(&text);
225            }
226            return FieldAction::Stay;
227        }
228        let Event::Key(key) = event else {
229            return FieldAction::Stay;
230        };
231
232        match (
233            key.code,
234            key.modifiers.contains(KeyModifiers::CONTROL),
235            key.modifiers.contains(KeyModifiers::ALT),
236        ) {
237            (KeyCode::Escape, _, _) if self.filtering => {
238                self.filtering = false;
239                FieldAction::Handled
240            }
241            (KeyCode::Escape, _, _) if !self.filter.is_empty() => {
242                self.filter.clear();
243                self.update_filter();
244                FieldAction::Handled
245            }
246            (KeyCode::Escape, _, _) => FieldAction::Cancel,
247            (KeyCode::Char('/'), false, false) => {
248                self.filtering = true;
249                FieldAction::Stay
250            }
251            (KeyCode::Backspace, _, _) if self.filtering => {
252                self.backspace_filter();
253                FieldAction::Stay
254            }
255            (KeyCode::Char(character), false, false) if self.filtering => {
256                self.append_filter(&character.to_string());
257                FieldAction::Stay
258            }
259            (KeyCode::Up | KeyCode::Left, _, _)
260            | (KeyCode::Char('k' | 'p'), true, _)
261            | (KeyCode::Char('k'), false, false) => {
262                self.previous();
263                FieldAction::Stay
264            }
265            (KeyCode::Down | KeyCode::Right, _, _)
266            | (KeyCode::Char('j' | 'n'), true, _)
267            | (KeyCode::Char('j'), false, false) => {
268                self.next();
269                FieldAction::Stay
270            }
271            (KeyCode::Home, _, _) | (KeyCode::Char('g'), false, false) => {
272                self.move_to(0);
273                FieldAction::Stay
274            }
275            (KeyCode::End, _, _) | (KeyCode::Char('G'), false, false) => {
276                self.move_to(self.filtered.len().saturating_sub(1));
277                FieldAction::Stay
278            }
279            (KeyCode::Char('u'), true, _) => {
280                self.page(-1);
281                FieldAction::Stay
282            }
283            (KeyCode::Char('d'), true, _) => {
284                self.page(1);
285                FieldAction::Stay
286            }
287            (KeyCode::Enter | KeyCode::Tab, _, _) if self.filtering => {
288                self.filtering = false;
289                FieldAction::Stay
290            }
291            (KeyCode::Enter | KeyCode::Tab, _, _) if !self.filtered.is_empty() => {
292                FieldAction::Accept
293            }
294            _ => FieldAction::Stay,
295        }
296    }
297
298    fn take_value(&mut self) -> Box<dyn Any> {
299        Box::new(self.options.remove(self.selected).value)
300    }
301
302    fn view(&self, styles: &PromptStyles, focused: bool, width: usize) -> FieldPresentation {
303        let mut title_spans = vec![TextSpan::new(
304            self.question.clone(),
305            styles.question(focused).clone(),
306        )];
307        let mut cursor = None;
308        if self.filtering || !self.filter.is_empty() {
309            title_spans.push(TextSpan::new("  / ", styles.answer.clone()));
310            if !self.filter.is_empty() {
311                title_spans.push(TextSpan::new(self.filter.clone(), styles.answer.clone()));
312            }
313            if self.filtering {
314                // The blank marks where typing continues, so it belongs to the
315                // cursor and disappears with it when the field loses focus.
316                if focused {
317                    title_spans.push(TextSpan::new(" ", styles.cursor.clone()));
318                    cursor = Some(
319                        PrintableText::new(
320                            format!("{}  / {}", self.question, self.filter).as_str(),
321                        )
322                        .width(),
323                    );
324                }
325            }
326        }
327        // A filter typed past the terminal's width scrolls the title, the same
328        // way an input scrolls its value, and for the same reason: the window
329        // is chosen before the view is composed.
330        let cursor = cursor.map(|column| {
331            let (windowed, column) = window_spans(title_spans.clone(), column, width);
332            title_spans = windowed;
333            column
334        });
335        let title = match cursor {
336            Some(column) => region(
337                FieldRegionKind::Focus,
338                field_line_view(styles, focused, line_view_with_cursor(title_spans, column)),
339            ),
340            None => field_line_view(styles, focused, line_view(title_spans)),
341        };
342        let mut body = vec![region(FieldRegionKind::Question, title)];
343        let mut regions = vec![
344            FieldRegionKind::Question,
345            FieldRegionKind::Control,
346            FieldRegionKind::Focus,
347        ];
348        if let Some(description) = &self.description {
349            body.push(region(
350                FieldRegionKind::Description,
351                field_line_view(
352                    styles,
353                    focused,
354                    line_view(vec![TextSpan::new(
355                        description.clone(),
356                        styles.muted.clone(),
357                    )]),
358                ),
359            ));
360            regions.push(FieldRegionKind::Description);
361        }
362        let list_height = self.visible_rows.min(self.options.len());
363        let (start, end) = if self.filtered.is_empty() {
364            (0, 0)
365        } else {
366            let selected_position = self.selected_position().unwrap_or(0);
367            let start = selected_position
368                .saturating_sub(self.visible_rows / 2)
369                .min(self.filtered.len().saturating_sub(self.visible_rows));
370            let end = (start + self.visible_rows).min(self.filtered.len());
371            (start, end)
372        };
373        let mut controls = Vec::new();
374        if self.filtered.is_empty() {
375            let no_matches = region(
376                FieldRegionKind::Error,
377                field_line_view(
378                    styles,
379                    focused,
380                    line_view(vec![TextSpan::new(
381                        self.no_matches_message.clone(),
382                        styles.error.clone(),
383                    )]),
384                ),
385            );
386            controls.push(if cursor.is_some() {
387                no_matches
388            } else {
389                region(FieldRegionKind::Focus, no_matches)
390            });
391            regions.push(FieldRegionKind::Error);
392        } else {
393            controls.extend(self.filtered[start..end].iter().map(|index| {
394                let option = &self.options[*index];
395                let selected = *index == self.selected;
396                let style = if selected {
397                    &styles.option_selected
398                } else {
399                    &styles.option
400                };
401                let option = field_line_view(
402                    styles,
403                    focused,
404                    View::row(
405                        VerticalAlign::Top,
406                        [
407                            fixed_view(
408                                2,
409                                vec![TextSpan::new(
410                                    if selected { "› " } else { "  " },
411                                    style.clone(),
412                                )],
413                            ),
414                            line_view(vec![TextSpan::new(option.label.clone(), style.clone())]),
415                        ],
416                    ),
417                );
418                if selected && cursor.is_none() {
419                    region(FieldRegionKind::Focus, option)
420                } else {
421                    option
422                }
423            }));
424        }
425        let rendered_rows = if self.filtered.is_empty() {
426            1
427        } else {
428            end - start
429        };
430        controls.extend(
431            (rendered_rows..list_height)
432                .map(|_| field_line_view(styles, focused, View::text("", styles.body.clone()))),
433        );
434        if self.options.len() > self.visible_rows {
435            let above = start;
436            let below = self.filtered.len().saturating_sub(end);
437            let status = match (above, below) {
438                (0, 0) => format!("  = {}", self.filtered.len()),
439                (0, below) => format!("  ↓ {below}"),
440                (above, 0) => format!("  ↑ {above}"),
441                (above, below) => format!("  ↑ {above} • ↓ {below}"),
442            };
443            controls.push(field_line_view(
444                styles,
445                focused,
446                line_view(vec![TextSpan::new(status, styles.muted.clone())]),
447            ));
448        }
449        body.push(region(
450            FieldRegionKind::Control,
451            View::column(Align::Left, controls),
452        ));
453        let presentation = FieldPresentation::new(View::column(Align::Left, body))
454            .with_help(clipped_line_view(vec![TextSpan::new(
455                self.current_help().to_owned(),
456                styles.help.clone(),
457            )]))
458            .with_regions(regions);
459        if cursor.is_some() {
460            presentation.with_cursor()
461        } else {
462            presentation
463        }
464    }
465
466    fn blur(&mut self) {
467        self.filtering = false;
468    }
469
470    fn captures_tab(&self) -> bool {
471        self.filtering
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use std::{collections::VecDeque, io};
478
479    use super::*;
480    use crate::runtime::PromptView;
481    use crate::{
482        Confirm, ConfirmSource, Form, FormOutcome, Group, Input,
483        runtime::{EventSource, RenderFinish, Renderer, test_styles},
484    };
485
486    fn key(code: KeyCode) -> Event {
487        Event::Key(runtime::KeyEvent::new(code))
488    }
489
490    fn options() -> Vec<SelectOption<&'static str>> {
491        vec![
492            SelectOption::new("One", "one"),
493            SelectOption::new("Two", "two"),
494        ]
495    }
496
497    #[test]
498    fn construction_rejects_empty_names_and_options() {
499        assert!(matches!(
500            Select::<String>::new(
501                FieldKey::new(""),
502                "Question",
503                vec![SelectOption::new("One", String::new())]
504            ),
505            Err(FieldConfigError::EmptyName)
506        ));
507        assert!(matches!(
508            Select::<String>::new(FieldKey::new("answer"), "Question", Vec::new()),
509            Err(FieldConfigError::EmptyOptions)
510        ));
511    }
512
513    #[test]
514    fn single_option_is_stable_and_multiple_options_cycle() {
515        let mut single = Select::new(
516            FieldKey::new("single"),
517            "Single",
518            vec![SelectOption::new("Only", 1)],
519        )
520        .expect("single option is valid");
521        let styles = test_styles();
522        assert_eq!(single.event(key(KeyCode::Left)), FieldAction::Stay);
523        assert!(
524            single.view(&styles, true, 80).rows()[1]
525                .text()
526                .contains("› Only")
527        );
528        assert_eq!(single.event(key(KeyCode::Right)), FieldAction::Stay);
529        assert!(
530            single.view(&styles, true, 80).rows()[1]
531                .text()
532                .contains("› Only")
533        );
534
535        let mut multiple = Select::new(FieldKey::new("multiple"), "Multiple", options())
536            .expect("multiple options are valid");
537        assert_eq!(multiple.event(key(KeyCode::Up)), FieldAction::Stay);
538        assert!(
539            multiple.view(&styles, true, 80).rows()[2]
540                .runs()
541                .iter()
542                .any(|run| run.style == styles.option_selected)
543        );
544        assert_eq!(multiple.event(key(KeyCode::Down)), FieldAction::Stay);
545        assert!(
546            multiple.view(&styles, true, 80).rows()[1]
547                .runs()
548                .iter()
549                .any(|run| run.style == styles.option_selected)
550        );
551        assert_eq!(multiple.event(key(KeyCode::Right)), FieldAction::Stay);
552        assert!(
553            multiple.view(&styles, true, 80).rows()[2]
554                .runs()
555                .iter()
556                .any(|run| run.style == styles.option_selected)
557        );
558        assert_eq!(multiple.event(key(KeyCode::Left)), FieldAction::Stay);
559        assert!(
560            multiple.view(&styles, true, 80).rows()[1]
561                .runs()
562                .iter()
563                .any(|run| run.style == styles.option_selected)
564        );
565    }
566
567    #[test]
568    fn view_carries_resolved_styles_and_frame_classification() {
569        let select = Select::new(FieldKey::new("choice"), "Choose", options())
570            .expect("select is valid")
571            .help("Use arrows, then Enter.");
572        let styles = test_styles();
573        let view = select.view(&styles, true, 80);
574        assert_eq!(view.cursor, None);
575        assert!(view.rows()[0].text().contains("Choose"));
576        assert!(
577            view.rows()[0]
578                .runs()
579                .iter()
580                .any(|run| run.style == styles.question)
581        );
582        assert!(view.rows()[1].text().contains("› One"));
583        assert!(
584            view.rows()[1]
585                .runs()
586                .iter()
587                .any(|run| run.style == styles.option_selected)
588        );
589        assert!(
590            view.rows()[2]
591                .runs()
592                .iter()
593                .any(|run| run.style == styles.option)
594        );
595        assert!(view.help.is_some());
596        assert!(
597            select.view(&styles, false, 80).rows()[0]
598                .runs()
599                .iter()
600                .any(|run| run.style == styles.muted)
601        );
602    }
603
604    #[test]
605    fn long_lists_scroll_and_filter_without_leaking_escape_to_the_form() {
606        let options = (0..12)
607            .map(|index| SelectOption::new(format!("Item {index}"), index))
608            .collect();
609        let mut select = Select::new(FieldKey::new("choice"), "Choose", options)
610            .expect("select is valid")
611            .visible_rows(3);
612
613        assert!(
614            select
615                .view(&test_styles(), true, 80)
616                .rows()
617                .iter()
618                .any(|line| { line.runs().iter().any(|run| run.text == "  ↓ 9") })
619        );
620        assert_eq!(select.event(key(KeyCode::End)), FieldAction::Stay);
621        assert_eq!(select.selected, 11);
622        assert!(
623            select
624                .view(&test_styles(), true, 80)
625                .rows()
626                .iter()
627                .any(|line| { line.runs().iter().any(|run| run.text == "  ↑ 9") })
628        );
629
630        assert_eq!(select.event(key(KeyCode::Char('/'))), FieldAction::Stay);
631        assert_eq!(select.event(key(KeyCode::Char('1'))), FieldAction::Stay);
632        assert_eq!(select.filtered, vec![1, 10, 11]);
633        assert_eq!(select.event(key(KeyCode::Escape)), FieldAction::Handled);
634        assert_eq!(select.filter, "1");
635        assert_eq!(select.event(key(KeyCode::Escape)), FieldAction::Handled);
636        assert!(select.filter.is_empty());
637        assert_eq!(select.filtered.len(), 12);
638    }
639
640    #[test]
641    fn an_empty_filter_result_cannot_be_submitted() {
642        let mut select =
643            Select::new(FieldKey::new("choice"), "Choose", options()).expect("select is valid");
644        assert_eq!(select.event(key(KeyCode::Char('/'))), FieldAction::Stay);
645        assert_eq!(
646            select.event(Event::Paste("missing".to_owned())),
647            FieldAction::Stay
648        );
649        assert!(select.filtered.is_empty());
650        assert_eq!(select.event(key(KeyCode::Enter)), FieldAction::Stay);
651        let presentation = select.view(&test_styles(), true, 80);
652        assert!(
653            presentation
654                .regions
655                .iter()
656                .any(|region| region.kind == FieldRegionKind::Error)
657        );
658    }
659
660    #[derive(Default)]
661    struct ScriptedEvents {
662        events: VecDeque<io::Result<Event>>,
663    }
664
665    impl ScriptedEvents {
666        fn new(events: impl IntoIterator<Item = io::Result<Event>>) -> Self {
667            Self {
668                events: events.into_iter().collect(),
669            }
670        }
671    }
672
673    impl EventSource for ScriptedEvents {
674        fn read_event(&mut self) -> io::Result<Event> {
675            self.events
676                .pop_front()
677                .unwrap_or_else(|| Err(io::Error::other("event script exhausted")))
678        }
679
680        fn poll_event(&mut self) -> io::Result<Option<Event>> {
681            Ok(None)
682        }
683
684        fn poll_event_timeout(
685            &mut self,
686            _timeout: std::time::Duration,
687        ) -> io::Result<Option<Event>> {
688            self.poll_event()
689        }
690    }
691
692    #[derive(Default)]
693    struct RecordingRenderer {
694        views: Vec<PromptView>,
695    }
696
697    impl Renderer for RecordingRenderer {
698        fn draw(
699            &mut self,
700            _output: &mut dyn urushi_terminal::CommandWriter,
701            view: &PromptView,
702            _start: crate::PromptStart,
703            _drawing_columns: u16,
704        ) -> io::Result<()> {
705            self.views.push(view.clone());
706            Ok(())
707        }
708
709        fn finish(
710            &mut self,
711            _output: &mut dyn urushi_terminal::CommandWriter,
712            _outcome: RenderFinish,
713        ) -> io::Result<()> {
714            Ok(())
715        }
716
717        fn clear_viewport(
718            &mut self,
719            _output: &mut dyn urushi_terminal::CommandWriter,
720        ) -> io::Result<()> {
721            Ok(())
722        }
723    }
724
725    fn rendered(view: &PromptView) -> crate::runtime::frame::FramedView {
726        crate::runtime::frame::frame(&crate::runtime::resolve::resolve_prompt(80, view), u16::MAX)
727    }
728
729    struct InteractiveTerminal;
730
731    impl urushi_terminal::RawModeControl for InteractiveTerminal {
732        fn is_interactive(&self) -> bool {
733            true
734        }
735
736        fn enable_raw_mode(&mut self) -> io::Result<()> {
737            Ok(())
738        }
739
740        fn disable_raw_mode(&mut self) -> io::Result<()> {
741            Ok(())
742        }
743    }
744
745    impl urushi_terminal::CommandWriter for InteractiveTerminal {
746        fn write_command(&mut self, _command: urushi_terminal::Command<'_>) -> io::Result<()> {
747            Ok(())
748        }
749    }
750
751    impl urushi_terminal::TerminalQuery for InteractiveTerminal {
752        fn terminal_size(&mut self) -> io::Result<urushi_terminal::TerminalSize> {
753            Ok(urushi_terminal::TerminalSize::new(80, 24))
754        }
755
756        fn cursor_position(&mut self) -> io::Result<urushi_terminal::Position> {
757            Ok(urushi_terminal::Position::new(0, 0))
758        }
759
760        fn window_size(&mut self) -> io::Result<urushi_terminal::WindowSize> {
761            Ok(urushi_terminal::WindowSize::new(
762                urushi_terminal::TerminalSize::new(80, 24),
763                None,
764            ))
765        }
766
767        fn raw_mode_enabled(&mut self) -> io::Result<bool> {
768            Ok(false)
769        }
770    }
771
772    impl urushi_terminal::KeyboardEnhancementQuery for InteractiveTerminal {
773        fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
774            Ok(false)
775        }
776    }
777
778    impl urushi_terminal::TerminalOutput for InteractiveTerminal {
779        fn flush(&mut self) -> io::Result<()> {
780            Ok(())
781        }
782    }
783
784    #[derive(Debug, PartialEq, Eq)]
785    struct NonCloneValue(&'static str);
786
787    #[test]
788    fn submit_moves_non_clone_values_and_back_preserves_choices() {
789        let selection_key = FieldKey::new("selection");
790        let confirmation_key = FieldKey::new("confirmation");
791        let tail_key = FieldKey::new("tail");
792        let selection = Select::new(
793            selection_key.clone(),
794            "Select",
795            vec![
796                SelectOption::new("First", NonCloneValue("first")),
797                SelectOption::new("Second", NonCloneValue("second")),
798            ],
799        )
800        .expect("select is valid");
801        let confirmation = Confirm::new(confirmation_key.clone(), "Confirm", Some(true))
802            .expect("confirm is valid");
803        let tail = Input::new(tail_key.clone(), "Tail", "done").expect("input is valid");
804        let form = Form::builder()
805            .group(
806                Group::builder()
807                    .field(selection)
808                    .field(confirmation)
809                    .field(tail)
810                    .build()
811                    .expect("group is valid"),
812            )
813            .build()
814            .expect("form is valid");
815        let mut events = ScriptedEvents::new([
816            Ok(key(KeyCode::Down)),
817            Ok(key(KeyCode::Enter)),
818            Ok(key(KeyCode::Right)),
819            Ok(key(KeyCode::Tab)),
820            Ok(key(KeyCode::BackTab)),
821            Ok(key(KeyCode::Enter)),
822            Ok(key(KeyCode::Enter)),
823        ]);
824        let mut renderer = RecordingRenderer::default();
825        let mut terminal = InteractiveTerminal;
826
827        let outcome = form
828            .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
829            .expect("form submits");
830        let FormOutcome::Submitted(values) = outcome else {
831            panic!("expected submitted values");
832        };
833        assert_eq!(values.get(&selection_key), Some(&NonCloneValue("second")));
834        assert_eq!(
835            values.get(&confirmation_key).map(|answer| answer.source),
836            Some(ConfirmSource::Explicit)
837        );
838        assert_eq!(
839            values.get(&confirmation_key).map(|answer| answer.value),
840            Some(false)
841        );
842        assert_eq!(values.get(&tail_key), Some(&"done".to_owned()));
843        for view in [&renderer.views[1], &renderer.views[5]] {
844            assert!(rendered(view).rows.iter().any(|row| {
845                row.text().contains("Second")
846                    && row
847                        .runs()
848                        .iter()
849                        .any(|run| run.style == test_styles().option_selected)
850            }));
851        }
852    }
853}