Skip to main content

vtcode_tui/core_tui/session/modal/
state.rs

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