Skip to main content

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

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