Skip to main content

rab/tui/components/
select_list.rs

1#![allow(clippy::type_complexity)]
2
3use crate::tui::component::Component;
4use crate::tui::fuzzy::fuzzy_filter;
5use crate::tui::keybindings::{
6    ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM,
7    ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
8};
9use crate::tui::util::{truncate_to_width, visible_width};
10use crossterm::event::KeyEvent;
11
12const DEFAULT_PRIMARY_COLUMN_WIDTH: usize = 32;
13const PRIMARY_COLUMN_GAP: usize = 2;
14const MIN_DESCRIPTION_WIDTH: usize = 10;
15
16/// An item in a SelectList.
17#[derive(Debug, Clone)]
18pub struct SelectItem {
19    pub value: String,
20    pub label: String,
21    pub description: Option<String>,
22}
23
24impl SelectItem {
25    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
26        Self {
27            value: value.into(),
28            label: label.into(),
29            description: None,
30        }
31    }
32
33    pub fn with_description(mut self, description: impl Into<String>) -> Self {
34        self.description = Some(description.into());
35        self
36    }
37}
38
39/// Theme functions for SelectList styling.
40pub struct SelectListTheme {
41    pub selected_prefix: Box<dyn Fn(&str) -> String>,
42    pub selected_text: Box<dyn Fn(&str) -> String>,
43    pub normal_text: Box<dyn Fn(&str) -> String>,
44    pub description: Box<dyn Fn(&str) -> String>,
45    pub scroll_info: crate::tui::Style,
46    pub no_match: crate::tui::Style,
47    pub hint: crate::tui::Style,
48}
49
50impl Default for SelectListTheme {
51    fn default() -> Self {
52        Self {
53            selected_prefix: Box::new(|s| format!("\x1b[1m> {}\x1b[0m", s)),
54            selected_text: Box::new(|s| format!("\x1b[1m{}\x1b[0m", s)),
55            normal_text: Box::new(|s| format!("  {}", s)),
56            description: Box::new(|s| format!("    {}", s)),
57            scroll_info: crate::tui::Style::new(),
58            no_match: crate::tui::Style::new(),
59            hint: crate::tui::Style::new(),
60        }
61    }
62}
63
64/// Layout options for the primary column (matching pi's SelectListLayoutOptions).
65pub struct SelectListLayoutOptions {
66    pub min_primary_column_width: Option<usize>,
67    pub max_primary_column_width: Option<usize>,
68    /// Custom truncation function for primary column.
69    pub truncate_primary: Option<Box<dyn Fn(&str, usize, usize, &SelectItem, bool) -> String>>,
70}
71
72/// Scrollable list with optional fuzzy search and two-column layout.
73pub struct SelectList {
74    items: Vec<SelectItem>,
75    selected_index: usize,
76    max_visible: usize,
77    scroll_offset: usize,
78    search_query: String,
79    search_enabled: bool,
80    filtered_indices: Vec<usize>,
81    theme: SelectListTheme,
82    layout: SelectListLayoutOptions,
83    pub on_select: Option<Box<dyn FnMut(String)>>,
84    pub on_cancel: Option<Box<dyn FnMut()>>,
85    pub on_selection_change: Option<Box<dyn FnMut(&SelectItem)>>,
86}
87
88impl SelectList {
89    pub fn new(
90        items: Vec<SelectItem>,
91        max_visible: usize,
92        theme: SelectListTheme,
93        layout: Option<SelectListLayoutOptions>,
94    ) -> Self {
95        let filtered_indices: Vec<usize> = (0..items.len()).collect();
96        Self {
97            items,
98            selected_index: 0,
99            max_visible: max_visible.max(1),
100            scroll_offset: 0,
101            search_query: String::new(),
102            search_enabled: false,
103            filtered_indices,
104            theme,
105            layout: layout.unwrap_or(SelectListLayoutOptions {
106                min_primary_column_width: None,
107                max_primary_column_width: None,
108                truncate_primary: None,
109            }),
110            on_select: None,
111            on_cancel: None,
112            on_selection_change: None,
113        }
114    }
115
116    /// Enable interactive search (fuzzy filtering as user types).
117    pub fn with_search(mut self) -> Self {
118        self.search_enabled = true;
119        self
120    }
121
122    /// Set items (re-applies search if active). Matches pi's behavior.
123    pub fn set_items(&mut self, items: Vec<SelectItem>) {
124        self.items = items;
125        self.filtered_indices = (0..self.items.len()).collect();
126        self.selected_index = 0;
127        self.scroll_offset = 0;
128        if !self.search_query.is_empty() {
129            self.apply_search();
130        }
131    }
132
133    pub fn set_on_select(&mut self, cb: Box<dyn FnMut(String)>) {
134        self.on_select = Some(cb);
135    }
136
137    pub fn set_on_cancel(&mut self, cb: Box<dyn FnMut()>) {
138        self.on_cancel = Some(cb);
139    }
140
141    pub fn items(&self) -> &[SelectItem] {
142        &self.items
143    }
144
145    pub fn selected_index(&self) -> usize {
146        self.selected_index
147    }
148
149    pub fn set_selected_index(&mut self, index: usize) {
150        let max = self.filtered_indices.len().saturating_sub(1);
151        self.selected_index = index.min(max);
152        self.adjust_scroll();
153        self.notify_selection_change();
154    }
155
156    pub fn get_selected_item(&self) -> Option<&SelectItem> {
157        self.filtered_indices
158            .get(self.selected_index)
159            .and_then(|&idx| self.items.get(idx))
160    }
161
162    /// Filter by prefix (simpler than fuzzy for user-typed single char; pi-style).
163    pub fn set_filter(&mut self, filter: &str) {
164        if filter.is_empty() {
165            self.filtered_indices = (0..self.items.len()).collect();
166        } else {
167            let lower = filter.to_lowercase();
168            self.filtered_indices = (0..self.items.len())
169                .filter(|&i| self.items[i].label.to_lowercase().contains(&lower))
170                .collect();
171        }
172        self.selected_index = 0;
173        self.scroll_offset = 0;
174    }
175
176    fn apply_search(&mut self) {
177        if self.search_query.trim().is_empty() {
178            self.filtered_indices = (0..self.items.len()).collect();
179        } else {
180            self.filtered_indices =
181                fuzzy_filter(&self.items, &self.search_query, |item| &item.label);
182        }
183        self.selected_index = 0;
184        self.scroll_offset = 0;
185    }
186
187    fn notify_selection_change(&self) {
188        // This is called from &self — but on_selection_change takes &mut.
189        // In practice this is handled by the caller calling set_selected_index
190        // or the public API.
191    }
192
193    fn move_up(&mut self) {
194        if self.selected_index == 0 {
195            self.selected_index = self.filtered_indices.len().saturating_sub(1);
196        } else {
197            self.selected_index -= 1;
198        }
199        self.adjust_scroll();
200    }
201
202    fn move_down(&mut self) {
203        let last = self.filtered_indices.len().saturating_sub(1);
204        if self.selected_index >= last {
205            self.selected_index = 0;
206        } else {
207            self.selected_index += 1;
208        }
209        self.adjust_scroll();
210    }
211
212    fn adjust_scroll(&mut self) {
213        if self.filtered_indices.len() <= self.max_visible {
214            self.scroll_offset = 0;
215        } else {
216            let half = self.max_visible / 2;
217            self.scroll_offset = self
218                .selected_index
219                .saturating_sub(half)
220                .min(self.filtered_indices.len() - self.max_visible);
221        }
222    }
223}
224
225impl Component for SelectList {
226    fn render(&mut self, width: usize) -> Vec<String> {
227        let mut lines = Vec::new();
228
229        if self.filtered_indices.is_empty() {
230            if !self.search_query.is_empty() {
231                lines.push(self.theme.no_match.apply("No matches"));
232            }
233            return lines;
234        }
235
236        let end = (self.scroll_offset + self.max_visible).min(self.filtered_indices.len());
237        let visible_slice = &self.filtered_indices[self.scroll_offset..end];
238
239        // Calculate primary column width (pi-style: clamp between min/max bounds)
240        let primary_column_width = self.get_primary_column_width();
241
242        for (i, &item_idx) in visible_slice.iter().enumerate() {
243            let actual_idx = self.scroll_offset + i;
244            let item = &self.items[item_idx];
245            let is_selected = actual_idx == self.selected_index;
246
247            if self.supports_two_column(width) && item.description.is_some() {
248                lines.push(self.render_two_column(item, is_selected, width, primary_column_width));
249            } else {
250                let prefix = if is_selected {
251                    (self.theme.selected_prefix)("")
252                } else {
253                    "  ".to_string()
254                };
255                let label = if is_selected {
256                    (self.theme.selected_text)(&item.label)
257                } else {
258                    (self.theme.normal_text)(&item.label)
259                };
260                let desc = if let Some(ref d) = item.description {
261                    format!(" {}", (self.theme.description)(d))
262                } else {
263                    String::new()
264                };
265                let line = format!("{}{}{}", prefix, label, desc);
266                lines.push(truncate_to_width(&line, width, "", false));
267            }
268        }
269
270        // Scroll indicator (pi: only show when items exceed viewport)
271        if self.filtered_indices.len() > self.max_visible {
272            let indicator = format!(
273                "({}/{})",
274                self.selected_index + 1,
275                self.filtered_indices.len()
276            );
277            lines.push(self.theme.scroll_info.apply(&indicator));
278        }
279
280        lines
281    }
282
283    fn handle_input(&mut self, key: &KeyEvent) -> bool {
284        let kb = get_keybindings();
285
286        if kb.matches(key, ACTION_SELECT_UP) {
287            self.move_up();
288            return true;
289        }
290
291        if kb.matches(key, ACTION_SELECT_DOWN) {
292            self.move_down();
293            return true;
294        }
295
296        if kb.matches(key, ACTION_SELECT_CONFIRM) {
297            let value = self.selected_item().map(|item| item.value.clone());
298            if let Some(value) = value
299                && let Some(ref mut cb) = self.on_select
300            {
301                cb(value);
302            }
303            return true;
304        }
305
306        if kb.matches(key, ACTION_SELECT_CANCEL) {
307            if let Some(ref mut cb) = self.on_cancel {
308                cb();
309            }
310            return true;
311        }
312
313        // Search: printable characters update search query
314        if self.search_enabled {
315            if let crossterm::event::KeyCode::Char(c) = key.code
316                && !key
317                    .modifiers
318                    .contains(crossterm::event::KeyModifiers::CONTROL)
319                && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
320            {
321                self.search_query.push(c);
322                self.apply_search();
323                return true;
324            }
325
326            if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
327                self.search_query.pop();
328                self.apply_search();
329                return true;
330            }
331        }
332
333        false
334    }
335}
336
337// ── Private helpers ─────────────────────────────────────────────────
338
339impl SelectList {
340    pub fn selected_item(&self) -> Option<&SelectItem> {
341        self.filtered_indices
342            .get(self.selected_index)
343            .and_then(|&idx| self.items.get(idx))
344    }
345
346    fn supports_two_column(&self, width: usize) -> bool {
347        width > 40
348    }
349
350    fn normalize_to_single_line(text: &str) -> String {
351        text.replace(['\r', '\n'], " ").trim().to_string()
352    }
353
354    fn get_primary_column_width(&self) -> usize {
355        let raw_min = self
356            .layout
357            .min_primary_column_width
358            .or(self.layout.max_primary_column_width)
359            .unwrap_or(DEFAULT_PRIMARY_COLUMN_WIDTH);
360        let raw_max = self
361            .layout
362            .max_primary_column_width
363            .or(self.layout.min_primary_column_width)
364            .unwrap_or(DEFAULT_PRIMARY_COLUMN_WIDTH);
365
366        let min = raw_min.max(1).min(raw_max);
367        let max = raw_max.max(1).max(raw_min);
368
369        let widest = self
370            .filtered_indices
371            .iter()
372            .map(|&i| visible_width(&self.items[i].label) + PRIMARY_COLUMN_GAP)
373            .max()
374            .unwrap_or(0);
375
376        widest.clamp(min, max)
377    }
378
379    fn render_two_column(
380        &self,
381        item: &SelectItem,
382        is_selected: bool,
383        width: usize,
384        primary_column_width: usize,
385    ) -> String {
386        let prefix = if is_selected { "→ " } else { "  " };
387        let prefix_width = visible_width(prefix);
388
389        let effective_primary = primary_column_width.max(1).min(width - prefix_width - 4);
390        let max_primary_width = effective_primary.saturating_sub(PRIMARY_COLUMN_GAP).max(1);
391
392        let truncated_value =
393            self.truncate_primary(item, is_selected, max_primary_width, effective_primary);
394        let truncated_vw = visible_width(&truncated_value);
395        let spacing = " ".repeat(effective_primary.saturating_sub(truncated_vw));
396
397        let description_start = prefix_width + truncated_vw + spacing.len();
398        let remaining = width.saturating_sub(description_start + 2);
399
400        let desc_single = item
401            .description
402            .as_ref()
403            .map(|d| Self::normalize_to_single_line(d));
404
405        if let Some(ref desc) = desc_single
406            && remaining > MIN_DESCRIPTION_WIDTH
407        {
408            let truncated_desc = truncate_to_width(desc, remaining, "", false);
409            if is_selected {
410                return (self.theme.selected_text)(&format!(
411                    "{}{}{}{}",
412                    prefix, truncated_value, spacing, truncated_desc
413                ));
414            }
415            let desc_text = (self.theme.description)(&format!("{}{}", spacing, truncated_desc));
416            return format!("{}{}{}", prefix, truncated_value, desc_text);
417        }
418
419        let max_allowed = width.saturating_sub(prefix_width + 2);
420        let truncated = self.truncate_primary(item, is_selected, max_allowed, max_allowed);
421        if is_selected {
422            return (self.theme.selected_text)(&format!("{}{}", prefix, truncated));
423        }
424        format!("{}{}", prefix, truncated)
425    }
426
427    fn truncate_primary(
428        &self,
429        item: &SelectItem,
430        is_selected: bool,
431        max_width: usize,
432        column_width: usize,
433    ) -> String {
434        let display = if item.label.is_empty() {
435            &item.value
436        } else {
437            &item.label
438        };
439
440        if let Some(ref custom) = self.layout.truncate_primary {
441            custom(display, max_width, column_width, item, is_selected)
442        } else {
443            truncate_to_width(display, max_width, "", false)
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn make_items() -> Vec<SelectItem> {
453        vec![
454            SelectItem::new("a", "Alpha"),
455            SelectItem::new("b", "Beta"),
456            SelectItem::new("c", "Gamma"),
457        ]
458    }
459
460    #[test]
461    fn test_basic_navigation() {
462        let mut list = SelectList::new(make_items(), 10, SelectListTheme::default(), None);
463        assert_eq!(list.get_selected_item().unwrap().value, "a");
464
465        list.move_down();
466        assert_eq!(list.get_selected_item().unwrap().value, "b");
467
468        list.move_up();
469        assert_eq!(list.get_selected_item().unwrap().value, "a");
470    }
471
472    #[test]
473    fn test_selection_wraps() {
474        let mut list = SelectList::new(make_items(), 10, SelectListTheme::default(), None);
475        list.move_up();
476        assert_eq!(list.get_selected_item().unwrap().value, "c");
477
478        list.move_down();
479        assert_eq!(list.get_selected_item().unwrap().value, "a");
480    }
481
482    #[test]
483    fn test_render() {
484        let mut list = SelectList::new(make_items(), 10, SelectListTheme::default(), None);
485        let lines = list.render(40);
486        assert!(lines.len() >= 3);
487    }
488
489    #[test]
490    fn test_set_filter() {
491        let mut list = SelectList::new(make_items(), 10, SelectListTheme::default(), None);
492        list.set_filter("beta");
493        assert_eq!(list.filtered_indices.len(), 1);
494        assert_eq!(list.items[list.filtered_indices[0]].label, "Beta");
495    }
496
497    #[test]
498    fn test_two_column_render() {
499        let items = vec![
500            SelectItem::new("alpha-command", "Alpha command")
501                .with_description("Does something useful"),
502            SelectItem::new("beta-tool", "Beta tool").with_description("Another tool description"),
503        ];
504        let mut list = SelectList::new(items, 10, SelectListTheme::default(), None);
505        let lines = list.render(80);
506        // Should have 2+ lines for items
507        assert!(lines.len() >= 2);
508    }
509
510    #[test]
511    fn test_get_primary_column_width() {
512        let items = vec![
513            SelectItem::new("a", "Short"),
514            SelectItem::new("b", "A much longer label here"),
515        ];
516        let list = SelectList::new(items, 10, SelectListTheme::default(), None);
517        let width = list.get_primary_column_width();
518        assert!(width > 5, "Width should accommodate longest label");
519    }
520}