1use crossterm::event::KeyCode;
4use ratatui::{
5 layout::Rect,
6 style::Style,
7 text::{Line, Span},
8 widgets::{Block, List, ListItem, ListState, ScrollbarOrientation, ScrollbarState},
9 Frame,
10};
11use tui_dispatch_core::{Component, EventKind};
12
13use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle, SelectionStyle};
14
15#[derive(Debug, Clone)]
17pub struct SelectListStyle {
18 pub base: BaseStyle,
20 pub selection: SelectionStyle,
22 pub scrollbar: ScrollbarStyle,
24}
25
26impl Default for SelectListStyle {
27 fn default() -> Self {
28 Self {
29 base: BaseStyle {
30 fg: Some(ratatui::style::Color::Reset),
31 ..Default::default()
32 },
33 selection: SelectionStyle::default(),
34 scrollbar: ScrollbarStyle::default(),
35 }
36 }
37}
38
39impl SelectListStyle {
40 pub fn borderless() -> Self {
42 let mut style = Self::default();
43 style.base.border = None;
44 style
45 }
46
47 pub fn minimal() -> Self {
49 let mut style = Self::default();
50 style.base.border = None;
51 style.base.padding = Padding::default();
52 style
53 }
54}
55
56impl ComponentStyle for SelectListStyle {
57 fn base(&self) -> &BaseStyle {
58 &self.base
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct SelectListBehavior {
65 pub show_scrollbar: bool,
67 pub wrap_navigation: bool,
69}
70
71impl Default for SelectListBehavior {
72 fn default() -> Self {
73 Self {
74 show_scrollbar: true,
75 wrap_navigation: false,
76 }
77 }
78}
79
80pub struct SelectListProps<'a, T, A> {
82 pub items: &'a [T],
84 pub count: usize,
86 pub selected: usize,
88 pub is_focused: bool,
90 pub style: SelectListStyle,
92 pub behavior: SelectListBehavior,
94 pub on_select: fn(usize) -> A,
96 pub render_item: &'a dyn Fn(&T) -> Line<'static>,
98}
99
100impl<'a, T, A> SelectListProps<'a, T, A> {
101 pub fn new(
105 items: &'a [T],
106 selected: usize,
107 on_select: fn(usize) -> A,
108 render_item: &'a dyn Fn(&T) -> Line<'static>,
109 ) -> Self {
110 Self {
111 items,
112 count: items.len(),
113 selected,
114 is_focused: true,
115 style: SelectListStyle::default(),
116 behavior: SelectListBehavior::default(),
117 on_select,
118 render_item,
119 }
120 }
121}
122
123#[derive(Default)]
128pub struct SelectList {
129 scroll_offset: usize,
131}
132
133impl SelectList {
134 pub fn new() -> Self {
136 Self::default()
137 }
138
139 fn ensure_visible(&mut self, selected: usize, viewport_height: usize) {
141 if viewport_height == 0 {
142 return;
143 }
144
145 if selected < self.scroll_offset {
146 self.scroll_offset = selected;
147 } else if selected >= self.scroll_offset + viewport_height {
148 self.scroll_offset = selected.saturating_sub(viewport_height - 1);
149 }
150 }
151}
152
153impl<A> Component<A> for SelectList {
154 type Props<'a> = SelectListProps<'a, Line<'static>, A>;
155
156 fn handle_event(
157 &mut self,
158 event: &EventKind,
159 props: Self::Props<'_>,
160 ) -> impl IntoIterator<Item = A> {
161 if !props.is_focused || props.count == 0 {
162 return None;
163 }
164
165 let len = props.count;
166
167 match event {
168 EventKind::Key(key) => match key.code {
169 KeyCode::Char('j') | KeyCode::Down => {
171 let new_idx = if props.behavior.wrap_navigation && props.selected == len - 1 {
172 0
173 } else {
174 (props.selected + 1).min(len.saturating_sub(1))
175 };
176 if new_idx != props.selected {
177 Some((props.on_select)(new_idx))
178 } else {
179 None
180 }
181 }
182 KeyCode::Char('k') | KeyCode::Up => {
184 let new_idx = if props.behavior.wrap_navigation && props.selected == 0 {
185 len.saturating_sub(1)
186 } else {
187 props.selected.saturating_sub(1)
188 };
189 if new_idx != props.selected {
190 Some((props.on_select)(new_idx))
191 } else {
192 None
193 }
194 }
195 KeyCode::Char('g') | KeyCode::Home => {
197 if props.selected != 0 {
198 Some((props.on_select)(0))
199 } else {
200 None
201 }
202 }
203 KeyCode::Char('G') | KeyCode::End => {
205 let last = len.saturating_sub(1);
206 if props.selected != last {
207 Some((props.on_select)(last))
208 } else {
209 None
210 }
211 }
212 KeyCode::Enter => Some((props.on_select)(props.selected)),
214 _ => None,
215 },
216 _ => None,
217 }
218 }
219
220 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
221 let style = &props.style;
222
223 if let Some(bg) = style.base.bg {
225 for y in area.y..area.y.saturating_add(area.height) {
226 for x in area.x..area.x.saturating_add(area.width) {
227 frame.buffer_mut()[(x, y)].set_bg(bg);
228 frame.buffer_mut()[(x, y)].set_symbol(" ");
229 }
230 }
231 }
232
233 let content_area = Rect {
235 x: area.x + style.base.padding.left,
236 y: area.y + style.base.padding.top,
237 width: area.width.saturating_sub(style.base.padding.horizontal()),
238 height: area.height.saturating_sub(style.base.padding.vertical()),
239 };
240
241 let mut inner_area = content_area;
242 if let Some(border) = &style.base.border {
243 let block = Block::default()
244 .borders(border.borders)
245 .border_style(border.style_for_focus(props.is_focused));
246 inner_area = block.inner(content_area);
247 frame.render_widget(block, content_area);
248 }
249
250 let viewport_height = inner_area.height as usize;
251 let render_selected = props.selected.min(props.items.len().saturating_sub(1));
252
253 if !props.items.is_empty() && viewport_height > 0 {
255 self.ensure_visible(render_selected, viewport_height);
256 }
257
258 if viewport_height > 0 {
259 let max_offset = props.count.saturating_sub(viewport_height);
260 self.scroll_offset = self.scroll_offset.min(max_offset);
261 }
262
263 let show_scrollbar = props.behavior.show_scrollbar
264 && viewport_height > 0
265 && props.count > viewport_height
266 && inner_area.width > 1;
267 let mut list_area = inner_area;
268 let scrollbar_area = if show_scrollbar {
269 let scrollbar_area = Rect {
270 x: inner_area.x + inner_area.width.saturating_sub(1),
271 width: 1,
272 ..inner_area
273 };
274 list_area.width = list_area.width.saturating_sub(1);
275 Some(scrollbar_area)
276 } else {
277 None
278 };
279
280 let items: Vec<ListItem> = props
282 .items
283 .iter()
284 .enumerate()
285 .map(|(i, item)| {
286 let is_selected = i == render_selected;
287 let line = (props.render_item)(item);
288
289 if style.selection.disabled {
291 ListItem::new(line)
292 } else {
293 let display_line = if let Some(marker) = style.selection.marker {
295 let prefix = if is_selected {
296 marker
297 } else {
298 &" "[..marker.len().min(2)]
299 };
300 let mut spans = vec![Span::raw(prefix)];
301 spans.extend(line.spans.iter().cloned());
302 Line::from(spans)
303 } else {
304 line
305 };
306
307 let item_style = if is_selected {
309 style.selection.style.unwrap_or_default()
310 } else {
311 let mut s = Style::default();
312 if let Some(fg) = style.base.fg {
313 s = s.fg(fg);
314 }
315 s
316 };
317
318 ListItem::new(display_line).style(item_style)
319 }
320 })
321 .collect();
322
323 let highlight_style = if style.selection.disabled {
325 Style::default()
326 } else {
327 style.selection.style.unwrap_or_default()
328 };
329 let list = List::new(items).highlight_style(highlight_style);
330
331 let selected = if props.items.is_empty() {
333 None
334 } else {
335 Some(render_selected)
336 };
337 let mut state = ListState::default().with_selected(selected);
338 *state.offset_mut() = self.scroll_offset;
339
340 frame.render_stateful_widget(list, list_area, &mut state);
341
342 if let Some(scrollbar_area) = scrollbar_area {
343 let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
344 let scrollbar_len = props
345 .count
346 .saturating_sub(viewport_height)
347 .saturating_add(1);
348 let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
349 .position(self.scroll_offset)
350 .viewport_content_length(viewport_height.max(1));
351 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use tui_dispatch_core::testing::{key, RenderHarness};
360
361 #[derive(Debug, Clone, PartialEq)]
362 enum TestAction {
363 Select(usize),
364 }
365
366 fn make_items() -> Vec<Line<'static>> {
367 vec![
368 Line::raw("Item 0"),
369 Line::raw("Item 1"),
370 Line::raw("Item 2"),
371 ]
372 }
373
374 fn render_item(item: &Line<'static>) -> Line<'static> {
375 item.clone()
376 }
377
378 #[test]
379 fn test_navigate_down() {
380 let mut list = SelectList::new();
381 let items = make_items();
382 let props = SelectListProps {
383 items: &items,
384 count: items.len(),
385 selected: 0,
386 is_focused: true,
387 style: SelectListStyle::default(),
388 behavior: SelectListBehavior::default(),
389 on_select: TestAction::Select,
390 render_item: &render_item,
391 };
392
393 let actions: Vec<_> = list
394 .handle_event(&EventKind::Key(key("j")), props)
395 .into_iter()
396 .collect();
397
398 assert_eq!(actions, vec![TestAction::Select(1)]);
399 }
400
401 #[test]
402 fn test_navigate_up() {
403 let mut list = SelectList::new();
404 let items = make_items();
405 let props = SelectListProps {
406 items: &items,
407 count: items.len(),
408 selected: 2,
409 is_focused: true,
410 style: SelectListStyle::default(),
411 behavior: SelectListBehavior::default(),
412 on_select: TestAction::Select,
413 render_item: &render_item,
414 };
415
416 let actions: Vec<_> = list
417 .handle_event(&EventKind::Key(key("k")), props)
418 .into_iter()
419 .collect();
420
421 assert_eq!(actions, vec![TestAction::Select(1)]);
422 }
423
424 #[test]
425 fn test_navigate_at_bounds() {
426 let mut list = SelectList::new();
427 let items = make_items();
428
429 let props = SelectListProps {
431 items: &items,
432 count: items.len(),
433 selected: 0,
434 is_focused: true,
435 style: SelectListStyle::default(),
436 behavior: SelectListBehavior::default(),
437 on_select: TestAction::Select,
438 render_item: &render_item,
439 };
440 let actions: Vec<_> = list
441 .handle_event(&EventKind::Key(key("k")), props)
442 .into_iter()
443 .collect();
444 assert!(actions.is_empty());
445
446 let props = SelectListProps {
448 items: &items,
449 count: items.len(),
450 selected: 2,
451 is_focused: true,
452 style: SelectListStyle::default(),
453 behavior: SelectListBehavior::default(),
454 on_select: TestAction::Select,
455 render_item: &render_item,
456 };
457 let actions: Vec<_> = list
458 .handle_event(&EventKind::Key(key("j")), props)
459 .into_iter()
460 .collect();
461 assert!(actions.is_empty());
462 }
463
464 #[test]
465 fn test_wrap_navigation() {
466 let mut list = SelectList::new();
467 let items = make_items();
468
469 let props = SelectListProps {
471 items: &items,
472 count: items.len(),
473 selected: 0,
474 is_focused: true,
475 style: SelectListStyle::default(),
476 behavior: SelectListBehavior {
477 wrap_navigation: true,
478 ..Default::default()
479 },
480 on_select: TestAction::Select,
481 render_item: &render_item,
482 };
483 let actions: Vec<_> = list
484 .handle_event(&EventKind::Key(key("k")), props)
485 .into_iter()
486 .collect();
487 assert_eq!(actions, vec![TestAction::Select(2)]);
488
489 let props = SelectListProps {
491 items: &items,
492 count: items.len(),
493 selected: 2,
494 is_focused: true,
495 style: SelectListStyle::default(),
496 behavior: SelectListBehavior {
497 wrap_navigation: true,
498 ..Default::default()
499 },
500 on_select: TestAction::Select,
501 render_item: &render_item,
502 };
503 let actions: Vec<_> = list
504 .handle_event(&EventKind::Key(key("j")), props)
505 .into_iter()
506 .collect();
507 assert_eq!(actions, vec![TestAction::Select(0)]);
508 }
509
510 #[test]
511 fn test_unfocused_ignores_events() {
512 let mut list = SelectList::new();
513 let items = make_items();
514 let props = SelectListProps {
515 items: &items,
516 count: items.len(),
517 selected: 0,
518 is_focused: false,
519 style: SelectListStyle::default(),
520 behavior: SelectListBehavior::default(),
521 on_select: TestAction::Select,
522 render_item: &render_item,
523 };
524
525 let actions: Vec<_> = list
526 .handle_event(&EventKind::Key(key("j")), props)
527 .into_iter()
528 .collect();
529
530 assert!(actions.is_empty());
531 }
532
533 #[test]
534 fn test_enter_selects_current() {
535 let mut list = SelectList::new();
536 let items = make_items();
537 let props = SelectListProps {
538 items: &items,
539 count: items.len(),
540 selected: 1,
541 is_focused: true,
542 style: SelectListStyle::default(),
543 behavior: SelectListBehavior::default(),
544 on_select: TestAction::Select,
545 render_item: &render_item,
546 };
547
548 let actions: Vec<_> = list
549 .handle_event(&EventKind::Key(key("enter")), props)
550 .into_iter()
551 .collect();
552
553 assert_eq!(actions, vec![TestAction::Select(1)]);
554 }
555
556 #[test]
557 fn test_render() {
558 let mut render = RenderHarness::new(30, 10);
559 let mut list = SelectList::new();
560 let items = make_items();
561
562 let output = render.render_to_string_plain(|frame| {
563 let props = SelectListProps {
564 items: &items,
565 count: items.len(),
566 selected: 1,
567 is_focused: true,
568 style: SelectListStyle::default(),
569 behavior: SelectListBehavior::default(),
570 on_select: |_| (),
571 render_item: &render_item,
572 };
573 list.render(frame, frame.area(), props);
574 });
575
576 assert!(output.contains("Item 0"));
577 assert!(output.contains("Item 1"));
578 assert!(output.contains("Item 2"));
579 }
580
581 #[test]
582 fn test_render_without_selection_styling() {
583 let mut render = RenderHarness::new(30, 10);
584 let mut list = SelectList::new();
585 let items = make_items();
586
587 let output = render.render_to_string_plain(|frame| {
588 let props = SelectListProps {
589 items: &items,
590 count: items.len(),
591 selected: 1,
592 is_focused: true,
593 style: SelectListStyle {
594 selection: SelectionStyle::disabled(),
595 ..Default::default()
596 },
597 behavior: SelectListBehavior::default(),
598 on_select: |_| (),
599 render_item: &render_item,
600 };
601 list.render(frame, frame.area(), props);
602 });
603
604 assert!(output.contains("Item 0"));
606 assert!(output.contains("Item 1"));
607 assert!(output.contains("Item 2"));
608 assert!(!output.contains(">"));
610 }
611}