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#[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#[allow(dead_code)]
39#[derive(Clone)]
40pub struct WizardStepState {
41 pub title: String,
43 pub question: String,
45 pub list: ModalListState,
47 pub completed: bool,
49 pub answer: Option<InlineListSelection>,
51 pub notes: String,
53 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 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 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 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 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 KeyCode::Left => {
1187 if self.current_step > 0 {
1188 self.current_step -= 1;
1189 ModalListKeyResult::Redraw
1190 } else {
1191 ModalListKeyResult::HandledNoRedraw
1192 }
1193 }
1194 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 KeyCode::Enter => self.submit_current_selection(),
1210 KeyCode::Esc => {
1212 ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
1213 }
1214 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 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 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 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 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 pub fn all_steps_completed(&self) -> bool {
1529 self.steps.iter().all(|step| step.completed)
1530 }
1531}