1use crate::fuzzy::{self, Match};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Target {
24 Workflow { namespace: String, run_id: String },
27 Row(usize),
29 Command(String),
31 Pane(u64),
33 Query(String),
35 Namespace(String),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Item {
42 pub label: String,
44 pub note: String,
47 pub preview: String,
50 pub target: Target,
51}
52
53impl Item {
54 pub fn new(label: impl Into<String>, target: Target) -> Self {
55 Self {
56 label: label.into(),
57 note: String::new(),
58 preview: String::new(),
59 target,
60 }
61 }
62
63 pub fn with_note(mut self, note: impl Into<String>) -> Self {
64 self.note = note.into();
65 self
66 }
67
68 pub fn with_preview(mut self, preview: impl Into<String>) -> Self {
69 self.preview = preview.into();
70 self
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Kind {
78 Workflows,
79 HistoryRows,
80 Panes,
81 Commands,
82 Filters,
83 Namespaces,
84}
85
86impl Kind {
87 pub fn title(self) -> &'static str {
88 match self {
89 Kind::Workflows => "workflows",
90 Kind::HistoryRows => "events",
91 Kind::Panes => "panes",
92 Kind::Commands => "commands",
93 Kind::Filters => "filters",
94 Kind::Namespaces => "namespaces",
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100pub struct Picker {
101 pub kind: Kind,
102 pub prompt: String,
104 items: Vec<Item>,
107 hits: Vec<(usize, Match)>,
109 pub cursor: usize,
111}
112
113impl Picker {
114 pub fn new(kind: Kind, items: Vec<Item>) -> Self {
115 let mut p = Self {
116 kind,
117 prompt: String::new(),
118 items,
119 hits: Vec::new(),
120 cursor: 0,
121 };
122 p.refilter();
123 p
124 }
125
126 pub fn is_empty(&self) -> bool {
127 self.hits.is_empty()
128 }
129
130 pub fn total(&self) -> usize {
131 self.items.len()
132 }
133
134 pub fn shown(&self) -> usize {
135 self.hits.len()
136 }
137
138 pub fn rows(&self) -> impl Iterator<Item = (&Item, &Match)> {
141 self.hits.iter().map(|(i, m)| (&self.items[*i], m))
142 }
143
144 pub fn selected(&self) -> Option<&Item> {
145 self.hits.get(self.cursor).map(|(i, _)| &self.items[*i])
146 }
147
148 pub fn accept(&self) -> Option<&Target> {
150 self.selected().map(|i| &i.target)
151 }
152
153 pub fn push(&mut self, c: char) {
154 self.prompt.push(c);
155 self.refilter();
156 }
157
158 pub fn backspace(&mut self) -> bool {
161 let had = self.prompt.pop().is_some();
162 if had {
163 self.refilter();
164 }
165 had
166 }
167
168 pub fn move_cursor(&mut self, delta: isize) {
174 if self.hits.is_empty() {
175 self.cursor = 0;
176 return;
177 }
178 let last = self.hits.len() as isize - 1;
179 self.cursor = (self.cursor as isize + delta).clamp(0, last) as usize;
180 }
181
182 fn refilter(&mut self) {
188 self.hits = fuzzy::rank(&self.prompt, &self.items, |i| i.label.clone());
189 self.cursor = 0;
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn items(labels: &[&str]) -> Vec<Item> {
198 labels
199 .iter()
200 .enumerate()
201 .map(|(i, l)| Item::new(*l, Target::Row(i)))
202 .collect()
203 }
204
205 fn picker(labels: &[&str]) -> Picker {
206 Picker::new(Kind::Workflows, items(labels))
207 }
208
209 fn labels(p: &Picker) -> Vec<String> {
210 p.rows().map(|(i, _)| i.label.clone()).collect()
211 }
212
213 #[test]
214 fn a_new_picker_shows_everything_in_the_order_given() {
215 let p = picker(&["c", "a", "b"]);
218 assert_eq!(labels(&p), vec!["c", "a", "b"]);
219 assert_eq!(p.shown(), 3);
220 assert_eq!(p.total(), 3);
221 }
222
223 #[test]
224 fn typing_narrows_the_list() {
225 let mut p = picker(&["order-checkout", "order-refund", "shipping"]);
226 p.push('o');
227 p.push('r');
228 assert_eq!(p.shown(), 2, "shipping has no 'or'");
229 }
230
231 #[test]
232 fn the_best_match_is_selected_as_you_type() {
233 let mut p = picker(&["processor", "order-checkout"]);
234 for c in "oc".chars() {
235 p.push(c);
236 }
237 assert_eq!(
238 p.selected().map(|i| i.label.as_str()),
239 Some("order-checkout"),
240 "a word-start match should outrank one buried mid-word"
241 );
242 }
243
244 #[test]
245 fn the_cursor_returns_to_the_top_on_every_keystroke() {
246 let mut p = picker(&["alpha", "beta", "gamma"]);
249 p.move_cursor(2);
250 assert_eq!(p.cursor, 2);
251 p.push('a');
252 assert_eq!(p.cursor, 0);
253 }
254
255 #[test]
256 fn the_cursor_clamps_rather_than_wrapping() {
257 let mut p = picker(&["a", "b"]);
258 p.move_cursor(10);
259 assert_eq!(p.cursor, 1, "clamped to the last row");
260 p.move_cursor(-10);
261 assert_eq!(p.cursor, 0, "clamped to the first");
262 }
263
264 #[test]
265 fn backspace_reports_when_there_is_nothing_left_to_delete() {
266 let mut p = picker(&["a"]);
267 p.push('a');
268 assert!(p.backspace(), "deleted the 'a'");
269 assert!(
270 !p.backspace(),
271 "empty, so the caller should close the picker"
272 );
273 }
274
275 #[test]
276 fn backspace_widens_the_list_again() {
277 let mut p = picker(&["order", "shipping"]);
278 p.push('o');
279 p.push('r');
280 assert_eq!(p.shown(), 1);
281 p.backspace();
282 p.backspace();
283 assert_eq!(p.shown(), 2, "back to everything");
284 }
285
286 #[test]
287 fn a_prompt_matching_nothing_leaves_no_selection() {
288 let mut p = picker(&["order"]);
291 for c in "zzz".chars() {
292 p.push(c);
293 }
294 assert!(p.is_empty());
295 assert_eq!(p.selected(), None);
296 assert_eq!(p.accept(), None);
297 }
298
299 #[test]
300 fn accept_returns_the_target_of_the_row_under_the_cursor() {
301 let mut p = picker(&["alpha", "beta"]);
302 p.move_cursor(1);
303 assert_eq!(p.accept(), Some(&Target::Row(1)));
304 }
305
306 #[test]
307 fn match_positions_come_back_for_highlighting() {
308 let mut p = picker(&["order-checkout"]);
309 p.push('o');
310 let (item, m) = p.rows().next().unwrap();
311 assert_eq!(m.positions.len(), 1);
312 assert!(item.label.is_char_boundary(m.positions[0]));
313 }
314
315 #[test]
316 fn notes_are_not_matched_against() {
317 let items = vec![Item::new("order-1", Target::Row(0)).with_note("Running")];
319 let mut p = Picker::new(Kind::Workflows, items);
320 for c in "running".chars() {
321 p.push(c);
322 }
323 assert!(p.is_empty(), "the note is shown, not searched");
324 }
325}