use crate::config::constants::ui;
use crate::ui::search::{exact_terms_match, normalize_query};
use crate::ui::tui::types::{
InlineEvent, InlineListItem, InlineListSearchConfig, InlineListSelection, OverlayEvent,
OverlayHotkey, OverlayHotkeyAction, OverlayHotkeyKey, OverlaySelectionChange,
OverlaySubmission, SecurePromptConfig, WizardModalMode, WizardStep,
};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::widgets::ListState;
#[derive(Clone)]
pub struct ModalState {
pub title: String,
pub lines: Vec<String>,
pub footer_hint: Option<String>,
pub hotkeys: Vec<OverlayHotkey>,
pub list: Option<ModalListState>,
pub secure_prompt: Option<SecurePromptConfig>,
#[allow(dead_code)]
pub restore_input: bool,
#[allow(dead_code)]
pub restore_cursor: bool,
pub search: Option<ModalSearchState>,
}
#[allow(dead_code)]
#[derive(Clone)]
pub struct WizardModalState {
pub title: String,
pub steps: Vec<WizardStepState>,
pub current_step: usize,
pub search: Option<ModalSearchState>,
pub mode: WizardModalMode,
}
#[allow(dead_code)]
#[derive(Clone)]
pub struct WizardStepState {
pub title: String,
pub question: String,
pub list: ModalListState,
pub completed: bool,
pub answer: Option<InlineListSelection>,
pub notes: String,
pub notes_active: bool,
pub allow_freeform: bool,
pub freeform_label: Option<String>,
pub freeform_placeholder: Option<String>,
pub freeform_default: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ModalKeyModifiers {
pub control: bool,
pub alt: bool,
pub command: bool,
}
#[derive(Debug, Clone)]
pub enum ModalListKeyResult {
NotHandled,
HandledNoRedraw,
Redraw,
Emit(InlineEvent),
Submit(InlineEvent),
Cancel(InlineEvent),
}
#[derive(Clone)]
pub struct ModalListState {
pub items: Vec<ModalListItem>,
pub visible_indices: Vec<usize>,
pub list_state: ListState,
pub total_selectable: usize,
pub filter_terms: Vec<String>,
pub filter_query: Option<String>,
pub viewport_rows: Option<u16>,
pub compact_rows: bool,
density_behavior: ModalListDensityBehavior,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModalListDensityBehavior {
Adjustable,
FixedComfortable,
}
const CONFIG_LIST_NAVIGATION_HINT: &str =
"Navigation: ↑/↓ select • Space/Enter apply • ←/→ change value • Esc close";
#[derive(Clone)]
pub struct ModalListItem {
pub title: String,
pub subtitle: Option<String>,
pub badge: Option<String>,
pub indent: u8,
pub selection: Option<InlineListSelection>,
pub search_value: Option<String>,
pub is_divider: bool,
}
#[derive(Clone)]
pub struct ModalSearchState {
pub label: String,
pub placeholder: Option<String>,
pub query: String,
}
impl From<InlineListSearchConfig> for ModalSearchState {
fn from(config: InlineListSearchConfig) -> Self {
Self {
label: config.label,
placeholder: config.placeholder,
query: String::new(),
}
}
}
impl ModalSearchState {
pub fn insert(&mut self, value: &str) {
for ch in value.chars() {
if matches!(ch, '\n' | '\r') {
continue;
}
self.query.push(ch);
}
}
pub fn push_char(&mut self, ch: char) {
self.query.push(ch);
}
pub fn backspace(&mut self) -> bool {
if self.query.pop().is_some() {
return true;
}
false
}
pub fn clear(&mut self) -> bool {
if self.query.is_empty() {
return false;
}
self.query.clear();
true
}
}
impl ModalState {
pub fn hotkey_action(
&self,
key: &KeyEvent,
modifiers: ModalKeyModifiers,
) -> Option<OverlayHotkeyAction> {
self.hotkeys.iter().find_map(|hotkey| match hotkey.key {
OverlayHotkeyKey::CtrlChar(ch)
if modifiers.control
&& !modifiers.alt
&& !modifiers.command
&& matches!(key.code, KeyCode::Char(key_ch) if key_ch.eq_ignore_ascii_case(&ch))
=>
{
Some(hotkey.action.clone())
}
OverlayHotkeyKey::Char(ch)
if !modifiers.control
&& !modifiers.alt
&& !modifiers.command
&& matches!(key.code, KeyCode::Char(key_ch) if key_ch.eq_ignore_ascii_case(&ch))
=>
{
Some(hotkey.action.clone())
}
_ => None,
})
}
pub fn handle_list_key_event(
&mut self,
key: &KeyEvent,
modifiers: ModalKeyModifiers,
) -> ModalListKeyResult {
let Some(list) = self.list.as_mut() else {
return ModalListKeyResult::NotHandled;
};
if let Some(search) = self.search.as_mut() {
match key.code {
KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
let previous = list.current_selection();
search.push_char(ch);
list.apply_search(&search.query);
if let Some(event) = selection_change_event(list, previous) {
return ModalListKeyResult::Emit(event);
}
return ModalListKeyResult::Redraw;
}
KeyCode::Backspace => {
if search.backspace() {
let previous = list.current_selection();
list.apply_search(&search.query);
if let Some(event) = selection_change_event(list, previous) {
return ModalListKeyResult::Emit(event);
}
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Delete => {
if search.clear() {
let previous = list.current_selection();
list.apply_search(&search.query);
if let Some(event) = selection_change_event(list, previous) {
return ModalListKeyResult::Emit(event);
}
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Esc => {
if search.clear() {
let previous = list.current_selection();
list.apply_search(&search.query);
if let Some(event) = selection_change_event(list, previous) {
return ModalListKeyResult::Emit(event);
}
return ModalListKeyResult::Redraw;
}
}
_ => {}
}
}
let previous_selection = list.current_selection();
match key.code {
KeyCode::Char('d') | KeyCode::Char('D') if modifiers.alt => {
if !list.supports_density_toggle() {
return ModalListKeyResult::HandledNoRedraw;
}
list.toggle_row_density();
ModalListKeyResult::Redraw
}
KeyCode::Up => {
if modifiers.command {
list.select_first();
} else {
list.select_previous();
}
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Down => {
if modifiers.command {
list.select_last();
} else {
list.select_next();
}
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::PageUp => {
list.page_up();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::PageDown => {
list.page_down();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Home => {
list.select_first();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::End => {
list.select_last();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Tab => {
if self.search.is_none() && !list.visible_indices.is_empty() {
list.select_first();
} else {
list.select_next();
}
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::BackTab => {
list.select_previous();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Left => {
if let Some(selection) = list.current_selection()
&& let Some(adjusted) = map_config_selection_for_arrow(&selection, true)
{
return ModalListKeyResult::Submit(InlineEvent::Overlay(
OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
));
}
list.select_previous();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Right => {
if let Some(selection) = list.current_selection()
&& let Some(adjusted) = map_config_selection_for_arrow(&selection, false)
{
return ModalListKeyResult::Submit(InlineEvent::Overlay(
OverlayEvent::Submitted(OverlaySubmission::Selection(adjusted)),
));
}
list.select_next();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
KeyCode::Enter => {
if let Some(selection) = list.current_selection() {
ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Selection(selection),
)))
} else {
ModalListKeyResult::HandledNoRedraw
}
}
KeyCode::Esc => {
ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
}
KeyCode::Char(ch) if modifiers.control || modifiers.alt => match ch {
'c' | 'C' if modifiers.control => {
ModalListKeyResult::Cancel(InlineEvent::Interrupt)
}
'n' | 'N' | 'j' | 'J' => {
list.select_next();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
'p' | 'P' | 'k' | 'K' => {
list.select_previous();
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
_ => ModalListKeyResult::NotHandled,
},
KeyCode::Char('\u{3}') => ModalListKeyResult::Cancel(InlineEvent::Interrupt),
_ => ModalListKeyResult::NotHandled,
}
}
pub fn handle_list_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
let Some(list) = self.list.as_mut() else {
return ModalListKeyResult::NotHandled;
};
let Some(&item_index) = list.visible_indices.get(visible_index) else {
return ModalListKeyResult::HandledNoRedraw;
};
if list
.items
.get(item_index)
.and_then(|item| item.selection.as_ref())
.is_none()
{
return ModalListKeyResult::HandledNoRedraw;
}
let previous_selection = list.current_selection();
if list.list_state.selected() == Some(visible_index) {
if let Some(selection) = list.current_selection() {
return ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Selection(selection),
)));
}
return ModalListKeyResult::HandledNoRedraw;
}
list.list_state.select(Some(visible_index));
if let Some(rows) = list.viewport_rows {
list.ensure_visible(rows);
}
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
pub fn handle_list_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
let Some(list) = self.list.as_mut() else {
return ModalListKeyResult::NotHandled;
};
let previous_selection = list.current_selection();
if down {
list.select_next();
} else {
list.select_previous();
}
if let Some(event) = selection_change_event(list, previous_selection) {
ModalListKeyResult::Emit(event)
} else {
ModalListKeyResult::Redraw
}
}
}
fn selection_change_event(
list: &ModalListState,
previous: Option<InlineListSelection>,
) -> Option<InlineEvent> {
let current = list.current_selection();
if current == previous {
return None;
}
current.map(|selection| {
InlineEvent::Overlay(OverlayEvent::SelectionChanged(
OverlaySelectionChange::List(selection),
))
})
}
fn is_custom_note_selection(selection: &InlineListSelection) -> bool {
matches!(
selection,
InlineListSelection::RequestUserInputAnswer {
selected,
other,
..
} if selected.is_empty() && other.is_some()
)
}
fn map_config_selection_for_arrow(
selection: &InlineListSelection,
is_left: bool,
) -> Option<InlineListSelection> {
let InlineListSelection::ConfigAction(action) = selection else {
return None;
};
if action.ends_with(":cycle") {
if is_left {
let key = action.trim_end_matches(":cycle");
return Some(InlineListSelection::ConfigAction(format!(
"{}:cycle_prev",
key
)));
}
return Some(selection.clone());
}
if action.ends_with(":inc") {
if is_left {
let key = action.trim_end_matches(":inc");
return Some(InlineListSelection::ConfigAction(format!("{}:dec", key)));
}
return Some(selection.clone());
}
if action.ends_with(":dec") {
if is_left {
return Some(selection.clone());
}
let key = action.trim_end_matches(":dec");
return Some(InlineListSelection::ConfigAction(format!("{}:inc", key)));
}
if action.ends_with(":toggle") {
let _ = is_left;
return Some(selection.clone());
}
None
}
impl ModalListItem {
pub fn is_header(&self) -> bool {
self.selection.is_none() && !self.is_divider
}
fn matches(&self, query: &str) -> bool {
if query.is_empty() {
return true;
}
let Some(value) = self.search_value.as_ref() else {
return false;
};
exact_terms_match(query, value)
}
}
#[allow(clippy::const_is_empty)]
pub fn is_divider_title(item: &InlineListItem) -> bool {
if item.selection.is_some() {
return false;
}
if item.indent != 0 {
return false;
}
if item.subtitle.is_some() || item.badge.is_some() {
return false;
}
let symbol = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL;
if symbol.is_empty() {
return false;
}
item.title
.chars()
.all(|ch| symbol.chars().any(|needle| needle == ch))
}
impl ModalListState {
pub fn new(items: Vec<InlineListItem>, selected: Option<InlineListSelection>) -> Self {
let converted: Vec<ModalListItem> = items
.into_iter()
.map(|item| {
let is_divider = is_divider_title(&item);
let search_value = item
.search_value
.as_ref()
.map(|value| value.to_ascii_lowercase());
ModalListItem {
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
indent: item.indent,
selection: item.selection,
search_value,
is_divider,
}
})
.collect();
let total_selectable = converted
.iter()
.filter(|item| item.selection.is_some())
.count();
let has_two_line_items = converted.iter().any(|item| {
item.subtitle
.as_ref()
.is_some_and(|subtitle| !subtitle.trim().is_empty())
});
let density_behavior = Self::density_behavior_for_items(&converted);
let is_model_picker_list = Self::is_model_picker_list(&converted);
let compact_rows =
Self::initial_compact_rows(density_behavior, has_two_line_items, is_model_picker_list);
let mut modal_state = Self {
visible_indices: (0..converted.len()).collect(),
items: converted,
list_state: ListState::default(),
total_selectable,
filter_terms: Vec::new(),
filter_query: None,
viewport_rows: None,
compact_rows,
density_behavior,
};
modal_state.select_initial(selected);
modal_state
}
fn density_behavior_for_items(items: &[ModalListItem]) -> ModalListDensityBehavior {
if items
.iter()
.any(|item| matches!(item.selection, Some(InlineListSelection::ConfigAction(_))))
{
ModalListDensityBehavior::FixedComfortable
} else {
ModalListDensityBehavior::Adjustable
}
}
fn initial_compact_rows(
density_behavior: ModalListDensityBehavior,
has_two_line_items: bool,
is_model_picker_list: bool,
) -> bool {
if is_model_picker_list {
return false;
}
match density_behavior {
ModalListDensityBehavior::FixedComfortable => false,
ModalListDensityBehavior::Adjustable => has_two_line_items,
}
}
fn is_model_picker_list(items: &[ModalListItem]) -> bool {
let mut has_model_selection = false;
for item in items {
let Some(selection) = item.selection.as_ref() else {
continue;
};
match selection {
InlineListSelection::Model(_)
| InlineListSelection::DynamicModel(_)
| InlineListSelection::CustomProvider(_)
| InlineListSelection::RefreshDynamicModels
| InlineListSelection::Reasoning(_)
| InlineListSelection::DisableReasoning
| InlineListSelection::CustomModel => {
has_model_selection = true;
}
_ => return false,
}
}
has_model_selection
}
pub fn current_selection(&self) -> Option<InlineListSelection> {
self.list_state
.selected()
.and_then(|index| self.visible_indices.get(index))
.and_then(|&item_index| self.items.get(item_index))
.and_then(|item| item.selection.clone())
}
pub fn get_best_matching_item(&self, query: &str) -> Option<String> {
if query.is_empty() {
return None;
}
let normalized_query = normalize_query(query);
self.visible_indices
.iter()
.filter_map(|&idx| self.items.get(idx))
.filter(|item| item.selection.is_some())
.filter_map(|item| item.search_value.as_ref())
.find(|search_value| exact_terms_match(&normalized_query, search_value))
.cloned()
}
pub fn select_previous(&mut self) {
if self.visible_indices.is_empty() {
return;
}
let Some(mut index) = self.list_state.selected() else {
if let Some(last) = self.last_selectable_index() {
self.list_state.select(Some(last));
}
return;
};
while index > 0 {
index -= 1;
let item_index = match self.visible_indices.get(index) {
Some(idx) => *idx,
None => {
tracing::warn!("visible_indices index {index} out of bounds");
continue;
}
};
if let Some(item) = self.items.get(item_index)
&& item.selection.is_some()
{
self.list_state.select(Some(index));
return;
}
}
if let Some(first) = self.first_selectable_index() {
self.list_state.select(Some(first));
} else {
self.list_state.select(None);
}
}
pub fn select_next(&mut self) {
if self.visible_indices.is_empty() {
return;
}
let mut index = self.list_state.selected().unwrap_or(usize::MAX);
if index == usize::MAX {
if let Some(first) = self.first_selectable_index() {
self.list_state.select(Some(first));
}
return;
}
while index + 1 < self.visible_indices.len() {
index += 1;
let item_index = self.visible_indices[index];
if self.items[item_index].selection.is_some() {
self.list_state.select(Some(index));
break;
}
}
}
pub fn select_first(&mut self) {
if let Some(first) = self.first_selectable_index() {
self.list_state.select(Some(first));
} else {
self.list_state.select(None);
}
if let Some(rows) = self.viewport_rows {
self.ensure_visible(rows);
}
}
pub fn select_last(&mut self) {
if let Some(last) = self.last_selectable_index() {
self.list_state.select(Some(last));
} else {
self.list_state.select(None);
}
if let Some(rows) = self.viewport_rows {
self.ensure_visible(rows);
}
}
pub(crate) fn selected_is_last(&self) -> bool {
let Some(selected) = self.list_state.selected() else {
return false;
};
self.last_selectable_index()
.is_some_and(|last| selected == last)
}
pub fn select_nth_selectable(&mut self, target_index: usize) -> bool {
let mut count = 0usize;
for (visible_pos, &item_index) in self.visible_indices.iter().enumerate() {
if self.items[item_index].selection.is_some() {
if count == target_index {
self.list_state.select(Some(visible_pos));
if let Some(rows) = self.viewport_rows {
self.ensure_visible(rows);
}
return true;
}
count += 1;
}
}
false
}
pub fn page_up(&mut self) {
let step = self.page_step();
if step == 0 {
self.select_previous();
return;
}
for _ in 0..step {
let before = self.list_state.selected();
self.select_previous();
if self.list_state.selected() == before {
break;
}
}
}
pub fn page_down(&mut self) {
let step = self.page_step();
if step == 0 {
self.select_next();
return;
}
for _ in 0..step {
let before = self.list_state.selected();
self.select_next();
if self.list_state.selected() == before {
break;
}
}
}
pub fn set_viewport_rows(&mut self, rows: u16) {
self.viewport_rows = Some(rows);
}
pub(super) fn ensure_visible(&mut self, viewport: u16) {
let Some(selected) = self.list_state.selected() else {
return;
};
if viewport == 0 {
return;
}
let visible = viewport as usize;
let offset = self.list_state.offset();
if selected < offset {
*self.list_state.offset_mut() = selected;
} else if selected >= offset + visible {
*self.list_state.offset_mut() = selected + 1 - visible;
}
}
pub fn apply_search(&mut self, query: &str) {
let preferred = self.current_selection();
self.apply_search_with_preference(query, preferred.clone());
}
pub fn apply_search_with_preference(
&mut self,
query: &str,
preferred: Option<InlineListSelection>,
) {
let trimmed = query.trim();
if trimmed.is_empty() {
if self.filter_query.is_none() {
if preferred.is_some() && self.current_selection() != preferred {
self.select_initial(preferred);
}
return;
}
self.visible_indices = (0..self.items.len()).collect();
self.filter_terms.clear();
self.filter_query = None;
self.select_initial(preferred);
return;
}
if self.filter_query.as_deref() == Some(trimmed) {
if preferred.is_some() && self.current_selection() != preferred {
self.select_initial(preferred);
}
return;
}
let normalized_query = normalize_query(trimmed);
let terms = normalized_query
.split_whitespace()
.filter(|term| !term.is_empty())
.map(|term| term.to_owned())
.collect::<Vec<_>>();
let mut indices = Vec::new();
let mut pending_divider: Option<usize> = None;
let mut current_header: Option<usize> = None;
let mut header_matches = false;
let mut header_included = false;
for (index, item) in self.items.iter().enumerate() {
if item.is_divider {
pending_divider = Some(index);
current_header = None;
header_matches = false;
header_included = false;
continue;
}
if item.is_header() {
current_header = Some(index);
header_matches = item.matches(&normalized_query);
header_included = false;
if header_matches {
if let Some(divider_index) = pending_divider.take() {
indices.push(divider_index);
}
indices.push(index);
header_included = true;
}
continue;
}
let item_matches = item.matches(&normalized_query);
let include_item = header_matches || item_matches;
if include_item {
if let Some(divider_index) = pending_divider.take() {
indices.push(divider_index);
}
if let Some(header_index) = current_header
&& !header_included
{
indices.push(header_index);
header_included = true;
}
indices.push(index);
}
}
self.visible_indices = indices;
self.filter_terms = terms;
self.filter_query = Some(trimmed.to_owned());
self.select_initial(preferred);
}
fn select_initial(&mut self, preferred: Option<InlineListSelection>) {
let mut selection_index = preferred.and_then(|needle| {
self.visible_indices
.iter()
.position(|&idx| self.items[idx].selection.as_ref() == Some(&needle))
});
if selection_index.is_none() {
selection_index = self.first_selectable_index();
}
self.list_state.select(selection_index);
*self.list_state.offset_mut() = 0;
}
fn first_selectable_index(&self) -> Option<usize> {
self.visible_indices
.iter()
.position(|&idx| self.items[idx].selection.is_some())
}
fn last_selectable_index(&self) -> Option<usize> {
self.visible_indices
.iter()
.rposition(|&idx| self.items[idx].selection.is_some())
}
pub(super) fn filter_active(&self) -> bool {
self.filter_query
.as_ref()
.is_some_and(|value| !value.is_empty())
}
#[cfg(test)]
pub(super) fn filter_query(&self) -> Option<&str> {
self.filter_query.as_deref()
}
pub(super) fn highlight_terms(&self) -> &[String] {
&self.filter_terms
}
pub(super) fn visible_selectable_count(&self) -> usize {
self.visible_indices
.iter()
.filter(|&&idx| self.items[idx].selection.is_some())
.count()
}
pub(super) fn total_selectable(&self) -> usize {
self.total_selectable
}
pub(super) fn compact_rows(&self) -> bool {
self.compact_rows
}
pub(super) fn supports_density_toggle(&self) -> bool {
matches!(self.density_behavior, ModalListDensityBehavior::Adjustable)
}
pub(super) fn non_filter_summary_text(&self, footer_hint: Option<&str>) -> Option<String> {
if !self.has_non_filter_summary(footer_hint) {
return None;
}
match self.density_behavior {
ModalListDensityBehavior::FixedComfortable => {
Some(CONFIG_LIST_NAVIGATION_HINT.to_owned())
}
ModalListDensityBehavior::Adjustable => footer_hint
.filter(|hint| !hint.is_empty())
.map(ToOwned::to_owned),
}
}
pub(crate) fn summary_line_rows(&self, footer_hint: Option<&str>) -> usize {
if self.filter_active() || self.has_non_filter_summary(footer_hint) {
1
} else {
0
}
}
fn has_non_filter_summary(&self, footer_hint: Option<&str>) -> bool {
match self.density_behavior {
ModalListDensityBehavior::FixedComfortable => true,
ModalListDensityBehavior::Adjustable => {
footer_hint.is_some_and(|hint| !hint.is_empty())
}
}
}
pub fn toggle_row_density(&mut self) {
self.compact_rows = !self.compact_rows;
}
fn page_step(&self) -> usize {
let rows = self.viewport_rows.unwrap_or(0).max(1);
usize::from(rows)
}
}
#[allow(dead_code)]
impl WizardModalState {
pub fn new(
title: String,
steps: Vec<WizardStep>,
current_step: usize,
search: Option<InlineListSearchConfig>,
mode: WizardModalMode,
) -> Self {
let step_states: Vec<WizardStepState> = steps
.into_iter()
.map(|step| {
let notes_active = step
.items
.first()
.and_then(|item| item.selection.as_ref())
.is_some_and(|selection| match selection {
InlineListSelection::RequestUserInputAnswer {
selected, other, ..
} => selected.is_empty() && other.is_some(),
_ => false,
});
WizardStepState {
title: step.title,
question: step.question,
list: ModalListState::new(step.items, step.answer.clone()),
completed: step.completed,
answer: step.answer,
notes: String::new(),
notes_active,
allow_freeform: step.allow_freeform,
freeform_label: step.freeform_label,
freeform_placeholder: step.freeform_placeholder,
freeform_default: step.freeform_default,
}
})
.collect();
let clamped_step = if step_states.is_empty() {
0
} else {
current_step.min(step_states.len().saturating_sub(1))
};
Self {
title,
steps: step_states,
current_step: clamped_step,
search: search.map(ModalSearchState::from),
mode,
}
}
pub fn handle_key_event(
&mut self,
key: &KeyEvent,
modifiers: ModalKeyModifiers,
) -> ModalListKeyResult {
if let Some(step) = self.steps.get_mut(self.current_step)
&& step.notes_active
{
match key.code {
KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
step.notes.push(ch);
return ModalListKeyResult::Redraw;
}
KeyCode::Backspace => {
if step.notes.pop().is_some() {
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Tab | KeyCode::Esc => {
if !step.notes.is_empty() {
step.notes.clear();
}
step.notes_active = false;
return ModalListKeyResult::Redraw;
}
_ => {}
}
}
if let Some(step) = self.steps.get_mut(self.current_step)
&& !step.notes_active
&& Self::step_selected_custom_note_item_index(step).is_some()
{
match key.code {
KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
step.notes_active = true;
step.notes.push(ch);
return ModalListKeyResult::Redraw;
}
KeyCode::Backspace => {
if step.notes.pop().is_some() {
step.notes_active = true;
return ModalListKeyResult::Redraw;
}
}
_ => {}
}
}
if let Some(search) = self.search.as_mut()
&& let Some(step) = self.steps.get_mut(self.current_step)
{
match key.code {
KeyCode::Char(ch) if !modifiers.control && !modifiers.alt && !modifiers.command => {
search.push_char(ch);
step.list.apply_search(&search.query);
return ModalListKeyResult::Redraw;
}
KeyCode::Backspace => {
if search.backspace() {
step.list.apply_search(&search.query);
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Delete => {
if search.clear() {
step.list.apply_search(&search.query);
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Tab => {
if let Some(best_match) = step.list.get_best_matching_item(&search.query) {
search.query = best_match;
step.list.apply_search(&search.query);
return ModalListKeyResult::Redraw;
}
return ModalListKeyResult::HandledNoRedraw;
}
KeyCode::Esc => {
if search.clear() {
step.list.apply_search(&search.query);
return ModalListKeyResult::Redraw;
}
}
_ => {}
}
}
if self.mode == WizardModalMode::MultiStep
&& !modifiers.control
&& !modifiers.alt
&& !modifiers.command
&& self.search.is_none()
&& let KeyCode::Char(ch) = key.code
&& ch.is_ascii_digit()
&& ch != '0'
{
let target_index = ch.to_digit(10).unwrap_or(1).saturating_sub(1) as usize;
if let Some(step) = self.steps.get_mut(self.current_step)
&& step.list.select_nth_selectable(target_index)
{
return self.submit_current_selection();
}
return ModalListKeyResult::HandledNoRedraw;
}
match key.code {
KeyCode::Char('n') | KeyCode::Char('N')
if modifiers.control && self.mode == WizardModalMode::MultiStep =>
{
if self.current_step < self.steps.len().saturating_sub(1) {
self.current_step += 1;
ModalListKeyResult::Redraw
} else {
ModalListKeyResult::HandledNoRedraw
}
}
KeyCode::Left => {
if self.current_step > 0 {
self.current_step -= 1;
ModalListKeyResult::Redraw
} else {
ModalListKeyResult::HandledNoRedraw
}
}
KeyCode::Right => {
let can_advance = match self.mode {
WizardModalMode::MultiStep => self.current_step_completed(),
WizardModalMode::TabbedList => true,
};
if can_advance && self.current_step < self.steps.len().saturating_sub(1) {
self.current_step += 1;
ModalListKeyResult::Redraw
} else {
ModalListKeyResult::HandledNoRedraw
}
}
KeyCode::Enter => self.submit_current_selection(),
KeyCode::Esc => {
ModalListKeyResult::Cancel(InlineEvent::Overlay(OverlayEvent::Cancelled))
}
KeyCode::Char('c') | KeyCode::Char('C') if modifiers.control => {
ModalListKeyResult::Cancel(InlineEvent::Interrupt)
}
KeyCode::Char('\u{3}') => ModalListKeyResult::Cancel(InlineEvent::Interrupt),
KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => {
if let Some(step) = self.steps.get_mut(self.current_step) {
match key.code {
KeyCode::Up => {
if modifiers.command {
step.list.select_first();
} else {
step.list.select_previous();
}
ModalListKeyResult::Redraw
}
KeyCode::Down => {
if modifiers.command {
step.list.select_last();
} else {
step.list.select_next();
}
ModalListKeyResult::Redraw
}
KeyCode::Tab => {
if self.search.is_none()
&& (step.allow_freeform
|| Self::step_selected_custom_note_item_index(step).is_some())
{
step.notes_active = !step.notes_active;
ModalListKeyResult::Redraw
} else {
step.list.select_next();
ModalListKeyResult::Redraw
}
}
KeyCode::BackTab => {
step.list.select_previous();
ModalListKeyResult::Redraw
}
_ => ModalListKeyResult::NotHandled,
}
} else {
ModalListKeyResult::NotHandled
}
}
_ => ModalListKeyResult::NotHandled,
}
}
pub fn handle_mouse_click(&mut self, visible_index: usize) -> ModalListKeyResult {
let submit_after_click = {
let Some(step) = self.steps.get_mut(self.current_step) else {
return ModalListKeyResult::NotHandled;
};
let Some(&item_index) = step.list.visible_indices.get(visible_index) else {
return ModalListKeyResult::HandledNoRedraw;
};
let Some(item) = step.list.items.get(item_index) else {
return ModalListKeyResult::HandledNoRedraw;
};
let Some(selection) = item.selection.as_ref() else {
return ModalListKeyResult::HandledNoRedraw;
};
let clicked_custom_note = is_custom_note_selection(selection);
let already_selected = step.list.list_state.selected() == Some(visible_index);
if self.mode == WizardModalMode::TabbedList {
step.list.list_state.select(Some(visible_index));
if let Some(rows) = step.list.viewport_rows {
step.list.ensure_visible(rows);
}
if clicked_custom_note
&& step.notes.trim().is_empty()
&& !step.has_freeform_default()
{
step.notes_active = true;
return ModalListKeyResult::Redraw;
}
true
} else if already_selected {
true
} else {
step.list.list_state.select(Some(visible_index));
if let Some(rows) = step.list.viewport_rows {
step.list.ensure_visible(rows);
}
return ModalListKeyResult::Redraw;
}
};
if submit_after_click {
return self.submit_current_selection();
}
ModalListKeyResult::Redraw
}
pub fn handle_mouse_scroll(&mut self, down: bool) -> ModalListKeyResult {
let Some(step) = self.steps.get_mut(self.current_step) else {
return ModalListKeyResult::NotHandled;
};
let before = step.list.list_state.selected();
if down {
step.list.select_next();
} else {
step.list.select_previous();
}
if step.list.list_state.selected() == before {
ModalListKeyResult::HandledNoRedraw
} else {
ModalListKeyResult::Redraw
}
}
fn current_selection(&self) -> Option<InlineListSelection> {
self.steps
.get(self.current_step)
.and_then(|step| {
step.list
.current_selection()
.map(|selection| (selection, step))
})
.map(|(selection, step)| match selection {
InlineListSelection::RequestUserInputAnswer {
question_id,
selected,
other,
} => {
let next_other = if other.is_some() {
step.submitted_freeform_value()
} else if step.notes.trim().is_empty() {
None
} else {
Some(step.notes.trim().to_string())
};
InlineListSelection::RequestUserInputAnswer {
question_id,
selected,
other: next_other,
}
}
InlineListSelection::AskUserChoice {
tab_id, choice_id, ..
} => {
let notes = step.notes.trim();
let text = if notes.is_empty() {
None
} else {
Some(notes.to_string())
};
InlineListSelection::AskUserChoice {
tab_id,
choice_id,
text,
}
}
_ => selection,
})
}
fn current_step_completed(&self) -> bool {
self.steps
.get(self.current_step)
.is_some_and(|step| step.completed)
}
fn step_selected_custom_note_item_index(step: &WizardStepState) -> Option<usize> {
let selected_visible = step.list.list_state.selected()?;
let item_index = *step.list.visible_indices.get(selected_visible)?;
let item = step.list.items.get(item_index)?;
item.selection
.as_ref()
.filter(|selection| is_custom_note_selection(selection))
.map(|_| item_index)
}
fn current_step_selected_custom_note_item_index(&self) -> Option<usize> {
self.steps
.get(self.current_step)
.and_then(Self::step_selected_custom_note_item_index)
}
fn current_step_requires_custom_note_input(&self) -> bool {
self.current_step_selected_custom_note_item_index()
.is_some()
}
fn current_step_supports_notes(&self) -> bool {
self.steps
.get(self.current_step)
.and_then(|step| step.list.current_selection())
.is_some_and(|selection| {
matches!(
selection,
InlineListSelection::RequestUserInputAnswer { .. }
| InlineListSelection::AskUserChoice { .. }
)
})
}
fn current_step_has_freeform_default(&self) -> bool {
self.steps
.get(self.current_step)
.is_some_and(WizardStepState::has_freeform_default)
}
pub fn unanswered_count(&self) -> usize {
self.steps.iter().filter(|step| !step.completed).count()
}
pub fn question_header(&self) -> String {
format!(
"Question {}/{} ({} unanswered)",
self.current_step.saturating_add(1),
self.steps.len(),
self.unanswered_count()
)
}
pub fn notes_line(&self) -> Option<String> {
let step = self.steps.get(self.current_step)?;
if step.notes_active || !step.notes.is_empty() {
let label = step.freeform_label.as_deref().unwrap_or("›");
if step.notes.is_empty()
&& let Some(placeholder) = step.freeform_placeholder.as_ref()
{
return Some(format!("{} {}", label, placeholder));
}
Some(format!("{} {}", label, step.notes))
} else {
None
}
}
pub fn notes_active(&self) -> bool {
self.steps
.get(self.current_step)
.is_some_and(|step| step.notes_active)
}
pub fn instruction_lines(&self) -> Vec<String> {
let step = match self.steps.get(self.current_step) {
Some(s) => s,
None => return Vec::new(),
};
let custom_note_selected = self.current_step_requires_custom_note_input();
if self.notes_active() {
if custom_note_selected {
vec![if self.current_step_has_freeform_default() {
"type custom note | enter to submit or accept default | esc to clear"
.to_string()
} else {
"type custom note | enter to continue | esc to clear".to_string()
}]
} else {
vec!["tab or esc to clear notes | enter to submit answer".to_string()]
}
} else {
let mut lines = Vec::new();
if custom_note_selected {
lines.push(if self.current_step_has_freeform_default() {
"type custom note | enter to accept default".to_string()
} else {
"type custom note | enter to continue".to_string()
});
} else if step.allow_freeform {
lines.push("tab to add notes | enter to submit answer".to_string());
} else {
lines.push("enter to submit answer".to_string());
}
lines.push("ctrl + n next question | esc to interrupt".to_string());
lines
}
}
fn complete_current_step(&mut self, answer: InlineListSelection) {
if let Some(step) = self.steps.get_mut(self.current_step) {
step.completed = true;
step.answer = Some(answer);
}
}
fn collect_answers(&self) -> Vec<InlineListSelection> {
self.steps
.iter()
.filter_map(|step| step.answer.clone())
.collect()
}
fn submit_current_selection(&mut self) -> ModalListKeyResult {
if self.current_step_requires_custom_note_input()
&& let Some(step) = self.steps.get_mut(self.current_step)
&& step.notes.trim().is_empty()
&& !step.has_freeform_default()
{
step.notes_active = true;
return ModalListKeyResult::Redraw;
}
let Some(selection) = self.current_selection() else {
return ModalListKeyResult::HandledNoRedraw;
};
match self.mode {
WizardModalMode::TabbedList => ModalListKeyResult::Submit(InlineEvent::Overlay(
OverlayEvent::Submitted(OverlaySubmission::Wizard(vec![selection])),
)),
WizardModalMode::MultiStep => {
self.complete_current_step(selection.clone());
if self.current_step < self.steps.len().saturating_sub(1) {
self.current_step += 1;
ModalListKeyResult::Redraw
} else {
ModalListKeyResult::Submit(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Wizard(self.collect_answers()),
)))
}
}
}
}
pub fn all_steps_completed(&self) -> bool {
self.steps.iter().all(|step| step.completed)
}
}
impl WizardStepState {
fn has_freeform_default(&self) -> bool {
self.freeform_default.is_some()
}
fn submitted_freeform_value(&self) -> Option<String> {
let notes = self.notes.trim();
if !notes.is_empty() {
return Some(notes.to_string());
}
self.freeform_default.clone()
}
}