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