use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
use nucleo_matcher::{Config, Matcher};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Borders};
use ratatui_interact::components::InputState;
use crate::app::fuzzy_match_score;
use crate::theme::UiColors;
use super::select_list::{SelectList, SelectListScrollState};
use super::{Component, EventResult, OverlayContent, OverlayRequest};
fn compute_scroll_offset(selected: Option<usize>, visible_items: usize) -> usize {
if let Some(sel) = selected {
if visible_items > 0 && sel >= visible_items {
sel.saturating_sub(visible_items - 1)
} else {
0
}
} else {
0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChoiceSelectAction {
Selected(String),
Cancelled(String),
}
#[derive(Debug, Clone)]
struct ChoiceSelectInner {
choices: Vec<String>,
descriptions: Vec<Option<String>>,
selected_index: Option<usize>,
filter_active: bool,
edit_input: InputState,
anchor: Rect,
}
impl ChoiceSelectInner {
fn filtered_choices(&self) -> Vec<(usize, String)> {
let filter = self.edit_input.text();
if filter.is_empty() || !self.filter_active {
return self
.choices
.iter()
.enumerate()
.map(|(i, c)| (i, c.clone()))
.collect();
}
let mut matcher = Matcher::new(Config::DEFAULT);
self.choices
.iter()
.enumerate()
.filter(|(i, c)| {
fuzzy_match_score(c, filter, &mut matcher) > 0
|| self
.descriptions
.get(*i)
.and_then(|d| d.as_deref())
.map(|desc| fuzzy_match_score(desc, filter, &mut matcher) > 0)
.unwrap_or(false)
})
.map(|(i, c)| (i, c.clone()))
.collect()
}
fn resolve_value(&self) -> String {
let filtered = self.filtered_choices();
if let Some(idx) = self.selected_index {
if let Some((_, text)) = filtered.get(idx) {
return text.clone();
}
}
self.edit_input.text().to_string()
}
fn description(&self, original_index: usize) -> Option<&str> {
self.descriptions
.get(original_index)
.and_then(|d| d.as_deref())
}
}
#[derive(Debug, Clone, Default)]
pub struct ChoiceSelectComponent {
state: Option<ChoiceSelectInner>,
mouse_position: Option<(u16, u16)>,
}
impl ChoiceSelectComponent {
pub fn new() -> Self {
Self {
state: None,
mouse_position: None,
}
}
pub fn open(&mut self, choices: Vec<String>, current_value: &str, anchor: Rect) {
let selected_index = choices.iter().position(|c| c == current_value);
let mut edit_input = InputState::empty();
edit_input.set_text(current_value.to_string());
self.state = Some(ChoiceSelectInner {
choices,
descriptions: Vec::new(),
selected_index,
filter_active: false,
edit_input,
anchor,
});
}
pub fn open_with_descriptions(
&mut self,
choices: Vec<String>,
descriptions: Vec<Option<String>>,
current_value: &str,
anchor: Rect,
) {
self.open(choices, current_value, anchor);
if let Some(ref mut inner) = self.state {
inner.descriptions = descriptions;
}
}
pub fn is_open(&self) -> bool {
self.state.is_some()
}
pub fn close(&mut self) {
self.state = None;
}
pub fn typed_text(&self) -> &str {
self.state
.as_ref()
.map(|s| s.edit_input.text())
.unwrap_or("")
}
pub fn text_before_cursor(&self) -> &str {
self.state
.as_ref()
.map(|s| s.edit_input.text_before_cursor())
.unwrap_or("")
}
pub fn text_after_cursor(&self) -> &str {
self.state
.as_ref()
.map(|s| s.edit_input.text_after_cursor())
.unwrap_or("")
}
pub fn compute_scroll_offset(&self, visible_items: usize) -> usize {
compute_scroll_offset(
self.state.as_ref().and_then(|s| s.selected_index),
visible_items,
)
}
#[cfg(test)]
pub fn selected_index(&self) -> Option<usize> {
self.state.as_ref().and_then(|s| s.selected_index)
}
pub fn filtered_choices(&self) -> Vec<(usize, String)> {
self.state
.as_ref()
.map(|s| s.filtered_choices())
.unwrap_or_default()
}
#[allow(dead_code)] pub fn click_select(&mut self, index: usize) -> Option<ChoiceSelectAction> {
let inner = self.state.as_mut()?;
let filtered = inner.filtered_choices();
if index < filtered.len() {
inner.selected_index = Some(index);
let value = inner.resolve_value();
self.state = None;
Some(ChoiceSelectAction::Selected(value))
} else {
None
}
}
pub fn set_anchor(&mut self, anchor: Rect) {
if let Some(ref mut inner) = self.state {
inner.anchor = anchor;
}
}
pub fn set_mouse_position(&mut self, pos: Option<(u16, u16)>) {
self.mouse_position = pos;
}
}
impl Component for ChoiceSelectComponent {
type Action = ChoiceSelectAction;
fn handle_key(&mut self, key: KeyEvent) -> EventResult<ChoiceSelectAction> {
let Some(ref mut inner) = self.state else {
return EventResult::NotHandled;
};
match key.code {
KeyCode::Enter => {
let value = inner.resolve_value();
self.state = None;
EventResult::Action(ChoiceSelectAction::Selected(value))
}
KeyCode::Esc => {
let typed = inner.edit_input.text().to_string();
self.state = None;
EventResult::Action(ChoiceSelectAction::Cancelled(typed))
}
KeyCode::Up => {
match inner.selected_index {
Some(0) | None => inner.selected_index = None,
Some(idx) => inner.selected_index = Some(idx - 1),
}
EventResult::Consumed
}
KeyCode::Down => {
let filtered_len = inner.filtered_choices().len();
if filtered_len > 0 {
match inner.selected_index {
None => inner.selected_index = Some(0),
Some(idx) if idx + 1 < filtered_len => {
inner.selected_index = Some(idx + 1);
}
_ => {}
}
}
EventResult::Consumed
}
KeyCode::Backspace => {
inner.edit_input.delete_char_backward();
inner.filter_active = true;
inner.selected_index = None;
EventResult::Consumed
}
KeyCode::Left => {
inner.edit_input.move_left();
EventResult::Consumed
}
KeyCode::Right => {
inner.edit_input.move_right();
EventResult::Consumed
}
KeyCode::Char(c) => {
inner.edit_input.insert_char(c);
inner.filter_active = true;
inner.selected_index = None;
EventResult::Consumed
}
_ => EventResult::NotHandled,
}
}
fn handle_mouse(&mut self, _event: MouseEvent, _area: Rect) -> EventResult<ChoiceSelectAction> {
EventResult::NotHandled
}
fn collect_overlays(&mut self) -> Vec<OverlayRequest> {
let Some(ref inner) = self.state else {
return Vec::new();
};
let filtered = inner.filtered_choices();
let anchor = inner.anchor;
let max_choice_len = filtered
.iter()
.map(|(_, c)| c.chars().count())
.max()
.unwrap_or(10) as u16;
let descriptions: Vec<Option<String>> = filtered
.iter()
.map(|(orig_idx, _)| inner.description(*orig_idx).map(|s| s.to_string()))
.collect();
let max_desc_len = descriptions
.iter()
.map(|d| d.as_ref().map(|s| s.chars().count() + 2).unwrap_or(0))
.max()
.unwrap_or(0) as u16;
let width = max_choice_len + max_desc_len + 4;
let max_visible = 10u16;
let height = if filtered.is_empty() {
2 } else {
(filtered.len() as u16).min(max_visible) + 1 };
let labels: Vec<String> = filtered.iter().map(|(_, c)| c.clone()).collect();
let selected_index = inner.selected_index;
vec![OverlayRequest {
anchor,
size: (width, height),
content: Box::new(ChoiceSelectOverlay {
labels,
descriptions,
selected_index,
mouse_position: self.mouse_position,
}),
}]
}
}
struct ChoiceSelectOverlay {
labels: Vec<String>,
descriptions: Vec<Option<String>>,
selected_index: Option<usize>,
mouse_position: Option<(u16, u16)>,
}
impl ChoiceSelectOverlay {
fn hovered_index(&self, area: Rect) -> Option<usize> {
let (col, row) = self.mouse_position?;
let inner_top = area.y; let inner_bottom = area.y + area.height.saturating_sub(1);
if col >= area.x
&& col < area.x + area.width
&& row >= inner_top
&& row < inner_bottom
{
let visible_items = area.height.saturating_sub(1) as usize; let scroll = compute_scroll_offset(self.selected_index, visible_items);
let idx = (row - inner_top) as usize + scroll;
if idx < self.labels.len() {
Some(idx)
} else {
None
}
} else {
None
}
}
}
impl OverlayContent for ChoiceSelectOverlay {
fn render(&self, area: Rect, buf: &mut Buffer, colors: &UiColors) {
let hovered = self.hovered_index(area);
let widget = SelectList::new(
String::new(),
&self.labels,
self.selected_index,
colors.choice,
colors.choice,
colors,
)
.with_descriptions(&self.descriptions)
.with_borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
.with_hovered(hovered);
let mut scroll_state = SelectListScrollState::default();
ratatui::widgets::StatefulWidget::render(widget, area, buf, &mut scroll_state);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_choices() -> Vec<String> {
vec![
"alpha".to_string(),
"beta".to_string(),
"gamma".to_string(),
"delta".to_string(),
]
}
#[test]
fn test_open_close_lifecycle() {
let mut cs = ChoiceSelectComponent::new();
assert!(!cs.is_open());
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
assert!(cs.is_open());
cs.close();
assert!(!cs.is_open());
}
#[test]
fn test_open_preselects_matching_value() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "gamma", Rect::new(0, 0, 20, 1));
let inner = cs.state.as_ref().unwrap();
assert_eq!(inner.selected_index, Some(2));
}
#[test]
fn test_open_no_match_preselects_none() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "nonexistent", Rect::new(0, 0, 20, 1));
let inner = cs.state.as_ref().unwrap();
assert_eq!(inner.selected_index, None);
}
#[test]
fn test_enter_confirms_selected() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
let key = KeyEvent::from(KeyCode::Enter);
let result = cs.handle_key(key);
assert_eq!(
result,
EventResult::Action(ChoiceSelectAction::Selected("beta".to_string()))
);
assert!(!cs.is_open());
}
#[test]
fn test_enter_with_no_selection_uses_typed_text() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "custom", Rect::new(0, 0, 20, 1));
let key = KeyEvent::from(KeyCode::Enter);
let result = cs.handle_key(key);
assert_eq!(
result,
EventResult::Action(ChoiceSelectAction::Selected("custom".to_string()))
);
}
#[test]
fn test_esc_cancels_with_typed_text() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
let key = KeyEvent::from(KeyCode::Esc);
let result = cs.handle_key(key);
assert_eq!(
result,
EventResult::Action(ChoiceSelectAction::Cancelled("beta".to_string()))
);
assert!(!cs.is_open());
}
#[test]
fn test_down_navigates_selection() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "", Rect::new(0, 0, 20, 1));
let down = KeyEvent::from(KeyCode::Down);
assert_eq!(cs.handle_key(down), EventResult::Consumed);
assert_eq!(cs.state.as_ref().unwrap().selected_index, Some(0));
let down = KeyEvent::from(KeyCode::Down);
assert_eq!(cs.handle_key(down), EventResult::Consumed);
assert_eq!(cs.state.as_ref().unwrap().selected_index, Some(1));
}
#[test]
fn test_up_from_first_deselects() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "alpha", Rect::new(0, 0, 20, 1));
let up = KeyEvent::from(KeyCode::Up);
assert_eq!(cs.handle_key(up), EventResult::Consumed);
assert_eq!(cs.state.as_ref().unwrap().selected_index, None);
}
#[test]
fn test_typing_activates_filter_and_clears_selection() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
assert_eq!(cs.state.as_ref().unwrap().selected_index, Some(1));
assert!(!cs.state.as_ref().unwrap().filter_active);
let key = KeyEvent::from(KeyCode::Char('a'));
assert_eq!(cs.handle_key(key), EventResult::Consumed);
let inner = cs.state.as_ref().unwrap();
assert!(inner.filter_active);
assert_eq!(inner.selected_index, None);
assert_eq!(inner.edit_input.text(), "betaa");
}
#[test]
fn test_filtering_narrows_choices() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "", Rect::new(0, 0, 20, 1));
cs.handle_key(KeyEvent::from(KeyCode::Char('a')));
cs.handle_key(KeyEvent::from(KeyCode::Char('l')));
let filtered = cs.filtered_choices();
assert!(filtered.iter().any(|(_, c)| c == "alpha"));
assert!(!filtered.iter().any(|(_, c)| c == "beta"));
assert!(!filtered.iter().any(|(_, c)| c == "gamma"));
assert!(filtered.len() < 4);
}
#[test]
fn test_backspace_activates_filter() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
let key = KeyEvent::from(KeyCode::Backspace);
assert_eq!(cs.handle_key(key), EventResult::Consumed);
let inner = cs.state.as_ref().unwrap();
assert!(inner.filter_active);
assert_eq!(inner.selected_index, None);
assert_eq!(inner.edit_input.text(), "bet");
}
#[test]
fn test_not_handled_when_closed() {
let mut cs = ChoiceSelectComponent::new();
let key = KeyEvent::from(KeyCode::Enter);
assert_eq!(cs.handle_key(key), EventResult::NotHandled);
}
#[test]
fn test_collect_overlays_when_closed() {
let mut cs = ChoiceSelectComponent::new();
assert!(cs.collect_overlays().is_empty());
}
#[test]
fn test_collect_overlays_when_open() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "", Rect::new(5, 10, 20, 1));
let overlays = cs.collect_overlays();
assert_eq!(overlays.len(), 1);
let req = &overlays[0];
assert_eq!(req.anchor, Rect::new(5, 10, 20, 1));
assert!(req.size.0 > 0);
assert_eq!(req.size.1, 5);
}
#[test]
fn test_click_select() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "", Rect::new(0, 0, 20, 1));
let result = cs.click_select(2);
assert_eq!(
result,
Some(ChoiceSelectAction::Selected("gamma".to_string()))
);
assert!(!cs.is_open());
}
#[test]
fn test_click_select_out_of_range() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "", Rect::new(0, 0, 20, 1));
let result = cs.click_select(100);
assert_eq!(result, None);
assert!(cs.is_open()); }
#[test]
fn test_open_with_descriptions() {
let mut cs = ChoiceSelectComponent::new();
let descs = vec![
Some("First letter".to_string()),
None,
Some("Third letter".to_string()),
None,
];
cs.open_with_descriptions(make_choices(), descs.clone(), "", Rect::new(0, 0, 20, 1));
let inner = cs.state.as_ref().unwrap();
assert_eq!(inner.descriptions, descs);
}
#[test]
fn test_filtering_matches_description() {
let mut cs = ChoiceSelectComponent::new();
let descs = vec![
Some("Authentication plugin".to_string()),
Some("Usage analytics".to_string()),
Some("Redis cache layer".to_string()),
];
cs.open_with_descriptions(
vec!["auth".to_string(), "analytics".to_string(), "cache".to_string()],
descs,
"",
Rect::new(0, 0, 40, 1),
);
cs.handle_key(KeyEvent::from(KeyCode::Char('r')));
cs.handle_key(KeyEvent::from(KeyCode::Char('e')));
cs.handle_key(KeyEvent::from(KeyCode::Char('d')));
cs.handle_key(KeyEvent::from(KeyCode::Char('i')));
cs.handle_key(KeyEvent::from(KeyCode::Char('s')));
let filtered = cs.filtered_choices();
assert_eq!(filtered.len(), 1, "Only 'cache' (whose description contains 'redis') should match");
assert_eq!(filtered[0].1, "cache");
}
#[test]
fn test_filtering_matches_both_name_and_description() {
let mut cs = ChoiceSelectComponent::new();
let descs = vec![
Some("Authentication plugin".to_string()),
Some("Analytics module".to_string()),
Some("Redis cache layer".to_string()),
];
cs.open_with_descriptions(
vec!["auth".to_string(), "analytics".to_string(), "cache".to_string()],
descs,
"",
Rect::new(0, 0, 40, 1),
);
cs.handle_key(KeyEvent::from(KeyCode::Char('a')));
cs.handle_key(KeyEvent::from(KeyCode::Char('n')));
let filtered = cs.filtered_choices();
assert!(
filtered.iter().any(|(_, c)| c == "auth"),
"auth should match via description 'Authentication plugin'"
);
assert!(
filtered.iter().any(|(_, c)| c == "analytics"),
"analytics should match via its name"
);
}
#[test]
fn test_down_does_not_exceed_filtered_count() {
let mut cs = ChoiceSelectComponent::new();
cs.open(vec!["a".to_string(), "b".to_string()], "", Rect::new(0, 0, 20, 1));
cs.handle_key(KeyEvent::from(KeyCode::Down)); cs.handle_key(KeyEvent::from(KeyCode::Down)); cs.handle_key(KeyEvent::from(KeyCode::Down));
assert_eq!(cs.state.as_ref().unwrap().selected_index, Some(1));
}
#[test]
fn test_overlay_renders_without_panic() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_choices(), "beta", Rect::new(0, 0, 20, 1));
let overlays = cs.collect_overlays();
assert_eq!(overlays.len(), 1);
let req = &overlays[0];
let area = Rect::new(0, 0, req.size.0, req.size.1);
let mut buf = Buffer::empty(area);
let palette = ratatui_themes::Theme::default().palette();
let colors = UiColors::from_palette(&palette);
req.content.render(area, &mut buf, &colors);
}
fn make_many_choices() -> Vec<String> {
(0..15).map(|i| format!("item_{i:02}")).collect()
}
#[test]
fn test_click_select_with_scroll_offset() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_many_choices(), "", Rect::new(0, 0, 20, 1));
let down = KeyEvent::from(KeyCode::Down);
for _ in 0..13 {
cs.handle_key(down);
}
assert_eq!(cs.selected_index(), Some(12));
let visible = 10usize;
let scroll = cs.compute_scroll_offset(visible);
assert_eq!(scroll, 3, "scroll_offset should be 3 when selected=12, visible=10");
let clicked_index = 5 + scroll;
let result = cs.click_select(clicked_index);
assert_eq!(
result,
Some(ChoiceSelectAction::Selected("item_08".to_string())),
"Should select item_08 (visual row 5 + scroll 3)"
);
}
#[test]
fn test_scroll_offset_computation() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_many_choices(), "", Rect::new(0, 0, 20, 1));
assert_eq!(cs.compute_scroll_offset(10), 0);
let down = KeyEvent::from(KeyCode::Down);
for _ in 0..6 {
cs.handle_key(down);
}
assert_eq!(cs.selected_index(), Some(5));
assert_eq!(cs.compute_scroll_offset(10), 0);
for _ in 0..5 {
cs.handle_key(down);
}
assert_eq!(cs.selected_index(), Some(10));
assert_eq!(cs.compute_scroll_offset(10), 1);
for _ in 0..4 {
cs.handle_key(down);
}
assert_eq!(cs.selected_index(), Some(14));
assert_eq!(cs.compute_scroll_offset(10), 5);
}
#[test]
fn test_hover_accounts_for_scroll() {
let mut cs = ChoiceSelectComponent::new();
cs.open(make_many_choices(), "", Rect::new(0, 0, 20, 1));
let down = KeyEvent::from(KeyCode::Down);
for _ in 0..13 {
cs.handle_key(down);
}
assert_eq!(cs.selected_index(), Some(12));
cs.set_mouse_position(Some((5, 4)));
let overlays = cs.collect_overlays();
assert_eq!(overlays.len(), 1);
let req = &overlays[0];
let area = Rect::new(0, 1, req.size.0, req.size.1);
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 20));
let palette = ratatui_themes::Theme::default().palette();
let colors = UiColors::from_palette(&palette);
req.content.render(area, &mut buf, &colors);
let hover_row = 4u16; let cell = buf.cell((1, hover_row)).unwrap(); assert_ne!(
cell.bg,
ratatui::style::Color::Reset,
"Hovered row should have a background highlight"
);
}
}