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