1use std::marker::PhantomData;
4use std::rc::Rc;
5
6use crossterm::event::KeyCode;
7use ratatui::{
8 layout::Rect,
9 style::Style,
10 text::{Line, Span},
11 widgets::{Block, List, ListItem, ListState, ScrollbarOrientation, ScrollbarState},
12 Frame,
13};
14use tui_dispatch_core::{Component, EventKind, HandlerResponse};
15
16use crate::commands;
17use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle, SelectionStyle};
18use crate::{ComponentDebugEntry, ComponentDebugState, ComponentInput, InteractiveComponent};
19
20#[derive(Debug, Clone)]
22pub struct SelectListStyle {
23 pub base: BaseStyle,
25 pub selection: SelectionStyle,
27 pub scrollbar: ScrollbarStyle,
29}
30
31impl Default for SelectListStyle {
32 fn default() -> Self {
33 Self {
34 base: BaseStyle {
35 fg: Some(ratatui::style::Color::Reset),
36 ..Default::default()
37 },
38 selection: SelectionStyle::default(),
39 scrollbar: ScrollbarStyle::default(),
40 }
41 }
42}
43
44impl SelectListStyle {
45 pub fn borderless() -> Self {
47 let mut style = Self::default();
48 style.base.border = None;
49 style
50 }
51
52 pub fn minimal() -> Self {
54 let mut style = Self::default();
55 style.base.border = None;
56 style.base.padding = Padding::default();
57 style
58 }
59}
60
61impl ComponentStyle for SelectListStyle {
62 fn base(&self) -> &BaseStyle {
63 &self.base
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct SelectListBehavior {
70 pub show_scrollbar: bool,
72 pub wrap_navigation: bool,
74}
75
76impl Default for SelectListBehavior {
77 fn default() -> Self {
78 Self {
79 show_scrollbar: true,
80 wrap_navigation: false,
81 }
82 }
83}
84
85pub type SelectListCallback<A> = Rc<dyn Fn(usize) -> A>;
87
88#[derive(Clone)]
90pub struct SelectListProps<'a, T, A> {
91 pub items: &'a [T],
93 pub count: usize,
95 pub selected: usize,
97 pub is_focused: bool,
99 pub style: SelectListStyle,
101 pub behavior: SelectListBehavior,
103 pub on_select: SelectListCallback<A>,
105 pub render_item: &'a dyn Fn(&T) -> Line<'static>,
107}
108
109pub struct SelectListRenderProps<'a, T> {
111 pub items: &'a [T],
113 pub count: usize,
115 pub selected: usize,
117 pub is_focused: bool,
119 pub style: SelectListStyle,
121 pub behavior: SelectListBehavior,
123 pub render_item: &'a dyn Fn(&T) -> Line<'static>,
125}
126
127impl<'a, T, A> SelectListProps<'a, T, A> {
128 pub fn new(
132 items: &'a [T],
133 selected: usize,
134 on_select: SelectListCallback<A>,
135 render_item: &'a dyn Fn(&T) -> Line<'static>,
136 ) -> Self {
137 Self {
138 items,
139 count: items.len(),
140 selected,
141 is_focused: true,
142 style: SelectListStyle::default(),
143 behavior: SelectListBehavior::default(),
144 on_select,
145 render_item,
146 }
147 }
148}
149
150pub struct SelectList<Item = Line<'static>> {
155 scroll_offset: usize,
157 _marker: PhantomData<fn() -> Item>,
158}
159
160impl<Item> Default for SelectList<Item> {
161 fn default() -> Self {
162 Self {
163 scroll_offset: 0,
164 _marker: PhantomData,
165 }
166 }
167}
168
169impl<Item> SelectList<Item> {
170 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn render_widget(
177 &mut self,
178 frame: &mut Frame,
179 area: Rect,
180 props: SelectListRenderProps<'_, Item>,
181 ) {
182 self.render_with(frame, area, props);
183 }
184
185 fn ensure_visible(&mut self, selected: usize, viewport_height: usize) {
187 if viewport_height == 0 {
188 return;
189 }
190
191 if selected < self.scroll_offset {
192 self.scroll_offset = selected;
193 } else if selected >= self.scroll_offset + viewport_height {
194 self.scroll_offset = selected.saturating_sub(viewport_height - 1);
195 }
196 }
197
198 fn next_index(&self, selected: usize, len: usize, wrap_navigation: bool) -> usize {
199 if wrap_navigation && selected == len.saturating_sub(1) {
200 0
201 } else {
202 (selected + 1).min(len.saturating_sub(1))
203 }
204 }
205
206 fn prev_index(&self, selected: usize, len: usize, wrap_navigation: bool) -> usize {
207 if wrap_navigation && selected == 0 {
208 len.saturating_sub(1)
209 } else {
210 selected.saturating_sub(1)
211 }
212 }
213
214 fn select_action<A>(
215 &self,
216 selected: usize,
217 next: usize,
218 on_select: &dyn Fn(usize) -> A,
219 ) -> Option<A> {
220 (next != selected).then(|| on_select(next))
221 }
222
223 fn handle_navigation<A>(
224 &mut self,
225 command: NavigationCommand,
226 props: &SelectListProps<'_, Item, A>,
227 ) -> Option<A> {
228 if !props.is_focused || props.count == 0 {
229 return None;
230 }
231
232 let len = props.count;
233
234 match command {
235 NavigationCommand::Next => self.select_action(
236 props.selected,
237 self.next_index(props.selected, len, props.behavior.wrap_navigation),
238 props.on_select.as_ref(),
239 ),
240 NavigationCommand::Prev => self.select_action(
241 props.selected,
242 self.prev_index(props.selected, len, props.behavior.wrap_navigation),
243 props.on_select.as_ref(),
244 ),
245 NavigationCommand::First => {
246 self.select_action(props.selected, 0, props.on_select.as_ref())
247 }
248 NavigationCommand::Last => self.select_action(
249 props.selected,
250 len.saturating_sub(1),
251 props.on_select.as_ref(),
252 ),
253 NavigationCommand::Select => Some((props.on_select.as_ref())(props.selected)),
254 }
255 }
256
257 fn render_with(
258 &mut self,
259 frame: &mut Frame,
260 area: Rect,
261 props: SelectListRenderProps<'_, Item>,
262 ) {
263 let style = &props.style;
264
265 if let Some(bg) = style.base.bg {
267 for y in area.y..area.y.saturating_add(area.height) {
268 for x in area.x..area.x.saturating_add(area.width) {
269 frame.buffer_mut()[(x, y)].set_bg(bg);
270 frame.buffer_mut()[(x, y)].set_symbol(" ");
271 }
272 }
273 }
274
275 let content_area = Rect {
277 x: area.x + style.base.padding.left,
278 y: area.y + style.base.padding.top,
279 width: area.width.saturating_sub(style.base.padding.horizontal()),
280 height: area.height.saturating_sub(style.base.padding.vertical()),
281 };
282
283 let mut inner_area = content_area;
284 if let Some(border) = &style.base.border {
285 let block = Block::default()
286 .borders(border.borders)
287 .border_style(border.style_for_focus(props.is_focused));
288 inner_area = block.inner(content_area);
289 frame.render_widget(block, content_area);
290 }
291
292 let viewport_height = inner_area.height as usize;
293 let render_selected = props.selected.min(props.items.len().saturating_sub(1));
294
295 if !props.items.is_empty() && viewport_height > 0 {
297 self.ensure_visible(render_selected, viewport_height);
298 }
299
300 if viewport_height > 0 {
301 let max_offset = props.count.saturating_sub(viewport_height);
302 self.scroll_offset = self.scroll_offset.min(max_offset);
303 }
304
305 let show_scrollbar = props.behavior.show_scrollbar
306 && viewport_height > 0
307 && props.count > viewport_height
308 && inner_area.width > 1;
309 let mut list_area = inner_area;
310 let scrollbar_area = if show_scrollbar {
311 let scrollbar_area = Rect {
312 x: inner_area.x + inner_area.width.saturating_sub(1),
313 width: 1,
314 ..inner_area
315 };
316 list_area.width = list_area.width.saturating_sub(1);
317 Some(scrollbar_area)
318 } else {
319 None
320 };
321
322 let items: Vec<ListItem> = props
324 .items
325 .iter()
326 .enumerate()
327 .map(|(i, item)| {
328 let is_selected = i == render_selected;
329 let line = (props.render_item)(item);
330
331 if style.selection.disabled {
333 ListItem::new(line)
334 } else {
335 let display_line = if let Some(marker) = style.selection.marker {
337 let prefix = if is_selected {
338 marker
339 } else {
340 &" "[..marker.len().min(2)]
341 };
342 let mut spans = vec![Span::raw(prefix)];
343 spans.extend(line.spans.iter().cloned());
344 Line::from(spans)
345 } else {
346 line
347 };
348
349 let item_style = if is_selected {
351 style.selection.style.unwrap_or_default()
352 } else {
353 let mut s = Style::default();
354 if let Some(fg) = style.base.fg {
355 s = s.fg(fg);
356 }
357 s
358 };
359
360 ListItem::new(display_line).style(item_style)
361 }
362 })
363 .collect();
364
365 let highlight_style = if style.selection.disabled {
367 Style::default()
368 } else {
369 style.selection.style.unwrap_or_default()
370 };
371 let list = List::new(items).highlight_style(highlight_style);
372
373 let selected = if props.items.is_empty() {
375 None
376 } else {
377 Some(render_selected)
378 };
379 let mut state = ListState::default().with_selected(selected);
380 *state.offset_mut() = self.scroll_offset;
381
382 frame.render_stateful_widget(list, list_area, &mut state);
383
384 if let Some(scrollbar_area) = scrollbar_area {
385 let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
386 let scrollbar_len = props
387 .count
388 .saturating_sub(viewport_height)
389 .saturating_add(1);
390 let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
391 .position(self.scroll_offset)
392 .viewport_content_length(viewport_height.max(1));
393 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
394 }
395 }
396}
397
398#[derive(Clone, Copy)]
399enum NavigationCommand {
400 Next,
401 Prev,
402 First,
403 Last,
404 Select,
405}
406
407impl<Item, A> Component<A> for SelectList<Item> {
408 type Props<'a>
409 = SelectListProps<'a, Item, A>
410 where
411 Item: 'a;
412
413 fn handle_event(
414 &mut self,
415 event: &EventKind,
416 props: Self::Props<'_>,
417 ) -> impl IntoIterator<Item = A> {
418 if !props.is_focused {
419 return None;
420 }
421
422 match event {
423 EventKind::Key(key) => match key.code {
424 KeyCode::Char('j') | KeyCode::Down => {
425 self.handle_navigation(NavigationCommand::Next, &props)
426 }
427 KeyCode::Char('k') | KeyCode::Up => {
428 self.handle_navigation(NavigationCommand::Prev, &props)
429 }
430 KeyCode::Char('g') | KeyCode::Home => {
431 self.handle_navigation(NavigationCommand::First, &props)
432 }
433 KeyCode::Char('G') | KeyCode::End => {
434 self.handle_navigation(NavigationCommand::Last, &props)
435 }
436 KeyCode::Enter => self.handle_navigation(NavigationCommand::Select, &props),
437 _ => None,
438 },
439 _ => None,
440 }
441 }
442
443 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
444 self.render_with(
445 frame,
446 area,
447 SelectListRenderProps {
448 items: props.items,
449 count: props.count,
450 selected: props.selected,
451 is_focused: props.is_focused,
452 style: props.style,
453 behavior: props.behavior,
454 render_item: props.render_item,
455 },
456 );
457 }
458}
459
460impl<Item> ComponentDebugState for SelectList<Item> {
461 fn debug_state(&self) -> Vec<ComponentDebugEntry> {
462 vec![ComponentDebugEntry::new(
463 "scroll_offset",
464 self.scroll_offset.to_string(),
465 )]
466 }
467}
468
469impl<Item, A, Ctx> InteractiveComponent<A, Ctx> for SelectList<Item> {
470 type Props<'a>
471 = SelectListProps<'a, Item, A>
472 where
473 Item: 'a;
474
475 fn update(
476 &mut self,
477 input: ComponentInput<'_, Ctx>,
478 props: Self::Props<'_>,
479 ) -> HandlerResponse<A> {
480 if !props.is_focused {
481 return HandlerResponse::ignored();
482 }
483
484 let action = match input {
485 ComponentInput::Command { name, .. } => match name {
486 commands::NEXT | commands::DOWN => {
487 self.handle_navigation(NavigationCommand::Next, &props)
488 }
489 commands::PREV | commands::UP => {
490 self.handle_navigation(NavigationCommand::Prev, &props)
491 }
492 commands::FIRST | commands::HOME => {
493 self.handle_navigation(NavigationCommand::First, &props)
494 }
495 commands::LAST | commands::END => {
496 self.handle_navigation(NavigationCommand::Last, &props)
497 }
498 commands::SELECT | commands::CONFIRM => {
499 self.handle_navigation(NavigationCommand::Select, &props)
500 }
501 _ => None,
502 },
503 ComponentInput::Key(key) => match key.code {
504 KeyCode::Char('j') | KeyCode::Down => {
505 self.handle_navigation(NavigationCommand::Next, &props)
506 }
507 KeyCode::Char('k') | KeyCode::Up => {
508 self.handle_navigation(NavigationCommand::Prev, &props)
509 }
510 KeyCode::Char('g') | KeyCode::Home => {
511 self.handle_navigation(NavigationCommand::First, &props)
512 }
513 KeyCode::Char('G') | KeyCode::End => {
514 self.handle_navigation(NavigationCommand::Last, &props)
515 }
516 KeyCode::Enter => self.handle_navigation(NavigationCommand::Select, &props),
517 _ => None,
518 },
519 _ => None,
520 };
521
522 match action {
523 Some(action) => HandlerResponse::action(action),
524 None => HandlerResponse::ignored(),
525 }
526 }
527
528 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
529 <Self as Component<A>>::render(self, frame, area, props);
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use tui_dispatch_core::testing::{key, RenderHarness};
537
538 #[derive(Debug, Clone, PartialEq)]
539 enum TestAction {
540 Select(usize),
541 }
542
543 fn make_items() -> Vec<Line<'static>> {
544 vec![
545 Line::raw("Item 0"),
546 Line::raw("Item 1"),
547 Line::raw("Item 2"),
548 ]
549 }
550
551 fn render_item(item: &Line<'static>) -> Line<'static> {
552 item.clone()
553 }
554
555 #[test]
556 fn test_navigate_down() {
557 let mut list = SelectList::new();
558 let items = make_items();
559 let props = SelectListProps {
560 items: &items,
561 count: items.len(),
562 selected: 0,
563 is_focused: true,
564 style: SelectListStyle::default(),
565 behavior: SelectListBehavior::default(),
566 on_select: Rc::new(TestAction::Select),
567 render_item: &render_item,
568 };
569
570 let actions: Vec<_> = list
571 .handle_event(&EventKind::Key(key("j")), props)
572 .into_iter()
573 .collect();
574
575 assert_eq!(actions, vec![TestAction::Select(1)]);
576 }
577
578 #[test]
579 fn test_navigate_up() {
580 let mut list = SelectList::new();
581 let items = make_items();
582 let props = SelectListProps {
583 items: &items,
584 count: items.len(),
585 selected: 2,
586 is_focused: true,
587 style: SelectListStyle::default(),
588 behavior: SelectListBehavior::default(),
589 on_select: Rc::new(TestAction::Select),
590 render_item: &render_item,
591 };
592
593 let actions: Vec<_> = list
594 .handle_event(&EventKind::Key(key("k")), props)
595 .into_iter()
596 .collect();
597
598 assert_eq!(actions, vec![TestAction::Select(1)]);
599 }
600
601 #[test]
602 fn test_navigate_at_bounds() {
603 let mut list = SelectList::new();
604 let items = make_items();
605
606 let props = SelectListProps {
608 items: &items,
609 count: items.len(),
610 selected: 0,
611 is_focused: true,
612 style: SelectListStyle::default(),
613 behavior: SelectListBehavior::default(),
614 on_select: Rc::new(TestAction::Select),
615 render_item: &render_item,
616 };
617 let actions: Vec<_> = list
618 .handle_event(&EventKind::Key(key("k")), props)
619 .into_iter()
620 .collect();
621 assert!(actions.is_empty());
622
623 let props = SelectListProps {
625 items: &items,
626 count: items.len(),
627 selected: 2,
628 is_focused: true,
629 style: SelectListStyle::default(),
630 behavior: SelectListBehavior::default(),
631 on_select: Rc::new(TestAction::Select),
632 render_item: &render_item,
633 };
634 let actions: Vec<_> = list
635 .handle_event(&EventKind::Key(key("j")), props)
636 .into_iter()
637 .collect();
638 assert!(actions.is_empty());
639 }
640
641 #[test]
642 fn test_wrap_navigation() {
643 let mut list = SelectList::new();
644 let items = make_items();
645
646 let props = SelectListProps {
648 items: &items,
649 count: items.len(),
650 selected: 0,
651 is_focused: true,
652 style: SelectListStyle::default(),
653 behavior: SelectListBehavior {
654 wrap_navigation: true,
655 ..Default::default()
656 },
657 on_select: Rc::new(TestAction::Select),
658 render_item: &render_item,
659 };
660 let actions: Vec<_> = list
661 .handle_event(&EventKind::Key(key("k")), props)
662 .into_iter()
663 .collect();
664 assert_eq!(actions, vec![TestAction::Select(2)]);
665
666 let props = SelectListProps {
668 items: &items,
669 count: items.len(),
670 selected: 2,
671 is_focused: true,
672 style: SelectListStyle::default(),
673 behavior: SelectListBehavior {
674 wrap_navigation: true,
675 ..Default::default()
676 },
677 on_select: Rc::new(TestAction::Select),
678 render_item: &render_item,
679 };
680 let actions: Vec<_> = list
681 .handle_event(&EventKind::Key(key("j")), props)
682 .into_iter()
683 .collect();
684 assert_eq!(actions, vec![TestAction::Select(0)]);
685 }
686
687 #[test]
688 fn test_unfocused_ignores_events() {
689 let mut list = SelectList::new();
690 let items = make_items();
691 let props = SelectListProps {
692 items: &items,
693 count: items.len(),
694 selected: 0,
695 is_focused: false,
696 style: SelectListStyle::default(),
697 behavior: SelectListBehavior::default(),
698 on_select: Rc::new(TestAction::Select),
699 render_item: &render_item,
700 };
701
702 let actions: Vec<_> = list
703 .handle_event(&EventKind::Key(key("j")), props)
704 .into_iter()
705 .collect();
706
707 assert!(actions.is_empty());
708 }
709
710 #[test]
711 fn test_unfocused_ignores_commands() {
712 let mut list = SelectList::new();
713 let items = make_items();
714 let props = SelectListProps {
715 items: &items,
716 count: items.len(),
717 selected: 0,
718 is_focused: false,
719 style: SelectListStyle::default(),
720 behavior: SelectListBehavior::default(),
721 on_select: Rc::new(TestAction::Select),
722 render_item: &render_item,
723 };
724
725 let response = <SelectList as InteractiveComponent<TestAction, ()>>::update(
726 &mut list,
727 ComponentInput::Command {
728 name: "next",
729 ctx: (),
730 },
731 props,
732 );
733
734 assert!(response.actions.is_empty());
735 assert!(!response.consumed);
736 assert!(!response.needs_render);
737 }
738
739 #[test]
740 fn test_enter_selects_current() {
741 let mut list = SelectList::new();
742 let items = make_items();
743 let props = SelectListProps {
744 items: &items,
745 count: items.len(),
746 selected: 1,
747 is_focused: true,
748 style: SelectListStyle::default(),
749 behavior: SelectListBehavior::default(),
750 on_select: Rc::new(TestAction::Select),
751 render_item: &render_item,
752 };
753
754 let actions: Vec<_> = list
755 .handle_event(&EventKind::Key(key("enter")), props)
756 .into_iter()
757 .collect();
758
759 assert_eq!(actions, vec![TestAction::Select(1)]);
760 }
761
762 #[test]
763 fn test_render() {
764 let mut render = RenderHarness::new(30, 10);
765 let mut list = SelectList::new();
766 let items = make_items();
767
768 let output = render.render_to_string_plain(|frame| {
769 let props = SelectListProps {
770 items: &items,
771 count: items.len(),
772 selected: 1,
773 is_focused: true,
774 style: SelectListStyle::default(),
775 behavior: SelectListBehavior::default(),
776 on_select: Rc::new(|_| ()),
777 render_item: &render_item,
778 };
779 <SelectList as Component<()>>::render(&mut list, frame, frame.area(), props);
780 });
781
782 assert!(output.contains("Item 0"));
783 assert!(output.contains("Item 1"));
784 assert!(output.contains("Item 2"));
785 }
786
787 #[test]
788 fn test_render_without_selection_styling() {
789 let mut render = RenderHarness::new(30, 10);
790 let mut list = SelectList::new();
791 let items = make_items();
792
793 let output = render.render_to_string_plain(|frame| {
794 let props = SelectListProps {
795 items: &items,
796 count: items.len(),
797 selected: 1,
798 is_focused: true,
799 style: SelectListStyle {
800 selection: SelectionStyle::disabled(),
801 ..Default::default()
802 },
803 behavior: SelectListBehavior::default(),
804 on_select: Rc::new(|_| ()),
805 render_item: &render_item,
806 };
807 <SelectList as Component<()>>::render(&mut list, frame, frame.area(), props);
808 });
809
810 assert!(output.contains("Item 0"));
812 assert!(output.contains("Item 1"));
813 assert!(output.contains("Item 2"));
814 assert!(!output.contains(">"));
816 }
817}