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_extras::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 items = Vec::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 items.push(item_output);
300 }
301
302 let separator = "\n".repeat(self.delegate.spacing().max(1));
304 items.join(&separator)
305 }
306
307 fn view_footer(&self) -> String {
308 let mut footer = String::new();
309 if !self.is_empty() {
310 let singular = self.status_item_singular.as_deref().unwrap_or("item");
311 let plural = self.status_item_plural.as_deref().unwrap_or("items");
312 let noun = if self.len() == 1 { singular } else { plural };
313 footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
314 }
315 let help_view = self.help.view(self);
316 if !help_view.is_empty() {
317 footer.push('\n');
318 footer.push_str(&help_view);
319 }
320 footer
321 }
322}
323
324impl<I: Item> help::KeyMap for Model<I> {
326 fn short_help(&self) -> Vec<&key::Binding> {
327 match self.filter_state {
328 FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
329 _ => vec![
330 &self.keymap.cursor_up,
331 &self.keymap.cursor_down,
332 &self.keymap.filter,
333 ],
334 }
335 }
336 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
337 match self.filter_state {
338 FilterState::Filtering => {
339 vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
340 }
341 _ => vec![
342 vec![
343 &self.keymap.cursor_up,
344 &self.keymap.cursor_down,
345 &self.keymap.next_page,
346 &self.keymap.prev_page,
347 ],
348 vec![
349 &self.keymap.go_to_start,
350 &self.keymap.go_to_end,
351 &self.keymap.filter,
352 &self.keymap.clear_filter,
353 ],
354 ],
355 }
356 }
357}
358
359impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
360 fn init() -> (Self, Option<Cmd>) {
361 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
362 (model, None)
363 }
364 fn update(&mut self, msg: Msg) -> Option<Cmd> {
365 if self.filter_state == FilterState::Filtering {
366 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
367 match key_msg.key {
368 crossterm::event::KeyCode::Esc => {
369 self.filter_state = if self.filtered_items.is_empty() {
370 FilterState::Unfiltered
371 } else {
372 FilterState::FilterApplied
373 };
374 self.filter_input.blur();
375 return None;
376 }
377 crossterm::event::KeyCode::Enter => {
378 self.apply_filter();
379 self.filter_input.blur();
380 return None;
381 }
382 crossterm::event::KeyCode::Char(c) => {
383 let mut s = self.filter_input.value();
384 s.push(c);
385 self.filter_input.set_value(&s);
386 self.apply_filter();
387 }
388 crossterm::event::KeyCode::Backspace => {
389 let mut s = self.filter_input.value();
390 s.pop();
391 self.filter_input.set_value(&s);
392 self.apply_filter();
393 }
394 crossterm::event::KeyCode::Delete => { }
395 crossterm::event::KeyCode::Left => {
396 let pos = self.filter_input.position();
397 if pos > 0 {
398 self.filter_input.set_cursor(pos - 1);
399 }
400 }
401 crossterm::event::KeyCode::Right => {
402 let pos = self.filter_input.position();
403 self.filter_input.set_cursor(pos + 1);
404 }
405 crossterm::event::KeyCode::Home => {
406 self.filter_input.cursor_start();
407 }
408 crossterm::event::KeyCode::End => {
409 self.filter_input.cursor_end();
410 }
411 _ => {}
412 }
413 }
414 return None;
415 }
416
417 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
418 if self.keymap.cursor_up.matches(key_msg) {
419 if self.cursor > 0 {
420 self.cursor -= 1;
421 }
422 } else if self.keymap.cursor_down.matches(key_msg) {
423 if self.cursor < self.len().saturating_sub(1) {
424 self.cursor += 1;
425 }
426 } else if self.keymap.go_to_start.matches(key_msg) {
427 self.cursor = 0;
428 } else if self.keymap.go_to_end.matches(key_msg) {
429 self.cursor = self.len().saturating_sub(1);
430 } else if self.keymap.filter.matches(key_msg) {
431 self.filter_state = FilterState::Filtering;
432 return Some(self.filter_input.focus());
434 } else if self.keymap.clear_filter.matches(key_msg) {
435 self.filter_input.set_value("");
436 self.filter_state = FilterState::Unfiltered;
437 self.filtered_items.clear();
438 self.cursor = 0;
439 self.update_pagination();
440 }
441 }
442 None
443 }
444 fn view(&self) -> String {
445 lipgloss::join_vertical(
446 lipgloss::LEFT,
447 &[&self.view_header(), &self.view_items(), &self.view_footer()],
448 )
449 }
450}
451
452pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
454pub use keys::ListKeyMap;
455pub use style::ListStyles;
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[derive(Clone)]
462 struct S(&'static str);
463 impl std::fmt::Display for S {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 write!(f, "{}", self.0)
466 }
467 }
468 impl Item for S {
469 fn filter_value(&self) -> String {
470 self.0.to_string()
471 }
472 }
473
474 #[test]
475 fn test_status_bar_item_name() {
476 let mut list = Model::new(
477 vec![S("foo"), S("bar")],
478 defaultitem::DefaultDelegate::new(),
479 10,
480 10,
481 );
482 let v = list.status_view();
483 assert!(v.contains("2 items"));
484 list.set_items(vec![S("foo")]);
485 let v = list.status_view();
486 assert!(v.contains("1 item"));
487 }
488
489 #[test]
490 fn test_status_bar_without_items() {
491 let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
492 assert!(list.status_view().contains("No items") || list.is_empty());
493 }
494
495 #[test]
496 fn test_custom_status_bar_item_name() {
497 let mut list = Model::new(
498 vec![S("foo"), S("bar")],
499 defaultitem::DefaultDelegate::new(),
500 10,
501 10,
502 );
503 list.set_status_bar_item_name("connection", "connections");
504 assert!(list.status_view().contains("2 connections"));
505 list.set_items(vec![S("foo")]);
506 assert!(list.status_view().contains("1 connection"));
507 list.set_items(vec![]);
508 let _ = list.status_view();
510 }
511
512 #[test]
513 fn test_set_filter_text_and_state_visible_items() {
514 let tc = vec![S("foo"), S("bar"), S("baz")];
515 let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
516 list.set_filter_text("ba");
517 list.set_filter_state(FilterState::Unfiltered);
518 assert_eq!(list.visible_items().len(), tc.len());
519
520 list.set_filter_state(FilterState::Filtering);
521 list.apply_filter();
522 let vis = list.visible_items();
523 assert_eq!(vis.len(), 2); list.set_filter_state(FilterState::FilterApplied);
526 let vis2 = list.visible_items();
527 assert_eq!(vis2.len(), 2);
528 }
529
530 #[test]
531 fn test_selection_highlighting_works() {
532 let items = vec![S("first item"), S("second item"), S("third item")];
533 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
534
535 let view_output = list.view();
537 assert!(!view_output.is_empty(), "View should not be empty");
538
539 let first_view = list.view();
541 list.cursor = 1; let second_view = list.view();
543
544 assert_ne!(
546 first_view, second_view,
547 "Selection highlighting should change the view"
548 );
549 }
550
551 #[test]
552 fn test_filter_highlighting_works() {
553 let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
554 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
555
556 list.set_filter_text("ap");
558 list.apply_filter(); let filtered_view = list.view();
561 assert!(
562 !filtered_view.is_empty(),
563 "Filtered view should not be empty"
564 );
565
566 assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
568
569 assert!(
571 !list.filtered_items.is_empty(),
572 "Filtered items should have match data"
573 );
574 if !list.filtered_items.is_empty() {
575 assert!(
576 !list.filtered_items[0].matches.is_empty(),
577 "First filtered item should have matches"
578 );
579 assert_eq!(list.filtered_items[0].item.0, "apple pie");
581 }
582 }
583
584 #[test]
585 fn test_filter_highlighting_segment_based() {
586 let items = vec![S("Nutella"), S("Linux"), S("Python")];
587 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
588
589 list.set_filter_text("nut");
591 list.apply_filter();
592
593 assert_eq!(list.len(), 1, "Should have 1 item matching 'nut'");
594 assert_eq!(list.filtered_items[0].item.0, "Nutella");
595
596 let matches = &list.filtered_items[0].matches;
598 assert_eq!(
599 matches.len(),
600 3,
601 "Should have 3 character matches for 'nut'"
602 );
603 assert_eq!(matches[0], 0, "First match should be at index 0 (N)");
604 assert_eq!(matches[1], 1, "Second match should be at index 1 (u)");
605 assert_eq!(matches[2], 2, "Third match should be at index 2 (t)");
606
607 let rendered = list.view();
609
610 assert!(!rendered.is_empty(), "Rendered view should not be empty");
612
613 use super::defaultitem::apply_character_highlighting;
615 let test_result = apply_character_highlighting(
616 "Nutella",
617 &[0, 1, 2], &lipgloss::Style::new().bold(true),
619 &lipgloss::Style::new(),
620 );
621 assert!(
623 test_result.len() > "Nutella".len(),
624 "Highlighted text should be longer due to ANSI codes"
625 );
626
627 let test_result_sparse = apply_character_highlighting(
629 "Nutella",
630 &[0, 2, 4], &lipgloss::Style::new().underline(true),
632 &lipgloss::Style::new(),
633 );
634 assert!(
635 test_result_sparse.len() > "Nutella".len(),
636 "Sparse highlighted text should also work"
637 );
638 }
639
640 #[test]
641 fn test_filter_ansi_efficiency() {
642 use super::defaultitem::apply_character_highlighting;
644 let highlight_style = lipgloss::Style::new().bold(true);
645 let normal_style = lipgloss::Style::new();
646
647 let consecutive_result = apply_character_highlighting(
648 "Hello",
649 &[0, 1, 2], &highlight_style,
651 &normal_style,
652 );
653
654 let sparse_result = apply_character_highlighting(
655 "Hello",
656 &[0, 2, 4], &highlight_style,
658 &normal_style,
659 );
660
661 assert!(
664 consecutive_result.len() < sparse_result.len(),
665 "Consecutive highlighting should be more efficient than sparse highlighting"
666 );
667 }
668
669 #[test]
670 fn test_filter_unicode_characters() {
671 let items = vec![S("café"), S("naïve"), S("🦀 rust"), S("北京")];
672 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
673
674 list.set_filter_text("caf");
676 list.apply_filter();
677 assert_eq!(list.len(), 1);
678 assert_eq!(list.filtered_items[0].item.0, "café");
679
680 list.set_filter_text("rust");
682 list.apply_filter();
683 assert_eq!(list.len(), 1);
684 assert_eq!(list.filtered_items[0].item.0, "🦀 rust");
685
686 let rendered = list.view();
688 assert!(!rendered.is_empty());
689 }
690
691 #[test]
692 fn test_filter_highlighting_no_pipe_characters() {
693 let items = vec![S("Nutella"), S("Linux"), S("Python")];
696 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
697
698 list.set_filter_text("nut");
700 list.apply_filter();
701
702 assert_eq!(
703 list.len(),
704 1,
705 "Filter 'nut' should match exactly 1 item (Nutella)"
706 );
707 assert_eq!(list.filtered_items[0].item.0, "Nutella");
708
709 if !list.is_empty() {
712 list.cursor = list.len(); }
714 let unselected_rendered = list.view();
715
716 assert!(
719 !unselected_rendered.contains("N│u") && !unselected_rendered.contains("ut│e"),
720 "Unselected item rendering should not have pipe characters between highlighted segments. Output: {:?}",
721 unselected_rendered
722 );
723
724 list.cursor = 0; let selected_rendered = list.view();
727
728 assert!(
730 !selected_rendered.contains("N│u") && !selected_rendered.contains("ut│e"),
731 "Selected item should not have pipe characters between highlighted text segments. Output: {:?}",
732 selected_rendered
733 );
734
735 list.set_filter_text("li");
737 list.apply_filter();
738
739 assert_eq!(list.len(), 1);
740 assert_eq!(list.filtered_items[0].item.0, "Linux");
741
742 list.cursor = list.len(); let linux_unselected = list.view();
745 assert!(
746 !linux_unselected.contains("Li│n") && !linux_unselected.contains("i│n"),
747 "Unselected Linux should not have pipes between highlighted segments. Output: {:?}",
748 linux_unselected
749 );
750 }
751
752 #[test]
753 fn test_filter_highlighting_visual_correctness() {
754 let items = vec![S("Testing"), S("Visual"), S("Correctness")];
757 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
758
759 list.set_filter_text("t");
761 list.apply_filter();
762
763 let rendered = list.view();
764 assert!(
766 !rendered.contains("│T")
767 && !rendered.contains("T│")
768 && !rendered.contains("│t")
769 && !rendered.contains("t│"),
770 "Single character highlighting should not have pipe artifacts. Output: {:?}",
771 rendered
772 );
773
774 list.set_filter_text("test");
776 list.apply_filter();
777
778 let rendered = list.view();
779 assert!(
781 !rendered.contains("T│e") && !rendered.contains("e│s") && !rendered.contains("s│t"),
782 "Contiguous highlighting should not have character separation. Output: {:?}",
783 rendered
784 );
785
786 assert!(
789 rendered.contains("Testing") || rendered.matches("Test").count() > 0,
790 "Original text should be preserved in some form. Output: {:?}",
791 rendered
792 );
793 }
794
795 #[test]
796 fn test_filter_highlighting_ansi_efficiency() {
797 let items = vec![S("AbCdEfGh")];
799 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
800
801 list.set_filter_text("aceg");
803 list.apply_filter();
804
805 list.cursor = list.len(); let rendered = list.view();
808
809 let reset_count = rendered.matches("\x1b[0m").count();
811 let total_length = rendered.len();
812
813 assert!(
815 reset_count < total_length / 5,
816 "Too many ANSI reset sequences detected ({} resets in {} chars). This suggests inefficient styling. Output: {:?}",
817 reset_count, total_length, rendered
818 );
819
820 assert!(
822 !rendered.contains("\x1b[0m│") && !rendered.contains("│\x1b["),
823 "ANSI sequences should not be mixed with pipe characters. Output: {:?}",
824 rendered
825 );
826 }
827
828 #[test]
829 fn test_filter_highlighting_state_consistency() {
830 let items = vec![S("StateTest"), S("Another"), S("ThirdItem")];
832 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
833
834 list.set_filter_text("st");
835 list.apply_filter();
836
837 list.cursor = list.len(); let unselected = list.view();
840
841 list.cursor = 0; let selected = list.view();
844
845 assert!(
847 !unselected.contains("S│t") && !unselected.contains("t│a"),
848 "Unselected state should not have character separation. Output: {:?}",
849 unselected
850 );
851
852 assert!(
853 !selected.contains("S│t") && !selected.contains("t│a"),
854 "Selected state should not have character separation. Output: {:?}",
855 selected
856 );
857
858 if selected.contains("│") {
860 let lines: Vec<&str> = selected.lines().collect();
861 for line in lines {
862 if line.contains("StateTest") || line.contains("st") {
863 if let Some(pipe_pos) = line.find("│") {
865 let after_pipe = &line[pipe_pos + "│".len()..];
866 assert!(
867 !after_pipe.contains("│"),
868 "Only one pipe should appear per line (left border). Line: {:?}",
869 line
870 );
871 }
872 }
873 }
874 }
875 }
876
877 #[test]
878 fn test_filter_edge_cases() {
879 let items = vec![S("a"), S("ab"), S("abc"), S(""), S(" ")];
880 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
881
882 list.set_filter_text("a");
884 list.apply_filter();
885 assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
886
887 list.set_filter_text("");
889 list.apply_filter();
890 assert_eq!(list.filter_state, FilterState::Unfiltered);
891
892 list.set_filter_text("ab");
894 list.apply_filter();
895 assert!(list.len() >= 2, "Should match 'ab', 'abc'");
896
897 let rendered = list.view();
899 assert!(!rendered.is_empty());
900 }
901}