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;