Skip to main content

vtcode_ui/tui/core_tui/session/modal/
state.rs

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