1use crate::config::constants::ui;
2use crate::ui::search::{fuzzy_match, normalize_query};
3use crate::ui::tui::types::{
4 InlineEvent, InlineListItem, InlineListSearchConfig, InlineListSelection, SecurePromptConfig,
5 WizardModalMode, WizardStep,
6};
7use ratatui::crossterm::event::{KeyCode, KeyEvent};
8use ratatui::widgets::ListState;
9use tui_popup::PopupState;
10
11#[derive(Clone)]
12pub struct ModalState {
13 pub title: String,
14 pub lines: Vec<String>,
15 pub footer_hint: Option<String>,
16 pub list: Option<ModalListState>,
17 pub secure_prompt: Option<SecurePromptConfig>,
18 pub is_plan_confirmation: bool,
19 #[allow(dead_code)]
20 pub popup_state: PopupState,
21 #[allow(dead_code)]
22 pub restore_input: bool,
23 #[allow(dead_code)]
24 pub restore_cursor: bool,
25 pub search: Option<ModalSearchState>,
26}
27
28#[allow(dead_code)]
30#[derive(Clone)]
31pub struct WizardModalState {
32 pub title: String,
33 pub steps: Vec<WizardStepState>,
34 pub current_step: usize,
35 pub search: Option<ModalSearchState>,
36 pub mode: WizardModalMode,
37}
38
39#[allow(dead_code)]
41#[derive(Clone)]
42pub struct WizardStepState {
43 pub title: String,
45 pub question: String,
47 pub list: ModalListState,
49 pub completed: bool,
51 pub answer: Option<InlineListSelection>,
53 pub notes: String,
55 pub notes_active: bool,
57
58 pub allow_freeform: bool,
59 pub freeform_label: Option<String>,
60 pub freeform_placeholder: Option<String>,
61}
62
63#[derive(Debug, Clone, Copy, Default)]
64pub struct ModalKeyModifiers {
65 pub control: bool,
66 pub alt: bool,
67 pub command: bool,
68}
69
70#[derive(Debug, Clone)]
71pub enum ModalListKeyResult {
72 NotHandled,
73 HandledNoRedraw,
74 Redraw,
75 Submit(InlineEvent),
76 Cancel(InlineEvent),
77}
78
79#[derive(Clone)]
80pub struct ModalListState {
81 pub items: Vec<ModalListItem>,
82 pub visible_indices: Vec<usize>,
83 pub list_state: ListState,
84 pub total_selectable: usize,
85 pub filter_terms: Vec<String>,
86 pub filter_query: Option<String>,
87 pub viewport_rows: Option<u16>,
88 pub compact_rows: bool,
89}
90
91#[derive(Clone)]
92pub struct ModalListItem {
93 pub title: String,
94 pub subtitle: Option<String>,
95 pub badge: Option<String>,
96 pub indent: u8,
97 pub selection: Option<InlineListSelection>,
98 pub search_value: Option<String>,
99 pub is_divider: bool,
100}
101
102#[derive(Clone)]
103pub struct ModalSearchState {
104 pub label: String,
105 pub placeholder: Option<String>,
106 pub query: String,
107}
108
109impl From<InlineListSearchConfig> for ModalSearchState {
110 fn from(config: InlineListSearchConfig) -> Self {
111 Self {
112 label: config.label,
113 placeholder: config.placeholder,
114 query: String::new(),
115 }
116 }
117}
118
119impl ModalSearchState {
120 pub fn insert(&mut self, value: &str) {
121 for ch in value.chars() {
122 if matches!(ch, '\n' | '\r') {
123 continue;
124 }
125 self.query.push(ch);
126 }
127 }
128
129 pub fn push_char(&mut self, ch: char) {
130 self.query.push(ch);
131 }
132
133 pub fn backspace(&mut self) -> bool {
134 if self.query.pop().is_some() {
135 return true;
136 }
137 false
138 }
139
140 pub fn clear(&mut self) -> bool {
141 if self.query.is_empty() {
142 return false;
143 }
144 self.query.clear();
145 true
146 }
147}
148
149impl ModalState {
150 pub fn handle_list_key_event(
151 &mut self,
152 key: &KeyEvent,
153 modifiers: ModalKeyModifiers,
154 ) -> ModalListKeyResult {
155 let Some(list) = self.list.as_mut() else {
156 return ModalListKeyResult::NotHandled;
157 };
158
159 if let Some(search) = self.search.as_mut() {
160 match key.code {
161 KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
162 search.push_char(ch);
163 list.apply_search(&search.query);
164 return ModalListKeyResult::Redraw;
165 }
166 KeyCode::Backspace => {
167 if search.backspace() {
168 list.apply_search(&search.query);
169 return ModalListKeyResult::Redraw;
170 }
171 return ModalListKeyResult::HandledNoRedraw;
172 }
173 KeyCode::Delete => {
174 if search.clear() {
175 list.apply_search(&search.query);
176 return ModalListKeyResult::Redraw;
177 }
178 return ModalListKeyResult::HandledNoRedraw;
179 }
180 KeyCode::Esc => {
181 if search.clear() {
182 list.apply_search(&search.query);
183 return ModalListKeyResult::Redraw;
184 }
185 }
186 _ => {}
187 }
188 }
189
190 match key.code {
191 KeyCode::Char('d') | KeyCode::Char('D') if modifiers.alt => {
192 list.toggle_row_density();
193 ModalListKeyResult::Redraw
194 }
195 KeyCode::Up => {
196 if modifiers.command {
197 list.select_first();
198 } else {
199 list.select_previous();
200 }
201 ModalListKeyResult::Redraw
202 }
203 KeyCode::Down => {
204 if modifiers.command {
205 list.select_last();
206 } else {
207 list.select_next();
208 }
209 ModalListKeyResult::Redraw
210 }
211 KeyCode::PageUp => {
212 list.page_up();
213 ModalListKeyResult::Redraw
214 }
215 KeyCode::PageDown => {
216 list.page_down();
217 ModalListKeyResult::Redraw
218 }
219 KeyCode::Home => {
220 list.select_first();
221 ModalListKeyResult::Redraw
222 }
223 KeyCode::End => {
224 list.select_last();
225 ModalListKeyResult::Redraw
226 }
227 KeyCode::Tab => {
228 if self.search.is_none() && !list.visible_indices.is_empty() {
231 list.select_first();
232 } else {
233 list.select_next();
234 }
235 ModalListKeyResult::Redraw
236 }
237 KeyCode::BackTab => {
238 list.select_previous();
239 ModalListKeyResult::Redraw
240 }
241 KeyCode::Left => {
242 if let Some(selection) = list.current_selection()
243 && let Some(adjusted) = map_config_selection_for_arrow(&selection, true)
244 {
245 return ModalListKeyResult::Submit(InlineEvent::ListModalSubmit(adjusted));
246 }
247 list.select_previous();
248 ModalListKeyResult::Redraw
249 }
250 KeyCode::Right => {
251 if let Some(selection) = list.current_selection()
252 && let Some(adjusted) = map_config_selection_for_arrow(&selection, false)
253 {
254 return ModalListKeyResult::Submit(InlineEvent::ListModalSubmit(adjusted));
255 }
256 list.select_next();
257 ModalListKeyResult::Redraw
258 }
259 KeyCode::Enter => {
260 if let Some(selection) = list.current_selection() {
261 ModalListKeyResult::Submit(InlineEvent::ListModalSubmit(selection))
262 } else {
263 ModalListKeyResult::HandledNoRedraw
264 }
265 }
266 KeyCode::Esc => ModalListKeyResult::Cancel(InlineEvent::ListModalCancel),
267 KeyCode::Char(ch) if modifiers.control || modifiers.alt => match ch {
268 'n' | 'N' | 'j' | 'J' => {
269 list.select_next();
270 ModalListKeyResult::Redraw
271 }
272 'p' | 'P' | 'k' | 'K' => {
273 list.select_previous();
274 ModalListKeyResult::Redraw
275 }
276 _ => ModalListKeyResult::NotHandled,
277 },
278 _ => ModalListKeyResult::NotHandled,
279 }
280 }
281}
282
283fn map_config_selection_for_arrow(
284 selection: &InlineListSelection,
285 is_left: bool,
286) -> Option<InlineListSelection> {
287 let InlineListSelection::ConfigAction(action) = selection else {
288 return None;
289 };
290
291 if action.ends_with(":cycle") {
292 if is_left {
293 let key = action.trim_end_matches(":cycle");
294 return Some(InlineListSelection::ConfigAction(format!(
295 "{}:cycle_prev",
296 key
297 )));
298 }
299 return Some(selection.clone());
300 }
301
302 if action.ends_with(":inc") {
303 if is_left {
304 let key = action.trim_end_matches(":inc");
305 return Some(InlineListSelection::ConfigAction(format!("{}:dec", key)));
306 }
307 return Some(selection.clone());
308 }
309
310 if action.ends_with(":dec") {
311 if is_left {
312 return Some(selection.clone());
313 }
314 let key = action.trim_end_matches(":dec");
315 return Some(InlineListSelection::ConfigAction(format!("{}:inc", key)));
316 }
317
318 if action.ends_with(":toggle") {
319 let _ = is_left;
320 return Some(selection.clone());
321 }
322
323 None
324}
325
326impl ModalListItem {
327 pub fn is_header(&self) -> bool {
328 self.selection.is_none() && !self.is_divider
329 }
330
331 fn matches(&self, query: &str) -> bool {
332 if query.is_empty() {
333 return true;
334 }
335 let Some(value) = self.search_value.as_ref() else {
336 return false;
337 };
338 fuzzy_match(query, value)
339 }
340}
341
342#[allow(clippy::const_is_empty)]
343pub fn is_divider_title(item: &InlineListItem) -> bool {
344 if item.selection.is_some() {
345 return false;
346 }
347 if item.indent != 0 {
348 return false;
349 }
350 if item.subtitle.is_some() || item.badge.is_some() {
351 return false;
352 }
353 let symbol = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL;
354 if symbol.is_empty() {
355 return false;
356 }
357 item.title
358 .chars()
359 .all(|ch| symbol.chars().any(|needle| needle == ch))
360}
361
362impl ModalListState {
363 pub fn new(items: Vec<InlineListItem>, selected: Option<InlineListSelection>) -> Self {
364 let converted: Vec<ModalListItem> = items
365 .into_iter()
366 .map(|item| {
367 let is_divider = is_divider_title(&item);
368 let search_value = item
369 .search_value
370 .as_ref()
371 .map(|value| value.to_ascii_lowercase());
372 ModalListItem {
373 title: item.title,
374 subtitle: item.subtitle,
375 badge: item.badge,
376 indent: item.indent,
377 selection: item.selection,
378 search_value,
379 is_divider,
380 }
381 })
382 .collect();
383 let total_selectable = converted
384 .iter()
385 .filter(|item| item.selection.is_some())
386 .count();
387 let mut modal_state = Self {
388 visible_indices: (0..converted.len()).collect(),
389 items: converted,
390 list_state: ListState::default(),
391 total_selectable,
392 filter_terms: Vec::new(),
393 filter_query: None,
394 viewport_rows: None,
395 compact_rows: false,
396 };
397 modal_state.select_initial(selected);
398 modal_state
399 }
400
401 pub fn current_selection(&self) -> Option<InlineListSelection> {
402 self.list_state
403 .selected()
404 .and_then(|index| self.visible_indices.get(index))
405 .and_then(|&item_index| self.items.get(item_index))
406 .and_then(|item| item.selection.clone())
407 }
408
409 pub fn get_best_matching_item(&self, query: &str) -> Option<String> {
410 if query.is_empty() {
411 return None;
412 }
413
414 let normalized_query = normalize_query(query);
415 self.visible_indices
416 .iter()
417 .filter_map(|&idx| self.items.get(idx))
418 .filter(|item| item.selection.is_some())
419 .filter_map(|item| item.search_value.as_ref())
420 .find(|search_value| fuzzy_match(&normalized_query, search_value))
421 .cloned()
422 }
423
424 pub fn select_previous(&mut self) {
425 if self.visible_indices.is_empty() {
426 return;
427 }
428 let Some(mut index) = self.list_state.selected() else {
429 if let Some(last) = self.last_selectable_index() {
430 self.list_state.select(Some(last));
431 }
432 return;
433 };
434
435 while index > 0 {
436 index -= 1;
437 let item_index = match self.visible_indices.get(index) {
438 Some(idx) => *idx,
439 None => {
440 tracing::warn!("visible_indices index {index} out of bounds");
441 continue;
442 }
443 };
444 if let Some(item) = self.items.get(item_index)
445 && item.selection.is_some()
446 {
447 self.list_state.select(Some(index));
448 return;
449 }
450 }
451
452 if let Some(first) = self.first_selectable_index() {
453 self.list_state.select(Some(first));
454 } else {
455 self.list_state.select(None);
456 }
457 }
458
459 pub fn select_next(&mut self) {
460 if self.visible_indices.is_empty() {
461 return;
462 }
463 let mut index = self.list_state.selected().unwrap_or(usize::MAX);
464 if index == usize::MAX {
465 if let Some(first) = self.first_selectable_index() {
466 self.list_state.select(Some(first));
467 }
468 return;
469 }
470 while index + 1 < self.visible_indices.len() {
471 index += 1;
472 let item_index = self.visible_indices[index];
473 if self.items[item_index].selection.is_some() {
474 self.list_state.select(Some(index));
475 break;
476 }
477 }
478 }
479
480 pub fn select_first(&mut self) {
481 if let Some(first) = self.first_selectable_index() {
482 self.list_state.select(Some(first));
483 } else {
484 self.list_state.select(None);
485 }
486 if let Some(rows) = self.viewport_rows {
487 self.ensure_visible(rows);
488 }
489 }
490
491 pub fn select_last(&mut self) {
492 if let Some(last) = self.last_selectable_index() {
493 self.list_state.select(Some(last));
494 } else {
495 self.list_state.select(None);
496 }
497 if let Some(rows) = self.viewport_rows {
498 self.ensure_visible(rows);
499 }
500 }
501
502 pub fn select_nth_selectable(&mut self, target_index: usize) -> bool {
503 let mut count = 0usize;
504 for (visible_pos, &item_index) in self.visible_indices.iter().enumerate() {
505 if self.items[item_index].selection.is_some() {
506 if count == target_index {
507 self.list_state.select(Some(visible_pos));
508 if let Some(rows) = self.viewport_rows {
509 self.ensure_visible(rows);
510 }
511 return true;
512 }
513 count += 1;
514 }
515 }
516 false
517 }
518
519 pub fn page_up(&mut self) {
520 let step = self.page_step();
521 if step == 0 {
522 self.select_previous();
523 return;
524 }
525 for _ in 0..step {
526 let before = self.list_state.selected();
527 self.select_previous();
528 if self.list_state.selected() == before {
529 break;
530 }
531 }
532 }
533
534 pub fn page_down(&mut self) {
535 let step = self.page_step();
536 if step == 0 {
537 self.select_next();
538 return;
539 }
540 for _ in 0..step {
541 let before = self.list_state.selected();
542 self.select_next();
543 if self.list_state.selected() == before {
544 break;
545 }
546 }
547 }
548
549 pub fn set_viewport_rows(&mut self, rows: u16) {
550 self.viewport_rows = Some(rows);
551 }
552
553 pub(super) fn ensure_visible(&mut self, viewport: u16) {
554 let Some(selected) = self.list_state.selected() else {
555 return;
556 };
557 if viewport == 0 {
558 return;
559 }
560 let visible = viewport as usize;
561 let offset = self.list_state.offset();
562 if selected < offset {
563 *self.list_state.offset_mut() = selected;
564 } else if selected >= offset + visible {
565 *self.list_state.offset_mut() = selected + 1 - visible;
566 }
567 }
568
569 pub fn apply_search(&mut self, query: &str) {
570 let preferred = self.current_selection();
571 self.apply_search_with_preference(query, preferred.clone());
572 }
573
574 pub fn apply_search_with_preference(
575 &mut self,
576 query: &str,
577 preferred: Option<InlineListSelection>,
578 ) {
579 let trimmed = query.trim();
580 if trimmed.is_empty() {
581 if self.filter_query.is_none() {
582 if preferred.is_some() && self.current_selection() != preferred {
583 self.select_initial(preferred);
584 }
585 return;
586 }
587 self.visible_indices = (0..self.items.len()).collect();
588 self.filter_terms.clear();
589 self.filter_query = None;
590 self.select_initial(preferred);
591 return;
592 }
593
594 if self.filter_query.as_deref() == Some(trimmed) {
595 if preferred.is_some() && self.current_selection() != preferred {
596 self.select_initial(preferred);
597 }
598 return;
599 }
600
601 let normalized_query = normalize_query(trimmed);
602 let terms = normalized_query
603 .split_whitespace()
604 .filter(|term| !term.is_empty())
605 .map(|term| term.to_owned())
606 .collect::<Vec<_>>();
607 let mut indices = Vec::new();
608 let mut pending_divider: Option<usize> = None;
609 let mut current_header: Option<usize> = None;
610 let mut header_matches = false;
611 let mut header_included = false;
612
613 for (index, item) in self.items.iter().enumerate() {
614 if item.is_divider {
615 pending_divider = Some(index);
616 current_header = None;
617 header_matches = false;
618 header_included = false;
619 continue;
620 }
621
622 if item.is_header() {
623 current_header = Some(index);
624 header_matches = item.matches(&normalized_query);
625 header_included = false;
626 if header_matches {
627 if let Some(divider_index) = pending_divider.take() {
628 indices.push(divider_index);
629 }
630 indices.push(index);
631 header_included = true;
632 }
633 continue;
634 }
635
636 let item_matches = item.matches(&normalized_query);
637 let include_item = header_matches || item_matches;
638 if include_item {
639 if let Some(divider_index) = pending_divider.take() {
640 indices.push(divider_index);
641 }
642 if let Some(header_index) = current_header
643 && !header_included
644 {
645 indices.push(header_index);
646 header_included = true;
647 }
648 indices.push(index);
649 }
650 }
651 self.visible_indices = indices;
652 self.filter_terms = terms;
653 self.filter_query = Some(trimmed.to_owned());
654 self.select_initial(preferred);
655 }
656
657 fn select_initial(&mut self, preferred: Option<InlineListSelection>) {
658 let mut selection_index = preferred.and_then(|needle| {
659 self.visible_indices
660 .iter()
661 .position(|&idx| self.items[idx].selection.as_ref() == Some(&needle))
662 });
663
664 if selection_index.is_none() {
665 selection_index = self.first_selectable_index();
666 }
667
668 self.list_state.select(selection_index);
669 *self.list_state.offset_mut() = 0;
670 }
671
672 fn first_selectable_index(&self) -> Option<usize> {
673 self.visible_indices
674 .iter()
675 .position(|&idx| self.items[idx].selection.is_some())
676 }
677
678 fn last_selectable_index(&self) -> Option<usize> {
679 self.visible_indices
680 .iter()
681 .rposition(|&idx| self.items[idx].selection.is_some())
682 }
683
684 pub(super) fn filter_active(&self) -> bool {
685 self.filter_query
686 .as_ref()
687 .is_some_and(|value| !value.is_empty())
688 }
689
690 pub(super) fn filter_query(&self) -> Option<&str> {
691 self.filter_query.as_deref()
692 }
693
694 pub(super) fn highlight_terms(&self) -> &[String] {
695 &self.filter_terms
696 }
697
698 pub(super) fn visible_selectable_count(&self) -> usize {
699 self.visible_indices
700 .iter()
701 .filter(|&&idx| self.items[idx].selection.is_some())
702 .count()
703 }
704
705 pub(super) fn total_selectable(&self) -> usize {
706 self.total_selectable
707 }
708
709 pub(super) fn compact_rows(&self) -> bool {
710 self.compact_rows
711 }
712
713 pub fn toggle_row_density(&mut self) {
714 self.compact_rows = !self.compact_rows;
715 }
716
717 fn page_step(&self) -> usize {
718 let rows = self.viewport_rows.unwrap_or(0).max(1);
719 usize::from(rows)
720 }
721}
722
723#[allow(dead_code)]
724impl WizardModalState {
725 pub fn new(
727 title: String,
728 steps: Vec<WizardStep>,
729 current_step: usize,
730 search: Option<InlineListSearchConfig>,
731 mode: WizardModalMode,
732 ) -> Self {
733 let step_states: Vec<WizardStepState> = steps
734 .into_iter()
735 .map(|step| {
736 let notes_active = step
737 .items
738 .first()
739 .and_then(|item| item.selection.as_ref())
740 .is_some_and(|selection| match selection {
741 InlineListSelection::RequestUserInputAnswer {
742 selected, other, ..
743 } => selected.is_empty() && other.is_some(),
744 _ => false,
745 });
746 WizardStepState {
747 title: step.title,
748 question: step.question,
749 list: ModalListState::new(step.items, step.answer.clone()),
750 completed: step.completed,
751 answer: step.answer,
752 notes: String::new(),
753 notes_active,
754 allow_freeform: step.allow_freeform,
755 freeform_label: step.freeform_label,
756 freeform_placeholder: step.freeform_placeholder,
757 }
758 })
759 .collect();
760
761 let clamped_step = if step_states.is_empty() {
762 0
763 } else {
764 current_step.min(step_states.len().saturating_sub(1))
765 };
766
767 Self {
768 title,
769 steps: step_states,
770 current_step: clamped_step,
771 search: search.map(ModalSearchState::from),
772 mode,
773 }
774 }
775
776 pub fn handle_key_event(
778 &mut self,
779 key: &KeyEvent,
780 modifiers: ModalKeyModifiers,
781 ) -> ModalListKeyResult {
782 if let Some(step) = self.steps.get_mut(self.current_step)
783 && step.notes_active
784 {
785 match key.code {
786 KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
787 step.notes.push(ch);
788 return ModalListKeyResult::Redraw;
789 }
790 KeyCode::Backspace => {
791 if step.notes.pop().is_some() {
792 return ModalListKeyResult::Redraw;
793 }
794 return ModalListKeyResult::HandledNoRedraw;
795 }
796 KeyCode::Tab | KeyCode::Esc => {
797 if !step.notes.is_empty() {
798 step.notes.clear();
799 }
800 step.notes_active = false;
801 return ModalListKeyResult::Redraw;
802 }
803 _ => {}
804 }
805 }
806
807 if let Some(search) = self.search.as_mut()
809 && let Some(step) = self.steps.get_mut(self.current_step)
810 {
811 match key.code {
812 KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
813 search.push_char(ch);
814 step.list.apply_search(&search.query);
815 return ModalListKeyResult::Redraw;
816 }
817 KeyCode::Backspace => {
818 if search.backspace() {
819 step.list.apply_search(&search.query);
820 return ModalListKeyResult::Redraw;
821 }
822 return ModalListKeyResult::HandledNoRedraw;
823 }
824 KeyCode::Delete => {
825 if search.clear() {
826 step.list.apply_search(&search.query);
827 return ModalListKeyResult::Redraw;
828 }
829 return ModalListKeyResult::HandledNoRedraw;
830 }
831 KeyCode::Tab => {
832 if let Some(best_match) = step.list.get_best_matching_item(&search.query) {
833 search.query = best_match;
834 step.list.apply_search(&search.query);
835 return ModalListKeyResult::Redraw;
836 }
837 return ModalListKeyResult::HandledNoRedraw;
838 }
839 KeyCode::Esc => {
840 if search.clear() {
841 step.list.apply_search(&search.query);
842 return ModalListKeyResult::Redraw;
843 }
844 }
845 _ => {}
846 }
847 }
848
849 if self.mode == WizardModalMode::MultiStep
850 && !modifiers.control
851 && !modifiers.alt
852 && !modifiers.command
853 && self.search.is_none()
854 && let KeyCode::Char(ch) = key.code
855 && ch.is_ascii_digit()
856 && ch != '0'
857 {
858 let target_index = ch.to_digit(10).unwrap_or(1).saturating_sub(1) as usize;
859 if let Some(step) = self.steps.get_mut(self.current_step)
860 && step.list.select_nth_selectable(target_index)
861 {
862 return self.submit_current_selection();
863 }
864 return ModalListKeyResult::HandledNoRedraw;
865 }
866
867 match key.code {
868 KeyCode::Char('n') | KeyCode::Char('N')
869 if modifiers.control && self.mode == WizardModalMode::MultiStep =>
870 {
871 if self.current_step < self.steps.len().saturating_sub(1) {
872 self.current_step += 1;
873 ModalListKeyResult::Redraw
874 } else {
875 ModalListKeyResult::HandledNoRedraw
876 }
877 }
878 KeyCode::Left => {
880 if self.current_step > 0 {
881 self.current_step -= 1;
882 ModalListKeyResult::Redraw
883 } else {
884 ModalListKeyResult::HandledNoRedraw
885 }
886 }
887 KeyCode::Right => {
889 let can_advance = match self.mode {
890 WizardModalMode::MultiStep => self.current_step_completed(),
891 WizardModalMode::TabbedList => true,
892 };
893
894 if can_advance && self.current_step < self.steps.len().saturating_sub(1) {
895 self.current_step += 1;
896 ModalListKeyResult::Redraw
897 } else {
898 ModalListKeyResult::HandledNoRedraw
899 }
900 }
901 KeyCode::Enter => self.submit_current_selection(),
903 KeyCode::Esc => ModalListKeyResult::Cancel(InlineEvent::WizardModalCancel),
905 KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => {
907 if let Some(step) = self.steps.get_mut(self.current_step) {
908 match key.code {
909 KeyCode::Up => {
910 if modifiers.command {
911 step.list.select_first();
912 } else {
913 step.list.select_previous();
914 }
915 ModalListKeyResult::Redraw
916 }
917 KeyCode::Down => {
918 if modifiers.command {
919 step.list.select_last();
920 } else {
921 step.list.select_next();
922 }
923 ModalListKeyResult::Redraw
924 }
925 KeyCode::Tab => {
926 if self.search.is_none() && step.allow_freeform {
927 step.notes_active = !step.notes_active;
928 ModalListKeyResult::Redraw
929 } else {
930 step.list.select_next();
931 ModalListKeyResult::Redraw
932 }
933 }
934 KeyCode::BackTab => {
935 step.list.select_previous();
936 ModalListKeyResult::Redraw
937 }
938 _ => ModalListKeyResult::NotHandled,
939 }
940 } else {
941 ModalListKeyResult::NotHandled
942 }
943 }
944 _ => ModalListKeyResult::NotHandled,
945 }
946 }
947
948 fn current_selection(&self) -> Option<InlineListSelection> {
950 self.steps
951 .get(self.current_step)
952 .and_then(|step| {
953 step.list
954 .current_selection()
955 .map(|selection| (selection, step))
956 })
957 .map(|(selection, step)| match selection {
958 InlineListSelection::RequestUserInputAnswer {
959 question_id,
960 selected,
961 other,
962 } => {
963 let notes = step.notes.trim();
964 let next_other = if other.is_some() {
965 Some(notes.to_string())
966 } else if notes.is_empty() {
967 None
968 } else {
969 Some(notes.to_string())
970 };
971 InlineListSelection::RequestUserInputAnswer {
972 question_id,
973 selected,
974 other: next_other,
975 }
976 }
977 InlineListSelection::AskUserChoice {
978 tab_id, choice_id, ..
979 } => {
980 let notes = step.notes.trim();
981 let text = if notes.is_empty() {
982 None
983 } else {
984 Some(notes.to_string())
985 };
986 InlineListSelection::AskUserChoice {
987 tab_id,
988 choice_id,
989 text,
990 }
991 }
992 _ => selection,
993 })
994 }
995
996 fn current_step_completed(&self) -> bool {
998 self.steps
999 .get(self.current_step)
1000 .is_some_and(|step| step.completed)
1001 }
1002
1003 fn current_step_supports_notes(&self) -> bool {
1004 self.steps
1005 .get(self.current_step)
1006 .and_then(|step| step.list.current_selection())
1007 .is_some_and(|selection| {
1008 matches!(
1009 selection,
1010 InlineListSelection::RequestUserInputAnswer { .. }
1011 | InlineListSelection::AskUserChoice { .. }
1012 )
1013 })
1014 }
1015
1016 pub fn unanswered_count(&self) -> usize {
1017 self.steps.iter().filter(|step| !step.completed).count()
1018 }
1019
1020 pub fn question_header(&self) -> String {
1021 format!(
1022 "Question {}/{} ({} unanswered)",
1023 self.current_step.saturating_add(1),
1024 self.steps.len(),
1025 self.unanswered_count()
1026 )
1027 }
1028
1029 pub fn notes_line(&self) -> Option<String> {
1030 let step = self.steps.get(self.current_step)?;
1031 if step.notes_active || !step.notes.is_empty() {
1032 let label = step.freeform_label.as_deref().unwrap_or("›");
1033 if step.notes.is_empty()
1034 && let Some(placeholder) = step.freeform_placeholder.as_ref()
1035 {
1036 return Some(format!("{} {}", label, placeholder));
1037 }
1038 Some(format!("{} {}", label, step.notes))
1039 } else {
1040 None
1041 }
1042 }
1043
1044 pub fn notes_active(&self) -> bool {
1045 self.steps
1046 .get(self.current_step)
1047 .is_some_and(|step| step.notes_active)
1048 }
1049
1050 pub fn instruction_lines(&self) -> Vec<String> {
1051 let step = match self.steps.get(self.current_step) {
1052 Some(s) => s,
1053 None => return Vec::new(),
1054 };
1055
1056 if self.notes_active() {
1057 vec!["tab or esc to clear notes | enter to submit answer".to_string()]
1058 } else {
1059 let mut lines = Vec::new();
1060 if step.allow_freeform {
1061 lines.push("tab to add notes | enter to submit answer".to_string());
1062 } else {
1063 lines.push("enter to submit answer".to_string());
1064 }
1065 lines.push("ctrl + n next question | esc to interrupt".to_string());
1066 lines
1067 }
1068 }
1069
1070 fn complete_current_step(&mut self, answer: InlineListSelection) {
1072 if let Some(step) = self.steps.get_mut(self.current_step) {
1073 step.completed = true;
1074 step.answer = Some(answer);
1075 }
1076 }
1077
1078 fn collect_answers(&self) -> Vec<InlineListSelection> {
1080 self.steps
1081 .iter()
1082 .filter_map(|step| step.answer.clone())
1083 .collect()
1084 }
1085
1086 fn submit_current_selection(&mut self) -> ModalListKeyResult {
1087 let Some(selection) = self.current_selection() else {
1088 return ModalListKeyResult::HandledNoRedraw;
1089 };
1090
1091 match self.mode {
1092 WizardModalMode::TabbedList => {
1093 ModalListKeyResult::Submit(InlineEvent::WizardModalSubmit(vec![selection]))
1094 }
1095 WizardModalMode::MultiStep => {
1096 self.complete_current_step(selection.clone());
1097 if self.current_step < self.steps.len().saturating_sub(1) {
1098 self.current_step += 1;
1099 ModalListKeyResult::Submit(InlineEvent::WizardModalStepComplete {
1100 step: self.current_step.saturating_sub(1),
1101 answer: selection,
1102 })
1103 } else {
1104 ModalListKeyResult::Submit(InlineEvent::WizardModalSubmit(
1105 self.collect_answers(),
1106 ))
1107 }
1108 }
1109 }
1110 }
1111
1112 pub fn all_steps_completed(&self) -> bool {
1114 self.steps.iter().all(|step| step.completed)
1115 }
1116}