Skip to main content

tui_kit/
list.rs

1use ratatui::{
2    layout::{Constraint, Direction, Layout, Rect},
3    text::{Line, Span},
4    widgets::Paragraph,
5    Frame,
6};
7
8use crate::{block::{focusable_block, render_scrollbar}, Theme};
9
10/// Scroll and selection state for a [`render_list`] widget.
11pub struct ListState {
12    /// Index of the currently selected item.
13    pub selected: usize,
14    offset: usize,
15}
16
17impl ListState {
18    /// Create a new [`ListState`] with nothing selected and no scroll offset.
19    pub fn new() -> Self {
20        Self { selected: 0, offset: 0 }
21    }
22
23    /// Move selection to the next item, wrapping around at the end.
24    pub fn select_next(&mut self, item_count: usize) {
25        if item_count == 0 {
26            return;
27        }
28        self.selected = (self.selected + 1) % item_count;
29    }
30
31    /// Move selection to the previous item, wrapping around at the beginning.
32    pub fn select_prev(&mut self) {
33        if self.selected == 0 {
34            return;
35        }
36        self.selected = self.selected.saturating_sub(1);
37    }
38
39    /// Return the index of the currently selected item.
40    pub fn selected(&self) -> usize {
41        self.selected
42    }
43
44    /// Return the current scroll offset (index of the first visible item).
45    pub fn offset(&self) -> usize {
46        self.offset
47    }
48
49    /// Clamp the scroll offset so the selected item stays within the visible area.
50    fn clamp_offset(&mut self, visible_height: usize) {
51        if visible_height == 0 {
52            return;
53        }
54        if self.selected < self.offset {
55            self.offset = self.selected;
56        } else if self.selected >= self.offset + visible_height {
57            self.offset = self.selected - visible_height + 1;
58        }
59    }
60}
61
62impl Default for ListState {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// A single row in a [`render_list`] widget.
69pub struct ListItem {
70    /// Primary text shown left-aligned.
71    pub primary: String,
72    /// Optional secondary text shown right-aligned in a dimmed style.
73    pub secondary: Option<String>,
74}
75
76/// Render a scrollable, focusable list inside `area`.
77///
78/// - Uses [`focusable_block`] for the outer border.
79/// - The selected row is highlighted with [`Theme::selection`].
80/// - `primary` text is left-aligned using [`Theme::body`].
81/// - `secondary` text is right-aligned using [`Theme::hint`].
82/// - Handles an empty `items` slice without panicking.
83pub fn render_list(
84    f: &mut Frame,
85    area: Rect,
86    title: &str,
87    shortcut: Option<u8>,
88    items: &[ListItem],
89    state: &mut ListState,
90    focused: bool,
91    theme: &Theme,
92) {
93    let block = focusable_block(title, shortcut, focused, theme);
94    let inner = block.inner(area);
95    f.render_widget(block, area);
96    render_scrollbar(f, area, items.len(), state.offset);
97
98    let visible_height = inner.height as usize;
99
100    // Clamp the selection to valid item indices before adjusting the offset.
101    if !items.is_empty() && state.selected >= items.len() {
102        state.selected = items.len() - 1;
103    }
104    state.clamp_offset(visible_height);
105
106    if items.is_empty() || visible_height == 0 {
107        return;
108    }
109
110    let visible_items = items
111        .iter()
112        .enumerate()
113        .skip(state.offset)
114        .take(visible_height);
115
116    for (idx, item) in visible_items {
117        let row_y = inner.y + (idx - state.offset) as u16;
118        let row_area = Rect {
119            x: inner.x,
120            y: row_y,
121            width: inner.width,
122            height: 1,
123        };
124
125        let is_selected = idx == state.selected;
126        let row_style = if is_selected { theme.selection } else { theme.body };
127
128        match &item.secondary {
129            None => {
130                let para = Paragraph::new(Line::from(Span::styled(
131                    item.primary.clone(),
132                    row_style,
133                )));
134                f.render_widget(para, row_area);
135            }
136            Some(sec) => {
137                // Split the row into primary (left) and secondary (right) columns.
138                let sec_width = (sec.chars().count() as u16).min(inner.width.saturating_sub(1));
139                let prim_width = inner.width.saturating_sub(sec_width);
140
141                let chunks = Layout::default()
142                    .direction(Direction::Horizontal)
143                    .constraints([
144                        Constraint::Length(prim_width),
145                        Constraint::Length(sec_width),
146                    ])
147                    .split(row_area);
148
149                let prim_style = if is_selected { theme.selection } else { theme.body };
150                let sec_style = if is_selected { theme.selection } else { theme.hint };
151
152                let prim_para = Paragraph::new(Line::from(Span::styled(
153                    item.primary.clone(),
154                    prim_style,
155                )));
156                let sec_para = Paragraph::new(Line::from(Span::styled(
157                    sec.clone(),
158                    sec_style,
159                )))
160                .alignment(ratatui::layout::Alignment::Right);
161
162                f.render_widget(prim_para, chunks[0]);
163                f.render_widget(sec_para, chunks[1]);
164            }
165        }
166    }
167}