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 backspace(&mut self) {
82 self.filter.pop();
83 self.settle();
84 }
85
86 pub fn clear_filter(&mut self) {
87 self.filter.clear();
88 self.settle();
89 }
90
91 pub fn filter_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
95 use crossterm::event::KeyModifiers;
96 let ctrl = mods.contains(KeyModifiers::CONTROL);
97 if ctrl && c == 'w' {
98 self.delete_word();
99 } else if ctrl && c == 'u' {
100 self.clear_filter();
101 } else if !ctrl && !mods.contains(KeyModifiers::ALT) {
102 self.type_char(c);
103 }
104 }
105
106 pub fn delete_word(&mut self) {
109 while self.filter.ends_with(' ') {
110 self.filter.pop();
111 }
112 while self.filter.chars().next_back().is_some_and(|c| c != ' ') {
113 self.filter.pop();
114 }
115 self.settle();
116 }
117
118 pub fn move_up(&mut self) {
119 self.step(-1);
120 }
121
122 pub fn move_down(&mut self) {
123 self.step(1);
124 }
125
126 fn step(&mut self, delta: isize) {
127 let filtered = self.filtered();
128 if filtered.is_empty() {
129 return;
130 }
131 let at = self.visible_selection() as isize;
132 let n = filtered.len() as isize;
133 let next = (at + delta).rem_euclid(n) as usize;
134 self.selected = filtered[next].0;
135 }
136
137 fn settle(&mut self) {
140 let filtered = self.filtered();
141 let named = filtered
142 .first()
143 .filter(|(_, item)| crate::home::fuzzy::substring_rank(&self.filter, item) == Some(0));
144 if let Some((exact, _)) = named {
145 self.selected = *exact;
146 } else if filtered.iter().all(|(i, _)| *i != self.selected)
147 && let Some((first, _)) = filtered.first()
148 {
149 self.selected = *first;
150 }
151 }
152}
153
154pub struct Picker<'a> {
158 items: Vec<&'a str>,
159 selected: Option<usize>,
160 focused: bool,
161 marks: Option<Vec<bool>>,
162 details: Option<Vec<String>>,
164 clicks: Option<Clicks>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Clicks {
170 Choose,
173 Tool,
175}
176
177impl<'a> Picker<'a> {
178 pub fn new(items: Vec<&'a str>, selected: Option<usize>, focused: bool) -> Self {
179 Self {
180 items,
181 selected,
182 focused,
183 marks: None,
184 details: None,
185 clicks: None,
186 }
187 }
188
189 pub fn on_click(mut self, clicks: Clicks) -> Self {
192 self.clicks = Some(clicks);
193 self
194 }
195
196 pub fn from_state(state: &'a PickerState, focused: bool) -> Self {
197 let items = state.filtered().into_iter().map(|(_, item)| item).collect();
198 Self {
199 items,
200 selected: Some(state.visible_selection()),
201 focused,
202 marks: None,
203 details: None,
204 clicks: Some(Clicks::Choose),
205 }
206 }
207
208 pub fn marks(mut self, marks: Vec<bool>) -> Self {
210 self.marks = Some(marks);
211 self
212 }
213
214 pub fn details(mut self, details: Vec<String>) -> Self {
216 self.details = Some(details);
217 self
218 }
219
220 pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
221 if area.height == 0 || area.width == 0 {
222 return;
223 }
224 let g = crate::glyphs::get();
225 let height = area.height as usize;
226 let selected = self.selected.unwrap_or(0);
229 let offset = selected.saturating_sub(height.saturating_sub(1));
230 let below = self.items.len().saturating_sub(offset + height);
231 if self.clicks == Some(Clicks::Choose) {
232 crate::app::pointer::record(area, Hit::Picker);
233 }
234 for row in 0..height.min(self.items.len().saturating_sub(offset)) {
235 let i = offset + row;
236 let is_selected = self.selected == Some(i);
237 let row_area = Rect {
238 y: area.y + row as u16,
239 height: 1,
240 ..area
241 };
242 if row + 1 == height && below > 0 && !is_selected {
243 let more = format!(" {} {} more", g.ellipsis, below + 1);
244 Paragraph::new(more)
245 .style(Style::default().fg(ctx.dimmed))
246 .render(row_area, buf);
247 break;
248 }
249 let (marker, mut style) = if is_selected {
254 (
255 if self.focused { g.rail } else { g.middot },
256 Style::default().fg(ctx.accent),
257 )
258 } else {
259 (" ", Style::default().fg(ctx.text_primary))
260 };
261 if is_selected && self.focused {
262 style = style.patch(ctx.highlight_style());
263 }
264 let mark = match &self.marks {
265 Some(marks) => {
266 let on = marks.get(i).copied().unwrap_or(false);
267 format!("{} ", if on { g.checkbox_on } else { g.checkbox_off })
268 }
269 None => String::new(),
270 };
271 Paragraph::new(format!("{}{}{}", marker, mark, self.items[i]))
272 .style(style)
273 .render(row_area, buf);
274 if let Some(detail) = self.details.as_ref().and_then(|d| d.get(i)) {
275 let w = crate::glyphs::display_width(detail) as u16;
276 if w + 2 < row_area.width {
277 let x = row_area.right() - w;
278 buf.set_string(x - 1, row_area.y, " ", style);
279 buf.set_string(x, row_area.y, detail, style.fg(ctx.dimmed));
280 }
281 }
282 if let Some(hit) = self.click_on(i) {
283 crate::app::pointer::record(row_area, hit);
284 }
285 }
286 }
287}
288
289impl Picker<'_> {
290 fn click_on(&self, i: usize) -> Option<Hit> {
292 let selected = self.selected.unwrap_or(0);
293 Some(match self.clicks.as_ref()? {
294 Clicks::Choose => Hit::PickerItem {
295 visible: i,
296 selected,
297 multi: self.marks.is_some(),
298 },
299 Clicks::Tool => Hit::Tool(i),
300 })
301 }
302}
303
304#[cfg(test)]
305mod tests;