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
10pub struct ListState {
12 pub selected: usize,
14 offset: usize,
15}
16
17impl ListState {
18 pub fn new() -> Self {
20 Self { selected: 0, offset: 0 }
21 }
22
23 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 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 pub fn selected(&self) -> usize {
41 self.selected
42 }
43
44 pub fn offset(&self) -> usize {
46 self.offset
47 }
48
49 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
68pub struct ListItem {
70 pub primary: String,
72 pub secondary: Option<String>,
74}
75
76pub 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 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 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}