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