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