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