Skip to main content

envision/component/selectable_list/
mod.rs

1//! A generic selectable list component with keyboard navigation.
2//!
3//! `SelectableList` provides a scrollable list of items with selection
4//! tracking and keyboard navigation (vim-style and arrow keys).
5//!
6//! # Example
7//!
8//! ```rust
9//! use envision::component::{Component, Focusable, SelectableListMessage, SelectableList, SelectableListState};
10//!
11//! // Create a list of items
12//! let mut state = SelectableList::<String>::init();
13//! state.set_items(vec!["Item 1".into(), "Item 2".into(), "Item 3".into()]);
14//!
15//! // Navigate down
16//! SelectableList::<String>::update(&mut state, SelectableListMessage::Down);
17//! assert_eq!(state.selected_index(), Some(1));
18//!
19//! // Get selected item
20//! assert_eq!(state.selected_item(), Some(&"Item 2".into()));
21//! ```
22
23use ratatui::prelude::*;
24use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
25
26use super::{Component, Focusable};
27use crate::input::{Event, KeyCode};
28use crate::theme::Theme;
29
30/// Messages that can be sent to a SelectableList.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum SelectableListMessage {
33    /// Move selection up by one.
34    Up,
35    /// Move selection down by one.
36    Down,
37    /// Move selection to the first item.
38    First,
39    /// Move selection to the last item.
40    Last,
41    /// Move selection up by a page.
42    PageUp(usize),
43    /// Move selection down by a page.
44    PageDown(usize),
45    /// Select the current item (triggers output).
46    Select,
47    /// Set the filter text for searching items.
48    SetFilter(String),
49    /// Clear the filter text.
50    ClearFilter,
51}
52
53/// Output messages from a SelectableList.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum SelectableListOutput<T: Clone> {
56    /// An item was selected (e.g., Enter pressed).
57    Selected(T),
58    /// The selection changed to a new index (original item index).
59    SelectionChanged(usize),
60    /// The filter text changed.
61    FilterChanged(String),
62}
63
64/// State for a SelectableList component.
65#[derive(Clone, Debug)]
66#[cfg_attr(
67    feature = "serialization",
68    derive(serde::Serialize, serde::Deserialize)
69)]
70pub struct SelectableListState<T: Clone> {
71    items: Vec<T>,
72    #[cfg_attr(feature = "serialization", serde(skip))]
73    list_state: ListState,
74    focused: bool,
75    disabled: bool,
76    filter_text: String,
77    filtered_indices: Vec<usize>,
78}
79
80impl<T: Clone + PartialEq> PartialEq for SelectableListState<T> {
81    fn eq(&self, other: &Self) -> bool {
82        self.items == other.items
83            && self.list_state.selected() == other.list_state.selected()
84            && self.focused == other.focused
85            && self.disabled == other.disabled
86            && self.filter_text == other.filter_text
87    }
88}
89
90impl<T: Clone> Default for SelectableListState<T> {
91    fn default() -> Self {
92        Self {
93            items: Vec::new(),
94            list_state: ListState::default(),
95            focused: false,
96            disabled: false,
97            filter_text: String::new(),
98            filtered_indices: Vec::new(),
99        }
100    }
101}
102
103impl<T: Clone> SelectableListState<T> {
104    /// Creates a new state with the given items.
105    ///
106    /// If the items list is non-empty, the first item is selected.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use envision::prelude::*;
112    ///
113    /// let state = SelectableListState::new(vec!["apple", "banana", "cherry"]);
114    /// assert_eq!(state.selected_index(), Some(0));
115    /// assert_eq!(state.len(), 3);
116    /// ```
117    pub fn new(items: Vec<T>) -> Self {
118        Self::with_items(items)
119    }
120
121    /// Creates a new state with the given items.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use envision::prelude::*;
127    ///
128    /// let state = SelectableListState::with_items(vec![1, 2, 3]);
129    /// assert_eq!(state.selected_item(), Some(&1));
130    /// ```
131    pub fn with_items(items: Vec<T>) -> Self {
132        let filtered_indices: Vec<usize> = (0..items.len()).collect();
133        let mut state = Self {
134            items,
135            list_state: ListState::default(),
136            focused: false,
137            disabled: false,
138            filter_text: String::new(),
139            filtered_indices,
140        };
141        if !state.items.is_empty() {
142            state.list_state.select(Some(0));
143        }
144        state
145    }
146
147    /// Sets the initially selected index (builder method).
148    ///
149    /// The index is clamped to the valid range. Has no effect on empty lists.
150    ///
151    /// # Example
152    ///
153    /// ```rust
154    /// use envision::component::SelectableListState;
155    ///
156    /// let state = SelectableListState::new(vec!["A", "B", "C"]).with_selected(1);
157    /// assert_eq!(state.selected_index(), Some(1));
158    /// assert_eq!(state.selected_item(), Some(&"B"));
159    /// ```
160    pub fn with_selected(mut self, index: usize) -> Self {
161        if self.items.is_empty() {
162            return self;
163        }
164        let clamped = index.min(self.items.len() - 1);
165        if let Some(filtered_pos) = self.filtered_indices.iter().position(|&fi| fi == clamped) {
166            self.list_state.select(Some(filtered_pos));
167        }
168        self
169    }
170
171    /// Returns a reference to the items.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use envision::prelude::*;
177    ///
178    /// let state = SelectableListState::new(vec!["a", "b", "c"]);
179    /// assert_eq!(state.items(), &["a", "b", "c"]);
180    /// ```
181    pub fn items(&self) -> &[T] {
182        &self.items
183    }
184
185    /// Sets the items, clearing any active filter and resetting selection.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use envision::prelude::*;
191    ///
192    /// let mut state = SelectableListState::new(vec!["old"]);
193    /// state.set_items(vec!["new1", "new2"]);
194    /// assert_eq!(state.items(), &["new1", "new2"]);
195    /// assert_eq!(state.selected_index(), Some(0));
196    /// ```
197    pub fn set_items(&mut self, items: Vec<T>) {
198        self.items = items;
199        self.filter_text.clear();
200        self.filtered_indices = (0..self.items.len()).collect();
201        if self.filtered_indices.is_empty() {
202            self.list_state.select(None);
203        } else {
204            let current = self.list_state.selected().unwrap_or(0);
205            let new_index = current.min(self.filtered_indices.len().saturating_sub(1));
206            self.list_state.select(Some(new_index));
207        }
208    }
209
210    /// Returns the currently selected index in the original items list.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use envision::prelude::*;
216    ///
217    /// let state = SelectableListState::new(vec!["a", "b", "c"]);
218    /// assert_eq!(state.selected_index(), Some(0));
219    ///
220    /// let empty: SelectableListState<String> = SelectableListState::new(vec![]);
221    /// assert_eq!(empty.selected_index(), None);
222    /// ```
223    pub fn selected_index(&self) -> Option<usize> {
224        self.list_state
225            .selected()
226            .and_then(|i| self.filtered_indices.get(i).copied())
227    }
228
229    /// Returns a reference to the currently selected item.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use envision::prelude::*;
235    ///
236    /// let state = SelectableListState::new(vec!["a", "b", "c"]);
237    /// assert_eq!(state.selected_item(), Some(&"a"));
238    /// ```
239    pub fn selected_item(&self) -> Option<&T> {
240        self.selected_index().and_then(|i| self.items.get(i))
241    }
242
243    /// Selects the item at the given index in the original items list.
244    ///
245    /// If the item is filtered out, the selection is unchanged.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use envision::prelude::*;
251    ///
252    /// let mut state = SelectableListState::new(vec!["a", "b", "c"]);
253    /// state.select(Some(2));
254    /// assert_eq!(state.selected_index(), Some(2));
255    /// assert_eq!(state.selected_item(), Some(&"c"));
256    /// ```
257    pub fn select(&mut self, index: Option<usize>) {
258        match index {
259            Some(i) if i < self.items.len() => {
260                if let Some(filtered_pos) = self.filtered_indices.iter().position(|&fi| fi == i) {
261                    self.list_state.select(Some(filtered_pos));
262                }
263            }
264            Some(_) => {} // Index out of bounds, ignore
265            None => self.list_state.select(None),
266        }
267    }
268
269    /// Sets the selected index.
270    ///
271    /// The index is clamped to the valid range. Has no effect on empty lists.
272    /// If the item at the given index is filtered out, the selection is unchanged.
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use envision::prelude::*;
278    ///
279    /// let mut state = SelectableListState::new(vec!["a", "b", "c"]);
280    /// state.set_selected(2);
281    /// assert_eq!(state.selected_index(), Some(2));
282    /// assert_eq!(state.selected_item(), Some(&"c"));
283    /// ```
284    pub fn set_selected(&mut self, index: usize) {
285        if self.items.is_empty() {
286            return;
287        }
288        let clamped = index.min(self.items.len() - 1);
289        if let Some(filtered_pos) = self.filtered_indices.iter().position(|&fi| fi == clamped) {
290            self.list_state.select(Some(filtered_pos));
291        }
292    }
293
294    /// Returns true if the list is empty.
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// use envision::prelude::*;
300    ///
301    /// let empty: SelectableListState<i32> = SelectableListState::new(vec![]);
302    /// assert!(empty.is_empty());
303    ///
304    /// let non_empty = SelectableListState::new(vec![1]);
305    /// assert!(!non_empty.is_empty());
306    /// ```
307    pub fn is_empty(&self) -> bool {
308        self.items.is_empty()
309    }
310
311    /// Returns the number of items in the list.
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// use envision::prelude::*;
317    ///
318    /// let state = SelectableListState::new(vec!["a", "b", "c"]);
319    /// assert_eq!(state.len(), 3);
320    /// ```
321    pub fn len(&self) -> usize {
322        self.items.len()
323    }
324
325    /// Returns the current filter text.
326    pub fn filter_text(&self) -> &str {
327        &self.filter_text
328    }
329
330    /// Returns the number of items visible after filtering.
331    pub fn visible_count(&self) -> usize {
332        self.filtered_indices.len()
333    }
334}
335
336impl<T: Clone + std::fmt::Display + 'static> SelectableListState<T> {
337    /// Returns true if the selectable list is focused.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use envision::prelude::*;
343    ///
344    /// let state = SelectableListState::new(vec!["a", "b"]);
345    /// assert!(!state.is_focused());
346    /// ```
347    pub fn is_focused(&self) -> bool {
348        self.focused
349    }
350
351    /// Sets the focus state.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use envision::prelude::*;
357    ///
358    /// let mut state = SelectableListState::new(vec!["a", "b"]);
359    /// state.set_focused(true);
360    /// assert!(state.is_focused());
361    /// ```
362    pub fn set_focused(&mut self, focused: bool) {
363        self.focused = focused;
364    }
365
366    /// Returns true if the selectable list is disabled.
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use envision::prelude::*;
372    ///
373    /// let state = SelectableListState::new(vec!["a"]);
374    /// assert!(!state.is_disabled());
375    /// ```
376    pub fn is_disabled(&self) -> bool {
377        self.disabled
378    }
379
380    /// Sets the disabled state.
381    ///
382    /// # Examples
383    ///
384    /// ```
385    /// use envision::prelude::*;
386    ///
387    /// let mut state = SelectableListState::new(vec!["a"]);
388    /// state.set_disabled(true);
389    /// assert!(state.is_disabled());
390    /// ```
391    pub fn set_disabled(&mut self, disabled: bool) {
392        self.disabled = disabled;
393    }
394
395    /// Sets the disabled state using builder pattern.
396    pub fn with_disabled(mut self, disabled: bool) -> Self {
397        self.disabled = disabled;
398        self
399    }
400
401    /// Sets the filter text for case-insensitive substring matching.
402    ///
403    /// Items whose `Display` output contains the filter text (case-insensitive)
404    /// are shown. Selection is preserved if the selected item remains visible,
405    /// otherwise it moves to the first visible item.
406    pub fn set_filter_text(&mut self, text: &str) {
407        self.filter_text = text.to_string();
408        self.apply_filter();
409    }
410
411    /// Clears the filter, showing all items.
412    pub fn clear_filter(&mut self) {
413        self.filter_text.clear();
414        self.apply_filter();
415    }
416
417    /// Recomputes filtered_indices based on the current filter_text.
418    fn apply_filter(&mut self) {
419        let previously_selected = self.selected_index();
420
421        if self.filter_text.is_empty() {
422            self.filtered_indices = (0..self.items.len()).collect();
423        } else {
424            let filter_lower = self.filter_text.to_lowercase();
425            self.filtered_indices = self
426                .items
427                .iter()
428                .enumerate()
429                .filter(|(_, item)| format!("{}", item).to_lowercase().contains(&filter_lower))
430                .map(|(i, _)| i)
431                .collect();
432        }
433
434        // Try to preserve the previously selected item
435        if let Some(prev_idx) = previously_selected {
436            if let Some(new_pos) = self.filtered_indices.iter().position(|&i| i == prev_idx) {
437                self.list_state.select(Some(new_pos));
438                return;
439            }
440        }
441
442        // Otherwise, select first visible item or none
443        if self.filtered_indices.is_empty() {
444            self.list_state.select(None);
445        } else {
446            self.list_state.select(Some(0));
447        }
448    }
449
450    /// Maps an input event to a selectable list message.
451    pub fn handle_event(&self, event: &Event) -> Option<SelectableListMessage> {
452        SelectableList::<T>::handle_event(self, event)
453    }
454
455    /// Dispatches an event, updating state and returning any output.
456    pub fn dispatch_event(&mut self, event: &Event) -> Option<SelectableListOutput<T>> {
457        SelectableList::<T>::dispatch_event(self, event)
458    }
459
460    /// Updates the selectable list state with a message, returning any output.
461    pub fn update(&mut self, msg: SelectableListMessage) -> Option<SelectableListOutput<T>> {
462        SelectableList::<T>::update(self, msg)
463    }
464}
465
466/// A generic selectable list component.
467///
468/// This component provides a scrollable list with keyboard navigation.
469/// It's generic over the item type `T`, which must be `Clone`.
470///
471/// # Navigation
472///
473/// - `Up` / `Down` - Move selection by one
474/// - `First` / `Last` - Jump to beginning/end
475/// - `PageUp` / `PageDown` - Move by page size
476/// - `Select` - Emit the selected item
477pub struct SelectableList<T: Clone>(std::marker::PhantomData<T>);
478
479impl<T: Clone + std::fmt::Display + 'static> Component for SelectableList<T> {
480    type State = SelectableListState<T>;
481    type Message = SelectableListMessage;
482    type Output = SelectableListOutput<T>;
483
484    fn init() -> Self::State {
485        SelectableListState::default()
486    }
487
488    fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
489        if !state.focused || state.disabled {
490            return None;
491        }
492        if let Some(key) = event.as_key() {
493            match key.code {
494                KeyCode::Up | KeyCode::Char('k') => Some(SelectableListMessage::Up),
495                KeyCode::Down | KeyCode::Char('j') => Some(SelectableListMessage::Down),
496                KeyCode::Home | KeyCode::Char('g') => Some(SelectableListMessage::First),
497                KeyCode::End | KeyCode::Char('G') => Some(SelectableListMessage::Last),
498                KeyCode::Enter => Some(SelectableListMessage::Select),
499                KeyCode::PageUp => Some(SelectableListMessage::PageUp(10)),
500                KeyCode::PageDown => Some(SelectableListMessage::PageDown(10)),
501                _ => None,
502            }
503        } else {
504            None
505        }
506    }
507
508    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
509        match msg {
510            SelectableListMessage::SetFilter(text) => {
511                state.set_filter_text(&text);
512                return Some(SelectableListOutput::FilterChanged(text));
513            }
514            SelectableListMessage::ClearFilter => {
515                state.clear_filter();
516                return Some(SelectableListOutput::FilterChanged(String::new()));
517            }
518            _ => {}
519        }
520
521        if state.disabled || state.filtered_indices.is_empty() {
522            return None;
523        }
524
525        let len = state.filtered_indices.len();
526        let current = state.list_state.selected().unwrap_or(0);
527
528        match msg {
529            SelectableListMessage::Up => {
530                let new_index = current.saturating_sub(1);
531                if new_index != current {
532                    state.list_state.select(Some(new_index));
533                    let orig = state.filtered_indices[new_index];
534                    return Some(SelectableListOutput::SelectionChanged(orig));
535                }
536            }
537            SelectableListMessage::Down => {
538                let new_index = (current + 1).min(len - 1);
539                if new_index != current {
540                    state.list_state.select(Some(new_index));
541                    let orig = state.filtered_indices[new_index];
542                    return Some(SelectableListOutput::SelectionChanged(orig));
543                }
544            }
545            SelectableListMessage::First => {
546                if current != 0 {
547                    state.list_state.select(Some(0));
548                    let orig = state.filtered_indices[0];
549                    return Some(SelectableListOutput::SelectionChanged(orig));
550                }
551            }
552            SelectableListMessage::Last => {
553                let last = len - 1;
554                if current != last {
555                    state.list_state.select(Some(last));
556                    let orig = state.filtered_indices[last];
557                    return Some(SelectableListOutput::SelectionChanged(orig));
558                }
559            }
560            SelectableListMessage::PageUp(page_size) => {
561                let new_index = current.saturating_sub(page_size);
562                if new_index != current {
563                    state.list_state.select(Some(new_index));
564                    let orig = state.filtered_indices[new_index];
565                    return Some(SelectableListOutput::SelectionChanged(orig));
566                }
567            }
568            SelectableListMessage::PageDown(page_size) => {
569                let new_index = (current + page_size).min(len - 1);
570                if new_index != current {
571                    state.list_state.select(Some(new_index));
572                    let orig = state.filtered_indices[new_index];
573                    return Some(SelectableListOutput::SelectionChanged(orig));
574                }
575            }
576            SelectableListMessage::Select => {
577                let orig = state.filtered_indices[current];
578                if let Some(item) = state.items.get(orig).cloned() {
579                    return Some(SelectableListOutput::Selected(item));
580                }
581            }
582            SelectableListMessage::SetFilter(_) | SelectableListMessage::ClearFilter => {
583                unreachable!("handled above")
584            }
585        }
586
587        None
588    }
589
590    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
591        let items: Vec<ListItem> = state
592            .filtered_indices
593            .iter()
594            .map(|&idx| ListItem::new(format!("{}", state.items[idx])))
595            .collect();
596
597        let highlight_style = if state.disabled {
598            theme.disabled_style()
599        } else {
600            theme.selected_highlight_style(state.focused)
601        };
602
603        let list = List::new(items)
604            .block(Block::default().borders(Borders::ALL))
605            .highlight_style(highlight_style)
606            .highlight_symbol("> ");
607
608        // We need to clone the state for rendering since StatefulWidget needs &mut
609        let mut list_state = state.list_state.clone();
610        frame.render_stateful_widget(list, area, &mut list_state);
611    }
612}
613
614impl<T: Clone + std::fmt::Display + 'static> Focusable for SelectableList<T> {
615    fn is_focused(state: &Self::State) -> bool {
616        state.focused
617    }
618
619    fn set_focused(state: &mut Self::State, focused: bool) {
620        state.focused = focused;
621    }
622}
623
624#[cfg(test)]
625mod tests;