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