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