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::{StatefulWidget, Widget, draw_text_span, draw_text_span_with_link, set_style_area};
13use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
14use ftui_core::geometry::{Rect, Size};
15use ftui_render::frame::{Frame, HitId, HitRegion};
16use ftui_style::Style;
17use ftui_text::{Text, display_width};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ListItem<'a> {
22 content: Text,
23 style: Style,
24 marker: &'a str,
25}
26
27impl<'a> ListItem<'a> {
28 #[must_use]
30 pub fn new(content: impl Into<Text>) -> Self {
31 Self {
32 content: content.into(),
33 style: Style::default(),
34 marker: "",
35 }
36 }
37
38 #[must_use]
40 pub fn style(mut self, style: Style) -> Self {
41 self.style = style;
42 self
43 }
44
45 #[must_use]
47 pub fn marker(mut self, marker: &'a str) -> Self {
48 self.marker = marker;
49 self
50 }
51}
52
53impl<'a> From<&'a str> for ListItem<'a> {
54 fn from(s: &'a str) -> Self {
55 Self::new(s)
56 }
57}
58
59#[derive(Debug, Clone, Default)]
61pub struct List<'a> {
62 block: Option<Block<'a>>,
63 items: Vec<ListItem<'a>>,
64 style: Style,
65 highlight_style: Style,
66 hover_style: Style,
67 highlight_symbol: Option<&'a str>,
68 hit_id: Option<HitId>,
71}
72
73impl<'a> List<'a> {
74 #[must_use]
76 pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<'a>>>) -> Self {
77 Self {
78 block: None,
79 items: items.into_iter().map(|i| i.into()).collect(),
80 style: Style::default(),
81 highlight_style: Style::default(),
82 hover_style: Style::default(),
83 highlight_symbol: None,
84 hit_id: None,
85 }
86 }
87
88 #[must_use]
90 pub fn block(mut self, block: Block<'a>) -> Self {
91 self.block = Some(block);
92 self
93 }
94
95 #[must_use]
97 pub fn style(mut self, style: Style) -> Self {
98 self.style = style;
99 self
100 }
101
102 #[must_use]
104 pub fn highlight_style(mut self, style: Style) -> Self {
105 self.highlight_style = style;
106 self
107 }
108
109 #[must_use]
111 pub fn hover_style(mut self, style: Style) -> Self {
112 self.hover_style = style;
113 self
114 }
115
116 #[must_use]
118 pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
119 self.highlight_symbol = Some(symbol);
120 self
121 }
122
123 #[must_use]
129 pub fn hit_id(mut self, id: HitId) -> Self {
130 self.hit_id = Some(id);
131 self
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct ListState {
138 undo_id: UndoWidgetId,
140 pub selected: Option<usize>,
142 pub hovered: Option<usize>,
144 pub offset: usize,
146 persistence_id: Option<String>,
148 scroll_into_view_requested: bool,
150}
151
152impl Default for ListState {
153 fn default() -> Self {
154 Self {
155 undo_id: UndoWidgetId::default(),
156 selected: None,
157 hovered: None,
158 offset: 0,
159 persistence_id: None,
160 scroll_into_view_requested: true,
161 }
162 }
163}
164
165impl ListState {
166 pub fn select(&mut self, index: Option<usize>) {
168 self.selected = index;
169 if index.is_none() {
170 self.offset = 0;
171 }
172 self.scroll_into_view_requested = true;
173 }
174
175 #[inline]
177 #[must_use = "use the selected index (if any)"]
178 pub fn selected(&self) -> Option<usize> {
179 self.selected
180 }
181
182 #[must_use]
184 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
185 self.persistence_id = Some(id.into());
186 self
187 }
188
189 #[inline]
191 #[must_use = "use the persistence id (if any)"]
192 pub fn persistence_id(&self) -> Option<&str> {
193 self.persistence_id.as_deref()
194 }
195
196 pub fn handle_mouse(
211 &mut self,
212 event: &MouseEvent,
213 hit: Option<(HitId, HitRegion, u64)>,
214 expected_id: HitId,
215 item_count: usize,
216 ) -> MouseResult {
217 match event.kind {
218 MouseEventKind::Down(MouseButton::Left) => {
219 if let Some((id, HitRegion::Content, data)) = hit
220 && id == expected_id
221 {
222 let index = data as usize;
223 if index < item_count {
224 if self.selected == Some(index) {
226 return MouseResult::Activated(index);
227 }
228 self.select(Some(index));
229 return MouseResult::Selected(index);
230 }
231 }
232 MouseResult::Ignored
233 }
234 MouseEventKind::Moved => {
235 if let Some((id, HitRegion::Content, data)) = hit
236 && id == expected_id
237 {
238 let index = data as usize;
239 if index < item_count {
240 let changed = self.hovered != Some(index);
241 self.hovered = Some(index);
242 return if changed {
243 MouseResult::HoverChanged
244 } else {
245 MouseResult::Ignored
246 };
247 }
248 }
249
250 if self.hovered.is_some() {
252 self.hovered = None;
253 MouseResult::HoverChanged
254 } else {
255 MouseResult::Ignored
256 }
257 }
258 MouseEventKind::ScrollUp => {
259 self.scroll_up(3);
260 MouseResult::Scrolled
261 }
262 MouseEventKind::ScrollDown => {
263 self.scroll_down(3, item_count);
264 MouseResult::Scrolled
265 }
266 _ => MouseResult::Ignored,
267 }
268 }
269
270 pub fn scroll_up(&mut self, lines: usize) {
272 self.offset = self.offset.saturating_sub(lines);
273 }
274
275 pub fn scroll_down(&mut self, lines: usize, item_count: usize) {
279 self.offset = self
280 .offset
281 .saturating_add(lines)
282 .min(item_count.saturating_sub(1));
283 }
284
285 pub fn select_next(&mut self, item_count: usize) {
289 if item_count == 0 {
290 return;
291 }
292 let next = match self.selected {
293 Some(i) => (i + 1).min(item_count.saturating_sub(1)),
294 None => 0,
295 };
296 self.selected = Some(next);
297 self.scroll_into_view_requested = true;
298 }
299
300 pub fn select_previous(&mut self) {
304 let prev = match self.selected {
305 Some(i) => i.saturating_sub(1),
306 None => 0,
307 };
308 self.selected = Some(prev);
309 self.scroll_into_view_requested = true;
310 }
311}
312
313#[derive(Clone, Debug, Default, PartialEq)]
321#[cfg_attr(
322 feature = "state-persistence",
323 derive(serde::Serialize, serde::Deserialize)
324)]
325pub struct ListPersistState {
326 pub selected: Option<usize>,
328 pub offset: usize,
330}
331
332impl Stateful for ListState {
333 type State = ListPersistState;
334
335 fn state_key(&self) -> StateKey {
336 StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
337 }
338
339 fn save_state(&self) -> ListPersistState {
340 ListPersistState {
341 selected: self.selected,
342 offset: self.offset,
343 }
344 }
345
346 fn restore_state(&mut self, state: ListPersistState) {
347 self.selected = state.selected;
348 self.hovered = None;
349 self.offset = state.offset;
350 }
351}
352
353impl<'a> StatefulWidget for List<'a> {
354 type State = ListState;
355
356 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
357 #[cfg(feature = "tracing")]
358 let _span = tracing::debug_span!(
359 "widget_render",
360 widget = "List",
361 x = area.x,
362 y = area.y,
363 w = area.width,
364 h = area.height
365 )
366 .entered();
367
368 let list_area = match &self.block {
369 Some(b) => {
370 b.render(area, frame);
371 b.inner(area)
372 }
373 None => area,
374 };
375
376 if list_area.is_empty() {
377 return;
378 }
379
380 set_style_area(&mut frame.buffer, list_area, self.style);
382
383 if self.items.is_empty() {
384 state.selected = None;
385 state.hovered = None;
386 state.offset = 0;
387 return;
388 }
389
390 let list_height = list_area.height as usize;
391
392 let max_offset = self.items.len().saturating_sub(list_height.max(1));
395 state.offset = state.offset.min(max_offset);
396
397 if let Some(selected) = state.selected {
399 if self.items.is_empty() {
400 state.selected = None;
401 } else if selected >= self.items.len() {
402 state.selected = Some(self.items.len() - 1);
403 }
404 }
405 if let Some(hovered) = state.hovered
406 && hovered >= self.items.len()
407 {
408 state.hovered = None;
409 }
410
411 if state.scroll_into_view_requested
413 && let Some(selected) = state.selected
414 {
415 if selected >= state.offset + list_height {
416 state.offset = selected - list_height + 1;
417 } else if selected < state.offset {
418 state.offset = selected;
419 }
420 state.scroll_into_view_requested = false;
421 }
422
423 for (i, item) in self
425 .items
426 .iter()
427 .enumerate()
428 .skip(state.offset)
429 .take(list_height)
430 {
431 let y = list_area.y.saturating_add((i - state.offset) as u16);
432 if y >= list_area.bottom() {
433 break;
434 }
435 let is_selected = state.selected == Some(i);
436 let is_hovered = state.hovered == Some(i);
437
438 let mut item_style = if is_hovered {
441 self.hover_style.merge(&item.style)
442 } else {
443 item.style
444 };
445 if is_selected {
446 item_style = self.highlight_style.merge(&item_style);
447 }
448
449 let row_area = Rect::new(list_area.x, y, list_area.width, 1);
451 set_style_area(&mut frame.buffer, row_area, item_style);
452
453 let symbol = if is_selected {
455 self.highlight_symbol.unwrap_or(item.marker)
456 } else {
457 item.marker
458 };
459
460 let mut x = list_area.x;
461
462 if !symbol.is_empty() {
464 x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
465 x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
467 }
468
469 if let Some(line) = item.content.lines().first() {
472 for span in line.spans() {
473 let span_style = match span.style {
474 Some(s) => s.merge(&item_style),
475 None => item_style,
476 };
477 x = draw_text_span_with_link(
478 frame,
479 x,
480 y,
481 &span.content,
482 span_style,
483 list_area.right(),
484 span.link.as_deref(),
485 );
486 if x >= list_area.right() {
487 break;
488 }
489 }
490 }
491
492 if let Some(id) = self.hit_id {
494 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
495 }
496 }
497 }
498}
499
500impl<'a> Widget for List<'a> {
501 fn render(&self, area: Rect, frame: &mut Frame) {
502 let mut state = ListState::default();
503 StatefulWidget::render(self, area, frame, &mut state);
504 }
505}
506
507impl MeasurableWidget for ListItem<'_> {
508 fn measure(&self, _available: Size) -> SizeConstraints {
509 let marker_width = display_width(self.marker) as u16;
511 let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
512
513 let text_width = self
515 .content
516 .lines()
517 .first()
518 .map(|line| line.width())
519 .unwrap_or(0)
520 .min(u16::MAX as usize) as u16;
521
522 let total_width = marker_width
523 .saturating_add(space_after_marker)
524 .saturating_add(text_width);
525
526 SizeConstraints::exact(Size::new(total_width, 1))
528 }
529
530 fn has_intrinsic_size(&self) -> bool {
531 true
532 }
533}
534
535impl MeasurableWidget for List<'_> {
536 fn measure(&self, available: Size) -> SizeConstraints {
537 let (chrome_width, chrome_height) = self
539 .block
540 .as_ref()
541 .map(|b| b.chrome_size())
542 .unwrap_or((0, 0));
543
544 if self.items.is_empty() {
545 return SizeConstraints {
547 min: Size::new(chrome_width, chrome_height),
548 preferred: Size::new(chrome_width, chrome_height),
549 max: None,
550 };
551 }
552
553 let inner_available = Size::new(
555 available.width.saturating_sub(chrome_width),
556 available.height.saturating_sub(chrome_height),
557 );
558
559 let mut max_width: u16 = 0;
561 let mut total_height: u16 = 0;
562
563 for item in &self.items {
564 let item_constraints = item.measure(inner_available);
565 max_width = max_width.max(item_constraints.preferred.width);
566 total_height = total_height.saturating_add(item_constraints.preferred.height);
567 }
568
569 if let Some(symbol) = self.highlight_symbol {
571 let symbol_width = display_width(symbol) as u16 + 1; max_width = max_width.saturating_add(symbol_width);
573 }
574
575 let preferred_width = max_width.saturating_add(chrome_width);
577 let preferred_height = total_height.saturating_add(chrome_height);
578
579 let min_height = chrome_height.saturating_add(1.min(total_height));
581
582 SizeConstraints {
583 min: Size::new(chrome_width, min_height),
584 preferred: Size::new(preferred_width, preferred_height),
585 max: None, }
587 }
588
589 fn has_intrinsic_size(&self) -> bool {
590 !self.items.is_empty()
591 }
592}
593
594#[derive(Debug, Clone)]
600pub struct ListStateSnapshot {
601 selected: Option<usize>,
602 offset: usize,
603}
604
605impl UndoSupport for ListState {
606 fn undo_widget_id(&self) -> UndoWidgetId {
607 self.undo_id
608 }
609
610 fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
611 Box::new(ListStateSnapshot {
612 selected: self.selected,
613 offset: self.offset,
614 })
615 }
616
617 fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
618 if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
619 self.selected = snap.selected;
620 self.hovered = None;
621 self.offset = snap.offset;
622 true
623 } else {
624 false
625 }
626 }
627}
628
629impl ListUndoExt for ListState {
630 fn selected_index(&self) -> Option<usize> {
631 self.selected
632 }
633
634 fn set_selected_index(&mut self, index: Option<usize>) {
635 self.selected = index;
636 if index.is_none() {
637 self.offset = 0;
638 }
639 }
640}
641
642impl ListState {
643 #[must_use]
647 pub fn undo_id(&self) -> UndoWidgetId {
648 self.undo_id
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use ftui_render::grapheme_pool::GraphemePool;
656
657 fn row_text(frame: &Frame, y: u16) -> String {
658 let width = frame.buffer.width();
659 let mut actual = String::new();
660 for x in 0..width {
661 let ch = frame
662 .buffer
663 .get(x, y)
664 .and_then(|cell| cell.content.as_char())
665 .unwrap_or(' ');
666 actual.push(ch);
667 }
668 actual.trim().to_string()
669 }
670
671 #[test]
672 fn render_empty_list() {
673 let list = List::new(Vec::<ListItem>::new());
674 let area = Rect::new(0, 0, 10, 5);
675 let mut pool = GraphemePool::new();
676 let mut frame = Frame::new(10, 5, &mut pool);
677 Widget::render(&list, area, &mut frame);
678 }
679
680 #[test]
681 fn render_simple_list() {
682 let items = vec![
683 ListItem::new("Item A"),
684 ListItem::new("Item B"),
685 ListItem::new("Item C"),
686 ];
687 let list = List::new(items);
688 let area = Rect::new(0, 0, 10, 3);
689 let mut pool = GraphemePool::new();
690 let mut frame = Frame::new(10, 3, &mut pool);
691 let mut state = ListState::default();
692 StatefulWidget::render(&list, area, &mut frame, &mut state);
693
694 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
695 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
696 assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
697 assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
698 }
699
700 #[test]
701 fn list_state_select() {
702 let mut state = ListState::default();
703 assert_eq!(state.selected(), None);
704
705 state.select(Some(2));
706 assert_eq!(state.selected(), Some(2));
707
708 state.select(None);
709 assert_eq!(state.selected(), None);
710 assert_eq!(state.offset, 0);
711 }
712
713 #[test]
714 fn list_scrolls_to_selected() {
715 let items: Vec<ListItem> = (0..10)
716 .map(|i| ListItem::new(format!("Item {i}")))
717 .collect();
718 let list = List::new(items);
719 let area = Rect::new(0, 0, 10, 3);
720 let mut pool = GraphemePool::new();
721 let mut frame = Frame::new(10, 3, &mut pool);
722 let mut state = ListState::default();
723 state.select(Some(5));
724
725 StatefulWidget::render(&list, area, &mut frame, &mut state);
726 assert!(state.offset <= 5);
728 assert!(state.offset + 3 > 5);
729 }
730
731 #[test]
732 fn list_clamps_selection() {
733 let items = vec![ListItem::new("A"), ListItem::new("B")];
734 let list = List::new(items);
735 let area = Rect::new(0, 0, 10, 3);
736 let mut pool = GraphemePool::new();
737 let mut frame = Frame::new(10, 3, &mut pool);
738 let mut state = ListState::default();
739 state.select(Some(10)); StatefulWidget::render(&list, area, &mut frame, &mut state);
742 assert_eq!(state.selected(), Some(1));
744 }
745
746 #[test]
747 fn render_list_with_highlight_symbol() {
748 let items = vec![ListItem::new("A"), ListItem::new("B")];
749 let list = List::new(items).highlight_symbol(">");
750 let area = Rect::new(0, 0, 10, 2);
751 let mut pool = GraphemePool::new();
752 let mut frame = Frame::new(10, 2, &mut pool);
753 let mut state = ListState::default();
754 state.select(Some(0));
755
756 StatefulWidget::render(&list, area, &mut frame, &mut state);
757 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
759 }
760
761 #[test]
762 fn render_zero_area() {
763 let list = List::new(vec![ListItem::new("A")]);
764 let area = Rect::new(0, 0, 0, 0);
765 let mut pool = GraphemePool::new();
766 let mut frame = Frame::new(1, 1, &mut pool);
767 let mut state = ListState::default();
768 StatefulWidget::render(&list, area, &mut frame, &mut state);
769 }
770
771 #[test]
772 fn list_item_from_str() {
773 let item: ListItem = "hello".into();
774 assert_eq!(
775 item.content.lines().first().unwrap().to_plain_text(),
776 "hello"
777 );
778 assert_eq!(item.marker, "");
779 }
780
781 #[test]
782 fn list_item_with_marker() {
783 let items = vec![
784 ListItem::new("A").marker("•"),
785 ListItem::new("B").marker("•"),
786 ];
787 let list = List::new(items);
788 let area = Rect::new(0, 0, 10, 2);
789 let mut pool = GraphemePool::new();
790 let mut frame = Frame::new(10, 2, &mut pool);
791 let mut state = ListState::default();
792 StatefulWidget::render(&list, area, &mut frame, &mut state);
793
794 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
796 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
797 }
798
799 #[test]
800 fn list_state_deselect_resets_offset() {
801 let mut state = ListState {
802 offset: 5,
803 ..Default::default()
804 };
805 state.select(Some(10));
806 assert_eq!(state.offset, 5); state.select(None);
809 assert_eq!(state.offset, 0); }
811
812 #[test]
813 fn list_scrolls_up_when_selection_above_viewport() {
814 let items: Vec<ListItem> = (0..10)
815 .map(|i| ListItem::new(format!("Item {i}")))
816 .collect();
817 let list = List::new(items);
818 let area = Rect::new(0, 0, 10, 3);
819 let mut pool = GraphemePool::new();
820 let mut frame = Frame::new(10, 3, &mut pool);
821 let mut state = ListState::default();
822
823 state.select(Some(8));
825 StatefulWidget::render(&list, area, &mut frame, &mut state);
826 assert!(state.offset > 0);
827
828 state.select(Some(0));
830 StatefulWidget::render(&list, area, &mut frame, &mut state);
831 assert_eq!(state.offset, 0);
832 }
833
834 #[test]
835 fn list_clamps_offset_to_fill_viewport_on_resize() {
836 let items: Vec<ListItem> = (0..10)
837 .map(|i| ListItem::new(format!("Item {i}")))
838 .collect();
839 let list = List::new(items);
840
841 let mut pool = GraphemePool::new();
842 let mut state = ListState {
843 offset: 7,
844 ..Default::default()
845 };
846
847 let area_small = Rect::new(0, 0, 10, 3);
849 let mut frame_small = Frame::new(10, 3, &mut pool);
850 StatefulWidget::render(&list, area_small, &mut frame_small, &mut state);
851 assert_eq!(state.offset, 7);
852 assert_eq!(row_text(&frame_small, 0), "Item 7");
853 assert_eq!(row_text(&frame_small, 2), "Item 9");
854
855 let area_large = Rect::new(0, 0, 10, 5);
857 let mut frame_large = Frame::new(10, 5, &mut pool);
858 StatefulWidget::render(&list, area_large, &mut frame_large, &mut state);
859 assert_eq!(state.offset, 5);
860 assert_eq!(row_text(&frame_large, 0), "Item 5");
861 assert_eq!(row_text(&frame_large, 4), "Item 9");
862 }
863
864 #[test]
865 fn render_list_more_items_than_viewport() {
866 let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
867 let list = List::new(items);
868 let area = Rect::new(0, 0, 5, 3);
869 let mut pool = GraphemePool::new();
870 let mut frame = Frame::new(5, 3, &mut pool);
871 let mut state = ListState::default();
872 StatefulWidget::render(&list, area, &mut frame, &mut state);
873
874 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
876 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
877 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
878 }
879
880 #[test]
881 fn widget_render_uses_default_state() {
882 let items = vec![ListItem::new("X")];
883 let list = List::new(items);
884 let area = Rect::new(0, 0, 5, 1);
885 let mut pool = GraphemePool::new();
886 let mut frame = Frame::new(5, 1, &mut pool);
887 Widget::render(&list, area, &mut frame);
889 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
890 }
891
892 #[test]
893 fn list_registers_hit_regions() {
894 let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
895 let list = List::new(items).hit_id(HitId::new(42));
896 let area = Rect::new(0, 0, 10, 3);
897 let mut pool = GraphemePool::new();
898 let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
899 let mut state = ListState::default();
900 StatefulWidget::render(&list, area, &mut frame, &mut state);
901
902 let hit0 = frame.hit_test(5, 0);
904 let hit1 = frame.hit_test(5, 1);
905 let hit2 = frame.hit_test(5, 2);
906
907 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
908 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
909 assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
910 }
911
912 #[test]
913 fn list_no_hit_without_hit_id() {
914 let items = vec![ListItem::new("A")];
915 let list = List::new(items); let area = Rect::new(0, 0, 10, 1);
917 let mut pool = GraphemePool::new();
918 let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
919 let mut state = ListState::default();
920 StatefulWidget::render(&list, area, &mut frame, &mut state);
921
922 assert!(frame.hit_test(5, 0).is_none());
924 }
925
926 #[test]
927 fn list_no_hit_without_hit_grid() {
928 let items = vec![ListItem::new("A")];
929 let list = List::new(items).hit_id(HitId::new(1));
930 let area = Rect::new(0, 0, 10, 1);
931 let mut pool = GraphemePool::new();
932 let mut frame = Frame::new(10, 1, &mut pool); let mut state = ListState::default();
934 StatefulWidget::render(&list, area, &mut frame, &mut state);
935
936 assert!(frame.hit_test(5, 0).is_none());
938 }
939
940 use crate::MeasurableWidget;
943 use ftui_core::geometry::Size;
944
945 #[test]
946 fn list_item_measure_simple() {
947 let item = ListItem::new("Hello"); let constraints = item.measure(Size::MAX);
949
950 assert_eq!(constraints.preferred, Size::new(5, 1));
951 assert_eq!(constraints.min, Size::new(5, 1));
952 assert_eq!(constraints.max, Some(Size::new(5, 1)));
953 }
954
955 #[test]
956 fn list_item_measure_with_marker() {
957 let item = ListItem::new("Hi").marker("•"); let constraints = item.measure(Size::MAX);
959
960 assert_eq!(constraints.preferred.width, 4);
961 assert_eq!(constraints.preferred.height, 1);
962 }
963
964 #[test]
965 fn list_item_has_intrinsic_size() {
966 let item = ListItem::new("test");
967 assert!(item.has_intrinsic_size());
968 }
969
970 #[test]
971 fn list_measure_empty() {
972 let list = List::new(Vec::<ListItem>::new());
973 let constraints = list.measure(Size::MAX);
974
975 assert_eq!(constraints.preferred, Size::new(0, 0));
976 assert!(!list.has_intrinsic_size());
977 }
978
979 #[test]
980 fn list_measure_single_item() {
981 let items = vec![ListItem::new("Hello")]; let list = List::new(items);
983 let constraints = list.measure(Size::MAX);
984
985 assert_eq!(constraints.preferred, Size::new(5, 1));
986 assert_eq!(constraints.min.height, 1);
987 }
988
989 #[test]
990 fn list_measure_multiple_items() {
991 let items = vec![
992 ListItem::new("Short"), ListItem::new("LongerItem"), ListItem::new("Tiny"), ];
996 let list = List::new(items);
997 let constraints = list.measure(Size::MAX);
998
999 assert_eq!(constraints.preferred.width, 10);
1001 assert_eq!(constraints.preferred.height, 3);
1003 }
1004
1005 #[test]
1006 fn list_measure_with_block() {
1007 let block = crate::block::Block::bordered(); let items = vec![ListItem::new("Hi")]; let list = List::new(items).block(block);
1010 let constraints = list.measure(Size::MAX);
1011
1012 assert_eq!(constraints.preferred, Size::new(4, 3));
1015 }
1016
1017 #[test]
1018 fn list_measure_with_highlight_symbol() {
1019 let items = vec![ListItem::new("Item")]; let list = List::new(items).highlight_symbol(">"); let constraints = list.measure(Size::MAX);
1023
1024 assert_eq!(constraints.preferred.width, 6);
1026 }
1027
1028 #[test]
1029 fn list_has_intrinsic_size() {
1030 let items = vec![ListItem::new("X")];
1031 let list = List::new(items);
1032 assert!(list.has_intrinsic_size());
1033 }
1034
1035 #[test]
1036 fn list_min_height_is_one_row() {
1037 let items: Vec<ListItem> = (0..100)
1038 .map(|i| ListItem::new(format!("Item {i}")))
1039 .collect();
1040 let list = List::new(items);
1041 let constraints = list.measure(Size::MAX);
1042
1043 assert_eq!(constraints.min.height, 1);
1045 assert_eq!(constraints.preferred.height, 100);
1047 }
1048
1049 #[test]
1050 fn list_measure_is_pure() {
1051 let items = vec![ListItem::new("Test")];
1052 let list = List::new(items);
1053 let a = list.measure(Size::new(100, 50));
1054 let b = list.measure(Size::new(100, 50));
1055 assert_eq!(a, b);
1056 }
1057
1058 #[test]
1061 fn list_state_undo_id_is_stable() {
1062 let state = ListState::default();
1063 let id1 = state.undo_id();
1064 let id2 = state.undo_id();
1065 assert_eq!(id1, id2);
1066 }
1067
1068 #[test]
1069 fn list_state_undo_id_unique_per_instance() {
1070 let state1 = ListState::default();
1071 let state2 = ListState::default();
1072 assert_ne!(state1.undo_id(), state2.undo_id());
1073 }
1074
1075 #[test]
1076 fn list_state_snapshot_and_restore() {
1077 let mut state = ListState::default();
1078 state.select(Some(5));
1079 state.offset = 3;
1080
1081 let snapshot = state.create_snapshot();
1082
1083 state.select(Some(10));
1085 state.offset = 8;
1086 assert_eq!(state.selected(), Some(10));
1087 assert_eq!(state.offset, 8);
1088
1089 assert!(state.restore_snapshot(snapshot.as_ref()));
1091 assert_eq!(state.selected(), Some(5));
1092 assert_eq!(state.offset, 3);
1093 }
1094
1095 #[test]
1096 fn list_state_undo_ext_methods() {
1097 let mut state = ListState::default();
1098 assert_eq!(state.selected_index(), None);
1099
1100 state.set_selected_index(Some(3));
1101 assert_eq!(state.selected_index(), Some(3));
1102
1103 state.set_selected_index(None);
1104 assert_eq!(state.selected_index(), None);
1105 assert_eq!(state.offset, 0); }
1107
1108 use crate::stateful::Stateful;
1111
1112 #[test]
1113 fn list_state_with_persistence_id() {
1114 let state = ListState::default().with_persistence_id("sidebar-menu");
1115 assert_eq!(state.persistence_id(), Some("sidebar-menu"));
1116 }
1117
1118 #[test]
1119 fn list_state_default_no_persistence_id() {
1120 let state = ListState::default();
1121 assert_eq!(state.persistence_id(), None);
1122 }
1123
1124 #[test]
1125 fn list_state_save_restore_round_trip() {
1126 let mut state = ListState::default().with_persistence_id("test");
1127 state.select(Some(7));
1128 state.offset = 4;
1129
1130 let saved = state.save_state();
1131 assert_eq!(saved.selected, Some(7));
1132 assert_eq!(saved.offset, 4);
1133
1134 state.select(None);
1136 assert_eq!(state.selected, None);
1137 assert_eq!(state.offset, 0);
1138
1139 state.restore_state(saved);
1141 assert_eq!(state.selected, Some(7));
1142 assert_eq!(state.offset, 4);
1143 }
1144
1145 #[test]
1146 fn list_state_key_uses_persistence_id() {
1147 let state = ListState::default().with_persistence_id("file-browser");
1148 let key = state.state_key();
1149 assert_eq!(key.widget_type, "List");
1150 assert_eq!(key.instance_id, "file-browser");
1151 }
1152
1153 #[test]
1154 fn list_state_key_default_when_no_id() {
1155 let state = ListState::default();
1156 let key = state.state_key();
1157 assert_eq!(key.widget_type, "List");
1158 assert_eq!(key.instance_id, "default");
1159 }
1160
1161 #[test]
1162 fn list_persist_state_default() {
1163 let persist = ListPersistState::default();
1164 assert_eq!(persist.selected, None);
1165 assert_eq!(persist.offset, 0);
1166 }
1167
1168 use crate::mouse::MouseResult;
1171 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
1172
1173 #[test]
1174 fn list_state_click_selects() {
1175 let mut state = ListState::default();
1176 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1177 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1178 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1179 assert_eq!(result, MouseResult::Selected(3));
1180 assert_eq!(state.selected(), Some(3));
1181 }
1182
1183 #[test]
1184 fn list_state_click_wrong_id_ignored() {
1185 let mut state = ListState::default();
1186 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1187 let hit = Some((HitId::new(99), HitRegion::Content, 3u64));
1188 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1189 assert_eq!(result, MouseResult::Ignored);
1190 assert_eq!(state.selected(), None);
1191 }
1192
1193 #[test]
1194 fn list_state_click_out_of_range() {
1195 let mut state = ListState::default();
1196 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1197 let hit = Some((HitId::new(1), HitRegion::Content, 15u64));
1198 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1199 assert_eq!(result, MouseResult::Ignored);
1200 assert_eq!(state.selected(), None);
1201 }
1202
1203 #[test]
1204 fn list_state_click_no_hit_ignored() {
1205 let mut state = ListState::default();
1206 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1207 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1208 assert_eq!(result, MouseResult::Ignored);
1209 }
1210
1211 #[test]
1212 #[allow(clippy::field_reassign_with_default)]
1213 fn list_state_scroll_up() {
1214 let mut state = {
1215 let mut s = ListState::default();
1216 s.offset = 10;
1217 s
1218 };
1219 state.scroll_up(3);
1220 assert_eq!(state.offset, 7);
1221 }
1222
1223 #[test]
1224 #[allow(clippy::field_reassign_with_default)]
1225 fn list_state_scroll_up_clamps_to_zero() {
1226 let mut state = {
1227 let mut s = ListState::default();
1228 s.offset = 1;
1229 s
1230 };
1231 state.scroll_up(5);
1232 assert_eq!(state.offset, 0);
1233 }
1234
1235 #[test]
1236 fn list_state_scroll_down() {
1237 let mut state = ListState::default();
1238 state.scroll_down(3, 20);
1239 assert_eq!(state.offset, 3);
1240 }
1241
1242 #[test]
1243 #[allow(clippy::field_reassign_with_default)]
1244 fn list_state_scroll_down_clamps() {
1245 let mut state = ListState::default();
1246 state.offset = 18;
1247 state.scroll_down(5, 20);
1248 assert_eq!(state.offset, 19); }
1250
1251 #[test]
1252 #[allow(clippy::field_reassign_with_default)]
1253 fn list_state_scroll_wheel_up() {
1254 let mut state = {
1255 let mut s = ListState::default();
1256 s.offset = 10;
1257 s
1258 };
1259 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1260 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1261 assert_eq!(result, MouseResult::Scrolled);
1262 assert_eq!(state.offset, 7);
1263 }
1264
1265 #[test]
1266 fn list_state_scroll_wheel_down() {
1267 let mut state = ListState::default();
1268 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1269 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1270 assert_eq!(result, MouseResult::Scrolled);
1271 assert_eq!(state.offset, 3);
1272 }
1273
1274 #[test]
1275 fn list_state_select_next() {
1276 let mut state = ListState::default();
1277 state.select_next(5);
1278 assert_eq!(state.selected(), Some(0));
1279 state.select_next(5);
1280 assert_eq!(state.selected(), Some(1));
1281 }
1282
1283 #[test]
1284 fn list_state_select_next_clamps() {
1285 let mut state = ListState::default();
1286 state.select(Some(4));
1287 state.select_next(5);
1288 assert_eq!(state.selected(), Some(4)); }
1290
1291 #[test]
1292 fn list_state_select_next_empty() {
1293 let mut state = ListState::default();
1294 state.select_next(0);
1295 assert_eq!(state.selected(), None); }
1297
1298 #[test]
1299 fn list_state_select_previous() {
1300 let mut state = ListState::default();
1301 state.select(Some(3));
1302 state.select_previous();
1303 assert_eq!(state.selected(), Some(2));
1304 }
1305
1306 #[test]
1307 fn list_state_select_previous_clamps() {
1308 let mut state = ListState::default();
1309 state.select(Some(0));
1310 state.select_previous();
1311 assert_eq!(state.selected(), Some(0)); }
1313
1314 #[test]
1315 fn list_state_select_previous_from_none() {
1316 let mut state = ListState::default();
1317 state.select_previous();
1318 assert_eq!(state.selected(), Some(0));
1319 }
1320
1321 #[test]
1322 fn list_state_right_click_ignored() {
1323 let mut state = ListState::default();
1324 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 2);
1325 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1326 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1327 assert_eq!(result, MouseResult::Ignored);
1328 }
1329
1330 #[test]
1331 fn list_state_click_border_region_ignored() {
1332 let mut state = ListState::default();
1333 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1334 let hit = Some((HitId::new(1), HitRegion::Border, 3u64));
1335 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1336 assert_eq!(result, MouseResult::Ignored);
1337 }
1338
1339 #[test]
1340 fn list_state_second_click_activates() {
1341 let mut state = ListState::default();
1342 state.select(Some(3));
1343
1344 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1345 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1346 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1347 assert_eq!(result, MouseResult::Activated(3));
1348 assert_eq!(state.selected(), Some(3));
1349 }
1350
1351 #[test]
1352 fn list_state_hover_updates() {
1353 let mut state = ListState::default();
1354 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1355 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1356 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1357 assert_eq!(result, MouseResult::HoverChanged);
1358 assert_eq!(state.hovered, Some(3));
1359 }
1360
1361 #[test]
1362 #[allow(clippy::field_reassign_with_default)]
1363 fn list_state_hover_same_index_ignored() {
1364 let mut state = {
1365 let mut s = ListState::default();
1366 s.hovered = Some(3);
1367 s
1368 };
1369 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1370 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1371 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1372 assert_eq!(result, MouseResult::Ignored);
1373 assert_eq!(state.hovered, Some(3));
1374 }
1375
1376 #[test]
1377 #[allow(clippy::field_reassign_with_default)]
1378 fn list_state_hover_clears() {
1379 let mut state = {
1380 let mut s = ListState::default();
1381 s.hovered = Some(5);
1382 s
1383 };
1384 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1385 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1387 assert_eq!(result, MouseResult::HoverChanged);
1388 assert_eq!(state.hovered, None);
1389 }
1390
1391 #[test]
1392 fn list_state_hover_clear_when_already_none() {
1393 let mut state = ListState::default();
1394 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1395 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1396 assert_eq!(result, MouseResult::Ignored);
1397 }
1398}