1use ratatui::{
32 buffer::Buffer,
33 layout::Rect,
34 style::{Color, Modifier, Style},
35 text::{Line, Span},
36 widgets::{Block, Borders, Paragraph, Widget, Wrap},
37};
38
39#[derive(Debug, Clone, Default)]
41pub struct ListPickerState {
42 pub selected_index: usize,
44 pub scroll: u16,
46 pub total_items: usize,
48}
49
50impl ListPickerState {
51 pub fn new(total_items: usize) -> Self {
53 Self {
54 selected_index: 0,
55 scroll: 0,
56 total_items,
57 }
58 }
59
60 pub fn select_prev(&mut self) {
62 if self.selected_index > 0 {
63 self.selected_index -= 1;
64 }
65 }
66
67 pub fn select_next(&mut self) {
69 if self.selected_index + 1 < self.total_items {
70 self.selected_index += 1;
71 }
72 }
73
74 pub fn select(&mut self, index: usize) {
76 if index < self.total_items {
77 self.selected_index = index;
78 }
79 }
80
81 pub fn select_first(&mut self) {
83 self.selected_index = 0;
84 }
85
86 pub fn select_last(&mut self) {
88 if self.total_items > 0 {
89 self.selected_index = self.total_items - 1;
90 }
91 }
92
93 pub fn ensure_visible(&mut self, viewport_height: usize) {
95 if viewport_height == 0 {
96 return;
97 }
98
99 if self.selected_index < self.scroll as usize {
100 self.scroll = self.selected_index as u16;
101 } else if self.selected_index >= self.scroll as usize + viewport_height {
102 self.scroll = (self.selected_index - viewport_height + 1) as u16;
103 }
104 }
105
106 pub fn set_total(&mut self, total: usize) {
108 self.total_items = total;
109 if self.selected_index >= total && total > 0 {
110 self.selected_index = total - 1;
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct ListPickerStyle {
118 pub selected_style: Style,
120 pub normal_style: Style,
122 pub indicator_style: Style,
124 pub border_style: Style,
126 pub indicator: &'static str,
128 pub indicator_empty: &'static str,
130 pub bordered: bool,
132}
133
134impl Default for ListPickerStyle {
135 fn default() -> Self {
136 Self {
137 selected_style: Style::default()
138 .fg(Color::Yellow)
139 .add_modifier(Modifier::BOLD),
140 normal_style: Style::default().fg(Color::White),
141 indicator_style: Style::default().fg(Color::Yellow),
142 border_style: Style::default().fg(Color::Cyan),
143 indicator: "▶ ",
144 indicator_empty: " ",
145 bordered: true,
146 }
147 }
148}
149
150impl From<&crate::theme::Theme> for ListPickerStyle {
151 fn from(theme: &crate::theme::Theme) -> Self {
152 let p = &theme.palette;
153 Self {
154 selected_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
155 normal_style: Style::default().fg(p.text),
156 indicator_style: Style::default().fg(p.primary),
157 border_style: Style::default().fg(p.border_accent),
158 indicator: "▶ ",
159 indicator_empty: " ",
160 bordered: true,
161 }
162 }
163}
164
165impl ListPickerStyle {
166 pub fn arrow() -> Self {
168 Self::default()
169 }
170
171 pub fn bracket() -> Self {
173 Self {
174 indicator: "> ",
175 indicator_empty: " ",
176 ..Default::default()
177 }
178 }
179
180 pub fn checkbox() -> Self {
182 Self {
183 indicator: "[x] ",
184 indicator_empty: "[ ] ",
185 selected_style: Style::default().fg(Color::Green),
186 ..Default::default()
187 }
188 }
189
190 pub fn bordered(mut self, bordered: bool) -> Self {
192 self.bordered = bordered;
193 self
194 }
195}
196
197type DefaultRenderFn<T> = fn(&T, usize, bool) -> Vec<Line<'static>>;
199
200pub struct ListPicker<'a, T, F = DefaultRenderFn<T>>
202where
203 F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
204{
205 items: &'a [T],
206 state: &'a ListPickerState,
207 style: ListPickerStyle,
208 title: Option<&'a str>,
209 footer: Option<Vec<Line<'static>>>,
210 render_fn: F,
211}
212
213impl<'a, T: std::fmt::Display> ListPicker<'a, T, DefaultRenderFn<T>> {
214 pub fn new(items: &'a [T], state: &'a ListPickerState) -> Self {
216 Self {
217 items,
218 state,
219 style: ListPickerStyle::default(),
220 title: None,
221 footer: None,
222 render_fn: |item, _idx, _selected| vec![Line::from(item.to_string())],
223 }
224 }
225}
226
227impl<'a, T, F> ListPicker<'a, T, F>
228where
229 F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
230{
231 pub fn render_item<G>(self, render_fn: G) -> ListPicker<'a, T, G>
236 where
237 G: Fn(&T, usize, bool) -> Vec<Line<'static>>,
238 {
239 ListPicker {
240 items: self.items,
241 state: self.state,
242 style: self.style,
243 title: self.title,
244 footer: self.footer,
245 render_fn,
246 }
247 }
248
249 pub fn title(mut self, title: &'a str) -> Self {
251 self.title = Some(title);
252 self
253 }
254
255 pub fn footer(mut self, footer: Vec<Line<'static>>) -> Self {
257 self.footer = Some(footer);
258 self
259 }
260
261 pub fn style(mut self, style: ListPickerStyle) -> Self {
263 self.style = style;
264 self
265 }
266
267 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
269 self.style(ListPickerStyle::from(theme))
270 }
271
272 fn build_lines(&self, _area: Rect, inner_height: u16) -> Vec<Line<'static>> {
274 let mut lines = Vec::new();
275
276 if let Some(title) = self.title {
278 lines.push(Line::from(vec![Span::styled(
279 title.to_string(),
280 Style::default()
281 .fg(Color::Cyan)
282 .add_modifier(Modifier::BOLD),
283 )]));
284 lines.push(Line::from("")); }
286
287 let header_lines = if self.title.is_some() { 2 } else { 0 };
289 let footer_lines = self.footer.as_ref().map(|f| f.len()).unwrap_or(0);
290 let available_height = inner_height as usize - header_lines - footer_lines;
291
292 if self.items.is_empty() {
294 lines.push(Line::from(vec![Span::styled(
295 "No items",
296 Style::default().fg(Color::Gray),
297 )]));
298 } else {
299 let scroll = self.state.scroll as usize;
300 for (idx, item) in self
301 .items
302 .iter()
303 .enumerate()
304 .skip(scroll)
305 .take(available_height)
306 {
307 let is_selected = idx == self.state.selected_index;
308 let indicator = if is_selected {
309 self.style.indicator
310 } else {
311 self.style.indicator_empty
312 };
313
314 let item_style = if is_selected {
315 self.style.selected_style
316 } else {
317 self.style.normal_style
318 };
319
320 let item_lines = (self.render_fn)(item, idx, is_selected);
321 for (line_idx, line) in item_lines.into_iter().enumerate() {
322 let mut spans = Vec::new();
323
324 if line_idx == 0 {
326 spans.push(Span::styled(
327 indicator.to_string(),
328 self.style.indicator_style,
329 ));
330 } else {
331 spans.push(Span::raw(" ".repeat(self.style.indicator.len())));
333 }
334
335 for span in line.spans {
337 spans.push(Span::styled(span.content.to_string(), item_style));
338 }
339
340 lines.push(Line::from(spans));
341 }
342 }
343 }
344
345 if let Some(footer) = &self.footer {
347 for line in footer {
348 lines.push(line.clone());
349 }
350 }
351
352 lines
353 }
354}
355
356impl<'a, T, F> Widget for ListPicker<'a, T, F>
357where
358 F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
359{
360 fn render(self, area: Rect, buf: &mut Buffer) {
361 let block = if self.style.bordered {
362 Some(
363 Block::default()
364 .borders(Borders::ALL)
365 .border_style(self.style.border_style),
366 )
367 } else {
368 None
369 };
370
371 let inner = if let Some(ref block) = block {
372 block.inner(area)
373 } else {
374 area
375 };
376
377 if let Some(block) = block {
378 block.render(area, buf);
379 }
380
381 let lines = self.build_lines(area, inner.height);
382 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
383 paragraph.render(inner, buf);
384 }
385}
386
387pub fn key_hints_footer(hints: &[(&str, &str)]) -> Vec<Line<'static>> {
389 let mut spans = Vec::new();
390 for (idx, (key, desc)) in hints.iter().enumerate() {
391 if idx > 0 {
392 spans.push(Span::raw(" | "));
393 }
394 spans.push(Span::styled(
395 key.to_string(),
396 Style::default().fg(Color::Green),
397 ));
398 spans.push(Span::raw(format!(": {}", desc)));
399 }
400 vec![Line::from(""), Line::from(spans)]
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_state_new() {
409 let state = ListPickerState::new(10);
410 assert_eq!(state.selected_index, 0);
411 assert_eq!(state.scroll, 0);
412 assert_eq!(state.total_items, 10);
413 }
414
415 #[test]
416 fn test_state_navigation() {
417 let mut state = ListPickerState::new(5);
418 assert_eq!(state.selected_index, 0);
419
420 state.select_next();
421 assert_eq!(state.selected_index, 1);
422
423 state.select_prev();
424 assert_eq!(state.selected_index, 0);
425
426 state.select_prev(); assert_eq!(state.selected_index, 0);
428
429 state.select_last();
430 assert_eq!(state.selected_index, 4);
431
432 state.select_next(); assert_eq!(state.selected_index, 4);
434 }
435
436 #[test]
437 fn test_select_first_and_last() {
438 let mut state = ListPickerState::new(10);
439 state.selected_index = 5;
440
441 state.select_first();
442 assert_eq!(state.selected_index, 0);
443
444 state.select_last();
445 assert_eq!(state.selected_index, 9);
446 }
447
448 #[test]
449 fn test_select_specific_index() {
450 let mut state = ListPickerState::new(10);
451
452 state.select(5);
453 assert_eq!(state.selected_index, 5);
454
455 state.select(100);
457 assert_eq!(state.selected_index, 5); }
459
460 #[test]
461 fn test_ensure_visible() {
462 let mut state = ListPickerState::new(20);
463 state.selected_index = 15;
464 state.ensure_visible(10);
465 assert!(state.scroll >= 6); }
467
468 #[test]
469 fn test_ensure_visible_scroll_up() {
470 let mut state = ListPickerState::new(20);
471 state.scroll = 10;
472 state.selected_index = 5;
473 state.ensure_visible(10);
474 assert_eq!(state.scroll, 5);
475 }
476
477 #[test]
478 fn test_ensure_visible_zero_viewport() {
479 let mut state = ListPickerState::new(20);
480 state.selected_index = 10;
481 state.scroll = 5;
482 state.ensure_visible(0);
483 assert_eq!(state.scroll, 5);
485 }
486
487 #[test]
488 fn test_set_total() {
489 let mut state = ListPickerState::new(10);
490 state.selected_index = 8;
491
492 state.set_total(5);
494 assert_eq!(state.total_items, 5);
495 assert_eq!(state.selected_index, 4);
496
497 state.set_total(20);
499 assert_eq!(state.total_items, 20);
500 assert_eq!(state.selected_index, 4); }
502
503 #[test]
504 fn test_empty_list() {
505 let mut state = ListPickerState::new(0);
506 state.select_next();
507 assert_eq!(state.selected_index, 0);
508 state.select_last();
509 assert_eq!(state.selected_index, 0);
510 }
511
512 #[test]
513 fn test_list_picker_render() {
514 let items = vec!["Item 1", "Item 2", "Item 3"];
515 let state = ListPickerState::new(items.len());
516 let picker = ListPicker::new(&items, &state).title("Test");
517
518 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
519 picker.render(Rect::new(0, 0, 40, 10), &mut buf);
520 }
522
523 #[test]
524 fn test_list_picker_with_custom_render() {
525 let items = vec!["A", "B", "C"];
526 let state = ListPickerState::new(items.len());
527 let picker = ListPicker::new(&items, &state).render_item(|item, idx, selected| {
528 let prefix = if selected { "> " } else { " " };
529 vec![Line::from(format!("{}{}. {}", prefix, idx + 1, item))]
530 });
531
532 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
533 picker.render(Rect::new(0, 0, 40, 10), &mut buf);
534 }
535
536 #[test]
537 fn test_list_picker_styles() {
538 let arrow = ListPickerStyle::arrow();
539 assert_eq!(arrow.indicator, "▶ ");
540
541 let bracket = ListPickerStyle::bracket();
542 assert_eq!(bracket.indicator, "> ");
543
544 let checkbox = ListPickerStyle::checkbox();
545 assert_eq!(checkbox.indicator, "[x] ");
546 assert_eq!(checkbox.indicator_empty, "[ ] ");
547 }
548
549 #[test]
550 fn test_list_picker_style_bordered() {
551 let style = ListPickerStyle::default().bordered(false);
552 assert!(!style.bordered);
553
554 let style = ListPickerStyle::default().bordered(true);
555 assert!(style.bordered);
556 }
557
558 #[test]
559 fn test_key_hints_footer() {
560 let footer = key_hints_footer(&[("↑↓", "Navigate"), ("Enter", "Select")]);
561 assert_eq!(footer.len(), 2);
562 }
563
564 #[test]
565 fn test_key_hints_footer_empty() {
566 let footer = key_hints_footer(&[]);
567 assert_eq!(footer.len(), 2); }
569}