use crate::theme::ThemeStyles;
#[cfg(test)]
use crossterm::event::KeyModifiers;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectorMarker {
#[default]
None,
Radio,
Checkbox,
}
#[derive(Debug, Clone)]
pub struct SelectorOption {
pub label: String,
pub description: Option<String>,
pub disabled: bool,
}
impl SelectorOption {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
description: None,
disabled: false,
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlRowKind {
Other,
Done,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectorAction {
None,
Select {
option_idx: usize,
},
Toggle {
option_idx: usize,
},
Other,
Done,
NavBack,
NavForward,
Timeout,
Cancel,
}
#[derive(Debug, Clone)]
pub struct ListSelectorState {
title: String,
options: Vec<SelectorOption>,
pub marker: SelectorMarker,
checked: HashSet<usize>,
markable_count: usize,
control_rows: Vec<ControlRowKind>,
pub timeout_secs: Option<u64>,
pub progress: Option<String>,
help_text: String,
max_visible: usize,
cursor: usize,
search: String,
}
impl ListSelectorState {
pub fn new(title: impl Into<String>, options: Vec<SelectorOption>) -> Self {
let markable = options.len();
Self {
title: title.into(),
options,
marker: SelectorMarker::None,
checked: HashSet::new(),
markable_count: markable,
control_rows: Vec::new(),
timeout_secs: None,
progress: None,
help_text: " \u{2191}\u{2193} navigate enter select esc cancel".to_string(),
max_visible: 12,
cursor: 0,
search: String::new(),
}
}
pub fn with_marker(mut self, marker: SelectorMarker) -> Self {
self.marker = marker;
self
}
pub fn with_checked(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
self.checked = indices.into_iter().collect();
self
}
pub fn with_control_rows(mut self, rows: Vec<ControlRowKind>) -> Self {
self.control_rows = rows;
self
}
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = Some(secs);
self
}
pub fn with_progress(mut self, progress: impl Into<String>) -> Self {
self.progress = Some(progress.into());
self
}
pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
self.help_text = text.into();
self
}
pub fn with_max_visible(mut self, n: usize) -> Self {
self.max_visible = n;
self
}
pub fn set_initial_cursor(&mut self, idx: usize) {
self.cursor = self.coerce_cursor(idx);
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn toggle(&mut self, option_idx: usize) {
if self.checked.contains(&option_idx) {
self.checked.remove(&option_idx);
} else {
self.checked.insert(option_idx);
}
}
pub fn checked_indices(&self) -> Vec<usize> {
self.checked.iter().copied().collect()
}
pub fn set_timeout_display(&mut self, secs: u64) {
self.timeout_secs = Some(secs);
}
pub fn is_compact(&self) -> bool {
self.options.len() > self.max_visible
}
pub fn search(&self) -> &str {
&self.search
}
fn filtered_options(&self) -> Vec<usize> {
if self.search.trim().is_empty() {
return (0..self.options.len()).collect();
}
let needle = self.search.to_lowercase();
(0..self.options.len())
.filter(|&i| {
let opt = &self.options[i];
opt.label.to_lowercase().contains(&needle)
|| opt
.description
.as_deref()
.is_some_and(|d| d.to_lowercase().contains(&needle))
})
.collect()
}
pub fn visible_option_count(&self) -> usize {
if self.is_compact() {
self.filtered_options().len()
} else {
self.options.len()
}
}
pub fn total_rows(&self) -> usize {
self.visible_option_count() + self.control_rows.len()
}
pub fn display_to_option(&self, display_idx: usize) -> Option<usize> {
if self.is_compact() {
self.filtered_options().get(display_idx).copied()
} else {
(display_idx < self.options.len()).then_some(display_idx)
}
}
pub fn cursor_option(&self) -> Option<usize> {
self.display_to_option(self.cursor)
}
fn cursor_on_control(&self, kind: ControlRowKind) -> bool {
let vis = self.visible_option_count();
if self.cursor < vis {
return false;
}
let ctrl_offset = self.cursor - vis;
self.control_rows.get(ctrl_offset) == Some(&kind)
}
fn coerce_cursor(&self, idx: usize) -> usize {
let total = self.total_rows();
if total == 0 {
return 0;
}
let clamped = idx.min(total - 1);
for i in clamped..total {
if self.row_is_navigable(i) {
return i;
}
}
for i in (0..clamped).rev() {
if self.row_is_navigable(i) {
return i;
}
}
clamped
}
fn row_is_navigable(&self, display_idx: usize) -> bool {
if let Some(opt_idx) = self.display_to_option(display_idx) {
!self.options.get(opt_idx).is_some_and(|o| o.disabled)
} else {
true
}
}
fn move_cursor(&mut self, delta: i32) {
let total = self.total_rows();
if total == 0 {
return;
}
let max_idx = (total - 1) as i32;
let mut idx = (self.cursor as i32 + delta).clamp(0, max_idx);
if idx as usize == self.cursor {
return;
}
if self.row_is_navigable(idx as usize) {
self.cursor = idx as usize;
return;
}
let max_steps = total as i32;
for _ in 0..max_steps {
let next = idx + delta;
if next < 0 || next > max_idx {
self.cursor = idx as usize;
return;
}
idx = next;
if self.row_is_navigable(idx as usize) {
self.cursor = idx as usize;
return;
}
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> SelectorAction {
if key.kind != KeyEventKind::Press {
return SelectorAction::None;
}
if self.is_compact() && self.handle_search_key(&key) {
return SelectorAction::None;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') if !self.is_compact() => {
self.move_cursor(-1);
SelectorAction::None
}
KeyCode::Down | KeyCode::Char('j') if !self.is_compact() => {
self.move_cursor(1);
SelectorAction::None
}
KeyCode::Enter => self.activate_cursor(false),
KeyCode::Char(' ') => self.activate_cursor(true),
KeyCode::Left => SelectorAction::NavBack,
KeyCode::Right => SelectorAction::NavForward,
KeyCode::Esc => SelectorAction::Cancel,
_ => SelectorAction::None,
}
}
fn handle_search_key(&mut self, key: &KeyEvent) -> bool {
match key.code {
KeyCode::Backspace => {
self.search.pop();
self.cursor = 0;
true
}
KeyCode::Char(c) if !c.is_control() => {
self.search.push(c);
self.cursor = 0;
true
}
_ => false,
}
}
fn activate_cursor(&mut self, _is_space: bool) -> SelectorAction {
if self.cursor_on_control(ControlRowKind::Other) {
return SelectorAction::Other;
}
if self.cursor_on_control(ControlRowKind::Done) {
return SelectorAction::Done;
}
match self.cursor_option() {
Some(idx) => match self.marker {
SelectorMarker::Checkbox => {
self.toggle(idx);
SelectorAction::Toggle { option_idx: idx }
}
SelectorMarker::Radio | SelectorMarker::None => {
SelectorAction::Select { option_idx: idx }
}
},
None => SelectorAction::None,
}
}
pub fn render(&self, width: usize, styles: &ThemeStyles) -> Vec<Line<'static>> {
let sym = styles.symbols;
let mut lines = Vec::new();
let mut title_parts = vec![Span::styled(
format!(" {} ", sym.tool_ask),
styles.accent.add_modifier(Modifier::BOLD),
)];
let title_text = if let Some(prog) = &self.progress {
format!("{} ({})", self.title, prog)
} else {
self.title.clone()
};
title_parts.push(Span::styled(
title_text,
styles.normal.add_modifier(Modifier::BOLD),
));
lines.push(Line::from(title_parts));
if let Some(secs) = self.timeout_secs {
let timer_style = if secs <= 5 {
styles.warning.add_modifier(Modifier::BOLD)
} else {
styles.muted
};
lines.push(Line::from(Span::styled(
format!(" {} {}s remaining", sym.icon_time, secs),
timer_style,
)));
}
if self.is_compact() {
let status = if self.search.trim().is_empty() {
" Type to search".to_string()
} else {
format!(" Search: {}", self.search)
};
lines.push(Line::from(Span::styled(status, styles.muted)));
}
let sep = sym.rule.repeat(width.max(2) / sym.rule.len().max(1));
lines.push(Line::from(Span::styled(
sep,
ratatui::style::Style::default().fg(ratatui::style::Color::Reset),
)));
let vis = self.visible_option_count();
for display_idx in 0..self.total_rows() {
let is_cursor = display_idx == self.cursor;
if let Some(opt_idx) = self.display_to_option(display_idx) {
let opt = &self.options[opt_idx];
let is_checked = self.checked.contains(&opt_idx);
let is_disabled = opt.disabled;
let prefix = if is_cursor {
Span::styled(
format!("{} ", sym.cursor),
styles.accent.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let marker_span =
self.render_marker(opt_idx, is_cursor, is_checked, is_disabled, sym, styles);
let label_style = if is_disabled {
styles.muted
} else if is_cursor {
styles.accent
} else {
styles.normal
};
lines.push(Line::from(vec![
prefix,
marker_span,
Span::styled(opt.label.clone(), label_style),
]));
if let Some(desc) = &opt.description
&& (!self.is_compact() || is_cursor)
{
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{} {}", sym.nav_expand, desc), styles.muted),
]));
}
} else {
let ctrl_offset = display_idx - vis;
let row = self.render_control_row(ctrl_offset, is_cursor, sym, styles);
lines.push(row);
}
}
let sep2 = sym.rule.repeat(width.max(2) / sym.rule.len().max(1));
lines.push(Line::from(Span::styled(
sep2,
ratatui::style::Style::default().fg(ratatui::style::Color::Reset),
)));
lines.push(Line::from(Span::styled(
self.help_text.clone(),
styles.muted,
)));
lines
}
fn render_marker(
&self,
opt_idx: usize,
is_cursor: bool,
is_checked: bool,
is_disabled: bool,
sym: crate::symbols::Symbols,
styles: &ThemeStyles,
) -> Span<'static> {
if opt_idx >= self.markable_count {
return Span::raw(" ");
}
match self.marker {
SelectorMarker::Radio => {
let glyph = if is_cursor {
sym.radio_on
} else {
sym.radio_off
};
let color = if is_disabled {
styles.muted
} else if is_cursor {
styles.accent
} else {
styles.muted
};
Span::styled(format!("{} ", glyph), color)
}
SelectorMarker::Checkbox => {
let glyph = if is_checked {
sym.checkbox_on
} else {
sym.checkbox_off
};
let color = if is_disabled {
styles.muted
} else if is_cursor {
styles.accent
} else if is_checked {
styles.success
} else {
styles.muted
};
Span::styled(format!("{} ", glyph), color)
}
SelectorMarker::None => Span::raw(""),
}
}
fn render_control_row(
&self,
ctrl_offset: usize,
is_cursor: bool,
sym: crate::symbols::Symbols,
styles: &ThemeStyles,
) -> Line<'static> {
let prefix = if is_cursor {
Span::styled(
format!("{} ", sym.cursor),
styles.accent.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
match self.control_rows.get(ctrl_offset) {
Some(ControlRowKind::Other) => {
let marker = if is_cursor {
Span::styled(format!("{} ", sym.radio_on), styles.accent)
} else {
Span::styled(format!("{} ", sym.radio_off), styles.muted)
};
Line::from(vec![
prefix,
marker,
Span::styled(
"Other (type your own)".to_string(),
if is_cursor {
styles.accent
} else {
styles.muted
},
),
])
}
Some(ControlRowKind::Done) => {
let has_sel = !self.checked.is_empty();
let style = if has_sel {
styles.success.add_modifier(Modifier::BOLD)
} else {
styles.muted
};
let marker = if has_sel {
Span::styled(format!("{} ", sym.status_success), styles.success)
} else {
Span::raw(" ")
};
Line::from(vec![
prefix,
marker,
Span::styled("Done selecting".to_string(), style),
])
}
None => Line::raw(""),
}
}
pub fn tick_timeout(&mut self) -> bool {
if let Some(secs) = self.timeout_secs.as_mut() {
if *secs == 0 {
return true;
}
*secs -= 1;
*secs == 0
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(n: usize) -> Vec<SelectorOption> {
(0..n)
.map(|i| SelectorOption::new(format!("Option {i}")))
.collect()
}
fn press(state: &mut ListSelectorState, code: KeyCode) -> SelectorAction {
state.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn radio_select_returns_select_action() {
let mut s = ListSelectorState::new("Pick", opts(3)).with_marker(SelectorMarker::Radio);
assert_eq!(
press(&mut s, KeyCode::Enter),
SelectorAction::Select { option_idx: 0 }
);
}
#[test]
fn checkbox_enter_toggles() {
let mut s = ListSelectorState::new("Pick", opts(3)).with_marker(SelectorMarker::Checkbox);
assert_eq!(
press(&mut s, KeyCode::Enter),
SelectorAction::Toggle { option_idx: 0 }
);
assert!(s.checked.contains(&0));
press(&mut s, KeyCode::Down);
assert_eq!(
press(&mut s, KeyCode::Enter),
SelectorAction::Toggle { option_idx: 1 }
);
assert!(s.checked.contains(&1));
}
#[test]
fn none_marker_returns_select() {
let mut s = ListSelectorState::new("Model", opts(3));
assert_eq!(
press(&mut s, KeyCode::Enter),
SelectorAction::Select { option_idx: 0 }
);
}
#[test]
fn down_moves_cursor() {
let mut s = ListSelectorState::new("Pick", opts(3));
press(&mut s, KeyCode::Down);
assert_eq!(s.cursor, 1);
assert_eq!(
press(&mut s, KeyCode::Enter),
SelectorAction::Select { option_idx: 1 }
);
}
#[test]
fn up_at_top_stays() {
let mut s = ListSelectorState::new("Pick", opts(3));
press(&mut s, KeyCode::Up);
assert_eq!(s.cursor, 0);
}
#[test]
fn esc_cancels() {
let mut s = ListSelectorState::new("Pick", opts(2));
assert_eq!(press(&mut s, KeyCode::Esc), SelectorAction::Cancel);
}
#[test]
fn left_right_nav() {
let mut s = ListSelectorState::new("Pick", opts(2));
assert_eq!(press(&mut s, KeyCode::Left), SelectorAction::NavBack);
assert_eq!(press(&mut s, KeyCode::Right), SelectorAction::NavForward);
}
#[test]
fn disabled_rows_skipped_on_down() {
let mut opts_disabled = opts(3);
opts_disabled[1].disabled = true;
let mut s = ListSelectorState::new("Pick", opts_disabled);
press(&mut s, KeyCode::Down);
assert_eq!(s.cursor, 2);
}
#[test]
fn disabled_rows_skipped_on_up() {
let mut opts_disabled = opts(3);
opts_disabled[1].disabled = true;
let mut s = ListSelectorState::new("Pick", opts_disabled);
s.set_initial_cursor(2);
press(&mut s, KeyCode::Up);
assert_eq!(s.cursor, 0);
}
#[test]
fn down_stops_at_last_row() {
let mut s = ListSelectorState::new("Pick", opts(3));
s.set_initial_cursor(2);
press(&mut s, KeyCode::Down);
assert_eq!(s.cursor, 2);
}
#[test]
fn up_stops_at_first_row() {
let mut s = ListSelectorState::new("Pick", opts(3));
press(&mut s, KeyCode::Up);
assert_eq!(s.cursor, 0);
}
#[test]
fn all_disabled_no_infinite_loop() {
let mut opts_disabled = opts(4);
for o in opts_disabled.iter_mut() {
o.disabled = true;
}
let mut s = ListSelectorState::new("Pick", opts_disabled);
s.set_initial_cursor(1);
press(&mut s, KeyCode::Down);
assert!(s.cursor < s.total_rows());
press(&mut s, KeyCode::Up);
assert!(s.cursor < s.total_rows());
}
#[test]
fn jk_navigation_skips_disabled() {
let mut opts_disabled = opts(4);
opts_disabled[2].disabled = true;
let mut s = ListSelectorState::new("Pick", opts_disabled);
s.set_initial_cursor(1);
press(&mut s, KeyCode::Char('j'));
assert_eq!(s.cursor, 3);
press(&mut s, KeyCode::Char('k'));
assert_eq!(s.cursor, 1);
}
#[test]
fn control_row_other() {
let mut s = ListSelectorState::new("Pick", opts(2))
.with_marker(SelectorMarker::Radio)
.with_control_rows(vec![ControlRowKind::Other]);
press(&mut s, KeyCode::Down);
press(&mut s, KeyCode::Down);
assert_eq!(s.cursor, 2);
assert_eq!(press(&mut s, KeyCode::Enter), SelectorAction::Other);
}
#[test]
fn control_row_done() {
let mut s = ListSelectorState::new("Pick", opts(2))
.with_marker(SelectorMarker::Checkbox)
.with_control_rows(vec![ControlRowKind::Other, ControlRowKind::Done]);
press(&mut s, KeyCode::Enter);
press(&mut s, KeyCode::Down);
press(&mut s, KeyCode::Down);
press(&mut s, KeyCode::Down);
assert_eq!(s.cursor, 3);
assert_eq!(press(&mut s, KeyCode::Enter), SelectorAction::Done);
}
#[test]
fn compact_mode_enables_search() {
let mut s = ListSelectorState::new("Pick", opts(15)).with_max_visible(10);
assert!(s.is_compact());
s.handle_key(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE));
assert_eq!(s.search, "O");
assert_eq!(s.visible_option_count(), 15);
s.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(s.visible_option_count() < 15);
}
#[test]
fn non_compact_does_not_search() {
let mut s = ListSelectorState::new("Pick", opts(3));
assert!(!s.is_compact());
s.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(s.search.is_empty());
}
#[test]
fn initial_cursor_clamped() {
let mut s = ListSelectorState::new("Pick", opts(3));
s.set_initial_cursor(10);
assert_eq!(s.cursor, 2); }
#[test]
fn render_produces_lines() {
let s = ListSelectorState::new("Pick", opts(3)).with_marker(SelectorMarker::Radio);
let lines = s.render(60, &ThemeStyles::default());
assert!(lines.len() >= 7);
}
#[test]
fn timeout_tick() {
let mut s = ListSelectorState::new("Pick", opts(2)).with_timeout(3);
assert!(!s.tick_timeout()); assert!(!s.tick_timeout()); assert!(s.tick_timeout()); assert_eq!(s.timeout_secs, Some(0));
}
}