Skip to main content

vtcode_tui/core_tui/session/modal/
state.rs

1use crate::config::constants::ui;
2use crate::ui::search::{exact_terms_match, normalize_query};
3use crate::ui::tui::types::{
4    InlineEvent, InlineListItem, InlineListSearchConfig, InlineListSelection, OverlayEvent,
5    OverlayHotkey, OverlayHotkeyAction, OverlayHotkeyKey, OverlaySelectionChange,
6    OverlaySubmission, SecurePromptConfig, WizardModalMode, WizardStep,
7};
8use ratatui::crossterm::event::{KeyCode, KeyEvent};
9use ratatui::widgets::ListState;
10use ratatui_cheese::input::InputState;
11
12#[derive(Clone)]
13pub struct ModalState {
14    pub title: String,
15    pub lines: Vec<String>,
16    pub footer_hint: Option<String>,
17    pub hotkeys: Vec<OverlayHotkey>,
18    pub list: Option<ModalListState>,
19    pub secure_prompt: Option<SecurePromptConfig>,
20    #[expect(dead_code)]
21    pub restore_input: bool,
22    #[expect(dead_code)]
23    pub restore_cursor: bool,
24    pub search: Option<ModalSearchState>,
25    pub is_help_modal: bool,
26}
27
28/// State for a multi-step wizard modal with tabs for navigation
29#[expect(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/// State for a single wizard step
40#[expect(dead_code)]
41#[derive(Clone)]
42pub struct WizardStepState {
43    /// Title displayed in the tab header
44    pub title: String,
45    /// Question or instruction shown above the list
46    pub question: String,
47    /// List state for selectable items
48    pub list: ModalListState,
49    /// Whether this step has been completed
50    pub completed: bool,
51    /// The selected answer for this step
52    pub answer: Option<InlineListSelection>,
53    /// Optional notes for the current step (free text)
54    pub notes: String,
55    /// Whether notes input is active for the current step
56    pub notes_active: bool,
57
58    pub allow_freeform: bool,
59    pub freeform_label: Option<String>,
60    pub freeform_placeholder: Option<String>,
61    pub freeform_default: Option<String>,
62}
63
64#[derive(Debug, Clone, Copy, Default)]
65pub struct ModalKeyModifiers {
66    pub control: bool,
67    pub alt: bool,
68    pub command: bool,
69}
70
71#[derive(Debug, Clone)]
72pub enum ModalListKeyResult {
73    NotHandled,
74    HandledNoRedraw,
75    Redraw,
76    Emit(InlineEvent),
77    Submit(InlineEvent),
78    Cancel(InlineEvent),
79}
80
81#[derive(Clone)]
82pub struct ModalListState {
83    pub items: Vec<ModalListItem>,
84    pub visible_indices: Vec<usize>,
85    pub list_state: ListState,
86    pub total_selectable: usize,
87    pub filter_terms: Vec<String>,
88    pub filter_query: Option<String>,
89    pub viewport_rows: Option<u16>,
90    pub compact_rows: bool,
91    density_behavior: ModalListDensityBehavior,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95enum ModalListDensityBehavior {
96    Adjustable,
97    FixedComfortable,
98}
99
100const CONFIG_LIST_NAVIGATION_HINT: &str =
101    "Navigation: ↑/↓ select • Space/Enter apply • ←/→ change value • Esc close";
102
103#[derive(Clone)]
104pub struct ModalListItem {
105    pub title: String,
106    pub subtitle: Option<String>,
107    pub badge: Option<String>,
108    pub indent: u8,
109    pub selection: Option<InlineListSelection>,
110    pub search_value: Option<String>,
111    pub is_divider: bool,
112}
113
114#[derive(Clone)]
115pub struct ModalSearchState {
116    pub label: String,
117    pub placeholder: Option<String>,
118    pub query: String,
119}
120
121impl From<InlineListSearchConfig> for ModalSearchState {
122    fn from(config: InlineListSearchConfig) -> Self {
123        Self {
124            label: config.label,
125            placeholder: config.placeholder,
126            query: String::new(),
127        }
128    }
129}
130
131impl ModalSearchState {
132    pub fn insert(&mut self, value: &str) {
133        for ch in value.chars() {
134            if matches!(ch, '\n' | '\r') {
135                continue;
136            }
137            self.query.push(ch);
138        }
139    }
140
141    pub fn push_char(&mut self, ch: char) {
142        self.query.push(ch);
143    }
144
145    pub fn backspace(&mut self) -> bool {
146        if self.query.pop().is_some() {
147            return true;
148        }
149        false
150    }
151
152    pub fn clear(&mut self) -> bool {
153        if self.query.is_empty() {
154            return false;
155        }
156        self.query.clear();
157        true
158    }
159}
160
161impl ModalState {
162    pub fn hotkey_action(
163        &self,
164        key: &KeyEvent,
165        modifiers: ModalKeyModifiers,
166    ) -> Option<OverlayHotkeyAction> {
167        self.hotkeys.iter().find_map(|hotkey| match hotkey.key {
168            OverlayHotkeyKey::CtrlChar(ch)
169                if modifiers.control
170                    && !modifiers.alt
171                    && !modifiers.command
172                    && matches!(key.code, KeyCode::Char(key_ch) if key_ch.eq_ignore_ascii_case(&ch))
173            =>
174            {
175                Some(hotkey.action)
176            }
177            OverlayHotkeyKey::Char(ch)
178                if !modifiers.control
179                    && !modifiers.alt
180                    && !modifiers.command
181                    && matches!(key.code, KeyCode::Char(key_ch) if key_ch.eq_ignore_ascii_case(&ch))
182            =>
183            {
184                Some(hotkey.action)
185            }
186            _ => None,
187        })
188    }
189
190    pub fn handle_list_key_event(
191        &mut self,
192        key: &KeyEvent,
193        modifiers: ModalKeyModifiers,
194    ) -> ModalListKeyResult {
195        let Some(list) = self.list.as_mut() else {
196            return ModalListKeyResult::NotHandled;
197        };
198
199        if let Some(search) = self.search.as_mut() {
200            match key.code {
201                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
202                    let previous = list.current_selection();
203                    search.push_char(ch);
204                    list.apply_search(&search.query);
205                    if let Some(event) = selection_change_event(list, previous) {
206                        return ModalListKeyResult::Emit(event);
207                    }
208                    return ModalListKeyResult::Redraw;
209                }
210                KeyCode::Backspace => {
211                    if search.backspace() {
212                        let previous = list.current_selection();
213                        list.apply_search(&search.query);
214                        if let Some(event) = selection_change_event(list, previous) {
215                            return ModalListKeyResult::Emit(event);
216                        }
217                        return ModalListKeyResult::Redraw;
218                    }
219                    return ModalListKeyResult::HandledNoRedraw;
220                }
221                KeyCode::Delete => {
222                    if search.clear() {
223                        let previous = list.current_selection();
224                        list.apply_search(&search.query);
225                        if let Some(event) = selection_change_event(list, previous) {
226                            return ModalListKeyResult::Emit(event);
227                        }
228                        return ModalListKeyResult::Redraw;
229                    }
230                    return ModalListKeyResult::HandledNoRedraw;
231                }
232                KeyCode::Esc => {
233                    if search.clear() {
234                        let previous = list.current_selection();
235                        list.apply_search(&search.query);
236                        if let Some(event) = selection_change_event(list, previous) {
237                            return ModalListKeyResult::Emit(event);
238                        }
239                        return ModalListKeyResult::Redraw;
240                    }
241                }
242                _ => {}
243            }
244        }
245
246        let previous_selection = list.current_selection();
247        match key.code {
248            KeyCode::Char('d') | KeyCode::Char('D') if modifiers.alt => {
249                if !list.supports_density_toggle() {
250                    return ModalListKeyResult::HandledNoRedraw;
251                }
252                list.toggle_row_density();
253                ModalListKeyResult::Redraw
254            }
255            KeyCode::Up => {
256                if modifiers.command {
257                    list.select_first();
258                } else {
259                    list.select_previous();
260                }
261                if let Some(event) = selection_change_event(list, previous_selection) {
262                    ModalListKeyResult::Emit(event)
263                } else {
264                    ModalListKeyResult::Redraw
265                }
266            }
267            KeyCode::Down => {
268                if modifiers.command {
269                    list.select_last();
270                } else {
271                    list.select_next();
272                }
273                if let Some(event) = selection_change_event(list, previous_selection) {
274                    ModalListKeyResult::Emit(event)
275                } else {
276                    ModalListKeyResult::Redraw
277                }
278            }
279            KeyCode::PageUp => {
280                list.page_up();
281                if let Some(event) = selection_change_event(list, previous_selection) {
282                    ModalListKeyResult::Emit(event)
283                } else {
284                    ModalListKeyResult::Redraw
285                }
286            }
287            KeyCode::PageDown => {
288                list.page_down();
289                if let Some(event) = selection_change_event(list, previous_selection) {
290                    ModalListKeyResult::Emit(event)
291                } else {
292                    ModalListKeyResult::Redraw
293                }
294            }
295            KeyCode::Home => {
296                list.select_first();
297                if let Some(event) = selection_change_event(list, previous_selection) {
298                    ModalListKeyResult::Emit(event)
299                } else {
300                    ModalListKeyResult::Redraw
301                }
302            }
303            KeyCode::End => {
304                list.select_last();
305                if let Some(event) = selection_change_event(list, previous_selection) {
306                    ModalListKeyResult::Emit(event)
307                } else {
308                    ModalListKeyResult::Redraw
309                }
310            }
311            KeyCode::Tab => {
312                // With no search active, Tab moves to first item for autocomplete behavior
313                // If search is active, we already handled it above
314                if self.search.is_none() && !list.visible_indices.is_empty() {
315                    list.select_first();
316                } else {
317                    list.select_next();
318                }
319                if let Some(event) = selection_change_event(list, previous_selection) {
320                    ModalListKeyResult::Emit(event)
321                } else {
322                    ModalListKeyResult::Redraw
323                }
324            }
325            KeyCode::BackTab => {
326                list.select_previous();
327                if let Some(event) = selection_change_event(list, previous_selection) {
328                    ModalListKeyResult::Emit(event)
329                } else {
330                    ModalListKeyResult::Redraw
331                }
332            }
333            KeyCode::Left => {
334                if let Some(selection) = list.current_selection()
335                    && let Some(adjusted) = map_config_selection_for_arrow(&selection, true)
336                {
337                    return ModalListKeyResult::Submit(InlineEvent::Overlay(
338                        OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
339                    ));
340                }
341                list.select_previous();
342                if let Some(event) = selection_change_event(list, previous_selection) {
343                    ModalListKeyResult::Emit(event)
344                } else {
345                    ModalListKeyResult::Redraw
346                }
347            }
348            KeyCode::Right => {
349                if let Some(selection) = list.current_selection()
350                    && let Some(adjusted) = map_config_selection_for_arrow(&selection, false)
351                {
352                    return ModalListKeyResult::Submit(InlineEvent::Overlay(
353                        OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
354                    ));
355                }
356                list.select_next();
357                if let Some(event) = selection_change_event(list, previous_selection) {
358                    ModalListKeyResult::Emit(event)
359                } else {
360                    ModalListKeyResult::Redraw
361                }
362            }
363            KeyCode::Enter => {
364                if let Some(selection) = list.current_selection() {
365                    ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
366                        OverlaySubmission::Selection(selection),
367                    )))
368                } else {
369                    ModalListKeyResult::HandledNoRedraw
370                }
371            }
372            KeyCode::Esc => {
373                ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
374            }
375            KeyCode::Char(ch) if modifiers.control || modifiers.alt => match ch {
376                'c' | 'C' if modifiers.control => {
377                    ModalListKeyResult::Cancel(InlineEvent::Interrupt)
378                }
379                'n' | 'N' | 'j' | 'J' => {
380                    list.select_next();
381                    if let Some(event) = selection_change_event(list, previous_selection) {
382                        ModalListKeyResult::Emit(event)
383                    } else {
384                        ModalListKeyResult::Redraw
385                    }
386                }
387                'p' | 'P' | 'k' | 'K' => {
388                    list.select_previous();
389                    if let Some(event) = selection_change_event(list, previous_selection) {
390                        ModalListKeyResult::Emit(event)
391                    } else {
392                        ModalListKeyResult::Redraw
393                    }
394                }
395                _ => ModalListKeyResult::NotHandled,
396            },
397            KeyCode::Char('\u{3}') => ModalListKeyResult::Cancel(InlineEvent::Interrupt),
398            _ => ModalListKeyResult::NotHandled,
399        }
400    }
401
402    pub fn handle_list_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
403        let Some(list) = self.list.as_mut() else {
404            return ModalListKeyResult::NotHandled;
405        };
406        let Some(&item_index) = list.visible_indices.get(visible_index) else {
407            return ModalListKeyResult::HandledNoRedraw;
408        };
409        if list
410            .items
411            .get(item_index)
412            .and_then(|item| item.selection.as_ref())
413            .is_none()
414        {
415            return ModalListKeyResult::HandledNoRedraw;
416        }
417
418        let previous_selection = list.current_selection();
419        if list.list_state.selected() == Some(visible_index) {
420            if let Some(selection) = list.current_selection() {
421                return ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
422                    OverlaySubmission::Selection(selection),
423                )));
424            }
425            return ModalListKeyResult::HandledNoRedraw;
426        }
427
428        list.list_state.select(Some(visible_index));
429        if let Some(rows) = list.viewport_rows {
430            list.ensure_visible(rows);
431        }
432        if let Some(event) = selection_change_event(list, previous_selection) {
433            ModalListKeyResult::Emit(event)
434        } else {
435            ModalListKeyResult::Redraw
436        }
437    }
438
439    pub fn handle_list_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
440        let Some(list) = self.list.as_mut() else {
441            return ModalListKeyResult::NotHandled;
442        };
443
444        let previous_selection = list.current_selection();
445        if down {
446            list.select_next();
447        } else {
448            list.select_previous();
449        }
450
451        if let Some(event) = selection_change_event(list, previous_selection) {
452            ModalListKeyResult::Emit(event)
453        } else {
454            ModalListKeyResult::Redraw
455        }
456    }
457}
458
459fn selection_change_event(
460    list: &ModalListState,
461    previous: Option<InlineListSelection>,
462) -> Option<InlineEvent> {
463    let current = list.current_selection();
464    if current == previous {
465        return None;
466    }
467    current.map(|selection| {
468        InlineEvent::Overlay(OverlayEvent::SelectionChanged(
469            OverlaySelectionChange::List(selection),
470        ))
471    })
472}
473
474fn is_custom_note_selection(selection: &InlineListSelection) -> bool {
475    matches!(
476        selection,
477        InlineListSelection::RequestUserInputAnswer {
478            selected,
479            other,
480            ..
481        } if selected.is_empty() && other.is_some()
482    )
483}
484
485fn map_config_selection_for_arrow(
486    selection: &InlineListSelection,
487    is_left: bool,
488) -> Option<InlineListSelection> {
489    let InlineListSelection::ConfigAction(action) = selection else {
490        return None;
491    };
492
493    if action.ends_with(":cycle") {
494        if is_left {
495            let key = action.trim_end_matches(":cycle");
496            return Some(InlineListSelection::ConfigAction(format!(
497                "{}:cycle_prev",
498                key
499            )));
500        }
501        return Some(selection.clone());
502    }
503
504    if action.ends_with(":inc") {
505        if is_left {
506            let key = action.trim_end_matches(":inc");
507            return Some(InlineListSelection::ConfigAction(format!("{}:dec", key)));
508        }
509        return Some(selection.clone());
510    }
511
512    if action.ends_with(":dec") {
513        if is_left {
514            return Some(selection.clone());
515        }
516        let key = action.trim_end_matches(":dec");
517        return Some(InlineListSelection::ConfigAction(format!("{}:inc", key)));
518    }
519
520    if action.ends_with(":toggle") {
521        let _ = is_left;
522        return Some(selection.clone());
523    }
524
525    None
526}
527
528impl ModalListItem {
529    pub fn is_header(&self) -> bool {
530        self.selection.is_none() && !self.is_divider
531    }
532
533    fn matches(&self, query: &str) -> bool {
534        if query.is_empty() {
535            return true;
536        }
537        let Some(value) = self.search_value.as_ref() else {
538            return false;
539        };
540        exact_terms_match(query, value)
541    }
542}
543
544#[expect(clippy::const_is_empty)]
545pub fn is_divider_title(item: &InlineListItem) -> bool {
546    if item.selection.is_some() {
547        return false;
548    }
549    if item.indent != 0 {
550        return false;
551    }
552    if item.subtitle.is_some() || item.badge.is_some() {
553        return false;
554    }
555    let symbol = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL;
556    if symbol.is_empty() {
557        return false;
558    }
559    item.title
560        .chars()
561        .all(|ch| symbol.chars().any(|needle| needle == ch))
562}
563
564impl ModalListState {
565    pub fn new(items: Vec<InlineListItem>, selected: Option<InlineListSelection>) -> Self {
566        let converted: Vec<ModalListItem> = items
567            .into_iter()
568            .map(|item| {
569                let is_divider = is_divider_title(&item);
570                let search_value = item
571                    .search_value
572                    .as_ref()
573                    .map(|value| value.to_ascii_lowercase());
574                ModalListItem {
575                    title: item.title,
576                    subtitle: item.subtitle,
577                    badge: item.badge,
578                    indent: item.indent,
579                    selection: item.selection,
580                    search_value,
581                    is_divider,
582                }
583            })
584            .collect();
585        let total_selectable = converted
586            .iter()
587            .filter(|item| item.selection.is_some())
588            .count();
589        let has_two_line_items = converted.iter().any(|item| {
590            item.subtitle
591                .as_ref()
592                .is_some_and(|subtitle| !subtitle.trim().is_empty())
593        });
594        let density_behavior = Self::density_behavior_for_items(&converted);
595        let is_model_picker_list = Self::is_model_picker_list(&converted);
596        let compact_rows =
597            Self::initial_compact_rows(density_behavior, has_two_line_items, is_model_picker_list);
598        let mut modal_state = Self {
599            visible_indices: (0..converted.len()).collect(),
600            items: converted,
601            list_state: ListState::default(),
602            total_selectable,
603            filter_terms: Vec::new(),
604            filter_query: None,
605            viewport_rows: None,
606            compact_rows,
607            density_behavior,
608        };
609        modal_state.select_initial(selected);
610        modal_state
611    }
612
613    fn density_behavior_for_items(items: &[ModalListItem]) -> ModalListDensityBehavior {
614        if items
615            .iter()
616            .any(|item| matches!(item.selection, Some(InlineListSelection::ConfigAction(_))))
617        {
618            ModalListDensityBehavior::FixedComfortable
619        } else {
620            ModalListDensityBehavior::Adjustable
621        }
622    }
623
624    fn initial_compact_rows(
625        density_behavior: ModalListDensityBehavior,
626        has_two_line_items: bool,
627        is_model_picker_list: bool,
628    ) -> bool {
629        if is_model_picker_list {
630            return false;
631        }
632        match density_behavior {
633            ModalListDensityBehavior::FixedComfortable => false,
634            ModalListDensityBehavior::Adjustable => has_two_line_items,
635        }
636    }
637
638    fn is_model_picker_list(items: &[ModalListItem]) -> bool {
639        let mut has_model_selection = false;
640        for item in items {
641            let Some(selection) = item.selection.as_ref() else {
642                continue;
643            };
644            match selection {
645                InlineListSelection::Model(_)
646                | InlineListSelection::DynamicModel(_)
647                | InlineListSelection::CustomProvider(_)
648                | InlineListSelection::RefreshDynamicModels
649                | InlineListSelection::Reasoning(_)
650                | InlineListSelection::DisableReasoning
651                | InlineListSelection::CustomModel => {
652                    has_model_selection = true;
653                }
654                _ => return false,
655            }
656        }
657        has_model_selection
658    }
659
660    pub fn current_selection(&self) -> Option<InlineListSelection> {
661        self.list_state
662            .selected()
663            .and_then(|index| self.visible_indices.get(index))
664            .and_then(|&item_index| self.items.get(item_index))
665            .and_then(|item| item.selection.clone())
666    }
667
668    pub fn get_best_matching_item(&self, query: &str) -> Option<String> {
669        if query.is_empty() {
670            return None;
671        }
672
673        let normalized_query = normalize_query(query);
674        self.visible_indices
675            .iter()
676            .filter_map(|&idx| self.items.get(idx))
677            .filter(|item| item.selection.is_some())
678            .filter_map(|item| item.search_value.as_ref())
679            .find(|search_value| exact_terms_match(&normalized_query, search_value))
680            .cloned()
681    }
682
683    pub fn select_previous(&mut self) {
684        if self.visible_indices.is_empty() {
685            return;
686        }
687        let Some(mut index) = self.list_state.selected() else {
688            if let Some(last) = self.last_selectable_index() {
689                self.list_state.select(Some(last));
690            }
691            return;
692        };
693
694        while index > 0 {
695            index -= 1;
696            let item_index = match self.visible_indices.get(index) {
697                Some(idx) => *idx,
698                None => {
699                    tracing::warn!("visible_indices index {index} out of bounds");
700                    continue;
701                }
702            };
703            if let Some(item) = self.items.get(item_index)
704                && item.selection.is_some()
705            {
706                self.list_state.select(Some(index));
707                return;
708            }
709        }
710
711        if let Some(first) = self.first_selectable_index() {
712            self.list_state.select(Some(first));
713        } else {
714            self.list_state.select(None);
715        }
716    }
717
718    pub fn select_next(&mut self) {
719        if self.visible_indices.is_empty() {
720            return;
721        }
722        let mut index = self.list_state.selected().unwrap_or(usize::MAX);
723        if index == usize::MAX {
724            if let Some(first) = self.first_selectable_index() {
725                self.list_state.select(Some(first));
726            }
727            return;
728        }
729        while index + 1 < self.visible_indices.len() {
730            index += 1;
731            let item_index = self.visible_indices[index];
732            if self.items[item_index].selection.is_some() {
733                self.list_state.select(Some(index));
734                break;
735            }
736        }
737    }
738
739    pub fn select_first(&mut self) {
740        if let Some(first) = self.first_selectable_index() {
741            self.list_state.select(Some(first));
742        } else {
743            self.list_state.select(None);
744        }
745        if let Some(rows) = self.viewport_rows {
746            self.ensure_visible(rows);
747        }
748    }
749
750    pub fn select_last(&mut self) {
751        if let Some(last) = self.last_selectable_index() {
752            self.list_state.select(Some(last));
753        } else {
754            self.list_state.select(None);
755        }
756        if let Some(rows) = self.viewport_rows {
757            self.ensure_visible(rows);
758        }
759    }
760
761    pub(crate) fn selected_is_last(&self) -> bool {
762        let Some(selected) = self.list_state.selected() else {
763            return false;
764        };
765        self.last_selectable_index()
766            .is_some_and(|last| selected == last)
767    }
768
769    pub fn select_nth_selectable(&mut self, target_index: usize) -> bool {
770        let mut count = 0usize;
771        for (visible_pos, &item_index) in self.visible_indices.iter().enumerate() {
772            if self.items[item_index].selection.is_some() {
773                if count == target_index {
774                    self.list_state.select(Some(visible_pos));
775                    if let Some(rows) = self.viewport_rows {
776                        self.ensure_visible(rows);
777                    }
778                    return true;
779                }
780                count += 1;
781            }
782        }
783        false
784    }
785
786    pub fn page_up(&mut self) {
787        let step = self.page_step();
788        if step == 0 {
789            self.select_previous();
790            return;
791        }
792        for _ in 0..step {
793            let before = self.list_state.selected();
794            self.select_previous();
795            if self.list_state.selected() == before {
796                break;
797            }
798        }
799    }
800
801    pub fn page_down(&mut self) {
802        let step = self.page_step();
803        if step == 0 {
804            self.select_next();
805            return;
806        }
807        for _ in 0..step {
808            let before = self.list_state.selected();
809            self.select_next();
810            if self.list_state.selected() == before {
811                break;
812            }
813        }
814    }
815
816    pub fn set_viewport_rows(&mut self, rows: u16) {
817        self.viewport_rows = Some(rows);
818    }
819
820    pub(super) fn ensure_visible(&mut self, viewport: u16) {
821        let Some(selected) = self.list_state.selected() else {
822            return;
823        };
824        if viewport == 0 {
825            return;
826        }
827        let visible = viewport as usize;
828        let offset = self.list_state.offset();
829        if selected < offset {
830            *self.list_state.offset_mut() = selected;
831        } else if selected >= offset + visible {
832            *self.list_state.offset_mut() = selected + 1 - visible;
833        }
834    }
835
836    pub fn apply_search(&mut self, query: &str) {
837        let preferred = self.current_selection();
838        self.apply_search_with_preference(query, preferred);
839    }
840
841    pub fn apply_search_with_preference(
842        &mut self,
843        query: &str,
844        preferred: Option<InlineListSelection>,
845    ) {
846        let trimmed = query.trim();
847        if trimmed.is_empty() {
848            if self.filter_query.is_none() {
849                if preferred.is_some() && self.current_selection() != preferred {
850                    self.select_initial(preferred);
851                }
852                return;
853            }
854            self.visible_indices = (0..self.items.len()).collect();
855            self.filter_terms.clear();
856            self.filter_query = None;
857            self.select_initial(preferred);
858            return;
859        }
860
861        if self.filter_query.as_deref() == Some(trimmed) {
862            if preferred.is_some() && self.current_selection() != preferred {
863                self.select_initial(preferred);
864            }
865            return;
866        }
867
868        let normalized_query = normalize_query(trimmed);
869        let terms = normalized_query
870            .split_whitespace()
871            .filter(|term| !term.is_empty())
872            .map(|term| term.to_owned())
873            .collect::<Vec<_>>();
874        let mut indices = Vec::new();
875        let mut pending_divider: Option<usize> = None;
876        let mut current_header: Option<usize> = None;
877        let mut header_matches = false;
878        let mut header_included = false;
879
880        for (index, item) in self.items.iter().enumerate() {
881            if item.is_divider {
882                pending_divider = Some(index);
883                current_header = None;
884                header_matches = false;
885                header_included = false;
886                continue;
887            }
888
889            if item.is_header() {
890                current_header = Some(index);
891                header_matches = item.matches(&normalized_query);
892                header_included = false;
893                if header_matches {
894                    if let Some(divider_index) = pending_divider.take() {
895                        indices.push(divider_index);
896                    }
897                    indices.push(index);
898                    header_included = true;
899                }
900                continue;
901            }
902
903            let item_matches = item.matches(&normalized_query);
904            let include_item = header_matches || item_matches;
905            if include_item {
906                if let Some(divider_index) = pending_divider.take() {
907                    indices.push(divider_index);
908                }
909                if let Some(header_index) = current_header
910                    && !header_included
911                {
912                    indices.push(header_index);
913                    header_included = true;
914                }
915                indices.push(index);
916            }
917        }
918        self.visible_indices = indices;
919        self.filter_terms = terms;
920        self.filter_query = Some(trimmed.to_owned());
921        self.select_initial(preferred);
922    }
923
924    fn select_initial(&mut self, preferred: Option<InlineListSelection>) {
925        let mut selection_index = preferred.and_then(|needle| {
926            self.visible_indices
927                .iter()
928                .position(|&idx| self.items[idx].selection.as_ref() == Some(&needle))
929        });
930
931        if selection_index.is_none() {
932            selection_index = self.first_selectable_index();
933        }
934
935        self.list_state.select(selection_index);
936        *self.list_state.offset_mut() = 0;
937    }
938
939    fn first_selectable_index(&self) -> Option<usize> {
940        self.visible_indices
941            .iter()
942            .position(|&idx| self.items[idx].selection.is_some())
943    }
944
945    fn last_selectable_index(&self) -> Option<usize> {
946        self.visible_indices
947            .iter()
948            .rposition(|&idx| self.items[idx].selection.is_some())
949    }
950
951    pub(super) fn filter_active(&self) -> bool {
952        self.filter_query
953            .as_ref()
954            .is_some_and(|value| !value.is_empty())
955    }
956
957    #[cfg(test)]
958    pub(super) fn filter_query(&self) -> Option<&str> {
959        self.filter_query.as_deref()
960    }
961
962    pub(super) fn highlight_terms(&self) -> &[String] {
963        &self.filter_terms
964    }
965
966    pub(super) fn visible_selectable_count(&self) -> usize {
967        self.visible_indices
968            .iter()
969            .filter(|&&idx| self.items[idx].selection.is_some())
970            .count()
971    }
972
973    pub(super) fn total_selectable(&self) -> usize {
974        self.total_selectable
975    }
976
977    pub(super) fn compact_rows(&self) -> bool {
978        self.compact_rows
979    }
980
981    pub(super) fn supports_density_toggle(&self) -> bool {
982        matches!(self.density_behavior, ModalListDensityBehavior::Adjustable)
983    }
984
985    pub(super) fn non_filter_summary_text(&self, footer_hint: Option<&str>) -> Option<String> {
986        if !self.has_non_filter_summary(footer_hint) {
987            return None;
988        }
989        match self.density_behavior {
990            ModalListDensityBehavior::FixedComfortable => {
991                Some(CONFIG_LIST_NAVIGATION_HINT.to_owned())
992            }
993            ModalListDensityBehavior::Adjustable => footer_hint
994                .filter(|hint| !hint.is_empty())
995                .map(ToOwned::to_owned),
996        }
997    }
998
999    pub(crate) fn summary_line_rows(&self, footer_hint: Option<&str>) -> usize {
1000        if self.filter_active() || self.has_non_filter_summary(footer_hint) {
1001            1
1002        } else {
1003            0
1004        }
1005    }
1006
1007    fn has_non_filter_summary(&self, footer_hint: Option<&str>) -> bool {
1008        match self.density_behavior {
1009            ModalListDensityBehavior::FixedComfortable => true,
1010            ModalListDensityBehavior::Adjustable => {
1011                footer_hint.is_some_and(|hint| !hint.is_empty())
1012            }
1013        }
1014    }
1015
1016    pub fn toggle_row_density(&mut self) {
1017        self.compact_rows = !self.compact_rows;
1018    }
1019
1020    fn page_step(&self) -> usize {
1021        let rows = self.viewport_rows.unwrap_or(0).max(1);
1022        usize::from(rows)
1023    }
1024}
1025
1026#[expect(dead_code)]
1027impl WizardModalState {
1028    /// Create a new wizard modal state from wizard steps
1029    pub fn new(
1030        title: String,
1031        steps: Vec<WizardStep>,
1032        current_step: usize,
1033        search: Option<InlineListSearchConfig>,
1034        mode: WizardModalMode,
1035    ) -> Self {
1036        let step_states: Vec<WizardStepState> = steps
1037            .into_iter()
1038            .map(|step| {
1039                let notes_active = step
1040                    .items
1041                    .first()
1042                    .and_then(|item| item.selection.as_ref())
1043                    .is_some_and(|selection| match selection {
1044                        InlineListSelection::RequestUserInputAnswer {
1045                            selected, other, ..
1046                        } => selected.is_empty() && other.is_some(),
1047                        _ => false,
1048                    });
1049                WizardStepState {
1050                    title: step.title,
1051                    question: step.question,
1052                    list: ModalListState::new(step.items, step.answer.clone()),
1053                    completed: step.completed,
1054                    answer: step.answer,
1055                    notes: String::new(),
1056                    notes_active,
1057                    allow_freeform: step.allow_freeform,
1058                    freeform_label: step.freeform_label,
1059                    freeform_placeholder: step.freeform_placeholder,
1060                    freeform_default: step.freeform_default,
1061                }
1062            })
1063            .collect();
1064
1065        let clamped_step = if step_states.is_empty() {
1066            0
1067        } else {
1068            current_step.min(step_states.len().saturating_sub(1))
1069        };
1070
1071        Self {
1072            title,
1073            steps: step_states,
1074            current_step: clamped_step,
1075            search: search.map(ModalSearchState::from),
1076            mode,
1077        }
1078    }
1079
1080    /// Handle key event for wizard navigation
1081    pub fn handle_key_event(
1082        &mut self,
1083        key: &KeyEvent,
1084        modifiers: ModalKeyModifiers,
1085    ) -> ModalListKeyResult {
1086        if let Some(step) = self.steps.get_mut(self.current_step)
1087            && step.notes_active
1088        {
1089            match key.code {
1090                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1091                    let mut state = InputState::new();
1092                    state.set_value(step.notes.clone());
1093                    state.end();
1094                    state.insert_char(ch);
1095                    step.notes = state.value().to_owned();
1096                    return ModalListKeyResult::Redraw;
1097                }
1098                KeyCode::Backspace => {
1099                    if !step.notes.is_empty() {
1100                        let mut state = InputState::new();
1101                        state.set_value(step.notes.clone());
1102                        state.end();
1103                        state.delete_before();
1104                        step.notes = state.value().to_owned();
1105                        return ModalListKeyResult::Redraw;
1106                    }
1107                    return ModalListKeyResult::HandledNoRedraw;
1108                }
1109                KeyCode::Tab | KeyCode::Esc => {
1110                    if !step.notes.is_empty() {
1111                        step.notes.clear();
1112                    }
1113                    step.notes_active = false;
1114                    return ModalListKeyResult::Redraw;
1115                }
1116                _ => {}
1117            }
1118        }
1119
1120        if let Some(step) = self.steps.get_mut(self.current_step)
1121            && !step.notes_active
1122            && Self::step_selected_custom_note_item_index(step).is_some()
1123        {
1124            match key.code {
1125                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1126                    step.notes_active = true;
1127                    let mut state = InputState::new();
1128                    state.set_value(step.notes.clone());
1129                    state.end();
1130                    state.insert_char(ch);
1131                    step.notes = state.value().to_owned();
1132                    return ModalListKeyResult::Redraw;
1133                }
1134                KeyCode::Backspace => {
1135                    if !step.notes.is_empty() {
1136                        step.notes_active = true;
1137                        let mut state = InputState::new();
1138                        state.set_value(step.notes.clone());
1139                        state.end();
1140                        state.delete_before();
1141                        step.notes = state.value().to_owned();
1142                        return ModalListKeyResult::Redraw;
1143                    }
1144                }
1145                _ => {}
1146            }
1147        }
1148
1149        // Search handling (if enabled)
1150        if let Some(search) = self.search.as_mut()
1151            && let Some(step) = self.steps.get_mut(self.current_step)
1152        {
1153            match key.code {
1154                KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
1155                    search.push_char(ch);
1156                    step.list.apply_search(&search.query);
1157                    return ModalListKeyResult::Redraw;
1158                }
1159                KeyCode::Backspace => {
1160                    if search.backspace() {
1161                        step.list.apply_search(&search.query);
1162                        return ModalListKeyResult::Redraw;
1163                    }
1164                    return ModalListKeyResult::HandledNoRedraw;
1165                }
1166                KeyCode::Delete => {
1167                    if search.clear() {
1168                        step.list.apply_search(&search.query);
1169                        return ModalListKeyResult::Redraw;
1170                    }
1171                    return ModalListKeyResult::HandledNoRedraw;
1172                }
1173                KeyCode::Tab => {
1174                    if let Some(best_match) = step.list.get_best_matching_item(&search.query) {
1175                        search.query = best_match;
1176                        step.list.apply_search(&search.query);
1177                        return ModalListKeyResult::Redraw;
1178                    }
1179                    return ModalListKeyResult::HandledNoRedraw;
1180                }
1181                KeyCode::Esc => {
1182                    if search.clear() {
1183                        step.list.apply_search(&search.query);
1184                        return ModalListKeyResult::Redraw;
1185                    }
1186                }
1187                _ => {}
1188            }
1189        }
1190
1191        if self.mode == WizardModalMode::MultiStep
1192            && !modifiers.control
1193            && !modifiers.alt
1194            && !modifiers.command
1195            && self.search.is_none()
1196            && let KeyCode::Char(ch) = key.code
1197            && ch.is_ascii_digit()
1198            && ch != '0'
1199        {
1200            let target_index = ch.to_digit(10).unwrap_or(1).saturating_sub(1) as usize;
1201            if let Some(step) = self.steps.get_mut(self.current_step)
1202                && step.list.select_nth_selectable(target_index)
1203            {
1204                return self.submit_current_selection();
1205            }
1206            return ModalListKeyResult::HandledNoRedraw;
1207        }
1208
1209        match key.code {
1210            KeyCode::Char('n') | KeyCode::Char('N')
1211                if modifiers.control && self.mode == WizardModalMode::MultiStep =>
1212            {
1213                if self.current_step < self.steps.len().saturating_sub(1) {
1214                    self.current_step += 1;
1215                    ModalListKeyResult::Redraw
1216                } else {
1217                    ModalListKeyResult::HandledNoRedraw
1218                }
1219            }
1220            // Left arrow: go to previous step if available
1221            KeyCode::Left => {
1222                if self.current_step > 0 {
1223                    self.current_step -= 1;
1224                    ModalListKeyResult::Redraw
1225                } else {
1226                    ModalListKeyResult::HandledNoRedraw
1227                }
1228            }
1229            // Right arrow: go to next step if current is completed
1230            KeyCode::Right => {
1231                let can_advance = match self.mode {
1232                    WizardModalMode::MultiStep => self.current_step_completed(),
1233                    WizardModalMode::TabbedList => true,
1234                };
1235
1236                if can_advance && self.current_step < self.steps.len().saturating_sub(1) {
1237                    self.current_step += 1;
1238                    ModalListKeyResult::Redraw
1239                } else {
1240                    ModalListKeyResult::HandledNoRedraw
1241                }
1242            }
1243            // Enter: select current item and mark step complete
1244            KeyCode::Enter => self.submit_current_selection(),
1245            // Escape or Ctrl+C: cancel wizard
1246            KeyCode::Esc => {
1247                ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
1248            }
1249            KeyCode::Char('c') | KeyCode::Char('C') if modifiers.control => {
1250                ModalListKeyResult::Cancel(InlineEvent::Interrupt)
1251            }
1252            KeyCode::Char('\u{3}') => ModalListKeyResult::Cancel(InlineEvent::Interrupt),
1253            // Up/Down/Tab: delegate to current step's list
1254            KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => {
1255                if let Some(step) = self.steps.get_mut(self.current_step) {
1256                    match key.code {
1257                        KeyCode::Up => {
1258                            if modifiers.command {
1259                                step.list.select_first();
1260                            } else {
1261                                step.list.select_previous();
1262                            }
1263                            ModalListKeyResult::Redraw
1264                        }
1265                        KeyCode::Down => {
1266                            if modifiers.command {
1267                                step.list.select_last();
1268                            } else {
1269                                step.list.select_next();
1270                            }
1271                            ModalListKeyResult::Redraw
1272                        }
1273                        KeyCode::Tab => {
1274                            if self.search.is_none()
1275                                && (step.allow_freeform
1276                                    || Self::step_selected_custom_note_item_index(step).is_some())
1277                            {
1278                                step.notes_active = !step.notes_active;
1279                                ModalListKeyResult::Redraw
1280                            } else {
1281                                step.list.select_next();
1282                                ModalListKeyResult::Redraw
1283                            }
1284                        }
1285                        KeyCode::BackTab => {
1286                            step.list.select_previous();
1287                            ModalListKeyResult::Redraw
1288                        }
1289                        _ => ModalListKeyResult::NotHandled,
1290                    }
1291                } else {
1292                    ModalListKeyResult::NotHandled
1293                }
1294            }
1295            _ => ModalListKeyResult::NotHandled,
1296        }
1297    }
1298
1299    pub fn handle_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
1300        let submit_after_click = {
1301            let Some(step) = self.steps.get_mut(self.current_step) else {
1302                return ModalListKeyResult::NotHandled;
1303            };
1304            let Some(&item_index) = step.list.visible_indices.get(visible_index) else {
1305                return ModalListKeyResult::HandledNoRedraw;
1306            };
1307            let Some(item) = step.list.items.get(item_index) else {
1308                return ModalListKeyResult::HandledNoRedraw;
1309            };
1310            let Some(selection) = item.selection.as_ref() else {
1311                return ModalListKeyResult::HandledNoRedraw;
1312            };
1313
1314            let clicked_custom_note = is_custom_note_selection(selection);
1315            let already_selected = step.list.list_state.selected() == Some(visible_index);
1316
1317            if self.mode == WizardModalMode::TabbedList {
1318                step.list.list_state.select(Some(visible_index));
1319                if let Some(rows) = step.list.viewport_rows {
1320                    step.list.ensure_visible(rows);
1321                }
1322
1323                if clicked_custom_note
1324                    && step.notes.trim().is_empty()
1325                    && !step.has_freeform_default()
1326                {
1327                    step.notes_active = true;
1328                    return ModalListKeyResult::Redraw;
1329                }
1330
1331                true
1332            } else if already_selected {
1333                true
1334            } else {
1335                step.list.list_state.select(Some(visible_index));
1336                if let Some(rows) = step.list.viewport_rows {
1337                    step.list.ensure_visible(rows);
1338                }
1339                return ModalListKeyResult::Redraw;
1340            }
1341        };
1342
1343        if submit_after_click {
1344            return self.submit_current_selection();
1345        }
1346
1347        ModalListKeyResult::Redraw
1348    }
1349
1350    pub fn handle_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
1351        let Some(step) = self.steps.get_mut(self.current_step) else {
1352            return ModalListKeyResult::NotHandled;
1353        };
1354
1355        let before = step.list.list_state.selected();
1356        if down {
1357            step.list.select_next();
1358        } else {
1359            step.list.select_previous();
1360        }
1361
1362        if step.list.list_state.selected() == before {
1363            ModalListKeyResult::HandledNoRedraw
1364        } else {
1365            ModalListKeyResult::Redraw
1366        }
1367    }
1368
1369    /// Get current selection from the active step
1370    fn current_selection(&self) -> Option<InlineListSelection> {
1371        self.steps
1372            .get(self.current_step)
1373            .and_then(|step| {
1374                step.list
1375                    .current_selection()
1376                    .map(|selection| (selection, step))
1377            })
1378            .map(|(selection, step)| match selection {
1379                InlineListSelection::RequestUserInputAnswer {
1380                    question_id,
1381                    selected,
1382                    other,
1383                } => {
1384                    let next_other = if other.is_some() {
1385                        step.submitted_freeform_value()
1386                    } else if step.notes.trim().is_empty() {
1387                        None
1388                    } else {
1389                        Some(step.notes.trim().to_string())
1390                    };
1391                    InlineListSelection::RequestUserInputAnswer {
1392                        question_id,
1393                        selected,
1394                        other: next_other,
1395                    }
1396                }
1397                InlineListSelection::AskUserChoice {
1398                    tab_id, choice_id, ..
1399                } => {
1400                    let notes = step.notes.trim();
1401                    let text = if notes.is_empty() {
1402                        None
1403                    } else {
1404                        Some(notes.to_string())
1405                    };
1406                    InlineListSelection::AskUserChoice {
1407                        tab_id,
1408                        choice_id,
1409                        text,
1410                    }
1411                }
1412                _ => selection,
1413            })
1414    }
1415
1416    /// Check if current step is completed
1417    fn current_step_completed(&self) -> bool {
1418        self.steps
1419            .get(self.current_step)
1420            .is_some_and(|step| step.completed)
1421    }
1422
1423    fn step_selected_custom_note_item_index(step: &WizardStepState) -> Option<usize> {
1424        let selected_visible = step.list.list_state.selected()?;
1425        let item_index = *step.list.visible_indices.get(selected_visible)?;
1426        let item = step.list.items.get(item_index)?;
1427        item.selection
1428            .as_ref()
1429            .filter(|selection| is_custom_note_selection(selection))
1430            .map(|_| item_index)
1431    }
1432
1433    fn current_step_selected_custom_note_item_index(&self) -> Option<usize> {
1434        self.steps
1435            .get(self.current_step)
1436            .and_then(Self::step_selected_custom_note_item_index)
1437    }
1438
1439    fn current_step_requires_custom_note_input(&self) -> bool {
1440        self.current_step_selected_custom_note_item_index()
1441            .is_some()
1442    }
1443
1444    fn current_step_supports_notes(&self) -> bool {
1445        self.steps
1446            .get(self.current_step)
1447            .and_then(|step| step.list.current_selection())
1448            .is_some_and(|selection| {
1449                matches!(
1450                    selection,
1451                    InlineListSelection::RequestUserInputAnswer { .. }
1452                        | InlineListSelection::AskUserChoice { .. }
1453                )
1454            })
1455    }
1456
1457    fn current_step_has_freeform_default(&self) -> bool {
1458        self.steps
1459            .get(self.current_step)
1460            .is_some_and(WizardStepState::has_freeform_default)
1461    }
1462
1463    pub fn unanswered_count(&self) -> usize {
1464        self.steps.iter().filter(|step| !step.completed).count()
1465    }
1466
1467    pub fn question_header(&self) -> String {
1468        format!(
1469            "Question {}/{} ({} unanswered)",
1470            self.current_step.saturating_add(1),
1471            self.steps.len(),
1472            self.unanswered_count()
1473        )
1474    }
1475
1476    pub fn notes_line(&self) -> Option<String> {
1477        let step = self.steps.get(self.current_step)?;
1478        if step.notes_active || !step.notes.is_empty() {
1479            let label = step.freeform_label.as_deref().unwrap_or("›");
1480            if step.notes.is_empty()
1481                && let Some(placeholder) = step.freeform_placeholder.as_ref()
1482            {
1483                return Some(format!("{} {}", label, placeholder));
1484            }
1485            Some(format!("{} {}", label, step.notes))
1486        } else {
1487            None
1488        }
1489    }
1490
1491    pub fn notes_active(&self) -> bool {
1492        self.steps
1493            .get(self.current_step)
1494            .is_some_and(|step| step.notes_active)
1495    }
1496
1497    pub fn instruction_lines(&self) -> Vec<String> {
1498        let step = match self.steps.get(self.current_step) {
1499            Some(s) => s,
1500            None => return Vec::new(),
1501        };
1502        let custom_note_selected = self.current_step_requires_custom_note_input();
1503
1504        if self.notes_active() {
1505            if custom_note_selected {
1506                vec![if self.current_step_has_freeform_default() {
1507                    "type custom note | enter to submit or accept default | esc to clear"
1508                        .to_string()
1509                } else {
1510                    "type custom note | enter to continue | esc to clear".to_string()
1511                }]
1512            } else {
1513                vec!["tab or esc to clear notes | enter to submit answer".to_string()]
1514            }
1515        } else {
1516            let mut lines = Vec::new();
1517            if custom_note_selected {
1518                lines.push(if self.current_step_has_freeform_default() {
1519                    "type custom note | enter to accept default".to_string()
1520                } else {
1521                    "type custom note | enter to continue".to_string()
1522                });
1523            } else if step.allow_freeform {
1524                lines.push("tab to add notes | enter to submit answer".to_string());
1525            } else {
1526                lines.push("enter to submit answer".to_string());
1527            }
1528            lines.push("ctrl + n next question | esc to interrupt".to_string());
1529            lines
1530        }
1531    }
1532
1533    /// Mark current step as completed with the given answer
1534    fn complete_current_step(&mut self, answer: InlineListSelection) {
1535        if let Some(step) = self.steps.get_mut(self.current_step) {
1536            step.completed = true;
1537            step.answer = Some(answer);
1538        }
1539    }
1540
1541    /// Collect all answers from completed steps
1542    fn collect_answers(&self) -> Vec<InlineListSelection> {
1543        self.steps
1544            .iter()
1545            .filter_map(|step| step.answer.clone())
1546            .collect()
1547    }
1548
1549    fn submit_current_selection(&mut self) -> ModalListKeyResult {
1550        if self.current_step_requires_custom_note_input()
1551            && let Some(step) = self.steps.get_mut(self.current_step)
1552            && step.notes.trim().is_empty()
1553            && !step.has_freeform_default()
1554        {
1555            step.notes_active = true;
1556            return ModalListKeyResult::Redraw;
1557        }
1558
1559        let Some(selection) = self.current_selection() else {
1560            return ModalListKeyResult::HandledNoRedraw;
1561        };
1562
1563        match self.mode {
1564            WizardModalMode::TabbedList => ModalListKeyResult::Submit(InlineEvent::Overlay(
1565                OverlayEvent::Submitted(OverlaySubmission::Wizard(vec![selection])),
1566            )),
1567            WizardModalMode::MultiStep => {
1568                self.complete_current_step(selection);
1569                if self.current_step < self.steps.len().saturating_sub(1) {
1570                    self.current_step += 1;
1571                    ModalListKeyResult::Redraw
1572                } else {
1573                    ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
1574                        OverlaySubmission::Wizard(self.collect_answers()),
1575                    )))
1576                }
1577            }
1578        }
1579    }
1580
1581    /// Check if all steps are completed
1582    pub fn all_steps_completed(&self) -> bool {
1583        self.steps.iter().all(|step| step.completed)
1584    }
1585}
1586
1587impl WizardStepState {
1588    fn has_freeform_default(&self) -> bool {
1589        self.freeform_default.is_some()
1590    }
1591
1592    fn submitted_freeform_value(&self) -> Option<String> {
1593        let notes = self.notes.trim();
1594        if !notes.is_empty() {
1595            return Some(notes.to_string());
1596        }
1597
1598        self.freeform_default.clone()
1599    }
1600}