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::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/// A single item in a list.
20#[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    /// Create a new list item with the given content.
29    #[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    /// Set the style for this list item.
39    #[must_use]
40    pub fn style(mut self, style: Style) -> Self {
41        self.style = style;
42        self
43    }
44
45    /// Set a prefix marker string for this item.
46    #[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/// A widget to display a list of items.
60#[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    /// Optional hit ID for mouse interaction.
69    /// When set, each list item registers a hit region with the hit grid.
70    hit_id: Option<HitId>,
71}
72
73impl<'a> List<'a> {
74    /// Create a new list from the given items.
75    #[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    /// Wrap the list in a decorative block.
89    #[must_use]
90    pub fn block(mut self, block: Block<'a>) -> Self {
91        self.block = Some(block);
92        self
93    }
94
95    /// Set the base style for the list area.
96    #[must_use]
97    pub fn style(mut self, style: Style) -> Self {
98        self.style = style;
99        self
100    }
101
102    /// Set the style applied to the selected item.
103    #[must_use]
104    pub fn highlight_style(mut self, style: Style) -> Self {
105        self.highlight_style = style;
106        self
107    }
108
109    /// Set the style applied to the hovered item (mouse move).
110    #[must_use]
111    pub fn hover_style(mut self, style: Style) -> Self {
112        self.hover_style = style;
113        self
114    }
115
116    /// Set a symbol displayed before the selected item.
117    #[must_use]
118    pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
119        self.highlight_symbol = Some(symbol);
120        self
121    }
122
123    /// Set a hit ID for mouse interaction.
124    ///
125    /// When set, each list item will register a hit region with the frame's
126    /// hit grid (if enabled). The hit data will be the item's index, allowing
127    /// click handlers to determine which item was clicked.
128    #[must_use]
129    pub fn hit_id(mut self, id: HitId) -> Self {
130        self.hit_id = Some(id);
131        self
132    }
133}
134
135/// Mutable state for a [`List`] widget tracking selection and scroll offset.
136#[derive(Debug, Clone, Default)]
137pub struct ListState {
138    /// Unique ID for undo tracking.
139    undo_id: UndoWidgetId,
140    /// Index of the currently selected item, if any.
141    pub selected: Option<usize>,
142    /// Index of the currently hovered item, if any.
143    pub hovered: Option<usize>,
144    /// Scroll offset (first visible item index).
145    pub offset: usize,
146    /// Optional persistence ID for state saving/restoration.
147    persistence_id: Option<String>,
148}
149
150impl ListState {
151    /// Set the selected item index, or `None` to deselect.
152    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    /// Return the currently selected item index.
160    #[inline]
161    #[must_use = "use the selected index (if any)"]
162    pub fn selected(&self) -> Option<usize> {
163        self.selected
164    }
165
166    /// Create a new ListState with a persistence ID for state saving.
167    #[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    /// Get the persistence ID, if set.
174    #[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    /// Handle a mouse event for this list.
181    ///
182    /// # Hit data convention
183    ///
184    /// The hit data (`u64`) encodes the item index. When the list renders with
185    /// a `hit_id`, each visible row registers `HitRegion::Content` with
186    /// `data = item_index as u64`.
187    ///
188    /// # Arguments
189    ///
190    /// * `event` — the mouse event from the terminal
191    /// * `hit` — result of `frame.hit_test(event.x, event.y)`, if available
192    /// * `expected_id` — the `HitId` this list was rendered with
193    /// * `item_count` — total number of items in the list
194    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                        // Deterministic "double click": second click on the already-selected row activates.
209                        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                // Mouse moved off the widget or to non-content region.
235                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    /// Scroll the list up by the given number of lines.
255    pub fn scroll_up(&mut self, lines: usize) {
256        self.offset = self.offset.saturating_sub(lines);
257    }
258
259    /// Scroll the list down by the given number of lines.
260    ///
261    /// Clamps so that the last item can still appear at the top of the viewport.
262    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    /// Move selection to the next item.
270    ///
271    /// If nothing is selected, selects the first item. Clamps to the last item.
272    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    /// Move selection to the previous item.
284    ///
285    /// If nothing is selected, selects the first item. Clamps to 0.
286    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// ============================================================================
296// Stateful Persistence Implementation
297// ============================================================================
298
299/// Persistable state for a [`ListState`].
300///
301/// Contains the user-facing state that should survive sessions.
302#[derive(Clone, Debug, Default, PartialEq)]
303#[cfg_attr(
304    feature = "state-persistence",
305    derive(serde::Serialize, serde::Deserialize)
306)]
307pub struct ListPersistState {
308    /// Selected item index.
309    pub selected: Option<usize>,
310    /// Scroll offset (first visible item).
311    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        // Apply base style
363        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        // Clamp offset so we don't render past the end, and so the viewport stays filled
375        // when height increases while scrolled near the bottom.
376        let max_offset = self.items.len().saturating_sub(list_height.max(1));
377        state.offset = state.offset.min(max_offset);
378
379        // Ensure selection is within bounds
380        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        // Ensure visible range includes selected item
394        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        // Iterate over visible items
403        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            // Determine style: merge highlight on top of item style so
418            // unset highlight properties inherit from the item.
419            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            // Apply item background style to the whole row
429            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            // Determine symbol
433            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            // Draw symbol if present
442            if !symbol.is_empty() {
443                x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
444                // Add a space after symbol
445                x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
446            }
447
448            // Draw content
449            // Note: List items are currently single-line for simplicity in v1
450            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            // Register hit region for this item (if hit testing enabled)
472            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        // ListItem is a single line of text with optional marker
489        let marker_width = display_width(self.marker) as u16;
490        let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
491
492        // Get text width from the first line (List currently renders only first line)
493        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        // ListItem is always 1 line tall
506        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        // Get block chrome if present
517        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            // Empty list: just the chrome
525            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        // Calculate inner available space
533        let inner_available = Size::new(
534            available.width.saturating_sub(chrome_width),
535            available.height.saturating_sub(chrome_height),
536        );
537
538        // Measure all items
539        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        // Add highlight symbol width if present
549        if let Some(symbol) = self.highlight_symbol {
550            let symbol_width = display_width(symbol) as u16 + 1; // +1 for space
551            max_width = max_width.saturating_add(symbol_width);
552        }
553
554        // Add chrome
555        let preferred_width = max_width.saturating_add(chrome_width);
556        let preferred_height = total_height.saturating_add(chrome_height);
557
558        // Minimum is chrome + 1 item height (can scroll)
559        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, // Lists can scroll, so no max
565        }
566    }
567
568    fn has_intrinsic_size(&self) -> bool {
569        !self.items.is_empty()
570    }
571}
572
573// ============================================================================
574// Undo Support Implementation
575// ============================================================================
576
577/// Snapshot of ListState for undo.
578#[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    /// Get the undo widget ID.
623    ///
624    /// This can be used to associate undo commands with this state instance.
625    #[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        // offset should have been adjusted so item 5 is visible
706        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)); // out of bounds
719
720        StatefulWidget::render(&list, area, &mut frame, &mut state);
721        // should clamp to last item
722        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        // First item should have ">" symbol
737        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        // Marker should be rendered at the start
774        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); // select doesn't reset offset
786
787        state.select(None);
788        assert_eq!(state.offset, 0); // deselect resets offset
789    }
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        // First scroll down
803        state.select(Some(8));
804        StatefulWidget::render(&list, area, &mut frame, &mut state);
805        assert!(state.offset > 0);
806
807        // Now select item 0 - should scroll back up
808        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        // Small viewport: show 7, 8, 9.
827        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        // Larger viewport: offset should pull back to fill the viewport (5..9).
835        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        // Only first 3 should render
854        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        // Using Widget trait (not StatefulWidget)
867        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        // Each row should have a hit region with the item index as data
882        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); // No hit_id set
895        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        // No hit region should be registered
902        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); // No hit grid
912        let mut state = ListState::default();
913        StatefulWidget::render(&list, area, &mut frame, &mut state);
914
915        // hit_test returns None when no hit grid
916        assert!(frame.hit_test(5, 0).is_none());
917    }
918
919    // --- MeasurableWidget tests ---
920
921    use crate::MeasurableWidget;
922    use ftui_core::geometry::Size;
923
924    #[test]
925    fn list_item_measure_simple() {
926        let item = ListItem::new("Hello"); // 5 chars
927        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("•"); // • + space + Hi = 1 + 1 + 2 = 4
937        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")]; // 5 chars, 1 line
961        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"),      // 5 chars
972            ListItem::new("LongerItem"), // 10 chars
973            ListItem::new("Tiny"),       // 4 chars
974        ];
975        let list = List::new(items);
976        let constraints = list.measure(Size::MAX);
977
978        // Width is max of all items = 10
979        assert_eq!(constraints.preferred.width, 10);
980        // Height is sum of all items = 3
981        assert_eq!(constraints.preferred.height, 3);
982    }
983
984    #[test]
985    fn list_measure_with_block() {
986        let block = crate::block::Block::bordered(); // 2x2 chrome
987        let items = vec![ListItem::new("Hi")]; // 2 chars, 1 line
988        let list = List::new(items).block(block);
989        let constraints = list.measure(Size::MAX);
990
991        // 2 (text) + 2 (chrome) = 4 width
992        // 1 (line) + 2 (chrome) = 3 height
993        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")]; // 4 chars
999        let list = List::new(items).highlight_symbol(">"); // 1 char + space = 2
1000
1001        let constraints = list.measure(Size::MAX);
1002
1003        // 4 (text) + 2 (symbol + space) = 6
1004        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        // Min height should be 1 (can scroll to see rest)
1023        assert_eq!(constraints.min.height, 1);
1024        // Preferred height is all items
1025        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    // --- Undo Support tests ---
1038
1039    #[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        // Modify state
1063        state.select(Some(10));
1064        state.offset = 8;
1065        assert_eq!(state.selected(), Some(10));
1066        assert_eq!(state.offset, 8);
1067
1068        // Restore
1069        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); // reset on deselect
1085    }
1086
1087    // --- Stateful Persistence tests ---
1088
1089    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        // Reset state
1114        state.select(None);
1115        assert_eq!(state.selected, None);
1116        assert_eq!(state.offset, 0);
1117
1118        // Restore
1119        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    // --- Mouse handling tests ---
1148
1149    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); // item_count - 1
1228    }
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)); // already at last
1268    }
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); // no items, no change
1275    }
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)); // already at first
1291    }
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        // No hit (mouse moved off the list)
1365        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}