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
15pub struct SelectOption<T> {
17 pub label: String,
19 pub value: T,
21}
22
23impl<T> SelectOption<T> {
24 pub fn new(label: impl Into<String>, value: T) -> Self {
29 Self {
30 label: label.into(),
31 value,
32 }
33 }
34}
35
36pub 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 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 #[must_use]
90 pub fn help(mut self, help: impl Into<String>) -> Self {
91 self.help = help.into();
92 self
93 }
94
95 #[must_use]
100 pub fn description(mut self, description: impl Into<String>) -> Self {
101 self.description = Some(description.into());
102 self
103 }
104
105 #[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 #[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 #[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 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 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}