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}