1use ratatui::{
42 layout::{Constraint, Direction, Layout, Rect},
43 style::{Modifier, Style},
44 text::{Line, Span, Text},
45 widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
46 Frame,
47};
48
49use crate::Theme;
50
51#[derive(Debug, Clone)]
53pub struct PickerState {
54 pub search: String,
56 pub selected: usize,
58 pub scroll_offset: usize,
60}
61
62impl PickerState {
63 pub fn new() -> Self {
64 Self { search: String::new(), selected: 0, scroll_offset: 0 }
65 }
66
67 pub fn select_next(&mut self, item_count: usize) {
69 if item_count == 0 { return; }
70 if self.selected + 1 < item_count {
71 self.selected += 1;
72 }
73 }
74
75 pub fn select_prev(&mut self) {
77 self.selected = self.selected.saturating_sub(1);
78 }
79
80 pub fn reset_selection(&mut self) {
82 self.selected = 0;
83 self.scroll_offset = 0;
84 }
85
86 pub fn clamp_scroll(&mut self, visible_height: usize) {
88 if visible_height == 0 { return; }
89 if self.selected < self.scroll_offset {
90 self.scroll_offset = self.selected;
91 } else if self.selected >= self.scroll_offset + visible_height {
92 self.scroll_offset = self.selected - visible_height + 1;
93 }
94 }
95}
96
97impl Default for PickerState {
98 fn default() -> Self { Self::new() }
99}
100
101#[derive(Debug, Clone)]
103pub struct PickerItem {
104 pub label: String,
106 pub tag: Option<String>,
108 pub tag_style: Option<Style>,
110}
111
112impl PickerItem {
113 pub fn new(label: impl Into<String>) -> Self {
114 Self { label: label.into(), tag: None, tag_style: None }
115 }
116
117 pub fn with_tag(label: impl Into<String>, tag: impl Into<String>) -> Self {
118 Self { label: label.into(), tag: Some(tag.into()), tag_style: None }
119 }
120
121 pub fn with_tag_styled(label: impl Into<String>, tag: impl Into<String>, style: Style) -> Self {
122 Self { label: label.into(), tag: Some(tag.into()), tag_style: Some(style) }
123 }
124}
125
126pub fn render_picker<'a>(
135 f: &mut Frame,
136 area: Rect,
137 title: &str,
138 items: &[PickerItem],
139 state: &PickerState,
140 detail: Vec<Line<'a>>,
141 total_count: usize,
142 theme: &Theme,
143) {
144 let cols = Layout::default()
146 .direction(Direction::Horizontal)
147 .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
148 .split(area);
149
150 let left_rows = Layout::default()
153 .direction(Direction::Vertical)
154 .constraints([Constraint::Length(3), Constraint::Min(1)])
155 .split(cols[0]);
156
157 let search_block = Block::default()
159 .borders(Borders::ALL)
160 .border_style(theme.border_focused)
161 .title(format!(" {} ", title))
162 .title_style(theme.tab_active);
163 f.render_widget(
164 Paragraph::new(state.search.as_str()).block(search_block),
165 left_rows[0],
166 );
167
168 let visible_height = left_rows[1].height.saturating_sub(2) as usize;
170 let scroll = state.scroll_offset.min(items.len().saturating_sub(1));
171
172 let list_items: Vec<ListItem> = items
173 .iter()
174 .enumerate()
175 .skip(scroll)
176 .take(visible_height)
177 .map(|(idx, item)| {
178 let selected = idx == state.selected;
179 let row_style = if selected { theme.selection } else { theme.body };
180 let prefix = if selected { "▶ " } else { " " };
181 let line = match &item.tag {
182 Some(tag) => {
183 let tag_style = if selected {
184 row_style
185 } else {
186 item.tag_style.unwrap_or(row_style)
187 };
188 Line::from(vec![
189 Span::styled(prefix.to_string(), row_style),
190 Span::styled(format!("{} ", tag), tag_style),
191 Span::styled(item.label.clone(), row_style),
192 ])
193 }
194 None => Line::from(vec![
195 Span::styled(prefix.to_string(), row_style),
196 Span::styled(item.label.clone(), row_style),
197 ]),
198 };
199 ListItem::new(line)
200 })
201 .collect();
202
203 let count_title = format!(" {}/{} ", items.len(), total_count);
204 let list_block = Block::default()
205 .borders(Borders::ALL)
206 .border_style(theme.border_unfocused)
207 .title(count_title)
208 .title_style(theme.hint);
209 f.render_widget(List::new(list_items).block(list_block), left_rows[1]);
210
211 let detail_content = if detail.is_empty() {
214 vec![Line::from(Span::styled(
215 "(no selection)",
216 theme.hint.add_modifier(Modifier::ITALIC),
217 ))]
218 } else {
219 detail
220 };
221
222 let detail_block = Block::default()
223 .borders(Borders::ALL)
224 .border_style(theme.border_unfocused)
225 .title(" Detail ")
226 .title_style(theme.hint);
227
228 f.render_widget(
229 Paragraph::new(Text::from(detail_content))
230 .block(detail_block)
231 .wrap(Wrap { trim: false }),
232 cols[1],
233 );
234}