Skip to main content

ftui_widgets/
list.rs

1#![forbid(unsafe_code)]
2
3//! List widget.
4//!
5//! A widget to display a list of items with selection support.
6
7use 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/// A single item in a list.
18#[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    /// Create a new list item with the given content.
27    pub fn new(content: impl Into<Text>) -> Self {
28        Self {
29            content: content.into(),
30            style: Style::default(),
31            marker: "",
32        }
33    }
34
35    /// Set the style for this list item.
36    pub fn style(mut self, style: Style) -> Self {
37        self.style = style;
38        self
39    }
40
41    /// Set a prefix marker string for this item.
42    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/// A widget to display a list of items.
55#[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    /// Optional hit ID for mouse interaction.
63    /// When set, each list item registers a hit region with the hit grid.
64    hit_id: Option<HitId>,
65}
66
67impl<'a> List<'a> {
68    /// Create a new list from the given items.
69    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    /// Wrap the list in a decorative block.
81    pub fn block(mut self, block: Block<'a>) -> Self {
82        self.block = Some(block);
83        self
84    }
85
86    /// Set the base style for the list area.
87    pub fn style(mut self, style: Style) -> Self {
88        self.style = style;
89        self
90    }
91
92    /// Set the style applied to the selected item.
93    pub fn highlight_style(mut self, style: Style) -> Self {
94        self.highlight_style = style;
95        self
96    }
97
98    /// Set a symbol displayed before the selected item.
99    pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
100        self.highlight_symbol = Some(symbol);
101        self
102    }
103
104    /// Set a hit ID for mouse interaction.
105    ///
106    /// When set, each list item will register a hit region with the frame's
107    /// hit grid (if enabled). The hit data will be the item's index, allowing
108    /// click handlers to determine which item was clicked.
109    pub fn hit_id(mut self, id: HitId) -> Self {
110        self.hit_id = Some(id);
111        self
112    }
113}
114
115/// Mutable state for a [`List`] widget tracking selection and scroll offset.
116#[derive(Debug, Clone, Default)]
117pub struct ListState {
118    /// Unique ID for undo tracking.
119    undo_id: UndoWidgetId,
120    /// Index of the currently selected item, if any.
121    pub selected: Option<usize>,
122    /// Scroll offset (first visible item index).
123    pub offset: usize,
124    /// Optional persistence ID for state saving/restoration.
125    persistence_id: Option<String>,
126}
127
128impl ListState {
129    /// Set the selected item index, or `None` to deselect.
130    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    /// Return the currently selected item index.
138    pub fn selected(&self) -> Option<usize> {
139        self.selected
140    }
141
142    /// Create a new ListState with a persistence ID for state saving.
143    #[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    /// Get the persistence ID, if set.
150    #[must_use]
151    pub fn persistence_id(&self) -> Option<&str> {
152        self.persistence_id.as_deref()
153    }
154}
155
156// ============================================================================
157// Stateful Persistence Implementation
158// ============================================================================
159
160/// Persistable state for a [`ListState`].
161///
162/// Contains the user-facing state that should survive sessions.
163#[derive(Clone, Debug, Default, PartialEq)]
164#[cfg_attr(
165    feature = "state-persistence",
166    derive(serde::Serialize, serde::Deserialize)
167)]
168pub struct ListPersistState {
169    /// Selected item index.
170    pub selected: Option<usize>,
171    /// Scroll offset (first visible item).
172    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        // Apply base style
223        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        // Clamp offset to valid range
232        state.offset = state.offset.min(self.items.len().saturating_sub(1));
233
234        let list_height = list_area.height as usize;
235
236        // Ensure selection is within bounds
237        if let Some(selected) = state.selected
238            && selected >= self.items.len()
239        {
240            state.selected = Some(self.items.len() - 1);
241        }
242
243        // Ensure visible range includes selected item
244        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        // Iterate over visible items
253        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            // Determine style: merge highlight on top of item style so
267            // unset highlight properties inherit from the item.
268            let item_style = if is_selected {
269                self.highlight_style.merge(&item.style)
270            } else {
271                item.style
272            };
273
274            // Apply item background style to the whole row
275            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            // Determine symbol
279            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            // Draw symbol if present
288            if !symbol.is_empty() {
289                x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
290                // Add a space after symbol
291                x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
292            }
293
294            // Draw content
295            // Note: List items are currently single-line for simplicity in v1
296            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            // Register hit region for this item (if hit testing enabled)
318            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        // ListItem is a single line of text with optional marker
335        let marker_width = display_width(self.marker) as u16;
336        let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
337
338        // Get text width from the first line (List currently renders only first line)
339        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        // ListItem is always 1 line tall
352        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        // Get block chrome if present
363        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            // Empty list: just the chrome
371            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        // Calculate inner available space
379        let inner_available = Size::new(
380            available.width.saturating_sub(chrome_width),
381            available.height.saturating_sub(chrome_height),
382        );
383
384        // Measure all items
385        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        // Add highlight symbol width if present
395        if let Some(symbol) = self.highlight_symbol {
396            let symbol_width = display_width(symbol) as u16 + 1; // +1 for space
397            max_width = max_width.saturating_add(symbol_width);
398        }
399
400        // Add chrome
401        let preferred_width = max_width.saturating_add(chrome_width);
402        let preferred_height = total_height.saturating_add(chrome_height);
403
404        // Minimum is chrome + 1 item height (can scroll)
405        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, // Lists can scroll, so no max
411        }
412    }
413
414    fn has_intrinsic_size(&self) -> bool {
415        !self.items.is_empty()
416    }
417}
418
419// ============================================================================
420// Undo Support Implementation
421// ============================================================================
422
423/// Snapshot of ListState for undo.
424#[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    /// Get the undo widget ID.
468    ///
469    /// This can be used to associate undo commands with this state instance.
470    #[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        // offset should have been adjusted so item 5 is visible
537        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)); // out of bounds
550
551        StatefulWidget::render(&list, area, &mut frame, &mut state);
552        // should clamp to last item
553        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        // First item should have ">" symbol
568        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        // Marker should be rendered at the start
605        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); // select doesn't reset offset
617
618        state.select(None);
619        assert_eq!(state.offset, 0); // deselect resets offset
620    }
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        // First scroll down
634        state.select(Some(8));
635        StatefulWidget::render(&list, area, &mut frame, &mut state);
636        assert!(state.offset > 0);
637
638        // Now select item 0 - should scroll back up
639        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        // Only first 3 should render
655        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        // Using Widget trait (not StatefulWidget)
668        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        // Each row should have a hit region with the item index as data
683        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); // No hit_id set
696        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        // No hit region should be registered
703        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); // No hit grid
713        let mut state = ListState::default();
714        StatefulWidget::render(&list, area, &mut frame, &mut state);
715
716        // hit_test returns None when no hit grid
717        assert!(frame.hit_test(5, 0).is_none());
718    }
719
720    // --- MeasurableWidget tests ---
721
722    use crate::MeasurableWidget;
723    use ftui_core::geometry::Size;
724
725    #[test]
726    fn list_item_measure_simple() {
727        let item = ListItem::new("Hello"); // 5 chars
728        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("•"); // • + space + Hi = 1 + 1 + 2 = 4
738        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")]; // 5 chars, 1 line
762        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"),      // 5 chars
773            ListItem::new("LongerItem"), // 10 chars
774            ListItem::new("Tiny"),       // 4 chars
775        ];
776        let list = List::new(items);
777        let constraints = list.measure(Size::MAX);
778
779        // Width is max of all items = 10
780        assert_eq!(constraints.preferred.width, 10);
781        // Height is sum of all items = 3
782        assert_eq!(constraints.preferred.height, 3);
783    }
784
785    #[test]
786    fn list_measure_with_block() {
787        let block = crate::block::Block::bordered(); // 2x2 chrome
788        let items = vec![ListItem::new("Hi")]; // 2 chars, 1 line
789        let list = List::new(items).block(block);
790        let constraints = list.measure(Size::MAX);
791
792        // 2 (text) + 2 (chrome) = 4 width
793        // 1 (line) + 2 (chrome) = 3 height
794        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")]; // 4 chars
800        let list = List::new(items).highlight_symbol(">"); // 1 char + space = 2
801
802        let constraints = list.measure(Size::MAX);
803
804        // 4 (text) + 2 (symbol + space) = 6
805        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        // Min height should be 1 (can scroll to see rest)
824        assert_eq!(constraints.min.height, 1);
825        // Preferred height is all items
826        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    // --- Undo Support tests ---
839
840    #[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        // Modify state
864        state.select(Some(10));
865        state.offset = 8;
866        assert_eq!(state.selected(), Some(10));
867        assert_eq!(state.offset, 8);
868
869        // Restore
870        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); // reset on deselect
886    }
887
888    // --- Stateful Persistence tests ---
889
890    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        // Reset state
915        state.select(None);
916        assert_eq!(state.selected, None);
917        assert_eq!(state.offset, 0);
918
919        // Restore
920        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}