1use 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#[derive(Debug, Clone)]
18pub struct SelectItem {
19 pub value: String,
21 pub label: String,
23 pub description: Option<String>,
25 pub search_text: Option<String>,
30}
31
32impl SelectItem {
33 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 pub fn with_description(mut self, description: &str) -> Self {
45 self.description = Some(description.to_string());
46 self
47 }
48
49 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 pub fn search_key(&self) -> &str {
57 self.search_text.as_deref().unwrap_or(&self.value)
58 }
59
60 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#[derive(Debug, Clone)]
72pub struct SelectListTheme {
73 pub selected_prefix: fn(&str) -> String,
75 pub selected_text: fn(&str) -> String,
77 pub description: fn(&str) -> String,
79 pub scroll_info: fn(&str) -> String,
81 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#[derive(Clone)]
99pub struct SelectListLayoutOptions {
100 pub min_primary_column_width: Option<usize>,
102 pub max_primary_column_width: Option<usize>,
104 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
118pub struct SelectList {
120 items: Mutex<Vec<SelectItem>>,
121 filtered_items: Mutex<Vec<SelectItem>>,
122 selected_index: Mutex<usize>,
123 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 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 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 pub fn set_layout(&mut self, layout: SelectListLayoutOptions) {
177 self.layout = layout;
178 }
179
180 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 pub fn is_multi_select(&self) -> bool {
191 self.multi_select
192 }
193
194 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 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 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 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 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 if let Ok(mut index) = self.selected_index.lock() {
248 *index = 0;
249 }
250 }
251
252 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 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 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 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 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 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 for _ in 0..self.max_visible.saturating_sub(1) {
324 self.move_up();
325 }
326 }
327 KeyCode::PageDown => {
328 for _ in 0..self.max_visible.saturating_sub(1) {
330 self.move_down();
331 }
332 }
333 _ => {}
334 }
335 }
336
337 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 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 fn confirm(&self) {
377 if self.multi_select {
378 let selected = self.get_selected_items();
379 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 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 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 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 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 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 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 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 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 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 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 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 }
593
594 fn as_any(&self) -> &dyn Any {
595 self
596 }
597}
598
599fn 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); }
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}