mod render;
use std::fmt::Display;
use std::marker::PhantomData;
use std::sync::Arc;
use ratatui::widgets::ListState;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
type MatcherFn = dyn Fn(&str, &str) -> Option<i64> + Send + Sync;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchableListMessage {
FilterChanged(String),
FilterChar(char),
FilterBackspace,
FilterClear,
Up,
Down,
First,
Last,
PageUp(usize),
PageDown(usize),
Select,
ToggleFocus,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchableListOutput<T: Clone> {
Selected(T),
SelectionChanged(usize),
FilterChanged(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub(super) enum Focus {
Filter,
List,
}
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SearchableListState<T: Clone> {
pub(super) items: Vec<T>,
pub(super) filtered_indices: Vec<usize>,
pub(super) filter_text: String,
pub(super) selected: Option<usize>,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(super) list_state: ListState,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(super) scroll: ScrollState,
pub(super) internal_focus: Focus,
pub(super) placeholder: String,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(super) matcher: Option<Arc<MatcherFn>>,
}
impl<T: Clone> Clone for SearchableListState<T> {
fn clone(&self) -> Self {
Self {
items: self.items.clone(),
filtered_indices: self.filtered_indices.clone(),
filter_text: self.filter_text.clone(),
selected: self.selected,
list_state: self.list_state.clone(),
scroll: self.scroll.clone(),
internal_focus: self.internal_focus.clone(),
placeholder: self.placeholder.clone(),
matcher: self.matcher.clone(),
}
}
}
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for SearchableListState<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SearchableListState")
.field("items", &self.items)
.field("filtered_indices", &self.filtered_indices)
.field("filter_text", &self.filter_text)
.field("selected", &self.selected)
.field("list_state", &self.list_state)
.field("internal_focus", &self.internal_focus)
.field("placeholder", &self.placeholder)
.field("matcher", &self.matcher.as_ref().map(|_| "..."))
.finish()
}
}
impl<T: Clone + PartialEq> PartialEq for SearchableListState<T> {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
&& self.filtered_indices == other.filtered_indices
&& self.filter_text == other.filter_text
&& self.selected == other.selected
&& self.list_state.selected() == other.list_state.selected()
&& self.internal_focus == other.internal_focus
&& self.placeholder == other.placeholder
}
}
impl<T: Clone> Default for SearchableListState<T> {
fn default() -> Self {
Self {
items: Vec::new(),
filtered_indices: Vec::new(),
filter_text: String::new(),
selected: None,
list_state: ListState::default(),
scroll: ScrollState::default(),
internal_focus: Focus::Filter,
placeholder: "Type to filter...".to_string(),
matcher: None,
}
}
}
impl<T: Clone> SearchableListState<T> {
pub fn new(items: Vec<T>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let selected = if items.is_empty() { None } else { Some(0) };
let mut list_state = ListState::default();
list_state.select(selected);
let scroll = ScrollState::new(filtered_indices.len());
Self {
items,
filtered_indices,
filter_text: String::new(),
selected,
list_state,
scroll,
internal_focus: Focus::Filter,
placeholder: "Type to filter...".to_string(),
matcher: None,
}
}
pub fn items(&self) -> &[T] {
&self.items
}
pub fn filtered_items(&self) -> Vec<&T> {
self.filtered_indices
.iter()
.filter_map(|&i| self.items.get(i))
.collect()
}
pub fn filtered_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn filter_text(&self) -> &str {
&self.filter_text
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&T> {
self.selected
.and_then(|si| self.filtered_indices.get(si))
.and_then(|&i| self.items.get(i))
}
pub fn set_selected(&mut self, index: Option<usize>) {
match index {
Some(i) => {
if self.filtered_indices.is_empty() {
return;
}
self.selected = Some(i.min(self.filtered_indices.len() - 1));
}
None => self.selected = None,
}
self.sync_list_state();
}
pub fn is_filter_focused(&self) -> bool {
self.internal_focus == Focus::Filter
}
pub fn is_list_focused(&self) -> bool {
self.internal_focus == Focus::List
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn with_matcher(
mut self,
matcher: impl Fn(&str, &str) -> Option<i64> + Send + Sync + 'static,
) -> Self {
self.matcher = Some(Arc::new(matcher));
self
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
fn sync_list_state(&mut self) {
self.list_state.select(self.selected);
}
}
impl<T: Clone + Display + 'static> SearchableListState<T> {
pub fn push_item(&mut self, item: T) {
self.items.push(item);
self.refilter();
}
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.refilter();
}
}
pub fn remove_item(&mut self, index: usize) -> Option<T> {
if index >= self.items.len() {
return None;
}
let item = self.items.remove(index);
self.refilter();
Some(item)
}
pub fn set_items(&mut self, items: Vec<T>) {
self.items = items;
self.refilter();
}
pub fn update(&mut self, msg: SearchableListMessage) -> Option<SearchableListOutput<T>> {
SearchableList::<T>::update(self, msg)
}
fn refilter(&mut self) {
let filter_lower = self.filter_text.to_lowercase();
if filter_lower.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else if let Some(ref matcher) = self.matcher {
let mut scored: Vec<(usize, i64)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
let text = format!("{}", item);
matcher(&self.filter_text, &text).map(|score| (i, score))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
} else {
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter(|(_, item)| {
let text = format!("{}", item).to_lowercase();
text.contains(&filter_lower)
})
.map(|(i, _)| i)
.collect();
};
self.scroll.set_content_length(self.filtered_indices.len());
if self.filtered_indices.is_empty() {
self.selected = None;
} else {
self.selected = Some(0);
}
self.sync_list_state();
}
}
pub struct SearchableList<T: Clone>(PhantomData<T>);
impl<T: Clone + Display + 'static> Component for SearchableList<T> {
type State = SearchableListState<T>;
type Message = SearchableListMessage;
type Output = SearchableListOutput<T>;
fn init() -> Self::State {
SearchableListState::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() {
if key.code == Key::Tab {
return Some(SearchableListMessage::ToggleFocus);
}
if key.code == Key::Esc {
return Some(SearchableListMessage::FilterClear);
}
match state.internal_focus {
Focus::Filter => {
match key.code {
Key::Up | Key::Char('k') if key.modifiers.ctrl() => {
Some(SearchableListMessage::Up)
}
Key::Down | Key::Char('j') if key.modifiers.ctrl() => {
Some(SearchableListMessage::Down)
}
Key::Enter => Some(SearchableListMessage::ToggleFocus),
Key::Backspace => Some(SearchableListMessage::FilterBackspace),
Key::Char(_) => key.raw_char.map(SearchableListMessage::FilterChar),
_ => None,
}
}
Focus::List => {
match key.code {
Key::Up | Key::Char('k') => Some(SearchableListMessage::Up),
Key::Down | Key::Char('j') => Some(SearchableListMessage::Down),
Key::Char('g') if key.modifiers.shift() => {
Some(SearchableListMessage::Last)
}
Key::Home | Key::Char('g') => Some(SearchableListMessage::First),
Key::End => Some(SearchableListMessage::Last),
Key::PageUp => Some(SearchableListMessage::PageUp(10)),
Key::PageDown => Some(SearchableListMessage::PageDown(10)),
Key::Enter => Some(SearchableListMessage::Select),
Key::Char(_) => {
key.raw_char.map(SearchableListMessage::FilterChar)
}
_ => None,
}
}
}
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SearchableListMessage::FilterChanged(text) => {
state.filter_text = text.clone();
state.refilter();
Some(SearchableListOutput::FilterChanged(text))
}
SearchableListMessage::FilterChar(c) => {
if state.internal_focus == Focus::List {
state.internal_focus = Focus::Filter;
}
state.filter_text.push(c);
let text = state.filter_text.clone();
state.refilter();
Some(SearchableListOutput::FilterChanged(text))
}
SearchableListMessage::FilterBackspace => {
if !state.filter_text.is_empty() {
state.filter_text.pop();
let text = state.filter_text.clone();
state.refilter();
Some(SearchableListOutput::FilterChanged(text))
} else {
None
}
}
SearchableListMessage::FilterClear => {
if !state.filter_text.is_empty() {
state.filter_text.clear();
state.refilter();
Some(SearchableListOutput::FilterChanged(String::new()))
} else {
None
}
}
SearchableListMessage::Up => {
if let Some(current) = state.selected {
let new_index = current.saturating_sub(1);
if new_index != current {
state.selected = Some(new_index);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(new_index));
}
}
None
}
SearchableListMessage::Down => {
if let Some(current) = state.selected {
let len = state.filtered_indices.len();
if len > 0 {
let new_index = (current + 1).min(len - 1);
if new_index != current {
state.selected = Some(new_index);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(new_index));
}
}
}
None
}
SearchableListMessage::First => {
if !state.filtered_indices.is_empty() && state.selected != Some(0) {
state.selected = Some(0);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(0));
}
None
}
SearchableListMessage::Last => {
let len = state.filtered_indices.len();
if len > 0 && state.selected != Some(len - 1) {
state.selected = Some(len - 1);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(len - 1));
}
None
}
SearchableListMessage::PageUp(page_size) => {
if let Some(current) = state.selected {
let new_index = current.saturating_sub(page_size);
if new_index != current {
state.selected = Some(new_index);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(new_index));
}
}
None
}
SearchableListMessage::PageDown(page_size) => {
if let Some(current) = state.selected {
let len = state.filtered_indices.len();
if len > 0 {
let new_index = (current + page_size).min(len - 1);
if new_index != current {
state.selected = Some(new_index);
state.sync_list_state();
return Some(SearchableListOutput::SelectionChanged(new_index));
}
}
}
None
}
SearchableListMessage::Select => state
.selected
.and_then(|si| state.filtered_indices.get(si).copied())
.and_then(|i| state.items.get(i).cloned())
.map(SearchableListOutput::Selected),
SearchableListMessage::ToggleFocus => {
state.internal_focus = match state.internal_focus {
Focus::Filter => Focus::List,
Focus::List => Focus::Filter,
};
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
render::render_searchable_list(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
#[cfg(test)]
mod event_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;