Skip to main content

tmprl_core/
picker.rs

1//! The picker: a filtered list you type at.
2//!
3//! One implementation behind every `<leader>f` binding. The things being picked are very
4//! different, workflows, history rows, open panes, commands, query fragments, but the
5//! interaction is identical every time: a prompt, a list that narrows as you type, a cursor
6//! you move with `<C-n>` and `<C-p>`, `Enter` to take one. Building that five times is how
7//! five slightly different pickers happen.
8//!
9//! Pure, and deliberately so. A picker holds strings and an opaque [`Target`], never a
10//! `WorkflowRow` or a pane handle, so this module compiles without knowing what a workflow
11//! is and can be driven in a unit test without a screen. Deciding what to *do* with an
12//! accepted target belongs to `tmprl-tui`, and the match on [`Target`] there is exhaustive,
13//! so a new kind of picker cannot be silently unhandled.
14
15use crate::fuzzy::{self, Match};
16
17/// What accepting an entry means.
18///
19/// Deliberately a small closed set of *outcomes* rather than one variant per picker: two
20/// pickers that both end in "put the cursor on a row" should not need two code paths to do
21/// it. `<leader>fl` and a future `<leader>fs` are both [`Target::Row`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Target {
24    /// Open this workflow's history. Namespace and run id, because a run id is only unique
25    /// within a namespace and a picker can be fanned out over several.
26    Workflow { namespace: String, run_id: String },
27    /// Put the cursor on this row of the screen the picker was opened from.
28    Row(usize),
29    /// Run this command id.
30    Command(String),
31    /// Focus this pane.
32    Pane(u64),
33    /// Write this text into the query bar, leaving it editable.
34    Query(String),
35    /// Point this pane at a namespace and show its workflows.
36    Namespace(String),
37}
38
39/// One candidate.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Item {
42    /// What is matched against and rendered in the list.
43    pub label: String,
44    /// The second, dimmer column: a workflow's type, a command's group. Not matched
45    /// against, so that typing a status does not pull in every row that merely ends in it.
46    pub note: String,
47    /// The body of the preview pane. Empty means the picker has no preview to show, which
48    /// is the honest thing for a list of command ids.
49    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/// Which picker is open. Only used for the title, but a title that says what you are
75/// looking at is the difference between five pickers and one confusing one.
76#[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    /// What has been typed, verbatim.
103    pub prompt: String,
104    /// Every candidate, in the order it was handed over. That order is the tiebreak when
105    /// scores are equal, so a workflow picker opens newest-first like the list behind it.
106    items: Vec<Item>,
107    /// Indices into `items`, best match first. Rebuilt on every keystroke.
108    hits: Vec<(usize, Match)>,
109    /// Position within `hits`, not within `items`.
110    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    /// The visible entries, best first, each with the match positions that produced it so
139    /// the renderer can underline the characters that were actually typed.
140    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    /// The target of the entry under the cursor, if there is one.
149    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    /// Delete a character. `false` when there was nothing to delete, which the caller turns
159    /// into closing the picker, the way backspace on an empty prompt does everywhere else.
160    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    /// Move the cursor, clamping rather than wrapping.
169    ///
170    /// Clamping, unlike `/`, because a picker's list is right there: running off the bottom
171    /// and reappearing at the top in a list you can see in full is disorienting, where in a
172    /// thousand-row table a wrap is the only way to keep going.
173    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    /// Re-rank against the current prompt.
183    ///
184    /// The cursor goes back to the top on every keystroke, because the best match is what
185    /// typing another character is *for*. Holding position would leave the cursor on
186    /// whatever happens to be at that index in a completely different list.
187    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        // The list behind a workflow picker is already sorted newest-first; opening the
216        // picker must not throw that away before anything has been typed.
217        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        // Otherwise the cursor keeps an index into a list that no longer has the same
247        // contents, and lands on something unrelated.
248        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        // Accepting must be impossible rather than accidentally taking row 0 of a list that
289        // is not showing anything.
290        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        // Typing a status should not drag in every row whose *type* happens to spell it.
318        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}