1use std::ops::Range;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use rich::Segment;
23
24use crate::component::{Component, Context, Flow, View};
25use crate::components::{
26 FilePicker, Input, Paginator, PaginatorStyle, Theme, display_path, fit, plain, question, text,
27};
28use crate::event::{Event, Key, KeyCode};
29use crate::keymap::{Keymap, keys};
30use crate::policy::{LineIo, NotInteractive};
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum Value {
35 Text(String),
37 Flag(bool),
39 List(Vec<String>),
41 Path(PathBuf),
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct Answers(pub Vec<(String, Value)>);
49
50impl Answers {
51 pub fn get(&self, name: &str) -> Option<&Value> {
52 self.0
53 .iter()
54 .find(|(key, _)| key == name)
55 .map(|(_, value)| value)
56 }
57
58 pub fn text(&self, name: &str) -> Option<&str> {
60 match self.get(name)? {
61 Value::Text(text) => Some(text),
62 _ => None,
63 }
64 }
65
66 pub fn flag(&self, name: &str) -> Option<bool> {
68 match self.get(name)? {
69 Value::Flag(flag) => Some(*flag),
70 _ => None,
71 }
72 }
73
74 pub fn list(&self, name: &str) -> Option<&[String]> {
76 match self.get(name)? {
77 Value::List(list) => Some(list),
78 _ => None,
79 }
80 }
81
82 pub fn path(&self, name: &str) -> Option<&Path> {
84 match self.get(name)? {
85 Value::Path(path) => Some(path),
86 _ => None,
87 }
88 }
89}
90
91type Options = Box<dyn Fn(&Answers) -> Vec<String>>;
93
94enum Kind {
95 Text(Box<Input>),
96 Choice {
97 options: Vec<String>,
98 index: usize,
99 source: Option<Options>,
101 },
102 Multi {
103 options: Vec<String>,
104 chosen: Vec<bool>,
105 cursor: usize,
107 limit: Option<usize>,
109 },
110 Toggle(bool),
111 Note(String),
112 File {
113 picker: Option<Box<FilePicker>>,
115 path: Option<PathBuf>,
116 },
117}
118
119struct Field {
120 name: String,
121 label: String,
122 description: Option<String>,
123 page: usize,
125 kind: Kind,
126 error: Option<String>,
127}
128
129impl Field {
130 fn value(&self) -> Result<Value, String> {
131 match &self.kind {
132 Kind::Text(input) => input.resolve().map(Value::Text),
133 Kind::Choice { options, index, .. } => Ok(Value::Text(
134 options.get(*index).cloned().unwrap_or_default(),
135 )),
136 Kind::Multi {
137 options, chosen, ..
138 } => Ok(Value::List(
139 options
140 .iter()
141 .zip(chosen)
142 .filter(|(_, on)| **on)
143 .map(|(option, _)| option.clone())
144 .collect(),
145 )),
146 Kind::Toggle(on) => Ok(Value::Flag(*on)),
147 Kind::Note(_) => Err(String::new()),
148 Kind::File { .. } => self
149 .file_path()
150 .0
151 .map(Value::Path)
152 .ok_or_else(|| "nothing picked yet".to_string()),
153 }
154 }
155
156 fn file_path(&self) -> (Option<PathBuf>, bool) {
158 match &self.kind {
159 Kind::File {
160 path: Some(path), ..
161 } => (Some(path.clone()), true),
162 Kind::File { picker, .. } => (picker.as_ref().and_then(|p| p.default_value()), false),
163 _ => (None, false),
164 }
165 }
166
167 fn is_note(&self) -> bool {
168 matches!(self.kind, Kind::Note(_))
169 }
170
171 fn summary(&self) -> String {
173 match (&self.kind, self.value()) {
174 (Kind::Text(input), Ok(Value::Text(text))) => input.display_value(&text),
175 (_, Ok(Value::Text(text))) => text,
176 (_, Ok(Value::Flag(on))) => if on { "yes" } else { "no" }.to_string(),
177 (_, Ok(Value::List(list))) => list.join(", "),
178 (_, Ok(Value::Path(path))) => display_path(&path),
179 (_, Err(_)) => String::new(),
180 }
181 }
182
183 fn picker(&mut self) -> Option<&mut FilePicker> {
184 match &mut self.kind {
185 Kind::File { picker, .. } => picker.as_deref_mut(),
186 _ => None,
187 }
188 }
189}
190
191#[must_use = "a component does nothing until it is run or mounted"]
220pub struct Form {
221 title: String,
222 fields: Vec<Field>,
223 focus: usize,
224 theme: Theme,
225 answer: Option<bool>,
226 mouse: bool,
227 rebound: Vec<(String, Vec<Key>)>,
229 groups: Vec<String>,
231 pager: Paginator,
233 browsing: bool,
235}
236
237#[derive(Clone, Debug, PartialEq, Eq)]
239enum Hit {
240 None,
241 Field(usize),
242 Options(usize, Vec<Range<usize>>),
244 Picker(usize),
246 Buttons,
247 Indicator,
248}
249
250struct Layout {
252 lines: Vec<Vec<Segment>>,
253 hits: Vec<Hit>,
254 cursor: Option<(usize, usize)>,
255}
256
257impl Layout {
258 fn push(&mut self, line: Vec<Segment>, hit: Hit) {
259 self.lines.push(line);
260 self.hits.push(hit);
261 }
262}
263
264const SUBMIT: &str = " Submit ";
265const CANCEL: &str = " Cancel ";
266
267impl Form {
268 pub fn new(title: impl Into<String>) -> Form {
269 Form {
270 title: title.into(),
271 fields: Vec::new(),
272 focus: 0,
273 theme: Theme::default(),
274 answer: None,
275 mouse: false,
276 rebound: Vec::new(),
277 groups: vec![String::new()],
278 pager: Paginator::new(1),
279 browsing: false,
280 }
281 }
282
283 pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
292 self.rebound
293 .push((action.to_string(), keys.into_iter().collect()));
294 self
295 }
296
297 fn rebound(&self, mut keymap: Keymap) -> Keymap {
299 for (action, keys) in &self.rebound {
300 keymap.rebind(action, keys.clone());
301 }
302 keymap
303 }
304
305 fn form_keymap(&self) -> Keymap {
307 let mut keymap = Keymap::new("form")
308 .bind("submit", keys("ctrl+s"), "submit (Enter on the last field)")
309 .bind("cancel", keys("escape"), "cancel")
310 .bind("next", keys("enter tab down"), "next field")
311 .bind("previous", keys("shift+tab up"), "previous field");
312 if self.groups.len() > 1 {
313 keymap = keymap
314 .bind(
315 "next-page",
316 keys("pagedown"),
317 "next page, once this one passes",
318 )
319 .bind("previous-page", keys("pageup"), "previous page");
320 }
321 self.rebound(keymap)
322 }
323
324 fn choice_keymap(&self) -> Keymap {
325 self.rebound(
326 Keymap::new("form-choice")
327 .bind("next-option", keys("right space"), "next option")
328 .bind("previous-option", keys("left"), "previous option"),
329 )
330 }
331
332 fn multi_keymap(&self) -> Keymap {
333 self.rebound(
334 Keymap::new("form-multi")
335 .bind("next-choice", keys("right"), "next option")
336 .bind("previous-choice", keys("left"), "previous option")
337 .bind("choose", keys("space x"), "choose or unchoose the option"),
338 )
339 }
340
341 fn file_keymap(&self) -> Keymap {
342 self.rebound(Keymap::new("form-file").bind(
343 "browse",
344 keys("space ctrl+o"),
345 "browse for the path",
346 ))
347 }
348
349 fn toggle_keymap(&self) -> Keymap {
350 self.rebound(
351 Keymap::new("form-toggle")
352 .bind("toggle", keys("space left right"), "switch")
353 .bind("yes", keys("y"), "on")
354 .bind("no", keys("n"), "off"),
355 )
356 }
357
358 pub fn with_mouse(mut self, on: bool) -> Self {
363 self.mouse = on;
364 for field in &mut self.fields {
365 if let Kind::File { picker, .. } = &mut field.kind {
366 *picker = picker.take().map(|p| Box::new(p.with_mouse(on)));
367 }
368 }
369 self
370 }
371
372 fn push(mut self, name: impl Into<String>, label: String, kind: Kind) -> Self {
373 self.fields.push(Field {
374 name: name.into(),
375 label,
376 description: None,
377 page: self.groups.len() - 1,
378 kind,
379 error: None,
380 });
381 self
382 }
383
384 pub fn text(self, name: impl Into<String>, label: impl Into<String>) -> Self {
386 let label = label.into();
387 let input = Input::new(label.clone());
388 self.push(name, label, Kind::Text(Box::new(input)))
389 }
390
391 pub fn input(self, name: impl Into<String>, input: Input) -> Self {
394 let label = input.label().to_string();
395 self.push(name, label, Kind::Text(Box::new(input)))
396 }
397
398 pub fn masked(self, name: impl Into<String>, label: impl Into<String>) -> Self {
401 let label = label.into();
402 let input = Input::masked(label.clone());
403 self.push(name, label, Kind::Text(Box::new(input)))
404 }
405
406 pub fn choice<I, S>(self, name: impl Into<String>, label: impl Into<String>, options: I) -> Self
408 where
409 I: IntoIterator<Item = S>,
410 S: Into<String>,
411 {
412 let options = options.into_iter().map(Into::into).collect();
413 let kind = Kind::Choice {
414 options,
415 index: 0,
416 source: None,
417 };
418 self.push(name, label.into(), kind)
419 }
420
421 pub fn dynamic_choice(
426 self,
427 name: impl Into<String>,
428 label: impl Into<String>,
429 options: impl Fn(&Answers) -> Vec<String> + 'static,
430 ) -> Self {
431 let kind = Kind::Choice {
432 options: Vec::new(),
433 index: 0,
434 source: Some(Box::new(options)),
435 };
436 let mut form = self.push(name, label.into(), kind);
437 form.refresh();
438 form
439 }
440
441 pub fn multi<I, S>(self, name: impl Into<String>, label: impl Into<String>, options: I) -> Self
445 where
446 I: IntoIterator<Item = S>,
447 S: Into<String>,
448 {
449 let options: Vec<String> = options.into_iter().map(Into::into).collect();
450 let kind = Kind::Multi {
451 chosen: vec![false; options.len()],
452 options,
453 cursor: 0,
454 limit: None,
455 };
456 self.push(name, label.into(), kind)
457 }
458
459 pub fn chosen<I, S>(mut self, options: I) -> Self
461 where
462 I: IntoIterator<Item = S>,
463 S: AsRef<str>,
464 {
465 let wanted: Vec<S> = options.into_iter().collect();
466 if let Some(Kind::Multi {
467 options, chosen, ..
468 }) = self.fields.last_mut().map(|field| &mut field.kind)
469 {
470 for (option, on) in options.iter().zip(chosen.iter_mut()) {
471 *on = wanted.iter().any(|w| w.as_ref() == option);
472 }
473 }
474 self
475 }
476
477 pub fn limit(mut self, count: usize) -> Self {
480 if let Some(Kind::Multi { limit, .. }) = self.fields.last_mut().map(|field| &mut field.kind)
481 {
482 *limit = Some(count);
483 }
484 self
485 }
486
487 pub fn toggle(self, name: impl Into<String>, label: impl Into<String>, on: bool) -> Self {
489 self.push(name, label.into(), Kind::Toggle(on))
490 }
491
492 pub fn note(self, title: impl Into<String>, body: impl Into<String>) -> Self {
495 self.push(String::new(), title.into(), Kind::Note(body.into()))
496 }
497
498 pub fn file(
504 self,
505 name: impl Into<String>,
506 label: impl Into<String>,
507 picker: FilePicker,
508 ) -> Self {
509 let mouse = self.mouse;
510 let kind = Kind::File {
511 picker: Some(Box::new(picker.with_mouse(mouse))),
512 path: None,
513 };
514 self.push(name, label.into(), kind)
515 }
516
517 pub fn describe(mut self, description: impl Into<String>) -> Self {
519 if let Some(field) = self.fields.last_mut() {
520 field.description = Some(description.into());
521 }
522 self
523 }
524
525 pub fn group(mut self, title: impl Into<String>) -> Self {
529 let current = self.groups.len() - 1;
530 if self.fields.iter().any(|field| field.page == current) {
531 self.groups.push(title.into());
532 } else {
533 self.groups[current] = title.into();
534 }
535 self.pager.set_pages(self.groups.len());
536 self
537 }
538
539 pub fn page_style(mut self, style: PaginatorStyle) -> Self {
541 self.pager = Paginator::new(self.groups.len())
542 .style(style)
543 .theme(self.theme.clone());
544 self
545 }
546
547 pub fn theme(mut self, theme: Theme) -> Self {
548 self.pager = std::mem::replace(&mut self.pager, Paginator::new(1)).theme(theme.clone());
549 self.theme = theme;
550 self
551 }
552
553 pub fn page(&self) -> usize {
555 self.pager.page()
556 }
557
558 fn on_page(&self, page: usize) -> Range<usize> {
560 match self.fields.iter().position(|f| f.page == page) {
561 Some(start) => {
562 let end = self.fields[start..]
563 .iter()
564 .position(|f| f.page != page)
565 .map_or(self.fields.len(), |n| start + n);
566 start..end
567 }
568 None => 0..0,
569 }
570 }
571
572 fn stops(&self) -> Vec<usize> {
575 let range = self.on_page(self.pager.page());
576 let stops: Vec<usize> = range
577 .clone()
578 .filter(|&index| !self.fields[index].is_note())
579 .collect();
580 if stops.is_empty() {
581 range.take(1).collect()
582 } else {
583 stops
584 }
585 }
586
587 fn move_focus(&mut self, delta: isize) {
588 let stops = self.stops();
589 if stops.is_empty() {
590 return;
591 }
592 let at = stops.iter().position(|&s| s == self.focus).unwrap_or(0);
593 let next = (at as isize + delta).rem_euclid(stops.len() as isize) as usize;
594 self.focus = stops[next];
595 }
596
597 fn show_page(&mut self, page: usize) {
599 self.pager.set_page(page);
600 self.focus = self.stops().first().copied().unwrap_or(0);
601 self.browsing = false;
602 }
603
604 fn on_last_stop(&self) -> bool {
606 self.stops().last() == Some(&self.focus)
607 }
608
609 fn check(&mut self, range: Range<usize>) -> Option<usize> {
612 let mut first = None;
613 for index in range {
614 let field = &mut self.fields[index];
615 if field.is_note() {
616 continue;
617 }
618 match field.value() {
619 Ok(_) => field.error = None,
620 Err(message) => {
621 field.error = Some(message);
622 first.get_or_insert(index);
623 }
624 }
625 }
626 first
627 }
628
629 fn next_page(&mut self) -> Flow<Answers> {
631 if self.pager.on_last() {
632 return Flow::Continue;
633 }
634 match self.check(self.on_page(self.pager.page())) {
635 Some(index) => self.focus = index,
636 None => self.show_page(self.pager.page() + 1),
637 }
638 Flow::Continue
639 }
640
641 fn go_to(&mut self, page: usize) {
644 while self.pager.page() < page {
645 let at = self.pager.page();
646 self.next_page();
647 if self.pager.page() == at {
648 return;
649 }
650 }
651 if page < self.pager.page() {
652 self.show_page(page);
653 }
654 }
655
656 fn answers_before(&self, end: usize) -> Answers {
658 Answers(
659 self.fields[..end]
660 .iter()
661 .filter(|field| !field.is_note())
662 .filter_map(|field| Some((field.name.clone(), field.value().ok()?)))
663 .collect(),
664 )
665 }
666
667 fn refresh(&mut self) {
669 for at in 0..self.fields.len() {
670 if !matches!(
671 &self.fields[at].kind,
672 Kind::Choice {
673 source: Some(_),
674 ..
675 }
676 ) {
677 continue;
678 }
679 let answers = self.answers_before(at);
680 if let Kind::Choice {
681 options,
682 index,
683 source: Some(source),
684 } = &mut self.fields[at].kind
685 {
686 let fresh = source(&answers);
687 if fresh != *options {
688 let current = options.get(*index).cloned();
689 *index = current
690 .and_then(|c| fresh.iter().position(|o| *o == c))
691 .unwrap_or(0);
692 *options = fresh;
693 }
694 }
695 }
696 }
697
698 fn submit(&mut self) -> Flow<Answers> {
699 if let Some(index) = self.check(0..self.fields.len()) {
700 let page = self.fields[index].page;
701 if page != self.pager.page() {
702 self.show_page(page);
703 }
704 self.focus = index;
705 return Flow::Continue;
706 }
707 let answers = self
708 .fields
709 .iter()
710 .filter(|field| !field.is_note())
711 .filter_map(|field| Some((field.name.clone(), field.value().ok()?)))
712 .collect();
713 self.answer = Some(true);
714 Flow::Done(Answers(answers))
715 }
716
717 fn open_picker(&mut self) {
718 if let Some(picker) = self.fields[self.focus].picker() {
719 picker.reopen();
720 self.browsing = true;
721 }
722 }
723
724 fn click(&mut self, context: &Context<'_>, row: usize, column: usize) -> Flow<Answers> {
725 let layout = self.layout(context);
726 match layout.hits.get(row).cloned().unwrap_or(Hit::None) {
727 Hit::Buttons => {
728 let submit = 2..2 + SUBMIT.len();
729 let cancel = submit.end + 2..submit.end + 2 + CANCEL.len();
730 if submit.contains(&column) {
731 return self.submit();
732 } else if cancel.contains(&column) {
733 self.answer = Some(false);
734 return Flow::Cancel;
735 }
736 }
737 Hit::Indicator => {
738 if let Some(page) = column
739 .checked_sub(2)
740 .and_then(|c| self.pager.page_at(context.width.saturating_sub(2), c))
741 {
742 self.go_to(page);
743 }
744 }
745 Hit::Options(index, columns) => {
746 self.focus = index;
747 self.browsing = false;
748 if let Some(option) = columns.iter().position(|r| r.contains(&column)) {
749 if let Kind::Multi { cursor, .. } = &mut self.fields[index].kind {
750 *cursor = option;
751 }
752 self.choose();
753 }
754 }
755 Hit::Field(index) => {
756 let again = index == self.focus;
757 self.focus = index;
758 self.browsing = false;
759 let mut browse = false;
760 match &mut self.fields[index].kind {
761 Kind::Toggle(on) => *on = !*on,
762 Kind::Choice { options, index, .. } if again => {
763 *index = (*index + 1) % options.len().max(1)
764 }
765 Kind::File { .. } => browse = true,
766 _ => {}
767 }
768 if browse {
769 self.open_picker();
770 }
771 }
772 Hit::Picker(_) | Hit::None => {}
773 }
774 Flow::Continue
775 }
776
777 fn choose(&mut self) {
780 if let Kind::Multi {
781 chosen,
782 cursor,
783 limit,
784 ..
785 } = &mut self.fields[self.focus].kind
786 && let Some(on) = chosen.get(*cursor).copied()
787 {
788 let count = chosen.iter().filter(|c| **c).count();
789 if on || limit.is_none_or(|limit| count < limit) {
790 chosen[*cursor] = !on;
791 }
792 }
793 }
794
795 fn browse(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
798 let mut event = event.clone();
799 if let Some(mouse) = event.mouse() {
800 let start = self.layout(context).hits.iter().find_map(|hit| match hit {
801 Hit::Picker(start) => Some(*start),
802 _ => None,
803 });
804 match start {
805 Some(start) if mouse.row as usize >= start => {
806 let mut moved = mouse;
807 moved.row -= start as u16;
808 event = Event::Mouse(moved);
809 }
810 _ if mouse.is_click() => {
811 self.browsing = false;
812 return self.click(context, mouse.row as usize, mouse.column as usize);
813 }
814 _ => return Flow::Continue,
815 }
816 }
817 let field = &mut self.fields[self.focus];
818 let Kind::File {
819 picker: Some(picker),
820 path,
821 } = &mut field.kind
822 else {
823 self.browsing = false;
824 return Flow::Continue;
825 };
826 match picker.handle(&event, context) {
827 Flow::Done(picked) => {
828 *path = Some(picked);
829 field.error = None;
830 self.browsing = false;
831 }
832 Flow::Cancel => self.browsing = false,
833 _ => {}
834 }
835 Flow::Continue
836 }
837
838 fn layout(&self, context: &Context<'_>) -> Layout {
840 let width = context.width;
841 let theme = &self.theme;
842 let mut title = question(theme, &self.title);
843 title.truncate(title.len() - 3);
844 let mut out = Layout {
845 lines: Vec::new(),
846 hits: Vec::new(),
847 cursor: None,
848 };
849 let label_width = self
850 .fields
851 .iter()
852 .filter(|field| !field.is_note())
853 .map(|field| rich::cells::cell_len(&field.label))
854 .max()
855 .unwrap_or(0);
856 if let Some(submitted) = self.answer {
857 if !submitted {
858 title.push(text(" › cancelled", &theme.hint));
859 out.push(fit(title, width), Hit::None);
860 return out;
861 }
862 out.push(fit(title, width), Hit::None);
863 for field in self.fields.iter().filter(|field| !field.is_note()) {
864 let label = format!(" {:width$} ", field.label, width = label_width);
865 let line = vec![
866 text(label, &theme.hint),
867 text(field.summary(), &theme.answer),
868 ];
869 out.push(fit(line, width), Hit::None);
870 }
871 return out;
872 }
873 out.push(fit(title, width), Hit::None);
874 let page = self.pager.page();
875 let paged = self.groups.len() > 1;
876 if !self.groups[page].is_empty() {
877 let line = vec![plain(" "), text(self.groups[page].clone(), &theme.prompt)];
878 out.push(fit(line, width), Hit::None);
879 }
880 let indent = " ".repeat(label_width + 4);
881 for index in self.on_page(page) {
882 let field = &self.fields[index];
883 let focused = index == self.focus;
884 if let Kind::Note(body) = &field.kind {
885 let line = vec![plain(" "), text(field.label.clone(), &theme.focused)];
886 out.push(fit(line, width), Hit::None);
887 let body = rich::Text::new(body.clone());
888 for line in context.lines_at(&body, width.saturating_sub(4)) {
889 let mut row = vec![plain(" ")];
890 row.extend(line);
891 out.push(fit(row, width), Hit::None);
892 }
893 continue;
894 }
895 let mut row: Vec<Segment> = if focused {
896 vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
897 } else {
898 vec![plain(" ")]
899 };
900 let padding = label_width - rich::cells::cell_len(&field.label);
901 row.push(if focused {
902 text(field.label.clone(), &theme.focused)
903 } else {
904 plain(field.label.clone())
905 });
906 row.push(plain(format!("{} ", " ".repeat(padding))));
907 let start = crate::components::width(&row);
908 let mut hit = Hit::Field(index);
909 match &field.kind {
910 Kind::Text(input) => {
911 let (segments, column) = input.field(width.saturating_sub(start));
912 row.extend(segments);
913 if focused {
914 let column = (start + column).min(width.saturating_sub(1));
915 out.cursor = Some((out.lines.len(), column));
916 }
917 }
918 Kind::Choice { options, index, .. } => {
919 let option = options.get(*index).cloned().unwrap_or_default();
920 if focused {
921 row.push(text("◂ ", &theme.hint));
922 row.push(text(option, &theme.answer));
923 row.push(text(" ▸", &theme.hint));
924 } else {
925 row.push(text(option, &theme.answer));
926 }
927 }
928 Kind::Multi {
929 options,
930 chosen,
931 cursor,
932 ..
933 } => {
934 let current = theme
935 .focused
936 .combine(&rich::Style::parse("underline").expect("style"));
937 let mut columns = Vec::new();
938 let mut at = start;
939 for (k, (option, on)) in options.iter().zip(chosen).enumerate() {
940 if k > 0 {
941 row.push(plain(" "));
942 at += 2;
943 }
944 let (mark, mark_style) = if *on {
945 (&theme.checked, &theme.checked_style)
946 } else {
947 (&theme.unchecked, &theme.hint)
948 };
949 row.push(text(format!("{mark} "), mark_style));
950 let style = if focused && k == *cursor {
951 ¤t
952 } else if *on {
953 &theme.answer
954 } else {
955 &theme.prompt
956 };
957 row.push(text(option.clone(), style));
958 let end =
959 at + rich::cells::cell_len(mark) + 1 + rich::cells::cell_len(option);
960 columns.push(at..end);
961 at = end;
962 }
963 hit = Hit::Options(index, columns);
964 }
965 Kind::Toggle(on) => row.push(if *on {
966 text(format!("{} yes", theme.checked), &theme.checked_style)
967 } else {
968 text(format!("{} no", theme.unchecked), &theme.hint)
969 }),
970 Kind::File { .. } => row.push(match field.file_path() {
971 (Some(path), true) => text(display_path(&path), &theme.answer),
972 (Some(path), false) => text(format!("({})", display_path(&path)), &theme.hint),
973 (None, _) => text("(space to browse)", &theme.hint),
974 }),
975 Kind::Note(_) => {}
976 }
977 out.push(fit(row, width), hit);
978 if let (true, Kind::Text(input)) = (focused, &field.kind) {
979 for line in input.suggestion_rows(width, &" ".repeat(label_width + 2)) {
980 out.push(line, Hit::Field(index));
981 }
982 }
983 if let (
984 true,
985 true,
986 Kind::File {
987 picker: Some(picker),
988 ..
989 },
990 ) = (focused, self.browsing, &field.kind)
991 {
992 let start = out.lines.len();
993 let view = picker.render(context);
994 if let Some((row, column)) = view.cursor {
995 out.cursor = Some((start + row, column));
996 }
997 for line in view.lines {
998 out.push(line, Hit::Picker(start));
999 }
1000 }
1001 if let Some(description) = &field.description {
1002 let line = vec![text(format!("{indent}{description}"), &theme.hint)];
1003 out.push(fit(line, width), Hit::Field(index));
1004 }
1005 if let Some(error) = &field.error {
1006 let line = vec![text(format!("{indent}✗ {error}"), &theme.error)];
1007 out.push(fit(line, width), Hit::Field(index));
1008 }
1009 }
1010 if paged {
1011 let mut line = vec![plain(" ")];
1012 line.extend(self.pager.indicator(width.saturating_sub(2)));
1013 out.push(line, Hit::Indicator);
1014 }
1015 if self.mouse {
1016 let button = theme
1017 .focused
1018 .combine(&rich::Style::parse("reverse").expect("style"));
1019 let line = vec![
1020 plain(" "),
1021 text(SUBMIT, &button),
1022 plain(" "),
1023 text(CANCEL, &button),
1024 ];
1025 out.push(fit(line, width), Hit::Buttons);
1026 }
1027 let hints = if self.browsing {
1028 " enter pick · esc close the picker".to_string()
1029 } else {
1030 let enter = match (self.on_last_stop(), self.pager.on_last()) {
1031 (true, true) => "enter submit",
1032 (true, false) => "enter next page",
1033 _ => "enter next",
1034 };
1035 let mut hints = format!(" tab/↑↓ move · {enter}");
1036 if paged {
1037 hints.push_str(" · pgup/pgdn page");
1038 }
1039 if let Some(Kind::File { .. }) = self.fields.get(self.focus).map(|f| &f.kind) {
1040 hints.push_str(" · space browse");
1041 }
1042 hints.push_str(" · ctrl+s submit · esc cancel");
1043 hints
1044 };
1045 out.push(fit(vec![text(hints, &theme.hint)], width), Hit::None);
1046 out
1047 }
1048
1049 fn handle_event(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
1050 if self.browsing {
1051 return self.browse(event, context);
1052 }
1053 if let Some(mouse) = event.mouse() {
1054 if mouse.is_click() {
1055 return self.click(context, mouse.row as usize, mouse.column as usize);
1056 }
1057 return Flow::Continue;
1058 }
1059 if let Event::Tick = event {
1060 for field in &mut self.fields {
1062 if let Kind::Text(input) = &mut field.kind {
1063 input.handle(event, context);
1064 }
1065 }
1066 return Flow::Continue;
1067 }
1068 let field = &mut self.fields[self.focus];
1072 if let (Some(key), Kind::Text(input)) = (event.key(), &mut field.kind)
1073 && input.suggesting()
1074 {
1075 match input.keymap().action(key) {
1076 Some("complete" | "up" | "down") => {
1077 input.handle(event, context);
1078 field.error = None;
1079 return Flow::Continue;
1080 }
1081 Some("submit") if input.has_selection() => {
1082 input.accept_selected();
1083 field.error = None;
1084 return Flow::Continue;
1085 }
1086 _ => {}
1087 }
1088 }
1089 if let Some(key) = event.key() {
1090 let last = self.on_last_stop();
1091 match self.form_keymap().action(key) {
1092 Some("cancel") => {
1093 self.answer = Some(false);
1094 return Flow::Cancel;
1095 }
1096 Some("submit") => return self.submit(),
1097 Some("next") if last && key.code == KeyCode::Enter => {
1100 return if self.pager.on_last() {
1101 self.submit()
1102 } else {
1103 self.next_page()
1104 };
1105 }
1106 Some("next") => {
1107 self.move_focus(1);
1108 return Flow::Continue;
1109 }
1110 Some("previous") => {
1111 self.move_focus(-1);
1112 return Flow::Continue;
1113 }
1114 Some("next-page") => return self.next_page(),
1115 Some("previous-page") => {
1116 if !self.pager.on_first() {
1117 self.show_page(self.pager.page() - 1);
1118 }
1119 return Flow::Continue;
1120 }
1121 _ => {}
1122 }
1123 }
1124 let action = |keymap: Keymap| {
1125 event
1126 .key()
1127 .and_then(|key| keymap.action(key).map(str::to_string))
1128 };
1129 match &self.fields[self.focus].kind {
1130 Kind::Multi { .. } => {
1131 let action = action(self.multi_keymap());
1132 if let Kind::Multi {
1133 options, cursor, ..
1134 } = &mut self.fields[self.focus].kind
1135 {
1136 let count = options.len().max(1);
1137 match action.as_deref() {
1138 Some("next-choice") => *cursor = (*cursor + 1) % count,
1139 Some("previous-choice") => *cursor = (*cursor + count - 1) % count,
1140 Some("choose") => self.choose(),
1141 _ => return Flow::Ignored,
1142 }
1143 }
1144 return Flow::Continue;
1145 }
1146 Kind::File { .. } => {
1147 return match action(self.file_keymap()).as_deref() {
1148 Some("browse") => {
1149 self.open_picker();
1150 Flow::Continue
1151 }
1152 _ => Flow::Ignored,
1153 };
1154 }
1155 Kind::Note(_) => return Flow::Ignored,
1156 _ => {}
1157 }
1158 let choice = action(self.choice_keymap());
1159 let toggle = action(self.toggle_keymap());
1160 let field = &mut self.fields[self.focus];
1161 match &mut field.kind {
1162 Kind::Text(input) => {
1163 let before = input.text().to_string();
1164 if let Flow::Ignored = input.handle(event, context) {
1166 return Flow::Ignored;
1167 }
1168 if input.text() != before {
1169 field.error = None;
1170 }
1171 }
1172 Kind::Choice { options, index, .. } => {
1173 let count = options.len().max(1);
1174 match choice.as_deref() {
1175 Some("next-option") => *index = (*index + 1) % count,
1176 Some("previous-option") => *index = (*index + count - 1) % count,
1177 _ => return Flow::Ignored,
1178 }
1179 }
1180 Kind::Toggle(on) => match toggle.as_deref() {
1181 Some("toggle") => *on = !*on,
1182 Some("yes") => *on = true,
1183 Some("no") => *on = false,
1184 _ => return Flow::Ignored,
1185 },
1186 Kind::Multi { .. } | Kind::File { .. } | Kind::Note(_) => {}
1187 }
1188 Flow::Continue
1189 }
1190
1191 fn prompt_path(
1194 io: &mut dyn LineIo,
1195 label: &str,
1196 default: Option<PathBuf>,
1197 ) -> Result<Value, NotInteractive> {
1198 match &default {
1199 Some(path) => io.write(&format!("{label} [{}]: ", display_path(path))),
1200 None => io.write(&format!("{label}: ")),
1201 }
1202 let line = io.read_line();
1203 let typed = line.as_deref().map(str::trim).unwrap_or_default();
1204 match (typed.is_empty(), default) {
1205 (false, _) => Ok(Value::Path(PathBuf::from(typed))),
1206 (true, Some(path)) => Ok(Value::Path(path)),
1207 (true, None) if line.is_none() => Err(NotInteractive::Ended),
1208 (true, None) => Err(NotInteractive::Invalid(format!("{label} needs a path"))),
1209 }
1210 }
1211
1212 fn prompt_list(
1214 io: &mut dyn LineIo,
1215 label: &str,
1216 options: &[String],
1217 chosen: &[bool],
1218 limit: Option<usize>,
1219 ) -> Result<Value, NotInteractive> {
1220 let current: Vec<String> = options
1221 .iter()
1222 .zip(chosen)
1223 .filter(|(_, on)| **on)
1224 .map(|(option, _)| option.clone())
1225 .collect();
1226 io.write(&format!(
1227 "{label}: {} (comma-separated) [{}]: ",
1228 options.join(" / "),
1229 current.join(", ")
1230 ));
1231 let line = io.read_line().unwrap_or_default();
1232 let mut picked = Vec::new();
1233 for word in line.split(',').map(str::trim).filter(|w| !w.is_empty()) {
1234 let found = options
1235 .iter()
1236 .find(|option| option.eq_ignore_ascii_case(word))
1237 .ok_or_else(|| NotInteractive::Invalid(format!("not an option: {word:?}")))?;
1238 picked.push(found.clone());
1239 }
1240 if line.trim().is_empty() {
1241 return Ok(Value::List(current));
1242 }
1243 let picked: Vec<String> = options
1245 .iter()
1246 .filter(|option| picked.contains(option))
1247 .cloned()
1248 .collect();
1249 match limit {
1250 Some(limit) if picked.len() > limit => Err(NotInteractive::Invalid(format!(
1251 "{label}: at most {limit} options"
1252 ))),
1253 _ => Ok(Value::List(picked)),
1254 }
1255 }
1256}
1257
1258impl Component for Form {
1259 type Output = Answers;
1260
1261 fn start(&mut self, _: &Context<'_>) -> Flow<Answers> {
1263 if self.fields.is_empty() {
1264 self.answer = Some(true);
1265 return Flow::Done(Answers::default());
1266 }
1267 self.refresh();
1268 if !self.stops().contains(&self.focus) {
1269 self.show_page(self.pager.page());
1270 }
1271 Flow::Continue
1272 }
1273
1274 fn keymap(&self) -> Keymap {
1275 let kind = self.fields.get(self.focus).map(|field| &field.kind);
1276 if let (
1277 true,
1278 Some(Kind::File {
1279 picker: Some(picker),
1280 ..
1281 }),
1282 ) = (self.browsing, kind)
1283 {
1284 return picker.keymap();
1285 }
1286 let mut keymap = self.form_keymap();
1287 match kind {
1288 Some(Kind::Text(input)) => keymap.extend(input.keymap()),
1289 Some(Kind::Choice { .. }) => keymap.extend(self.choice_keymap()),
1290 Some(Kind::Multi { .. }) => keymap.extend(self.multi_keymap()),
1291 Some(Kind::Toggle(_)) => keymap.extend(self.toggle_keymap()),
1292 Some(Kind::File { .. }) => keymap.extend(self.file_keymap()),
1293 Some(Kind::Note(_)) | None => {}
1294 }
1295 keymap
1296 }
1297
1298 fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<Answers> {
1299 if self.fields.is_empty() {
1300 return Flow::Done(Answers::default());
1301 }
1302 let flow = self.handle_event(event, context);
1303 self.refresh();
1304 flow
1305 }
1306
1307 fn render(&self, context: &Context<'_>) -> View {
1308 let layout = self.layout(context);
1309 let view = View::new(layout.lines);
1310 match layout.cursor {
1311 Some((row, column)) => view.with_cursor(row, column),
1312 None => view,
1313 }
1314 }
1315
1316 fn mouse(&self) -> bool {
1317 self.mouse
1318 }
1319
1320 fn tick(&self) -> Option<Duration> {
1323 self.fields
1324 .iter()
1325 .filter_map(|field| match &field.kind {
1326 Kind::Text(input) => input.tick(),
1327 Kind::File {
1328 picker: Some(picker),
1329 ..
1330 } if self.browsing => picker.tick(),
1331 _ => None,
1332 })
1333 .min()
1334 }
1335
1336 fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<Answers>, NotInteractive> {
1339 io.write(&format!("{}\n", self.title));
1340 let mut answers = Vec::new();
1341 let mut page = None;
1342 for at in 0..self.fields.len() {
1343 if page != Some(self.fields[at].page) {
1344 page = Some(self.fields[at].page);
1345 let title = &self.groups[self.fields[at].page];
1346 if !title.is_empty() {
1347 io.write(&format!("{title}\n"));
1348 }
1349 }
1350 let so_far = Answers(answers.clone());
1351 let field = &mut self.fields[at];
1352 let value = match &mut field.kind {
1353 Kind::Text(input) => match input.prompt(io)? {
1354 Some(text) => Value::Text(text),
1355 None => return Ok(None),
1356 },
1357 Kind::Note(body) => {
1358 io.write(&format!("{}\n{body}\n", field.label));
1359 continue;
1360 }
1361 Kind::Choice {
1362 options,
1363 index,
1364 source,
1365 } => {
1366 if let Some(source) = source {
1367 *options = source(&so_far);
1368 *index = 0;
1369 }
1370 let current = options.get(*index).cloned().unwrap_or_default();
1371 io.write(&format!(
1372 "{}: {} [{current}]: ",
1373 field.label,
1374 options.join(" / ")
1375 ));
1376 let line = io.read_line().unwrap_or_default();
1379 let line = line.trim();
1380 if line.is_empty() {
1381 Value::Text(current)
1382 } else {
1383 let found = options
1384 .iter()
1385 .find(|option| option.eq_ignore_ascii_case(line))
1386 .ok_or_else(|| {
1387 NotInteractive::Invalid(format!("not an option: {line:?}"))
1388 })?;
1389 Value::Text(found.clone())
1390 }
1391 }
1392 Kind::Multi {
1393 options,
1394 chosen,
1395 limit,
1396 ..
1397 } => Form::prompt_list(io, &field.label, options, chosen, *limit)?,
1398 Kind::Toggle(on) => {
1399 io.write(&format!(
1400 "{} [{}]: ",
1401 field.label,
1402 if *on { "Y/n" } else { "y/N" }
1403 ));
1404 let line = io.read_line().unwrap_or_default();
1405 Value::Flag(match line.trim().to_lowercase().as_str() {
1406 "" => *on,
1407 "y" | "yes" => true,
1408 "n" | "no" => false,
1409 other => {
1410 return Err(NotInteractive::Invalid(format!(
1411 "not yes or no: {other:?}"
1412 )));
1413 }
1414 })
1415 }
1416 Kind::File { .. } => {
1417 let default = field.file_path().0;
1418 Form::prompt_path(io, &field.label, default)?
1419 }
1420 };
1421 answers.push((field.name.clone(), value));
1422 }
1423 Ok(Some(Answers(answers)))
1424 }
1425}