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