Skip to main content

vtcode_tui/core_tui/session/modal/
state.rs

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