use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SelectableListMessage {
Up,
Down,
First,
Last,
PageUp(usize),
PageDown(usize),
Select,
SetFilter(String),
ClearFilter,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SelectableListOutput<T: Clone> {
Selected(T),
SelectionChanged(usize),
FilterChanged(String),
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SelectableListState<T: Clone> {
items: Vec<T>,
#[cfg_attr(feature = "serialization", serde(skip))]
list_state: ListState,
filter_text: String,
filtered_indices: Vec<usize>,
#[cfg_attr(feature = "serialization", serde(skip))]
scroll: ScrollState,
}
impl<T: Clone + PartialEq> PartialEq for SelectableListState<T> {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
&& self.list_state.selected() == other.list_state.selected()
&& self.filter_text == other.filter_text
}
}
impl<T: Clone> Default for SelectableListState<T> {
fn default() -> Self {
Self {
items: Vec::new(),
list_state: ListState::default(),
filter_text: String::new(),
filtered_indices: Vec::new(),
scroll: ScrollState::default(),
}
}
}
impl<T: Clone> SelectableListState<T> {
pub fn new(items: Vec<T>) -> Self {
Self::with_items(items)
}
pub fn with_items(items: Vec<T>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let scroll = ScrollState::new(filtered_indices.len());
let mut state = Self {
items,
list_state: ListState::default(),
filter_text: String::new(),
filtered_indices,
scroll,
};
if !state.items.is_empty() {
state.list_state.select(Some(0));
}
state
}
pub fn with_selected(mut self, index: usize) -> Self {
if self.items.is_empty() {
return self;
}
let clamped = index.min(self.items.len() - 1);
if let Some(filtered_pos) = self.filtered_indices.iter().position(|&fi| fi == clamped) {
self.list_state.select(Some(filtered_pos));
}
self
}
pub fn items(&self) -> &[T] {
&self.items
}
pub fn set_items(&mut self, items: Vec<T>) {
self.items = items;
self.filter_text.clear();
self.filtered_indices = (0..self.items.len()).collect();
self.scroll.set_content_length(self.filtered_indices.len());
if self.filtered_indices.is_empty() {
self.list_state.select(None);
} else {
let current = self.list_state.selected().unwrap_or(0);
let new_index = current.min(self.filtered_indices.len().saturating_sub(1));
self.list_state.select(Some(new_index));
}
}
pub fn selected_index(&self) -> Option<usize> {
self.list_state
.selected()
.and_then(|i| self.filtered_indices.get(i).copied())
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&T> {
self.selected_index().and_then(|i| self.items.get(i))
}
pub fn select(&mut self, index: Option<usize>) {
match index {
Some(i) if i < self.items.len() => {
if let Some(filtered_pos) = self.filtered_indices.iter().position(|&fi| fi == i) {
self.list_state.select(Some(filtered_pos));
}
}
Some(_) => {} None => self.list_state.select(None),
}
}
pub fn set_selected(&mut self, index: Option<usize>) {
match index {
Some(i) => {
if self.items.is_empty() {
return;
}
let clamped = i.min(self.items.len() - 1);
if let Some(filtered_pos) =
self.filtered_indices.iter().position(|&fi| fi == clamped)
{
self.list_state.select(Some(filtered_pos));
}
}
None => self.list_state.select(None),
}
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn filter_text(&self) -> &str {
&self.filter_text
}
pub fn visible_count(&self) -> usize {
self.filtered_indices.len()
}
}
impl<T: Clone + std::fmt::Display + 'static> SelectableListState<T> {
pub fn update_item(&mut self, index: usize, f: impl FnOnce(&mut T)) {
if let Some(item) = self.items.get_mut(index) {
f(item);
self.apply_filter();
}
}
pub fn push_item(&mut self, item: T) {
self.items.push(item);
self.apply_filter();
}
pub fn remove_item(&mut self, index: usize) -> Option<T> {
if index >= self.items.len() {
return None;
}
let item = self.items.remove(index);
if let Some(sel) = self.list_state.selected() {
if let Some(&orig_idx) = self.filtered_indices.get(sel) {
if orig_idx >= self.items.len() {
}
}
}
self.apply_filter();
Some(item)
}
pub fn set_filter_text(&mut self, text: &str) {
self.filter_text = text.to_string();
self.apply_filter();
}
pub fn clear_filter(&mut self) {
self.filter_text.clear();
self.apply_filter();
}
fn apply_filter(&mut self) {
let previously_selected = self.selected_index();
if self.filter_text.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let filter_lower = self.filter_text.to_lowercase();
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter(|(_, item)| format!("{}", item).to_lowercase().contains(&filter_lower))
.map(|(i, _)| i)
.collect();
}
self.scroll.set_content_length(self.filtered_indices.len());
if let Some(prev_idx) = previously_selected {
if let Some(new_pos) = self.filtered_indices.iter().position(|&i| i == prev_idx) {
self.list_state.select(Some(new_pos));
return;
}
}
if self.filtered_indices.is_empty() {
self.list_state.select(None);
} else {
self.list_state.select(Some(0));
}
}
pub fn update(&mut self, msg: SelectableListMessage) -> Option<SelectableListOutput<T>> {
SelectableList::<T>::update(self, msg)
}
}
pub struct SelectableList<T: Clone>(std::marker::PhantomData<T>);
impl<T: Clone + std::fmt::Display + 'static> Component for SelectableList<T> {
type State = SelectableListState<T>;
type Message = SelectableListMessage;
type Output = SelectableListOutput<T>;
fn init() -> Self::State {
SelectableListState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Up | Key::Char('k') => Some(SelectableListMessage::Up),
Key::Down | Key::Char('j') => Some(SelectableListMessage::Down),
Key::Char('g') if key.modifiers.shift() => Some(SelectableListMessage::Last),
Key::Home | Key::Char('g') => Some(SelectableListMessage::First),
Key::End => Some(SelectableListMessage::Last),
Key::Enter => Some(SelectableListMessage::Select),
Key::PageUp => Some(SelectableListMessage::PageUp(10)),
Key::PageDown => Some(SelectableListMessage::PageDown(10)),
_ => None,
}
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SelectableListMessage::SetFilter(text) => {
state.set_filter_text(&text);
return Some(SelectableListOutput::FilterChanged(text));
}
SelectableListMessage::ClearFilter => {
state.clear_filter();
return Some(SelectableListOutput::FilterChanged(String::new()));
}
_ => {}
}
if state.filtered_indices.is_empty() {
return None;
}
let len = state.filtered_indices.len();
let current = state.list_state.selected().unwrap_or(0);
match msg {
SelectableListMessage::Up => {
let new_index = current.saturating_sub(1);
if new_index != current {
state.list_state.select(Some(new_index));
let orig = state.filtered_indices[new_index];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::Down => {
let new_index = (current + 1).min(len - 1);
if new_index != current {
state.list_state.select(Some(new_index));
let orig = state.filtered_indices[new_index];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::First => {
if current != 0 {
state.list_state.select(Some(0));
let orig = state.filtered_indices[0];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::Last => {
let last = len - 1;
if current != last {
state.list_state.select(Some(last));
let orig = state.filtered_indices[last];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::PageUp(page_size) => {
let new_index = current.saturating_sub(page_size);
if new_index != current {
state.list_state.select(Some(new_index));
let orig = state.filtered_indices[new_index];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::PageDown(page_size) => {
let new_index = (current + page_size).min(len - 1);
if new_index != current {
state.list_state.select(Some(new_index));
let orig = state.filtered_indices[new_index];
return Some(SelectableListOutput::SelectionChanged(orig));
}
}
SelectableListMessage::Select => {
let orig = state.filtered_indices[current];
if let Some(item) = state.items.get(orig).cloned() {
return Some(SelectableListOutput::Selected(item));
}
}
SelectableListMessage::SetFilter(_) | SelectableListMessage::ClearFilter => {
unreachable!("handled above")
}
}
None
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
let mut ann = crate::annotation::Annotation::list("selectable_list")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled);
if let Some(idx) = state.selected_index() {
ann = ann.with_selected(true).with_value(idx.to_string());
}
reg.register(ctx.area, ann);
});
let mut items = Vec::with_capacity(state.filtered_indices.len());
for &idx in &state.filtered_indices {
items.push(ListItem::new(state.items[idx].to_string()));
}
let highlight_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.selected_highlight_style(ctx.focused)
};
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(ctx.area);
let list = List::new(items)
.block(block)
.highlight_style(highlight_style)
.highlight_symbol("> ");
let mut list_state = state.list_state.clone();
ctx.frame
.render_stateful_widget(list, ctx.area, &mut list_state);
if state.filtered_indices.len() > inner.height as usize {
let mut bar_scroll = ScrollState::new(state.filtered_indices.len());
bar_scroll.set_viewport_height(inner.height as usize);
bar_scroll.set_offset(list_state.offset());
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
}
}
#[cfg(test)]
mod tests;