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