use crate::help::Help;
use crate::key::{Binding, matches};
use crate::paginator::{Paginator, Type as PaginatorType};
use crate::spinner::{SpinnerModel, TickMsg};
use crate::textinput::TextInput;
use bubbletea::{Cmd, KeyMsg, Message, Model, MouseAction, MouseButton, MouseMsg};
use lipgloss::{Color, Style, height as lipgloss_height};
use std::time::Duration;
pub trait Item: Clone + Send + 'static {
fn filter_value(&self) -> &str;
fn title(&self) -> &str {
self.filter_value()
}
fn description(&self) -> &str {
""
}
}
pub trait ItemDelegate<I: Item>: Clone + Send + 'static {
fn height(&self) -> usize;
fn spacing(&self) -> usize;
fn render(&self, item: &I, index: usize, selected: bool, width: usize) -> String;
fn update(&mut self, _msg: &Message, _item: &mut I) -> Option<Cmd> {
None
}
}
#[derive(Debug, Clone)]
pub struct DefaultDelegate {
pub show_description: bool,
pub normal_style: Style,
pub selected_style: Style,
pub item_height: usize,
pub item_spacing: usize,
}
impl Default for DefaultDelegate {
fn default() -> Self {
Self::new()
}
}
impl DefaultDelegate {
#[must_use]
pub fn new() -> Self {
Self {
show_description: true,
normal_style: Style::new(),
selected_style: Style::new().foreground_color(Color::from("212")).bold(),
item_height: 2,
item_spacing: 1,
}
}
#[must_use]
pub fn with_height(mut self, h: usize) -> Self {
self.item_height = h;
self
}
#[must_use]
pub fn with_spacing(mut self, s: usize) -> Self {
self.item_spacing = s;
self
}
#[must_use]
pub fn with_show_description(mut self, v: bool) -> Self {
self.show_description = v;
self
}
}
impl<I: Item> ItemDelegate<I> for DefaultDelegate {
fn height(&self) -> usize {
if !self.show_description {
return 1;
}
self.item_height
}
fn spacing(&self) -> usize {
self.item_spacing
}
fn render(&self, item: &I, _index: usize, selected: bool, width: usize) -> String {
let title = item.title();
let desc = item.description();
let truncate = |value: &str| {
use unicode_width::UnicodeWidthStr;
if UnicodeWidthStr::width(value) <= width {
value.to_string()
} else if width == 0 {
String::new()
} else {
let target_width = width.saturating_sub(1);
let mut current_width = 0;
let mut result = String::new();
for c in value.chars() {
let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + w > target_width {
break;
}
result.push(c);
current_width += w;
}
format!("{}…", result)
}
};
let title_trunc = truncate(title);
let desc_trunc = truncate(desc);
if selected {
if self.show_description {
format!(
"{}\n{}",
self.selected_style.render(&title_trunc),
self.selected_style.render(&desc_trunc)
)
} else {
self.selected_style.render(&title_trunc)
}
} else {
if self.show_description {
format!(
"{}\n{}",
self.normal_style.render(&title_trunc),
self.normal_style.render(&desc_trunc)
)
} else {
self.normal_style.render(&title_trunc)
}
}
}
}
#[derive(Debug, Clone)]
pub struct Rank {
pub index: usize,
pub matched_indices: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterState {
Unfiltered,
Filtering,
FilterApplied,
}
impl std::fmt::Display for FilterState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unfiltered => write!(f, "unfiltered"),
Self::Filtering => write!(f, "filtering"),
Self::FilterApplied => write!(f, "filter applied"),
}
}
}
pub type FilterFn = Box<dyn Fn(&str, &[String]) -> Vec<Rank> + Send + Sync>;
pub fn default_filter(term: &str, targets: &[String]) -> Vec<Rank> {
let term_lower = term.to_lowercase();
targets
.iter()
.enumerate()
.filter(|(_, target)| target.to_lowercase().contains(&term_lower))
.map(|(index, target)| {
let target_lower = target.to_lowercase();
let start = target_lower.find(&term_lower).unwrap_or(0);
let matched_indices: Vec<usize> = (start..start + term.len()).collect();
Rank {
index,
matched_indices,
}
})
.collect()
}
#[derive(Debug, Clone)]
pub struct KeyMap {
pub cursor_up: Binding,
pub cursor_down: Binding,
pub next_page: Binding,
pub prev_page: Binding,
pub goto_start: Binding,
pub goto_end: Binding,
pub filter: Binding,
pub clear_filter: Binding,
pub cancel_while_filtering: Binding,
pub accept_while_filtering: Binding,
pub show_full_help: Binding,
pub close_full_help: Binding,
pub quit: Binding,
pub force_quit: Binding,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
cursor_up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
cursor_down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
next_page: Binding::new()
.keys(&["right", "l", "pgdown"])
.help("→/l", "next page"),
prev_page: Binding::new()
.keys(&["left", "h", "pgup"])
.help("←/h", "prev page"),
goto_start: Binding::new().keys(&["home", "g"]).help("g/home", "start"),
goto_end: Binding::new().keys(&["end", "G"]).help("G/end", "end"),
filter: Binding::new().keys(&["/"]).help("/", "filter"),
clear_filter: Binding::new().keys(&["esc"]).help("esc", "clear filter"),
cancel_while_filtering: Binding::new().keys(&["esc"]).help("esc", "cancel"),
accept_while_filtering: Binding::new()
.keys(&["enter"])
.help("enter", "apply filter"),
show_full_help: Binding::new().keys(&["?"]).help("?", "help"),
close_full_help: Binding::new()
.keys(&["esc", "?"])
.help("?/esc", "close help"),
quit: Binding::new().keys(&["q"]).help("q", "quit"),
force_quit: Binding::new()
.keys(&["ctrl+c"])
.help("ctrl+c", "force quit"),
}
}
}
#[derive(Debug, Clone)]
pub struct Styles {
pub title: Style,
pub title_bar: Style,
pub filter_prompt: Style,
pub filter_cursor: Style,
pub status_bar: Style,
pub status_empty: Style,
pub no_items: Style,
pub pagination: Style,
pub help: Style,
pub active_pagination_dot: Style,
pub inactive_pagination_dot: Style,
pub divider_dot: Style,
}
impl Default for Styles {
fn default() -> Self {
Self {
title_bar: Style::new().padding((0u16, 0u16, 1u16, 2u16)),
title: Style::new()
.background_color(Color::from("62"))
.foreground_color(Color::from("230"))
.padding((0u16, 1u16)),
filter_prompt: Style::new().foreground_color(Color::from("#ECFD65")),
filter_cursor: Style::new().foreground_color(Color::from("#EE6FF8")),
status_bar: Style::new()
.foreground_color(Color::from("240"))
.padding((0u16, 0u16, 1u16, 2u16)),
status_empty: Style::new().foreground_color(Color::from("240")),
no_items: Style::new().foreground_color(Color::from("240")),
pagination: Style::new().padding_left(2),
help: Style::new()
.foreground_color(Color::from("240"))
.padding((1u16, 0u16, 0u16, 2u16)),
active_pagination_dot: Style::new()
.foreground_color(Color::from("240"))
.set_string("•"),
inactive_pagination_dot: Style::new()
.foreground_color(Color::from("240"))
.set_string("•"),
divider_dot: Style::new()
.foreground_color(Color::from("240"))
.set_string(" • "),
}
}
}
#[derive(Debug, Clone)]
pub struct FilterMatchesMsg(pub Vec<Rank>);
#[derive(Debug, Clone, Copy)]
pub struct StatusMessageTimeoutMsg;
#[derive(Clone)]
pub struct List<I: Item, D: ItemDelegate<I>> {
pub title: String,
pub show_title: bool,
pub show_filter: bool,
pub show_status_bar: bool,
pub show_pagination: bool,
pub show_help: bool,
pub filtering_enabled: bool,
pub infinite_scrolling: bool,
pub item_name_singular: String,
pub item_name_plural: String,
pub key_map: KeyMap,
pub styles: Styles,
pub status_message_lifetime: Duration,
pub mouse_wheel_enabled: bool,
pub mouse_wheel_delta: usize,
pub mouse_click_enabled: bool,
spinner: SpinnerModel,
paginator: Paginator,
help: Help,
filter_input: TextInput,
items: Vec<I>,
filtered_indices: Vec<usize>,
delegate: D,
width: usize,
height: usize,
cursor: usize,
filter_state: FilterState,
show_spinner: bool,
status_message: Option<String>,
}
impl<I: Item, D: ItemDelegate<I>> List<I, D> {
#[must_use]
pub fn new(items: Vec<I>, delegate: D, width: usize, height: usize) -> Self {
let items_len = items.len();
let filtered_indices: Vec<usize> = (0..items_len).collect();
let mut filter_input = TextInput::new();
filter_input.prompt = "Filter: ".to_string();
filter_input.focus();
let mut list = Self {
title: "List".to_string(),
show_title: true,
show_filter: true,
show_status_bar: true,
show_pagination: true,
show_help: true,
filtering_enabled: true,
infinite_scrolling: false,
item_name_singular: "item".to_string(),
item_name_plural: "items".to_string(),
key_map: KeyMap::default(),
styles: Styles::default(),
status_message_lifetime: Duration::from_secs(1),
mouse_wheel_enabled: true,
mouse_wheel_delta: 1,
mouse_click_enabled: true,
spinner: SpinnerModel::new(),
paginator: Paginator::new().display_type(PaginatorType::Dots),
help: Help::new(),
filter_input,
items,
filtered_indices,
delegate,
width,
height,
cursor: 0,
filter_state: FilterState::Unfiltered,
show_spinner: false,
status_message: None,
};
list.update_pagination();
list
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn mouse_wheel(mut self, enabled: bool) -> Self {
self.mouse_wheel_enabled = enabled;
self
}
#[must_use]
pub fn mouse_wheel_delta(mut self, delta: usize) -> Self {
self.mouse_wheel_delta = delta;
self
}
#[must_use]
pub fn mouse_click(mut self, enabled: bool) -> Self {
self.mouse_click_enabled = enabled;
self
}
pub fn set_items(&mut self, items: Vec<I>) {
let len = items.len();
self.items = items;
self.filtered_indices = (0..len).collect();
self.paginator.set_total_pages_from_items(len);
self.paginator.set_page(0);
self.cursor = 0;
}
#[must_use]
pub fn items(&self) -> &[I] {
&self.items
}
#[must_use]
pub fn visible_items(&self) -> Vec<&I> {
self.filtered_indices
.iter()
.filter_map(|&i| self.items.get(i))
.collect()
}
#[must_use]
pub fn index(&self) -> usize {
self.cursor
}
#[must_use]
pub fn selected_item(&self) -> Option<&I> {
self.filtered_indices
.get(self.cursor)
.and_then(|&i| self.items.get(i))
}
pub fn select(&mut self, index: usize) {
self.cursor = index.min(self.filtered_indices.len().saturating_sub(1));
}
pub fn cursor_up(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
if self.cursor == 0 {
if self.infinite_scrolling {
self.cursor = self.filtered_indices.len() - 1;
}
} else {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
if self.cursor >= self.filtered_indices.len() - 1 {
if self.infinite_scrolling {
self.cursor = 0;
}
} else {
self.cursor += 1;
}
}
#[must_use]
pub fn filter_state(&self) -> FilterState {
self.filter_state
}
#[must_use]
pub fn filter_value(&self) -> String {
self.filter_input.value()
}
pub fn set_filter_value(&mut self, value: &str) {
self.filter_input.set_value(value);
self.apply_filter();
}
pub fn reset_filter(&mut self) {
self.filter_input.reset();
self.filter_state = FilterState::Unfiltered;
self.filtered_indices = (0..self.items.len()).collect();
self.paginator.set_total_pages_from_items(self.items.len());
self.paginator.set_page(0);
self.cursor = 0;
self.update_pagination();
}
fn apply_filter(&mut self) {
let term = self.filter_input.value();
if term.is_empty() {
self.reset_filter();
return;
}
let targets: Vec<String> = self
.items
.iter()
.map(|i| i.filter_value().to_string())
.collect();
let ranks = default_filter(&term, &targets);
self.filtered_indices = ranks.iter().map(|r| r.index).collect();
self.paginator
.set_total_pages_from_items(self.filtered_indices.len());
self.paginator.set_page(0);
self.cursor = 0;
self.filter_state = FilterState::FilterApplied;
self.update_pagination();
}
pub fn start_spinner(&mut self) -> Option<Message> {
self.show_spinner = true;
self.update_pagination();
Some(self.spinner.tick())
}
pub fn stop_spinner(&mut self) {
self.show_spinner = false;
self.update_pagination();
}
#[must_use]
pub fn spinner_visible(&self) -> bool {
self.show_spinner
}
pub fn new_status_message(&mut self, msg: impl Into<String>) -> Option<Cmd> {
self.status_message = Some(msg.into());
let lifetime = self.status_message_lifetime;
Some(Cmd::new(move || {
std::thread::sleep(lifetime);
Message::new(StatusMessageTimeoutMsg)
}))
}
#[must_use]
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn set_width(&mut self, w: usize) {
self.width = w;
self.help.width = w;
}
pub fn set_height(&mut self, h: usize) {
self.height = h;
self.update_pagination();
}
#[must_use]
pub fn width(&self) -> usize {
self.width
}
#[must_use]
pub fn height(&self) -> usize {
self.height
}
#[must_use]
pub fn paginator(&self) -> &Paginator {
&self.paginator
}
fn update_pagination(&mut self) {
let items_len = self.filtered_indices.len();
let item_height = (self.delegate.height() + self.delegate.spacing()).max(1);
let mut avail_height = self.height;
if self.show_title || (self.show_filter && self.filtering_enabled) {
avail_height = avail_height.saturating_sub(lipgloss_height(&self.title_view()));
}
if self.show_status_bar {
avail_height = avail_height.saturating_sub(lipgloss_height(&self.status_view()));
}
if self.show_pagination {
avail_height = avail_height.saturating_sub(lipgloss_height(&self.pagination_view()));
}
if self.show_help {
avail_height = avail_height.saturating_sub(lipgloss_height(&self.help_view()));
}
let per_page = (avail_height / item_height).max(1);
let current_page = self.paginator.page();
let mut paginator = self.paginator.clone().per_page(per_page);
paginator.set_total_pages_from_items(items_len);
let max_page = paginator.get_total_pages().saturating_sub(1);
paginator.set_page(current_page.min(max_page));
self.paginator = paginator;
}
pub fn update(&mut self, msg: Message) -> Option<Cmd> {
if msg.is::<StatusMessageTimeoutMsg>() {
self.status_message = None;
return None;
}
if self.show_spinner && msg.is::<TickMsg>() {
return self.spinner.update(msg);
}
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if self.filter_state == FilterState::Filtering {
if matches(&key_str, &[&self.key_map.cancel_while_filtering]) {
self.reset_filter();
return None;
}
if matches(&key_str, &[&self.key_map.accept_while_filtering]) {
self.apply_filter();
self.filter_state = FilterState::FilterApplied;
self.filter_input.blur();
return None;
}
return self.filter_input.update(msg);
}
if matches(&key_str, &[&self.key_map.cursor_up]) {
self.cursor_up();
} else if matches(&key_str, &[&self.key_map.cursor_down]) {
self.cursor_down();
} else if matches(&key_str, &[&self.key_map.next_page]) {
self.paginator.next_page();
let start = self.paginator.page() * self.paginator.get_per_page();
self.cursor = if self.filtered_indices.is_empty() {
0
} else {
start.min(self.filtered_indices.len() - 1)
};
} else if matches(&key_str, &[&self.key_map.prev_page]) {
self.paginator.prev_page();
let start = self.paginator.page() * self.paginator.get_per_page();
self.cursor = if self.filtered_indices.is_empty() {
0
} else {
start.min(self.filtered_indices.len() - 1)
};
} else if matches(&key_str, &[&self.key_map.goto_start]) {
self.cursor = 0;
self.paginator.set_page(0);
} else if matches(&key_str, &[&self.key_map.goto_end]) {
self.cursor = self.filtered_indices.len().saturating_sub(1);
self.paginator
.set_page(self.paginator.get_total_pages().saturating_sub(1));
} else if matches(&key_str, &[&self.key_map.filter]) && self.filtering_enabled {
self.filter_state = FilterState::Filtering;
self.filter_input.focus();
self.update_pagination();
} else if matches(&key_str, &[&self.key_map.clear_filter]) {
self.reset_filter();
} else if matches(&key_str, &[&self.key_map.show_full_help]) {
self.help.show_all = true;
} else if matches(&key_str, &[&self.key_map.close_full_help]) {
self.help.show_all = false;
}
}
if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
if mouse.action != MouseAction::Press {
return None;
}
match mouse.button {
MouseButton::WheelUp if self.mouse_wheel_enabled => {
for _ in 0..self.mouse_wheel_delta {
self.cursor_up();
}
}
MouseButton::WheelDown if self.mouse_wheel_enabled => {
for _ in 0..self.mouse_wheel_delta {
self.cursor_down();
}
}
MouseButton::Left if self.mouse_click_enabled => {
let mut content_start_y = 0usize;
if self.show_title && !self.title.is_empty() {
content_start_y += 1;
}
if self.show_filter && self.filter_state == FilterState::Filtering {
content_start_y += 1;
}
let click_y = mouse.y as usize;
if click_y >= content_start_y {
let item_height = self.delegate.height() + self.delegate.spacing();
let relative_y = click_y - content_start_y;
let item_index_in_view = relative_y / item_height.max(1);
let per_page = self.paginator.get_per_page();
let page_start = self.paginator.page() * per_page;
let target_cursor = page_start + item_index_in_view;
if target_cursor < self.filtered_indices.len() {
self.cursor = target_cursor;
}
}
}
_ => {}
}
}
None
}
fn title_view(&self) -> String {
let mut view = String::new();
if self.show_filter && self.filter_state == FilterState::Filtering {
view.push_str(&self.filter_input.view());
} else if self.show_title {
view.push_str(&self.styles.title.render(&self.title));
if self.filter_state != FilterState::Filtering
&& let Some(msg) = self.status_message.as_deref()
{
view.push_str(" ");
view.push_str(msg);
}
}
if self.show_spinner {
let spinner_view = self.spinner.view();
let gap = " ";
if self.width > 0 {
let current_w = lipgloss::width(&view);
let spinner_w = lipgloss::width(&spinner_view);
if current_w + lipgloss::width(gap) + spinner_w <= self.width {
view.push_str(gap);
view.push_str(&spinner_view);
}
}
}
if view.is_empty() {
return view;
}
self.styles.title_bar.render(&view)
}
fn status_view(&self) -> String {
let total_items = self.items.len();
let visible_items = self.filtered_indices.len();
let item_name = if visible_items == 1 {
&self.item_name_singular
} else {
&self.item_name_plural
};
let items_display = format!("{visible_items} {item_name}");
let mut status = String::new();
if self.filter_state == FilterState::Filtering {
if visible_items == 0 {
status = self.styles.status_empty.render("Nothing matched");
} else {
status = items_display;
}
} else if total_items == 0 {
status = self
.styles
.status_empty
.render(&format!("No {}", self.item_name_plural));
} else {
if self.filter_state == FilterState::FilterApplied {
let mut f = self.filter_input.value();
f = f.trim().to_string();
if f.chars().count() > 10 {
f = f.chars().take(10).collect::<String>() + "…";
}
status.push('“');
status.push_str(&f);
status.push_str("” ");
}
status.push_str(&items_display);
}
let num_filtered = total_items.saturating_sub(visible_items);
if num_filtered > 0 {
status.push_str(&self.styles.divider_dot.render(" • "));
status.push_str(&format!("{num_filtered} filtered"));
}
self.styles.status_bar.render(&status)
}
fn pagination_view(&self) -> String {
if self.paginator.get_total_pages() < 2 {
return String::new();
}
self.styles.pagination.render(&self.paginator.view())
}
fn help_view(&self) -> String {
let bindings: Vec<&Binding> = vec![
&self.key_map.cursor_up,
&self.key_map.cursor_down,
&self.key_map.filter,
&self.key_map.quit,
];
self.styles
.help
.render(&self.help.short_help_view(&bindings))
}
fn populated_view(&self) -> String {
if self.filtered_indices.is_empty() {
if self.filter_state == FilterState::Filtering {
return String::new();
}
return self
.styles
.no_items
.render(&format!("No {}.", self.item_name_plural));
}
let total_visible = self.filtered_indices.len();
let per_page = self.paginator.get_per_page();
let (start, end) = self.paginator.get_slice_bounds(total_visible);
let mut out = String::new();
for (i, &item_idx) in self.filtered_indices[start..end].iter().enumerate() {
let global_idx = start + i;
let selected = global_idx == self.cursor;
if let Some(item) = self.items.get(item_idx) {
out.push_str(&self.delegate.render(item, global_idx, selected, self.width));
if i != (end - start).saturating_sub(1) {
out.push_str(&"\n".repeat(self.delegate.spacing() + 1));
}
}
}
let items_on_page = end.saturating_sub(start);
if items_on_page < per_page {
let n = (per_page - items_on_page) * (self.delegate.height() + self.delegate.spacing());
out.push_str(&"\n".repeat(n));
}
out
}
#[must_use]
pub fn view(&self) -> String {
let mut sections: Vec<String> = Vec::new();
if self.show_title || (self.show_filter && self.filtering_enabled) {
sections.push(self.title_view());
}
sections.push(self.populated_view());
if self.show_status_bar {
sections.push(self.status_view());
}
if self.show_pagination {
sections.push(self.pagination_view());
}
if self.show_help {
sections.push(self.help_view());
}
sections.join("\n")
}
#[must_use]
pub fn init(&self) -> Option<Cmd> {
None
}
}
impl<I: Item, D: ItemDelegate<I>> Model for List<I, D> {
fn init(&self) -> Option<Cmd> {
List::init(self)
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
List::update(self, msg)
}
fn view(&self) -> String {
List::view(self)
}
}
impl<I: Item + std::fmt::Debug, D: ItemDelegate<I> + std::fmt::Debug> std::fmt::Debug
for List<I, D>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("List")
.field("title", &self.title)
.field("items_count", &self.items.len())
.field("cursor", &self.cursor)
.field("filter_state", &self.filter_state)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone)]
struct TestItem {
name: String,
}
impl Item for TestItem {
fn filter_value(&self) -> &str {
&self.name
}
}
fn test_items() -> Vec<TestItem> {
vec![
TestItem {
name: "Apple".into(),
},
TestItem {
name: "Banana".into(),
},
TestItem {
name: "Cherry".into(),
},
TestItem {
name: "Date".into(),
},
]
}
#[test]
fn test_list_new() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert_eq!(list.items().len(), 4);
assert_eq!(list.index(), 0);
}
#[test]
fn test_list_navigation() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert_eq!(list.index(), 0);
list.cursor_down();
assert_eq!(list.index(), 1);
list.cursor_down();
assert_eq!(list.index(), 2);
list.cursor_up();
assert_eq!(list.index(), 1);
}
#[test]
fn test_list_selected_item() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert_eq!(list.selected_item().map(|i| i.name.as_str()), Some("Apple"));
list.cursor_down();
assert_eq!(
list.selected_item().map(|i| i.name.as_str()),
Some("Banana")
);
}
#[test]
fn test_list_filter() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
list.set_filter_value("an");
assert_eq!(list.visible_items().len(), 1);
assert_eq!(list.visible_items()[0].name, "Banana");
}
#[test]
fn test_list_reset_filter() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
list.set_filter_value("an");
assert_eq!(list.visible_items().len(), 1);
list.reset_filter();
assert_eq!(list.visible_items().len(), 4);
}
#[test]
fn test_cancel_filter_resets_pagination() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 10);
list.filter_state = FilterState::Filtering;
list.filtered_indices = vec![0];
list.paginator
.set_total_pages_from_items(list.filtered_indices.len());
let key_msg = Message::new(KeyMsg::from_type(bubbletea::KeyType::Esc));
let _ = list.update(key_msg);
assert_eq!(list.filter_state, FilterState::Unfiltered);
assert_eq!(list.filtered_indices.len(), list.items.len());
let per_page = list.paginator.get_per_page();
let expected_pages = list.items.len().div_ceil(per_page);
assert_eq!(list.paginator.get_total_pages(), expected_pages);
assert_eq!(list.cursor, 0);
}
#[test]
fn test_list_filter_state() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert_eq!(list.filter_state(), FilterState::Unfiltered);
}
#[test]
fn test_list_infinite_scroll() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
list.infinite_scrolling = true;
list.cursor_up();
assert_eq!(list.index(), 3);
list.cursor_down();
assert_eq!(list.index(), 0);
}
#[test]
fn test_list_status_message() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert!(list.status_message().is_none());
list.new_status_message("Test message");
assert_eq!(list.status_message(), Some("Test message"));
}
#[test]
fn test_list_status_message_uses_singular_name() {
let items = vec![TestItem {
name: "Apple".into(),
}];
let mut list = List::new(items, DefaultDelegate::new(), 80, 6);
list.item_name_singular = "fruit".to_string();
list.item_name_plural = "fruits".to_string();
let view = list.view();
assert!(view.contains("1 fruit"));
}
#[test]
fn test_list_apply_filter_resets_page() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
list.paginator.set_page(2);
list.set_filter_value("a");
assert_eq!(list.paginator.page(), 0);
}
#[test]
fn test_list_reset_filter_resets_page() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
list.paginator.set_page(3);
list.reset_filter();
assert_eq!(list.paginator.page(), 0);
}
#[test]
fn test_list_spinner() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert!(!list.spinner_visible());
list.start_spinner();
assert!(list.spinner_visible());
list.stop_spinner();
assert!(!list.spinner_visible());
}
#[test]
fn test_list_view() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Fruits");
let view = list.view();
assert!(view.contains("Fruits"));
assert!(view.contains("Apple"));
}
#[test]
fn test_default_filter() {
let targets = vec![
"Apple".to_string(),
"Banana".to_string(),
"Cherry".to_string(),
];
let ranks = default_filter("an", &targets);
assert_eq!(ranks.len(), 1);
assert_eq!(ranks[0].index, 1); }
#[test]
fn test_default_delegate() {
let delegate = DefaultDelegate::new().with_height(2).with_spacing(1);
assert_eq!(delegate.item_height, 2);
assert_eq!(delegate.item_spacing, 1);
}
#[test]
fn test_keymap_default() {
let km = KeyMap::default();
assert!(!km.cursor_up.get_keys().is_empty());
assert!(!km.filter.get_keys().is_empty());
}
#[test]
fn test_filter_state_display() {
assert_eq!(FilterState::Unfiltered.to_string(), "unfiltered");
assert_eq!(FilterState::Filtering.to_string(), "filtering");
assert_eq!(FilterState::FilterApplied.to_string(), "filter applied");
}
#[test]
fn test_model_trait_init_returns_none() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
let cmd = Model::init(&list);
assert!(cmd.is_none(), "Model::init should return None for List");
}
#[test]
fn test_model_trait_view_returns_content() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Test List");
let view = Model::view(&list);
assert!(view.contains("Test List"), "View should contain the title");
assert!(view.contains("Apple"), "View should contain first item");
}
#[test]
fn test_model_trait_update_handles_messages() {
let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert_eq!(list.index(), 0);
let key_msg = Message::new(KeyMsg {
key_type: bubbletea::KeyType::Runes,
runes: vec!['j'], alt: false,
paste: false,
});
let _ = Model::update(&mut list, key_msg);
assert_eq!(list.index(), 1, "Cursor should have moved down");
}
#[test]
fn test_list_satisfies_model_bounds() {
fn accepts_model<M: Model + Send + 'static>(_model: M) {}
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
accepts_model(list);
}
#[test]
fn test_list_pagination_calculation() {
let delegate = DefaultDelegate::new()
.with_show_description(false)
.with_spacing(0)
.with_height(1);
let list = List::new(test_items(), delegate, 80, 10);
let mut avail_height = list.height;
if list.show_title || (list.show_filter && list.filtering_enabled) {
avail_height = avail_height.saturating_sub(lipgloss_height(&list.title_view()));
}
if list.show_status_bar {
avail_height = avail_height.saturating_sub(lipgloss_height(&list.status_view()));
}
if list.show_pagination {
avail_height = avail_height.saturating_sub(lipgloss_height(&list.pagination_view()));
}
if list.show_help {
avail_height = avail_height.saturating_sub(lipgloss_height(&list.help_view()));
}
let item_height = (<DefaultDelegate as ItemDelegate<TestItem>>::height(&list.delegate)
+ <DefaultDelegate as ItemDelegate<TestItem>>::spacing(&list.delegate))
.max(1);
let expected_per_page = (avail_height / item_height).max(1);
assert_eq!(list.paginator().get_per_page(), expected_per_page);
}
#[test]
fn test_list_paginator_accessor() {
let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
assert!(list.paginator().get_per_page() > 0);
assert_eq!(list.paginator().page(), 0);
}
#[test]
fn test_list_pagination_with_many_items() {
let items: Vec<TestItem> = (1..=50)
.map(|i| TestItem {
name: format!("Item {}", i),
})
.collect();
let delegate = DefaultDelegate::new()
.with_show_description(false)
.with_spacing(0)
.with_height(1);
let list = List::new(items, delegate, 80, 10);
let per_page = list.paginator().get_per_page();
let expected_pages = list.items.len().div_ceil(per_page);
assert_eq!(list.paginator().get_total_pages(), expected_pages);
}
}