Skip to main content

escriba_ui/
picker.rs

1//! The picker — a filtered list of candidates that holds keys while open.
2//!
3//! ## What is escriba's here and what is not
4//!
5//! The narrowing machine is **`egaku::FuzzyPicker<T>`**, a fleet library: a
6//! typed `PickerEvent → PickerEffect<T>` state machine with its own scoring
7//! and no rendering dependencies. escriba does not own that and must not
8//! reimplement it — the fleet already carries three keymap implementations
9//! because things got rewritten instead of consumed.
10//!
11//! What escriba owns is the two ends nobody else can supply:
12//!
13//! - **the SOURCE** — where candidates come from, and what accepting one
14//!   MEANS. `Choice` is that answer, and it is deliberately a closed enum
15//!   rather than a string, so a picker whose accept nothing handles cannot be
16//!   constructed.
17//! - **the key translation** — escriba's `Key` into `PickerEvent`.
18//!
19//! ## Why a closed `Choice` rather than a callback
20//!
21//! A callback would let a source decide what happens on accept, which sounds
22//! flexible and is how the editor would acquire a second dispatch path. An
23//! accepted pick lowers into a `Negai` and goes through the one interpreter
24//! like everything else; the enum is what forces that.
25
26use egaku::picker::{FuzzyPicker, PickerEffect, PickerEvent};
27
28/// Re-exported so consumers build items without depending on egaku directly.
29/// escriba's crates speak to the fleet library through THIS module; a second
30/// import path is how two versions of a type end up in one workspace.
31pub use egaku::picker::PickerItem;
32use escriba_core::BufferId;
33
34/// What accepting a row MEANS.
35///
36/// Closed on purpose. Adding a source is adding a variant, which fails the
37/// interpreter to compile until the accept is handled — the same seal
38/// `Negai` uses.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Choice {
41    /// Switch to this buffer.
42    Buffer(BufferId),
43    /// Run this command by name.
44    Command(String),
45    /// Open `path` at its start — a file, with no particular line.
46    OpenFile(std::path::PathBuf),
47    /// Open `path` and put the cursor on `line` (0-based).
48    ///
49    /// The buffer may not be open yet, which is exactly why this is a PATH
50    /// and not a `BufferId`: a grep hit names a place in the project, not a
51    /// place in the editor's current state.
52    Location { path: std::path::PathBuf, line: u32 },
53}
54
55/// Which source a picker is showing. Used for its title, and to keep the
56/// operator oriented about what they are picking FROM.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Source {
59    Buffers,
60    Commands,
61    /// Every binding: key, what it runs, what it does. The searchable keymap
62    /// — "how do I do X" rather than "run X".
63    Help,
64    /// Matches for a pattern across the project.
65    Grep,
66    /// Files under the working directory.
67    Files,
68    /// Directories that look like project roots.
69    Project,
70    /// Located findings — diagnostics, conflicts, grep results published as
71    /// a list. The `trouble.*` family's view.
72    Findings,
73}
74
75impl Source {
76    #[must_use]
77    pub const fn title(self) -> &'static str {
78        match self {
79            Self::Buffers => "Buffers",
80            Self::Commands => "Commands",
81            Self::Help => "Help",
82            Self::Grep => "Grep",
83            Self::Files => "Files",
84            Self::Project => "Project",
85            Self::Findings => "Diagnostics",
86        }
87    }
88}
89
90/// An open picker: the fleet's narrowing machine plus escriba's source.
91#[derive(Debug)]
92pub struct Picker {
93    inner: FuzzyPicker<Choice>,
94    source: Source,
95}
96
97/// What a keypress did to an open picker.
98///
99/// Total over the outcomes so a caller cannot forget the "still open"
100/// case — the splash's three-arm enum widened by exactly the thing a picker
101/// needs and a one-shot screen does not: it holds keys for MANY presses.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Consumed {
104    /// No picker is open; the key was not ours.
105    NotShowing,
106    /// The picker took the key and is still open.
107    Held,
108    /// The picker took the key and closed without committing.
109    Dismissed,
110    /// A row was committed; the picker is closed.
111    Chose(Choice),
112}
113
114impl Picker {
115    /// Open a picker over `items`.
116    #[must_use]
117    pub fn open(source: Source, items: Vec<PickerItem<Choice>>) -> Self {
118        let mut inner = FuzzyPicker::new(items);
119        let _ = inner.on_event(PickerEvent::Open);
120        Self { inner, source }
121    }
122
123    #[must_use]
124    pub const fn source(&self) -> Source {
125        self.source
126    }
127
128    /// Replace the rows from a live producer, if it is safe to do so.
129    ///
130    /// A scan posts results in batches while the operator is already looking at
131    /// the picker, so the row set grows under them. `FuzzyPicker::set_items`
132    /// preserves the query and the open state but **resets the highlight to the
133    /// top** — so re-listing under someone who has scrolled down would move
134    /// their selection on every batch, which is worse than showing them fewer
135    /// rows.
136    ///
137    /// The rule: refresh only while the highlight is still where it started.
138    /// Once the operator navigates, they have chosen a row set to work with and
139    /// keep it. Returns whether the refresh was taken, so a caller can tell the
140    /// difference between "updated" and "deliberately left alone".
141    pub fn refresh_items(&mut self, items: Vec<PickerItem<Choice>>) -> bool {
142        if !self.is_at_top() {
143            return false;
144        }
145        self.inner.set_items(items);
146        true
147    }
148
149    /// Is the highlight still on the first visible row — i.e. has the operator
150    /// not navigated yet?
151    ///
152    /// An empty picker counts as "at the top": there is nothing to have moved
153    /// away from, and the first batch must be able to land.
154    #[must_use]
155    pub fn is_at_top(&self) -> bool {
156        let rows = self.rows();
157        rows.first().is_none_or(|(_, selected)| *selected)
158    }
159
160    #[must_use]
161    pub fn query(&self) -> &str {
162        self.inner.query()
163    }
164
165    #[must_use]
166    pub fn visible_count(&self) -> usize {
167        self.inner.visible_count()
168    }
169
170    /// The rows to paint, as `(label, selected)`.
171    ///
172    /// Borrowed from the machine rather than copied, so a face cannot paint a
173    /// stale view.
174    #[must_use]
175    pub fn rows(&self) -> Vec<(String, bool)> {
176        let view = self.inner.view();
177        let sel = view.selected;
178        view.rows
179            .iter()
180            .enumerate()
181            .map(|(i, it)| (it.label.clone(), i == sel))
182            .collect()
183    }
184
185    /// Feed a key. `None` for a key the picker has no meaning for — which is
186    /// HELD rather than passed through, because a picker that let unknown
187    /// keys reach the buffer would edit the file behind the overlay.
188    pub fn on_key(&mut self, key: &escriba_keymap::Key) -> Consumed {
189        let Some(event) = translate(key) else {
190            return Consumed::Held;
191        };
192        for effect in self.inner.on_event(event) {
193            match effect {
194                PickerEffect::Accepted { key } => return Consumed::Chose(key),
195                PickerEffect::Cancelled => return Consumed::Dismissed,
196                PickerEffect::Opened
197                | PickerEffect::Filtered { .. }
198                | PickerEffect::Moved { .. } => {}
199            }
200        }
201        Consumed::Held
202    }
203}
204
205/// escriba's `Key` → egaku's `PickerEvent`.
206///
207/// The bindings are the ones every picker in the category agrees on
208/// (telescope, helm, fzf, Cmd-P), so muscle memory transfers: `<C-n>`/`<C-p>`
209/// as well as the arrows, `<Esc>` to dismiss, `<CR>` to accept.
210#[must_use]
211pub fn translate(key: &escriba_keymap::Key) -> Option<PickerEvent> {
212    use escriba_keymap::Key;
213    Some(match key {
214        Key::Char(c) => PickerEvent::Type(*c),
215        Key::Backspace => PickerEvent::Backspace,
216        Key::Up | Key::Ctrl('p') => PickerEvent::NavUp,
217        Key::Down | Key::Ctrl('n') => PickerEvent::NavDown,
218        Key::Enter => PickerEvent::Accept,
219        Key::Esc | Key::Ctrl('c') => PickerEvent::Cancel,
220        _ => return None,
221    })
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn picker() -> Picker {
229        Picker::open(
230            Source::Buffers,
231            vec![
232                PickerItem::new(Choice::Buffer(BufferId(1)), "alpha.rs"),
233                PickerItem::new(Choice::Buffer(BufferId(2)), "beta.rs"),
234                PickerItem::new(Choice::Buffer(BufferId(3)), "gamma.txt"),
235            ],
236        )
237    }
238
239    #[test]
240    fn typing_narrows_the_rows() {
241        let mut p = picker();
242        assert_eq!(p.visible_count(), 3);
243        assert_eq!(p.on_key(&escriba_keymap::Key::Char('b')), Consumed::Held);
244        assert!(p.visible_count() < 3, "typing must filter");
245        assert_eq!(p.query(), "b");
246    }
247
248    #[test]
249    fn enter_commits_the_highlighted_row() {
250        let mut p = picker();
251        match p.on_key(&escriba_keymap::Key::Enter) {
252            Consumed::Chose(Choice::Buffer(_)) => {}
253            other => panic!("Enter must commit, got {other:?}"),
254        }
255    }
256
257    #[test]
258    fn esc_dismisses_without_committing() {
259        let mut p = picker();
260        assert_eq!(p.on_key(&escriba_keymap::Key::Esc), Consumed::Dismissed);
261    }
262
263    #[test]
264    fn an_unknown_key_is_held_not_passed_through() {
265        // The load-bearing one. A key the picker has no meaning for must NOT
266        // fall through to the buffer — an overlay that let `x` reach the
267        // editor would delete a character behind itself.
268        let mut p = picker();
269        assert_eq!(
270            p.on_key(&escriba_keymap::Key::Ctrl('w')),
271            Consumed::Held,
272            "an unknown key must be swallowed by the open overlay",
273        );
274    }
275
276    #[test]
277    fn navigation_moves_the_selection() {
278        let mut p = picker();
279        let first = p.rows().iter().position(|(_, sel)| *sel);
280        p.on_key(&escriba_keymap::Key::Ctrl('n'));
281        let second = p.rows().iter().position(|(_, sel)| *sel);
282        assert_ne!(first, second, "<C-n> must move the highlight");
283    }
284
285    #[test]
286    fn both_the_arrows_and_the_control_pair_navigate() {
287        // Muscle memory transfers from every picker in the category; binding
288        // only one of the two pairs is how a picker feels broken.
289        let mut a = picker();
290        a.on_key(&escriba_keymap::Key::Down);
291        let via_arrow = a.rows().iter().position(|(_, s)| *s);
292        let mut b = picker();
293        b.on_key(&escriba_keymap::Key::Ctrl('n'));
294        assert_eq!(via_arrow, b.rows().iter().position(|(_, s)| *s));
295    }
296}