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}