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#[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
39pub 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
64pub struct SelectListLayoutOptions {
66 pub min_primary_column_width: Option<usize>,
67 pub max_primary_column_width: Option<usize>,
68 pub truncate_primary: Option<Box<dyn Fn(&str, usize, usize, &SelectItem, bool) -> String>>,
70}
71
72pub 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 pub fn with_search(mut self) -> Self {
118 self.search_enabled = true;
119 self
120 }
121
122 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 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 }
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 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 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 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
337impl 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 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}