1#![forbid(unsafe_code)]
2
3use crate::block::Block;
8use crate::measurable::{MeasurableWidget, SizeConstraints};
9use crate::mouse::MouseResult;
10use crate::stateful::{StateKey, Stateful};
11use crate::undo_support::{ListUndoExt, UndoSupport, UndoWidgetId};
12use crate::{
13 StatefulWidget, Widget, clear_text_area, clear_text_row, draw_text_span,
14 draw_text_span_with_link,
15};
16use ftui_core::event::{KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
17use ftui_core::geometry::{Rect, Size};
18use ftui_render::frame::{Frame, HitId, HitRegion};
19use ftui_style::Style;
20use ftui_text::{Line, Span, Text as FtuiText, display_width};
21use std::collections::BTreeSet;
22#[cfg(feature = "tracing")]
23use web_time::Instant;
24
25type Text = FtuiText<'static>;
26
27fn text_into_owned(text: FtuiText<'_>) -> FtuiText<'static> {
28 FtuiText::from_lines(
29 text.into_iter()
30 .map(|line| Line::from_spans(line.into_iter().map(Span::into_owned))),
31 )
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ListItem<'a> {
37 content: Text,
38 style: Style,
39 marker: &'a str,
40}
41
42impl<'a> ListItem<'a> {
43 #[must_use]
45 pub fn new<'t>(content: impl Into<FtuiText<'t>>) -> Self {
46 Self {
47 content: text_into_owned(content.into()),
48 style: Style::default(),
49 marker: "",
50 }
51 }
52
53 #[must_use]
55 pub fn style(mut self, style: Style) -> Self {
56 self.style = style;
57 self
58 }
59
60 #[must_use]
62 pub fn marker(mut self, marker: &'a str) -> Self {
63 self.marker = marker;
64 self
65 }
66}
67
68impl<'a> From<&'a str> for ListItem<'a> {
69 fn from(s: &'a str) -> Self {
70 Self::new(s)
71 }
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct List<'a> {
77 block: Option<Block<'a>>,
78 items: Vec<ListItem<'a>>,
79 style: Style,
80 highlight_style: Style,
81 hover_style: Style,
82 highlight_symbol: Option<&'a str>,
83 hit_id: Option<HitId>,
86 data_hash: Option<u64>,
88}
89
90impl<'a> List<'a> {
91 #[must_use]
93 pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<'a>>>) -> Self {
94 Self {
95 block: None,
96 items: items.into_iter().map(|i| i.into()).collect(),
97 style: Style::default(),
98 highlight_style: Style::default(),
99 hover_style: Style::default(),
100 highlight_symbol: None,
101 hit_id: None,
102 data_hash: None,
103 }
104 }
105
106 #[must_use]
112 pub fn data_hash(mut self, hash: u64) -> Self {
113 self.data_hash = Some(hash);
114 self
115 }
116
117 #[must_use]
119 pub fn block(mut self, block: Block<'a>) -> Self {
120 self.block = Some(block);
121 self
122 }
123
124 #[must_use]
126 pub fn style(mut self, style: Style) -> Self {
127 self.style = style;
128 self
129 }
130
131 #[must_use]
133 pub fn highlight_style(mut self, style: Style) -> Self {
134 self.highlight_style = style;
135 self
136 }
137
138 #[must_use]
140 pub fn hover_style(mut self, style: Style) -> Self {
141 self.hover_style = style;
142 self
143 }
144
145 #[must_use]
147 pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
148 self.highlight_symbol = Some(symbol);
149 self
150 }
151
152 #[must_use]
158 pub fn hit_id(mut self, id: HitId) -> Self {
159 self.hit_id = Some(id);
160 self
161 }
162
163 fn filtered_indices(&self, state: &mut ListState) -> std::sync::Arc<[usize]> {
164 let query_str = state.filter_query();
165
166 if let Some(hash) = self.data_hash
167 && let Some((cached_hash, ref cached_query, ref indices)) = state.cached_display_indices
168 && cached_hash == hash
169 && cached_query == query_str
170 {
171 return std::sync::Arc::clone(indices);
172 }
173
174 let query = query_str.trim();
175 let indices: Vec<usize> = if query.is_empty() {
176 (0..self.items.len()).collect()
177 } else {
178 let query_lower = query.to_lowercase();
179 self.items
180 .iter()
181 .enumerate()
182 .filter_map(|(idx, item)| {
183 let line_text_cow;
186 let line_text_ref = if let Some(line) = item.content.lines().first() {
187 if line.spans().len() == 1 {
188 &line.spans()[0].content
189 } else {
190 line_text_cow = std::borrow::Cow::Owned(line.to_plain_text());
191 &line_text_cow
192 }
193 } else {
194 ""
195 };
196
197 let marker_matches = !item.marker.is_empty()
198 && crate::contains_ignore_case(item.marker, &query_lower);
199 if marker_matches || crate::contains_ignore_case(line_text_ref, &query_lower) {
200 Some(idx)
201 } else {
202 None
203 }
204 })
205 .collect()
206 };
207
208 let arc_indices: std::sync::Arc<[usize]> = indices.into();
209
210 if let Some(hash) = self.data_hash {
211 state.cached_display_indices = Some((
212 hash,
213 query_str.to_string(),
214 std::sync::Arc::clone(&arc_indices),
215 ));
216 }
217
218 arc_indices
219 }
220
221 fn apply_filtered_selection_guard(
222 &self,
223 state: &mut ListState,
224 filtered: &[usize],
225 force_select_first: bool,
226 ) {
227 if filtered.is_empty() {
228 state.selected = None;
229 state.hovered = None;
230 state.offset = 0;
231 state.multi_selected.clear();
232 return;
233 }
234
235 if let Some(selected) = state.selected {
236 if filtered.binary_search(&selected).is_err() {
237 state.selected = filtered.first().copied();
238 }
239 } else if force_select_first {
240 state.selected = filtered.first().copied();
241 }
242
243 state
244 .multi_selected
245 .retain(|idx| filtered.binary_search(idx).is_ok());
246 }
247
248 fn move_selection_in_filtered(
249 &self,
250 state: &mut ListState,
251 filtered: &[usize],
252 direction: isize,
253 ) -> bool {
254 if filtered.is_empty() {
255 if state.selected.is_some() {
256 state.select(None);
257 return true;
258 }
259 return false;
260 }
261
262 let max_pos = filtered.len().saturating_sub(1) as isize;
263
264 let next_pos = if let Some(selected) = state.selected {
265 let current_pos = filtered.binary_search(&selected).unwrap_or_else(|pos| pos);
268 (current_pos as isize + direction).clamp(0, max_pos) as usize
269 } else if direction > 0 {
270 0
271 } else {
272 max_pos as usize
273 };
274
275 let next_index = filtered[next_pos];
276
277 if state.selected == Some(next_index) {
278 return false;
279 }
280
281 state.selected = Some(next_index);
282 if !state.multi_select_enabled {
283 state.multi_selected.clear();
284 state.multi_selected.insert(next_index);
285 }
286 state.scroll_into_view_requested = true;
287 #[cfg(feature = "tracing")]
288 state.log_selection_change("keyboard_move");
289 true
290 }
291
292 pub fn handle_key(&self, state: &mut ListState, key: &KeyEvent) -> bool {
301 let nav_modifiers = key
302 .modifiers
303 .intersects(Modifiers::CTRL | Modifiers::ALT | Modifiers::SUPER);
304
305 match key.code {
306 KeyCode::Up if !nav_modifiers => {
307 let filtered = self.filtered_indices(state);
308 self.move_selection_in_filtered(state, &filtered, -1)
309 }
310 KeyCode::Down if !nav_modifiers => {
311 let filtered = self.filtered_indices(state);
312 self.move_selection_in_filtered(state, &filtered, 1)
313 }
314 KeyCode::Char('k') if !nav_modifiers => {
316 let filtered = self.filtered_indices(state);
317 self.move_selection_in_filtered(state, &filtered, -1)
318 }
319 KeyCode::Char('j') if !nav_modifiers => {
320 let filtered = self.filtered_indices(state);
321 self.move_selection_in_filtered(state, &filtered, 1)
322 }
323 KeyCode::Char(' ') if state.multi_select_enabled() => {
324 if let Some(selected) = state.selected {
325 state.toggle_multi_selected(selected);
326 true
327 } else {
328 false
329 }
330 }
331 KeyCode::Backspace => {
332 if state.filter_query.is_empty() {
333 return false;
334 }
335 state.filter_query.pop();
336 state.offset = 0;
337 state.scroll_into_view_requested = true;
338 let filtered = self.filtered_indices(state);
339 self.apply_filtered_selection_guard(state, &filtered, true);
340 #[cfg(feature = "tracing")]
341 state.log_selection_change("filter_backspace");
342 true
343 }
344 KeyCode::Escape => {
345 if state.filter_query.is_empty() {
346 return false;
347 }
348 state.filter_query.clear();
349 state.offset = 0;
350 state.scroll_into_view_requested = true;
351 let filtered = self.filtered_indices(state);
352 self.apply_filtered_selection_guard(state, &filtered, false);
353 #[cfg(feature = "tracing")]
354 state.log_selection_change("filter_clear");
355 true
356 }
357 KeyCode::Char(ch)
358 if !ch.is_control() && !key.ctrl() && !key.alt() && !key.super_key() =>
359 {
360 state.filter_query.push(ch);
362 state.offset = 0;
363 state.scroll_into_view_requested = true;
364 let filtered = self.filtered_indices(state);
365 self.apply_filtered_selection_guard(state, &filtered, true);
366 #[cfg(feature = "tracing")]
367 state.log_selection_change("filter_append");
368 true
369 }
370 _ => false,
371 }
372 }
373}
374
375#[derive(Debug, Clone)]
377pub struct ListState {
378 undo_id: UndoWidgetId,
380 pub selected: Option<usize>,
382 pub hovered: Option<usize>,
384 pub offset: usize,
386 persistence_id: Option<String>,
388 scroll_into_view_requested: bool,
390 filter_query: String,
392 multi_select_enabled: bool,
394 multi_selected: BTreeSet<usize>,
396 #[doc(hidden)]
398 pub cached_display_indices: Option<(u64, String, std::sync::Arc<[usize]>)>,
399}
400
401impl Default for ListState {
402 fn default() -> Self {
403 Self {
404 undo_id: UndoWidgetId::default(),
405 selected: None,
406 hovered: None,
407 offset: 0,
408 persistence_id: None,
409 scroll_into_view_requested: true,
410 filter_query: String::new(),
411 multi_select_enabled: false,
412 multi_selected: BTreeSet::new(),
413 cached_display_indices: None,
414 }
415 }
416}
417
418impl ListState {
419 pub fn select(&mut self, index: Option<usize>) {
421 self.selected = index;
422 if index.is_none() {
423 self.offset = 0;
424 self.multi_selected.clear();
425 } else if !self.multi_select_enabled
426 && let Some(selected) = index
427 {
428 self.multi_selected.clear();
429 self.multi_selected.insert(selected);
430 }
431 self.scroll_into_view_requested = true;
432 #[cfg(feature = "tracing")]
433 self.log_selection_change("select");
434 }
435
436 #[inline]
438 #[must_use = "use the selected index (if any)"]
439 pub fn selected(&self) -> Option<usize> {
440 self.selected
441 }
442
443 #[must_use]
445 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
446 self.persistence_id = Some(id.into());
447 self
448 }
449
450 #[inline]
452 #[must_use = "use the persistence id (if any)"]
453 pub fn persistence_id(&self) -> Option<&str> {
454 self.persistence_id.as_deref()
455 }
456
457 pub fn set_multi_select(&mut self, enabled: bool) {
459 if self.multi_select_enabled == enabled {
460 return;
461 }
462 self.multi_select_enabled = enabled;
463 if !enabled {
464 self.multi_selected.clear();
465 if let Some(selected) = self.selected {
466 self.multi_selected.insert(selected);
467 }
468 }
469 }
470
471 #[must_use]
473 pub const fn multi_select_enabled(&self) -> bool {
474 self.multi_select_enabled
475 }
476
477 #[must_use]
479 pub fn filter_query(&self) -> &str {
480 &self.filter_query
481 }
482
483 pub fn set_filter_query(&mut self, query: impl Into<String>) {
485 self.filter_query = query.into();
486 self.offset = 0;
487 self.scroll_into_view_requested = true;
488 }
489
490 pub fn clear_filter_query(&mut self) {
492 if !self.filter_query.is_empty() {
493 self.filter_query.clear();
494 self.offset = 0;
495 self.scroll_into_view_requested = true;
496 }
497 }
498
499 #[must_use]
501 pub fn selected_count(&self) -> usize {
502 if self.multi_select_enabled {
503 self.multi_selected.len()
504 } else {
505 usize::from(self.selected.is_some())
506 }
507 }
508
509 #[must_use]
511 pub fn selected_indices(&self) -> &BTreeSet<usize> {
512 &self.multi_selected
513 }
514
515 fn toggle_multi_selected(&mut self, index: usize) {
516 if !self.multi_select_enabled {
517 self.select(Some(index));
518 return;
519 }
520 if !self.multi_selected.insert(index) {
521 self.multi_selected.remove(&index);
522 }
523 self.selected = Some(index);
524 self.scroll_into_view_requested = true;
525 #[cfg(feature = "tracing")]
526 self.log_selection_change("toggle_multi");
527 }
528
529 #[cfg(feature = "tracing")]
530 fn log_selection_change(&self, action: &str) {
531 tracing::debug!(
532 message = "list.selection",
533 action,
534 selected = self.selected,
535 selected_count = self.selected_count(),
536 filter_active = !self.filter_query.trim().is_empty()
537 );
538 }
539
540 pub fn handle_mouse(
555 &mut self,
556 event: &MouseEvent,
557 hit: Option<(HitId, HitRegion, u64)>,
558 expected_id: HitId,
559 item_count: usize,
560 ) -> MouseResult {
561 match event.kind {
562 MouseEventKind::Down(MouseButton::Left) => {
563 if let Some((id, HitRegion::Content, data)) = hit
564 && id == expected_id
565 {
566 let index = data as usize;
567 if index < item_count {
568 if self.multi_select_enabled && event.modifiers.contains(Modifiers::CTRL) {
569 self.toggle_multi_selected(index);
570 return MouseResult::Selected(index);
571 }
572 if self.multi_select_enabled {
573 self.multi_selected.clear();
574 self.multi_selected.insert(index);
575 }
576 if !self.multi_select_enabled && self.selected == Some(index) {
578 #[cfg(feature = "tracing")]
579 self.log_selection_change("activate");
580 return MouseResult::Activated(index);
581 }
582 self.select(Some(index));
583 return MouseResult::Selected(index);
584 }
585 }
586 MouseResult::Ignored
587 }
588 MouseEventKind::Moved => {
589 if let Some((id, HitRegion::Content, data)) = hit
590 && id == expected_id
591 {
592 let index = data as usize;
593 if index < item_count {
594 let changed = self.hovered != Some(index);
595 self.hovered = Some(index);
596 return if changed {
597 MouseResult::HoverChanged
598 } else {
599 MouseResult::Ignored
600 };
601 }
602 }
603
604 if self.hovered.is_some() {
606 self.hovered = None;
607 MouseResult::HoverChanged
608 } else {
609 MouseResult::Ignored
610 }
611 }
612 MouseEventKind::ScrollUp => {
613 self.scroll_up(3);
614 MouseResult::Scrolled
615 }
616 MouseEventKind::ScrollDown => {
617 self.scroll_down(3, item_count);
618 MouseResult::Scrolled
619 }
620 _ => MouseResult::Ignored,
621 }
622 }
623
624 pub fn scroll_up(&mut self, lines: usize) {
626 self.offset = self.offset.saturating_sub(lines);
627 }
628
629 pub fn scroll_down(&mut self, lines: usize, item_count: usize) {
633 self.offset = self
634 .offset
635 .saturating_add(lines)
636 .min(item_count.saturating_sub(1));
637 }
638
639 pub fn select_next(&mut self, item_count: usize) {
643 if item_count == 0 {
644 return;
645 }
646 let next = match self.selected {
647 Some(i) => (i + 1).min(item_count.saturating_sub(1)),
648 None => 0,
649 };
650 self.selected = Some(next);
651 if !self.multi_select_enabled {
652 self.multi_selected.clear();
653 self.multi_selected.insert(next);
654 }
655 self.scroll_into_view_requested = true;
656 #[cfg(feature = "tracing")]
657 self.log_selection_change("select_next");
658 }
659
660 pub fn select_previous(&mut self) {
664 let prev = match self.selected {
665 Some(i) => i.saturating_sub(1),
666 None => 0,
667 };
668 self.selected = Some(prev);
669 if !self.multi_select_enabled {
670 self.multi_selected.clear();
671 self.multi_selected.insert(prev);
672 }
673 self.scroll_into_view_requested = true;
674 #[cfg(feature = "tracing")]
675 self.log_selection_change("select_previous");
676 }
677}
678
679#[derive(Clone, Debug, Default, PartialEq)]
687#[cfg_attr(
688 feature = "state-persistence",
689 derive(serde::Serialize, serde::Deserialize)
690)]
691pub struct ListPersistState {
692 pub selected: Option<usize>,
694 pub offset: usize,
696 pub filter_query: String,
698 pub multi_select_enabled: bool,
700 pub multi_selected: Vec<usize>,
702}
703
704impl Stateful for ListState {
705 type State = ListPersistState;
706
707 fn state_key(&self) -> StateKey {
708 StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
709 }
710
711 fn save_state(&self) -> ListPersistState {
712 ListPersistState {
713 selected: self.selected,
714 offset: self.offset,
715 filter_query: self.filter_query.clone(),
716 multi_select_enabled: self.multi_select_enabled,
717 multi_selected: self.multi_selected.iter().copied().collect(),
718 }
719 }
720
721 fn restore_state(&mut self, state: ListPersistState) {
722 self.selected = state.selected;
723 self.hovered = None;
724 self.offset = state.offset;
725 self.filter_query = state.filter_query;
726 self.multi_select_enabled = state.multi_select_enabled;
727 self.multi_selected = state.multi_selected.into_iter().collect();
728 }
729}
730
731impl<'a> StatefulWidget for List<'a> {
732 type State = ListState;
733
734 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
735 #[cfg(feature = "tracing")]
736 let _widget_span = tracing::debug_span!(
737 "widget_render",
738 widget = "List",
739 x = area.x,
740 y = area.y,
741 w = area.width,
742 h = area.height
743 )
744 .entered();
745
746 #[cfg(feature = "tracing")]
747 let render_start = Instant::now();
748 #[cfg(feature = "tracing")]
749 let total_items = self.items.len();
750 let filter_active = !state.filter_query.trim().is_empty();
751 #[cfg(feature = "tracing")]
752 let selected_count = state.selected_count();
753 #[cfg(feature = "tracing")]
754 let render_span = tracing::debug_span!(
755 "list.render",
756 total_items,
757 visible_items = tracing::field::Empty,
758 selected_count,
759 filter_active,
760 render_duration_us = tracing::field::Empty
761 );
762 #[cfg(feature = "tracing")]
763 let _render_guard = render_span.enter();
764
765 let list_area = match &self.block {
766 Some(b) => {
767 b.render(area, frame);
768 b.inner(area)
769 }
770 None => area,
771 };
772
773 let mut rendered_visible_items = 0usize;
774
775 if !list_area.is_empty() {
776 clear_text_area(frame, list_area, self.style);
779
780 if self.items.is_empty() {
781 state.selected = None;
782 state.hovered = None;
783 state.offset = 0;
784 state.multi_selected.clear();
785 draw_text_span(
786 frame,
787 list_area.x,
788 list_area.y,
789 "No items",
790 self.style,
791 list_area.right(),
792 );
793 } else {
794 if let Some(selected) = state.selected
796 && selected >= self.items.len()
797 {
798 state.selected = Some(self.items.len().saturating_sub(1));
799 }
800 if let Some(hovered) = state.hovered
801 && hovered >= self.items.len()
802 {
803 state.hovered = None;
804 }
805
806 let filtered_indices = self.filtered_indices(state);
807 self.apply_filtered_selection_guard(state, &filtered_indices, filter_active);
808
809 if filtered_indices.is_empty() {
810 draw_text_span(
811 frame,
812 list_area.x,
813 list_area.y,
814 "No matches",
815 self.style,
816 list_area.right(),
817 );
818 } else {
819 let list_height = list_area.height as usize;
820 let max_offset = filtered_indices.len().saturating_sub(list_height.max(1));
821 state.offset = state.offset.min(max_offset);
822
823 if let Some(hovered) = state.hovered
824 && filtered_indices.binary_search(&hovered).is_err()
825 {
826 state.hovered = None;
827 }
828
829 if state.scroll_into_view_requested {
831 if let Some(selected) = state.selected
832 && let Some(selected_pos) =
833 filtered_indices.binary_search(&selected).ok()
834 {
835 if selected_pos >= state.offset + list_height {
836 state.offset = selected_pos - list_height + 1;
837 } else if selected_pos < state.offset {
838 state.offset = selected_pos;
839 }
840 }
841 state.scroll_into_view_requested = false;
842 }
843
844 for (row, item_index) in filtered_indices
845 .iter()
846 .skip(state.offset)
847 .take(list_height)
848 .enumerate()
849 {
850 let i = *item_index;
851 let item = &self.items[i];
852 let y = list_area.y.saturating_add(row as u16);
853 if y >= list_area.bottom() {
854 break;
855 }
856 let is_selected = state.selected == Some(i)
857 || (state.multi_select_enabled && state.multi_selected.contains(&i));
858 let is_hovered = state.hovered == Some(i);
859
860 let mut item_style = if is_hovered {
863 self.hover_style.merge(&item.style)
864 } else {
865 item.style
866 };
867 if is_selected {
868 item_style = self.highlight_style.merge(&item_style);
869 }
870
871 let row_area = Rect::new(list_area.x, y, list_area.width, 1);
873 clear_text_row(frame, row_area, item_style);
874
875 let symbol = if is_selected {
877 self.highlight_symbol.unwrap_or(item.marker)
878 } else {
879 item.marker
880 };
881
882 let mut x = list_area.x;
883
884 if !symbol.is_empty() {
886 x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
887 x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
889 }
890
891 if let Some(line) = item.content.lines().first() {
894 for span in line.spans() {
895 let span_style = match span.style {
896 Some(s) => s.merge(&item_style),
897 None => item_style,
898 };
899 x = draw_text_span_with_link(
900 frame,
901 x,
902 y,
903 &span.content,
904 span_style,
905 list_area.right(),
906 span.link.as_deref(),
907 );
908 if x >= list_area.right() {
909 break;
910 }
911 }
912 }
913
914 if let Some(id) = self.hit_id {
916 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
917 }
918
919 rendered_visible_items = rendered_visible_items.saturating_add(1);
920 }
921
922 if filtered_indices.len() > list_height && list_area.width > 0 {
923 let indicator_x = list_area.right().saturating_sub(1);
924 if state.offset > 0 {
925 draw_text_span(
926 frame,
927 indicator_x,
928 list_area.y,
929 "↑",
930 self.style,
931 list_area.right(),
932 );
933 }
934 if state.offset + list_height < filtered_indices.len() {
935 draw_text_span(
936 frame,
937 indicator_x,
938 list_area.bottom().saturating_sub(1),
939 "↓",
940 self.style,
941 list_area.right(),
942 );
943 }
944 }
945 }
946 }
947 }
948
949 #[cfg(feature = "tracing")]
950 {
951 let elapsed_us = render_start.elapsed().as_micros() as u64;
952 render_span.record("visible_items", rendered_visible_items);
953 render_span.record("render_duration_us", elapsed_us);
954 tracing::debug!(
955 message = "list.metrics",
956 total_items,
957 visible_items = rendered_visible_items,
958 selected_count = state.selected_count(),
959 filter_active,
960 list_render_duration_us = elapsed_us
961 );
962 }
963 }
964}
965
966impl<'a> Widget for List<'a> {
967 fn render(&self, area: Rect, frame: &mut Frame) {
968 let mut state = ListState::default();
969 StatefulWidget::render(self, area, frame, &mut state);
970 }
971}
972
973impl ftui_a11y::Accessible for List<'_> {
974 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
975 use ftui_a11y::node::{A11yNodeInfo, A11yRole};
976
977 let base_id = crate::a11y_node_id(area);
978 let item_count = self.items.len();
979 let child_ids: Vec<u64> = (0..item_count).map(|i| base_id + 1 + i as u64).collect();
980
981 let title = self
982 .block
983 .as_ref()
984 .and_then(|b| b.title_text())
985 .unwrap_or_default();
986
987 let mut list_node =
988 A11yNodeInfo::new(base_id, A11yRole::List, area).with_children(child_ids);
989 if !title.is_empty() {
990 list_node = list_node.with_name(title);
991 }
992 list_node = list_node.with_description(format!("{item_count} items"));
993
994 let mut nodes = vec![list_node];
995 for (i, item) in self.items.iter().enumerate() {
996 let item_id = base_id + 1 + i as u64;
997 let item_text = item
998 .content
999 .lines()
1000 .first()
1001 .map(|line| line.to_plain_text())
1002 .unwrap_or_default();
1003 let item_node =
1004 A11yNodeInfo::new(item_id, A11yRole::ListItem, area).with_parent(base_id);
1005 let item_node = if item_text.is_empty() {
1006 item_node
1007 } else {
1008 item_node.with_name(item_text)
1009 };
1010 nodes.push(item_node);
1011 }
1012 nodes
1013 }
1014}
1015
1016impl MeasurableWidget for ListItem<'_> {
1017 fn measure(&self, _available: Size) -> SizeConstraints {
1018 let marker_width = display_width(self.marker) as u16;
1020 let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
1021
1022 let text_width = self
1024 .content
1025 .lines()
1026 .first()
1027 .map(|line| line.width())
1028 .unwrap_or(0)
1029 .min(u16::MAX as usize) as u16;
1030
1031 let total_width = marker_width
1032 .saturating_add(space_after_marker)
1033 .saturating_add(text_width);
1034
1035 SizeConstraints::exact(Size::new(total_width, 1))
1037 }
1038
1039 fn has_intrinsic_size(&self) -> bool {
1040 true
1041 }
1042}
1043
1044impl MeasurableWidget for List<'_> {
1045 fn measure(&self, available: Size) -> SizeConstraints {
1046 let (chrome_width, chrome_height) = self
1048 .block
1049 .as_ref()
1050 .map(|b| b.chrome_size())
1051 .unwrap_or((0, 0));
1052
1053 if self.items.is_empty() {
1054 return SizeConstraints {
1056 min: Size::new(chrome_width, chrome_height),
1057 preferred: Size::new(chrome_width, chrome_height),
1058 max: None,
1059 };
1060 }
1061
1062 let inner_available = Size::new(
1064 available.width.saturating_sub(chrome_width),
1065 available.height.saturating_sub(chrome_height),
1066 );
1067
1068 let mut max_width: u16 = 0;
1070 let mut total_height: u16 = 0;
1071
1072 for item in &self.items {
1073 let item_constraints = item.measure(inner_available);
1074 max_width = max_width.max(item_constraints.preferred.width);
1075 total_height = total_height.saturating_add(item_constraints.preferred.height);
1076 }
1077
1078 if let Some(symbol) = self.highlight_symbol {
1080 let symbol_width = display_width(symbol) as u16 + 1; max_width = max_width.saturating_add(symbol_width);
1082 }
1083
1084 let preferred_width = max_width.saturating_add(chrome_width);
1086 let preferred_height = total_height.saturating_add(chrome_height);
1087
1088 let min_height = chrome_height.saturating_add(1.min(total_height));
1090
1091 SizeConstraints {
1092 min: Size::new(chrome_width, min_height),
1093 preferred: Size::new(preferred_width, preferred_height),
1094 max: None, }
1096 }
1097
1098 fn has_intrinsic_size(&self) -> bool {
1099 !self.items.is_empty()
1100 }
1101}
1102
1103#[derive(Debug, Clone)]
1109pub struct ListStateSnapshot {
1110 selected: Option<usize>,
1111 offset: usize,
1112 filter_query: String,
1113 multi_select_enabled: bool,
1114 multi_selected: Vec<usize>,
1115}
1116
1117impl UndoSupport for ListState {
1118 fn undo_widget_id(&self) -> UndoWidgetId {
1119 self.undo_id
1120 }
1121
1122 fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
1123 Box::new(ListStateSnapshot {
1124 selected: self.selected,
1125 offset: self.offset,
1126 filter_query: self.filter_query.clone(),
1127 multi_select_enabled: self.multi_select_enabled,
1128 multi_selected: self.multi_selected.iter().copied().collect(),
1129 })
1130 }
1131
1132 fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
1133 if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
1134 self.selected = snap.selected;
1135 self.hovered = None;
1136 self.offset = snap.offset;
1137 self.filter_query = snap.filter_query.clone();
1138 self.multi_select_enabled = snap.multi_select_enabled;
1139 self.multi_selected = snap.multi_selected.iter().copied().collect();
1140 true
1141 } else {
1142 false
1143 }
1144 }
1145}
1146
1147impl ListUndoExt for ListState {
1148 fn selected_index(&self) -> Option<usize> {
1149 self.selected
1150 }
1151
1152 fn set_selected_index(&mut self, index: Option<usize>) {
1153 self.selected = index;
1154 if index.is_none() {
1155 self.offset = 0;
1156 self.multi_selected.clear();
1157 } else if !self.multi_select_enabled
1158 && let Some(selected) = index
1159 {
1160 self.multi_selected.clear();
1161 self.multi_selected.insert(selected);
1162 }
1163 }
1164}
1165
1166impl ListState {
1167 #[must_use]
1171 pub fn undo_id(&self) -> UndoWidgetId {
1172 self.undo_id
1173 }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use ftui_core::event::{KeyCode, KeyEvent};
1180 use ftui_render::cell::Cell;
1181 use ftui_render::grapheme_pool::GraphemePool;
1182 #[cfg(feature = "tracing")]
1183 use std::sync::{Arc, Mutex};
1184 #[cfg(feature = "tracing")]
1185 use tracing::Subscriber;
1186 #[cfg(feature = "tracing")]
1187 use tracing_subscriber::Layer;
1188 #[cfg(feature = "tracing")]
1189 use tracing_subscriber::layer::{Context, SubscriberExt};
1190
1191 fn row_text(frame: &Frame, y: u16) -> String {
1192 let width = frame.buffer.width();
1193 let mut actual = String::new();
1194 for x in 0..width {
1195 let ch = frame
1196 .buffer
1197 .get(x, y)
1198 .and_then(|cell| cell.content.as_char())
1199 .unwrap_or(' ');
1200 actual.push(ch);
1201 }
1202 actual.trim().to_string()
1203 }
1204
1205 fn raw_row_text(frame: &Frame, y: u16) -> String {
1206 let width = frame.buffer.width();
1207 let mut actual = String::new();
1208 for x in 0..width {
1209 let ch = frame
1210 .buffer
1211 .get(x, y)
1212 .and_then(|cell| cell.content.as_char())
1213 .unwrap_or(' ');
1214 actual.push(ch);
1215 }
1216 actual
1217 }
1218
1219 #[cfg(feature = "tracing")]
1220 #[derive(Debug, Default)]
1221 struct ListTraceState {
1222 list_render_seen: bool,
1223 has_total_items_field: bool,
1224 has_visible_items_field: bool,
1225 has_selected_count_field: bool,
1226 has_filter_active_field: bool,
1227 render_duration_recorded: bool,
1228 selection_events: usize,
1229 }
1230
1231 #[cfg(feature = "tracing")]
1232 struct ListTraceCapture {
1233 state: Arc<Mutex<ListTraceState>>,
1234 }
1235
1236 #[cfg(feature = "tracing")]
1237 impl<S> Layer<S> for ListTraceCapture
1238 where
1239 S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
1240 {
1241 fn on_new_span(
1242 &self,
1243 attrs: &tracing::span::Attributes<'_>,
1244 _id: &tracing::Id,
1245 _ctx: Context<'_, S>,
1246 ) {
1247 if attrs.metadata().name() != "list.render" {
1248 return;
1249 }
1250 let fields = attrs.metadata().fields();
1251 let mut state = self.state.lock().expect("list trace state lock");
1252 state.list_render_seen = true;
1253 state.has_total_items_field |= fields.field("total_items").is_some();
1254 state.has_visible_items_field |= fields.field("visible_items").is_some();
1255 state.has_selected_count_field |= fields.field("selected_count").is_some();
1256 state.has_filter_active_field |= fields.field("filter_active").is_some();
1257 }
1258
1259 fn on_record(
1260 &self,
1261 id: &tracing::Id,
1262 values: &tracing::span::Record<'_>,
1263 ctx: Context<'_, S>,
1264 ) {
1265 let Some(span) = ctx.span(id) else {
1266 return;
1267 };
1268 if span.metadata().name() != "list.render" {
1269 return;
1270 }
1271 struct DurationVisitor {
1272 saw_duration: bool,
1273 }
1274 impl tracing::field::Visit for DurationVisitor {
1275 fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
1276 if field.name() == "render_duration_us" {
1277 self.saw_duration = true;
1278 }
1279 }
1280
1281 fn record_debug(
1282 &mut self,
1283 field: &tracing::field::Field,
1284 _value: &dyn std::fmt::Debug,
1285 ) {
1286 if field.name() == "render_duration_us" {
1287 self.saw_duration = true;
1288 }
1289 }
1290 }
1291 let mut visitor = DurationVisitor {
1292 saw_duration: false,
1293 };
1294 values.record(&mut visitor);
1295 if visitor.saw_duration {
1296 self.state
1297 .lock()
1298 .expect("list trace state lock")
1299 .render_duration_recorded = true;
1300 }
1301 }
1302
1303 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1304 struct MessageVisitor {
1305 message: Option<String>,
1306 }
1307 impl tracing::field::Visit for MessageVisitor {
1308 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1309 if field.name() == "message" {
1310 self.message = Some(value.to_owned());
1311 }
1312 }
1313
1314 fn record_debug(
1315 &mut self,
1316 field: &tracing::field::Field,
1317 value: &dyn std::fmt::Debug,
1318 ) {
1319 if field.name() == "message" {
1320 self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
1321 }
1322 }
1323 }
1324 let mut visitor = MessageVisitor { message: None };
1325 event.record(&mut visitor);
1326 if visitor.message.as_deref() == Some("list.selection") {
1327 let mut state = self.state.lock().expect("list trace state lock");
1328 state.selection_events = state.selection_events.saturating_add(1);
1329 }
1330 }
1331 }
1332
1333 #[test]
1334 fn render_empty_list() {
1335 let list = List::new(Vec::<ListItem>::new());
1336 let area = Rect::new(0, 0, 10, 5);
1337 let mut pool = GraphemePool::new();
1338 let mut frame = Frame::new(10, 5, &mut pool);
1339 Widget::render(&list, area, &mut frame);
1340 }
1341
1342 #[test]
1343 fn render_simple_list() {
1344 let items = vec![
1345 ListItem::new("Item A"),
1346 ListItem::new("Item B"),
1347 ListItem::new("Item C"),
1348 ];
1349 let list = List::new(items);
1350 let area = Rect::new(0, 0, 10, 3);
1351 let mut pool = GraphemePool::new();
1352 let mut frame = Frame::new(10, 3, &mut pool);
1353 let mut state = ListState::default();
1354 StatefulWidget::render(&list, area, &mut frame, &mut state);
1355
1356 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
1357 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
1358 assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
1359 assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
1360 }
1361
1362 #[test]
1363 fn list_state_select() {
1364 let mut state = ListState::default();
1365 assert_eq!(state.selected(), None);
1366
1367 state.select(Some(2));
1368 assert_eq!(state.selected(), Some(2));
1369
1370 state.select(None);
1371 assert_eq!(state.selected(), None);
1372 assert_eq!(state.offset, 0);
1373 }
1374
1375 #[test]
1376 fn list_scrolls_to_selected() {
1377 let items: Vec<ListItem> = (0..10)
1378 .map(|i| ListItem::new(format!("Item {i}")))
1379 .collect();
1380 let list = List::new(items);
1381 let area = Rect::new(0, 0, 10, 3);
1382 let mut pool = GraphemePool::new();
1383 let mut frame = Frame::new(10, 3, &mut pool);
1384 let mut state = ListState::default();
1385 state.select(Some(5));
1386
1387 StatefulWidget::render(&list, area, &mut frame, &mut state);
1388 assert!(state.offset <= 5);
1390 assert!(state.offset + 3 > 5);
1391 }
1392
1393 #[test]
1394 fn list_clamps_selection() {
1395 let items = vec![ListItem::new("A"), ListItem::new("B")];
1396 let list = List::new(items);
1397 let area = Rect::new(0, 0, 10, 3);
1398 let mut pool = GraphemePool::new();
1399 let mut frame = Frame::new(10, 3, &mut pool);
1400 let mut state = ListState::default();
1401 state.select(Some(10)); StatefulWidget::render(&list, area, &mut frame, &mut state);
1404 assert_eq!(state.selected(), Some(1));
1406 }
1407
1408 #[test]
1409 fn render_list_with_highlight_symbol() {
1410 let items = vec![ListItem::new("A"), ListItem::new("B")];
1411 let list = List::new(items).highlight_symbol(">");
1412 let area = Rect::new(0, 0, 10, 2);
1413 let mut pool = GraphemePool::new();
1414 let mut frame = Frame::new(10, 2, &mut pool);
1415 let mut state = ListState::default();
1416 state.select(Some(0));
1417
1418 StatefulWidget::render(&list, area, &mut frame, &mut state);
1419 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
1421 }
1422
1423 #[test]
1424 fn render_zero_area() {
1425 let list = List::new(vec![ListItem::new("A")]);
1426 let area = Rect::new(0, 0, 0, 0);
1427 let mut pool = GraphemePool::new();
1428 let mut frame = Frame::new(1, 1, &mut pool);
1429 let mut state = ListState::default();
1430 StatefulWidget::render(&list, area, &mut frame, &mut state);
1431 }
1432
1433 #[test]
1434 fn list_item_from_str() {
1435 let item: ListItem = "hello".into();
1436 assert_eq!(
1437 item.content.lines().first().unwrap().to_plain_text(),
1438 "hello"
1439 );
1440 assert_eq!(item.marker, "");
1441 }
1442
1443 #[test]
1444 fn list_item_with_marker() {
1445 let items = vec![
1446 ListItem::new("A").marker("•"),
1447 ListItem::new("B").marker("•"),
1448 ];
1449 let list = List::new(items);
1450 let area = Rect::new(0, 0, 10, 2);
1451 let mut pool = GraphemePool::new();
1452 let mut frame = Frame::new(10, 2, &mut pool);
1453 let mut state = ListState::default();
1454 StatefulWidget::render(&list, area, &mut frame, &mut state);
1455
1456 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
1458 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
1459 }
1460
1461 #[test]
1462 fn list_state_deselect_resets_offset() {
1463 let mut state = ListState {
1464 offset: 5,
1465 ..Default::default()
1466 };
1467 state.select(Some(10));
1468 assert_eq!(state.offset, 5); state.select(None);
1471 assert_eq!(state.offset, 0); }
1473
1474 #[test]
1475 fn list_scrolls_up_when_selection_above_viewport() {
1476 let items: Vec<ListItem> = (0..10)
1477 .map(|i| ListItem::new(format!("Item {i}")))
1478 .collect();
1479 let list = List::new(items);
1480 let area = Rect::new(0, 0, 10, 3);
1481 let mut pool = GraphemePool::new();
1482 let mut frame = Frame::new(10, 3, &mut pool);
1483 let mut state = ListState::default();
1484
1485 state.select(Some(8));
1487 StatefulWidget::render(&list, area, &mut frame, &mut state);
1488 assert!(state.offset > 0);
1489
1490 state.select(Some(0));
1492 StatefulWidget::render(&list, area, &mut frame, &mut state);
1493 assert_eq!(state.offset, 0);
1494 }
1495
1496 #[test]
1497 fn list_clamps_offset_to_fill_viewport_on_resize() {
1498 let items: Vec<ListItem> = (0..10)
1499 .map(|i| ListItem::new(format!("Item {i}")))
1500 .collect();
1501 let list = List::new(items);
1502
1503 let mut pool = GraphemePool::new();
1504 let mut state = ListState {
1505 offset: 7,
1506 ..Default::default()
1507 };
1508
1509 let area_small = Rect::new(0, 0, 10, 3);
1511 let mut frame_small = Frame::new(10, 3, &mut pool);
1512 StatefulWidget::render(&list, area_small, &mut frame_small, &mut state);
1513 assert_eq!(state.offset, 7);
1514 assert!(row_text(&frame_small, 0).starts_with("Item 7"));
1515 assert!(row_text(&frame_small, 2).starts_with("Item 9"));
1516
1517 let area_large = Rect::new(0, 0, 10, 5);
1519 let mut frame_large = Frame::new(10, 5, &mut pool);
1520 StatefulWidget::render(&list, area_large, &mut frame_large, &mut state);
1521 assert_eq!(state.offset, 5);
1522 assert!(row_text(&frame_large, 0).starts_with("Item 5"));
1523 assert!(row_text(&frame_large, 4).starts_with("Item 9"));
1524 }
1525
1526 #[test]
1527 fn render_list_more_items_than_viewport() {
1528 let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
1529 let list = List::new(items);
1530 let area = Rect::new(0, 0, 5, 3);
1531 let mut pool = GraphemePool::new();
1532 let mut frame = Frame::new(5, 3, &mut pool);
1533 let mut state = ListState::default();
1534 StatefulWidget::render(&list, area, &mut frame, &mut state);
1535
1536 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1538 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
1539 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
1540 }
1541
1542 #[test]
1543 fn widget_render_uses_default_state() {
1544 let items = vec![ListItem::new("X")];
1545 let list = List::new(items);
1546 let area = Rect::new(0, 0, 5, 1);
1547 let mut pool = GraphemePool::new();
1548 let mut frame = Frame::new(5, 1, &mut pool);
1549 Widget::render(&list, area, &mut frame);
1551 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
1552 }
1553
1554 #[test]
1555 fn list_registers_hit_regions() {
1556 let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
1557 let list = List::new(items).hit_id(HitId::new(42));
1558 let area = Rect::new(0, 0, 10, 3);
1559 let mut pool = GraphemePool::new();
1560 let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
1561 let mut state = ListState::default();
1562 StatefulWidget::render(&list, area, &mut frame, &mut state);
1563
1564 let hit0 = frame.hit_test(5, 0);
1566 let hit1 = frame.hit_test(5, 1);
1567 let hit2 = frame.hit_test(5, 2);
1568
1569 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
1570 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
1571 assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
1572 }
1573
1574 #[test]
1575 fn list_no_hit_without_hit_id() {
1576 let items = vec![ListItem::new("A")];
1577 let list = List::new(items); let area = Rect::new(0, 0, 10, 1);
1579 let mut pool = GraphemePool::new();
1580 let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
1581 let mut state = ListState::default();
1582 StatefulWidget::render(&list, area, &mut frame, &mut state);
1583
1584 assert!(frame.hit_test(5, 0).is_none());
1586 }
1587
1588 #[test]
1589 fn list_no_hit_without_hit_grid() {
1590 let items = vec![ListItem::new("A")];
1591 let list = List::new(items).hit_id(HitId::new(1));
1592 let area = Rect::new(0, 0, 10, 1);
1593 let mut pool = GraphemePool::new();
1594 let mut frame = Frame::new(10, 1, &mut pool); let mut state = ListState::default();
1596 StatefulWidget::render(&list, area, &mut frame, &mut state);
1597
1598 assert!(frame.hit_test(5, 0).is_none());
1600 }
1601
1602 use crate::MeasurableWidget;
1605 use ftui_core::geometry::Size;
1606
1607 #[test]
1608 fn list_item_measure_simple() {
1609 let item = ListItem::new("Hello"); let constraints = item.measure(Size::MAX);
1611
1612 assert_eq!(constraints.preferred, Size::new(5, 1));
1613 assert_eq!(constraints.min, Size::new(5, 1));
1614 assert_eq!(constraints.max, Some(Size::new(5, 1)));
1615 }
1616
1617 #[test]
1618 fn list_item_measure_with_marker() {
1619 let item = ListItem::new("Hi").marker("•"); let constraints = item.measure(Size::MAX);
1621
1622 assert_eq!(constraints.preferred.width, 4);
1623 assert_eq!(constraints.preferred.height, 1);
1624 }
1625
1626 #[test]
1627 fn list_item_has_intrinsic_size() {
1628 let item = ListItem::new("test");
1629 assert!(item.has_intrinsic_size());
1630 }
1631
1632 #[test]
1633 fn list_measure_empty() {
1634 let list = List::new(Vec::<ListItem>::new());
1635 let constraints = list.measure(Size::MAX);
1636
1637 assert_eq!(constraints.preferred, Size::new(0, 0));
1638 assert!(!list.has_intrinsic_size());
1639 }
1640
1641 #[test]
1642 fn list_measure_single_item() {
1643 let items = vec![ListItem::new("Hello")]; let list = List::new(items);
1645 let constraints = list.measure(Size::MAX);
1646
1647 assert_eq!(constraints.preferred, Size::new(5, 1));
1648 assert_eq!(constraints.min.height, 1);
1649 }
1650
1651 #[test]
1652 fn list_measure_multiple_items() {
1653 let items = vec![
1654 ListItem::new("Short"), ListItem::new("LongerItem"), ListItem::new("Tiny"), ];
1658 let list = List::new(items);
1659 let constraints = list.measure(Size::MAX);
1660
1661 assert_eq!(constraints.preferred.width, 10);
1663 assert_eq!(constraints.preferred.height, 3);
1665 }
1666
1667 #[test]
1668 fn list_measure_with_block() {
1669 let block = crate::block::Block::bordered(); let items = vec![ListItem::new("Hi")]; let list = List::new(items).block(block);
1672 let constraints = list.measure(Size::MAX);
1673
1674 assert_eq!(constraints.preferred, Size::new(6, 5));
1677 }
1678
1679 #[test]
1680 fn list_measure_with_highlight_symbol() {
1681 let items = vec![ListItem::new("Item")]; let list = List::new(items).highlight_symbol(">"); let constraints = list.measure(Size::MAX);
1685
1686 assert_eq!(constraints.preferred.width, 6);
1688 }
1689
1690 #[test]
1691 fn list_has_intrinsic_size() {
1692 let items = vec![ListItem::new("X")];
1693 let list = List::new(items);
1694 assert!(list.has_intrinsic_size());
1695 }
1696
1697 #[test]
1698 fn list_min_height_is_one_row() {
1699 let items: Vec<ListItem> = (0..100)
1700 .map(|i| ListItem::new(format!("Item {i}")))
1701 .collect();
1702 let list = List::new(items);
1703 let constraints = list.measure(Size::MAX);
1704
1705 assert_eq!(constraints.min.height, 1);
1707 assert_eq!(constraints.preferred.height, 100);
1709 }
1710
1711 #[test]
1712 fn list_measure_is_pure() {
1713 let items = vec![ListItem::new("Test")];
1714 let list = List::new(items);
1715 let a = list.measure(Size::new(100, 50));
1716 let b = list.measure(Size::new(100, 50));
1717 assert_eq!(a, b);
1718 }
1719
1720 #[test]
1723 fn list_state_undo_id_is_stable() {
1724 let state = ListState::default();
1725 let id1 = state.undo_id();
1726 let id2 = state.undo_id();
1727 assert_eq!(id1, id2);
1728 }
1729
1730 #[test]
1731 fn list_state_undo_id_unique_per_instance() {
1732 let state1 = ListState::default();
1733 let state2 = ListState::default();
1734 assert_ne!(state1.undo_id(), state2.undo_id());
1735 }
1736
1737 #[test]
1738 fn list_state_snapshot_and_restore() {
1739 let mut state = ListState::default();
1740 state.select(Some(5));
1741 state.offset = 3;
1742
1743 let snapshot = state.create_snapshot();
1744
1745 state.select(Some(10));
1747 state.offset = 8;
1748 assert_eq!(state.selected(), Some(10));
1749 assert_eq!(state.offset, 8);
1750
1751 assert!(state.restore_snapshot(snapshot.as_ref()));
1753 assert_eq!(state.selected(), Some(5));
1754 assert_eq!(state.offset, 3);
1755 }
1756
1757 #[test]
1758 fn list_state_undo_ext_methods() {
1759 let mut state = ListState::default();
1760 assert_eq!(state.selected_index(), None);
1761
1762 state.set_selected_index(Some(3));
1763 assert_eq!(state.selected_index(), Some(3));
1764
1765 state.set_selected_index(None);
1766 assert_eq!(state.selected_index(), None);
1767 assert_eq!(state.offset, 0); }
1769
1770 use crate::stateful::Stateful;
1773
1774 #[test]
1775 fn list_state_with_persistence_id() {
1776 let state = ListState::default().with_persistence_id("sidebar-menu");
1777 assert_eq!(state.persistence_id(), Some("sidebar-menu"));
1778 }
1779
1780 #[test]
1781 fn list_state_default_no_persistence_id() {
1782 let state = ListState::default();
1783 assert_eq!(state.persistence_id(), None);
1784 }
1785
1786 #[test]
1787 fn list_state_save_restore_round_trip() {
1788 let mut state = ListState::default().with_persistence_id("test");
1789 state.select(Some(7));
1790 state.offset = 4;
1791
1792 let saved = state.save_state();
1793 assert_eq!(saved.selected, Some(7));
1794 assert_eq!(saved.offset, 4);
1795
1796 state.select(None);
1798 assert_eq!(state.selected, None);
1799 assert_eq!(state.offset, 0);
1800
1801 state.restore_state(saved);
1803 assert_eq!(state.selected, Some(7));
1804 assert_eq!(state.offset, 4);
1805 }
1806
1807 #[test]
1808 fn list_state_key_uses_persistence_id() {
1809 let state = ListState::default().with_persistence_id("file-browser");
1810 let key = state.state_key();
1811 assert_eq!(key.widget_type, "List");
1812 assert_eq!(key.instance_id, "file-browser");
1813 }
1814
1815 #[test]
1816 fn list_state_key_default_when_no_id() {
1817 let state = ListState::default();
1818 let key = state.state_key();
1819 assert_eq!(key.widget_type, "List");
1820 assert_eq!(key.instance_id, "default");
1821 }
1822
1823 #[test]
1824 fn list_persist_state_default() {
1825 let persist = ListPersistState::default();
1826 assert_eq!(persist.selected, None);
1827 assert_eq!(persist.offset, 0);
1828 }
1829
1830 use crate::mouse::MouseResult;
1833 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
1834
1835 #[test]
1836 fn list_state_click_selects() {
1837 let mut state = ListState::default();
1838 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1839 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1840 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1841 assert_eq!(result, MouseResult::Selected(3));
1842 assert_eq!(state.selected(), Some(3));
1843 }
1844
1845 #[test]
1846 fn list_state_click_wrong_id_ignored() {
1847 let mut state = ListState::default();
1848 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1849 let hit = Some((HitId::new(99), HitRegion::Content, 3u64));
1850 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1851 assert_eq!(result, MouseResult::Ignored);
1852 assert_eq!(state.selected(), None);
1853 }
1854
1855 #[test]
1856 fn list_state_click_out_of_range() {
1857 let mut state = ListState::default();
1858 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1859 let hit = Some((HitId::new(1), HitRegion::Content, 15u64));
1860 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1861 assert_eq!(result, MouseResult::Ignored);
1862 assert_eq!(state.selected(), None);
1863 }
1864
1865 #[test]
1866 fn list_state_click_no_hit_ignored() {
1867 let mut state = ListState::default();
1868 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1869 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1870 assert_eq!(result, MouseResult::Ignored);
1871 }
1872
1873 #[test]
1874 #[allow(clippy::field_reassign_with_default)]
1875 fn list_state_scroll_up() {
1876 let mut state = {
1877 let mut s = ListState::default();
1878 s.offset = 10;
1879 s
1880 };
1881 state.scroll_up(3);
1882 assert_eq!(state.offset, 7);
1883 }
1884
1885 #[test]
1886 #[allow(clippy::field_reassign_with_default)]
1887 fn list_state_scroll_up_clamps_to_zero() {
1888 let mut state = {
1889 let mut s = ListState::default();
1890 s.offset = 1;
1891 s
1892 };
1893 state.scroll_up(5);
1894 assert_eq!(state.offset, 0);
1895 }
1896
1897 #[test]
1898 fn list_state_scroll_down() {
1899 let mut state = ListState::default();
1900 state.scroll_down(3, 20);
1901 assert_eq!(state.offset, 3);
1902 }
1903
1904 #[test]
1905 #[allow(clippy::field_reassign_with_default)]
1906 fn list_state_scroll_down_clamps() {
1907 let mut state = ListState::default();
1908 state.offset = 18;
1909 state.scroll_down(5, 20);
1910 assert_eq!(state.offset, 19); }
1912
1913 #[test]
1914 #[allow(clippy::field_reassign_with_default)]
1915 fn list_state_scroll_wheel_up() {
1916 let mut state = {
1917 let mut s = ListState::default();
1918 s.offset = 10;
1919 s
1920 };
1921 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1922 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1923 assert_eq!(result, MouseResult::Scrolled);
1924 assert_eq!(state.offset, 7);
1925 }
1926
1927 #[test]
1928 fn list_state_scroll_wheel_down() {
1929 let mut state = ListState::default();
1930 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1931 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1932 assert_eq!(result, MouseResult::Scrolled);
1933 assert_eq!(state.offset, 3);
1934 }
1935
1936 #[test]
1937 fn list_state_select_next() {
1938 let mut state = ListState::default();
1939 state.select_next(5);
1940 assert_eq!(state.selected(), Some(0));
1941 state.select_next(5);
1942 assert_eq!(state.selected(), Some(1));
1943 }
1944
1945 #[test]
1946 fn list_state_select_next_clamps() {
1947 let mut state = ListState::default();
1948 state.select(Some(4));
1949 state.select_next(5);
1950 assert_eq!(state.selected(), Some(4)); }
1952
1953 #[test]
1954 fn list_state_select_next_empty() {
1955 let mut state = ListState::default();
1956 state.select_next(0);
1957 assert_eq!(state.selected(), None); }
1959
1960 #[test]
1961 fn list_state_select_previous() {
1962 let mut state = ListState::default();
1963 state.select(Some(3));
1964 state.select_previous();
1965 assert_eq!(state.selected(), Some(2));
1966 }
1967
1968 #[test]
1969 fn list_state_select_previous_clamps() {
1970 let mut state = ListState::default();
1971 state.select(Some(0));
1972 state.select_previous();
1973 assert_eq!(state.selected(), Some(0)); }
1975
1976 #[test]
1977 fn list_state_select_previous_from_none() {
1978 let mut state = ListState::default();
1979 state.select_previous();
1980 assert_eq!(state.selected(), Some(0));
1981 }
1982
1983 #[test]
1984 fn list_handle_key_down_from_none_selects_first() {
1985 let list = List::new(vec![
1986 ListItem::new("a"),
1987 ListItem::new("b"),
1988 ListItem::new("c"),
1989 ]);
1990 let mut state = ListState::default();
1991 assert_eq!(state.selected(), None);
1992
1993 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
1995 assert_eq!(state.selected(), Some(0));
1997 }
1998
1999 #[test]
2000 fn list_handle_key_up_from_none_selects_last() {
2001 let list = List::new(vec![
2002 ListItem::new("a"),
2003 ListItem::new("b"),
2004 ListItem::new("c"),
2005 ]);
2006 let mut state = ListState::default();
2007 assert_eq!(state.selected(), None);
2008
2009 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2011 assert_eq!(state.selected(), Some(2));
2013 }
2014
2015 #[test]
2016 fn list_handle_key_navigation_supports_jk_and_arrows() {
2017 let list = List::new(vec![
2018 ListItem::new("a"),
2019 ListItem::new("b"),
2020 ListItem::new("c"),
2021 ]);
2022 let mut state = ListState::default();
2023 state.select(Some(0));
2024
2025 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2026 assert_eq!(state.selected(), Some(1));
2027 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2028 assert_eq!(state.selected(), Some(2));
2029 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2030 assert_eq!(state.selected(), Some(1));
2031 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
2032 assert_eq!(state.selected(), Some(0));
2033 }
2034
2035 #[test]
2036 fn list_handle_key_filter_is_incremental_and_editable() {
2037 let list = List::new(vec![
2038 ListItem::new("alpha"),
2039 ListItem::new("banana"),
2040 ListItem::new("beta"),
2041 ]);
2042 let mut state = ListState::default();
2043
2044 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2045 assert_eq!(state.filter_query(), "b");
2046 assert_eq!(state.selected(), Some(1));
2047
2048 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('e'))));
2049 assert_eq!(state.filter_query(), "be");
2050 assert_eq!(state.selected(), Some(2));
2051
2052 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
2053 assert_eq!(state.filter_query(), "b");
2054
2055 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
2056 assert_eq!(state.filter_query(), "");
2057 }
2058
2059 #[test]
2060 fn list_render_filter_no_matches_shows_empty_state() {
2061 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2062 let mut state = ListState::default();
2063 state.set_filter_query("zzz");
2064
2065 let mut pool = GraphemePool::new();
2066 let mut frame = Frame::new(14, 3, &mut pool);
2067 StatefulWidget::render(&list, Rect::new(0, 0, 14, 3), &mut frame, &mut state);
2068
2069 assert_eq!(row_text(&frame, 0), "No matches");
2070 }
2071
2072 #[test]
2073 fn list_render_shorter_item_clears_stale_row_suffix() {
2074 let mut pool = GraphemePool::new();
2075 let mut frame = Frame::new(12, 2, &mut pool);
2076 let mut state = ListState::default();
2077 let area = Rect::new(0, 0, 12, 2);
2078
2079 let long = List::new(vec![ListItem::new("alphabet")]);
2080 StatefulWidget::render(&long, area, &mut frame, &mut state);
2081
2082 let short = List::new(vec![ListItem::new("a")]);
2083 StatefulWidget::render(&short, area, &mut frame, &mut state);
2084
2085 assert_eq!(raw_row_text(&frame, 0), "a ");
2086 }
2087
2088 #[test]
2089 fn list_render_empty_state_clears_stale_rows_and_tail() {
2090 let list = List::new(Vec::<ListItem>::new());
2091 let mut pool = GraphemePool::new();
2092 let mut frame = Frame::new(12, 3, &mut pool);
2093 let area = Rect::new(0, 0, 12, 3);
2094 frame.buffer.fill(area, Cell::from_char('X'));
2095
2096 Widget::render(&list, area, &mut frame);
2097
2098 assert_eq!(raw_row_text(&frame, 0), "No items ");
2099 assert_eq!(raw_row_text(&frame, 1), " ");
2100 assert_eq!(raw_row_text(&frame, 2), " ");
2101 }
2102
2103 #[test]
2104 fn list_render_no_matches_clears_stale_rows_and_tail() {
2105 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2106 let mut state = ListState::default();
2107 state.set_filter_query("zzz");
2108
2109 let mut pool = GraphemePool::new();
2110 let mut frame = Frame::new(12, 3, &mut pool);
2111 let area = Rect::new(0, 0, 12, 3);
2112 frame.buffer.fill(area, Cell::from_char('X'));
2113
2114 StatefulWidget::render(&list, area, &mut frame, &mut state);
2115
2116 assert_eq!(raw_row_text(&frame, 0), "No matches ");
2117 assert_eq!(raw_row_text(&frame, 1), " ");
2118 assert_eq!(raw_row_text(&frame, 2), " ");
2119 }
2120
2121 #[test]
2122 fn list_multi_select_toggle_with_space() {
2123 let list = List::new(vec![
2124 ListItem::new("alpha"),
2125 ListItem::new("beta"),
2126 ListItem::new("gamma"),
2127 ]);
2128 let mut state = ListState::default();
2129 state.set_multi_select(true);
2130 state.select(Some(0));
2131
2132 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2133 assert!(state.selected_indices().contains(&0));
2134
2135 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2136 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2137 assert!(state.selected_indices().contains(&1));
2138 assert_eq!(state.selected_count(), 2);
2139 }
2140
2141 #[test]
2142 fn list_render_draws_scroll_indicators() {
2143 let items: Vec<ListItem> = (0..8).map(|i| ListItem::new(format!("Item {i}"))).collect();
2144 let list = List::new(items);
2145 let mut state = ListState {
2146 selected: Some(4),
2147 offset: 2,
2148 scroll_into_view_requested: false,
2149 ..Default::default()
2150 };
2151 let mut pool = GraphemePool::new();
2152 let mut frame = Frame::new(8, 3, &mut pool);
2153 StatefulWidget::render(&list, Rect::new(0, 0, 8, 3), &mut frame, &mut state);
2154
2155 assert_eq!(
2156 frame.buffer.get(7, 0).and_then(|c| c.content.as_char()),
2157 Some('↑')
2158 );
2159 assert_eq!(
2160 frame.buffer.get(7, 2).and_then(|c| c.content.as_char()),
2161 Some('↓')
2162 );
2163 }
2164
2165 #[cfg(feature = "tracing")]
2166 #[test]
2167 fn list_tracing_span_and_selection_events_are_emitted() {
2168 let trace_state = Arc::new(Mutex::new(ListTraceState::default()));
2169 let _trace_test_guard = crate::tracing_test_support::acquire();
2170 let subscriber = tracing_subscriber::registry().with(ListTraceCapture {
2171 state: Arc::clone(&trace_state),
2172 });
2173 let _guard = tracing::subscriber::set_default(subscriber);
2174 tracing::callsite::rebuild_interest_cache();
2175
2176 let list = List::new(vec![
2177 ListItem::new("a"),
2178 ListItem::new("b"),
2179 ListItem::new("c"),
2180 ]);
2181 let mut state = ListState::default();
2182 state.select(Some(0));
2183 let mut pool = GraphemePool::new();
2184 let mut frame = Frame::new(10, 3, &mut pool);
2185 tracing::callsite::rebuild_interest_cache();
2186 StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
2187 tracing::callsite::rebuild_interest_cache();
2188 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2189
2190 tracing::callsite::rebuild_interest_cache();
2191 let snapshot = trace_state.lock().expect("list trace state lock");
2192 assert!(snapshot.list_render_seen, "expected list.render span");
2193 assert!(
2194 snapshot.has_total_items_field,
2195 "list.render missing total_items"
2196 );
2197 assert!(
2198 snapshot.has_visible_items_field,
2199 "list.render missing visible_items"
2200 );
2201 assert!(
2202 snapshot.has_selected_count_field,
2203 "list.render missing selected_count"
2204 );
2205 assert!(
2206 snapshot.has_filter_active_field,
2207 "list.render missing filter_active"
2208 );
2209 assert!(
2210 snapshot.render_duration_recorded,
2211 "list.render did not record render_duration_us"
2212 );
2213 assert!(
2214 snapshot.selection_events >= 1,
2215 "expected list.selection debug event"
2216 );
2217 }
2218
2219 #[test]
2220 fn list_state_right_click_ignored() {
2221 let mut state = ListState::default();
2222 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 2);
2223 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2224 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2225 assert_eq!(result, MouseResult::Ignored);
2226 }
2227
2228 #[test]
2229 fn list_state_click_border_region_ignored() {
2230 let mut state = ListState::default();
2231 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2232 let hit = Some((HitId::new(1), HitRegion::Border, 3u64));
2233 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2234 assert_eq!(result, MouseResult::Ignored);
2235 }
2236
2237 #[test]
2238 fn list_state_second_click_activates() {
2239 let mut state = ListState::default();
2240 state.select(Some(3));
2241
2242 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2243 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2244 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2245 assert_eq!(result, MouseResult::Activated(3));
2246 assert_eq!(state.selected(), Some(3));
2247 }
2248
2249 #[test]
2250 fn list_state_hover_updates() {
2251 let mut state = ListState::default();
2252 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2253 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2254 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2255 assert_eq!(result, MouseResult::HoverChanged);
2256 assert_eq!(state.hovered, Some(3));
2257 }
2258
2259 #[test]
2260 #[allow(clippy::field_reassign_with_default)]
2261 fn list_state_hover_same_index_ignored() {
2262 let mut state = {
2263 let mut s = ListState::default();
2264 s.hovered = Some(3);
2265 s
2266 };
2267 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2268 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2269 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2270 assert_eq!(result, MouseResult::Ignored);
2271 assert_eq!(state.hovered, Some(3));
2272 }
2273
2274 #[test]
2275 #[allow(clippy::field_reassign_with_default)]
2276 fn list_state_hover_clears() {
2277 let mut state = {
2278 let mut s = ListState::default();
2279 s.hovered = Some(5);
2280 s
2281 };
2282 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2283 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2285 assert_eq!(result, MouseResult::HoverChanged);
2286 assert_eq!(state.hovered, None);
2287 }
2288
2289 #[test]
2290 fn list_state_hover_clear_when_already_none() {
2291 let mut state = ListState::default();
2292 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2293 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2294 assert_eq!(result, MouseResult::Ignored);
2295 }
2296
2297 #[test]
2300 fn list_navigate_down_while_filter_active() {
2301 let list = List::new(vec![
2302 ListItem::new("alpha"),
2303 ListItem::new("banana"),
2304 ListItem::new("beta"),
2305 ListItem::new("gamma"),
2306 ]);
2307 let mut state = ListState::default();
2308 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2310 assert_eq!(state.filter_query(), "b");
2311 assert_eq!(state.selected(), Some(1)); assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2316 assert_eq!(state.selected(), Some(2)); assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2320 assert_eq!(state.selected(), Some(2));
2321 }
2322
2323 #[test]
2324 fn list_navigate_up_while_filter_active() {
2325 let list = List::new(vec![
2326 ListItem::new("alpha"),
2327 ListItem::new("banana"),
2328 ListItem::new("beta"),
2329 ListItem::new("gamma"),
2330 ]);
2331 let mut state = ListState::default();
2332 state.set_filter_query("b");
2333 state.select(Some(2));
2335
2336 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2338 assert_eq!(state.selected(), Some(1));
2339
2340 assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2342 assert_eq!(state.selected(), Some(1));
2343 }
2344
2345 #[test]
2346 fn list_filter_case_insensitive() {
2347 let list = List::new(vec![
2348 ListItem::new("Alpha"),
2349 ListItem::new("BANANA"),
2350 ListItem::new("beta"),
2351 ]);
2352 let mut state = ListState::default();
2353 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('B'))));
2355 assert_eq!(state.selected(), Some(1)); }
2357
2358 #[test]
2359 fn list_filter_matches_marker() {
2360 let list = List::new(vec![
2361 ListItem::new("apple").marker("fruit"),
2362 ListItem::new("carrot").marker("veggie"),
2363 ListItem::new("berry").marker("fruit"),
2364 ]);
2365 let mut state = ListState::default();
2366 state.set_filter_query("veg");
2368 let filtered = list.filtered_indices(&mut state);
2369 assert_eq!(&*filtered, &[1]); }
2371
2372 #[test]
2373 fn list_multi_select_toggle_while_filtered() {
2374 let list = List::new(vec![
2375 ListItem::new("alpha"),
2376 ListItem::new("banana"),
2377 ListItem::new("beta"),
2378 ListItem::new("gamma"),
2379 ]);
2380 let mut state = ListState::default();
2381 state.set_multi_select(true);
2382 state.set_filter_query("b");
2383
2384 state.select(Some(1));
2386 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2388 assert!(state.selected_indices().contains(&1));
2389
2390 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2392 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2393 assert!(state.selected_indices().contains(&2));
2394 assert_eq!(state.selected_count(), 2);
2395 }
2396
2397 #[test]
2398 fn list_disable_multi_select_clears_extras() {
2399 let mut state = ListState::default();
2400 state.set_multi_select(true);
2401 state.toggle_multi_selected(0);
2402 state.toggle_multi_selected(1);
2403 state.toggle_multi_selected(2);
2404 assert_eq!(state.selected_count(), 3);
2405 assert_eq!(state.selected(), Some(2));
2407
2408 state.set_multi_select(false);
2410 assert_eq!(state.selected_count(), 1);
2411 assert!(state.selected_indices().contains(&2)); }
2413
2414 #[test]
2415 fn list_navigation_with_ctrl_modifier_ignored() {
2416 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2417 let mut state = ListState::default();
2418 state.select(Some(0));
2419
2420 let ctrl_down = KeyEvent {
2421 code: KeyCode::Down,
2422 modifiers: Modifiers::CTRL,
2423 kind: ftui_core::event::KeyEventKind::Press,
2424 };
2425 assert!(!list.handle_key(&mut state, &ctrl_down));
2426 assert_eq!(state.selected(), Some(0)); }
2428
2429 #[test]
2430 fn list_space_with_no_selection_in_multi_select_is_noop() {
2431 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2432 let mut state = ListState::default();
2433 state.set_multi_select(true);
2434 assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2436 assert_eq!(state.selected_count(), 0);
2437 }
2438
2439 #[test]
2440 fn list_backspace_on_empty_filter_returns_false() {
2441 let list = List::new(vec![ListItem::new("alpha")]);
2442 let mut state = ListState::default();
2443 assert!(state.filter_query().is_empty());
2444 assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
2445 }
2446
2447 #[test]
2448 fn list_escape_on_empty_filter_returns_false() {
2449 let list = List::new(vec![ListItem::new("alpha")]);
2450 let mut state = ListState::default();
2451 assert!(state.filter_query().is_empty());
2452 assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
2453 }
2454
2455 #[test]
2456 fn list_navigate_in_empty_filtered_result() {
2457 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2458 let mut state = ListState::default();
2459 state.select(Some(0));
2460 state.set_filter_query("zzz"); let handled = list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down));
2464 if handled {
2466 assert_eq!(state.selected(), None);
2467 }
2468 }
2469
2470 #[test]
2471 fn list_filter_preserves_selection_when_still_visible() {
2472 let list = List::new(vec![
2473 ListItem::new("alpha"),
2474 ListItem::new("banana"),
2475 ListItem::new("beta"),
2476 ]);
2477 let mut state = ListState::default();
2478 state.select(Some(2)); assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2482 assert_eq!(state.selected(), Some(2)); }
2484
2485 #[test]
2486 fn list_filter_moves_selection_when_current_hidden() {
2487 let list = List::new(vec![
2488 ListItem::new("alpha"),
2489 ListItem::new("banana"),
2490 ListItem::new("cherry"),
2491 ]);
2492 let mut state = ListState::default();
2493 state.select(Some(2)); assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2497 assert_eq!(state.selected(), Some(1)); }
2499
2500 #[test]
2501 #[allow(clippy::field_reassign_with_default)]
2502 fn list_set_filter_query_resets_offset() {
2503 let mut state = ListState::default();
2504 state.offset = 10;
2505 state.set_filter_query("abc");
2506 assert_eq!(state.offset, 0);
2507 assert_eq!(state.filter_query(), "abc");
2508 }
2509
2510 #[test]
2511 fn list_clear_filter_query_resets_offset() {
2512 let mut state = ListState::default();
2513 state.set_filter_query("abc");
2514 state.offset = 5;
2515 state.clear_filter_query();
2516 assert_eq!(state.offset, 0);
2517 assert!(state.filter_query().is_empty());
2518 }
2519
2520 #[test]
2521 #[allow(clippy::field_reassign_with_default)]
2522 fn list_clear_filter_query_noop_when_empty() {
2523 let mut state = ListState::default();
2524 state.offset = 5;
2525 state.clear_filter_query(); assert_eq!(state.offset, 5); }
2528
2529 #[test]
2530 fn list_select_next_in_multi_select_preserves_others() {
2531 let mut state = ListState::default();
2532 state.set_multi_select(true);
2533 state.toggle_multi_selected(0);
2534 state.toggle_multi_selected(2);
2535 assert_eq!(state.selected(), Some(2));
2537 assert_eq!(state.selected_count(), 2);
2538
2539 state.select_next(5);
2541 assert_eq!(state.selected(), Some(3)); assert!(state.selected_indices().contains(&0));
2543 assert!(state.selected_indices().contains(&2));
2544 }
2545
2546 #[test]
2547 fn list_deselect_clears_multi_selected() {
2548 let mut state = ListState::default();
2549 state.set_multi_select(true);
2550 state.toggle_multi_selected(0);
2551 state.toggle_multi_selected(1);
2552 state.toggle_multi_selected(2);
2553 assert_eq!(state.selected_count(), 3);
2554
2555 state.select(None);
2556 assert_eq!(state.selected_count(), 0);
2557 assert!(state.selected_indices().is_empty());
2558 }
2559
2560 #[test]
2561 fn list_vi_j_moves_through_filtered_items() {
2562 let list = List::new(vec![
2563 ListItem::new("xylophone"),
2564 ListItem::new("berry"),
2565 ListItem::new("box"),
2566 ListItem::new("cat"),
2567 ]);
2568 let mut state = ListState::default();
2569 state.set_filter_query("b");
2570 state.select(Some(1)); assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2575 assert_eq!(state.selected(), Some(2)); assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2579 assert_eq!(state.selected(), Some(2));
2580 }
2581
2582 #[test]
2583 fn list_jk_navigate_not_filter_even_when_empty() {
2584 let list = List::new(vec![
2587 ListItem::new("alpha"),
2588 ListItem::new("jam"),
2589 ListItem::new("kite"),
2590 ]);
2591 let mut state = ListState::default();
2592 assert!(state.filter_query().is_empty());
2593
2594 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2596 assert!(state.filter_query().is_empty());
2597 assert_eq!(state.selected(), Some(0)); assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2601 assert!(state.filter_query().is_empty());
2602 assert_eq!(state.selected(), Some(1));
2603
2604 assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
2606 assert!(state.filter_query().is_empty());
2607 assert_eq!(state.selected(), Some(0));
2608 }
2609
2610 #[test]
2611 fn list_multi_select_untoggle_removes_from_set() {
2612 let mut state = ListState::default();
2613 state.set_multi_select(true);
2614 state.select(Some(0));
2615 state.toggle_multi_selected(0);
2616 assert!(state.selected_indices().contains(&0));
2617 assert_eq!(state.selected_count(), 1);
2618
2619 state.toggle_multi_selected(0);
2621 assert!(!state.selected_indices().contains(&0));
2622 }
2623
2624 #[test]
2625 fn list_widget_render_uses_default_state() {
2626 let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2627 let mut state = ListState::default();
2628 let mut pool = GraphemePool::new();
2629 let mut frame = Frame::new(10, 3, &mut pool);
2630 StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
2631 assert_eq!(row_text(&frame, 0), "alpha");
2633 assert_eq!(row_text(&frame, 1), "beta");
2634 }
2635}