1pub mod defaultitem;
22pub mod keys;
23pub mod style;
24
25use crate::{help, key, paginator, spinner, textinput};
26use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
27use fuzzy_matcher::skim::SkimMatcherV2;
28use fuzzy_matcher::FuzzyMatcher;
29use lipgloss;
30use std::fmt::Display;
31
32pub trait Item: Display + Clone {
36 fn filter_value(&self) -> String;
38}
39
40pub trait ItemDelegate<I: Item> {
42 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
44 fn height(&self) -> usize;
46 fn spacing(&self) -> usize;
48 fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
50}
51
52#[derive(Debug, Clone)]
54#[allow(dead_code)]
55struct FilteredItem<I: Item> {
56 index: usize, item: I,
58 matches: Vec<usize>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FilterState {
66 Unfiltered,
68 Filtering,
70 FilterApplied,
72}
73
74pub struct Model<I: Item> {
76 pub title: String,
78 items: Vec<I>,
79 delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
80
81 pub filter_input: textinput::Model,
84 pub paginator: paginator::Model,
86 pub spinner: spinner::Model,
88 pub help: help::Model,
90 pub keymap: keys::ListKeyMap,
92
93 filter_state: FilterState,
95 filtered_items: Vec<FilteredItem<I>>,
96 cursor: usize,
97 width: usize,
98 height: usize,
99
100 pub styles: style::ListStyles,
103
104 status_item_singular: Option<String>,
106 status_item_plural: Option<String>,
107}
108
109impl<I: Item + Send + Sync + 'static> Model<I> {
110 pub fn new(
112 items: Vec<I>,
113 delegate: impl ItemDelegate<I> + Send + Sync + 'static,
114 width: usize,
115 height: usize,
116 ) -> Self {
117 let mut filter_input = textinput::new();
118 filter_input.set_placeholder("Filter...");
119 let mut paginator = paginator::Model::new();
120 paginator.set_per_page(10);
121
122 let mut s = Self {
123 title: "List".to_string(),
124 items,
125 delegate: Box::new(delegate),
126 filter_input,
127 paginator,
128 spinner: spinner::Model::new(),
129 help: help::Model::new(),
130 keymap: keys::ListKeyMap::default(),
131 filter_state: FilterState::Unfiltered,
132 filtered_items: vec![],
133 cursor: 0,
134 width,
135 height,
136 styles: style::ListStyles::default(),
137 status_item_singular: None,
138 status_item_plural: None,
139 };
140 s.update_pagination();
141 s
142 }
143
144 pub fn set_items(&mut self, items: Vec<I>) {
146 self.items = items;
147 self.update_pagination();
148 }
149 pub fn visible_items(&self) -> Vec<I> {
151 if self.filter_state == FilterState::Unfiltered {
152 self.items.clone()
153 } else {
154 self.filtered_items.iter().map(|f| f.item.clone()).collect()
155 }
156 }
157 pub fn set_filter_text(&mut self, s: &str) {
159 self.filter_input.set_value(s);
160 }
161 pub fn set_filter_state(&mut self, st: FilterState) {
163 self.filter_state = st;
164 }
165 pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
167 self.status_item_singular = Some(singular.to_string());
168 self.status_item_plural = Some(plural.to_string());
169 }
170 pub fn status_view(&self) -> String {
172 self.view_footer()
173 }
174
175 pub fn with_title(mut self, title: &str) -> Self {
177 self.title = title.to_string();
178 self
179 }
180 pub fn selected_item(&self) -> Option<&I> {
182 if self.filter_state == FilterState::Unfiltered {
183 self.items.get(self.cursor)
184 } else {
185 self.filtered_items.get(self.cursor).map(|fi| &fi.item)
186 }
187 }
188 pub fn cursor(&self) -> usize {
190 self.cursor
191 }
192 pub fn len(&self) -> usize {
194 if self.filter_state == FilterState::Unfiltered {
195 self.items.len()
196 } else {
197 self.filtered_items.len()
198 }
199 }
200 pub fn is_empty(&self) -> bool {
202 self.len() == 0
203 }
204
205 fn update_pagination(&mut self) {
206 let item_count = self.len();
207 let item_height = self.delegate.height() + self.delegate.spacing();
208 let available_height = self.height.saturating_sub(4);
209 let per_page = if item_height > 0 {
210 available_height / item_height
211 } else {
212 10
213 }
214 .max(1);
215 self.paginator.set_per_page(per_page);
216 self.paginator
217 .set_total_pages(item_count.div_ceil(per_page));
218 if self.cursor >= item_count {
219 self.cursor = item_count.saturating_sub(1);
220 }
221 }
222
223 #[allow(dead_code)]
224 fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
225 if index < self.filtered_items.len() {
226 Some(&self.filtered_items[index].matches)
227 } else {
228 None
229 }
230 }
231
232 fn apply_filter(&mut self) {
233 let filter_term = self.filter_input.value().to_lowercase();
234 if filter_term.is_empty() {
235 self.filter_state = FilterState::Unfiltered;
236 self.filtered_items.clear();
237 } else {
238 let matcher = SkimMatcherV2::default();
239 self.filtered_items = self
240 .items
241 .iter()
242 .enumerate()
243 .filter_map(|(i, item)| {
244 matcher
245 .fuzzy_indices(&item.filter_value(), &filter_term)
246 .map(|(_score, indices)| FilteredItem {
247 index: i,
248 item: item.clone(),
249 matches: indices,
250 })
251 })
252 .collect();
253 self.filter_state = FilterState::FilterApplied;
254 }
255 self.cursor = 0;
256 self.update_pagination();
257 }
258
259 fn view_header(&self) -> String {
260 if self.filter_state == FilterState::Filtering {
261 let prompt = self.styles.filter_prompt.clone().render("Filter:");
262 format!("{} {}", prompt, self.filter_input.view())
263 } else {
264 let mut header = self.title.clone();
265 if self.filter_state == FilterState::FilterApplied {
266 header.push_str(&format!(" (filtered: {})", self.len()));
267 }
268 self.styles.title.clone().render(&header)
269 }
270 }
271
272 fn view_items(&self) -> String {
273 if self.is_empty() {
274 return self.styles.no_items.clone().render("No items");
275 }
276
277 let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
278 self.items.iter().enumerate().collect()
279 } else {
280 self.filtered_items
281 .iter()
282 .map(|fi| (fi.index, &fi.item))
283 .collect()
284 };
285
286 let (start, end) = self.paginator.get_slice_bounds(items_to_render.len());
287 let mut result = String::new();
288
289 for (list_idx, (_orig_idx, item)) in items_to_render
291 .iter()
292 .enumerate()
293 .take(end.min(items_to_render.len()))
294 .skip(start)
295 {
296 let visible_index = start + list_idx;
298 let item_output = self.delegate.render(self, visible_index, item);
299
300 if !result.is_empty() {
301 for _ in 0..self.delegate.spacing() {
303 result.push('\n');
304 }
305 }
306
307 result.push_str(&item_output);
308 }
309
310 result
311 }
312
313 fn view_footer(&self) -> String {
314 let mut footer = String::new();
315 if !self.is_empty() {
316 let singular = self.status_item_singular.as_deref().unwrap_or("item");
317 let plural = self.status_item_plural.as_deref().unwrap_or("items");
318 let noun = if self.len() == 1 { singular } else { plural };
319 footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
320 }
321 let help_view = self.help.view(self);
322 if !help_view.is_empty() {
323 footer.push('\n');
324 footer.push_str(&help_view);
325 }
326 footer
327 }
328}
329
330impl<I: Item> help::KeyMap for Model<I> {
332 fn short_help(&self) -> Vec<&key::Binding> {
333 match self.filter_state {
334 FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
335 _ => vec![
336 &self.keymap.cursor_up,
337 &self.keymap.cursor_down,
338 &self.keymap.filter,
339 ],
340 }
341 }
342 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
343 match self.filter_state {
344 FilterState::Filtering => {
345 vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
346 }
347 _ => vec![
348 vec![
349 &self.keymap.cursor_up,
350 &self.keymap.cursor_down,
351 &self.keymap.next_page,
352 &self.keymap.prev_page,
353 ],
354 vec![
355 &self.keymap.go_to_start,
356 &self.keymap.go_to_end,
357 &self.keymap.filter,
358 &self.keymap.clear_filter,
359 ],
360 ],
361 }
362 }
363}
364
365impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
366 fn init() -> (Self, Option<Cmd>) {
367 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
368 (model, None)
369 }
370 fn update(&mut self, msg: Msg) -> Option<Cmd> {
371 if self.filter_state == FilterState::Filtering {
372 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
373 match key_msg.key {
374 crossterm::event::KeyCode::Esc => {
375 self.filter_state = if self.filtered_items.is_empty() {
376 FilterState::Unfiltered
377 } else {
378 FilterState::FilterApplied
379 };
380 self.filter_input.blur();
381 return None;
382 }
383 crossterm::event::KeyCode::Enter => {
384 self.apply_filter();
385 self.filter_input.blur();
386 return None;
387 }
388 crossterm::event::KeyCode::Char(c) => {
389 let mut s = self.filter_input.value();
390 s.push(c);
391 self.filter_input.set_value(&s);
392 self.apply_filter();
393 }
394 crossterm::event::KeyCode::Backspace => {
395 let mut s = self.filter_input.value();
396 s.pop();
397 self.filter_input.set_value(&s);
398 self.apply_filter();
399 }
400 crossterm::event::KeyCode::Delete => { }
401 crossterm::event::KeyCode::Left => {
402 let pos = self.filter_input.position();
403 if pos > 0 {
404 self.filter_input.set_cursor(pos - 1);
405 }
406 }
407 crossterm::event::KeyCode::Right => {
408 let pos = self.filter_input.position();
409 self.filter_input.set_cursor(pos + 1);
410 }
411 crossterm::event::KeyCode::Home => {
412 self.filter_input.cursor_start();
413 }
414 crossterm::event::KeyCode::End => {
415 self.filter_input.cursor_end();
416 }
417 _ => {}
418 }
419 }
420 return None;
421 }
422
423 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
424 if self.keymap.cursor_up.matches(key_msg) {
425 if self.cursor > 0 {
426 self.cursor -= 1;
427 }
428 } else if self.keymap.cursor_down.matches(key_msg) {
429 if self.cursor < self.len().saturating_sub(1) {
430 self.cursor += 1;
431 }
432 } else if self.keymap.go_to_start.matches(key_msg) {
433 self.cursor = 0;
434 } else if self.keymap.go_to_end.matches(key_msg) {
435 self.cursor = self.len().saturating_sub(1);
436 } else if self.keymap.filter.matches(key_msg) {
437 self.filter_state = FilterState::Filtering;
438 return Some(self.filter_input.focus());
440 } else if self.keymap.clear_filter.matches(key_msg) {
441 self.filter_input.set_value("");
442 self.filter_state = FilterState::Unfiltered;
443 self.filtered_items.clear();
444 self.cursor = 0;
445 self.update_pagination();
446 }
447 }
448 None
449 }
450 fn view(&self) -> String {
451 lipgloss::join_vertical(
452 lipgloss::LEFT,
453 &[&self.view_header(), &self.view_items(), &self.view_footer()],
454 )
455 }
456}
457
458pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
460pub use keys::ListKeyMap;
461pub use style::ListStyles;
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[derive(Clone)]
468 struct S(&'static str);
469 impl std::fmt::Display for S {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 write!(f, "{}", self.0)
472 }
473 }
474 impl Item for S {
475 fn filter_value(&self) -> String {
476 self.0.to_string()
477 }
478 }
479
480 #[test]
481 fn test_status_bar_item_name() {
482 let mut list = Model::new(
483 vec![S("foo"), S("bar")],
484 defaultitem::DefaultDelegate::new(),
485 10,
486 10,
487 );
488 let v = list.status_view();
489 assert!(v.contains("2 items"));
490 list.set_items(vec![S("foo")]);
491 let v = list.status_view();
492 assert!(v.contains("1 item"));
493 }
494
495 #[test]
496 fn test_status_bar_without_items() {
497 let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
498 assert!(list.status_view().contains("No items") || list.is_empty());
499 }
500
501 #[test]
502 fn test_custom_status_bar_item_name() {
503 let mut list = Model::new(
504 vec![S("foo"), S("bar")],
505 defaultitem::DefaultDelegate::new(),
506 10,
507 10,
508 );
509 list.set_status_bar_item_name("connection", "connections");
510 assert!(list.status_view().contains("2 connections"));
511 list.set_items(vec![S("foo")]);
512 assert!(list.status_view().contains("1 connection"));
513 list.set_items(vec![]);
514 let _ = list.status_view();
516 }
517
518 #[test]
519 fn test_set_filter_text_and_state_visible_items() {
520 let tc = vec![S("foo"), S("bar"), S("baz")];
521 let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
522 list.set_filter_text("ba");
523 list.set_filter_state(FilterState::Unfiltered);
524 assert_eq!(list.visible_items().len(), tc.len());
525
526 list.set_filter_state(FilterState::Filtering);
527 list.apply_filter();
528 let vis = list.visible_items();
529 assert_eq!(vis.len(), 2); list.set_filter_state(FilterState::FilterApplied);
532 let vis2 = list.visible_items();
533 assert_eq!(vis2.len(), 2);
534 }
535
536 #[test]
537 fn test_selection_highlighting_works() {
538 let items = vec![S("first item"), S("second item"), S("third item")];
539 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
540
541 let view_output = list.view();
543 assert!(!view_output.is_empty(), "View should not be empty");
544
545 let first_view = list.view();
547 list.cursor = 1; let second_view = list.view();
549
550 assert_ne!(
552 first_view, second_view,
553 "Selection highlighting should change the view"
554 );
555 }
556
557 #[test]
558 fn test_filter_highlighting_works() {
559 let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
560 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
561
562 list.set_filter_text("ap");
564 list.apply_filter(); let filtered_view = list.view();
567 assert!(
568 !filtered_view.is_empty(),
569 "Filtered view should not be empty"
570 );
571
572 assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
574
575 assert!(
577 !list.filtered_items.is_empty(),
578 "Filtered items should have match data"
579 );
580 if !list.filtered_items.is_empty() {
581 assert!(
582 !list.filtered_items[0].matches.is_empty(),
583 "First filtered item should have matches"
584 );
585 assert_eq!(list.filtered_items[0].item.0, "apple pie");
587 }
588 }
589}