use std::collections::HashMap;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthChar;
use crate::ui::fuzzy::fuzzy_match;
use crate::ui::widgets::preview_helpers::{
calculate_preview_height, render_preview_metadata, PREVIEW_MAX_WIDTH,
};
use crate::ui::{SelectItem, TextInputState};
#[derive(Debug, Clone)]
pub struct SelectState {
pub items: Vec<SelectItem>,
pub cursor_index: usize,
pub scroll_offset: usize,
pub filtered_indices: Vec<usize>,
pub max_display: usize,
pub match_indices: HashMap<usize, Vec<usize>>,
}
impl SelectState {
pub fn new(items: Vec<SelectItem>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
Self {
items,
cursor_index: 0,
scroll_offset: 0,
filtered_indices,
max_display: 10,
match_indices: HashMap::new(),
}
}
pub fn with_max_display(mut self, max_display: usize) -> Self {
self.max_display = max_display;
self
}
pub fn move_up(&mut self) {
if self.cursor_index > 0 {
self.cursor_index -= 1;
if self.cursor_index < self.scroll_offset {
self.scroll_offset = self.cursor_index;
}
}
}
pub fn move_down(&mut self) {
if self.cursor_index + 1 < self.filtered_indices.len() {
self.cursor_index += 1;
if self.cursor_index >= self.scroll_offset + self.max_display {
self.scroll_offset = self.cursor_index - self.max_display + 1;
}
}
}
pub fn adjust_max_display(&mut self, actual_available: usize) {
self.max_display = actual_available.max(1);
if self.cursor_index >= self.scroll_offset + self.max_display {
self.scroll_offset = self.cursor_index.saturating_sub(self.max_display - 1);
}
}
pub fn update_filter(&mut self, query: &str) {
self.match_indices.clear();
if query.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
self.adjust_cursor_and_scroll();
return;
}
let mut matches: Vec<(usize, i64, Vec<usize>)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
fuzzy_match(query, &item.label).map(|m| (i, m.score, m.indices))
})
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
self.filtered_indices = matches.iter().map(|(i, _, _)| *i).collect();
self.match_indices = matches
.into_iter()
.map(|(i, _, indices)| (i, indices))
.collect();
self.adjust_cursor_and_scroll();
}
fn adjust_cursor_and_scroll(&mut self) {
let max_index = self.filtered_indices.len().saturating_sub(1);
self.cursor_index = self.cursor_index.min(max_index);
self.scroll_offset = self.scroll_offset.min(self.cursor_index);
}
pub fn selected_item(&self) -> Option<&SelectItem> {
if self.filtered_indices.is_empty() {
None
} else {
let item_idx = self.filtered_indices[self.cursor_index];
Some(&self.items[item_idx])
}
}
}
pub struct SelectListWidget<'a> {
title: &'a str,
placeholder: &'a str,
input: &'a TextInputState,
items: &'a [SelectItem],
filtered_indices: &'a [usize],
selected_index: usize,
scroll_offset: usize,
max_display: usize,
#[allow(dead_code)]
preview: Option<&'a str>,
match_indices: Option<&'a HashMap<usize, Vec<usize>>>,
legend: Option<&'a str>,
}
impl<'a> SelectListWidget<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
title: &'a str,
placeholder: &'a str,
input: &'a TextInputState,
items: &'a [SelectItem],
filtered_indices: &'a [usize],
selected_index: usize,
scroll_offset: usize,
max_display: usize,
preview: Option<&'a str>,
) -> Self {
Self {
title,
placeholder,
input,
items,
filtered_indices,
selected_index,
scroll_offset,
max_display,
preview,
match_indices: None,
legend: None,
}
}
pub fn with_state(
title: &'a str,
placeholder: &'a str,
input: &'a TextInputState,
state: &'a SelectState,
preview: Option<&'a str>,
) -> Self {
Self {
title,
placeholder,
input,
items: &state.items,
filtered_indices: &state.filtered_indices,
selected_index: state.cursor_index,
scroll_offset: state.scroll_offset,
max_display: state.max_display,
preview,
match_indices: Some(&state.match_indices),
legend: None,
}
}
pub fn with_legend(mut self, legend: &'a str) -> Self {
self.legend = Some(legend);
self
}
}
#[allow(clippy::too_many_arguments)]
fn render_label_with_highlight(
buf: &mut Buffer,
x: u16,
y: u16,
label: &str,
match_indices: &[usize],
base_style: Style,
highlight_style: Style,
max_width: u16,
) {
let mut current_x = x;
for (i, c) in label.chars().enumerate() {
if current_x >= x + max_width {
break;
}
let style = if match_indices.contains(&i) {
highlight_style
} else {
base_style
};
buf.set_string(current_x, y, c.to_string(), style);
let char_width = UnicodeWidthChar::width(c).unwrap_or(1) as u16;
current_x += char_width;
}
}
fn adjust_scroll_offset(
desired_scroll_offset: usize,
cursor_index: usize,
max_display: usize,
total_items: usize,
) -> usize {
if total_items == 0 {
return 0;
}
let max_display = max_display.max(1);
let mut scroll_offset = desired_scroll_offset.min(cursor_index);
if cursor_index >= scroll_offset + max_display {
scroll_offset = cursor_index.saturating_sub(max_display - 1);
}
scroll_offset.min(total_items.saturating_sub(max_display))
}
const MIN_WIDTH: u16 = 20;
const MIN_HEIGHT: u16 = 10;
impl Widget for SelectListWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
let message = format!(
"Terminal too small ({}x{}). Need {}x{}",
area.width, area.height, MIN_WIDTH, MIN_HEIGHT
);
let truncated = &message[..message.len().min(area.width as usize)];
let style = Style::default().fg(Color::Yellow);
let y = area.y + area.height.saturating_sub(1) / 2;
buf.set_string(area.x, y, truncated, style);
return;
}
const TITLE_HEIGHT: u16 = 2; const STATS_HEIGHT: u16 = 2; const INPUT_HEIGHT: u16 = 2; const LIST_HEADER_HEIGHT: u16 = 1; const HELP_HEIGHT: u16 = 2; const MAX_PREVIEW_HEIGHT: u16 = 15;
const MIN_LIST_HEIGHT: u16 = 4;
let ideal_preview_height = if !self.filtered_indices.is_empty() {
let selected_item_idx = self.filtered_indices[self.selected_index];
let selected_item = &self.items[selected_item_idx];
calculate_preview_height(&selected_item.metadata).min(MAX_PREVIEW_HEIGHT)
} else {
0
};
let fixed_top_height = TITLE_HEIGHT + STATS_HEIGHT + INPUT_HEIGHT + LIST_HEADER_HEIGHT;
let available_height = area.height.saturating_sub(fixed_top_height + HELP_HEIGHT);
let preview_with_margin = if ideal_preview_height >= 3 {
ideal_preview_height + 1
} else {
0
};
let (list_max_height, preview_height) =
if available_height >= MIN_LIST_HEIGHT + preview_with_margin {
(
available_height.saturating_sub(preview_with_margin),
ideal_preview_height,
)
} else if available_height >= MIN_LIST_HEIGHT {
let remaining = available_height.saturating_sub(MIN_LIST_HEIGHT);
let adjusted_preview = if remaining >= 4 {
(remaining.saturating_sub(1)).max(3)
} else {
0 };
(MIN_LIST_HEIGHT, adjusted_preview)
} else {
(available_height, 0)
};
let mut y = area.y;
let title_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
buf.set_string(area.x, y, self.title, title_style);
y += 2;
let stats = format!(
"{} / {} items • {} of {}",
self.filtered_indices.len(),
self.items.len(),
if self.filtered_indices.is_empty() {
0
} else {
self.selected_index + 1
},
self.filtered_indices.len(),
);
buf.set_string(area.x, y, &stats, Style::default().fg(Color::DarkGray));
y += 2;
buf.set_string(
area.x,
y,
self.placeholder,
Style::default().fg(Color::DarkGray),
);
y += 1;
buf.set_string(
area.x,
y,
"❯ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let before_cursor = self.input.text_before_cursor();
buf.set_string(
area.x + 2,
y,
&before_cursor,
Style::default().fg(Color::White),
);
let cursor_x = area.x + 2 + before_cursor.chars().count() as u16;
if cursor_x < area.x + area.width {
buf.set_string(cursor_x, y, "█", Style::default().fg(Color::Cyan));
}
let after_cursor = self.input.text_after_cursor();
if cursor_x + 1 < area.x + area.width {
buf.set_string(
cursor_x + 1,
y,
&after_cursor,
Style::default().fg(Color::White),
);
}
y += 2;
let list_end_y = y + list_max_height;
if self.filtered_indices.is_empty() {
buf.set_string(
area.x,
y,
"No matches found",
Style::default().fg(Color::Red),
);
y += 2;
} else {
let list_height = list_max_height as usize;
let total_items = self.filtered_indices.len();
let cursor_index = self.selected_index.min(total_items.saturating_sub(1));
let desired_scroll_offset = self.scroll_offset.min(cursor_index);
let desired_max_display = self.max_display.max(1);
let min_visible_items = 3usize.min(list_height).max(1);
let mut show_top_more = false;
let mut show_bottom_more = false;
let mut effective_max_display = if list_height > 0 {
desired_max_display.min(list_height).max(1)
} else {
0
};
let mut effective_scroll_offset = desired_scroll_offset;
if effective_max_display > 0 {
for _ in 0..3 {
let indicator_lines = (show_top_more as usize) + (show_bottom_more as usize);
effective_max_display = desired_max_display
.min(list_height.saturating_sub(indicator_lines))
.max(1);
effective_scroll_offset = adjust_scroll_offset(
desired_scroll_offset,
cursor_index,
effective_max_display,
total_items,
);
let visible_end =
(effective_scroll_offset + effective_max_display).min(total_items);
let hidden_above = effective_scroll_offset > 0;
let hidden_below = visible_end < total_items;
let mut next_show_top_more = hidden_above;
let mut next_show_bottom_more = hidden_below;
while (next_show_top_more as usize + next_show_bottom_more as usize) > 0
&& list_height.saturating_sub(
next_show_top_more as usize + next_show_bottom_more as usize,
) < min_visible_items
{
if next_show_top_more {
next_show_top_more = false;
} else {
next_show_bottom_more = false;
}
}
if next_show_top_more == show_top_more
&& next_show_bottom_more == show_bottom_more
{
show_top_more = next_show_top_more;
show_bottom_more = next_show_bottom_more;
break;
}
show_top_more = next_show_top_more;
show_bottom_more = next_show_bottom_more;
}
let indicator_lines = (show_top_more as usize) + (show_bottom_more as usize);
effective_max_display = desired_max_display
.min(list_height.saturating_sub(indicator_lines))
.max(1);
effective_scroll_offset = adjust_scroll_offset(
desired_scroll_offset,
cursor_index,
effective_max_display,
total_items,
);
}
let visible_end = (effective_scroll_offset + effective_max_display).min(total_items);
if show_top_more && y < list_end_y {
let msg = format!("↑ {} more", effective_scroll_offset);
buf.set_string(area.x, y, &msg, Style::default().fg(Color::Yellow));
y += 1;
}
for display_idx in effective_scroll_offset..visible_end {
if y >= list_end_y {
break;
}
let item_idx = self.filtered_indices[display_idx];
let item = &self.items[item_idx];
let is_selected = display_idx == self.selected_index;
let (prefix, base_style) = if is_selected {
(
"▶ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(Color::White))
};
buf.set_string(area.x, y, prefix, base_style);
if let Some(indices) = self.match_indices.and_then(|m| m.get(&item_idx)) {
let highlight_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
render_label_with_highlight(
buf,
area.x + 2,
y,
&item.label,
indices,
base_style,
highlight_style,
area.width.saturating_sub(2),
);
} else {
buf.set_string(area.x + 2, y, &item.label, base_style);
}
y += 1;
}
let hidden_below = self.filtered_indices.len().saturating_sub(visible_end);
if show_bottom_more && hidden_below > 0 && y < list_end_y {
let msg = format!("↓ {} more", hidden_below);
buf.set_string(area.x, y, &msg, Style::default().fg(Color::Yellow));
y += 1;
}
y += 1;
if !self.filtered_indices.is_empty() && preview_height >= 3 {
let selected_item_idx = self.filtered_indices[self.selected_index];
let selected_item = &self.items[selected_item_idx];
let preview_width = area.width.min(PREVIEW_MAX_WIDTH);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Preview ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let preview_area = Rect::new(area.x, y, preview_width, preview_height);
let inner = block.inner(preview_area);
block.render(preview_area, buf);
buf.set_string(
inner.x,
inner.y,
&selected_item.label,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
if let Some(ref metadata) = selected_item.metadata {
render_preview_metadata(buf, inner, metadata, preview_width);
}
y += preview_height + 1;
}
}
if let Some(legend) = self.legend {
if y < area.y + area.height {
buf.set_string(area.x, y, legend, Style::default().fg(Color::DarkGray));
y += 1;
}
}
if y < area.y + area.height {
let help_line = Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" navigate • "),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(" select • "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(" cancel"),
]);
buf.set_line(area.x, y, &help_line, area.width);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_select_list_widget_creation() {
let input = TextInputState::new();
let items = vec![SelectItem {
label: "Test".to_string(),
value: "test".to_string(),
description: None,
metadata: None,
}];
let filtered_indices = vec![0];
let widget = SelectListWidget::new(
"Title",
"Search...",
&input,
&items,
&filtered_indices,
0,
0,
10,
None,
);
assert_eq!(widget.title, "Title");
assert_eq!(widget.selected_index, 0);
}
fn create_test_items() -> Vec<SelectItem> {
vec![
SelectItem {
label: "feature/auth".to_string(),
value: "/path/auth".to_string(),
description: None,
metadata: None,
},
SelectItem {
label: "main".to_string(),
value: "/path/main".to_string(),
description: None,
metadata: None,
},
SelectItem {
label: "feature/dashboard".to_string(),
value: "/path/dashboard".to_string(),
description: None,
metadata: None,
},
SelectItem {
label: "feature/settings".to_string(),
value: "/path/settings".to_string(),
description: None,
metadata: None,
},
]
}
#[test]
fn test_select_state_new() {
let items = create_test_items();
let state = SelectState::new(items);
assert_eq!(state.items.len(), 4);
assert_eq!(state.cursor_index, 0);
assert_eq!(state.filtered_indices, vec![0, 1, 2, 3]);
assert_eq!(state.max_display, 10);
}
#[test]
fn test_select_state_move_up_down() {
let items = create_test_items();
let mut state = SelectState::new(items);
assert_eq!(state.cursor_index, 0);
state.move_down();
assert_eq!(state.cursor_index, 1);
state.move_down();
assert_eq!(state.cursor_index, 2);
state.move_up();
assert_eq!(state.cursor_index, 1);
state.cursor_index = 0;
state.move_up();
assert_eq!(state.cursor_index, 0);
state.cursor_index = 3;
state.move_down();
assert_eq!(state.cursor_index, 3);
}
#[test]
fn test_select_state_update_filter() {
let items = create_test_items();
let mut state = SelectState::new(items);
state.update_filter("feature");
assert_eq!(state.filtered_indices.len(), 3);
assert!(state.filtered_indices.contains(&0));
assert!(state.filtered_indices.contains(&2));
assert!(state.filtered_indices.contains(&3));
state.update_filter("auth");
assert_eq!(state.filtered_indices.len(), 1);
assert!(state.filtered_indices.contains(&0));
state.update_filter("");
assert_eq!(state.filtered_indices, vec![0, 1, 2, 3]);
}
#[test]
fn test_select_state_fuzzy_match() {
let items = create_test_items();
let mut state = SelectState::new(items);
state.update_filter("fauth");
assert!(!state.filtered_indices.is_empty());
assert!(state.filtered_indices.contains(&0));
}
#[test]
fn test_select_state_match_indices() {
let items = create_test_items();
let mut state = SelectState::new(items);
state.update_filter("auth");
assert!(!state.match_indices.is_empty());
assert!(state.match_indices.contains_key(&0));
let indices = state.match_indices.get(&0).unwrap();
assert!(!indices.is_empty());
}
#[test]
fn test_select_state_empty_query_clears_match_indices() {
let items = create_test_items();
let mut state = SelectState::new(items);
state.update_filter("auth");
assert!(!state.match_indices.is_empty());
state.update_filter("");
assert!(state.match_indices.is_empty());
}
#[test]
fn test_select_state_selected_item() {
let items = create_test_items();
let mut state = SelectState::new(items);
let selected = state.selected_item().unwrap();
assert_eq!(selected.label, "feature/auth");
state.cursor_index = 2;
let selected = state.selected_item().unwrap();
assert_eq!(selected.label, "feature/dashboard");
}
#[test]
fn test_select_state_selected_item_empty() {
let state = SelectState::new(vec![]);
assert!(state.selected_item().is_none());
}
#[test]
fn test_render_keeps_cursor_visible_when_list_height_is_small() {
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
let input = TextInputState::new();
let items: Vec<SelectItem> = (0..20)
.map(|i| SelectItem {
label: format!("item-{i:02}"),
value: format!("/path/{i:02}"),
description: None,
metadata: None,
})
.collect();
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let widget = SelectListWidget::new(
"Title",
"Search...",
&input,
&items,
&filtered_indices,
6,
0,
10,
None,
);
let area = Rect::new(0, 0, 80, 16);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let mut cursor_line = None;
for y in 0..area.height {
let line: String = (0..area.width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect();
if line.contains('▶') {
cursor_line = Some(line);
break;
}
}
let cursor_line = cursor_line.expect("cursor line should be rendered");
assert!(cursor_line.contains("item-06"));
}
}