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