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::{
13    StatefulWidget, Widget, clear_text_area, clear_text_row, draw_text_span,
14    draw_text_span_with_link,
15};
16use ftui_core::event::{KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
17use ftui_core::geometry::{Rect, Size};
18use ftui_render::frame::{Frame, HitId, HitRegion};
19use ftui_style::Style;
20use ftui_text::{Line, Span, Text as FtuiText, display_width};
21use std::collections::BTreeSet;
22#[cfg(feature = "tracing")]
23use web_time::Instant;
24
25type Text = FtuiText<'static>;
26
27fn text_into_owned(text: FtuiText<'_>) -> FtuiText<'static> {
28    FtuiText::from_lines(
29        text.into_iter()
30            .map(|line| Line::from_spans(line.into_iter().map(Span::into_owned))),
31    )
32}
33
34/// A single item in a list.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ListItem<'a> {
37    content: Text,
38    style: Style,
39    marker: &'a str,
40}
41
42impl<'a> ListItem<'a> {
43    /// Create a new list item with the given content.
44    #[must_use]
45    pub fn new<'t>(content: impl Into<FtuiText<'t>>) -> Self {
46        Self {
47            content: text_into_owned(content.into()),
48            style: Style::default(),
49            marker: "",
50        }
51    }
52
53    /// Set the style for this list item.
54    #[must_use]
55    pub fn style(mut self, style: Style) -> Self {
56        self.style = style;
57        self
58    }
59
60    /// Set a prefix marker string for this item.
61    #[must_use]
62    pub fn marker(mut self, marker: &'a str) -> Self {
63        self.marker = marker;
64        self
65    }
66}
67
68impl<'a> From<&'a str> for ListItem<'a> {
69    fn from(s: &'a str) -> Self {
70        Self::new(s)
71    }
72}
73
74/// A widget to display a list of items.
75#[derive(Debug, Clone, Default)]
76pub struct List<'a> {
77    block: Option<Block<'a>>,
78    items: Vec<ListItem<'a>>,
79    style: Style,
80    highlight_style: Style,
81    hover_style: Style,
82    highlight_symbol: Option<&'a str>,
83    /// Optional hit ID for mouse interaction.
84    /// When set, each list item registers a hit region with the hit grid.
85    hit_id: Option<HitId>,
86    /// Optional data hash to enable caching of filtered indices.
87    data_hash: Option<u64>,
88}
89
90impl<'a> List<'a> {
91    /// Create a new list from the given items.
92    #[must_use]
93    pub fn new(items: impl IntoIterator<Item = impl Into<ListItem<'a>>>) -> Self {
94        Self {
95            block: None,
96            items: items.into_iter().map(|i| i.into()).collect(),
97            style: Style::default(),
98            highlight_style: Style::default(),
99            hover_style: Style::default(),
100            highlight_symbol: None,
101            hit_id: None,
102            data_hash: None,
103        }
104    }
105
106    /// Set an explicit data hash to enable caching of filtered indices.
107    ///
108    /// This is highly recommended for large lists. When provided, the list widget
109    /// will cache the result of filtering in the `ListState`, skipping expensive
110    /// O(N) string processing on frames where the hash and filter query have not changed.
111    #[must_use]
112    pub fn data_hash(mut self, hash: u64) -> Self {
113        self.data_hash = Some(hash);
114        self
115    }
116
117    /// Wrap the list in a decorative block.
118    #[must_use]
119    pub fn block(mut self, block: Block<'a>) -> Self {
120        self.block = Some(block);
121        self
122    }
123
124    /// Set the base style for the list area.
125    #[must_use]
126    pub fn style(mut self, style: Style) -> Self {
127        self.style = style;
128        self
129    }
130
131    /// Set the style applied to the selected item.
132    #[must_use]
133    pub fn highlight_style(mut self, style: Style) -> Self {
134        self.highlight_style = style;
135        self
136    }
137
138    /// Set the style applied to the hovered item (mouse move).
139    #[must_use]
140    pub fn hover_style(mut self, style: Style) -> Self {
141        self.hover_style = style;
142        self
143    }
144
145    /// Set a symbol displayed before the selected item.
146    #[must_use]
147    pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
148        self.highlight_symbol = Some(symbol);
149        self
150    }
151
152    /// Set a hit ID for mouse interaction.
153    ///
154    /// When set, each list item will register a hit region with the frame's
155    /// hit grid (if enabled). The hit data will be the item's index, allowing
156    /// click handlers to determine which item was clicked.
157    #[must_use]
158    pub fn hit_id(mut self, id: HitId) -> Self {
159        self.hit_id = Some(id);
160        self
161    }
162
163    fn filtered_indices(&self, state: &mut ListState) -> std::sync::Arc<[usize]> {
164        let query_str = state.filter_query();
165
166        if let Some(hash) = self.data_hash
167            && let Some((cached_hash, ref cached_query, ref indices)) = state.cached_display_indices
168            && cached_hash == hash
169            && cached_query == query_str
170        {
171            return std::sync::Arc::clone(indices);
172        }
173
174        let query = query_str.trim();
175        let indices: Vec<usize> = if query.is_empty() {
176            (0..self.items.len()).collect()
177        } else {
178            let query_lower = query.to_lowercase();
179            self.items
180                .iter()
181                .enumerate()
182                .filter_map(|(idx, item)| {
183                    // Optimization: check single-span content directly to avoid allocation
184                    // from to_plain_text().
185                    let line_text_cow;
186                    let line_text_ref = if let Some(line) = item.content.lines().first() {
187                        if line.spans().len() == 1 {
188                            &line.spans()[0].content
189                        } else {
190                            line_text_cow = std::borrow::Cow::Owned(line.to_plain_text());
191                            &line_text_cow
192                        }
193                    } else {
194                        ""
195                    };
196
197                    let marker_matches = !item.marker.is_empty()
198                        && crate::contains_ignore_case(item.marker, &query_lower);
199                    if marker_matches || crate::contains_ignore_case(line_text_ref, &query_lower) {
200                        Some(idx)
201                    } else {
202                        None
203                    }
204                })
205                .collect()
206        };
207
208        let arc_indices: std::sync::Arc<[usize]> = indices.into();
209
210        if let Some(hash) = self.data_hash {
211            state.cached_display_indices = Some((
212                hash,
213                query_str.to_string(),
214                std::sync::Arc::clone(&arc_indices),
215            ));
216        }
217
218        arc_indices
219    }
220
221    fn apply_filtered_selection_guard(
222        &self,
223        state: &mut ListState,
224        filtered: &[usize],
225        force_select_first: bool,
226    ) {
227        if filtered.is_empty() {
228            state.selected = None;
229            state.hovered = None;
230            state.offset = 0;
231            state.multi_selected.clear();
232            return;
233        }
234
235        if let Some(selected) = state.selected {
236            if filtered.binary_search(&selected).is_err() {
237                state.selected = filtered.first().copied();
238            }
239        } else if force_select_first {
240            state.selected = filtered.first().copied();
241        }
242
243        state
244            .multi_selected
245            .retain(|idx| filtered.binary_search(idx).is_ok());
246    }
247
248    fn move_selection_in_filtered(
249        &self,
250        state: &mut ListState,
251        filtered: &[usize],
252        direction: isize,
253    ) -> bool {
254        if filtered.is_empty() {
255            if state.selected.is_some() {
256                state.select(None);
257                return true;
258            }
259            return false;
260        }
261
262        let max_pos = filtered.len().saturating_sub(1) as isize;
263
264        let next_pos = if let Some(selected) = state.selected {
265            // `filtered` is sorted by index (ascending), so we can use binary search
266            // for O(log N) lookup instead of O(N) linear scan.
267            let current_pos = filtered.binary_search(&selected).unwrap_or_else(|pos| pos);
268            (current_pos as isize + direction).clamp(0, max_pos) as usize
269        } else if direction > 0 {
270            0
271        } else {
272            max_pos as usize
273        };
274
275        let next_index = filtered[next_pos];
276
277        if state.selected == Some(next_index) {
278            return false;
279        }
280
281        state.selected = Some(next_index);
282        if !state.multi_select_enabled {
283            state.multi_selected.clear();
284            state.multi_selected.insert(next_index);
285        }
286        state.scroll_into_view_requested = true;
287        #[cfg(feature = "tracing")]
288        state.log_selection_change("keyboard_move");
289        true
290    }
291
292    /// Handle keyboard navigation and incremental filtering for this list.
293    ///
294    /// Supported keys:
295    /// - Navigation: `Up`/`Down`, `k`/`j` (vi-style, always navigate — never
296    ///   appended to the filter query)
297    /// - Incremental filter input: printable chars (except `j`/`k`)
298    /// - Filter editing: `Backspace`, `Escape`
299    /// - Multi-select toggle (when enabled): `Space`
300    pub fn handle_key(&self, state: &mut ListState, key: &KeyEvent) -> bool {
301        let nav_modifiers = key
302            .modifiers
303            .intersects(Modifiers::CTRL | Modifiers::ALT | Modifiers::SUPER);
304
305        match key.code {
306            KeyCode::Up if !nav_modifiers => {
307                let filtered = self.filtered_indices(state);
308                self.move_selection_in_filtered(state, &filtered, -1)
309            }
310            KeyCode::Down if !nav_modifiers => {
311                let filtered = self.filtered_indices(state);
312                self.move_selection_in_filtered(state, &filtered, 1)
313            }
314            // j/k are always vi-style navigation, even when a filter is active.
315            KeyCode::Char('k') if !nav_modifiers => {
316                let filtered = self.filtered_indices(state);
317                self.move_selection_in_filtered(state, &filtered, -1)
318            }
319            KeyCode::Char('j') if !nav_modifiers => {
320                let filtered = self.filtered_indices(state);
321                self.move_selection_in_filtered(state, &filtered, 1)
322            }
323            KeyCode::Char(' ') if state.multi_select_enabled() => {
324                if let Some(selected) = state.selected {
325                    state.toggle_multi_selected(selected);
326                    true
327                } else {
328                    false
329                }
330            }
331            KeyCode::Backspace => {
332                if state.filter_query.is_empty() {
333                    return false;
334                }
335                state.filter_query.pop();
336                state.offset = 0;
337                state.scroll_into_view_requested = true;
338                let filtered = self.filtered_indices(state);
339                self.apply_filtered_selection_guard(state, &filtered, true);
340                #[cfg(feature = "tracing")]
341                state.log_selection_change("filter_backspace");
342                true
343            }
344            KeyCode::Escape => {
345                if state.filter_query.is_empty() {
346                    return false;
347                }
348                state.filter_query.clear();
349                state.offset = 0;
350                state.scroll_into_view_requested = true;
351                let filtered = self.filtered_indices(state);
352                self.apply_filtered_selection_guard(state, &filtered, false);
353                #[cfg(feature = "tracing")]
354                state.log_selection_change("filter_clear");
355                true
356            }
357            KeyCode::Char(ch)
358                if !ch.is_control() && !key.ctrl() && !key.alt() && !key.super_key() =>
359            {
360                // Preserve uppercase input when Shift is held.
361                state.filter_query.push(ch);
362                state.offset = 0;
363                state.scroll_into_view_requested = true;
364                let filtered = self.filtered_indices(state);
365                self.apply_filtered_selection_guard(state, &filtered, true);
366                #[cfg(feature = "tracing")]
367                state.log_selection_change("filter_append");
368                true
369            }
370            _ => false,
371        }
372    }
373}
374
375/// Mutable state for a [`List`] widget tracking selection and scroll offset.
376#[derive(Debug, Clone)]
377pub struct ListState {
378    /// Unique ID for undo tracking.
379    undo_id: UndoWidgetId,
380    /// Index of the currently selected item, if any.
381    pub selected: Option<usize>,
382    /// Index of the currently hovered item, if any.
383    pub hovered: Option<usize>,
384    /// Scroll offset (first visible item index).
385    pub offset: usize,
386    /// Optional persistence ID for state saving/restoration.
387    persistence_id: Option<String>,
388    /// Whether to force the selected item into view on next render.
389    scroll_into_view_requested: bool,
390    /// Incremental filter query applied to items (case-insensitive).
391    filter_query: String,
392    /// Whether multi-select behavior is enabled.
393    multi_select_enabled: bool,
394    /// Set of selected indices when multi-select is enabled.
395    multi_selected: BTreeSet<usize>,
396    /// Cached display indices (data_hash, filter_query, indices)
397    #[doc(hidden)]
398    pub cached_display_indices: Option<(u64, String, std::sync::Arc<[usize]>)>,
399}
400
401impl Default for ListState {
402    fn default() -> Self {
403        Self {
404            undo_id: UndoWidgetId::default(),
405            selected: None,
406            hovered: None,
407            offset: 0,
408            persistence_id: None,
409            scroll_into_view_requested: true,
410            filter_query: String::new(),
411            multi_select_enabled: false,
412            multi_selected: BTreeSet::new(),
413            cached_display_indices: None,
414        }
415    }
416}
417
418impl ListState {
419    /// Set the selected item index, or `None` to deselect.
420    pub fn select(&mut self, index: Option<usize>) {
421        self.selected = index;
422        if index.is_none() {
423            self.offset = 0;
424            self.multi_selected.clear();
425        } else if !self.multi_select_enabled
426            && let Some(selected) = index
427        {
428            self.multi_selected.clear();
429            self.multi_selected.insert(selected);
430        }
431        self.scroll_into_view_requested = true;
432        #[cfg(feature = "tracing")]
433        self.log_selection_change("select");
434    }
435
436    /// Return the currently selected item index.
437    #[inline]
438    #[must_use = "use the selected index (if any)"]
439    pub fn selected(&self) -> Option<usize> {
440        self.selected
441    }
442
443    /// Create a new ListState with a persistence ID for state saving.
444    #[must_use]
445    pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
446        self.persistence_id = Some(id.into());
447        self
448    }
449
450    /// Get the persistence ID, if set.
451    #[inline]
452    #[must_use = "use the persistence id (if any)"]
453    pub fn persistence_id(&self) -> Option<&str> {
454        self.persistence_id.as_deref()
455    }
456
457    /// Enable or disable multi-select mode.
458    pub fn set_multi_select(&mut self, enabled: bool) {
459        if self.multi_select_enabled == enabled {
460            return;
461        }
462        self.multi_select_enabled = enabled;
463        if !enabled {
464            self.multi_selected.clear();
465            if let Some(selected) = self.selected {
466                self.multi_selected.insert(selected);
467            }
468        }
469    }
470
471    /// Whether multi-select mode is enabled.
472    #[must_use]
473    pub const fn multi_select_enabled(&self) -> bool {
474        self.multi_select_enabled
475    }
476
477    /// Current incremental filter query.
478    #[must_use]
479    pub fn filter_query(&self) -> &str {
480        &self.filter_query
481    }
482
483    /// Replace the incremental filter query.
484    pub fn set_filter_query(&mut self, query: impl Into<String>) {
485        self.filter_query = query.into();
486        self.offset = 0;
487        self.scroll_into_view_requested = true;
488    }
489
490    /// Clear the current filter query.
491    pub fn clear_filter_query(&mut self) {
492        if !self.filter_query.is_empty() {
493            self.filter_query.clear();
494            self.offset = 0;
495            self.scroll_into_view_requested = true;
496        }
497    }
498
499    /// Number of selected rows (single or multi mode).
500    #[must_use]
501    pub fn selected_count(&self) -> usize {
502        if self.multi_select_enabled {
503            self.multi_selected.len()
504        } else {
505            usize::from(self.selected.is_some())
506        }
507    }
508
509    /// Selected indices in multi-select mode.
510    #[must_use]
511    pub fn selected_indices(&self) -> &BTreeSet<usize> {
512        &self.multi_selected
513    }
514
515    fn toggle_multi_selected(&mut self, index: usize) {
516        if !self.multi_select_enabled {
517            self.select(Some(index));
518            return;
519        }
520        if !self.multi_selected.insert(index) {
521            self.multi_selected.remove(&index);
522        }
523        self.selected = Some(index);
524        self.scroll_into_view_requested = true;
525        #[cfg(feature = "tracing")]
526        self.log_selection_change("toggle_multi");
527    }
528
529    #[cfg(feature = "tracing")]
530    fn log_selection_change(&self, action: &str) {
531        tracing::debug!(
532            message = "list.selection",
533            action,
534            selected = self.selected,
535            selected_count = self.selected_count(),
536            filter_active = !self.filter_query.trim().is_empty()
537        );
538    }
539
540    /// Handle a mouse event for this list.
541    ///
542    /// # Hit data convention
543    ///
544    /// The hit data (`u64`) encodes the item index. When the list renders with
545    /// a `hit_id`, each visible row registers `HitRegion::Content` with
546    /// `data = item_index as u64`.
547    ///
548    /// # Arguments
549    ///
550    /// * `event` — the mouse event from the terminal
551    /// * `hit` — result of `frame.hit_test(event.x, event.y)`, if available
552    /// * `expected_id` — the `HitId` this list was rendered with
553    /// * `item_count` — total number of items in the list
554    pub fn handle_mouse(
555        &mut self,
556        event: &MouseEvent,
557        hit: Option<(HitId, HitRegion, u64)>,
558        expected_id: HitId,
559        item_count: usize,
560    ) -> MouseResult {
561        match event.kind {
562            MouseEventKind::Down(MouseButton::Left) => {
563                if let Some((id, HitRegion::Content, data)) = hit
564                    && id == expected_id
565                {
566                    let index = data as usize;
567                    if index < item_count {
568                        if self.multi_select_enabled && event.modifiers.contains(Modifiers::CTRL) {
569                            self.toggle_multi_selected(index);
570                            return MouseResult::Selected(index);
571                        }
572                        if self.multi_select_enabled {
573                            self.multi_selected.clear();
574                            self.multi_selected.insert(index);
575                        }
576                        // Deterministic "double click": second click on the already-selected row activates.
577                        if !self.multi_select_enabled && self.selected == Some(index) {
578                            #[cfg(feature = "tracing")]
579                            self.log_selection_change("activate");
580                            return MouseResult::Activated(index);
581                        }
582                        self.select(Some(index));
583                        return MouseResult::Selected(index);
584                    }
585                }
586                MouseResult::Ignored
587            }
588            MouseEventKind::Moved => {
589                if let Some((id, HitRegion::Content, data)) = hit
590                    && id == expected_id
591                {
592                    let index = data as usize;
593                    if index < item_count {
594                        let changed = self.hovered != Some(index);
595                        self.hovered = Some(index);
596                        return if changed {
597                            MouseResult::HoverChanged
598                        } else {
599                            MouseResult::Ignored
600                        };
601                    }
602                }
603
604                // Mouse moved off the widget or to non-content region.
605                if self.hovered.is_some() {
606                    self.hovered = None;
607                    MouseResult::HoverChanged
608                } else {
609                    MouseResult::Ignored
610                }
611            }
612            MouseEventKind::ScrollUp => {
613                self.scroll_up(3);
614                MouseResult::Scrolled
615            }
616            MouseEventKind::ScrollDown => {
617                self.scroll_down(3, item_count);
618                MouseResult::Scrolled
619            }
620            _ => MouseResult::Ignored,
621        }
622    }
623
624    /// Scroll the list up by the given number of lines.
625    pub fn scroll_up(&mut self, lines: usize) {
626        self.offset = self.offset.saturating_sub(lines);
627    }
628
629    /// Scroll the list down by the given number of lines.
630    ///
631    /// Clamps so that the last item can still appear at the top of the viewport.
632    pub fn scroll_down(&mut self, lines: usize, item_count: usize) {
633        self.offset = self
634            .offset
635            .saturating_add(lines)
636            .min(item_count.saturating_sub(1));
637    }
638
639    /// Move selection to the next item.
640    ///
641    /// If nothing is selected, selects the first item. Clamps to the last item.
642    pub fn select_next(&mut self, item_count: usize) {
643        if item_count == 0 {
644            return;
645        }
646        let next = match self.selected {
647            Some(i) => (i + 1).min(item_count.saturating_sub(1)),
648            None => 0,
649        };
650        self.selected = Some(next);
651        if !self.multi_select_enabled {
652            self.multi_selected.clear();
653            self.multi_selected.insert(next);
654        }
655        self.scroll_into_view_requested = true;
656        #[cfg(feature = "tracing")]
657        self.log_selection_change("select_next");
658    }
659
660    /// Move selection to the previous item.
661    ///
662    /// If nothing is selected, selects the first item. Clamps to 0.
663    pub fn select_previous(&mut self) {
664        let prev = match self.selected {
665            Some(i) => i.saturating_sub(1),
666            None => 0,
667        };
668        self.selected = Some(prev);
669        if !self.multi_select_enabled {
670            self.multi_selected.clear();
671            self.multi_selected.insert(prev);
672        }
673        self.scroll_into_view_requested = true;
674        #[cfg(feature = "tracing")]
675        self.log_selection_change("select_previous");
676    }
677}
678
679// ============================================================================
680// Stateful Persistence Implementation
681// ============================================================================
682
683/// Persistable state for a [`ListState`].
684///
685/// Contains the user-facing state that should survive sessions.
686#[derive(Clone, Debug, Default, PartialEq)]
687#[cfg_attr(
688    feature = "state-persistence",
689    derive(serde::Serialize, serde::Deserialize)
690)]
691pub struct ListPersistState {
692    /// Selected item index.
693    pub selected: Option<usize>,
694    /// Scroll offset (first visible item).
695    pub offset: usize,
696    /// Incremental filter query.
697    pub filter_query: String,
698    /// Whether multi-select mode was enabled.
699    pub multi_select_enabled: bool,
700    /// Multi-selected indices when multi-select mode is enabled.
701    pub multi_selected: Vec<usize>,
702}
703
704impl Stateful for ListState {
705    type State = ListPersistState;
706
707    fn state_key(&self) -> StateKey {
708        StateKey::new("List", self.persistence_id.as_deref().unwrap_or("default"))
709    }
710
711    fn save_state(&self) -> ListPersistState {
712        ListPersistState {
713            selected: self.selected,
714            offset: self.offset,
715            filter_query: self.filter_query.clone(),
716            multi_select_enabled: self.multi_select_enabled,
717            multi_selected: self.multi_selected.iter().copied().collect(),
718        }
719    }
720
721    fn restore_state(&mut self, state: ListPersistState) {
722        self.selected = state.selected;
723        self.hovered = None;
724        self.offset = state.offset;
725        self.filter_query = state.filter_query;
726        self.multi_select_enabled = state.multi_select_enabled;
727        self.multi_selected = state.multi_selected.into_iter().collect();
728    }
729}
730
731impl<'a> StatefulWidget for List<'a> {
732    type State = ListState;
733
734    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
735        #[cfg(feature = "tracing")]
736        let _widget_span = tracing::debug_span!(
737            "widget_render",
738            widget = "List",
739            x = area.x,
740            y = area.y,
741            w = area.width,
742            h = area.height
743        )
744        .entered();
745
746        #[cfg(feature = "tracing")]
747        let render_start = Instant::now();
748        #[cfg(feature = "tracing")]
749        let total_items = self.items.len();
750        let filter_active = !state.filter_query.trim().is_empty();
751        #[cfg(feature = "tracing")]
752        let selected_count = state.selected_count();
753        #[cfg(feature = "tracing")]
754        let render_span = tracing::debug_span!(
755            "list.render",
756            total_items,
757            visible_items = tracing::field::Empty,
758            selected_count,
759            filter_active,
760            render_duration_us = tracing::field::Empty
761        );
762        #[cfg(feature = "tracing")]
763        let _render_guard = render_span.enter();
764
765        let list_area = match &self.block {
766            Some(b) => {
767                b.render(area, frame);
768                b.inner(area)
769            }
770            None => area,
771        };
772
773        let mut rendered_visible_items = 0usize;
774
775        if !list_area.is_empty() {
776            // Clear the owned list area so shorter rows and empty states do not
777            // leak stale content from prior renders.
778            clear_text_area(frame, list_area, self.style);
779
780            if self.items.is_empty() {
781                state.selected = None;
782                state.hovered = None;
783                state.offset = 0;
784                state.multi_selected.clear();
785                draw_text_span(
786                    frame,
787                    list_area.x,
788                    list_area.y,
789                    "No items",
790                    self.style,
791                    list_area.right(),
792                );
793            } else {
794                // Clamp selection/hover to item bounds before applying filters.
795                if let Some(selected) = state.selected
796                    && selected >= self.items.len()
797                {
798                    state.selected = Some(self.items.len().saturating_sub(1));
799                }
800                if let Some(hovered) = state.hovered
801                    && hovered >= self.items.len()
802                {
803                    state.hovered = None;
804                }
805
806                let filtered_indices = self.filtered_indices(state);
807                self.apply_filtered_selection_guard(state, &filtered_indices, filter_active);
808
809                if filtered_indices.is_empty() {
810                    draw_text_span(
811                        frame,
812                        list_area.x,
813                        list_area.y,
814                        "No matches",
815                        self.style,
816                        list_area.right(),
817                    );
818                } else {
819                    let list_height = list_area.height as usize;
820                    let max_offset = filtered_indices.len().saturating_sub(list_height.max(1));
821                    state.offset = state.offset.min(max_offset);
822
823                    if let Some(hovered) = state.hovered
824                        && filtered_indices.binary_search(&hovered).is_err()
825                    {
826                        state.hovered = None;
827                    }
828
829                    // Ensure visible range includes selected item.
830                    if state.scroll_into_view_requested {
831                        if let Some(selected) = state.selected
832                            && let Some(selected_pos) =
833                                filtered_indices.binary_search(&selected).ok()
834                        {
835                            if selected_pos >= state.offset + list_height {
836                                state.offset = selected_pos - list_height + 1;
837                            } else if selected_pos < state.offset {
838                                state.offset = selected_pos;
839                            }
840                        }
841                        state.scroll_into_view_requested = false;
842                    }
843
844                    for (row, item_index) in filtered_indices
845                        .iter()
846                        .skip(state.offset)
847                        .take(list_height)
848                        .enumerate()
849                    {
850                        let i = *item_index;
851                        let item = &self.items[i];
852                        let y = list_area.y.saturating_add(row as u16);
853                        if y >= list_area.bottom() {
854                            break;
855                        }
856                        let is_selected = state.selected == Some(i)
857                            || (state.multi_select_enabled && state.multi_selected.contains(&i));
858                        let is_hovered = state.hovered == Some(i);
859
860                        // Determine style: merge highlight on top of item style so
861                        // unset highlight properties inherit from the item.
862                        let mut item_style = if is_hovered {
863                            self.hover_style.merge(&item.style)
864                        } else {
865                            item.style
866                        };
867                        if is_selected {
868                            item_style = self.highlight_style.merge(&item_style);
869                        }
870
871                        // Apply item background style to the whole row
872                        let row_area = Rect::new(list_area.x, y, list_area.width, 1);
873                        clear_text_row(frame, row_area, item_style);
874
875                        // Determine symbol
876                        let symbol = if is_selected {
877                            self.highlight_symbol.unwrap_or(item.marker)
878                        } else {
879                            item.marker
880                        };
881
882                        let mut x = list_area.x;
883
884                        // Draw symbol if present
885                        if !symbol.is_empty() {
886                            x = draw_text_span(frame, x, y, symbol, item_style, list_area.right());
887                            // Add a space after symbol
888                            x = draw_text_span(frame, x, y, " ", item_style, list_area.right());
889                        }
890
891                        // Draw content
892                        // Note: List items are currently single-line for simplicity in v1
893                        if let Some(line) = item.content.lines().first() {
894                            for span in line.spans() {
895                                let span_style = match span.style {
896                                    Some(s) => s.merge(&item_style),
897                                    None => item_style,
898                                };
899                                x = draw_text_span_with_link(
900                                    frame,
901                                    x,
902                                    y,
903                                    &span.content,
904                                    span_style,
905                                    list_area.right(),
906                                    span.link.as_deref(),
907                                );
908                                if x >= list_area.right() {
909                                    break;
910                                }
911                            }
912                        }
913
914                        // Register hit region for this item (if hit testing enabled)
915                        if let Some(id) = self.hit_id {
916                            frame.register_hit(row_area, id, HitRegion::Content, i as u64);
917                        }
918
919                        rendered_visible_items = rendered_visible_items.saturating_add(1);
920                    }
921
922                    if filtered_indices.len() > list_height && list_area.width > 0 {
923                        let indicator_x = list_area.right().saturating_sub(1);
924                        if state.offset > 0 {
925                            draw_text_span(
926                                frame,
927                                indicator_x,
928                                list_area.y,
929                                "↑",
930                                self.style,
931                                list_area.right(),
932                            );
933                        }
934                        if state.offset + list_height < filtered_indices.len() {
935                            draw_text_span(
936                                frame,
937                                indicator_x,
938                                list_area.bottom().saturating_sub(1),
939                                "↓",
940                                self.style,
941                                list_area.right(),
942                            );
943                        }
944                    }
945                }
946            }
947        }
948
949        #[cfg(feature = "tracing")]
950        {
951            let elapsed_us = render_start.elapsed().as_micros() as u64;
952            render_span.record("visible_items", rendered_visible_items);
953            render_span.record("render_duration_us", elapsed_us);
954            tracing::debug!(
955                message = "list.metrics",
956                total_items,
957                visible_items = rendered_visible_items,
958                selected_count = state.selected_count(),
959                filter_active,
960                list_render_duration_us = elapsed_us
961            );
962        }
963    }
964}
965
966impl<'a> Widget for List<'a> {
967    fn render(&self, area: Rect, frame: &mut Frame) {
968        let mut state = ListState::default();
969        StatefulWidget::render(self, area, frame, &mut state);
970    }
971}
972
973impl ftui_a11y::Accessible for List<'_> {
974    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
975        use ftui_a11y::node::{A11yNodeInfo, A11yRole};
976
977        let base_id = crate::a11y_node_id(area);
978        let item_count = self.items.len();
979        let child_ids: Vec<u64> = (0..item_count).map(|i| base_id + 1 + i as u64).collect();
980
981        let title = self
982            .block
983            .as_ref()
984            .and_then(|b| b.title_text())
985            .unwrap_or_default();
986
987        let mut list_node =
988            A11yNodeInfo::new(base_id, A11yRole::List, area).with_children(child_ids);
989        if !title.is_empty() {
990            list_node = list_node.with_name(title);
991        }
992        list_node = list_node.with_description(format!("{item_count} items"));
993
994        let mut nodes = vec![list_node];
995        for (i, item) in self.items.iter().enumerate() {
996            let item_id = base_id + 1 + i as u64;
997            let item_text = item
998                .content
999                .lines()
1000                .first()
1001                .map(|line| line.to_plain_text())
1002                .unwrap_or_default();
1003            let item_node =
1004                A11yNodeInfo::new(item_id, A11yRole::ListItem, area).with_parent(base_id);
1005            let item_node = if item_text.is_empty() {
1006                item_node
1007            } else {
1008                item_node.with_name(item_text)
1009            };
1010            nodes.push(item_node);
1011        }
1012        nodes
1013    }
1014}
1015
1016impl MeasurableWidget for ListItem<'_> {
1017    fn measure(&self, _available: Size) -> SizeConstraints {
1018        // ListItem is a single line of text with optional marker
1019        let marker_width = display_width(self.marker) as u16;
1020        let space_after_marker = if self.marker.is_empty() { 0u16 } else { 1 };
1021
1022        // Get text width from the first line (List currently renders only first line)
1023        let text_width = self
1024            .content
1025            .lines()
1026            .first()
1027            .map(|line| line.width())
1028            .unwrap_or(0)
1029            .min(u16::MAX as usize) as u16;
1030
1031        let total_width = marker_width
1032            .saturating_add(space_after_marker)
1033            .saturating_add(text_width);
1034
1035        // ListItem is always 1 line tall
1036        SizeConstraints::exact(Size::new(total_width, 1))
1037    }
1038
1039    fn has_intrinsic_size(&self) -> bool {
1040        true
1041    }
1042}
1043
1044impl MeasurableWidget for List<'_> {
1045    fn measure(&self, available: Size) -> SizeConstraints {
1046        // Get block chrome if present
1047        let (chrome_width, chrome_height) = self
1048            .block
1049            .as_ref()
1050            .map(|b| b.chrome_size())
1051            .unwrap_or((0, 0));
1052
1053        if self.items.is_empty() {
1054            // Empty list: just the chrome
1055            return SizeConstraints {
1056                min: Size::new(chrome_width, chrome_height),
1057                preferred: Size::new(chrome_width, chrome_height),
1058                max: None,
1059            };
1060        }
1061
1062        // Calculate inner available space
1063        let inner_available = Size::new(
1064            available.width.saturating_sub(chrome_width),
1065            available.height.saturating_sub(chrome_height),
1066        );
1067
1068        // Measure all items
1069        let mut max_width: u16 = 0;
1070        let mut total_height: u16 = 0;
1071
1072        for item in &self.items {
1073            let item_constraints = item.measure(inner_available);
1074            max_width = max_width.max(item_constraints.preferred.width);
1075            total_height = total_height.saturating_add(item_constraints.preferred.height);
1076        }
1077
1078        // Add highlight symbol width if present
1079        if let Some(symbol) = self.highlight_symbol {
1080            let symbol_width = display_width(symbol) as u16 + 1; // +1 for space
1081            max_width = max_width.saturating_add(symbol_width);
1082        }
1083
1084        // Add chrome
1085        let preferred_width = max_width.saturating_add(chrome_width);
1086        let preferred_height = total_height.saturating_add(chrome_height);
1087
1088        // Minimum is chrome + 1 item height (can scroll)
1089        let min_height = chrome_height.saturating_add(1.min(total_height));
1090
1091        SizeConstraints {
1092            min: Size::new(chrome_width, min_height),
1093            preferred: Size::new(preferred_width, preferred_height),
1094            max: None, // Lists can scroll, so no max
1095        }
1096    }
1097
1098    fn has_intrinsic_size(&self) -> bool {
1099        !self.items.is_empty()
1100    }
1101}
1102
1103// ============================================================================
1104// Undo Support Implementation
1105// ============================================================================
1106
1107/// Snapshot of ListState for undo.
1108#[derive(Debug, Clone)]
1109pub struct ListStateSnapshot {
1110    selected: Option<usize>,
1111    offset: usize,
1112    filter_query: String,
1113    multi_select_enabled: bool,
1114    multi_selected: Vec<usize>,
1115}
1116
1117impl UndoSupport for ListState {
1118    fn undo_widget_id(&self) -> UndoWidgetId {
1119        self.undo_id
1120    }
1121
1122    fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
1123        Box::new(ListStateSnapshot {
1124            selected: self.selected,
1125            offset: self.offset,
1126            filter_query: self.filter_query.clone(),
1127            multi_select_enabled: self.multi_select_enabled,
1128            multi_selected: self.multi_selected.iter().copied().collect(),
1129        })
1130    }
1131
1132    fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
1133        if let Some(snap) = snapshot.downcast_ref::<ListStateSnapshot>() {
1134            self.selected = snap.selected;
1135            self.hovered = None;
1136            self.offset = snap.offset;
1137            self.filter_query = snap.filter_query.clone();
1138            self.multi_select_enabled = snap.multi_select_enabled;
1139            self.multi_selected = snap.multi_selected.iter().copied().collect();
1140            true
1141        } else {
1142            false
1143        }
1144    }
1145}
1146
1147impl ListUndoExt for ListState {
1148    fn selected_index(&self) -> Option<usize> {
1149        self.selected
1150    }
1151
1152    fn set_selected_index(&mut self, index: Option<usize>) {
1153        self.selected = index;
1154        if index.is_none() {
1155            self.offset = 0;
1156            self.multi_selected.clear();
1157        } else if !self.multi_select_enabled
1158            && let Some(selected) = index
1159        {
1160            self.multi_selected.clear();
1161            self.multi_selected.insert(selected);
1162        }
1163    }
1164}
1165
1166impl ListState {
1167    /// Get the undo widget ID.
1168    ///
1169    /// This can be used to associate undo commands with this state instance.
1170    #[must_use]
1171    pub fn undo_id(&self) -> UndoWidgetId {
1172        self.undo_id
1173    }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179    use ftui_core::event::{KeyCode, KeyEvent};
1180    use ftui_render::cell::Cell;
1181    use ftui_render::grapheme_pool::GraphemePool;
1182    #[cfg(feature = "tracing")]
1183    use std::sync::{Arc, Mutex};
1184    #[cfg(feature = "tracing")]
1185    use tracing::Subscriber;
1186    #[cfg(feature = "tracing")]
1187    use tracing_subscriber::Layer;
1188    #[cfg(feature = "tracing")]
1189    use tracing_subscriber::layer::{Context, SubscriberExt};
1190
1191    fn row_text(frame: &Frame, y: u16) -> String {
1192        let width = frame.buffer.width();
1193        let mut actual = String::new();
1194        for x in 0..width {
1195            let ch = frame
1196                .buffer
1197                .get(x, y)
1198                .and_then(|cell| cell.content.as_char())
1199                .unwrap_or(' ');
1200            actual.push(ch);
1201        }
1202        actual.trim().to_string()
1203    }
1204
1205    fn raw_row_text(frame: &Frame, y: u16) -> String {
1206        let width = frame.buffer.width();
1207        let mut actual = String::new();
1208        for x in 0..width {
1209            let ch = frame
1210                .buffer
1211                .get(x, y)
1212                .and_then(|cell| cell.content.as_char())
1213                .unwrap_or(' ');
1214            actual.push(ch);
1215        }
1216        actual
1217    }
1218
1219    #[cfg(feature = "tracing")]
1220    #[derive(Debug, Default)]
1221    struct ListTraceState {
1222        list_render_seen: bool,
1223        has_total_items_field: bool,
1224        has_visible_items_field: bool,
1225        has_selected_count_field: bool,
1226        has_filter_active_field: bool,
1227        render_duration_recorded: bool,
1228        selection_events: usize,
1229    }
1230
1231    #[cfg(feature = "tracing")]
1232    struct ListTraceCapture {
1233        state: Arc<Mutex<ListTraceState>>,
1234    }
1235
1236    #[cfg(feature = "tracing")]
1237    impl<S> Layer<S> for ListTraceCapture
1238    where
1239        S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
1240    {
1241        fn on_new_span(
1242            &self,
1243            attrs: &tracing::span::Attributes<'_>,
1244            _id: &tracing::Id,
1245            _ctx: Context<'_, S>,
1246        ) {
1247            if attrs.metadata().name() != "list.render" {
1248                return;
1249            }
1250            let fields = attrs.metadata().fields();
1251            let mut state = self.state.lock().expect("list trace state lock");
1252            state.list_render_seen = true;
1253            state.has_total_items_field |= fields.field("total_items").is_some();
1254            state.has_visible_items_field |= fields.field("visible_items").is_some();
1255            state.has_selected_count_field |= fields.field("selected_count").is_some();
1256            state.has_filter_active_field |= fields.field("filter_active").is_some();
1257        }
1258
1259        fn on_record(
1260            &self,
1261            id: &tracing::Id,
1262            values: &tracing::span::Record<'_>,
1263            ctx: Context<'_, S>,
1264        ) {
1265            let Some(span) = ctx.span(id) else {
1266                return;
1267            };
1268            if span.metadata().name() != "list.render" {
1269                return;
1270            }
1271            struct DurationVisitor {
1272                saw_duration: bool,
1273            }
1274            impl tracing::field::Visit for DurationVisitor {
1275                fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
1276                    if field.name() == "render_duration_us" {
1277                        self.saw_duration = true;
1278                    }
1279                }
1280
1281                fn record_debug(
1282                    &mut self,
1283                    field: &tracing::field::Field,
1284                    _value: &dyn std::fmt::Debug,
1285                ) {
1286                    if field.name() == "render_duration_us" {
1287                        self.saw_duration = true;
1288                    }
1289                }
1290            }
1291            let mut visitor = DurationVisitor {
1292                saw_duration: false,
1293            };
1294            values.record(&mut visitor);
1295            if visitor.saw_duration {
1296                self.state
1297                    .lock()
1298                    .expect("list trace state lock")
1299                    .render_duration_recorded = true;
1300            }
1301        }
1302
1303        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1304            struct MessageVisitor {
1305                message: Option<String>,
1306            }
1307            impl tracing::field::Visit for MessageVisitor {
1308                fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1309                    if field.name() == "message" {
1310                        self.message = Some(value.to_owned());
1311                    }
1312                }
1313
1314                fn record_debug(
1315                    &mut self,
1316                    field: &tracing::field::Field,
1317                    value: &dyn std::fmt::Debug,
1318                ) {
1319                    if field.name() == "message" {
1320                        self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
1321                    }
1322                }
1323            }
1324            let mut visitor = MessageVisitor { message: None };
1325            event.record(&mut visitor);
1326            if visitor.message.as_deref() == Some("list.selection") {
1327                let mut state = self.state.lock().expect("list trace state lock");
1328                state.selection_events = state.selection_events.saturating_add(1);
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn render_empty_list() {
1335        let list = List::new(Vec::<ListItem>::new());
1336        let area = Rect::new(0, 0, 10, 5);
1337        let mut pool = GraphemePool::new();
1338        let mut frame = Frame::new(10, 5, &mut pool);
1339        Widget::render(&list, area, &mut frame);
1340    }
1341
1342    #[test]
1343    fn render_simple_list() {
1344        let items = vec![
1345            ListItem::new("Item A"),
1346            ListItem::new("Item B"),
1347            ListItem::new("Item C"),
1348        ];
1349        let list = List::new(items);
1350        let area = Rect::new(0, 0, 10, 3);
1351        let mut pool = GraphemePool::new();
1352        let mut frame = Frame::new(10, 3, &mut pool);
1353        let mut state = ListState::default();
1354        StatefulWidget::render(&list, area, &mut frame, &mut state);
1355
1356        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('I'));
1357        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('A'));
1358        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('B'));
1359        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('C'));
1360    }
1361
1362    #[test]
1363    fn list_state_select() {
1364        let mut state = ListState::default();
1365        assert_eq!(state.selected(), None);
1366
1367        state.select(Some(2));
1368        assert_eq!(state.selected(), Some(2));
1369
1370        state.select(None);
1371        assert_eq!(state.selected(), None);
1372        assert_eq!(state.offset, 0);
1373    }
1374
1375    #[test]
1376    fn list_scrolls_to_selected() {
1377        let items: Vec<ListItem> = (0..10)
1378            .map(|i| ListItem::new(format!("Item {i}")))
1379            .collect();
1380        let list = List::new(items);
1381        let area = Rect::new(0, 0, 10, 3);
1382        let mut pool = GraphemePool::new();
1383        let mut frame = Frame::new(10, 3, &mut pool);
1384        let mut state = ListState::default();
1385        state.select(Some(5));
1386
1387        StatefulWidget::render(&list, area, &mut frame, &mut state);
1388        // offset should have been adjusted so item 5 is visible
1389        assert!(state.offset <= 5);
1390        assert!(state.offset + 3 > 5);
1391    }
1392
1393    #[test]
1394    fn list_clamps_selection() {
1395        let items = vec![ListItem::new("A"), ListItem::new("B")];
1396        let list = List::new(items);
1397        let area = Rect::new(0, 0, 10, 3);
1398        let mut pool = GraphemePool::new();
1399        let mut frame = Frame::new(10, 3, &mut pool);
1400        let mut state = ListState::default();
1401        state.select(Some(10)); // out of bounds
1402
1403        StatefulWidget::render(&list, area, &mut frame, &mut state);
1404        // should clamp to last item
1405        assert_eq!(state.selected(), Some(1));
1406    }
1407
1408    #[test]
1409    fn render_list_with_highlight_symbol() {
1410        let items = vec![ListItem::new("A"), ListItem::new("B")];
1411        let list = List::new(items).highlight_symbol(">");
1412        let area = Rect::new(0, 0, 10, 2);
1413        let mut pool = GraphemePool::new();
1414        let mut frame = Frame::new(10, 2, &mut pool);
1415        let mut state = ListState::default();
1416        state.select(Some(0));
1417
1418        StatefulWidget::render(&list, area, &mut frame, &mut state);
1419        // First item should have ">" symbol
1420        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('>'));
1421    }
1422
1423    #[test]
1424    fn render_zero_area() {
1425        let list = List::new(vec![ListItem::new("A")]);
1426        let area = Rect::new(0, 0, 0, 0);
1427        let mut pool = GraphemePool::new();
1428        let mut frame = Frame::new(1, 1, &mut pool);
1429        let mut state = ListState::default();
1430        StatefulWidget::render(&list, area, &mut frame, &mut state);
1431    }
1432
1433    #[test]
1434    fn list_item_from_str() {
1435        let item: ListItem = "hello".into();
1436        assert_eq!(
1437            item.content.lines().first().unwrap().to_plain_text(),
1438            "hello"
1439        );
1440        assert_eq!(item.marker, "");
1441    }
1442
1443    #[test]
1444    fn list_item_with_marker() {
1445        let items = vec![
1446            ListItem::new("A").marker("•"),
1447            ListItem::new("B").marker("•"),
1448        ];
1449        let list = List::new(items);
1450        let area = Rect::new(0, 0, 10, 2);
1451        let mut pool = GraphemePool::new();
1452        let mut frame = Frame::new(10, 2, &mut pool);
1453        let mut state = ListState::default();
1454        StatefulWidget::render(&list, area, &mut frame, &mut state);
1455
1456        // Marker should be rendered at the start
1457        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('•'));
1458        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('•'));
1459    }
1460
1461    #[test]
1462    fn list_state_deselect_resets_offset() {
1463        let mut state = ListState {
1464            offset: 5,
1465            ..Default::default()
1466        };
1467        state.select(Some(10));
1468        assert_eq!(state.offset, 5); // select doesn't reset offset
1469
1470        state.select(None);
1471        assert_eq!(state.offset, 0); // deselect resets offset
1472    }
1473
1474    #[test]
1475    fn list_scrolls_up_when_selection_above_viewport() {
1476        let items: Vec<ListItem> = (0..10)
1477            .map(|i| ListItem::new(format!("Item {i}")))
1478            .collect();
1479        let list = List::new(items);
1480        let area = Rect::new(0, 0, 10, 3);
1481        let mut pool = GraphemePool::new();
1482        let mut frame = Frame::new(10, 3, &mut pool);
1483        let mut state = ListState::default();
1484
1485        // First scroll down
1486        state.select(Some(8));
1487        StatefulWidget::render(&list, area, &mut frame, &mut state);
1488        assert!(state.offset > 0);
1489
1490        // Now select item 0 - should scroll back up
1491        state.select(Some(0));
1492        StatefulWidget::render(&list, area, &mut frame, &mut state);
1493        assert_eq!(state.offset, 0);
1494    }
1495
1496    #[test]
1497    fn list_clamps_offset_to_fill_viewport_on_resize() {
1498        let items: Vec<ListItem> = (0..10)
1499            .map(|i| ListItem::new(format!("Item {i}")))
1500            .collect();
1501        let list = List::new(items);
1502
1503        let mut pool = GraphemePool::new();
1504        let mut state = ListState {
1505            offset: 7,
1506            ..Default::default()
1507        };
1508
1509        // Small viewport: show 7, 8, 9.
1510        let area_small = Rect::new(0, 0, 10, 3);
1511        let mut frame_small = Frame::new(10, 3, &mut pool);
1512        StatefulWidget::render(&list, area_small, &mut frame_small, &mut state);
1513        assert_eq!(state.offset, 7);
1514        assert!(row_text(&frame_small, 0).starts_with("Item 7"));
1515        assert!(row_text(&frame_small, 2).starts_with("Item 9"));
1516
1517        // Larger viewport: offset should pull back to fill the viewport (5..9).
1518        let area_large = Rect::new(0, 0, 10, 5);
1519        let mut frame_large = Frame::new(10, 5, &mut pool);
1520        StatefulWidget::render(&list, area_large, &mut frame_large, &mut state);
1521        assert_eq!(state.offset, 5);
1522        assert!(row_text(&frame_large, 0).starts_with("Item 5"));
1523        assert!(row_text(&frame_large, 4).starts_with("Item 9"));
1524    }
1525
1526    #[test]
1527    fn render_list_more_items_than_viewport() {
1528        let items: Vec<ListItem> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
1529        let list = List::new(items);
1530        let area = Rect::new(0, 0, 5, 3);
1531        let mut pool = GraphemePool::new();
1532        let mut frame = Frame::new(5, 3, &mut pool);
1533        let mut state = ListState::default();
1534        StatefulWidget::render(&list, area, &mut frame, &mut state);
1535
1536        // Only first 3 should render
1537        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1538        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('1'));
1539        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('2'));
1540    }
1541
1542    #[test]
1543    fn widget_render_uses_default_state() {
1544        let items = vec![ListItem::new("X")];
1545        let list = List::new(items);
1546        let area = Rect::new(0, 0, 5, 1);
1547        let mut pool = GraphemePool::new();
1548        let mut frame = Frame::new(5, 1, &mut pool);
1549        // Using Widget trait (not StatefulWidget)
1550        Widget::render(&list, area, &mut frame);
1551        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
1552    }
1553
1554    #[test]
1555    fn list_registers_hit_regions() {
1556        let items = vec![ListItem::new("A"), ListItem::new("B"), ListItem::new("C")];
1557        let list = List::new(items).hit_id(HitId::new(42));
1558        let area = Rect::new(0, 0, 10, 3);
1559        let mut pool = GraphemePool::new();
1560        let mut frame = Frame::with_hit_grid(10, 3, &mut pool);
1561        let mut state = ListState::default();
1562        StatefulWidget::render(&list, area, &mut frame, &mut state);
1563
1564        // Each row should have a hit region with the item index as data
1565        let hit0 = frame.hit_test(5, 0);
1566        let hit1 = frame.hit_test(5, 1);
1567        let hit2 = frame.hit_test(5, 2);
1568
1569        assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 0)));
1570        assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 1)));
1571        assert_eq!(hit2, Some((HitId::new(42), HitRegion::Content, 2)));
1572    }
1573
1574    #[test]
1575    fn list_no_hit_without_hit_id() {
1576        let items = vec![ListItem::new("A")];
1577        let list = List::new(items); // No hit_id set
1578        let area = Rect::new(0, 0, 10, 1);
1579        let mut pool = GraphemePool::new();
1580        let mut frame = Frame::with_hit_grid(10, 1, &mut pool);
1581        let mut state = ListState::default();
1582        StatefulWidget::render(&list, area, &mut frame, &mut state);
1583
1584        // No hit region should be registered
1585        assert!(frame.hit_test(5, 0).is_none());
1586    }
1587
1588    #[test]
1589    fn list_no_hit_without_hit_grid() {
1590        let items = vec![ListItem::new("A")];
1591        let list = List::new(items).hit_id(HitId::new(1));
1592        let area = Rect::new(0, 0, 10, 1);
1593        let mut pool = GraphemePool::new();
1594        let mut frame = Frame::new(10, 1, &mut pool); // No hit grid
1595        let mut state = ListState::default();
1596        StatefulWidget::render(&list, area, &mut frame, &mut state);
1597
1598        // hit_test returns None when no hit grid
1599        assert!(frame.hit_test(5, 0).is_none());
1600    }
1601
1602    // --- MeasurableWidget tests ---
1603
1604    use crate::MeasurableWidget;
1605    use ftui_core::geometry::Size;
1606
1607    #[test]
1608    fn list_item_measure_simple() {
1609        let item = ListItem::new("Hello"); // 5 chars
1610        let constraints = item.measure(Size::MAX);
1611
1612        assert_eq!(constraints.preferred, Size::new(5, 1));
1613        assert_eq!(constraints.min, Size::new(5, 1));
1614        assert_eq!(constraints.max, Some(Size::new(5, 1)));
1615    }
1616
1617    #[test]
1618    fn list_item_measure_with_marker() {
1619        let item = ListItem::new("Hi").marker("•"); // • + space + Hi = 1 + 1 + 2 = 4
1620        let constraints = item.measure(Size::MAX);
1621
1622        assert_eq!(constraints.preferred.width, 4);
1623        assert_eq!(constraints.preferred.height, 1);
1624    }
1625
1626    #[test]
1627    fn list_item_has_intrinsic_size() {
1628        let item = ListItem::new("test");
1629        assert!(item.has_intrinsic_size());
1630    }
1631
1632    #[test]
1633    fn list_measure_empty() {
1634        let list = List::new(Vec::<ListItem>::new());
1635        let constraints = list.measure(Size::MAX);
1636
1637        assert_eq!(constraints.preferred, Size::new(0, 0));
1638        assert!(!list.has_intrinsic_size());
1639    }
1640
1641    #[test]
1642    fn list_measure_single_item() {
1643        let items = vec![ListItem::new("Hello")]; // 5 chars, 1 line
1644        let list = List::new(items);
1645        let constraints = list.measure(Size::MAX);
1646
1647        assert_eq!(constraints.preferred, Size::new(5, 1));
1648        assert_eq!(constraints.min.height, 1);
1649    }
1650
1651    #[test]
1652    fn list_measure_multiple_items() {
1653        let items = vec![
1654            ListItem::new("Short"),      // 5 chars
1655            ListItem::new("LongerItem"), // 10 chars
1656            ListItem::new("Tiny"),       // 4 chars
1657        ];
1658        let list = List::new(items);
1659        let constraints = list.measure(Size::MAX);
1660
1661        // Width is max of all items = 10
1662        assert_eq!(constraints.preferred.width, 10);
1663        // Height is sum of all items = 3
1664        assert_eq!(constraints.preferred.height, 3);
1665    }
1666
1667    #[test]
1668    fn list_measure_with_block() {
1669        let block = crate::block::Block::bordered(); // 4x4 chrome (borders + padding)
1670        let items = vec![ListItem::new("Hi")]; // 2 chars, 1 line
1671        let list = List::new(items).block(block);
1672        let constraints = list.measure(Size::MAX);
1673
1674        // 2 (text) + 4 (chrome) = 6 width
1675        // 1 (line) + 4 (chrome) = 5 height
1676        assert_eq!(constraints.preferred, Size::new(6, 5));
1677    }
1678
1679    #[test]
1680    fn list_measure_with_highlight_symbol() {
1681        let items = vec![ListItem::new("Item")]; // 4 chars
1682        let list = List::new(items).highlight_symbol(">"); // 1 char + space = 2
1683
1684        let constraints = list.measure(Size::MAX);
1685
1686        // 4 (text) + 2 (symbol + space) = 6
1687        assert_eq!(constraints.preferred.width, 6);
1688    }
1689
1690    #[test]
1691    fn list_has_intrinsic_size() {
1692        let items = vec![ListItem::new("X")];
1693        let list = List::new(items);
1694        assert!(list.has_intrinsic_size());
1695    }
1696
1697    #[test]
1698    fn list_min_height_is_one_row() {
1699        let items: Vec<ListItem> = (0..100)
1700            .map(|i| ListItem::new(format!("Item {i}")))
1701            .collect();
1702        let list = List::new(items);
1703        let constraints = list.measure(Size::MAX);
1704
1705        // Min height should be 1 (can scroll to see rest)
1706        assert_eq!(constraints.min.height, 1);
1707        // Preferred height is all items
1708        assert_eq!(constraints.preferred.height, 100);
1709    }
1710
1711    #[test]
1712    fn list_measure_is_pure() {
1713        let items = vec![ListItem::new("Test")];
1714        let list = List::new(items);
1715        let a = list.measure(Size::new(100, 50));
1716        let b = list.measure(Size::new(100, 50));
1717        assert_eq!(a, b);
1718    }
1719
1720    // --- Undo Support tests ---
1721
1722    #[test]
1723    fn list_state_undo_id_is_stable() {
1724        let state = ListState::default();
1725        let id1 = state.undo_id();
1726        let id2 = state.undo_id();
1727        assert_eq!(id1, id2);
1728    }
1729
1730    #[test]
1731    fn list_state_undo_id_unique_per_instance() {
1732        let state1 = ListState::default();
1733        let state2 = ListState::default();
1734        assert_ne!(state1.undo_id(), state2.undo_id());
1735    }
1736
1737    #[test]
1738    fn list_state_snapshot_and_restore() {
1739        let mut state = ListState::default();
1740        state.select(Some(5));
1741        state.offset = 3;
1742
1743        let snapshot = state.create_snapshot();
1744
1745        // Modify state
1746        state.select(Some(10));
1747        state.offset = 8;
1748        assert_eq!(state.selected(), Some(10));
1749        assert_eq!(state.offset, 8);
1750
1751        // Restore
1752        assert!(state.restore_snapshot(snapshot.as_ref()));
1753        assert_eq!(state.selected(), Some(5));
1754        assert_eq!(state.offset, 3);
1755    }
1756
1757    #[test]
1758    fn list_state_undo_ext_methods() {
1759        let mut state = ListState::default();
1760        assert_eq!(state.selected_index(), None);
1761
1762        state.set_selected_index(Some(3));
1763        assert_eq!(state.selected_index(), Some(3));
1764
1765        state.set_selected_index(None);
1766        assert_eq!(state.selected_index(), None);
1767        assert_eq!(state.offset, 0); // reset on deselect
1768    }
1769
1770    // --- Stateful Persistence tests ---
1771
1772    use crate::stateful::Stateful;
1773
1774    #[test]
1775    fn list_state_with_persistence_id() {
1776        let state = ListState::default().with_persistence_id("sidebar-menu");
1777        assert_eq!(state.persistence_id(), Some("sidebar-menu"));
1778    }
1779
1780    #[test]
1781    fn list_state_default_no_persistence_id() {
1782        let state = ListState::default();
1783        assert_eq!(state.persistence_id(), None);
1784    }
1785
1786    #[test]
1787    fn list_state_save_restore_round_trip() {
1788        let mut state = ListState::default().with_persistence_id("test");
1789        state.select(Some(7));
1790        state.offset = 4;
1791
1792        let saved = state.save_state();
1793        assert_eq!(saved.selected, Some(7));
1794        assert_eq!(saved.offset, 4);
1795
1796        // Reset state
1797        state.select(None);
1798        assert_eq!(state.selected, None);
1799        assert_eq!(state.offset, 0);
1800
1801        // Restore
1802        state.restore_state(saved);
1803        assert_eq!(state.selected, Some(7));
1804        assert_eq!(state.offset, 4);
1805    }
1806
1807    #[test]
1808    fn list_state_key_uses_persistence_id() {
1809        let state = ListState::default().with_persistence_id("file-browser");
1810        let key = state.state_key();
1811        assert_eq!(key.widget_type, "List");
1812        assert_eq!(key.instance_id, "file-browser");
1813    }
1814
1815    #[test]
1816    fn list_state_key_default_when_no_id() {
1817        let state = ListState::default();
1818        let key = state.state_key();
1819        assert_eq!(key.widget_type, "List");
1820        assert_eq!(key.instance_id, "default");
1821    }
1822
1823    #[test]
1824    fn list_persist_state_default() {
1825        let persist = ListPersistState::default();
1826        assert_eq!(persist.selected, None);
1827        assert_eq!(persist.offset, 0);
1828    }
1829
1830    // --- Mouse handling tests ---
1831
1832    use crate::mouse::MouseResult;
1833    use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
1834
1835    #[test]
1836    fn list_state_click_selects() {
1837        let mut state = ListState::default();
1838        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1839        let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
1840        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1841        assert_eq!(result, MouseResult::Selected(3));
1842        assert_eq!(state.selected(), Some(3));
1843    }
1844
1845    #[test]
1846    fn list_state_click_wrong_id_ignored() {
1847        let mut state = ListState::default();
1848        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1849        let hit = Some((HitId::new(99), HitRegion::Content, 3u64));
1850        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1851        assert_eq!(result, MouseResult::Ignored);
1852        assert_eq!(state.selected(), None);
1853    }
1854
1855    #[test]
1856    fn list_state_click_out_of_range() {
1857        let mut state = ListState::default();
1858        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1859        let hit = Some((HitId::new(1), HitRegion::Content, 15u64));
1860        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1861        assert_eq!(result, MouseResult::Ignored);
1862        assert_eq!(state.selected(), None);
1863    }
1864
1865    #[test]
1866    fn list_state_click_no_hit_ignored() {
1867        let mut state = ListState::default();
1868        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1869        let result = state.handle_mouse(&event, None, HitId::new(1), 10);
1870        assert_eq!(result, MouseResult::Ignored);
1871    }
1872
1873    #[test]
1874    #[allow(clippy::field_reassign_with_default)]
1875    fn list_state_scroll_up() {
1876        let mut state = {
1877            let mut s = ListState::default();
1878            s.offset = 10;
1879            s
1880        };
1881        state.scroll_up(3);
1882        assert_eq!(state.offset, 7);
1883    }
1884
1885    #[test]
1886    #[allow(clippy::field_reassign_with_default)]
1887    fn list_state_scroll_up_clamps_to_zero() {
1888        let mut state = {
1889            let mut s = ListState::default();
1890            s.offset = 1;
1891            s
1892        };
1893        state.scroll_up(5);
1894        assert_eq!(state.offset, 0);
1895    }
1896
1897    #[test]
1898    fn list_state_scroll_down() {
1899        let mut state = ListState::default();
1900        state.scroll_down(3, 20);
1901        assert_eq!(state.offset, 3);
1902    }
1903
1904    #[test]
1905    #[allow(clippy::field_reassign_with_default)]
1906    fn list_state_scroll_down_clamps() {
1907        let mut state = ListState::default();
1908        state.offset = 18;
1909        state.scroll_down(5, 20);
1910        assert_eq!(state.offset, 19); // item_count - 1
1911    }
1912
1913    #[test]
1914    #[allow(clippy::field_reassign_with_default)]
1915    fn list_state_scroll_wheel_up() {
1916        let mut state = {
1917            let mut s = ListState::default();
1918            s.offset = 10;
1919            s
1920        };
1921        let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1922        let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1923        assert_eq!(result, MouseResult::Scrolled);
1924        assert_eq!(state.offset, 7);
1925    }
1926
1927    #[test]
1928    fn list_state_scroll_wheel_down() {
1929        let mut state = ListState::default();
1930        let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1931        let result = state.handle_mouse(&event, None, HitId::new(1), 20);
1932        assert_eq!(result, MouseResult::Scrolled);
1933        assert_eq!(state.offset, 3);
1934    }
1935
1936    #[test]
1937    fn list_state_select_next() {
1938        let mut state = ListState::default();
1939        state.select_next(5);
1940        assert_eq!(state.selected(), Some(0));
1941        state.select_next(5);
1942        assert_eq!(state.selected(), Some(1));
1943    }
1944
1945    #[test]
1946    fn list_state_select_next_clamps() {
1947        let mut state = ListState::default();
1948        state.select(Some(4));
1949        state.select_next(5);
1950        assert_eq!(state.selected(), Some(4)); // already at last
1951    }
1952
1953    #[test]
1954    fn list_state_select_next_empty() {
1955        let mut state = ListState::default();
1956        state.select_next(0);
1957        assert_eq!(state.selected(), None); // no items, no change
1958    }
1959
1960    #[test]
1961    fn list_state_select_previous() {
1962        let mut state = ListState::default();
1963        state.select(Some(3));
1964        state.select_previous();
1965        assert_eq!(state.selected(), Some(2));
1966    }
1967
1968    #[test]
1969    fn list_state_select_previous_clamps() {
1970        let mut state = ListState::default();
1971        state.select(Some(0));
1972        state.select_previous();
1973        assert_eq!(state.selected(), Some(0)); // already at first
1974    }
1975
1976    #[test]
1977    fn list_state_select_previous_from_none() {
1978        let mut state = ListState::default();
1979        state.select_previous();
1980        assert_eq!(state.selected(), Some(0));
1981    }
1982
1983    #[test]
1984    fn list_handle_key_down_from_none_selects_first() {
1985        let list = List::new(vec![
1986            ListItem::new("a"),
1987            ListItem::new("b"),
1988            ListItem::new("c"),
1989        ]);
1990        let mut state = ListState::default();
1991        assert_eq!(state.selected(), None);
1992
1993        // Press Down
1994        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
1995        // Should select "a" (index 0)
1996        assert_eq!(state.selected(), Some(0));
1997    }
1998
1999    #[test]
2000    fn list_handle_key_up_from_none_selects_last() {
2001        let list = List::new(vec![
2002            ListItem::new("a"),
2003            ListItem::new("b"),
2004            ListItem::new("c"),
2005        ]);
2006        let mut state = ListState::default();
2007        assert_eq!(state.selected(), None);
2008
2009        // Press Up
2010        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2011        // Should select "c" (index 2)
2012        assert_eq!(state.selected(), Some(2));
2013    }
2014
2015    #[test]
2016    fn list_handle_key_navigation_supports_jk_and_arrows() {
2017        let list = List::new(vec![
2018            ListItem::new("a"),
2019            ListItem::new("b"),
2020            ListItem::new("c"),
2021        ]);
2022        let mut state = ListState::default();
2023        state.select(Some(0));
2024
2025        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2026        assert_eq!(state.selected(), Some(1));
2027        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2028        assert_eq!(state.selected(), Some(2));
2029        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2030        assert_eq!(state.selected(), Some(1));
2031        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
2032        assert_eq!(state.selected(), Some(0));
2033    }
2034
2035    #[test]
2036    fn list_handle_key_filter_is_incremental_and_editable() {
2037        let list = List::new(vec![
2038            ListItem::new("alpha"),
2039            ListItem::new("banana"),
2040            ListItem::new("beta"),
2041        ]);
2042        let mut state = ListState::default();
2043
2044        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2045        assert_eq!(state.filter_query(), "b");
2046        assert_eq!(state.selected(), Some(1));
2047
2048        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('e'))));
2049        assert_eq!(state.filter_query(), "be");
2050        assert_eq!(state.selected(), Some(2));
2051
2052        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
2053        assert_eq!(state.filter_query(), "b");
2054
2055        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
2056        assert_eq!(state.filter_query(), "");
2057    }
2058
2059    #[test]
2060    fn list_render_filter_no_matches_shows_empty_state() {
2061        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2062        let mut state = ListState::default();
2063        state.set_filter_query("zzz");
2064
2065        let mut pool = GraphemePool::new();
2066        let mut frame = Frame::new(14, 3, &mut pool);
2067        StatefulWidget::render(&list, Rect::new(0, 0, 14, 3), &mut frame, &mut state);
2068
2069        assert_eq!(row_text(&frame, 0), "No matches");
2070    }
2071
2072    #[test]
2073    fn list_render_shorter_item_clears_stale_row_suffix() {
2074        let mut pool = GraphemePool::new();
2075        let mut frame = Frame::new(12, 2, &mut pool);
2076        let mut state = ListState::default();
2077        let area = Rect::new(0, 0, 12, 2);
2078
2079        let long = List::new(vec![ListItem::new("alphabet")]);
2080        StatefulWidget::render(&long, area, &mut frame, &mut state);
2081
2082        let short = List::new(vec![ListItem::new("a")]);
2083        StatefulWidget::render(&short, area, &mut frame, &mut state);
2084
2085        assert_eq!(raw_row_text(&frame, 0), "a           ");
2086    }
2087
2088    #[test]
2089    fn list_render_empty_state_clears_stale_rows_and_tail() {
2090        let list = List::new(Vec::<ListItem>::new());
2091        let mut pool = GraphemePool::new();
2092        let mut frame = Frame::new(12, 3, &mut pool);
2093        let area = Rect::new(0, 0, 12, 3);
2094        frame.buffer.fill(area, Cell::from_char('X'));
2095
2096        Widget::render(&list, area, &mut frame);
2097
2098        assert_eq!(raw_row_text(&frame, 0), "No items    ");
2099        assert_eq!(raw_row_text(&frame, 1), "            ");
2100        assert_eq!(raw_row_text(&frame, 2), "            ");
2101    }
2102
2103    #[test]
2104    fn list_render_no_matches_clears_stale_rows_and_tail() {
2105        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2106        let mut state = ListState::default();
2107        state.set_filter_query("zzz");
2108
2109        let mut pool = GraphemePool::new();
2110        let mut frame = Frame::new(12, 3, &mut pool);
2111        let area = Rect::new(0, 0, 12, 3);
2112        frame.buffer.fill(area, Cell::from_char('X'));
2113
2114        StatefulWidget::render(&list, area, &mut frame, &mut state);
2115
2116        assert_eq!(raw_row_text(&frame, 0), "No matches  ");
2117        assert_eq!(raw_row_text(&frame, 1), "            ");
2118        assert_eq!(raw_row_text(&frame, 2), "            ");
2119    }
2120
2121    #[test]
2122    fn list_multi_select_toggle_with_space() {
2123        let list = List::new(vec![
2124            ListItem::new("alpha"),
2125            ListItem::new("beta"),
2126            ListItem::new("gamma"),
2127        ]);
2128        let mut state = ListState::default();
2129        state.set_multi_select(true);
2130        state.select(Some(0));
2131
2132        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2133        assert!(state.selected_indices().contains(&0));
2134
2135        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2136        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2137        assert!(state.selected_indices().contains(&1));
2138        assert_eq!(state.selected_count(), 2);
2139    }
2140
2141    #[test]
2142    fn list_render_draws_scroll_indicators() {
2143        let items: Vec<ListItem> = (0..8).map(|i| ListItem::new(format!("Item {i}"))).collect();
2144        let list = List::new(items);
2145        let mut state = ListState {
2146            selected: Some(4),
2147            offset: 2,
2148            scroll_into_view_requested: false,
2149            ..Default::default()
2150        };
2151        let mut pool = GraphemePool::new();
2152        let mut frame = Frame::new(8, 3, &mut pool);
2153        StatefulWidget::render(&list, Rect::new(0, 0, 8, 3), &mut frame, &mut state);
2154
2155        assert_eq!(
2156            frame.buffer.get(7, 0).and_then(|c| c.content.as_char()),
2157            Some('↑')
2158        );
2159        assert_eq!(
2160            frame.buffer.get(7, 2).and_then(|c| c.content.as_char()),
2161            Some('↓')
2162        );
2163    }
2164
2165    #[cfg(feature = "tracing")]
2166    #[test]
2167    fn list_tracing_span_and_selection_events_are_emitted() {
2168        let trace_state = Arc::new(Mutex::new(ListTraceState::default()));
2169        let _trace_test_guard = crate::tracing_test_support::acquire();
2170        let subscriber = tracing_subscriber::registry().with(ListTraceCapture {
2171            state: Arc::clone(&trace_state),
2172        });
2173        let _guard = tracing::subscriber::set_default(subscriber);
2174        tracing::callsite::rebuild_interest_cache();
2175
2176        let list = List::new(vec![
2177            ListItem::new("a"),
2178            ListItem::new("b"),
2179            ListItem::new("c"),
2180        ]);
2181        let mut state = ListState::default();
2182        state.select(Some(0));
2183        let mut pool = GraphemePool::new();
2184        let mut frame = Frame::new(10, 3, &mut pool);
2185        tracing::callsite::rebuild_interest_cache();
2186        StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
2187        tracing::callsite::rebuild_interest_cache();
2188        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2189
2190        tracing::callsite::rebuild_interest_cache();
2191        let snapshot = trace_state.lock().expect("list trace state lock");
2192        assert!(snapshot.list_render_seen, "expected list.render span");
2193        assert!(
2194            snapshot.has_total_items_field,
2195            "list.render missing total_items"
2196        );
2197        assert!(
2198            snapshot.has_visible_items_field,
2199            "list.render missing visible_items"
2200        );
2201        assert!(
2202            snapshot.has_selected_count_field,
2203            "list.render missing selected_count"
2204        );
2205        assert!(
2206            snapshot.has_filter_active_field,
2207            "list.render missing filter_active"
2208        );
2209        assert!(
2210            snapshot.render_duration_recorded,
2211            "list.render did not record render_duration_us"
2212        );
2213        assert!(
2214            snapshot.selection_events >= 1,
2215            "expected list.selection debug event"
2216        );
2217    }
2218
2219    #[test]
2220    fn list_state_right_click_ignored() {
2221        let mut state = ListState::default();
2222        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 2);
2223        let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2224        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2225        assert_eq!(result, MouseResult::Ignored);
2226    }
2227
2228    #[test]
2229    fn list_state_click_border_region_ignored() {
2230        let mut state = ListState::default();
2231        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2232        let hit = Some((HitId::new(1), HitRegion::Border, 3u64));
2233        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2234        assert_eq!(result, MouseResult::Ignored);
2235    }
2236
2237    #[test]
2238    fn list_state_second_click_activates() {
2239        let mut state = ListState::default();
2240        state.select(Some(3));
2241
2242        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2243        let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2244        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2245        assert_eq!(result, MouseResult::Activated(3));
2246        assert_eq!(state.selected(), Some(3));
2247    }
2248
2249    #[test]
2250    fn list_state_hover_updates() {
2251        let mut state = ListState::default();
2252        let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2253        let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2254        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2255        assert_eq!(result, MouseResult::HoverChanged);
2256        assert_eq!(state.hovered, Some(3));
2257    }
2258
2259    #[test]
2260    #[allow(clippy::field_reassign_with_default)]
2261    fn list_state_hover_same_index_ignored() {
2262        let mut state = {
2263            let mut s = ListState::default();
2264            s.hovered = Some(3);
2265            s
2266        };
2267        let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2268        let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2269        let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2270        assert_eq!(result, MouseResult::Ignored);
2271        assert_eq!(state.hovered, Some(3));
2272    }
2273
2274    #[test]
2275    #[allow(clippy::field_reassign_with_default)]
2276    fn list_state_hover_clears() {
2277        let mut state = {
2278            let mut s = ListState::default();
2279            s.hovered = Some(5);
2280            s
2281        };
2282        let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2283        // No hit (mouse moved off the list)
2284        let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2285        assert_eq!(result, MouseResult::HoverChanged);
2286        assert_eq!(state.hovered, None);
2287    }
2288
2289    #[test]
2290    fn list_state_hover_clear_when_already_none() {
2291        let mut state = ListState::default();
2292        let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2293        let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2294        assert_eq!(result, MouseResult::Ignored);
2295    }
2296
2297    // --- bd-1lg.27: Selection & filter interaction tests ---
2298
2299    #[test]
2300    fn list_navigate_down_while_filter_active() {
2301        let list = List::new(vec![
2302            ListItem::new("alpha"),
2303            ListItem::new("banana"),
2304            ListItem::new("beta"),
2305            ListItem::new("gamma"),
2306        ]);
2307        let mut state = ListState::default();
2308        // Type "b" to filter → matches banana(1) and beta(2)
2309        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2310        assert_eq!(state.filter_query(), "b");
2311        // Selection should land on first match
2312        assert_eq!(state.selected(), Some(1)); // banana
2313
2314        // Navigate down in filtered list → should move to beta(2)
2315        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2316        assert_eq!(state.selected(), Some(2)); // beta
2317
2318        // Navigate down at end → should stay at beta(2)
2319        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2320        assert_eq!(state.selected(), Some(2));
2321    }
2322
2323    #[test]
2324    fn list_navigate_up_while_filter_active() {
2325        let list = List::new(vec![
2326            ListItem::new("alpha"),
2327            ListItem::new("banana"),
2328            ListItem::new("beta"),
2329            ListItem::new("gamma"),
2330        ]);
2331        let mut state = ListState::default();
2332        state.set_filter_query("b");
2333        // Force selection to beta(2) — last filtered match
2334        state.select(Some(2));
2335
2336        // Navigate up → should move to banana(1)
2337        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2338        assert_eq!(state.selected(), Some(1));
2339
2340        // Navigate up at top → should stay at banana(1)
2341        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Up)));
2342        assert_eq!(state.selected(), Some(1));
2343    }
2344
2345    #[test]
2346    fn list_filter_case_insensitive() {
2347        let list = List::new(vec![
2348            ListItem::new("Alpha"),
2349            ListItem::new("BANANA"),
2350            ListItem::new("beta"),
2351        ]);
2352        let mut state = ListState::default();
2353        // Type uppercase 'B' → should match "BANANA" and "beta"
2354        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('B'))));
2355        assert_eq!(state.selected(), Some(1)); // BANANA is first match
2356    }
2357
2358    #[test]
2359    fn list_filter_matches_marker() {
2360        let list = List::new(vec![
2361            ListItem::new("apple").marker("fruit"),
2362            ListItem::new("carrot").marker("veggie"),
2363            ListItem::new("berry").marker("fruit"),
2364        ]);
2365        let mut state = ListState::default();
2366        // Type "veg" → should match only carrot (via marker)
2367        state.set_filter_query("veg");
2368        let filtered = list.filtered_indices(&mut state);
2369        assert_eq!(&*filtered, &[1]); // only carrot
2370    }
2371
2372    #[test]
2373    fn list_multi_select_toggle_while_filtered() {
2374        let list = List::new(vec![
2375            ListItem::new("alpha"),
2376            ListItem::new("banana"),
2377            ListItem::new("beta"),
2378            ListItem::new("gamma"),
2379        ]);
2380        let mut state = ListState::default();
2381        state.set_multi_select(true);
2382        state.set_filter_query("b");
2383
2384        // Select banana(1) first
2385        state.select(Some(1));
2386        // Toggle multi-select on banana
2387        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2388        assert!(state.selected_indices().contains(&1));
2389
2390        // Navigate to beta and toggle
2391        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down)));
2392        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2393        assert!(state.selected_indices().contains(&2));
2394        assert_eq!(state.selected_count(), 2);
2395    }
2396
2397    #[test]
2398    fn list_disable_multi_select_clears_extras() {
2399        let mut state = ListState::default();
2400        state.set_multi_select(true);
2401        state.toggle_multi_selected(0);
2402        state.toggle_multi_selected(1);
2403        state.toggle_multi_selected(2);
2404        assert_eq!(state.selected_count(), 3);
2405        // toggle_multi_selected sets selected to the last toggled index
2406        assert_eq!(state.selected(), Some(2));
2407
2408        // Disable multi-select → should keep only the current selection
2409        state.set_multi_select(false);
2410        assert_eq!(state.selected_count(), 1);
2411        assert!(state.selected_indices().contains(&2)); // current selected
2412    }
2413
2414    #[test]
2415    fn list_navigation_with_ctrl_modifier_ignored() {
2416        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2417        let mut state = ListState::default();
2418        state.select(Some(0));
2419
2420        let ctrl_down = KeyEvent {
2421            code: KeyCode::Down,
2422            modifiers: Modifiers::CTRL,
2423            kind: ftui_core::event::KeyEventKind::Press,
2424        };
2425        assert!(!list.handle_key(&mut state, &ctrl_down));
2426        assert_eq!(state.selected(), Some(0)); // unchanged
2427    }
2428
2429    #[test]
2430    fn list_space_with_no_selection_in_multi_select_is_noop() {
2431        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2432        let mut state = ListState::default();
2433        state.set_multi_select(true);
2434        // No selection
2435        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char(' '))));
2436        assert_eq!(state.selected_count(), 0);
2437    }
2438
2439    #[test]
2440    fn list_backspace_on_empty_filter_returns_false() {
2441        let list = List::new(vec![ListItem::new("alpha")]);
2442        let mut state = ListState::default();
2443        assert!(state.filter_query().is_empty());
2444        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Backspace)));
2445    }
2446
2447    #[test]
2448    fn list_escape_on_empty_filter_returns_false() {
2449        let list = List::new(vec![ListItem::new("alpha")]);
2450        let mut state = ListState::default();
2451        assert!(state.filter_query().is_empty());
2452        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Escape)));
2453    }
2454
2455    #[test]
2456    fn list_navigate_in_empty_filtered_result() {
2457        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2458        let mut state = ListState::default();
2459        state.select(Some(0));
2460        state.set_filter_query("zzz"); // nothing matches
2461
2462        // Navigate should deselect since no filtered items
2463        let handled = list.handle_key(&mut state, &KeyEvent::new(KeyCode::Down));
2464        // Either handled (deselected) or not, selection should be None
2465        if handled {
2466            assert_eq!(state.selected(), None);
2467        }
2468    }
2469
2470    #[test]
2471    fn list_filter_preserves_selection_when_still_visible() {
2472        let list = List::new(vec![
2473            ListItem::new("alpha"),
2474            ListItem::new("banana"),
2475            ListItem::new("beta"),
2476        ]);
2477        let mut state = ListState::default();
2478        state.select(Some(2)); // beta
2479
2480        // Type "b" → beta still matches, selection should stay
2481        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2482        assert_eq!(state.selected(), Some(2)); // beta still selected
2483    }
2484
2485    #[test]
2486    fn list_filter_moves_selection_when_current_hidden() {
2487        let list = List::new(vec![
2488            ListItem::new("alpha"),
2489            ListItem::new("banana"),
2490            ListItem::new("cherry"),
2491        ]);
2492        let mut state = ListState::default();
2493        state.select(Some(2)); // cherry
2494
2495        // Type "b" → cherry doesn't match, should move to banana(1)
2496        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('b'))));
2497        assert_eq!(state.selected(), Some(1)); // banana (first match)
2498    }
2499
2500    #[test]
2501    #[allow(clippy::field_reassign_with_default)]
2502    fn list_set_filter_query_resets_offset() {
2503        let mut state = ListState::default();
2504        state.offset = 10;
2505        state.set_filter_query("abc");
2506        assert_eq!(state.offset, 0);
2507        assert_eq!(state.filter_query(), "abc");
2508    }
2509
2510    #[test]
2511    fn list_clear_filter_query_resets_offset() {
2512        let mut state = ListState::default();
2513        state.set_filter_query("abc");
2514        state.offset = 5;
2515        state.clear_filter_query();
2516        assert_eq!(state.offset, 0);
2517        assert!(state.filter_query().is_empty());
2518    }
2519
2520    #[test]
2521    #[allow(clippy::field_reassign_with_default)]
2522    fn list_clear_filter_query_noop_when_empty() {
2523        let mut state = ListState::default();
2524        state.offset = 5;
2525        state.clear_filter_query(); // already empty
2526        assert_eq!(state.offset, 5); // unchanged
2527    }
2528
2529    #[test]
2530    fn list_select_next_in_multi_select_preserves_others() {
2531        let mut state = ListState::default();
2532        state.set_multi_select(true);
2533        state.toggle_multi_selected(0);
2534        state.toggle_multi_selected(2);
2535        // toggle_multi_selected sets selected to last toggled (2)
2536        assert_eq!(state.selected(), Some(2));
2537        assert_eq!(state.selected_count(), 2);
2538
2539        // Navigate down should not clear multi_selected
2540        state.select_next(5);
2541        assert_eq!(state.selected(), Some(3)); // moved from 2 to 3
2542        assert!(state.selected_indices().contains(&0));
2543        assert!(state.selected_indices().contains(&2));
2544    }
2545
2546    #[test]
2547    fn list_deselect_clears_multi_selected() {
2548        let mut state = ListState::default();
2549        state.set_multi_select(true);
2550        state.toggle_multi_selected(0);
2551        state.toggle_multi_selected(1);
2552        state.toggle_multi_selected(2);
2553        assert_eq!(state.selected_count(), 3);
2554
2555        state.select(None);
2556        assert_eq!(state.selected_count(), 0);
2557        assert!(state.selected_indices().is_empty());
2558    }
2559
2560    #[test]
2561    fn list_vi_j_moves_through_filtered_items() {
2562        let list = List::new(vec![
2563            ListItem::new("xylophone"),
2564            ListItem::new("berry"),
2565            ListItem::new("box"),
2566            ListItem::new("cat"),
2567        ]);
2568        let mut state = ListState::default();
2569        state.set_filter_query("b");
2570        // Filtered: berry(1), box(2)
2571        state.select(Some(1)); // berry
2572
2573        // j should move to box(2), skipping xylophone(0) and cat(3)
2574        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2575        assert_eq!(state.selected(), Some(2)); // box
2576
2577        // j at end → should stay at box(2)
2578        assert!(!list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2579        assert_eq!(state.selected(), Some(2));
2580    }
2581
2582    #[test]
2583    fn list_jk_navigate_not_filter_even_when_empty() {
2584        // j/k are always vi-navigation and must never be appended to the
2585        // filter query, even when the filter is empty. (bd-2pp0c)
2586        let list = List::new(vec![
2587            ListItem::new("alpha"),
2588            ListItem::new("jam"),
2589            ListItem::new("kite"),
2590        ]);
2591        let mut state = ListState::default();
2592        assert!(state.filter_query().is_empty());
2593
2594        // j navigates down, does NOT start a filter for "j"
2595        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2596        assert!(state.filter_query().is_empty());
2597        assert_eq!(state.selected(), Some(0)); // first selection from None
2598
2599        // j again moves to next item
2600        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('j'))));
2601        assert!(state.filter_query().is_empty());
2602        assert_eq!(state.selected(), Some(1));
2603
2604        // k navigates up, does NOT start a filter for "k"
2605        assert!(list.handle_key(&mut state, &KeyEvent::new(KeyCode::Char('k'))));
2606        assert!(state.filter_query().is_empty());
2607        assert_eq!(state.selected(), Some(0));
2608    }
2609
2610    #[test]
2611    fn list_multi_select_untoggle_removes_from_set() {
2612        let mut state = ListState::default();
2613        state.set_multi_select(true);
2614        state.select(Some(0));
2615        state.toggle_multi_selected(0);
2616        assert!(state.selected_indices().contains(&0));
2617        assert_eq!(state.selected_count(), 1);
2618
2619        // Toggle again removes from multi_selected
2620        state.toggle_multi_selected(0);
2621        assert!(!state.selected_indices().contains(&0));
2622    }
2623
2624    #[test]
2625    fn list_widget_render_uses_default_state() {
2626        let list = List::new(vec![ListItem::new("alpha"), ListItem::new("beta")]);
2627        let mut state = ListState::default();
2628        let mut pool = GraphemePool::new();
2629        let mut frame = Frame::new(10, 3, &mut pool);
2630        StatefulWidget::render(&list, Rect::new(0, 0, 10, 3), &mut frame, &mut state);
2631        // No selection, first item should render at row 0
2632        assert_eq!(row_text(&frame, 0), "alpha");
2633        assert_eq!(row_text(&frame, 1), "beta");
2634    }
2635}