1use 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 pub fn new(name: impl Into<String>) -> Self {
55 Self {
56 name: name.into(),
57 marker: PhantomData,
58 }
59 }
60
61 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
76pub struct FormValues {
78 values: HashMap<String, Box<dyn Any>>,
79}
80
81impl FormValues {
82 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#[derive(Debug)]
101pub enum FormOutcome {
102 Submitted(FormValues),
104 Cancelled,
106}
107
108#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
110pub enum PromptStart {
111 #[default]
116 NewLine,
117 CurrentLine,
119 CurrentPosition { column: u16 },
123}
124
125#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
127pub enum InlineResizePolicy {
128 #[default]
131 ReturnError,
132 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
157pub struct FormBuilder {
159 groups: Vec<Group>,
160 start: PromptStart,
161 width: Option<u16>,
162 inline_resize_policy: InlineResizePolicy,
163}
164
165impl FormBuilder {
166 #[must_use]
171 pub fn start(mut self, start: PromptStart) -> Self {
172 self.start = start;
173 self
174 }
175
176 #[must_use]
182 pub fn width(mut self, width: u16) -> Self {
183 self.width = Some(width);
184 self
185 }
186
187 #[must_use]
193 pub fn inline_resize_policy(mut self, policy: InlineResizePolicy) -> Self {
194 self.inline_resize_policy = policy;
195 self
196 }
197
198 #[must_use]
200 pub fn group(mut self, group: Group) -> Self {
201 self.groups.push(group);
202 self
203 }
204
205 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
229pub 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 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 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 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 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 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 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
662pub struct Group {
664 fields: Vec<FieldEntry>,
665 title: Option<String>,
666 description: Option<String>,
667}
668
669impl Group {
670 pub fn builder() -> GroupBuilder {
672 GroupBuilder {
673 fields: Vec::new(),
674 title: None,
675 description: None,
676 }
677 }
678}
679
680pub struct GroupBuilder {
682 fields: Vec<FieldEntry>,
683 title: Option<String>,
684 description: Option<String>,
685}
686
687impl GroupBuilder {
688 #[must_use]
690 pub fn title(mut self, title: impl Into<String>) -> Self {
691 self.title = Some(title.into());
692 self
693 }
694
695 #[must_use]
697 pub fn description(mut self, description: impl Into<String>) -> Self {
698 self.description = Some(description.into());
699 self
700 }
701
702 #[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 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 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}