Skip to main content

urushi_prompt/runtime/
form.rs

1//! Form construction, navigation, and blocking execution orchestration.
2
3use std::{
4    any::Any,
5    collections::{HashMap, VecDeque},
6    fmt,
7    marker::PhantomData,
8};
9
10use urushi::{ColorLevel, RenderSettings, Theme};
11
12use super::{
13    TextSpan,
14    error::{FormBuildError, GroupBuildError, IoOperation, RunError},
15    field::{self, Field, FieldAction, FieldEntry},
16    terminal::{Event, KeyCode, KeyEvent, KeyModifiers, RenderFinish, Renderer, TerminalSession},
17    terminal_backend::{TerminalRenderer, accepts_prompt_event},
18    view::{GUTTER, LineKind, PromptLine, PromptStyles, PromptView, gutter_view},
19};
20
21#[cfg(test)]
22use super::terminal::{EventSource, SplitTerminal, TerminalControl};
23
24pub struct FieldKey<T> {
25    name: String,
26    marker: PhantomData<fn() -> T>,
27}
28
29impl<T> Clone for FieldKey<T> {
30    fn clone(&self) -> Self {
31        Self {
32            name: self.name.clone(),
33            marker: PhantomData,
34        }
35    }
36}
37
38impl<T> PartialEq for FieldKey<T> {
39    fn eq(&self, other: &Self) -> bool {
40        self.name == other.name
41    }
42}
43
44impl<T> Eq for FieldKey<T> {}
45
46impl<T> std::hash::Hash for FieldKey<T> {
47    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
48        std::hash::Hash::hash(&self.name, state);
49    }
50}
51
52impl<T> FieldKey<T> {
53    /// Creates a field key with a caller-chosen name.
54    pub fn new(name: impl Into<String>) -> Self {
55        Self {
56            name: name.into(),
57            marker: PhantomData,
58        }
59    }
60
61    /// Returns the field name used to identify this key within a form.
62    pub fn name(&self) -> &str {
63        &self.name
64    }
65}
66
67impl<T> fmt::Debug for FieldKey<T> {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        formatter
70            .debug_struct("FieldKey")
71            .field("name", &self.name)
72            .finish_non_exhaustive()
73    }
74}
75
76/// Values made available only after a form is submitted.
77pub struct FormValues {
78    values: HashMap<String, Box<dyn Any>>,
79}
80
81impl FormValues {
82    /// Returns the submitted value associated with `key` when its type matches.
83    pub fn get<T: 'static>(&self, key: &FieldKey<T>) -> Option<&T> {
84        self.values
85            .get(key.name())
86            .and_then(|value| value.downcast_ref())
87    }
88}
89
90impl fmt::Debug for FormValues {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        formatter
93            .debug_struct("FormValues")
94            .field("field_count", &self.values.len())
95            .finish()
96    }
97}
98
99/// The terminal-visible outcome of a completed form run.
100#[derive(Debug)]
101pub enum FormOutcome {
102    /// Every field was accepted; values can now be read with [`FieldKey`].
103    Submitted(FormValues),
104    /// The user cancelled without exposing any partial field values.
105    Cancelled,
106}
107
108/// Where a prompt establishes the left edge of its owned region.
109#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
110pub enum PromptStart {
111    /// Starts on a new line at column zero.
112    ///
113    /// This is the default and always emits a carriage return and line feed
114    /// before the first frame.
115    #[default]
116    NewLine,
117    /// Starts at column zero of the cursor's current line, overwriting it.
118    CurrentLine,
119    /// Starts at the cursor's current position, whose column is supplied by
120    /// the caller rather than queried from the terminal. If the terminal later
121    /// becomes narrower than that column, the prompt uses its last column.
122    CurrentPosition { column: u16 },
123}
124
125/// What an inline prompt does when its terminal viewport is resized.
126#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
127pub enum InlineResizePolicy {
128    /// Stop the form and return [`RunError::Resized`] without erasing an
129    /// unlocatable prompt region.
130    #[default]
131    ReturnError,
132    /// Clear the visible primary-buffer viewport and redraw the current form.
133    ///
134    /// This does not enter the alternate screen or clear scrollback, but it
135    /// does erase every other visible cell in the viewport.
136    ClearViewportAndRedraw,
137}
138
139impl PromptStart {
140    pub(crate) const fn column(self) -> u16 {
141        match self {
142            Self::NewLine | Self::CurrentLine => 0,
143            Self::CurrentPosition { column } => column,
144        }
145    }
146
147    pub(super) fn within(self, terminal_columns: u16) -> Self {
148        match self {
149            Self::CurrentPosition { column } => Self::CurrentPosition {
150                column: column.min(terminal_columns.max(1) - 1),
151            },
152            other => other,
153        }
154    }
155}
156
157/// A builder for a blocking prompt form.
158pub struct FormBuilder {
159    groups: Vec<Group>,
160    start: PromptStart,
161    width: Option<u16>,
162    inline_resize_policy: InlineResizePolicy,
163}
164
165impl FormBuilder {
166    /// Chooses where the prompt starts.
167    ///
168    /// The default is [`PromptStart::NewLine`]. The chosen left edge applies
169    /// to every row in the prompt region.
170    #[must_use]
171    pub fn start(mut self, start: PromptStart) -> Self {
172        self.start = start;
173        self
174    }
175
176    /// Limits the prompt's drawing width in terminal cells.
177    ///
178    /// By default the prompt uses the terminal width remaining after its left
179    /// edge. A supplied width is capped at that same available width. Zero is
180    /// normalized to the renderer's one-cell minimum.
181    #[must_use]
182    pub fn width(mut self, width: u16) -> Self {
183        self.width = Some(width);
184        self
185    }
186
187    /// Chooses how this inline prompt responds to a terminal resize.
188    ///
189    /// The default is [`InlineResizePolicy::ReturnError`]. Clearing and
190    /// redrawing must be selected explicitly because it erases the complete
191    /// visible primary-buffer viewport, including content outside the prompt.
192    #[must_use]
193    pub fn inline_resize_policy(mut self, policy: InlineResizePolicy) -> Self {
194        self.inline_resize_policy = policy;
195        self
196    }
197
198    /// Appends `group` in execution order.
199    #[must_use]
200    pub fn group(mut self, group: Group) -> Self {
201        self.groups.push(group);
202        self
203    }
204
205    /// Validates group presence and field-name uniqueness.
206    pub fn build(self) -> Result<Form, FormBuildError> {
207        if self.groups.is_empty() {
208            return Err(FormBuildError::EmptyForm);
209        }
210
211        let mut names = std::collections::HashSet::new();
212        for group in &self.groups {
213            for field in &group.fields {
214                if !names.insert(field.name().to_owned()) {
215                    return Err(FormBuildError::DuplicateFieldName(field.name().to_owned()));
216                }
217            }
218        }
219
220        Ok(Form {
221            groups: self.groups,
222            start: self.start,
223            width: self.width,
224            inline_resize_policy: self.inline_resize_policy,
225        })
226    }
227}
228
229/// A blocking, ordered collection of prompt groups.
230pub struct Form {
231    groups: Vec<Group>,
232    pub(super) start: PromptStart,
233    width: Option<u16>,
234    inline_resize_policy: InlineResizePolicy,
235}
236
237impl Form {
238    /// Starts building a form.
239    pub fn builder() -> FormBuilder {
240        FormBuilder {
241            groups: Vec::new(),
242            start: PromptStart::default(),
243            width: None,
244            inline_resize_policy: InlineResizePolicy::default(),
245        }
246    }
247
248    /// Runs this form on the process's interactive terminal connection.
249    ///
250    /// The supplied theme and detected terminal capabilities are resolved once into the
251    /// prompt's styles, and fields build their view from those resolved
252    /// values; no component role reaches the renderer. This convenience path
253    /// opens the process's default interactive terminal. It does not query the
254    /// terminal background or change the supplied theme.
255    pub fn run(self, theme: &Theme) -> Result<FormOutcome, RunError> {
256        #[cfg(unix)]
257        {
258            let mut terminal =
259                urushi_terminal::backend::native::NativeTerminal::open().map_err(|source| {
260                    RunError::Io {
261                        operation: IoOperation::EnterTerminal,
262                        source,
263                        cleanup: None,
264                    }
265                })?;
266            self.run_with_terminal(&mut terminal, theme)
267        }
268
269        #[cfg(not(unix))]
270        let (mut terminal, info) = {
271            let stderr = std::io::stderr();
272            let info = match urushi_terminal::detect(&stderr).map_err(|source| RunError::Io {
273                operation: IoOperation::EnterTerminal,
274                source,
275                cleanup: None,
276            })? {
277                urushi_terminal::TerminalDetection::Terminal(info) => info,
278                urushi_terminal::TerminalDetection::NonTerminal => {
279                    return Err(RunError::NotInteractive);
280                }
281            };
282            (
283                urushi_terminal::backend::crossterm::CrosstermBackend::new(stderr),
284                info,
285            )
286        };
287
288        #[cfg(not(unix))]
289        {
290            self.run_with_info(&mut terminal, theme, info, VecDeque::new())
291        }
292    }
293
294    /// Runs this form on a caller-owned interactive terminal connection.
295    ///
296    /// The same backend supplies terminal queries, input events, and rendered
297    /// output for the complete prompt session. This method queries its size
298    /// and rendering capabilities, but never queries its background and never
299    /// changes the supplied theme. A caller selecting a theme with
300    /// [`urushi::ThemeMode::Auto`] should query the background first and then
301    /// pass that same backend here.
302    pub fn run_with_terminal(
303        self,
304        terminal: &mut impl urushi_terminal::TerminalBackend,
305        theme: &Theme,
306    ) -> Result<FormOutcome, RunError> {
307        if !terminal.is_interactive() {
308            return Err(RunError::NotInteractive);
309        }
310
311        let mut size = terminal.terminal_size().map_err(|source| RunError::Io {
312            operation: IoOperation::EnterTerminal,
313            source,
314            cleanup: None,
315        })?;
316        let capabilities = terminal
317            .terminal_capabilities()
318            .map_err(|source| RunError::Io {
319                operation: IoOperation::EnterTerminal,
320                source,
321                cleanup: None,
322            })?;
323
324        // Queries may preserve ordinary input, including resize notifications,
325        // for the event reader. Fold every resize that happened before session
326        // entry into the initial geometry while retaining prompt input in order.
327        let mut initial_events = VecDeque::new();
328        loop {
329            let event = terminal.poll_event().map_err(|source| RunError::Io {
330                operation: IoOperation::EnterTerminal,
331                source,
332                cleanup: None,
333            })?;
334            match event {
335                Some(Event::Resize(resized)) => size = resized,
336                Some(event) if accepts_prompt_event(&event) => initial_events.push_back(event),
337                Some(_) => {}
338                None => break,
339            }
340        }
341
342        let info = urushi_terminal::TerminalInfo::new(size, capabilities);
343        self.run_with_info(terminal, theme, info, initial_events)
344    }
345
346    fn run_with_info(
347        self,
348        terminal: &mut impl urushi_terminal::TerminalBackend,
349        theme: &Theme,
350        info: urushi_terminal::TerminalInfo,
351        initial_events: VecDeque<Event>,
352    ) -> Result<FormOutcome, RunError> {
353        let size = (
354            info.size().columns().try_into().unwrap_or(u16::MAX),
355            info.size().rows().try_into().unwrap_or(u16::MAX),
356        );
357        let mut settings = RenderSettings::from(info.capabilities());
358        settings = apply_no_color(
359            settings,
360            std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()),
361        );
362        let mut renderer = TerminalRenderer::with_size(size);
363        let styles = PromptStyles::resolve(theme, &settings);
364        self.run_on(&mut renderer, terminal, &styles, initial_events)
365    }
366
367    #[cfg(test)]
368    pub(crate) fn run_with<S, R, T>(
369        self,
370        events: &mut S,
371        renderer: &mut R,
372        terminal: &mut T,
373        styles: &PromptStyles,
374    ) -> Result<FormOutcome, RunError>
375    where
376        S: EventSource,
377        R: Renderer,
378        T: TerminalControl,
379    {
380        let mut terminal = SplitTerminal::new(events, terminal);
381        self.run_on(renderer, &mut terminal, styles, VecDeque::new())
382    }
383
384    fn run_on<R, T>(
385        mut self,
386        renderer: &mut R,
387        terminal: &mut T,
388        styles: &PromptStyles,
389        mut deferred: VecDeque<Event>,
390    ) -> Result<FormOutcome, RunError>
391    where
392        R: Renderer,
393        T: urushi_terminal::TerminalBackend,
394    {
395        if !terminal.is_interactive() {
396            return Err(RunError::NotInteractive);
397        }
398
399        let mut session = TerminalSession::enter(renderer, terminal)?;
400        let mut state = FormState::Running { group: 0, field: 0 };
401        self.groups[0].fields[0].activate();
402
403        loop {
404            let terminal_columns = session.columns();
405            let start = self.start.within(terminal_columns);
406            let drawing_columns = self.drawing_width(terminal_columns);
407            if let Err(source) = session.draw(
408                &self.view(&state, styles, drawing_columns),
409                start,
410                drawing_columns,
411            ) {
412                return Err(session.fail(IoOperation::Render, source));
413            }
414
415            let mut event = match deferred.pop_front() {
416                Some(event) => event,
417                None => loop {
418                    match session.read_event() {
419                        Ok(event) if accepts_prompt_event(&event) => break event,
420                        Ok(_) => {}
421                        Err(source) => return Err(session.fail(IoOperation::ReadEvent, source)),
422                    }
423                },
424            };
425            if matches!(event, Event::Resize(_)) {
426                // Dragging a window edge emits a resize per intermediate size.
427                // Act once on the latest size already waiting: ReturnError has
428                // one terminal exit, while ClearViewportAndRedraw has one
429                // destructive clear and one replacement frame.
430                loop {
431                    match session.poll_event() {
432                        Ok(Some(waiting)) if !accepts_prompt_event(&waiting) => {}
433                        Ok(Some(waiting @ Event::Resize(_))) => event = waiting,
434                        Ok(Some(waiting)) => {
435                            deferred.push_back(waiting);
436                            break;
437                        }
438                        Ok(None) => break,
439                        Err(source) => return Err(session.fail(IoOperation::ReadEvent, source)),
440                    }
441                }
442            }
443            if let Event::Resize(size) = &event {
444                session.resize(
445                    size.columns().try_into().unwrap_or(u16::MAX),
446                    size.rows().try_into().unwrap_or(u16::MAX),
447                );
448                match self.inline_resize_policy {
449                    InlineResizePolicy::ReturnError => {
450                        let cleanup = session.cleanup(RenderFinish::Error);
451                        return Err(RunError::Resized { cleanup });
452                    }
453                    InlineResizePolicy::ClearViewportAndRedraw => {
454                        if let Err(source) = session.clear_viewport() {
455                            return Err(session.fail(IoOperation::Render, source));
456                        }
457                        continue;
458                    }
459                }
460            }
461
462            match self.reduce(&mut state, event) {
463                ReducerResult::Running => {}
464                ReducerResult::Submitted => {
465                    let cleanup = session.cleanup(RenderFinish::Submitted);
466                    if let Some(source) = cleanup {
467                        return Err(RunError::Io {
468                            operation: IoOperation::Cleanup,
469                            source,
470                            cleanup: None,
471                        });
472                    }
473                    return Ok(FormOutcome::Submitted(self.into_values()));
474                }
475                ReducerResult::Cancelled => {
476                    let cleanup = session.cleanup(RenderFinish::Cancelled);
477                    if let Some(source) = cleanup {
478                        return Err(RunError::Io {
479                            operation: IoOperation::Cleanup,
480                            source,
481                            cleanup: None,
482                        });
483                    }
484                    return Ok(FormOutcome::Cancelled);
485                }
486            }
487        }
488    }
489
490    pub(super) fn drawing_width(&self, terminal_columns: u16) -> u16 {
491        let start = self.start.within(terminal_columns);
492        let available = terminal_columns.saturating_sub(start.column()).max(1);
493        self.width.unwrap_or(available).max(1).min(available)
494    }
495
496    pub(super) fn reduce(&mut self, state: &mut FormState, event: Event) -> ReducerResult {
497        let FormState::Running { group, field } = *state else {
498            unreachable!("the runtime exits immediately after a terminal form state");
499        };
500
501        let action = match event {
502            Event::Key(KeyEvent {
503                code: KeyCode::Char('c'),
504                modifiers,
505                ..
506            }) if modifiers.contains(KeyModifiers::CONTROL) => FieldAction::Cancel,
507            Event::Key(KeyEvent {
508                code: KeyCode::BackTab,
509                ..
510            }) => FieldAction::Back,
511            Event::Key(KeyEvent {
512                code: KeyCode::Tab, ..
513            }) if self.next_position(group, field).is_none()
514                && !self.groups[group].fields[field].captures_tab() =>
515            {
516                FieldAction::Stay
517            }
518            event @ Event::Key(KeyEvent {
519                code: KeyCode::Escape,
520                ..
521            }) => match self.groups[group].fields[field].event(event) {
522                FieldAction::Stay => FieldAction::Cancel,
523                action => action,
524            },
525            event => self.groups[group].fields[field].event(event),
526        };
527
528        match action {
529            FieldAction::Stay | FieldAction::Handled => ReducerResult::Running,
530            FieldAction::Cancel => {
531                *state = FormState::Cancelled;
532                ReducerResult::Cancelled
533            }
534            FieldAction::Back => {
535                if let Some((previous_group, previous_field)) = self.previous_position(group, field)
536                {
537                    self.groups[group].fields[field].deactivate();
538                    self.groups[previous_group].fields[previous_field].activate();
539                    *state = FormState::Running {
540                        group: previous_group,
541                        field: previous_field,
542                    };
543                }
544                ReducerResult::Running
545            }
546            FieldAction::Accept => {
547                self.groups[group].fields[field].accept();
548                if let Some((next_group, next_field)) = self.next_position(group, field) {
549                    self.groups[next_group].fields[next_field].activate();
550                    *state = FormState::Running {
551                        group: next_group,
552                        field: next_field,
553                    };
554                    ReducerResult::Running
555                } else {
556                    *state = FormState::Submitted;
557                    ReducerResult::Submitted
558                }
559            }
560        }
561    }
562
563    fn next_position(&self, group: usize, field: usize) -> Option<(usize, usize)> {
564        if field + 1 < self.groups[group].fields.len() {
565            Some((group, field + 1))
566        } else if group + 1 < self.groups.len() {
567            Some((group + 1, 0))
568        } else {
569            None
570        }
571    }
572
573    fn previous_position(&self, group: usize, field: usize) -> Option<(usize, usize)> {
574        if field > 0 {
575            Some((group, field - 1))
576        } else if group > 0 {
577            let previous_group = group - 1;
578            Some((previous_group, self.groups[previous_group].fields.len() - 1))
579        } else {
580            None
581        }
582    }
583
584    pub(super) fn view(
585        &self,
586        state: &FormState,
587        styles: &PromptStyles,
588        columns: u16,
589    ) -> PromptView {
590        let FormState::Running { group, field } = *state else {
591            return PromptView {
592                lines: Vec::new(),
593                cursor: None,
594            };
595        };
596
597        // Every field row sits beside a marker gutter, so the width a field
598        // lays itself out in is the terminal's less that gutter.
599        let field_width = usize::from(columns.max(1)).saturating_sub(GUTTER);
600
601        let mut lines = Vec::new();
602        let mut footer = None;
603        if let Some(title) = &self.groups[group].title {
604            lines.push(PromptLine::spans(vec![TextSpan::new(
605                title.clone(),
606                styles.question.clone(),
607            )]));
608        }
609        if let Some(description) = &self.groups[group].description {
610            lines.push(PromptLine::spans(vec![TextSpan::new(
611                description.clone(),
612                styles.muted.clone(),
613            )]));
614        }
615        if !lines.is_empty() {
616            lines.push(PromptLine::blank());
617        }
618        for (index, entry) in self.groups[group].fields.iter().enumerate() {
619            let focused = index == field;
620            let mut field_view = entry.view(styles, focused, field_width);
621            if focused {
622                footer = field_view.help.take().map(PromptLine::new);
623            }
624            lines.push(PromptLine::field(field_view, focused));
625            if index + 1 < self.groups[group].fields.len() {
626                lines.push(PromptLine::blank());
627            }
628        }
629
630        if let Some(help) = footer {
631            lines.push(PromptLine::blank());
632            lines.push(
633                PromptLine::new(gutter_view(styles, false, help.view)).with_kind(LineKind::Help),
634            );
635        }
636
637        PromptView {
638            lines,
639            cursor: None,
640        }
641    }
642
643    fn into_values(mut self) -> FormValues {
644        let mut values = HashMap::new();
645        for group in &mut self.groups {
646            for field in &mut group.fields {
647                values.insert(field.name().to_owned(), field.take_value());
648            }
649        }
650        FormValues { values }
651    }
652}
653
654fn apply_no_color(settings: RenderSettings, no_color: bool) -> RenderSettings {
655    if no_color {
656        settings.color_level(ColorLevel::None)
657    } else {
658        settings
659    }
660}
661
662/// A group of fields executed in builder insertion order.
663pub struct Group {
664    fields: Vec<FieldEntry>,
665    title: Option<String>,
666    description: Option<String>,
667}
668
669impl Group {
670    /// Starts building a group.
671    pub fn builder() -> GroupBuilder {
672        GroupBuilder {
673            fields: Vec::new(),
674            title: None,
675            description: None,
676        }
677    }
678}
679
680/// A builder for a non-empty prompt group.
681pub struct GroupBuilder {
682    fields: Vec<FieldEntry>,
683    title: Option<String>,
684    description: Option<String>,
685}
686
687impl GroupBuilder {
688    /// Sets a heading rendered above this group.
689    #[must_use]
690    pub fn title(mut self, title: impl Into<String>) -> Self {
691        self.title = Some(title.into());
692        self
693    }
694
695    /// Sets supporting text rendered below the group heading.
696    #[must_use]
697    pub fn description(mut self, description: impl Into<String>) -> Self {
698        self.description = Some(description.into());
699        self
700    }
701
702    /// Appends a crate-provided field in execution order.
703    #[must_use]
704    pub fn field<F>(mut self, field: F) -> Self
705    where
706        F: Field + 'static,
707    {
708        self.fields
709            .push(field::private::Sealed::into_entry(Box::new(field)));
710        self
711    }
712
713    /// Validates that this group contains at least one field.
714    pub fn build(self) -> Result<Group, GroupBuildError> {
715        if self.fields.is_empty() {
716            return Err(GroupBuildError::EmptyGroup);
717        }
718        Ok(Group {
719            fields: self.fields,
720            title: self.title,
721            description: self.description,
722        })
723    }
724}
725
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727pub(crate) enum FormState {
728    Running { group: usize, field: usize },
729    Submitted,
730    Cancelled,
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub(crate) enum ReducerResult {
735    Running,
736    Submitted,
737    Cancelled,
738}
739
740#[cfg(test)]
741mod tests {
742    use std::io;
743
744    use super::*;
745    use crate::runtime::{
746        IoOperation, KeyCode, KeyEvent, terminal::tests::*, test_styles, view::tests::test_theme,
747    };
748    use crate::{
749        Confirm, ConfirmAnswer, ConfirmSource, FieldConfigError, Input, Select, SelectOption,
750    };
751
752    #[test]
753    fn no_color_only_narrows_the_prompt_color_level() {
754        let settings = RenderSettings::default()
755            .color_level(ColorLevel::TrueColor)
756            .hyperlinks(true);
757
758        let narrowed = apply_no_color(settings, true);
759
760        assert_eq!(narrowed.get_color_level(), ColorLevel::None);
761        assert!(narrowed.get_hyperlinks());
762        assert_eq!(apply_no_color(settings, false), settings);
763    }
764
765    #[test]
766    fn builder_rejects_empty_and_duplicate_configuration() {
767        assert!(matches!(
768            Form::builder().build(),
769            Err(FormBuildError::EmptyForm)
770        ));
771        assert!(matches!(
772            Group::builder().build(),
773            Err(GroupBuildError::EmptyGroup)
774        ));
775
776        let first = Group::builder()
777            .field(TestField::new("same", "one"))
778            .build()
779            .expect("group is valid");
780        let second = Group::builder()
781            .field(TestField::new("same", "two"))
782            .build()
783            .expect("group is valid");
784        assert!(matches!(
785            Form::builder().group(first).group(second).build(),
786            Err(FormBuildError::DuplicateFieldName(name)) if name == "same"
787        ));
788    }
789
790    #[test]
791    fn form_builder_defaults_and_caps_the_prompt_region_width() {
792        let group = || {
793            Group::builder()
794                .field(TestField::new("field", "value"))
795                .build()
796                .expect("test group has a field")
797        };
798        let default = Form::builder()
799            .group(group())
800            .build()
801            .expect("default form is valid");
802        assert_eq!(default.start, PromptStart::NewLine);
803        assert_eq!(
804            default.inline_resize_policy,
805            InlineResizePolicy::ReturnError
806        );
807        assert_eq!(default.drawing_width(20), 20);
808
809        let positioned = Form::builder()
810            .start(PromptStart::CurrentPosition { column: 7 })
811            .width(8)
812            .group(group())
813            .build()
814            .expect("positioned form is valid");
815        assert_eq!(positioned.drawing_width(20), 8);
816        assert_eq!(positioned.drawing_width(12), 5);
817
818        let oversized = Form::builder()
819            .start(PromptStart::CurrentPosition { column: 7 })
820            .width(u16::MAX)
821            .group(group())
822            .build()
823            .expect("oversized width is capped at runtime");
824        assert_eq!(oversized.drawing_width(20), 13);
825
826        let zero = Form::builder()
827            .width(0)
828            .group(group())
829            .build()
830            .expect("zero width is normalized at runtime");
831        assert_eq!(zero.drawing_width(20), 1);
832
833        let out_of_bounds = Form::builder()
834            .start(PromptStart::CurrentPosition { column: u16::MAX })
835            .group(group())
836            .build()
837            .expect("out-of-bounds start is normalized at runtime");
838        assert_eq!(
839            out_of_bounds.start.within(20),
840            PromptStart::CurrentPosition { column: 19 }
841        );
842        assert_eq!(out_of_bounds.drawing_width(20), 1);
843    }
844
845    #[test]
846    fn form_run_passes_the_selected_region_to_the_renderer() {
847        let group = Group::builder()
848            .field(TestField::new("field", "value"))
849            .build()
850            .expect("test group has a field");
851        let form = Form::builder()
852            .start(PromptStart::CurrentPosition { column: 7 })
853            .width(12)
854            .group(group)
855            .build()
856            .expect("configured form is valid");
857        let mut events = ScriptedEvents::new([Ok(cancel())]);
858        let mut renderer = RecordingRenderer::default();
859        let mut terminal = RecordingTerminal::interactive();
860
861        assert!(matches!(
862            form.run_with(&mut events, &mut renderer, &mut terminal, &test_styles(),),
863            Ok(FormOutcome::Cancelled)
864        ));
865        assert_eq!(
866            renderer.regions,
867            [(PromptStart::CurrentPosition { column: 7 }, 12)]
868        );
869    }
870
871    #[test]
872    fn caller_owned_terminal_is_reused_without_an_implicit_background_query() {
873        let mut terminal = RecordingTerminal {
874            events: [
875                Ok(Event::Resize(urushi_terminal::TerminalSize::new(100, 30))),
876                Ok(cancel()),
877            ]
878            .into(),
879            waiting: 2,
880            ..RecordingTerminal::interactive()
881        };
882        let observed = urushi_terminal::TerminalQuery::terminal_background(&mut terminal)
883            .expect("explicit background query succeeds");
884        assert_eq!(
885            observed,
886            Some(urushi_terminal::TerminalBackground::new(0, 0, 0))
887        );
888
889        let outcome = form([TestField::new("field", "value")])
890            .run_with_terminal(&mut terminal, &test_theme())
891            .expect("caller-owned terminal run succeeds");
892
893        assert!(matches!(outcome, FormOutcome::Cancelled));
894        assert_eq!(
895            terminal.calls,
896            [
897                "terminal_background",
898                "terminal_size",
899                "terminal_capabilities",
900                "enable_raw_mode",
901                "flush",
902                "flush",
903                "flush",
904                "show_cursor",
905                "flush",
906                "disable_raw_mode",
907            ]
908        );
909    }
910
911    #[test]
912    fn caller_owned_terminal_is_restored_after_an_event_failure() {
913        let mut terminal = RecordingTerminal {
914            events: [Err(io::Error::other("read failed"))].into(),
915            ..RecordingTerminal::interactive()
916        };
917
918        let result = form([TestField::new("field", "value")])
919            .run_with_terminal(&mut terminal, &test_theme());
920
921        assert_io_operation(result, IoOperation::ReadEvent);
922        assert_eq!(
923            terminal.calls,
924            [
925                "terminal_size",
926                "terminal_capabilities",
927                "enable_raw_mode",
928                "flush",
929                "flush",
930                "flush",
931                "show_cursor",
932                "flush",
933                "disable_raw_mode",
934            ]
935        );
936    }
937
938    #[test]
939    fn form_run_clamps_the_start_after_a_narrowing_resize() {
940        let group = Group::builder()
941            .field(TestField::new("field", "value"))
942            .build()
943            .expect("test group has a field");
944        let form = Form::builder()
945            .start(PromptStart::CurrentPosition { column: 7 })
946            .inline_resize_policy(InlineResizePolicy::ClearViewportAndRedraw)
947            .group(group)
948            .build()
949            .expect("configured form is valid");
950        let mut events = ScriptedEvents::new([
951            Ok(Event::Resize(urushi_terminal::TerminalSize::new(3, 4))),
952            Ok(cancel()),
953        ]);
954        let mut renderer = RecordingRenderer::default();
955        let mut terminal = RecordingTerminal::interactive();
956
957        assert!(matches!(
958            form.run_with(&mut events, &mut renderer, &mut terminal, &test_styles()),
959            Ok(FormOutcome::Cancelled)
960        ));
961        assert_eq!(
962            renderer.regions,
963            [
964                (PromptStart::CurrentPosition { column: 7 }, 73),
965                (PromptStart::CurrentPosition { column: 2 }, 1),
966            ]
967        );
968        assert_eq!(renderer.viewport_clears, 1);
969    }
970
971    #[test]
972    fn public_errors_are_contextual_standard_errors() {
973        assert_eq!(
974            FormBuildError::DuplicateFieldName("name".to_owned()).to_string(),
975            "field name `name` is duplicated in the form"
976        );
977        assert_eq!(
978            GroupBuildError::EmptyGroup.to_string(),
979            "a prompt group must contain at least one field"
980        );
981        assert_eq!(
982            FieldConfigError::EmptyOptions.to_string(),
983            "a select field must contain at least one option"
984        );
985
986        let error = RunError::Io {
987            operation: IoOperation::Render,
988            source: io::Error::other("draw failed"),
989            cleanup: Some(io::Error::other("restore failed")),
990        };
991        assert_eq!(
992            error.to_string(),
993            "failed to render prompt: draw failed; terminal cleanup also failed: restore failed"
994        );
995        assert!(std::error::Error::source(&error).is_some());
996
997        let resized = RunError::Resized { cleanup: None };
998        assert_eq!(resized.to_string(), "terminal resized during inline prompt");
999        assert!(std::error::Error::source(&resized).is_none());
1000    }
1001
1002    #[test]
1003    fn field_keys_compare_and_hash_without_value_trait_bounds() {
1004        struct OpaqueValue;
1005
1006        let first = FieldKey::<OpaqueValue>::new("answer");
1007        let second = first.clone();
1008        assert!(first == second);
1009
1010        let mut keys = std::collections::HashSet::new();
1011        keys.insert(first);
1012        assert!(keys.contains(&second));
1013    }
1014
1015    #[test]
1016    fn submit_back_and_cancel_follow_the_form_reducer() {
1017        let mut events = ScriptedEvents::new([Ok(enter()), Ok(back()), Ok(enter()), Ok(enter())]);
1018        let mut renderer = RecordingRenderer::default();
1019        let mut terminal = RecordingTerminal::interactive();
1020        let outcome = form([
1021            TestField::new("first", "one"),
1022            TestField::new("second", "two"),
1023        ])
1024        .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
1025        .expect("form submits");
1026
1027        let FormOutcome::Submitted(values) = outcome else {
1028            panic!("expected submitted form");
1029        };
1030        assert_eq!(values.get(&FieldKey::new("first")), Some(&"one".to_owned()));
1031        assert_eq!(
1032            values.get(&FieldKey::new("second")),
1033            Some(&"two".to_owned())
1034        );
1035        assert_eq!(
1036            renderer.views,
1037            vec![
1038                Some("first".to_owned()),
1039                Some("second".to_owned()),
1040                Some("first".to_owned()),
1041                Some("second".to_owned())
1042            ]
1043        );
1044        assert_eq!(renderer.finishes, vec![RenderFinish::Submitted]);
1045        assert_eq!(
1046            terminal.calls,
1047            [
1048                "enable_raw_mode",
1049                "flush",
1050                "show_cursor",
1051                "flush",
1052                "disable_raw_mode"
1053            ]
1054        );
1055
1056        let mut events = ScriptedEvents::new([Ok(cancel())]);
1057        let mut renderer = RecordingRenderer::default();
1058        let mut terminal = RecordingTerminal::interactive();
1059        assert!(matches!(
1060            form([TestField::new("first", "one")]).run_with(
1061                &mut events,
1062                &mut renderer,
1063                &mut terminal,
1064                &test_styles(),
1065            ),
1066            Ok(FormOutcome::Cancelled)
1067        ));
1068        assert_eq!(renderer.finishes, vec![RenderFinish::Cancelled]);
1069        assert_eq!(
1070            terminal.calls,
1071            [
1072                "enable_raw_mode",
1073                "flush",
1074                "show_cursor",
1075                "flush",
1076                "disable_raw_mode"
1077            ]
1078        );
1079
1080        let mut events = ScriptedEvents::new([Ok(ctrl_c())]);
1081        let mut renderer = RecordingRenderer::default();
1082        let mut terminal = RecordingTerminal::interactive();
1083        assert!(matches!(
1084            form([TestField::new("first", "one")]).run_with(
1085                &mut events,
1086                &mut renderer,
1087                &mut terminal,
1088                &test_styles(),
1089            ),
1090            Ok(FormOutcome::Cancelled)
1091        ));
1092        assert_eq!(renderer.finishes, vec![RenderFinish::Cancelled]);
1093        assert_eq!(
1094            terminal.calls,
1095            [
1096                "enable_raw_mode",
1097                "flush",
1098                "show_cursor",
1099                "flush",
1100                "disable_raw_mode"
1101            ]
1102        );
1103    }
1104
1105    #[test]
1106    fn tab_does_not_submit_the_last_field() {
1107        let confirmation_key = FieldKey::<ConfirmAnswer>::new("confirmation");
1108        let form = Form::builder()
1109            .group(
1110                Group::builder()
1111                    .field(
1112                        Confirm::new(confirmation_key.clone(), "Continue?", Some(true))
1113                            .expect("confirm is valid"),
1114                    )
1115                    .build()
1116                    .expect("group is valid"),
1117            )
1118            .build()
1119            .expect("form is valid");
1120        let mut events = ScriptedEvents::new([
1121            Ok(Event::Key(KeyEvent::new(KeyCode::Tab))),
1122            Ok(Event::Key(KeyEvent::new(KeyCode::Char('n')))),
1123        ]);
1124        let mut renderer = RecordingRenderer::default();
1125        let mut terminal = RecordingTerminal::interactive();
1126
1127        let outcome = form
1128            .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
1129            .expect("form submits after an explicit answer");
1130        let FormOutcome::Submitted(values) = outcome else {
1131            panic!("expected submitted form");
1132        };
1133        assert_eq!(
1134            values.get(&confirmation_key),
1135            Some(&ConfirmAnswer {
1136                value: false,
1137                source: ConfirmSource::Explicit,
1138            })
1139        );
1140        assert_eq!(renderer.views.len(), 2);
1141    }
1142
1143    #[test]
1144    fn validated_input_select_and_confirm_submit_typed_values() {
1145        let name_key = FieldKey::new("name");
1146        let language_key = FieldKey::new("language");
1147        let confirmation_key = FieldKey::<ConfirmAnswer>::new("confirmation");
1148        let form = Form::builder()
1149            .group(
1150                Group::builder()
1151                    .field(
1152                        Input::new(name_key.clone(), "Name", "")
1153                            .expect("input is valid")
1154                            .required(),
1155                    )
1156                    .field(
1157                        Select::new(
1158                            language_key.clone(),
1159                            "Language",
1160                            vec![
1161                                SelectOption::new("Japanese", "ja"),
1162                                SelectOption::new("English", "en"),
1163                            ],
1164                        )
1165                        .expect("select is valid"),
1166                    )
1167                    .field(
1168                        Confirm::new(confirmation_key.clone(), "Continue?", Some(false))
1169                            .expect("confirm is valid"),
1170                    )
1171                    .build()
1172                    .expect("group is valid"),
1173            )
1174            .build()
1175            .expect("form is valid");
1176        let mut events = ScriptedEvents::new([
1177            Ok(enter()),
1178            Ok(Event::Key(KeyEvent::new(KeyCode::Char('名')))),
1179            Ok(Event::Key(KeyEvent::new(KeyCode::Tab))),
1180            Ok(Event::Key(KeyEvent::new(KeyCode::Down))),
1181            Ok(enter()),
1182            Ok(Event::Key(KeyEvent::new(KeyCode::Char('y')))),
1183            Ok(enter()),
1184        ]);
1185        let mut renderer = RecordingRenderer::default();
1186        let mut terminal = RecordingTerminal::interactive();
1187
1188        let outcome = form
1189            .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
1190            .expect("form submits");
1191        let FormOutcome::Submitted(values) = outcome else {
1192            panic!("expected submitted values");
1193        };
1194        assert_eq!(values.get(&name_key), Some(&"名".to_owned()));
1195        assert_eq!(values.get(&language_key), Some(&"en"));
1196        assert_eq!(
1197            values.get(&confirmation_key),
1198            Some(&ConfirmAnswer {
1199                value: true,
1200                source: ConfirmSource::Explicit,
1201            })
1202        );
1203        assert_eq!(renderer.finishes, vec![RenderFinish::Submitted]);
1204        assert_eq!(
1205            terminal.calls,
1206            [
1207                "enable_raw_mode",
1208                "flush",
1209                "show_cursor",
1210                "flush",
1211                "disable_raw_mode"
1212            ]
1213        );
1214    }
1215    #[test]
1216    fn the_default_resize_policy_returns_an_error_after_one_coalesced_resize() {
1217        let mut events = ScriptedEvents::new([
1218            Ok(Event::Resize(urushi_terminal::TerminalSize::new(10, 5))),
1219            Ok(Event::Resize(urushi_terminal::TerminalSize::new(20, 6))),
1220            Ok(Event::Resize(urushi_terminal::TerminalSize::new(30, 7))),
1221            Ok(enter()),
1222        ])
1223        .arriving_together(3);
1224        let mut renderer = RecordingRenderer::default();
1225        let mut terminal = RecordingTerminal::interactive();
1226
1227        let outcome = form([TestField::new("field", "value")]).run_with(
1228            &mut events,
1229            &mut renderer,
1230            &mut terminal,
1231            &test_styles(),
1232        );
1233
1234        assert!(matches!(outcome, Err(RunError::Resized { cleanup: None })));
1235        assert_eq!(renderer.resizes, [(30, 7)]);
1236        assert_eq!(renderer.viewport_clears, 0);
1237        assert_eq!(renderer.views.len(), 1);
1238        assert_eq!(renderer.finishes, [RenderFinish::Error]);
1239    }
1240
1241    #[test]
1242    fn clear_viewport_policy_redraws_then_keeps_the_event_that_ended_the_burst() {
1243        let mut events = ScriptedEvents::new([
1244            Ok(Event::Resize(urushi_terminal::TerminalSize::new(10, 5))),
1245            Ok(enter()),
1246        ])
1247        .arriving_together(2);
1248        let mut renderer = RecordingRenderer::default();
1249        let mut terminal = RecordingTerminal::interactive();
1250
1251        // Draining the burst reads one event past its end. That event is what
1252        // the user typed, and it is held over rather than dropped.
1253        let outcome = form_with_resize_policy(
1254            [TestField::new("field", "value")],
1255            InlineResizePolicy::ClearViewportAndRedraw,
1256        )
1257        .run_with(&mut events, &mut renderer, &mut terminal, &test_styles())
1258        .expect("the form submits");
1259        assert!(matches!(outcome, FormOutcome::Submitted(_)));
1260        assert_eq!(renderer.resizes, [(10, 5)]);
1261        assert_eq!(renderer.viewport_clears, 1);
1262        assert_eq!(renderer.views.len(), 2);
1263    }
1264
1265    #[test]
1266    fn resize_error_retains_a_cleanup_failure() {
1267        let mut events =
1268            ScriptedEvents::new([Ok(Event::Resize(urushi_terminal::TerminalSize::new(10, 5)))]);
1269        let mut renderer = RecordingRenderer {
1270            fail_finish: true,
1271            ..RecordingRenderer::default()
1272        };
1273        let mut terminal = RecordingTerminal::interactive();
1274
1275        let result = form([TestField::new("field", "value")]).run_with(
1276            &mut events,
1277            &mut renderer,
1278            &mut terminal,
1279            &test_styles(),
1280        );
1281
1282        assert!(matches!(
1283            result,
1284            Err(RunError::Resized {
1285                cleanup: Some(ref error)
1286            }) if error.to_string() == "finish failed"
1287        ));
1288        assert_eq!(
1289            terminal.calls,
1290            [
1291                "enable_raw_mode",
1292                "flush",
1293                "show_cursor",
1294                "flush",
1295                "disable_raw_mode"
1296            ]
1297        );
1298    }
1299
1300    #[test]
1301    fn a_failed_viewport_clear_is_a_render_error_and_runs_cleanup() {
1302        let mut events =
1303            ScriptedEvents::new([Ok(Event::Resize(urushi_terminal::TerminalSize::new(10, 5)))]);
1304        let mut renderer = RecordingRenderer {
1305            fail_clear_viewport: true,
1306            ..RecordingRenderer::default()
1307        };
1308        let mut terminal = RecordingTerminal::interactive();
1309
1310        let result = form_with_resize_policy(
1311            [TestField::new("field", "value")],
1312            InlineResizePolicy::ClearViewportAndRedraw,
1313        )
1314        .run_with(&mut events, &mut renderer, &mut terminal, &test_styles());
1315
1316        assert_io_operation(result, IoOperation::Render);
1317        assert_eq!(renderer.viewport_clears, 1);
1318        assert_eq!(renderer.finishes, [RenderFinish::Error]);
1319    }
1320}