Skip to main content

rpi_tui/
select_list.rs

1//! SelectList component - a selectable list with filtering.
2//!
3//! Provides a list of items that can be navigated and selected.
4
5use std::any::Any;
6use std::sync::{Arc, Mutex};
7
8use super::component::Component;
9use super::fuzzy::fuzzy_filter;
10use crate::utils::{truncate_to_width, visible_width};
11
12const DEFAULT_PRIMARY_COLUMN_WIDTH: usize = 32;
13const PRIMARY_COLUMN_GAP: usize = 2;
14const MIN_DESCRIPTION_WIDTH: usize = 10;
15
16/// A select list item.
17#[derive(Debug, Clone)]
18pub struct SelectItem {
19    /// The value (returned on selection).
20    pub value: String,
21    /// Display label.
22    pub label: String,
23    /// Optional description.
24    pub description: Option<String>,
25    /// Optional extra text used ONLY by fuzzy filtering. Lets a searchable
26    /// selector match on the display name, provider, or a qualified id while
27    /// `value` stays the stable selection key (upstream's
28    /// `getModelSelectorSearchText`). Falls back to `value` when unset.
29    pub search_text: Option<String>,
30}
31
32impl SelectItem {
33    /// Create a new select item.
34    pub fn new(value: &str, label: &str) -> Self {
35        Self {
36            value: value.to_string(),
37            label: label.to_string(),
38            description: None,
39            search_text: None,
40        }
41    }
42
43    /// Add a description.
44    pub fn with_description(mut self, description: &str) -> Self {
45        self.description = Some(description.to_string());
46        self
47    }
48
49    /// Add the text fuzzy filtering should match against (instead of `value`).
50    pub fn with_search_text(mut self, search_text: &str) -> Self {
51        self.search_text = Some(search_text.to_string());
52        self
53    }
54
55    /// The string fuzzy filtering matches against.
56    pub fn search_key(&self) -> &str {
57        self.search_text.as_deref().unwrap_or(&self.value)
58    }
59
60    /// Get the display value (label or value).
61    pub fn display_value(&self) -> &str {
62        if self.label.is_empty() {
63            &self.value
64        } else {
65            &self.label
66        }
67    }
68}
69
70/// Theme for select list.
71#[derive(Debug, Clone)]
72pub struct SelectListTheme {
73    /// Style for selected item prefix.
74    pub selected_prefix: fn(&str) -> String,
75    /// Style for selected item text.
76    pub selected_text: fn(&str) -> String,
77    /// Style for description text.
78    pub description: fn(&str) -> String,
79    /// Style for scroll info.
80    pub scroll_info: fn(&str) -> String,
81    /// Style for no match message.
82    pub no_match: fn(&str) -> String,
83}
84
85impl Default for SelectListTheme {
86    fn default() -> Self {
87        Self {
88            selected_prefix: |s| format!("\x1b[32m{}\x1b[0m", s),
89            selected_text: |s| format!("\x1b[1;36m{}\x1b[0m", s),
90            description: |s| format!("\x1b[90m{}\x1b[0m", s),
91            scroll_info: |s| format!("\x1b[90m{}\x1b[0m", s),
92            no_match: |s| format!("\x1b[90m{}\x1b[0m", s),
93        }
94    }
95}
96
97/// Layout options for select list.
98#[derive(Clone)]
99pub struct SelectListLayoutOptions {
100    /// Minimum width for the primary column.
101    pub min_primary_column_width: Option<usize>,
102    /// Maximum width for the primary column.
103    pub max_primary_column_width: Option<usize>,
104    /// Custom truncation function.
105    pub truncate_primary: Option<Arc<dyn Fn(&str, usize, usize) -> String + Send + Sync>>,
106}
107
108impl Default for SelectListLayoutOptions {
109    fn default() -> Self {
110        Self {
111            min_primary_column_width: None,
112            max_primary_column_width: None,
113            truncate_primary: None,
114        }
115    }
116}
117
118/// SelectList component.
119pub struct SelectList {
120    items: Mutex<Vec<SelectItem>>,
121    filtered_items: Mutex<Vec<SelectItem>>,
122    selected_index: Mutex<usize>,
123    /// Values toggled in multi-select mode. Values (rather than indexes) are
124    /// retained so filtering does not lose the user's selections.
125    selected_values: Mutex<std::collections::HashSet<String>>,
126    multi_select: bool,
127    max_visible: usize,
128    theme: SelectListTheme,
129    layout: SelectListLayoutOptions,
130    on_select: Mutex<Option<Arc<dyn Fn(&SelectItem) + Send + Sync>>>,
131    on_multi_select: Mutex<Option<Arc<dyn Fn(&[SelectItem]) + Send + Sync>>>,
132    on_cancel: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
133    on_selection_change: Mutex<Option<Arc<dyn Fn(&SelectItem) + Send + Sync>>>,
134}
135
136impl SelectList {
137    /// Create a new select list.
138    pub fn new(items: Vec<SelectItem>, max_visible: usize) -> Self {
139        Self {
140            filtered_items: Mutex::new(items.clone()),
141            items: Mutex::new(items),
142            selected_index: Mutex::new(0),
143            selected_values: Mutex::new(std::collections::HashSet::new()),
144            multi_select: false,
145            max_visible,
146            theme: SelectListTheme::default(),
147            layout: SelectListLayoutOptions::default(),
148            on_select: Mutex::new(None),
149            on_multi_select: Mutex::new(None),
150            on_cancel: Mutex::new(None),
151            on_selection_change: Mutex::new(None),
152        }
153    }
154
155    /// Create a select list with a theme.
156    pub fn with_theme(items: Vec<SelectItem>, max_visible: usize, theme: SelectListTheme) -> Self {
157        Self {
158            filtered_items: Mutex::new(items.clone()),
159            items: Mutex::new(items),
160            selected_index: Mutex::new(0),
161            selected_values: Mutex::new(std::collections::HashSet::new()),
162            multi_select: false,
163            max_visible,
164            theme,
165            layout: SelectListLayoutOptions::default(),
166            on_select: Mutex::new(None),
167            on_multi_select: Mutex::new(None),
168            on_cancel: Mutex::new(None),
169            on_selection_change: Mutex::new(None),
170        }
171    }
172
173    /// Override the primary/description column layout. The session picker uses
174    /// this to widen the primary column so a preview is not clipped to the
175    /// 32-column default.
176    pub fn set_layout(&mut self, layout: SelectListLayoutOptions) {
177        self.layout = layout;
178    }
179
180    /// Create a selector that permits selecting several options with Space
181    /// before submitting with Enter. Existing single-select selectors should
182    /// continue using [`Self::new`].
183    pub fn new_multi(items: Vec<SelectItem>, max_visible: usize) -> Self {
184        let mut list = Self::new(items, max_visible);
185        list.multi_select = true;
186        list
187    }
188
189    /// Whether this list is in multi-select mode.
190    pub fn is_multi_select(&self) -> bool {
191        self.multi_select
192    }
193
194    /// Set a callback invoked with all toggled options when Enter is pressed.
195    pub fn on_multi_select(&self, callback: Arc<dyn Fn(&[SelectItem]) + Send + Sync>) {
196        if let Ok(mut cb) = self.on_multi_select.lock() {
197            *cb = Some(callback);
198        }
199    }
200
201    /// Return the currently toggled options, preserving the source order.
202    pub fn get_selected_items(&self) -> Vec<SelectItem> {
203        let selected = self.selected_values.lock().ok();
204        let items = self.items.lock().ok();
205        match (selected, items) {
206            (Some(selected), Some(items)) => items
207                .iter()
208                .filter(|item| selected.contains(&item.value))
209                .cloned()
210                .collect(),
211            _ => Vec::new(),
212        }
213    }
214
215    /// Toggle the currently highlighted option. No-op for a regular selector.
216    pub fn toggle_selected(&self) {
217        if !self.multi_select {
218            return;
219        }
220        let Some(item) = self.get_selected_item() else {
221            return;
222        };
223        if let Ok(mut selected) = self.selected_values.lock() {
224            if !selected.insert(item.value.clone()) {
225                selected.remove(&item.value);
226            }
227        }
228    }
229
230    /// Whether an item is currently toggled in multi-select mode.
231    fn is_selected_value(&self, value: &str) -> bool {
232        self.selected_values
233            .lock()
234            .map(|values| values.contains(value))
235            .unwrap_or(false)
236    }
237
238    /// Set the filter text.
239    pub fn set_filter(&self, filter: &str) {
240        if let Ok(items) = self.items.lock() {
241            let filtered = fuzzy_filter(&items, filter, |item| item.search_key());
242            if let Ok(mut filtered_items) = self.filtered_items.lock() {
243                *filtered_items = filtered;
244            }
245        }
246        // Reset selection when filter changes
247        if let Ok(mut index) = self.selected_index.lock() {
248            *index = 0;
249        }
250    }
251
252    /// Set the selected index.
253    pub fn set_selected_index(&self, index: usize) {
254        if let Ok(mut current) = self.selected_index.lock() {
255            if let Ok(filtered) = self.filtered_items.lock() {
256                *current = index.min(filtered.len().saturating_sub(1));
257            }
258        }
259    }
260
261    /// Get the selected item.
262    pub fn get_selected_item(&self) -> Option<SelectItem> {
263        let index = self.selected_index.lock().ok()?;
264        let filtered = self.filtered_items.lock().ok()?;
265        filtered.get(*index).cloned()
266    }
267
268    /// Set callback for when an item is selected.
269    pub fn on_select(&self, callback: Arc<dyn Fn(&SelectItem) + Send + Sync>) {
270        if let Ok(mut cb) = self.on_select.lock() {
271            *cb = Some(callback);
272        }
273    }
274
275    /// Set callback for when cancelled.
276    pub fn on_cancel(&self, callback: Arc<dyn Fn() + Send + Sync>) {
277        if let Ok(mut cb) = self.on_cancel.lock() {
278            *cb = Some(callback);
279        }
280    }
281
282    /// Set callback for when selection changes.
283    pub fn on_selection_change(&self, callback: Arc<dyn Fn(&SelectItem) + Send + Sync>) {
284        if let Ok(mut cb) = self.on_selection_change.lock() {
285            *cb = Some(callback);
286        }
287    }
288
289    /// Handle key input.
290    pub fn handle_key(&self, key: crossterm::event::KeyEvent) {
291        use crossterm::event::{KeyCode, KeyModifiers};
292
293        match key.code {
294            KeyCode::Up => {
295                self.move_up();
296            }
297            KeyCode::Down => {
298                self.move_down();
299            }
300            KeyCode::Enter => {
301                self.confirm();
302            }
303            KeyCode::Char(' ') if self.multi_select => {
304                self.toggle_selected();
305            }
306            KeyCode::Esc => {
307                self.cancel();
308            }
309            KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
310                self.move_up();
311            }
312            KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
313                self.move_down();
314            }
315            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
316                self.move_up();
317            }
318            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
319                self.move_down();
320            }
321            KeyCode::PageUp => {
322                // Page up in the list (tui.select.pageUp).
323                for _ in 0..self.max_visible.saturating_sub(1) {
324                    self.move_up();
325                }
326            }
327            KeyCode::PageDown => {
328                // Page down in the list (tui.select.pageDown).
329                for _ in 0..self.max_visible.saturating_sub(1) {
330                    self.move_down();
331                }
332            }
333            _ => {}
334        }
335    }
336
337    /// Move selection up.
338    fn move_up(&self) {
339        if let Ok(mut index) = self.selected_index.lock() {
340            if let Ok(filtered) = self.filtered_items.lock() {
341                if filtered.is_empty() {
342                    return;
343                }
344                if *index == 0 {
345                    *index = filtered.len() - 1;
346                } else {
347                    *index -= 1;
348                }
349                if let Some(item) = filtered.get(*index) {
350                    self.notify_selection_change(item);
351                }
352            }
353        }
354    }
355
356    /// Move selection down.
357    fn move_down(&self) {
358        if let Ok(mut index) = self.selected_index.lock() {
359            if let Ok(filtered) = self.filtered_items.lock() {
360                if filtered.is_empty() {
361                    return;
362                }
363                if *index >= filtered.len() - 1 {
364                    *index = 0;
365                } else {
366                    *index += 1;
367                }
368                if let Some(item) = filtered.get(*index) {
369                    self.notify_selection_change(item);
370                }
371            }
372        }
373    }
374
375    /// Confirm selection.
376    fn confirm(&self) {
377        if self.multi_select {
378            let selected = self.get_selected_items();
379            // A bare Enter is still useful when the user did not press Space:
380            // treat the highlighted row as the sole answer, matching native
381            // selector ergonomics while retaining explicit multi-toggle UX.
382            let selected = if selected.is_empty() {
383                self.get_selected_item().into_iter().collect::<Vec<_>>()
384            } else {
385                selected
386            };
387            if let Ok(cb) = self.on_multi_select.lock() {
388                if let Some(callback) = cb.as_ref() {
389                    callback(&selected);
390                }
391            }
392            return;
393        }
394        if let Some(item) = self.get_selected_item() {
395            if let Ok(cb) = self.on_select.lock() {
396                if let Some(callback) = cb.as_ref() {
397                    callback(&item);
398                }
399            }
400        }
401    }
402
403    /// Cancel selection.
404    fn cancel(&self) {
405        if let Ok(cb) = self.on_cancel.lock() {
406            if let Some(callback) = cb.as_ref() {
407                callback();
408            }
409        }
410    }
411
412    /// Notify selection change.
413    fn notify_selection_change(&self, item: &SelectItem) {
414        if let Ok(cb) = self.on_selection_change.lock() {
415            if let Some(callback) = cb.as_ref() {
416                callback(item);
417            }
418        }
419    }
420
421    /// Get primary column width.
422    fn get_primary_column_width(&self, filtered: &[SelectItem]) -> usize {
423        let (min_width, max_width) = self.get_primary_column_bounds();
424
425        let widest = filtered
426            .iter()
427            .map(|item| visible_width(item.display_value()) + PRIMARY_COLUMN_GAP)
428            .max()
429            .unwrap_or(DEFAULT_PRIMARY_COLUMN_WIDTH);
430
431        widest.clamp(min_width, max_width)
432    }
433
434    /// Get primary column bounds.
435    fn get_primary_column_bounds(&self) -> (usize, usize) {
436        let raw_min = self
437            .layout
438            .min_primary_column_width
439            .or(self.layout.max_primary_column_width)
440            .unwrap_or(DEFAULT_PRIMARY_COLUMN_WIDTH);
441        let raw_max = self
442            .layout
443            .max_primary_column_width
444            .or(self.layout.min_primary_column_width)
445            .unwrap_or(DEFAULT_PRIMARY_COLUMN_WIDTH);
446
447        let min = raw_min.min(raw_max).max(1);
448        let max = raw_min.max(raw_max).max(1);
449        (min, max)
450    }
451
452    /// Render an item.
453    fn render_item(
454        &self,
455        item: &SelectItem,
456        is_selected: bool,
457        width: usize,
458        primary_column_width: usize,
459    ) -> String {
460        let prefix = if self.multi_select {
461            let marker = if self.is_selected_value(&item.value) {
462                "x"
463            } else {
464                " "
465            };
466            if is_selected {
467                format!("→ [{}] ", marker)
468            } else {
469                format!("  [{}] ", marker)
470            }
471        } else if is_selected {
472            "→ ".to_string()
473        } else {
474            "  ".to_string()
475        };
476        let prefix_width = visible_width(&prefix);
477
478        // Handle description
479        if let Some(ref description) = item.description {
480            if width > 40 {
481                let effective_primary =
482                    primary_column_width.min(width.saturating_sub(prefix_width + 4));
483                let max_primary = effective_primary.saturating_sub(PRIMARY_COLUMN_GAP).max(1);
484
485                let truncated_value =
486                    self.truncate_primary(item, is_selected, max_primary, effective_primary);
487                let value_width = visible_width(&truncated_value);
488                let spacing = " ".repeat(effective_primary.saturating_sub(value_width).max(1));
489
490                let desc_start = prefix_width + value_width + spacing.len();
491                let remaining = width.saturating_sub(desc_start + 2);
492
493                if remaining > MIN_DESCRIPTION_WIDTH {
494                    let truncated_desc =
495                        truncate_to_width(&normalize_single_line(description), remaining, "");
496                    if is_selected {
497                        return (self.theme.selected_text)(&format!(
498                            "{}{}{}{}",
499                            prefix, truncated_value, spacing, truncated_desc
500                        ));
501                    }
502                    let desc_text =
503                        (self.theme.description)(&format!("{}{}", spacing, truncated_desc));
504                    return format!("{}{}{}", prefix, truncated_value, desc_text);
505                }
506            }
507        }
508
509        // No description or not enough space
510        let max_width = width.saturating_sub(prefix_width + 2);
511        let truncated = self.truncate_primary(item, is_selected, max_width, max_width);
512
513        if is_selected {
514            (self.theme.selected_text)(&format!("{}{}", prefix, truncated))
515        } else {
516            format!("{}{}", prefix, truncated)
517        }
518    }
519
520    /// Truncate primary text.
521    fn truncate_primary(
522        &self,
523        item: &SelectItem,
524        _is_selected: bool,
525        max_width: usize,
526        column_width: usize,
527    ) -> String {
528        let display = item.display_value();
529
530        if let Some(ref truncate_fn) = self.layout.truncate_primary {
531            truncate_fn(display, max_width, column_width)
532        } else {
533            truncate_to_width(display, max_width, "")
534        }
535    }
536}
537
538impl Component for SelectList {
539    fn render(&self, width: usize) -> Vec<String> {
540        let filtered = if let Ok(filtered) = self.filtered_items.lock() {
541            filtered.clone()
542        } else {
543            return Vec::new();
544        };
545
546        let mut lines = Vec::new();
547
548        if filtered.is_empty() {
549            lines.push((self.theme.no_match)("  No matching items"));
550            return lines;
551        }
552
553        let selected = self
554            .selected_index
555            .lock()
556            .unwrap_or_else(|e| e.into_inner());
557        let primary_column_width = self.get_primary_column_width(&filtered);
558
559        // Calculate visible range with scrolling
560        let start_index = if filtered.len() <= self.max_visible {
561            0
562        } else {
563            (*selected)
564                .saturating_sub(self.max_visible / 2)
565                .min(filtered.len().saturating_sub(self.max_visible))
566        };
567        let end_index = (start_index + self.max_visible).min(filtered.len());
568
569        // Render visible items
570        for i in start_index..end_index {
571            if let Some(item) = filtered.get(i) {
572                let is_selected = i == *selected;
573                lines.push(self.render_item(item, is_selected, width, primary_column_width));
574            }
575        }
576
577        // Add scroll indicator if needed
578        if start_index > 0 || end_index < filtered.len() {
579            let scroll_text = format!("  ({}/{})", *selected + 1, filtered.len());
580            lines.push((self.theme.scroll_info)(&truncate_to_width(
581                &scroll_text,
582                width.saturating_sub(2),
583                "",
584            )));
585        }
586
587        lines
588    }
589
590    fn invalidate(&self) {
591        // No cached state
592    }
593
594    fn as_any(&self) -> &dyn Any {
595        self
596    }
597}
598
599/// Normalize text to a single line.
600fn normalize_single_line(text: &str) -> String {
601    text.replace(|c: char| c == '\r' || c == '\n', " ")
602        .trim()
603        .to_string()
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_select_list_new() {
612        let items = vec![SelectItem::new("one", "One"), SelectItem::new("two", "Two")];
613        let list = SelectList::new(items, 5);
614
615        let lines = list.render(40);
616        assert_eq!(lines.len(), 2);
617    }
618
619    #[test]
620    fn test_select_list_filter() {
621        let items = vec![
622            SelectItem::new("apple", "Apple"),
623            SelectItem::new("banana", "Banana"),
624            SelectItem::new("apricot", "Apricot"),
625        ];
626        let list = SelectList::new(items, 5);
627
628        list.set_filter("ap");
629        let lines = list.render(40);
630        assert_eq!(lines.len(), 2); // Apple and Apricot
631    }
632
633    #[test]
634    fn test_select_list_navigation() {
635        let items = vec![SelectItem::new("one", "One"), SelectItem::new("two", "Two")];
636        let list = SelectList::new(items, 5);
637
638        assert_eq!(list.get_selected_item().unwrap().value, "one");
639
640        list.set_selected_index(1);
641        assert_eq!(list.get_selected_item().unwrap().value, "two");
642    }
643
644    #[test]
645    fn test_select_item() {
646        let item = SelectItem::new("value", "Label").with_description("Description");
647
648        assert_eq!(item.value, "value");
649        assert_eq!(item.label, "Label");
650        assert_eq!(item.description, Some("Description".to_string()));
651        assert_eq!(item.display_value(), "Label");
652    }
653
654    #[test]
655    fn test_multi_select_toggle_and_submit() {
656        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
657        let list = SelectList::new_multi(
658            vec![SelectItem::new("one", "One"), SelectItem::new("two", "Two")],
659            5,
660        );
661        let submitted = Arc::new(Mutex::new(Vec::<String>::new()));
662        let submitted_copy = submitted.clone();
663        list.on_multi_select(Arc::new(move |items| {
664            *submitted_copy.lock().unwrap() = items.iter().map(|item| item.value.clone()).collect();
665        }));
666
667        list.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
668        list.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
669        list.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
670        list.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
671
672        assert_eq!(
673            submitted.lock().unwrap().as_slice(),
674            &["one".to_string(), "two".to_string()]
675        );
676        assert!(list.render(40)[0].contains("[x]"));
677    }
678}