1#![forbid(unsafe_code)]
2
3use crate::block::Block;
8use crate::measurable::{MeasurableWidget, SizeConstraints};
9use crate::stateful::{StateKey, Stateful};
10use crate::undo_support::{ListUndoExt, UndoSupport, UndoWidgetId};
11use crate::{StatefulWidget, Widget, draw_text_span, draw_text_span_with_link, set_style_area};
12use ftui_core::geometry::{Rect, Size};
13use ftui_render::frame::{Frame, HitId, HitRegion};
14use ftui_style::Style;
15use ftui_text::{Text, display_width};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ListItem<'a> {
20 content: Text,
21 style: Style,
22 marker: &'a str,
23}
24
25impl<'a> ListItem<'a> {
26 pub fn new(content: impl Into<Text>) -> Self {
28 Self {
29 content: content.into(),
30 style: Style::default(),
31 marker: "",
32 }
33 }
34
35 pub fn style(mut self, style: Style) -> Self {
37 self.style = style;
38 self
39 }
40
41 pub fn marker(mut self, marker: &'a str) -> Self {
43 self.marker = marker;
44 self
45 }
46}
47
48impl<'a> From<&'a str> for ListItem<'a> {
49 fn from(s: &'a str) -> Self {
50 Self::new(s)
51 }
52}
53
54#[derive(Debug, Clone, Default)]
56pub struct List<'a> {
57 block: Option<Block<'a>>,
58 items: Vec<ListItem<'a>>,
59 style: Style,
60 highlight_style: Style,
61 highlight_symbol: Option<&'a str>,
62 hit_id: Option<HitId>,
65}
66
67impl<'a> List<'a> {
68 pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<'a>>>) -> Self {
70 Self {
71 block: None,
72 items: items.into_iter().map(|i| i.into()).collect(),
73 style: Style::default(),
74 highlight_style: Style::default(),
75 highlight_symbol: None,
76 hit_id: None,
77 }
78 }
79
80 pub fn block(mut self, block: Block<'a>) -> Self {
82 self.block = Some(block);
83 self
84 }
85
86 pub fn style(mut self, style: Style) -> Self {
88 self.style = style;
89 self
90 }
91
92 pub fn highlight_style(mut self, style: Style) -> Self {
94 self.highlight_style = style;
95 self
96 }
97
98 pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
100 self.highlight_symbol = Some(symbol);
101 self
102 }
103
104 pub fn hit_id(mut self, id: HitId) -> Self {
110 self.hit_id = Some(id);
111 self
112 }
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct ListState {
118 undo_id: UndoWidgetId,
120 pub selected: Option<usize>,
122 pub offset: usize,
124 persistence_id: Option<String>,
126}
127
128impl ListState {
129 pub fn select(&mut self, index: Option<usize>) {
131 self.selected = index;
132 if index.is_none() {
133 self.offset = 0;
134 }
135 }
136
137 pub fn selected(&self) -> Option<usize> {
139 self.selected
140 }
141
142 #[must_use]
144 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
145 self.persistence_id = Some(id.into());
146 self
147 }
148
149 #[must_use]
151 pub fn persistence_id(&self) -> Option<&str> {
152 self.persistence_id.as_deref()
153 }
154}
155
156#[derive(Clone, Debug, Default, PartialEq)]
164#[cfg_attr(
165 feature = "state-persistence",
166 derive(serde::Serialize, serde::Deserialize)
167)]
168pub struct ListPersistState {
169 pub selected: Option<usize>,
171 pub offset: usize,
173}
174
175impl Stateful for ListState {
176 type State = ListPersistState;
177
178 fn state_key(&self) -> StateKey {
179 StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
180 }
181
182 fn save_state(&self) -> ListPersistState {
183 ListPersistState {
184 selected: self.selected,
185 offset: self.offset,
186 }
187 }
188
189 fn restore_state(&mut self, state: ListPersistState) {
190 self.selected = state.selected;
191 self.offset = state.offset;
192 }
193}
194
195impl<'a> StatefulWidget for List<'a> {
196 type State = ListState;
197
198 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
199 #[cfg(feature = "tracing")]
200 let _span = tracing::debug_span!(
201 "widget_render",
202 widget = "List",
203 x = area.x,
204 y = area.y,
205 w = area.width,
206 h = area.height
207 )
208 .entered();
209
210 let list_area = match &self.block {
211 Some(b) => {
212 b.render(area, frame);
213 b.inner(area)
214 }
215 None => area,
216 };
217
218 if list_area.is_empty() {
219 return;
220 }
221
222 set_style_area(&mut frame.buffer, list_area, self.style);
224
225 if self.items.is_empty() {
226 state.selected = None;
227 state.offset = 0;
228 return;
229 }
230
231 state.offset = state.offset.min(self.items.len().saturating_sub(1));
233
234 let list_height = list_area.height as usize;
235
236 if let Some(selected) = state.selected
238 && selected >= self.items.len()
239 {
240 state.selected = Some(self.items.len() - 1);
241 }
242
243 if let Some(selected) = state.selected {
245 if selected >= state.offset + list_height {
246 state.offset = selected - list_height + 1;
247 } else if selected < state.offset {
248 state.offset = selected;
249 }
250 }
251
252 for (i, item) in self
254 .items
255 .iter()
256 .enumerate()
257 .skip(state.offset)
258 .take(list_height)
259 {
260 let y = list_area.y.saturating_add((i - state.offset) as u16);
261 if y >= list_area.bottom() {
262 break;
263 }
264 let is_selected = state.selected == Some(i);
265
266 let item_style = if is_selected {
269 self.highlight_style.merge(&item.style)
270 } else {
271 item.style
272 };
273
274 let row_area = Rect::new(list_area.x, y, list_area.width, 1);
276 set_style_area(&mut frame.buffer, row_area, item_style);
277
278 let symbol = if is_selected {
280 self.highlight_symbol.unwrap_or(item.marker)
281 } else {
282 item.marker
283 };
284
285 let mut x = list_area.x;
286
287 if !symbol.is_empty() {
289 x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
290 x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
292 }
293
294 if let Some(line) = item.content.lines().first() {
297 for span in line.spans() {
298 let span_style = match span.style {
299 Some(s) => s.merge(&item_style),
300 None => item_style,
301 };
302 x = draw_text_span_with_link(
303 frame,
304 x,
305 y,
306 &span.content,
307 span_style,
308 list_area.right(),
309 span.link.as_deref(),
310 );
311 if x >= list_area.right() {
312 break;
313 }
314 }
315 }
316
317 if let Some(id) = self.hit_id {
319 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
320 }
321 }
322 }
323}
324
325impl<'a> Widget for List<'a> {
326 fn render(&self, area: Rect, frame: &mut Frame) {
327 let mut state = ListState::default();
328 StatefulWidget::render(self, area, frame, &mut state);
329 }
330}
331
332impl MeasurableWidget for ListItem<'_> {
333 fn measure(&self, _available: Size) -> SizeConstraints {
334 let marker_width = display_width(self.marker) as u16;
336 let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
337
338 let text_width = self
340 .content
341 .lines()
342 .first()
343 .map(|line| line.width())
344 .unwrap_or(0)
345 .min(u16::MAX as usize) as u16;
346
347 let total_width = marker_width
348 .saturating_add(space_after_marker)
349 .saturating_add(text_width);
350
351 SizeConstraints::exact(Size::new(total_width, 1))
353 }
354
355 fn has_intrinsic_size(&self) -> bool {
356 true
357 }
358}
359
360impl MeasurableWidget for List<'_> {
361 fn measure(&self, available: Size) -> SizeConstraints {
362 let (chrome_width, chrome_height) = self
364 .block
365 .as_ref()
366 .map(|b| b.chrome_size())
367 .unwrap_or((0, 0));
368
369 if self.items.is_empty() {
370 return SizeConstraints {
372 min: Size::new(chrome_width, chrome_height),
373 preferred: Size::new(chrome_width, chrome_height),
374 max: None,
375 };
376 }
377
378 let inner_available = Size::new(
380 available.width.saturating_sub(chrome_width),
381 available.height.saturating_sub(chrome_height),
382 );
383
384 let mut max_width: u16 = 0;
386 let mut total_height: u16 = 0;
387
388 for item in &self.items {
389 let item_constraints = item.measure(inner_available);
390 max_width = max_width.max(item_constraints.preferred.width);
391 total_height = total_height.saturating_add(item_constraints.preferred.height);
392 }
393
394 if let Some(symbol) = self.highlight_symbol {
396 let symbol_width = display_width(symbol) as u16 + 1; max_width = max_width.saturating_add(symbol_width);
398 }
399
400 let preferred_width = max_width.saturating_add(chrome_width);
402 let preferred_height = total_height.saturating_add(chrome_height);
403
404 let min_height = chrome_height.saturating_add(1.min(total_height));
406
407 SizeConstraints {
408 min: Size::new(chrome_width, min_height),
409 preferred: Size::new(preferred_width, preferred_height),
410 max: None, }
412 }
413
414 fn has_intrinsic_size(&self) -> bool {
415 !self.items.is_empty()
416 }
417}
418
419#[derive(Debug, Clone)]
425pub struct ListStateSnapshot {
426 selected: Option<usize>,
427 offset: usize,
428}
429
430impl UndoSupport for ListState {
431 fn undo_widget_id(&self) -> UndoWidgetId {
432 self.undo_id
433 }
434
435 fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
436 Box::new(ListStateSnapshot {
437 selected: self.selected,
438 offset: self.offset,
439 })
440 }
441
442 fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
443 if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
444 self.selected = snap.selected;
445 self.offset = snap.offset;
446 true
447 } else {
448 false
449 }
450 }
451}
452
453impl ListUndoExt for ListState {
454 fn selected_index(&self) -> Option<usize> {
455 self.selected
456 }
457
458 fn set_selected_index(&mut self, index: Option<usize>) {
459 self.selected = index;
460 if index.is_none() {
461 self.offset = 0;
462 }
463 }
464}
465
466impl ListState {
467 #[must_use]
471 pub fn undo_id(&self) -> UndoWidgetId {
472 self.undo_id
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use ftui_render::grapheme_pool::GraphemePool;
480
481 #[test]
482 fn render_empty_list() {
483 let list = List::new(Vec::<ListItem>::new());
484 let area = Rect::new(0, 0, 10, 5);
485 let mut pool = GraphemePool::new();
486 let mut frame = Frame::new(10, 5, &mut pool);
487 Widget::render(&list, area, &mut frame);
488 }
489
490 #[test]
491 fn render_simple_list() {
492 let items = vec![
493 ListItem::new("Item A"),
494 ListItem::new("Item B"),
495 ListItem::new("Item C"),
496 ];
497 let list = List::new(items);
498 let area = Rect::new(0, 0, 10, 3);
499 let mut pool = GraphemePool::new();
500 let mut frame = Frame::new(10, 3, &mut pool);
501 let mut state = ListState::default();
502 StatefulWidget::render(&list, area, &mut frame, &mut state);
503
504 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
505 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
506 assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
507 assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
508 }
509
510 #[test]
511 fn list_state_select() {
512 let mut state = ListState::default();
513 assert_eq!(state.selected(), None);
514
515 state.select(Some(2));
516 assert_eq!(state.selected(), Some(2));
517
518 state.select(None);
519 assert_eq!(state.selected(), None);
520 assert_eq!(state.offset, 0);
521 }
522
523 #[test]
524 fn list_scrolls_to_selected() {
525 let items: Vec<ListItem> = (0..10)
526 .map(|i| ListItem::new(format!("Item {i}")))
527 .collect();
528 let list = List::new(items);
529 let area = Rect::new(0, 0, 10, 3);
530 let mut pool = GraphemePool::new();
531 let mut frame = Frame::new(10, 3, &mut pool);
532 let mut state = ListState::default();
533 state.select(Some(5));
534
535 StatefulWidget::render(&list, area, &mut frame, &mut state);
536 assert!(state.offset <= 5);
538 assert!(state.offset + 3 > 5);
539 }
540
541 #[test]
542 fn list_clamps_selection() {
543 let items = vec![ListItem::new("A"), ListItem::new("B")];
544 let list = List::new(items);
545 let area = Rect::new(0, 0, 10, 3);
546 let mut pool = GraphemePool::new();
547 let mut frame = Frame::new(10, 3, &mut pool);
548 let mut state = ListState::default();
549 state.select(Some(10)); StatefulWidget::render(&list, area, &mut frame, &mut state);
552 assert_eq!(state.selected(), Some(1));
554 }
555
556 #[test]
557 fn render_list_with_highlight_symbol() {
558 let items = vec![ListItem::new("A"), ListItem::new("B")];
559 let list = List::new(items).highlight_symbol(">");
560 let area = Rect::new(0, 0, 10, 2);
561 let mut pool = GraphemePool::new();
562 let mut frame = Frame::new(10, 2, &mut pool);
563 let mut state = ListState::default();
564 state.select(Some(0));
565
566 StatefulWidget::render(&list, area, &mut frame, &mut state);
567 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
569 }
570
571 #[test]
572 fn render_zero_area() {
573 let list = List::new(vec![ListItem::new("A")]);
574 let area = Rect::new(0, 0, 0, 0);
575 let mut pool = GraphemePool::new();
576 let mut frame = Frame::new(1, 1, &mut pool);
577 let mut state = ListState::default();
578 StatefulWidget::render(&list, area, &mut frame, &mut state);
579 }
580
581 #[test]
582 fn list_item_from_str() {
583 let item: ListItem = "hello".into();
584 assert_eq!(
585 item.content.lines().first().unwrap().to_plain_text(),
586 "hello"
587 );
588 assert_eq!(item.marker, "");
589 }
590
591 #[test]
592 fn list_item_with_marker() {
593 let items = vec![
594 ListItem::new("A").marker("•"),
595 ListItem::new("B").marker("•"),
596 ];
597 let list = List::new(items);
598 let area = Rect::new(0, 0, 10, 2);
599 let mut pool = GraphemePool::new();
600 let mut frame = Frame::new(10, 2, &mut pool);
601 let mut state = ListState::default();
602 StatefulWidget::render(&list, area, &mut frame, &mut state);
603
604 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
606 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
607 }
608
609 #[test]
610 fn list_state_deselect_resets_offset() {
611 let mut state = ListState {
612 offset: 5,
613 ..Default::default()
614 };
615 state.select(Some(10));
616 assert_eq!(state.offset, 5); state.select(None);
619 assert_eq!(state.offset, 0); }
621
622 #[test]
623 fn list_scrolls_up_when_selection_above_viewport() {
624 let items: Vec<ListItem> = (0..10)
625 .map(|i| ListItem::new(format!("Item {i}")))
626 .collect();
627 let list = List::new(items);
628 let area = Rect::new(0, 0, 10, 3);
629 let mut pool = GraphemePool::new();
630 let mut frame = Frame::new(10, 3, &mut pool);
631 let mut state = ListState::default();
632
633 state.select(Some(8));
635 StatefulWidget::render(&list, area, &mut frame, &mut state);
636 assert!(state.offset > 0);
637
638 state.select(Some(0));
640 StatefulWidget::render(&list, area, &mut frame, &mut state);
641 assert_eq!(state.offset, 0);
642 }
643
644 #[test]
645 fn render_list_more_items_than_viewport() {
646 let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
647 let list = List::new(items);
648 let area = Rect::new(0, 0, 5, 3);
649 let mut pool = GraphemePool::new();
650 let mut frame = Frame::new(5, 3, &mut pool);
651 let mut state = ListState::default();
652 StatefulWidget::render(&list, area, &mut frame, &mut state);
653
654 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
656 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
657 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
658 }
659
660 #[test]
661 fn widget_render_uses_default_state() {
662 let items = vec![ListItem::new("X")];
663 let list = List::new(items);
664 let area = Rect::new(0, 0, 5, 1);
665 let mut pool = GraphemePool::new();
666 let mut frame = Frame::new(5, 1, &mut pool);
667 Widget::render(&list, area, &mut frame);
669 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
670 }
671
672 #[test]
673 fn list_registers_hit_regions() {
674 let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
675 let list = List::new(items).hit_id(HitId::new(42));
676 let area = Rect::new(0, 0, 10, 3);
677 let mut pool = GraphemePool::new();
678 let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
679 let mut state = ListState::default();
680 StatefulWidget::render(&list, area, &mut frame, &mut state);
681
682 let hit0 = frame.hit_test(5, 0);
684 let hit1 = frame.hit_test(5, 1);
685 let hit2 = frame.hit_test(5, 2);
686
687 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
688 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
689 assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
690 }
691
692 #[test]
693 fn list_no_hit_without_hit_id() {
694 let items = vec![ListItem::new("A")];
695 let list = List::new(items); let area = Rect::new(0, 0, 10, 1);
697 let mut pool = GraphemePool::new();
698 let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
699 let mut state = ListState::default();
700 StatefulWidget::render(&list, area, &mut frame, &mut state);
701
702 assert!(frame.hit_test(5, 0).is_none());
704 }
705
706 #[test]
707 fn list_no_hit_without_hit_grid() {
708 let items = vec![ListItem::new("A")];
709 let list = List::new(items).hit_id(HitId::new(1));
710 let area = Rect::new(0, 0, 10, 1);
711 let mut pool = GraphemePool::new();
712 let mut frame = Frame::new(10, 1, &mut pool); let mut state = ListState::default();
714 StatefulWidget::render(&list, area, &mut frame, &mut state);
715
716 assert!(frame.hit_test(5, 0).is_none());
718 }
719
720 use crate::MeasurableWidget;
723 use ftui_core::geometry::Size;
724
725 #[test]
726 fn list_item_measure_simple() {
727 let item = ListItem::new("Hello"); let constraints = item.measure(Size::MAX);
729
730 assert_eq!(constraints.preferred, Size::new(5, 1));
731 assert_eq!(constraints.min, Size::new(5, 1));
732 assert_eq!(constraints.max, Some(Size::new(5, 1)));
733 }
734
735 #[test]
736 fn list_item_measure_with_marker() {
737 let item = ListItem::new("Hi").marker("•"); let constraints = item.measure(Size::MAX);
739
740 assert_eq!(constraints.preferred.width, 4);
741 assert_eq!(constraints.preferred.height, 1);
742 }
743
744 #[test]
745 fn list_item_has_intrinsic_size() {
746 let item = ListItem::new("test");
747 assert!(item.has_intrinsic_size());
748 }
749
750 #[test]
751 fn list_measure_empty() {
752 let list = List::new(Vec::<ListItem>::new());
753 let constraints = list.measure(Size::MAX);
754
755 assert_eq!(constraints.preferred, Size::new(0, 0));
756 assert!(!list.has_intrinsic_size());
757 }
758
759 #[test]
760 fn list_measure_single_item() {
761 let items = vec![ListItem::new("Hello")]; let list = List::new(items);
763 let constraints = list.measure(Size::MAX);
764
765 assert_eq!(constraints.preferred, Size::new(5, 1));
766 assert_eq!(constraints.min.height, 1);
767 }
768
769 #[test]
770 fn list_measure_multiple_items() {
771 let items = vec![
772 ListItem::new("Short"), ListItem::new("LongerItem"), ListItem::new("Tiny"), ];
776 let list = List::new(items);
777 let constraints = list.measure(Size::MAX);
778
779 assert_eq!(constraints.preferred.width, 10);
781 assert_eq!(constraints.preferred.height, 3);
783 }
784
785 #[test]
786 fn list_measure_with_block() {
787 let block = crate::block::Block::bordered(); let items = vec![ListItem::new("Hi")]; let list = List::new(items).block(block);
790 let constraints = list.measure(Size::MAX);
791
792 assert_eq!(constraints.preferred, Size::new(4, 3));
795 }
796
797 #[test]
798 fn list_measure_with_highlight_symbol() {
799 let items = vec![ListItem::new("Item")]; let list = List::new(items).highlight_symbol(">"); let constraints = list.measure(Size::MAX);
803
804 assert_eq!(constraints.preferred.width, 6);
806 }
807
808 #[test]
809 fn list_has_intrinsic_size() {
810 let items = vec![ListItem::new("X")];
811 let list = List::new(items);
812 assert!(list.has_intrinsic_size());
813 }
814
815 #[test]
816 fn list_min_height_is_one_row() {
817 let items: Vec<ListItem> = (0..100)
818 .map(|i| ListItem::new(format!("Item {i}")))
819 .collect();
820 let list = List::new(items);
821 let constraints = list.measure(Size::MAX);
822
823 assert_eq!(constraints.min.height, 1);
825 assert_eq!(constraints.preferred.height, 100);
827 }
828
829 #[test]
830 fn list_measure_is_pure() {
831 let items = vec![ListItem::new("Test")];
832 let list = List::new(items);
833 let a = list.measure(Size::new(100, 50));
834 let b = list.measure(Size::new(100, 50));
835 assert_eq!(a, b);
836 }
837
838 #[test]
841 fn list_state_undo_id_is_stable() {
842 let state = ListState::default();
843 let id1 = state.undo_id();
844 let id2 = state.undo_id();
845 assert_eq!(id1, id2);
846 }
847
848 #[test]
849 fn list_state_undo_id_unique_per_instance() {
850 let state1 = ListState::default();
851 let state2 = ListState::default();
852 assert_ne!(state1.undo_id(), state2.undo_id());
853 }
854
855 #[test]
856 fn list_state_snapshot_and_restore() {
857 let mut state = ListState::default();
858 state.select(Some(5));
859 state.offset = 3;
860
861 let snapshot = state.create_snapshot();
862
863 state.select(Some(10));
865 state.offset = 8;
866 assert_eq!(state.selected(), Some(10));
867 assert_eq!(state.offset, 8);
868
869 assert!(state.restore_snapshot(snapshot.as_ref()));
871 assert_eq!(state.selected(), Some(5));
872 assert_eq!(state.offset, 3);
873 }
874
875 #[test]
876 fn list_state_undo_ext_methods() {
877 let mut state = ListState::default();
878 assert_eq!(state.selected_index(), None);
879
880 state.set_selected_index(Some(3));
881 assert_eq!(state.selected_index(), Some(3));
882
883 state.set_selected_index(None);
884 assert_eq!(state.selected_index(), None);
885 assert_eq!(state.offset, 0); }
887
888 use crate::stateful::Stateful;
891
892 #[test]
893 fn list_state_with_persistence_id() {
894 let state = ListState::default().with_persistence_id("sidebar-menu");
895 assert_eq!(state.persistence_id(), Some("sidebar-menu"));
896 }
897
898 #[test]
899 fn list_state_default_no_persistence_id() {
900 let state = ListState::default();
901 assert_eq!(state.persistence_id(), None);
902 }
903
904 #[test]
905 fn list_state_save_restore_round_trip() {
906 let mut state = ListState::default().with_persistence_id("test");
907 state.select(Some(7));
908 state.offset = 4;
909
910 let saved = state.save_state();
911 assert_eq!(saved.selected, Some(7));
912 assert_eq!(saved.offset, 4);
913
914 state.select(None);
916 assert_eq!(state.selected, None);
917 assert_eq!(state.offset, 0);
918
919 state.restore_state(saved);
921 assert_eq!(state.selected, Some(7));
922 assert_eq!(state.offset, 4);
923 }
924
925 #[test]
926 fn list_state_key_uses_persistence_id() {
927 let state = ListState::default().with_persistence_id("file-browser");
928 let key = state.state_key();
929 assert_eq!(key.widget_type, "List");
930 assert_eq!(key.instance_id, "file-browser");
931 }
932
933 #[test]
934 fn list_state_key_default_when_no_id() {
935 let state = ListState::default();
936 let key = state.state_key();
937 assert_eq!(key.widget_type, "List");
938 assert_eq!(key.instance_id, "default");
939 }
940
941 #[test]
942 fn list_persist_state_default() {
943 let persist = ListPersistState::default();
944 assert_eq!(persist.selected, None);
945 assert_eq!(persist.offset, 0);
946 }
947}