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