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