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