1pub mod defaultitem;
4pub mod keys;
5pub mod style;
6
7use crate::{help, key, paginator, spinner, textinput};
8use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
9use fuzzy_matcher::skim::SkimMatcherV2;
10use fuzzy_matcher::FuzzyMatcher;
11use lipgloss;
12use lipgloss::style::Style;
13use lipgloss_list as lg_list;
14use std::fmt::Display;
15
16pub trait Item: Display + Clone {
20 fn filter_value(&self) -> String;
22}
23
24pub trait ItemDelegate<I: Item> {
26 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
28 fn height(&self) -> usize;
30 fn spacing(&self) -> usize;
32 fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
34}
35
36#[derive(Debug, Clone)]
38#[allow(dead_code)]
39struct FilteredItem<I: Item> {
40 index: usize, item: I,
42 matches: Vec<usize>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum FilterState {
50 Unfiltered,
52 Filtering,
54 FilterApplied,
56}
57
58pub struct Model<I: Item> {
60 pub title: String,
62 items: Vec<I>,
63 delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
64
65 pub filter_input: textinput::Model,
68 pub paginator: paginator::Model,
70 pub spinner: spinner::Model,
72 pub help: help::Model,
74 pub keymap: keys::ListKeyMap,
76
77 filter_state: FilterState,
79 filtered_items: Vec<FilteredItem<I>>,
80 cursor: usize,
81 width: usize,
82 height: usize,
83
84 pub styles: style::ListStyles,
87
88 status_item_singular: Option<String>,
90 status_item_plural: Option<String>,
91}
92
93impl<I: Item + Send + Sync + 'static> Model<I> {
94 pub fn new(
96 items: Vec<I>,
97 delegate: impl ItemDelegate<I> + Send + Sync + 'static,
98 width: usize,
99 height: usize,
100 ) -> Self {
101 let mut filter_input = textinput::new();
102 filter_input.set_placeholder("Filter...");
103 let mut paginator = paginator::Model::new();
104 paginator.set_per_page(10);
105
106 let mut s = Self {
107 title: "List".to_string(),
108 items,
109 delegate: Box::new(delegate),
110 filter_input,
111 paginator,
112 spinner: spinner::Model::new(),
113 help: help::Model::new(),
114 keymap: keys::ListKeyMap::default(),
115 filter_state: FilterState::Unfiltered,
116 filtered_items: vec![],
117 cursor: 0,
118 width,
119 height,
120 styles: style::ListStyles::default(),
121 status_item_singular: None,
122 status_item_plural: None,
123 };
124 s.update_pagination();
125 s
126 }
127
128 pub fn set_items(&mut self, items: Vec<I>) {
130 self.items = items;
131 self.update_pagination();
132 }
133 pub fn visible_items(&self) -> Vec<I> {
135 if self.filter_state == FilterState::Unfiltered {
136 self.items.clone()
137 } else {
138 self.filtered_items.iter().map(|f| f.item.clone()).collect()
139 }
140 }
141 pub fn set_filter_text(&mut self, s: &str) {
143 self.filter_input.set_value(s);
144 }
145 pub fn set_filter_state(&mut self, st: FilterState) {
147 self.filter_state = st;
148 }
149 pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
151 self.status_item_singular = Some(singular.to_string());
152 self.status_item_plural = Some(plural.to_string());
153 }
154 pub fn status_view(&self) -> String {
156 self.view_footer()
157 }
158
159 pub fn with_title(mut self, title: &str) -> Self {
161 self.title = title.to_string();
162 self
163 }
164 pub fn selected_item(&self) -> Option<&I> {
166 if self.filter_state == FilterState::Unfiltered {
167 self.items.get(self.cursor)
168 } else {
169 self.filtered_items.get(self.cursor).map(|fi| &fi.item)
170 }
171 }
172 pub fn cursor(&self) -> usize {
174 self.cursor
175 }
176 pub fn len(&self) -> usize {
178 if self.filter_state == FilterState::Unfiltered {
179 self.items.len()
180 } else {
181 self.filtered_items.len()
182 }
183 }
184 pub fn is_empty(&self) -> bool {
186 self.len() == 0
187 }
188
189 fn update_pagination(&mut self) {
190 let item_count = self.len();
191 let item_height = self.delegate.height() + self.delegate.spacing();
192 let available_height = self.height.saturating_sub(4);
193 let per_page = if item_height > 0 {
194 available_height / item_height
195 } else {
196 10
197 }
198 .max(1);
199 self.paginator.set_per_page(per_page);
200 self.paginator
201 .set_total_pages(item_count.div_ceil(per_page));
202 if self.cursor >= item_count {
203 self.cursor = item_count.saturating_sub(1);
204 }
205 }
206
207 #[allow(dead_code)]
208 fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
209 if index < self.filtered_items.len() {
210 Some(&self.filtered_items[index].matches)
211 } else {
212 None
213 }
214 }
215
216 fn apply_filter(&mut self) {
217 let filter_term = self.filter_input.value().to_lowercase();
218 if filter_term.is_empty() {
219 self.filter_state = FilterState::Unfiltered;
220 self.filtered_items.clear();
221 } else {
222 let matcher = SkimMatcherV2::default();
223 self.filtered_items = self
224 .items
225 .iter()
226 .enumerate()
227 .filter_map(|(i, item)| {
228 matcher
229 .fuzzy_indices(&item.filter_value(), &filter_term)
230 .map(|(_score, indices)| FilteredItem {
231 index: i,
232 item: item.clone(),
233 matches: indices,
234 })
235 })
236 .collect();
237 self.filter_state = FilterState::FilterApplied;
238 }
239 self.cursor = 0;
240 self.update_pagination();
241 }
242
243 fn view_header(&self) -> String {
244 if self.filter_state == FilterState::Filtering {
245 let prompt = self.styles.filter_prompt.clone().render("Filter:");
246 format!("{} {}", prompt, self.filter_input.view())
247 } else {
248 let mut header = self.title.clone();
249 if self.filter_state == FilterState::FilterApplied {
250 header.push_str(&format!(" (filtered: {})", self.len()));
251 }
252 self.styles.title.clone().render(&header)
253 }
254 }
255
256 fn view_items(&self) -> String {
257 if self.is_empty() {
258 return self.styles.no_items.clone().render("No items");
259 }
260
261 let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
262 self.items.iter().enumerate().collect()
263 } else {
264 self.filtered_items
265 .iter()
266 .map(|fi| (fi.index, &fi.item))
267 .collect()
268 };
269
270 let (start, end) = self.paginator.get_slice_bounds(items_to_render.len());
271
272 let mut list = lg_list::List::new();
273 let title_style_normal = Style::new().padding_left(2);
274
275 list = list.enumerator_style(Style::new());
276 list = list.item_style(title_style_normal.clone());
278
279 for (_idx, item) in items_to_render
280 .iter()
281 .take(end.min(items_to_render.len()))
282 .skip(start)
283 {
284 list = list.item(&item.to_string());
285 }
286 list.to_string()
287 }
288
289 fn view_footer(&self) -> String {
290 let mut footer = String::new();
291 if !self.is_empty() {
292 let singular = self.status_item_singular.as_deref().unwrap_or("item");
293 let plural = self.status_item_plural.as_deref().unwrap_or("items");
294 let noun = if self.len() == 1 { singular } else { plural };
295 footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
296 }
297 let help_view = self.help.view(self);
298 if !help_view.is_empty() {
299 footer.push('\n');
300 footer.push_str(&help_view);
301 }
302 footer
303 }
304}
305
306impl<I: Item> help::KeyMap for Model<I> {
308 fn short_help(&self) -> Vec<&key::Binding> {
309 match self.filter_state {
310 FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
311 _ => vec![
312 &self.keymap.cursor_up,
313 &self.keymap.cursor_down,
314 &self.keymap.filter,
315 ],
316 }
317 }
318 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
319 match self.filter_state {
320 FilterState::Filtering => {
321 vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
322 }
323 _ => vec![
324 vec![
325 &self.keymap.cursor_up,
326 &self.keymap.cursor_down,
327 &self.keymap.next_page,
328 &self.keymap.prev_page,
329 ],
330 vec![
331 &self.keymap.go_to_start,
332 &self.keymap.go_to_end,
333 &self.keymap.filter,
334 &self.keymap.clear_filter,
335 ],
336 ],
337 }
338 }
339}
340
341impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
342 fn init() -> (Self, Option<Cmd>) {
343 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
344 (model, None)
345 }
346 fn update(&mut self, msg: Msg) -> Option<Cmd> {
347 if self.filter_state == FilterState::Filtering {
348 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
349 match key_msg.key {
350 crossterm::event::KeyCode::Esc => {
351 self.filter_state = if self.filtered_items.is_empty() {
352 FilterState::Unfiltered
353 } else {
354 FilterState::FilterApplied
355 };
356 self.filter_input.blur();
357 return None;
358 }
359 crossterm::event::KeyCode::Enter => {
360 self.apply_filter();
361 self.filter_input.blur();
362 return None;
363 }
364 crossterm::event::KeyCode::Char(c) => {
365 let mut s = self.filter_input.value();
366 s.push(c);
367 self.filter_input.set_value(&s);
368 self.apply_filter();
369 }
370 crossterm::event::KeyCode::Backspace => {
371 let mut s = self.filter_input.value();
372 s.pop();
373 self.filter_input.set_value(&s);
374 self.apply_filter();
375 }
376 crossterm::event::KeyCode::Delete => { }
377 crossterm::event::KeyCode::Left => {
378 let pos = self.filter_input.position();
379 if pos > 0 {
380 self.filter_input.set_cursor(pos - 1);
381 }
382 }
383 crossterm::event::KeyCode::Right => {
384 let pos = self.filter_input.position();
385 self.filter_input.set_cursor(pos + 1);
386 }
387 crossterm::event::KeyCode::Home => {
388 self.filter_input.cursor_start();
389 }
390 crossterm::event::KeyCode::End => {
391 self.filter_input.cursor_end();
392 }
393 _ => {}
394 }
395 }
396 return None;
397 }
398
399 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
400 if self.keymap.cursor_up.matches(key_msg) {
401 if self.cursor > 0 {
402 self.cursor -= 1;
403 }
404 } else if self.keymap.cursor_down.matches(key_msg) {
405 if self.cursor < self.len().saturating_sub(1) {
406 self.cursor += 1;
407 }
408 } else if self.keymap.go_to_start.matches(key_msg) {
409 self.cursor = 0;
410 } else if self.keymap.go_to_end.matches(key_msg) {
411 self.cursor = self.len().saturating_sub(1);
412 } else if self.keymap.filter.matches(key_msg) {
413 self.filter_state = FilterState::Filtering;
414 return Some(self.filter_input.focus());
416 } else if self.keymap.clear_filter.matches(key_msg) {
417 self.filter_input.set_value("");
418 self.filter_state = FilterState::Unfiltered;
419 self.filtered_items.clear();
420 self.cursor = 0;
421 self.update_pagination();
422 }
423 }
424 None
425 }
426 fn view(&self) -> String {
427 lipgloss::join_vertical(
428 lipgloss::LEFT,
429 &[&self.view_header(), &self.view_items(), &self.view_footer()],
430 )
431 }
432}
433
434pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
436pub use keys::ListKeyMap;
437pub use style::ListStyles;
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[derive(Clone)]
444 struct S(&'static str);
445 impl std::fmt::Display for S {
446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447 write!(f, "{}", self.0)
448 }
449 }
450 impl Item for S {
451 fn filter_value(&self) -> String {
452 self.0.to_string()
453 }
454 }
455
456 #[test]
457 fn test_status_bar_item_name() {
458 let mut list = Model::new(
459 vec![S("foo"), S("bar")],
460 defaultitem::DefaultDelegate::new(),
461 10,
462 10,
463 );
464 let v = list.status_view();
465 assert!(v.contains("2 items"));
466 list.set_items(vec![S("foo")]);
467 let v = list.status_view();
468 assert!(v.contains("1 item"));
469 }
470
471 #[test]
472 fn test_status_bar_without_items() {
473 let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
474 assert!(list.status_view().contains("No items") || list.is_empty());
475 }
476
477 #[test]
478 fn test_custom_status_bar_item_name() {
479 let mut list = Model::new(
480 vec![S("foo"), S("bar")],
481 defaultitem::DefaultDelegate::new(),
482 10,
483 10,
484 );
485 list.set_status_bar_item_name("connection", "connections");
486 assert!(list.status_view().contains("2 connections"));
487 list.set_items(vec![S("foo")]);
488 assert!(list.status_view().contains("1 connection"));
489 list.set_items(vec![]);
490 let _ = list.status_view();
492 }
493
494 #[test]
495 fn test_set_filter_text_and_state_visible_items() {
496 let tc = vec![S("foo"), S("bar"), S("baz")];
497 let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
498 list.set_filter_text("ba");
499 list.set_filter_state(FilterState::Unfiltered);
500 assert_eq!(list.visible_items().len(), tc.len());
501
502 list.set_filter_state(FilterState::Filtering);
503 list.apply_filter();
504 let vis = list.visible_items();
505 assert_eq!(vis.len(), 2); list.set_filter_state(FilterState::FilterApplied);
508 let vis2 = list.visible_items();
509 assert_eq!(vis2.len(), 2);
510 }
511}