1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
//! # A generic fuzzy item picker
//! This is a generic picker implementation, which is essentially a wrapper around the [`nucleo`]
//! fuzzy matching library.
mod editable;
mod event;
use std::{
cmp::min,
io::{self, Stdout, Write},
sync::{
mpsc::{channel, Receiver},
Arc,
},
// std::sync::mpsc::Receiver
thread::{available_parallelism, sleep, spawn},
time::{Duration, Instant},
};
use crossterm::{
cursor,
event::{
read, DisableBracketedPaste, EnableBracketedPaste, Event, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
style::{
Attribute, Color, Print, PrintStyledContent, ResetColor, SetAttribute, SetForegroundColor,
Stylize,
},
terminal::{
self, disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen,
},
tty::IsTty,
QueueableCommand,
};
use nucleo::{Config, Injector, Nucleo, Utf32String};
use crate::{
editable::{EditableString, MovementType},
event::{process_events, EventSummary},
};
pub use nucleo;
/// A representation of the current state of the picker.
#[derive(Debug)]
struct PickerState {
/// The width of the screen.
width: u16,
/// The height of the screen, including the prompt.
height: u16,
/// The selector index position, or [`None`] if there is nothing to select.
selector_index: Option<u16>,
/// The query string.
query: EditableString,
/// The current number of items to be drawn to the terminal.
draw_count: u16,
/// The total number of items.
item_count: u32,
/// The number of matches.
matched_item_count: u32,
/// Has the state changed?
needs_redraw: bool,
}
impl PickerState {
/// The initial picker state.
pub fn new(dimensions: (u16, u16)) -> Self {
let (width, height) = dimensions;
Self {
width,
height,
selector_index: None,
query: EditableString::default(),
draw_count: 0,
matched_item_count: 0,
item_count: 0,
needs_redraw: true,
}
}
/// Increment the current item selection.
pub fn incr_selection(&mut self) {
self.needs_redraw = true;
self.selector_index = self.selector_index.map(|i| i.saturating_add(1));
self.clamp_selector_index();
}
/// Decrement the current item selection.
pub fn decr_selection(&mut self) {
self.needs_redraw = true;
self.selector_index = self.selector_index.map(|i| i.saturating_sub(1));
self.clamp_selector_index();
}
/// Update the draw count from a snapshot.
pub fn update_counts<T: Send + Sync + 'static>(
&mut self,
changed: bool,
snapshot: &nucleo::Snapshot<T>,
) {
if changed {
self.needs_redraw = true;
self.item_count = snapshot.item_count();
self.matched_item_count = snapshot.matched_item_count();
self.draw_count = self.matched_item_count.try_into().unwrap_or(u16::MAX);
self.clamp_draw_count();
self.clamp_selector_index();
}
}
/// Clamp the draw count so that it falls in the valid range.
fn clamp_draw_count(&mut self) {
self.draw_count = min(self.draw_count, self.height - 2)
}
/// Clamp the selector index so that it falls in the valid range.
fn clamp_selector_index(&mut self) {
if self.draw_count == 0 {
self.selector_index = None;
} else {
let position = min(self.selector_index.unwrap_or(0), self.draw_count - 1);
self.selector_index = Some(position);
}
}
/// Change the cursor position.
pub fn shift(&mut self, st: MovementType) {
self.needs_redraw = self.query.shift(st);
}
/// Paste the contents of the string at the current cursor position.
pub fn paste(&mut self, contents: &str) {
self.needs_redraw = self.query.paste(contents);
}
/// Append a char to the query string.
pub fn insert_char(&mut self, ch: char) {
self.needs_redraw = self.query.insert(ch);
}
/// Delete a char from the query string.
pub fn delete_char(&mut self) {
self.needs_redraw = self.query.delete();
}
/// Format a [`Utf32String`] for displaying. Currently:
/// - Delete control characters.
/// - Truncates the string to an appropriate length.
/// - Replaces any newline characters with spaces.
fn format_display(&self, display: &Utf32String) -> String {
display
.slice(..)
.chars()
.filter(|ch| !ch.is_control())
.take(self.width as usize - 2)
.map(|ch| match ch {
'\n' => ' ',
s => s,
})
.collect()
}
/// Draw the terminal to the screen. This assumes that the draw count has been updated and the
/// selector index has been properly clamped, or this method will panic!
pub fn draw<T: Send + Sync + 'static>(
&mut self,
stdout: &mut Stdout,
snapshot: &nucleo::Snapshot<T>,
) -> Result<(), io::Error> {
if self.needs_redraw {
// reset redraw state
self.needs_redraw = false;
// clear screen and set cursor position to bottom
stdout
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, self.height - 2))?;
// draw the match counts
stdout
.queue(SetAttribute(Attribute::Italic))?
.queue(SetForegroundColor(Color::Green))?
.queue(Print(" "))?
.queue(Print(self.matched_item_count))?
.queue(Print("/"))?
.queue(Print(self.item_count))?
.queue(SetAttribute(Attribute::Reset))?
.queue(ResetColor)?;
// draw the matches
for it in snapshot.matched_items(..self.draw_count as u32) {
let render = self.format_display(&it.matcher_columns[0]);
stdout
.queue(cursor::MoveUp(1))?
.queue(cursor::MoveToColumn(2))?
.queue(Print(render))?;
}
// draw the selection indicator
if let Some(position) = self.selector_index {
stdout
.queue(cursor::MoveTo(0, self.height - 3 - position))?
.queue(PrintStyledContent("▌".with(Color::Magenta)))?;
}
// render the query string
stdout
.queue(cursor::MoveTo(0, self.height - 1))?
.queue(Print("> "))?
.queue(Print(&self.query))?
.queue(cursor::MoveTo(
self.query.position() as u16 + 2,
self.height - 1,
))?;
// flush to terminal
stdout.flush()
} else {
Ok(())
}
}
/// Resize the terminal state on screen size change.
pub fn resize(&mut self, width: u16, height: u16) {
self.needs_redraw = true;
self.width = width;
self.height = height;
self.clamp_draw_count();
self.clamp_selector_index();
}
}
/// The core picker struct.
///
/// Internally, it holds a [`Nucleo`] instance which is created on initialization.
pub struct Picker<T: Send + Sync + 'static> {
matcher: Nucleo<T>,
}
impl<T: Send + Sync + 'static> Default for Picker<T> {
fn default() -> Self {
Self::new(Config::DEFAULT, Self::suggested_threads(), 1)
}
}
impl<T: Send + Sync + 'static> Picker<T> {
/// Best-effort guess to reduce thread contention. Reserve two threads:
/// 1. for populating the macher
/// 2. for rendering the terminal UI and handling user input
fn suggested_threads() -> Option<usize> {
available_parallelism()
.map(|it| it.get().checked_sub(2).unwrap_or(1))
.ok()
}
/// Suggested frame length of 16ms, or ~60 FPS.
const fn suggested_frame_interval() -> Duration {
Duration::from_millis(16)
}
/// Create a new [`Picker`] instance with the prescribed number of columns.
pub fn new(config: Config, num_threads: Option<usize>, columns: u32) -> Self {
Self {
matcher: Nucleo::new(config, Arc::new(|| {}), num_threads, columns),
}
}
/// Create a new [`Picker`] instance with the prescribed number of columns.
pub fn new_with_config(columns: u32, config: Config) -> Self {
Self {
matcher: Nucleo::new(config, Arc::new(|| {}), Self::suggested_threads(), columns),
}
}
/// Get an [`Injector`] from the internal [`Nucleo`] instance.
pub fn injector(&self) -> Injector<T> {
self.matcher.injector()
}
/// Open the picker prompt and return the picked item, if any.
pub fn pick(&mut self) -> Result<Option<&T>, io::Error> {
if !std::io::stdin().is_tty() {
return Err(io::Error::new(io::ErrorKind::Other, "is not interactive"));
}
// read keyboard events from a separate thread to avoid 'read()' polling
// and handle multiple keyboard events per frame
let (sender, receiver) = channel();
spawn(move || loop {
if let Ok(event) = read() {
if sender.send(event).is_err() {
break;
}
}
});
self.pick_inner(receiver, Self::suggested_frame_interval())
}
// TODO: allow multiple selections, so the return type is `Vec<&T>` instead of `Option<&T>`.
// We chould imitate the fzf picker style: every time an item is selected (using `TAB`), the
// global matcher index is registered. Even if the query is changed, the old matches should
// be preserved. The main question is how to associate the `match` index with the `global`
// index in Nucleo so the previous matches can be rendered, even when the item drops out of the
// match list.
fn pick_inner(
&mut self,
events: Receiver<Event>,
interval: Duration,
) -> Result<Option<&T>, io::Error> {
let mut stdout = io::stdout();
let mut term = PickerState::new(size()?);
enable_raw_mode()?;
execute!(
stdout,
EnterAlternateScreen,
EnableBracketedPaste,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
let selection = loop {
let deadline = Instant::now() + interval;
// process any queued keyboard events and reset query pattern if necessary
match process_events(&mut term, &events)? {
EventSummary::Continue => {}
EventSummary::UpdateQuery(append) => {
self.matcher.pattern.reparse(
0,
&term.query.to_string(),
nucleo::pattern::CaseMatching::Smart,
nucleo::pattern::Normalization::Smart,
append,
);
}
EventSummary::Select => {
break term
.selector_index
.and_then(|idx| self.matcher.snapshot().get_matched_item(idx as u32))
.map(|it| it.data);
}
EventSummary::Quit => {
break None;
}
};
// redraw the screen
term.draw(&mut stdout, self.matcher.snapshot())?;
// increment the matcher and terminal state
let status = self.matcher.tick(10);
term.update_counts(status.changed, self.matcher.snapshot());
// wait before attempting redraw if the matcher finished earlier
sleep(deadline - Instant::now());
};
drop(events);
disable_raw_mode()?;
execute!(
stdout,
PopKeyboardEnhancementFlags,
DisableBracketedPaste,
LeaveAlternateScreen
)?;
Ok(selection)
}
}