1use crate::app::pointer::Hit;
5use crate::render::context::RenderContext;
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::widgets::{Paragraph, Widget};
10
11#[derive(Debug, Clone, Default)]
14pub struct PickerState {
15 items: Vec<String>,
16 pub filter: String,
17 selected: usize,
20}
21
22impl PickerState {
23 pub fn new(items: Vec<String>) -> Self {
24 Self {
25 items,
26 filter: String::new(),
27 selected: 0,
28 }
29 }
30
31 pub fn items(&self) -> &[String] {
33 &self.items
34 }
35
36 pub fn filtered(&self) -> Vec<(usize, &str)> {
39 let mut ranked: Vec<(u8, usize, &str)> = self
40 .items
41 .iter()
42 .enumerate()
43 .filter_map(|(i, item)| {
44 crate::home::fuzzy::substring_rank(&self.filter, item)
45 .map(|r| (r, i, item.as_str()))
46 })
47 .collect();
48 ranked.sort_by_key(|(rank, i, _)| (*rank, *i));
49 ranked.into_iter().map(|(_, i, item)| (i, item)).collect()
50 }
51
52 pub fn visible_selection(&self) -> usize {
55 self.filtered()
56 .iter()
57 .position(|(i, _)| *i == self.selected)
58 .unwrap_or(0)
59 }
60
61 pub fn selected_original(&self) -> Option<usize> {
63 let filtered = self.filtered();
64 filtered
65 .get(self.visible_selection())
66 .or_else(|| filtered.first())
67 .map(|(i, _)| *i)
68 }
69
70 pub fn select_original(&mut self, index: usize) {
71 if index < self.items.len() {
72 self.selected = index;
73 }
74 }
75
76 pub fn type_char(&mut self, c: char) {
77 self.filter.push(c);
78 self.settle();
79 }
80
81 pub fn type_text(&mut self, text: &str) {
83 self.filter
84 .push_str(&crate::widgets::text_input::one_line(text));
85 self.settle();
86 }
87
88 pub fn backspace(&mut self) {
89 self.filter.pop();
90 self.settle();
91 }
92
93 pub fn clear_filter(&mut self) {
94 self.filter.clear();
95 self.settle();
96 }
97
98 pub fn filter_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
102 use crossterm::event::KeyModifiers;
103 let ctrl = mods.contains(KeyModifiers::CONTROL);
104 if ctrl && c == 'w' {
105 self.delete_word();
106 } else if ctrl && c == 'u' {
107 self.clear_filter();
108 } else if !ctrl && !mods.contains(KeyModifiers::ALT) {
109 self.type_char(c);
110 }
111 }
112
113 pub fn delete_word(&mut self) {
116 while self.filter.ends_with(' ') {
117 self.filter.pop();
118 }
119 while self.filter.chars().next_back().is_some_and(|c| c != ' ') {
120 self.filter.pop();
121 }
122 self.settle();
123 }
124
125 pub fn move_up(&mut self) {
126 self.step(-1);
127 }
128
129 pub fn move_down(&mut self) {
130 self.step(1);
131 }
132
133 fn step(&mut self, delta: isize) {
134 let filtered = self.filtered();
135 if filtered.is_empty() {
136 return;
137 }
138 let at = self.visible_selection() as isize;
139 let n = filtered.len() as isize;
140 let next = (at + delta).rem_euclid(n) as usize;
141 self.selected = filtered[next].0;
142 }
143
144 fn settle(&mut self) {
147 let filtered = self.filtered();
148 let named = filtered
149 .first()
150 .filter(|(_, item)| crate::home::fuzzy::substring_rank(&self.filter, item) == Some(0));
151 if let Some((exact, _)) = named {
152 self.selected = *exact;
153 } else if filtered.iter().all(|(i, _)| *i != self.selected)
154 && let Some((first, _)) = filtered.first()
155 {
156 self.selected = *first;
157 }
158 }
159}
160
161pub struct Picker<'a> {
165 items: Vec<&'a str>,
166 selected: Option<usize>,
167 focused: bool,
168 marks: Option<Vec<bool>>,
169 details: Option<Vec<String>>,
171 clicks: Option<Clicks>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum Clicks {
177 Choose,
180 Tool,
182}
183
184impl<'a> Picker<'a> {
185 pub fn new(items: Vec<&'a str>, selected: Option<usize>, focused: bool) -> Self {
186 Self {
187 items,
188 selected,
189 focused,
190 marks: None,
191 details: None,
192 clicks: None,
193 }
194 }
195
196 pub fn on_click(mut self, clicks: Clicks) -> Self {
199 self.clicks = Some(clicks);
200 self
201 }
202
203 pub fn from_state(state: &'a PickerState, focused: bool) -> Self {
204 let items = state.filtered().into_iter().map(|(_, item)| item).collect();
205 Self {
206 items,
207 selected: Some(state.visible_selection()),
208 focused,
209 marks: None,
210 details: None,
211 clicks: Some(Clicks::Choose),
212 }
213 }
214
215 pub fn marks(mut self, marks: Vec<bool>) -> Self {
217 self.marks = Some(marks);
218 self
219 }
220
221 pub fn details(mut self, details: Vec<String>) -> Self {
223 self.details = Some(details);
224 self
225 }
226
227 pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
228 if area.height == 0 || area.width == 0 {
229 return;
230 }
231 let g = crate::glyphs::get();
232 let height = area.height as usize;
233 let selected = self.selected.unwrap_or(0);
236 let offset = selected.saturating_sub(height.saturating_sub(1));
237 let below = self.items.len().saturating_sub(offset + height);
238 if self.clicks == Some(Clicks::Choose) {
239 crate::app::pointer::record(area, Hit::Picker);
240 }
241 for row in 0..height.min(self.items.len().saturating_sub(offset)) {
242 let i = offset + row;
243 let is_selected = self.selected == Some(i);
244 let row_area = Rect {
245 y: area.y + row as u16,
246 height: 1,
247 ..area
248 };
249 if row + 1 == height && below > 0 && !is_selected {
250 let more = format!(" {} {} more", g.ellipsis, below + 1);
251 Paragraph::new(more)
252 .style(Style::default().fg(ctx.dimmed))
253 .render(row_area, buf);
254 break;
255 }
256 let (marker, mut style) = if is_selected {
261 (
262 if self.focused { g.rail } else { g.middot },
263 Style::default().fg(ctx.accent),
264 )
265 } else {
266 (" ", Style::default().fg(ctx.text_primary))
267 };
268 if is_selected && self.focused {
269 style = style.patch(ctx.highlight_style());
270 }
271 let mark = match &self.marks {
272 Some(marks) => {
273 let on = marks.get(i).copied().unwrap_or(false);
274 format!("{} ", if on { g.checkbox_on } else { g.checkbox_off })
275 }
276 None => String::new(),
277 };
278 Paragraph::new(format!("{}{}{}", marker, mark, self.items[i]))
279 .style(style)
280 .render(row_area, buf);
281 if let Some(detail) = self.details.as_ref().and_then(|d| d.get(i)) {
282 let w = crate::glyphs::display_width(detail) as u16;
283 if w + 2 < row_area.width {
284 let x = row_area.right() - w;
285 buf.set_string(x - 1, row_area.y, " ", style);
286 buf.set_string(x, row_area.y, detail, style.fg(ctx.dimmed));
287 }
288 }
289 if let Some(hit) = self.click_on(i) {
290 crate::app::pointer::record(row_area, hit);
291 }
292 }
293 }
294}
295
296impl Picker<'_> {
297 fn click_on(&self, i: usize) -> Option<Hit> {
299 let selected = self.selected.unwrap_or(0);
300 Some(match self.clicks.as_ref()? {
301 Clicks::Choose => Hit::PickerItem {
302 visible: i,
303 selected,
304 multi: self.marks.is_some(),
305 },
306 Clicks::Tool => Hit::Tool(i),
307 })
308 }
309}
310
311#[cfg(test)]
312mod tests;