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}
71
72impl Source {
73    #[must_use]
74    pub const fn title(self) -> &'static str {
75        match self {
76            Self::Buffers => "Buffers",
77            Self::Commands => "Commands",
78            Self::Help => "Help",
79            Self::Grep => "Grep",
80            Self::Files => "Files",
81            Self::Project => "Project",
82        }
83    }
84}
85
86/// An open picker: the fleet's narrowing machine plus escriba's source.
87#[derive(Debug)]
88pub struct Picker {
89    inner: FuzzyPicker<Choice>,
90    source: Source,
91}
92
93/// What a keypress did to an open picker.
94///
95/// Total over the outcomes so a caller cannot forget the "still open"
96/// case — the splash's three-arm enum widened by exactly the thing a picker
97/// needs and a one-shot screen does not: it holds keys for MANY presses.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Consumed {
100    /// No picker is open; the key was not ours.
101    NotShowing,
102    /// The picker took the key and is still open.
103    Held,
104    /// The picker took the key and closed without committing.
105    Dismissed,
106    /// A row was committed; the picker is closed.
107    Chose(Choice),
108}
109
110impl Picker {
111    /// Open a picker over `items`.
112    #[must_use]
113    pub fn open(source: Source, items: Vec<PickerItem<Choice>>) -> Self {
114        let mut inner = FuzzyPicker::new(items);
115        let _ = inner.on_event(PickerEvent::Open);
116        Self { inner, source }
117    }
118
119    #[must_use]
120    pub const fn source(&self) -> Source {
121        self.source
122    }
123
124    #[must_use]
125    pub fn query(&self) -> &str {
126        self.inner.query()
127    }
128
129    #[must_use]
130    pub fn visible_count(&self) -> usize {
131        self.inner.visible_count()
132    }
133
134    /// The rows to paint, as `(label, selected)`.
135    ///
136    /// Borrowed from the machine rather than copied, so a face cannot paint a
137    /// stale view.
138    #[must_use]
139    pub fn rows(&self) -> Vec<(String, bool)> {
140        let view = self.inner.view();
141        let sel = view.selected;
142        view.rows
143            .iter()
144            .enumerate()
145            .map(|(i, it)| (it.label.clone(), i == sel))
146            .collect()
147    }
148
149    /// Feed a key. `None` for a key the picker has no meaning for — which is
150    /// HELD rather than passed through, because a picker that let unknown
151    /// keys reach the buffer would edit the file behind the overlay.
152    pub fn on_key(&mut self, key: &escriba_keymap::Key) -> Consumed {
153        let Some(event) = translate(key) else {
154            return Consumed::Held;
155        };
156        for effect in self.inner.on_event(event) {
157            match effect {
158                PickerEffect::Accepted { key } => return Consumed::Chose(key),
159                PickerEffect::Cancelled => return Consumed::Dismissed,
160                PickerEffect::Opened
161                | PickerEffect::Filtered { .. }
162                | PickerEffect::Moved { .. } => {}
163            }
164        }
165        Consumed::Held
166    }
167}
168
169/// escriba's `Key` → egaku's `PickerEvent`.
170///
171/// The bindings are the ones every picker in the category agrees on
172/// (telescope, helm, fzf, Cmd-P), so muscle memory transfers: `<C-n>`/`<C-p>`
173/// as well as the arrows, `<Esc>` to dismiss, `<CR>` to accept.
174#[must_use]
175pub fn translate(key: &escriba_keymap::Key) -> Option<PickerEvent> {
176    use escriba_keymap::Key;
177    Some(match key {
178        Key::Char(c) => PickerEvent::Type(*c),
179        Key::Backspace => PickerEvent::Backspace,
180        Key::Up | Key::Ctrl('p') => PickerEvent::NavUp,
181        Key::Down | Key::Ctrl('n') => PickerEvent::NavDown,
182        Key::Enter => PickerEvent::Accept,
183        Key::Esc | Key::Ctrl('c') => PickerEvent::Cancel,
184        _ => return None,
185    })
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn picker() -> Picker {
193        Picker::open(
194            Source::Buffers,
195            vec![
196                PickerItem::new(Choice::Buffer(BufferId(1)), "alpha.rs"),
197                PickerItem::new(Choice::Buffer(BufferId(2)), "beta.rs"),
198                PickerItem::new(Choice::Buffer(BufferId(3)), "gamma.txt"),
199            ],
200        )
201    }
202
203    #[test]
204    fn typing_narrows_the_rows() {
205        let mut p = picker();
206        assert_eq!(p.visible_count(), 3);
207        assert_eq!(p.on_key(&escriba_keymap::Key::Char('b')), Consumed::Held);
208        assert!(p.visible_count() < 3, "typing must filter");
209        assert_eq!(p.query(), "b");
210    }
211
212    #[test]
213    fn enter_commits_the_highlighted_row() {
214        let mut p = picker();
215        match p.on_key(&escriba_keymap::Key::Enter) {
216            Consumed::Chose(Choice::Buffer(_)) => {}
217            other => panic!("Enter must commit, got {other:?}"),
218        }
219    }
220
221    #[test]
222    fn esc_dismisses_without_committing() {
223        let mut p = picker();
224        assert_eq!(p.on_key(&escriba_keymap::Key::Esc), Consumed::Dismissed);
225    }
226
227    #[test]
228    fn an_unknown_key_is_held_not_passed_through() {
229        // The load-bearing one. A key the picker has no meaning for must NOT
230        // fall through to the buffer — an overlay that let `x` reach the
231        // editor would delete a character behind itself.
232        let mut p = picker();
233        assert_eq!(
234            p.on_key(&escriba_keymap::Key::Ctrl('w')),
235            Consumed::Held,
236            "an unknown key must be swallowed by the open overlay",
237        );
238    }
239
240    #[test]
241    fn navigation_moves_the_selection() {
242        let mut p = picker();
243        let first = p.rows().iter().position(|(_, sel)| *sel);
244        p.on_key(&escriba_keymap::Key::Ctrl('n'));
245        let second = p.rows().iter().position(|(_, sel)| *sel);
246        assert_ne!(first, second, "<C-n> must move the highlight");
247    }
248
249    #[test]
250    fn both_the_arrows_and_the_control_pair_navigate() {
251        // Muscle memory transfers from every picker in the category; binding
252        // only one of the two pairs is how a picker feels broken.
253        let mut a = picker();
254        a.on_key(&escriba_keymap::Key::Down);
255        let via_arrow = a.rows().iter().position(|(_, s)| *s);
256        let mut b = picker();
257        b.on_key(&escriba_keymap::Key::Ctrl('n'));
258        assert_eq!(via_arrow, b.rows().iter().position(|(_, s)| *s));
259    }
260}