Skip to main content

urushi_prompt/
input.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/// An error reported by a synchronous input validator.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ValidationError {
18    /// The message shown beneath the input.
19    pub message: String,
20}
21
22impl ValidationError {
23    /// Creates a validation error with a displayable message.
24    /// `message` is plain text. Escape sequences in it are counted as ordinary
25    /// characters when the prompt measures its cells, so a pre-styled string
26    /// mis-aligns the field; style it through the prompt's theme instead.
27    pub fn new(message: impl Into<String>) -> Self {
28        Self {
29            message: message.into(),
30        }
31    }
32}
33
34/// A synchronous validation rule for a prompt value.
35pub type Validator<T> = Box<dyn Fn(&T) -> Result<(), ValidationError> + Send + Sync + 'static>;
36
37/// A one-line text input field.
38pub struct Input {
39    key: FieldKey<String>,
40    question: String,
41    description: Option<String>,
42    value: String,
43    placeholder: Option<String>,
44    help: String,
45    required: bool,
46    required_message: String,
47    validators: Vec<Validator<String>>,
48    cursor: usize,
49    validation_error: Option<String>,
50}
51
52impl Input {
53    /// Creates an input with a typed key, prompt question, and initial value.
54    pub fn new(
55        key: FieldKey<String>,
56        question: impl Into<String>,
57        initial_value: impl Into<String>,
58    ) -> Result<Self, FieldConfigError> {
59        if key.name().is_empty() {
60            return Err(FieldConfigError::EmptyName);
61        }
62
63        let value = initial_value.into();
64        let cursor = value.graphemes(true).count();
65        Ok(Self {
66            key,
67            question: question.into(),
68            description: None,
69            value,
70            placeholder: None,
71            help: "enter continue • shift+tab back • esc cancel".to_owned(),
72            required: false,
73            required_message: "This field is required.".to_owned(),
74            validators: Vec::new(),
75            cursor,
76            validation_error: None,
77        })
78    }
79
80    /// Sets text shown when the current value is empty.
81    /// `placeholder` is plain text. Escape sequences in it are counted as ordinary
82    /// characters when the prompt measures its cells, so a pre-styled string
83    /// mis-aligns the field; style it through the prompt's theme instead.
84    #[must_use]
85    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
86        self.placeholder = Some(placeholder.into());
87        self
88    }
89
90    /// Sets supporting text shown below the question.
91    /// `description` is plain text. Escape sequences in it are counted as ordinary
92    /// characters when the prompt measures its cells, so a pre-styled string
93    /// mis-aligns the field; style it through the prompt's theme instead.
94    #[must_use]
95    pub fn description(mut self, description: impl Into<String>) -> Self {
96        self.description = Some(description.into());
97        self
98    }
99
100    /// Sets the navigation hint shown beneath the field.
101    /// `help` is plain text. Escape sequences in it are counted as ordinary
102    /// characters when the prompt measures its cells, so a pre-styled string
103    /// mis-aligns the field; style it through the prompt's theme instead.
104    #[must_use]
105    pub fn help(mut self, help: impl Into<String>) -> Self {
106        self.help = help.into();
107        self
108    }
109
110    /// Requires a non-empty value before the form can advance.
111    #[must_use]
112    pub fn required(mut self) -> Self {
113        self.required = true;
114        self
115    }
116
117    /// Sets the message shown when a required input is empty.
118    #[must_use]
119    pub fn required_message(mut self, message: impl Into<String>) -> Self {
120        self.required_message = message.into();
121        self
122    }
123
124    /// Appends a synchronous validator, evaluated after the required check.
125    #[must_use]
126    pub fn validate(mut self, validator: Validator<String>) -> Self {
127        self.validators.push(validator);
128        self
129    }
130
131    /// Returns the prompt question.
132    pub fn question(&self) -> &str {
133        &self.question
134    }
135
136    /// Returns the current input value.
137    pub fn value(&self) -> &str {
138        &self.value
139    }
140
141    fn edit(&mut self) {
142        self.validation_error = None;
143    }
144
145    fn byte_index(&self, grapheme_index: usize) -> usize {
146        self.value
147            .grapheme_indices(true)
148            .nth(grapheme_index)
149            .map_or(self.value.len(), |(byte_index, _)| byte_index)
150    }
151
152    fn delete_grapheme_at(&mut self, grapheme_index: usize) {
153        let start = self.byte_index(grapheme_index);
154        let end = self.byte_index(grapheme_index + 1);
155        if start != end {
156            self.value.replace_range(start..end, "");
157        }
158    }
159
160    fn insert_text(&mut self, text: &str) {
161        let text = text
162            .replace("\r\n", "\n")
163            .chars()
164            .filter_map(|character| match character {
165                '\r' | '\n' | '\t' => Some(' '),
166                character if character.is_control() => None,
167                character => Some(character),
168            })
169            .collect::<String>();
170        let byte_index = self.byte_index(self.cursor);
171        self.value.insert_str(byte_index, &text);
172        self.cursor += text.graphemes(true).count();
173        self.edit();
174    }
175
176    fn submit(&mut self) -> FieldAction {
177        if self.required && self.value.is_empty() {
178            self.validation_error = Some(self.required_message.clone());
179            return FieldAction::Stay;
180        }
181
182        for validator in &self.validators {
183            if let Err(error) = validator(&self.value) {
184                self.validation_error = Some(error.message);
185                return FieldAction::Stay;
186            }
187        }
188
189        self.validation_error = None;
190        FieldAction::Accept
191    }
192
193    fn answer_spans(&self, styles: &PromptStyles, focused: bool) -> Vec<TextSpan> {
194        if self.value.is_empty() {
195            // The blank the cursor sits on exists only to be highlighted; an
196            // unfocused field has no cursor and would draw a stray space.
197            let mut spans = if focused {
198                vec![TextSpan::new(" ", styles.cursor.clone())]
199            } else {
200                Vec::new()
201            };
202            if let Some(placeholder) = &self.placeholder {
203                spans.push(TextSpan::new(
204                    placeholder.clone(),
205                    styles.placeholder.clone(),
206                ));
207            }
208            return spans;
209        }
210
211        let cursor_byte = self.byte_index(self.cursor);
212        let mut spans = Vec::new();
213        if cursor_byte > 0 {
214            spans.push(TextSpan::new(
215                self.value[..cursor_byte].to_owned(),
216                styles.answer.clone(),
217            ));
218        }
219        if let Some(grapheme) = self.value[cursor_byte..].graphemes(true).next() {
220            // An unfocused field has no cursor, so the character under it is
221            // drawn as ordinary answer text.
222            let style = if focused {
223                &styles.cursor
224            } else {
225                &styles.answer
226            };
227            spans.push(TextSpan::new(grapheme, style.clone()));
228            let after_cursor = cursor_byte + grapheme.len();
229            if after_cursor < self.value.len() {
230                spans.push(TextSpan::new(
231                    self.value[after_cursor..].to_owned(),
232                    styles.answer.clone(),
233                ));
234            }
235        } else if focused {
236            spans.push(TextSpan::new(" ", styles.cursor.clone()));
237        }
238        spans
239    }
240}
241
242impl runtime::private::Sealed for Input {
243    fn into_entry(self: Box<Self>) -> FieldEntry {
244        FieldEntry::new(self.key.name().to_owned(), self)
245    }
246}
247
248impl RuntimeField for Input {
249    fn event(&mut self, event: Event) -> FieldAction {
250        if let Event::Paste(text) = event {
251            self.insert_text(&text);
252            return FieldAction::Stay;
253        }
254        let Event::Key(key) = event else {
255            return FieldAction::Stay;
256        };
257
258        match (
259            key.code,
260            key.modifiers.contains(KeyModifiers::CONTROL),
261            key.modifiers.contains(KeyModifiers::ALT),
262        ) {
263            (KeyCode::Escape, _, _) => FieldAction::Cancel,
264            (KeyCode::Char('a'), true, _) | (KeyCode::Home, _, _) => {
265                self.cursor = 0;
266                FieldAction::Stay
267            }
268            (KeyCode::Char('e'), true, _) | (KeyCode::End, _, _) => {
269                self.cursor = self.value.graphemes(true).count();
270                FieldAction::Stay
271            }
272            (KeyCode::Char('b'), true, _) | (KeyCode::Left, _, _) => {
273                self.cursor = self.cursor.saturating_sub(1);
274                FieldAction::Stay
275            }
276            (KeyCode::Char('f'), true, _) | (KeyCode::Right, _, _) => {
277                self.cursor = (self.cursor + 1).min(self.value.graphemes(true).count());
278                FieldAction::Stay
279            }
280            (KeyCode::Char('u'), true, _) => {
281                let end = self.byte_index(self.cursor);
282                self.value.replace_range(..end, "");
283                self.cursor = 0;
284                self.edit();
285                FieldAction::Stay
286            }
287            (KeyCode::Char('k'), true, _) => {
288                let start = self.byte_index(self.cursor);
289                self.value.truncate(start);
290                self.edit();
291                FieldAction::Stay
292            }
293            (KeyCode::Backspace, _, _) if self.cursor > 0 => {
294                self.delete_grapheme_at(self.cursor - 1);
295                self.cursor -= 1;
296                self.edit();
297                FieldAction::Stay
298            }
299            (KeyCode::Delete, _, _) if self.cursor < self.value.graphemes(true).count() => {
300                self.delete_grapheme_at(self.cursor);
301                self.edit();
302                FieldAction::Stay
303            }
304            (KeyCode::Enter | KeyCode::Tab, _, _) => self.submit(),
305            (KeyCode::Char(character), false, false) => {
306                self.insert_text(&character.to_string());
307                FieldAction::Stay
308            }
309            _ => FieldAction::Stay,
310        }
311    }
312
313    fn take_value(&mut self) -> Box<dyn Any> {
314        Box::new(std::mem::take(&mut self.value))
315    }
316
317    fn view(&self, styles: &PromptStyles, focused: bool, width: usize) -> FieldPresentation {
318        // The prompt marker keeps its cells whatever the value does, so the
319        // window the value scrolls in is the field's width less the marker.
320        let answer_start = 2_usize;
321        let cursor_prefix = &self.value[..self.byte_index(self.cursor)];
322        let cursor_column = PrintableText::new(cursor_prefix).width();
323        let answer_spans = self.answer_spans(styles, focused);
324        // A value wider than the terminal is windowed here, before a `View`
325        // exists: choosing which cells of an already-composed run are visible
326        // is a text-layer operation, and doing it here is what lets the view
327        // be resolved against the width the terminal really has.
328        let (answer_spans, cursor_column) = if focused {
329            window_spans(
330                answer_spans,
331                cursor_column,
332                width.saturating_sub(answer_start),
333            )
334        } else {
335            (answer_spans, cursor_column)
336        };
337        let answer_value = if focused {
338            line_view_with_cursor(answer_spans, cursor_column)
339        } else {
340            line_view(answer_spans)
341        };
342        let answer = field_line_view(
343            styles,
344            focused,
345            region(
346                FieldRegionKind::Control,
347                region(
348                    FieldRegionKind::Focus,
349                    View::row(
350                        VerticalAlign::Top,
351                        [
352                            fixed_view(
353                                answer_start,
354                                vec![TextSpan::new("› ", styles.answer.clone())],
355                            ),
356                            answer_value,
357                        ],
358                    ),
359                ),
360            ),
361        );
362        let mut body = vec![region(
363            FieldRegionKind::Question,
364            field_line_view(
365                styles,
366                focused,
367                line_view(vec![TextSpan::new(
368                    self.question.clone(),
369                    styles.question(focused).clone(),
370                )]),
371            ),
372        )];
373        let mut regions = vec![
374            FieldRegionKind::Question,
375            FieldRegionKind::Control,
376            FieldRegionKind::Focus,
377        ];
378        if let Some(description) = &self.description {
379            body.push(region(
380                FieldRegionKind::Description,
381                field_line_view(
382                    styles,
383                    focused,
384                    line_view(vec![TextSpan::new(
385                        description.clone(),
386                        styles.muted.clone(),
387                    )]),
388                ),
389            ));
390            regions.push(FieldRegionKind::Description);
391        }
392        body.push(answer);
393        if let Some(message) = &self.validation_error {
394            body.push(region(
395                FieldRegionKind::Error,
396                field_line_view(
397                    styles,
398                    focused,
399                    View::row(
400                        VerticalAlign::Top,
401                        [
402                            fixed_view(2, vec![TextSpan::new("! ", styles.error.clone())]),
403                            View::text(message.clone(), styles.error.clone()),
404                        ],
405                    ),
406                ),
407            ));
408            regions.push(FieldRegionKind::Error);
409        }
410        let presentation = FieldPresentation::new(View::column(Align::Left, body))
411            .with_help(clipped_line_view(vec![TextSpan::new(
412                self.help.clone(),
413                styles.help.clone(),
414            )]))
415            .with_regions(regions);
416        if focused {
417            presentation.with_cursor()
418        } else {
419            presentation
420        }
421    }
422
423    fn validation_error(&self) -> Option<&str> {
424        self.validation_error.as_deref()
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use std::{
431        collections::VecDeque,
432        io,
433        sync::{
434            Arc,
435            atomic::{AtomicUsize, Ordering},
436        },
437    };
438
439    use super::*;
440    use crate::runtime::PromptView;
441    use crate::{
442        Form, FormOutcome, Group,
443        runtime::{EventSource, RenderFinish, Renderer, test_styles},
444    };
445
446    fn key(code: KeyCode) -> Event {
447        Event::Key(runtime::KeyEvent::new(code))
448    }
449
450    #[test]
451    fn input_rejects_an_empty_field_name() {
452        assert!(matches!(
453            Input::new(FieldKey::new(""), "Name", ""),
454            Err(FieldConfigError::EmptyName)
455        ));
456    }
457
458    #[test]
459    fn edits_follow_unicode_grapheme_boundaries() {
460        let mut input = Input::new(FieldKey::new("name"), "Name", "aあb").expect("input is valid");
461
462        assert_eq!(input.event(key(KeyCode::Left)), FieldAction::Stay);
463        assert_eq!(input.event(key(KeyCode::Char('い'))), FieldAction::Stay);
464        assert_eq!(input.value(), "aあいb");
465        assert_eq!(input.event(key(KeyCode::Backspace)), FieldAction::Stay);
466        assert_eq!(input.value(), "aあb");
467        assert_eq!(input.event(key(KeyCode::Delete)), FieldAction::Stay);
468        assert_eq!(input.value(), "aあ");
469
470        let mut combined =
471            Input::new(FieldKey::new("combined"), "Name", "e\u{301}x").expect("input is valid");
472        assert_eq!(combined.event(key(KeyCode::Left)), FieldAction::Stay);
473        assert_eq!(combined.event(key(KeyCode::Backspace)), FieldAction::Stay);
474        assert_eq!(combined.value(), "x");
475    }
476
477    #[test]
478    fn paste_inserts_at_the_cursor_and_flattens_line_breaks() {
479        let mut input = Input::new(FieldKey::new("name"), "Name", "ab").expect("input is valid");
480        assert_eq!(input.event(key(KeyCode::Left)), FieldAction::Stay);
481        assert_eq!(
482            input.event(Event::Paste("あ\r\nい\tう".to_owned())),
483            FieldAction::Stay
484        );
485        assert_eq!(input.value(), "aあ い うb");
486    }
487
488    #[test]
489    fn view_uses_resolved_styles_and_cjk_display_columns() {
490        let mut input = Input::new(FieldKey::new("name"), "名前", "あ")
491            .expect("input is valid")
492            .placeholder("入力してください");
493        assert_eq!(input.event(key(KeyCode::Home)), FieldAction::Stay);
494
495        let styles = test_styles();
496        let view = input.view(&styles, true, 80);
497        let rows = view.rows();
498        assert!(rows[0].text().contains("名前"));
499        assert!(
500            rows[0]
501                .runs()
502                .iter()
503                .any(|run| run.style == styles.question)
504        );
505        assert!(rows[1].text().contains("› あ"));
506        assert!(rows[1].runs().iter().any(|run| run.style == styles.cursor));
507        let resolved = urushi::resolve(&view.body, urushi::Available::NONE).unwrap();
508        let cursor = resolved
509            .anchor(view.cursor.expect("focused input has a cursor anchor"))
510            .expect("cursor anchor resolves");
511        assert_eq!((cursor.y(), cursor.x()), (1, 4));
512        assert!(view.help.is_some());
513
514        let empty = Input::new(FieldKey::new("empty"), "Name", "")
515            .expect("input is valid")
516            .placeholder("Example");
517        let empty_view = empty.view(&styles, true, 80);
518        assert!(
519            empty_view.rows()[1]
520                .runs()
521                .iter()
522                .any(|run| run.style == styles.cursor)
523        );
524        assert!(
525            empty_view.rows()[1]
526                .runs()
527                .iter()
528                .any(|run| run.style == styles.placeholder)
529        );
530
531        assert_eq!(input.event(key(KeyCode::End)), FieldAction::Stay);
532        let rows = input.view(&styles, true, 80).rows();
533        let trailing = rows[1].runs();
534        let trailing = trailing.last().expect("the answer row has runs");
535        assert_eq!(trailing.text, " ");
536        assert_eq!(trailing.style, styles.cursor);
537    }
538
539    #[test]
540    fn an_unfocused_input_recedes_and_drops_the_cursor() {
541        let styles = test_styles();
542        let input = Input::new(FieldKey::new("name"), "Name", "value").expect("input is valid");
543
544        let unfocused = input.view(&styles, false, 80);
545        assert!(
546            unfocused.rows()[0]
547                .runs()
548                .iter()
549                .any(|run| run.style == styles.muted)
550        );
551        assert!(
552            unfocused.rows()[1]
553                .runs()
554                .iter()
555                .all(|span| span.style != styles.cursor)
556        );
557        assert_eq!(
558            unfocused.rows()[1]
559                .runs()
560                .iter()
561                .map(|span| span.text.as_str())
562                .collect::<String>(),
563            "  › value"
564        );
565
566        let empty = Input::new(FieldKey::new("empty"), "Name", "").expect("input is valid");
567        assert_eq!(empty.view(&styles, false, 80).rows()[1].text(), "  ›   ");
568    }
569
570    #[test]
571    fn an_unfocused_input_keeps_a_space_the_cursor_was_resting_on() {
572        // Losing focus drops the cursor, not a character of the value. The
573        // blank that is dropped is the synthetic one drawn past the end of the
574        // value, which is why it is decided by position rather than by text.
575        let styles = test_styles();
576        let mut input = Input::new(FieldKey::new("name"), "Name", "John Doe")
577            .expect("input is valid")
578            .placeholder("");
579        assert_eq!(input.event(key(KeyCode::Home)), FieldAction::Stay);
580        for _ in 0..4 {
581            assert_eq!(input.event(key(KeyCode::Right)), FieldAction::Stay);
582        }
583
584        let unfocused = input.view(&styles, false, 80);
585        assert_eq!(
586            unfocused.rows()[1]
587                .runs()
588                .iter()
589                .map(|span| span.text.as_str())
590                .collect::<String>(),
591            "  › John Doe"
592        );
593    }
594
595    #[test]
596    fn validators_run_in_registration_order_after_required() {
597        let second_runs = Arc::new(AtomicUsize::new(0));
598        let second_runs_for_validator = Arc::clone(&second_runs);
599        let mut input = Input::new(FieldKey::new("name"), "Name", "value")
600            .expect("input is valid")
601            .validate(Box::new(|_| Err(ValidationError::new("first failure"))))
602            .validate(Box::new(move |_| {
603                second_runs_for_validator.fetch_add(1, Ordering::SeqCst);
604                Ok(())
605            }));
606
607        assert_eq!(input.event(key(KeyCode::Enter)), FieldAction::Stay);
608        assert_eq!(input.validation_error(), Some("first failure"));
609        assert_eq!(second_runs.load(Ordering::SeqCst), 0);
610
611        let mut required = Input::new(FieldKey::new("required"), "Required", "")
612            .expect("input is valid")
613            .required()
614            .required_message("A value is needed.")
615            .validate(Box::new(|_| Err(ValidationError::new("validator failure"))));
616        assert_eq!(required.event(key(KeyCode::Tab)), FieldAction::Stay);
617        assert_eq!(required.validation_error(), Some("A value is needed."));
618    }
619
620    #[derive(Default)]
621    struct ScriptedEvents {
622        events: VecDeque<io::Result<Event>>,
623    }
624
625    impl ScriptedEvents {
626        fn new(events: impl IntoIterator<Item = io::Result<Event>>) -> Self {
627            Self {
628                events: events.into_iter().collect(),
629            }
630        }
631    }
632
633    impl EventSource for ScriptedEvents {
634        fn read_event(&mut self) -> io::Result<Event> {
635            self.events
636                .pop_front()
637                .unwrap_or_else(|| Err(io::Error::other("event script exhausted")))
638        }
639
640        fn poll_event(&mut self) -> io::Result<Option<Event>> {
641            Ok(None)
642        }
643
644        fn poll_event_timeout(
645            &mut self,
646            _timeout: std::time::Duration,
647        ) -> io::Result<Option<Event>> {
648            self.poll_event()
649        }
650    }
651
652    #[derive(Default)]
653    struct RecordingRenderer {
654        views: Vec<PromptView>,
655    }
656
657    impl Renderer for RecordingRenderer {
658        fn draw(
659            &mut self,
660            _output: &mut dyn urushi_terminal::CommandWriter,
661            view: &PromptView,
662            _start: crate::PromptStart,
663            _drawing_columns: u16,
664        ) -> io::Result<()> {
665            self.views.push(view.clone());
666            Ok(())
667        }
668
669        fn finish(
670            &mut self,
671            _output: &mut dyn urushi_terminal::CommandWriter,
672            _outcome: RenderFinish,
673        ) -> io::Result<()> {
674            Ok(())
675        }
676
677        fn clear_viewport(
678            &mut self,
679            _output: &mut dyn urushi_terminal::CommandWriter,
680        ) -> io::Result<()> {
681            Ok(())
682        }
683    }
684
685    fn rendered(view: &PromptView) -> crate::runtime::frame::FramedView {
686        crate::runtime::frame::frame(&crate::runtime::resolve::resolve_prompt(80, view), u16::MAX)
687    }
688
689    struct InteractiveTerminal;
690
691    impl urushi_terminal::RawModeControl for InteractiveTerminal {
692        fn is_interactive(&self) -> bool {
693            true
694        }
695
696        fn enable_raw_mode(&mut self) -> io::Result<()> {
697            Ok(())
698        }
699
700        fn disable_raw_mode(&mut self) -> io::Result<()> {
701            Ok(())
702        }
703    }
704
705    impl urushi_terminal::CommandWriter for InteractiveTerminal {
706        fn write_command(&mut self, _command: urushi_terminal::Command<'_>) -> io::Result<()> {
707            Ok(())
708        }
709    }
710
711    impl urushi_terminal::TerminalQuery for InteractiveTerminal {
712        fn terminal_size(&mut self) -> io::Result<urushi_terminal::TerminalSize> {
713            Ok(urushi_terminal::TerminalSize::new(80, 24))
714        }
715
716        fn cursor_position(&mut self) -> io::Result<urushi_terminal::Position> {
717            Ok(urushi_terminal::Position::new(0, 0))
718        }
719
720        fn window_size(&mut self) -> io::Result<urushi_terminal::WindowSize> {
721            Ok(urushi_terminal::WindowSize::new(
722                urushi_terminal::TerminalSize::new(80, 24),
723                None,
724            ))
725        }
726
727        fn raw_mode_enabled(&mut self) -> io::Result<bool> {
728            Ok(false)
729        }
730    }
731
732    impl urushi_terminal::KeyboardEnhancementQuery for InteractiveTerminal {
733        fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
734            Ok(false)
735        }
736    }
737
738    impl urushi_terminal::TerminalOutput for InteractiveTerminal {
739        fn flush(&mut self) -> io::Result<()> {
740            Ok(())
741        }
742    }
743
744    #[test]
745    fn invalid_edits_clear_the_error_and_then_advance_with_typed_values() {
746        let first = Input::new(FieldKey::new("first"), "First", "")
747            .expect("input is valid")
748            .required();
749        let second = Input::new(FieldKey::new("second"), "Second", "done").expect("input is valid");
750        let form = Form::builder()
751            .group(
752                Group::builder()
753                    .field(first)
754                    .field(second)
755                    .build()
756                    .expect("group is valid"),
757            )
758            .build()
759            .expect("form is valid");
760        let mut events = ScriptedEvents::new([
761            Ok(key(KeyCode::Enter)),
762            Ok(key(KeyCode::Char('値'))),
763            Ok(key(KeyCode::Tab)),
764            Ok(key(KeyCode::Enter)),
765        ]);
766        let mut renderer = RecordingRenderer::default();
767        let mut terminal = InteractiveTerminal;
768
769        let outcome = form
770            .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
771            .expect("form submits");
772        let FormOutcome::Submitted(values) = outcome else {
773            panic!("expected submitted values");
774        };
775        assert_eq!(values.get(&FieldKey::new("first")), Some(&"値".to_owned()));
776        assert_eq!(
777            values.get(&FieldKey::new("second")),
778            Some(&"done".to_owned())
779        );
780        assert_eq!(renderer.views.len(), 4);
781        let rejected = rendered(&renderer.views[1]);
782        assert!(rejected.rows.iter().any(|row| {
783            row.runs()
784                .iter()
785                .any(|run| run.style == test_styles().error)
786        }));
787        let final_view = rendered(&renderer.views[3]);
788        assert!(
789            final_view
790                .rows
791                .iter()
792                .any(|row| row.text().contains("Second"))
793        );
794        assert!(
795            final_view
796                .rows
797                .iter()
798                .any(|row| row.text().contains("› 値"))
799        );
800        assert_eq!(
801            final_view
802                .rows
803                .iter()
804                .flat_map(crate::runtime::frame::FramedRow::runs)
805                .filter(|run| run.style == test_styles().help)
806                .count(),
807            1
808        );
809    }
810}