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, Default)]
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}
149
150impl ListState {
151 pub fn select(&mut self, index: Option<usize>) {
153 self.selected = index;
154 if index.is_none() {
155 self.offset = 0;
156 }
157 }
158
159 #[inline]
161 #[must_use = "use the selected index (if any)"]
162 pub fn selected(&self) -> Option<usize> {
163 self.selected
164 }
165
166 #[must_use]
168 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
169 self.persistence_id = Some(id.into());
170 self
171 }
172
173 #[inline]
175 #[must_use = "use the persistence id (if any)"]
176 pub fn persistence_id(&self) -> Option<&str> {
177 self.persistence_id.as_deref()
178 }
179
180 pub fn handle_mouse(
195 &mut self,
196 event: &MouseEvent,
197 hit: Option<(HitId, HitRegion, u64)>,
198 expected_id: HitId,
199 item_count: usize,
200 ) -> MouseResult {
201 match event.kind {
202 MouseEventKind::Down(MouseButton::Left) => {
203 if let Some((id, HitRegion::Content, data)) = hit
204 && id == expected_id
205 {
206 let index = data as usize;
207 if index < item_count {
208 if self.selected == Some(index) {
210 return MouseResult::Activated(index);
211 }
212 self.select(Some(index));
213 return MouseResult::Selected(index);
214 }
215 }
216 MouseResult::Ignored
217 }
218 MouseEventKind::Moved => {
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 let changed = self.hovered != Some(index);
225 self.hovered = Some(index);
226 return if changed {
227 MouseResult::HoverChanged
228 } else {
229 MouseResult::Ignored
230 };
231 }
232 }
233
234 if self.hovered.is_some() {
236 self.hovered = None;
237 MouseResult::HoverChanged
238 } else {
239 MouseResult::Ignored
240 }
241 }
242 MouseEventKind::ScrollUp => {
243 self.scroll_up(3);
244 MouseResult::Scrolled
245 }
246 MouseEventKind::ScrollDown => {
247 self.scroll_down(3, item_count);
248 MouseResult::Scrolled
249 }
250 _ => MouseResult::Ignored,
251 }
252 }
253
254 pub fn scroll_up(&mut self, lines: usize) {
256 self.offset = self.offset.saturating_sub(lines);
257 }
258
259 pub fn scroll_down(&mut self, lines: usize, item_count: usize) {
263 self.offset = self
264 .offset
265 .saturating_add(lines)
266 .min(item_count.saturating_sub(1));
267 }
268
269 pub fn select_next(&mut self, item_count: usize) {
273 if item_count == 0 {
274 return;
275 }
276 let next = match self.selected {
277 Some(i) => (i + 1).min(item_count.saturating_sub(1)),
278 None => 0,
279 };
280 self.selected = Some(next);
281 }
282
283 pub fn select_previous(&mut self) {
287 let prev = match self.selected {
288 Some(i) => i.saturating_sub(1),
289 None => 0,
290 };
291 self.selected = Some(prev);
292 }
293}
294
295#[derive(Clone, Debug, Default, PartialEq)]
303#[cfg_attr(
304 feature = "state-persistence",
305 derive(serde::Serialize, serde::Deserialize)
306)]
307pub struct ListPersistState {
308 pub selected: Option<usize>,
310 pub offset: usize,
312}
313
314impl Stateful for ListState {
315 type State = ListPersistState;
316
317 fn state_key(&self) -> StateKey {
318 StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
319 }
320
321 fn save_state(&self) -> ListPersistState {
322 ListPersistState {
323 selected: self.selected,
324 offset: self.offset,
325 }
326 }
327
328 fn restore_state(&mut self, state: ListPersistState) {
329 self.selected = state.selected;
330 self.hovered = None;
331 self.offset = state.offset;
332 }
333}
334
335impl<'a> StatefulWidget for List<'a> {
336 type State = ListState;
337
338 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
339 #[cfg(feature = "tracing")]
340 let _span = tracing::debug_span!(
341 "widget_render",
342 widget = "List",
343 x = area.x,
344 y = area.y,
345 w = area.width,
346 h = area.height
347 )
348 .entered();
349
350 let list_area = match &self.block {
351 Some(b) => {
352 b.render(area, frame);
353 b.inner(area)
354 }
355 None => area,
356 };
357
358 if list_area.is_empty() {
359 return;
360 }
361
362 set_style_area(&mut frame.buffer, list_area, self.style);
364
365 if self.items.is_empty() {
366 state.selected = None;
367 state.hovered = None;
368 state.offset = 0;
369 return;
370 }
371
372 let list_height = list_area.height as usize;
373
374 let max_offset = self.items.len().saturating_sub(list_height.max(1));
377 state.offset = state.offset.min(max_offset);
378
379 if let Some(selected) = state.selected {
381 if self.items.is_empty() {
382 state.selected = None;
383 } else if selected >= self.items.len() {
384 state.selected = Some(self.items.len() - 1);
385 }
386 }
387 if let Some(hovered) = state.hovered
388 && hovered >= self.items.len()
389 {
390 state.hovered = None;
391 }
392
393 if let Some(selected) = state.selected {
395 if selected >= state.offset + list_height {
396 state.offset = selected - list_height + 1;
397 } else if selected < state.offset {
398 state.offset = selected;
399 }
400 }
401
402 for (i, item) in self
404 .items
405 .iter()
406 .enumerate()
407 .skip(state.offset)
408 .take(list_height)
409 {
410 let y = list_area.y.saturating_add((i - state.offset) as u16);
411 if y >= list_area.bottom() {
412 break;
413 }
414 let is_selected = state.selected == Some(i);
415 let is_hovered = state.hovered == Some(i);
416
417 let mut item_style = if is_hovered {
420 self.hover_style.merge(&item.style)
421 } else {
422 item.style
423 };
424 if is_selected {
425 item_style = self.highlight_style.merge(&item_style);
426 }
427
428 let row_area = Rect::new(list_area.x, y, list_area.width, 1);
430 set_style_area(&mut frame.buffer, row_area, item_style);
431
432 let symbol = if is_selected {
434 self.highlight_symbol.unwrap_or(item.marker)
435 } else {
436 item.marker
437 };
438
439 let mut x = list_area.x;
440
441 if !symbol.is_empty() {
443 x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
444 x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
446 }
447
448 if let Some(line) = item.content.lines().first() {
451 for span in line.spans() {
452 let span_style = match span.style {
453 Some(s) => s.merge(&item_style),
454 None => item_style,
455 };
456 x = draw_text_span_with_link(
457 frame,
458 x,
459 y,
460 &span.content,
461 span_style,
462 list_area.right(),
463 span.link.as_deref(),
464 );
465 if x >= list_area.right() {
466 break;
467 }
468 }
469 }
470
471 if let Some(id) = self.hit_id {
473 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
474 }
475 }
476 }
477}
478
479impl<'a> Widget for List<'a> {
480 fn render(&self, area: Rect, frame: &mut Frame) {
481 let mut state = ListState::default();
482 StatefulWidget::render(self, area, frame, &mut state);
483 }
484}
485
486impl MeasurableWidget for ListItem<'_> {
487 fn measure(&self, _available: Size) -> SizeConstraints {
488 let marker_width = display_width(self.marker) as u16;
490 let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
491
492 let text_width = self
494 .content
495 .lines()
496 .first()
497 .map(|line| line.width())
498 .unwrap_or(0)
499 .min(u16::MAX as usize) as u16;
500
501 let total_width = marker_width
502 .saturating_add(space_after_marker)
503 .saturating_add(text_width);
504
505 SizeConstraints::exact(Size::new(total_width, 1))
507 }
508
509 fn has_intrinsic_size(&self) -> bool {
510 true
511 }
512}
513
514impl MeasurableWidget for List<'_> {
515 fn measure(&self, available: Size) -> SizeConstraints {
516 let (chrome_width, chrome_height) = self
518 .block
519 .as_ref()
520 .map(|b| b.chrome_size())
521 .unwrap_or((0, 0));
522
523 if self.items.is_empty() {
524 return SizeConstraints {
526 min: Size::new(chrome_width, chrome_height),
527 preferred: Size::new(chrome_width, chrome_height),
528 max: None,
529 };
530 }
531
532 let inner_available = Size::new(
534 available.width.saturating_sub(chrome_width),
535 available.height.saturating_sub(chrome_height),
536 );
537
538 let mut max_width: u16 = 0;
540 let mut total_height: u16 = 0;
541
542 for item in &self.items {
543 let item_constraints = item.measure(inner_available);
544 max_width = max_width.max(item_constraints.preferred.width);
545 total_height = total_height.saturating_add(item_constraints.preferred.height);
546 }
547
548 if let Some(symbol) = self.highlight_symbol {
550 let symbol_width = display_width(symbol) as u16 + 1; max_width = max_width.saturating_add(symbol_width);
552 }
553
554 let preferred_width = max_width.saturating_add(chrome_width);
556 let preferred_height = total_height.saturating_add(chrome_height);
557
558 let min_height = chrome_height.saturating_add(1.min(total_height));
560
561 SizeConstraints {
562 min: Size::new(chrome_width, min_height),
563 preferred: Size::new(preferred_width, preferred_height),
564 max: None, }
566 }
567
568 fn has_intrinsic_size(&self) -> bool {
569 !self.items.is_empty()
570 }
571}
572
573#[derive(Debug, Clone)]
579pub struct ListStateSnapshot {
580 selected: Option<usize>,
581 offset: usize,
582}
583
584impl UndoSupport for ListState {
585 fn undo_widget_id(&self) -> UndoWidgetId {
586 self.undo_id
587 }
588
589 fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
590 Box::new(ListStateSnapshot {
591 selected: self.selected,
592 offset: self.offset,
593 })
594 }
595
596 fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
597 if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
598 self.selected = snap.selected;
599 self.hovered = None;
600 self.offset = snap.offset;
601 true
602 } else {
603 false
604 }
605 }
606}
607
608impl ListUndoExt for ListState {
609 fn selected_index(&self) -> Option<usize> {
610 self.selected
611 }
612
613 fn set_selected_index(&mut self, index: Option<usize>) {
614 self.selected = index;
615 if index.is_none() {
616 self.offset = 0;
617 }
618 }
619}
620
621impl ListState {
622 #[must_use]
626 pub fn undo_id(&self) -> UndoWidgetId {
627 self.undo_id
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use ftui_render::grapheme_pool::GraphemePool;
635
636 fn row_text(frame: &Frame, y: u16) -> String {
637 let width = frame.buffer.width();
638 let mut actual = String::new();
639 for x in 0..width {
640 let ch = frame
641 .buffer
642 .get(x, y)
643 .and_then(|cell| cell.content.as_char())
644 .unwrap_or(' ');
645 actual.push(ch);
646 }
647 actual.trim().to_string()
648 }
649
650 #[test]
651 fn render_empty_list() {
652 let list = List::new(Vec::<ListItem>::new());
653 let area = Rect::new(0, 0, 10, 5);
654 let mut pool = GraphemePool::new();
655 let mut frame = Frame::new(10, 5, &mut pool);
656 Widget::render(&list, area, &mut frame);
657 }
658
659 #[test]
660 fn render_simple_list() {
661 let items = vec![
662 ListItem::new("Item A"),
663 ListItem::new("Item B"),
664 ListItem::new("Item C"),
665 ];
666 let list = List::new(items);
667 let area = Rect::new(0, 0, 10, 3);
668 let mut pool = GraphemePool::new();
669 let mut frame = Frame::new(10, 3, &mut pool);
670 let mut state = ListState::default();
671 StatefulWidget::render(&list, area, &mut frame, &mut state);
672
673 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
674 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
675 assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
676 assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
677 }
678
679 #[test]
680 fn list_state_select() {
681 let mut state = ListState::default();
682 assert_eq!(state.selected(), None);
683
684 state.select(Some(2));
685 assert_eq!(state.selected(), Some(2));
686
687 state.select(None);
688 assert_eq!(state.selected(), None);
689 assert_eq!(state.offset, 0);
690 }
691
692 #[test]
693 fn list_scrolls_to_selected() {
694 let items: Vec<ListItem> = (0..10)
695 .map(|i| ListItem::new(format!("Item {i}")))
696 .collect();
697 let list = List::new(items);
698 let area = Rect::new(0, 0, 10, 3);
699 let mut pool = GraphemePool::new();
700 let mut frame = Frame::new(10, 3, &mut pool);
701 let mut state = ListState::default();
702 state.select(Some(5));
703
704 StatefulWidget::render(&list, area, &mut frame, &mut state);
705 assert!(state.offset <= 5);
707 assert!(state.offset + 3 > 5);
708 }
709
710 #[test]
711 fn list_clamps_selection() {
712 let items = vec![ListItem::new("A"), ListItem::new("B")];
713 let list = List::new(items);
714 let area = Rect::new(0, 0, 10, 3);
715 let mut pool = GraphemePool::new();
716 let mut frame = Frame::new(10, 3, &mut pool);
717 let mut state = ListState::default();
718 state.select(Some(10)); StatefulWidget::render(&list, area, &mut frame, &mut state);
721 assert_eq!(state.selected(), Some(1));
723 }
724
725 #[test]
726 fn render_list_with_highlight_symbol() {
727 let items = vec![ListItem::new("A"), ListItem::new("B")];
728 let list = List::new(items).highlight_symbol(">");
729 let area = Rect::new(0, 0, 10, 2);
730 let mut pool = GraphemePool::new();
731 let mut frame = Frame::new(10, 2, &mut pool);
732 let mut state = ListState::default();
733 state.select(Some(0));
734
735 StatefulWidget::render(&list, area, &mut frame, &mut state);
736 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
738 }
739
740 #[test]
741 fn render_zero_area() {
742 let list = List::new(vec![ListItem::new("A")]);
743 let area = Rect::new(0, 0, 0, 0);
744 let mut pool = GraphemePool::new();
745 let mut frame = Frame::new(1, 1, &mut pool);
746 let mut state = ListState::default();
747 StatefulWidget::render(&list, area, &mut frame, &mut state);
748 }
749
750 #[test]
751 fn list_item_from_str() {
752 let item: ListItem = "hello".into();
753 assert_eq!(
754 item.content.lines().first().unwrap().to_plain_text(),
755 "hello"
756 );
757 assert_eq!(item.marker, "");
758 }
759
760 #[test]
761 fn list_item_with_marker() {
762 let items = vec![
763 ListItem::new("A").marker("•"),
764 ListItem::new("B").marker("•"),
765 ];
766 let list = List::new(items);
767 let area = Rect::new(0, 0, 10, 2);
768 let mut pool = GraphemePool::new();
769 let mut frame = Frame::new(10, 2, &mut pool);
770 let mut state = ListState::default();
771 StatefulWidget::render(&list, area, &mut frame, &mut state);
772
773 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
775 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
776 }
777
778 #[test]
779 fn list_state_deselect_resets_offset() {
780 let mut state = ListState {
781 offset: 5,
782 ..Default::default()
783 };
784 state.select(Some(10));
785 assert_eq!(state.offset, 5); state.select(None);
788 assert_eq!(state.offset, 0); }
790
791 #[test]
792 fn list_scrolls_up_when_selection_above_viewport() {
793 let items: Vec<ListItem> = (0..10)
794 .map(|i| ListItem::new(format!("Item {i}")))
795 .collect();
796 let list = List::new(items);
797 let area = Rect::new(0, 0, 10, 3);
798 let mut pool = GraphemePool::new();
799 let mut frame = Frame::new(10, 3, &mut pool);
800 let mut state = ListState::default();
801
802 state.select(Some(8));
804 StatefulWidget::render(&list, area, &mut frame, &mut state);
805 assert!(state.offset > 0);
806
807 state.select(Some(0));
809 StatefulWidget::render(&list, area, &mut frame, &mut state);
810 assert_eq!(state.offset, 0);
811 }
812
813 #[test]
814 fn list_clamps_offset_to_fill_viewport_on_resize() {
815 let items: Vec<ListItem> = (0..10)
816 .map(|i| ListItem::new(format!("Item {i}")))
817 .collect();
818 let list = List::new(items);
819
820 let mut pool = GraphemePool::new();
821 let mut state = ListState {
822 offset: 7,
823 ..Default::default()
824 };
825
826 let area_small = Rect::new(0, 0, 10, 3);
828 let mut frame_small = Frame::new(10, 3, &mut pool);
829 StatefulWidget::render(&list, area_small, &mut frame_small, &mut state);
830 assert_eq!(state.offset, 7);
831 assert_eq!(row_text(&frame_small, 0), "Item 7");
832 assert_eq!(row_text(&frame_small, 2), "Item 9");
833
834 let area_large = Rect::new(0, 0, 10, 5);
836 let mut frame_large = Frame::new(10, 5, &mut pool);
837 StatefulWidget::render(&list, area_large, &mut frame_large, &mut state);
838 assert_eq!(state.offset, 5);
839 assert_eq!(row_text(&frame_large, 0), "Item 5");
840 assert_eq!(row_text(&frame_large, 4), "Item 9");
841 }
842
843 #[test]
844 fn render_list_more_items_than_viewport() {
845 let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
846 let list = List::new(items);
847 let area = Rect::new(0, 0, 5, 3);
848 let mut pool = GraphemePool::new();
849 let mut frame = Frame::new(5, 3, &mut pool);
850 let mut state = ListState::default();
851 StatefulWidget::render(&list, area, &mut frame, &mut state);
852
853 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
855 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
856 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
857 }
858
859 #[test]
860 fn widget_render_uses_default_state() {
861 let items = vec![ListItem::new("X")];
862 let list = List::new(items);
863 let area = Rect::new(0, 0, 5, 1);
864 let mut pool = GraphemePool::new();
865 let mut frame = Frame::new(5, 1, &mut pool);
866 Widget::render(&list, area, &mut frame);
868 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
869 }
870
871 #[test]
872 fn list_registers_hit_regions() {
873 let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
874 let list = List::new(items).hit_id(HitId::new(42));
875 let area = Rect::new(0, 0, 10, 3);
876 let mut pool = GraphemePool::new();
877 let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
878 let mut state = ListState::default();
879 StatefulWidget::render(&list, area, &mut frame, &mut state);
880
881 let hit0 = frame.hit_test(5, 0);
883 let hit1 = frame.hit_test(5, 1);
884 let hit2 = frame.hit_test(5, 2);
885
886 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
887 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
888 assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
889 }
890
891 #[test]
892 fn list_no_hit_without_hit_id() {
893 let items = vec![ListItem::new("A")];
894 let list = List::new(items); let area = Rect::new(0, 0, 10, 1);
896 let mut pool = GraphemePool::new();
897 let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
898 let mut state = ListState::default();
899 StatefulWidget::render(&list, area, &mut frame, &mut state);
900
901 assert!(frame.hit_test(5, 0).is_none());
903 }
904
905 #[test]
906 fn list_no_hit_without_hit_grid() {
907 let items = vec![ListItem::new("A")];
908 let list = List::new(items).hit_id(HitId::new(1));
909 let area = Rect::new(0, 0, 10, 1);
910 let mut pool = GraphemePool::new();
911 let mut frame = Frame::new(10, 1, &mut pool); let mut state = ListState::default();
913 StatefulWidget::render(&list, area, &mut frame, &mut state);
914
915 assert!(frame.hit_test(5, 0).is_none());
917 }
918
919 use crate::MeasurableWidget;
922 use ftui_core::geometry::Size;
923
924 #[test]
925 fn list_item_measure_simple() {
926 let item = ListItem::new("Hello"); let constraints = item.measure(Size::MAX);
928
929 assert_eq!(constraints.preferred, Size::new(5, 1));
930 assert_eq!(constraints.min, Size::new(5, 1));
931 assert_eq!(constraints.max, Some(Size::new(5, 1)));
932 }
933
934 #[test]
935 fn list_item_measure_with_marker() {
936 let item = ListItem::new("Hi").marker("•"); let constraints = item.measure(Size::MAX);
938
939 assert_eq!(constraints.preferred.width, 4);
940 assert_eq!(constraints.preferred.height, 1);
941 }
942
943 #[test]
944 fn list_item_has_intrinsic_size() {
945 let item = ListItem::new("test");
946 assert!(item.has_intrinsic_size());
947 }
948
949 #[test]
950 fn list_measure_empty() {
951 let list = List::new(Vec::<ListItem>::new());
952 let constraints = list.measure(Size::MAX);
953
954 assert_eq!(constraints.preferred, Size::new(0, 0));
955 assert!(!list.has_intrinsic_size());
956 }
957
958 #[test]
959 fn list_measure_single_item() {
960 let items = vec![ListItem::new("Hello")]; let list = List::new(items);
962 let constraints = list.measure(Size::MAX);
963
964 assert_eq!(constraints.preferred, Size::new(5, 1));
965 assert_eq!(constraints.min.height, 1);
966 }
967
968 #[test]
969 fn list_measure_multiple_items() {
970 let items = vec![
971 ListItem::new("Short"), ListItem::new("LongerItem"), ListItem::new("Tiny"), ];
975 let list = List::new(items);
976 let constraints = list.measure(Size::MAX);
977
978 assert_eq!(constraints.preferred.width, 10);
980 assert_eq!(constraints.preferred.height, 3);
982 }
983
984 #[test]
985 fn list_measure_with_block() {
986 let block = crate::block::Block::bordered(); let items = vec![ListItem::new("Hi")]; let list = List::new(items).block(block);
989 let constraints = list.measure(Size::MAX);
990
991 assert_eq!(constraints.preferred, Size::new(4, 3));
994 }
995
996 #[test]
997 fn list_measure_with_highlight_symbol() {
998 let items = vec![ListItem::new("Item")]; let list = List::new(items).highlight_symbol(">"); let constraints = list.measure(Size::MAX);
1002
1003 assert_eq!(constraints.preferred.width, 6);
1005 }
1006
1007 #[test]
1008 fn list_has_intrinsic_size() {
1009 let items = vec![ListItem::new("X")];
1010 let list = List::new(items);
1011 assert!(list.has_intrinsic_size());
1012 }
1013
1014 #[test]
1015 fn list_min_height_is_one_row() {
1016 let items: Vec<ListItem> = (0..100)
1017 .map(|i| ListItem::new(format!("Item {i}")))
1018 .collect();
1019 let list = List::new(items);
1020 let constraints = list.measure(Size::MAX);
1021
1022 assert_eq!(constraints.min.height, 1);
1024 assert_eq!(constraints.preferred.height, 100);
1026 }
1027
1028 #[test]
1029 fn list_measure_is_pure() {
1030 let items = vec![ListItem::new("Test")];
1031 let list = List::new(items);
1032 let a = list.measure(Size::new(100, 50));
1033 let b = list.measure(Size::new(100, 50));
1034 assert_eq!(a, b);
1035 }
1036
1037 #[test]
1040 fn list_state_undo_id_is_stable() {
1041 let state = ListState::default();
1042 let id1 = state.undo_id();
1043 let id2 = state.undo_id();
1044 assert_eq!(id1, id2);
1045 }
1046
1047 #[test]
1048 fn list_state_undo_id_unique_per_instance() {
1049 let state1 = ListState::default();
1050 let state2 = ListState::default();
1051 assert_ne!(state1.undo_id(), state2.undo_id());
1052 }
1053
1054 #[test]
1055 fn list_state_snapshot_and_restore() {
1056 let mut state = ListState::default();
1057 state.select(Some(5));
1058 state.offset = 3;
1059
1060 let snapshot = state.create_snapshot();
1061
1062 state.select(Some(10));
1064 state.offset = 8;
1065 assert_eq!(state.selected(), Some(10));
1066 assert_eq!(state.offset, 8);
1067
1068 assert!(state.restore_snapshot(snapshot.as_ref()));
1070 assert_eq!(state.selected(), Some(5));
1071 assert_eq!(state.offset, 3);
1072 }
1073
1074 #[test]
1075 fn list_state_undo_ext_methods() {
1076 let mut state = ListState::default();
1077 assert_eq!(state.selected_index(), None);
1078
1079 state.set_selected_index(Some(3));
1080 assert_eq!(state.selected_index(), Some(3));
1081
1082 state.set_selected_index(None);
1083 assert_eq!(state.selected_index(), None);
1084 assert_eq!(state.offset, 0); }
1086
1087 use crate::stateful::Stateful;
1090
1091 #[test]
1092 fn list_state_with_persistence_id() {
1093 let state = ListState::default().with_persistence_id("sidebar-menu");
1094 assert_eq!(state.persistence_id(), Some("sidebar-menu"));
1095 }
1096
1097 #[test]
1098 fn list_state_default_no_persistence_id() {
1099 let state = ListState::default();
1100 assert_eq!(state.persistence_id(), None);
1101 }
1102
1103 #[test]
1104 fn list_state_save_restore_round_trip() {
1105 let mut state = ListState::default().with_persistence_id("test");
1106 state.select(Some(7));
1107 state.offset = 4;
1108
1109 let saved = state.save_state();
1110 assert_eq!(saved.selected, Some(7));
1111 assert_eq!(saved.offset, 4);
1112
1113 state.select(None);
1115 assert_eq!(state.selected, None);
1116 assert_eq!(state.offset, 0);
1117
1118 state.restore_state(saved);
1120 assert_eq!(state.selected, Some(7));
1121 assert_eq!(state.offset, 4);
1122 }
1123
1124 #[test]
1125 fn list_state_key_uses_persistence_id() {
1126 let state = ListState::default().with_persistence_id("file-browser");
1127 let key = state.state_key();
1128 assert_eq!(key.widget_type, "List");
1129 assert_eq!(key.instance_id, "file-browser");
1130 }
1131
1132 #[test]
1133 fn list_state_key_default_when_no_id() {
1134 let state = ListState::default();
1135 let key = state.state_key();
1136 assert_eq!(key.widget_type, "List");
1137 assert_eq!(key.instance_id, "default");
1138 }
1139
1140 #[test]
1141 fn list_persist_state_default() {
1142 let persist = ListPersistState::default();
1143 assert_eq!(persist.selected, None);
1144 assert_eq!(persist.offset, 0);
1145 }
1146
1147 use crate::mouse::MouseResult;
1150 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
1151
1152 #[test]
1153 fn list_state_click_selects() {
1154 let mut state = ListState::default();
1155 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1156 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1157 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1158 assert_eq!(result, MouseResult::Selected(3));
1159 assert_eq!(state.selected(), Some(3));
1160 }
1161
1162 #[test]
1163 fn list_state_click_wrong_id_ignored() {
1164 let mut state = ListState::default();
1165 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1166 let hit = Some((HitId::new(99), HitRegion::Content, 3u64));
1167 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1168 assert_eq!(result, MouseResult::Ignored);
1169 assert_eq!(state.selected(), None);
1170 }
1171
1172 #[test]
1173 fn list_state_click_out_of_range() {
1174 let mut state = ListState::default();
1175 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1176 let hit = Some((HitId::new(1), HitRegion::Content, 15u64));
1177 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1178 assert_eq!(result, MouseResult::Ignored);
1179 assert_eq!(state.selected(), None);
1180 }
1181
1182 #[test]
1183 fn list_state_click_no_hit_ignored() {
1184 let mut state = ListState::default();
1185 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1186 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1187 assert_eq!(result, MouseResult::Ignored);
1188 }
1189
1190 #[test]
1191 #[allow(clippy::field_reassign_with_default)]
1192 fn list_state_scroll_up() {
1193 let mut state = {
1194 let mut s = ListState::default();
1195 s.offset = 10;
1196 s
1197 };
1198 state.scroll_up(3);
1199 assert_eq!(state.offset, 7);
1200 }
1201
1202 #[test]
1203 #[allow(clippy::field_reassign_with_default)]
1204 fn list_state_scroll_up_clamps_to_zero() {
1205 let mut state = {
1206 let mut s = ListState::default();
1207 s.offset = 1;
1208 s
1209 };
1210 state.scroll_up(5);
1211 assert_eq!(state.offset, 0);
1212 }
1213
1214 #[test]
1215 fn list_state_scroll_down() {
1216 let mut state = ListState::default();
1217 state.scroll_down(3, 20);
1218 assert_eq!(state.offset, 3);
1219 }
1220
1221 #[test]
1222 #[allow(clippy::field_reassign_with_default)]
1223 fn list_state_scroll_down_clamps() {
1224 let mut state = ListState::default();
1225 state.offset = 18;
1226 state.scroll_down(5, 20);
1227 assert_eq!(state.offset, 19); }
1229
1230 #[test]
1231 #[allow(clippy::field_reassign_with_default)]
1232 fn list_state_scroll_wheel_up() {
1233 let mut state = {
1234 let mut s = ListState::default();
1235 s.offset = 10;
1236 s
1237 };
1238 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1239 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1240 assert_eq!(result, MouseResult::Scrolled);
1241 assert_eq!(state.offset, 7);
1242 }
1243
1244 #[test]
1245 fn list_state_scroll_wheel_down() {
1246 let mut state = ListState::default();
1247 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1248 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1249 assert_eq!(result, MouseResult::Scrolled);
1250 assert_eq!(state.offset, 3);
1251 }
1252
1253 #[test]
1254 fn list_state_select_next() {
1255 let mut state = ListState::default();
1256 state.select_next(5);
1257 assert_eq!(state.selected(), Some(0));
1258 state.select_next(5);
1259 assert_eq!(state.selected(), Some(1));
1260 }
1261
1262 #[test]
1263 fn list_state_select_next_clamps() {
1264 let mut state = ListState::default();
1265 state.select(Some(4));
1266 state.select_next(5);
1267 assert_eq!(state.selected(), Some(4)); }
1269
1270 #[test]
1271 fn list_state_select_next_empty() {
1272 let mut state = ListState::default();
1273 state.select_next(0);
1274 assert_eq!(state.selected(), None); }
1276
1277 #[test]
1278 fn list_state_select_previous() {
1279 let mut state = ListState::default();
1280 state.select(Some(3));
1281 state.select_previous();
1282 assert_eq!(state.selected(), Some(2));
1283 }
1284
1285 #[test]
1286 fn list_state_select_previous_clamps() {
1287 let mut state = ListState::default();
1288 state.select(Some(0));
1289 state.select_previous();
1290 assert_eq!(state.selected(), Some(0)); }
1292
1293 #[test]
1294 fn list_state_select_previous_from_none() {
1295 let mut state = ListState::default();
1296 state.select_previous();
1297 assert_eq!(state.selected(), Some(0));
1298 }
1299
1300 #[test]
1301 fn list_state_right_click_ignored() {
1302 let mut state = ListState::default();
1303 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 2);
1304 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1305 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1306 assert_eq!(result, MouseResult::Ignored);
1307 }
1308
1309 #[test]
1310 fn list_state_click_border_region_ignored() {
1311 let mut state = ListState::default();
1312 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1313 let hit = Some((HitId::new(1), HitRegion::Border, 3u64));
1314 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1315 assert_eq!(result, MouseResult::Ignored);
1316 }
1317
1318 #[test]
1319 fn list_state_second_click_activates() {
1320 let mut state = ListState::default();
1321 state.select(Some(3));
1322
1323 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1324 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1325 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1326 assert_eq!(result, MouseResult::Activated(3));
1327 assert_eq!(state.selected(), Some(3));
1328 }
1329
1330 #[test]
1331 fn list_state_hover_updates() {
1332 let mut state = ListState::default();
1333 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1334 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1335 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1336 assert_eq!(result, MouseResult::HoverChanged);
1337 assert_eq!(state.hovered, Some(3));
1338 }
1339
1340 #[test]
1341 #[allow(clippy::field_reassign_with_default)]
1342 fn list_state_hover_same_index_ignored() {
1343 let mut state = {
1344 let mut s = ListState::default();
1345 s.hovered = Some(3);
1346 s
1347 };
1348 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1349 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1350 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1351 assert_eq!(result, MouseResult::Ignored);
1352 assert_eq!(state.hovered, Some(3));
1353 }
1354
1355 #[test]
1356 #[allow(clippy::field_reassign_with_default)]
1357 fn list_state_hover_clears() {
1358 let mut state = {
1359 let mut s = ListState::default();
1360 s.hovered = Some(5);
1361 s
1362 };
1363 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1364 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1366 assert_eq!(result, MouseResult::HoverChanged);
1367 assert_eq!(state.hovered, None);
1368 }
1369
1370 #[test]
1371 fn list_state_hover_clear_when_already_none() {
1372 let mut state = ListState::default();
1373 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
1374 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1375 assert_eq!(result, MouseResult::Ignored);
1376 }
1377}