1 use crate::{
2     compositor::{Component, Compositor, Context, EventResult},
3     ui::EditorView,
4 };
5 use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
6 use tui::{
7     buffer::Buffer as Surface,
8     widgets::{Block, BorderType, Borders},
9 };
10 
11 use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
12 use fuzzy_matcher::FuzzyMatcher;
13 use tui::widgets::Widget;
14 
15 use std::{borrow::Cow, collections::HashMap, path::PathBuf};
16 
17 use crate::ui::{Prompt, PromptEvent};
18 use helix_core::Position;
19 use helix_view::{
20     editor::Action,
21     graphics::{Color, CursorKind, Margin, Rect, Style},
22     Document, Editor,
23 };
24 
25 pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80;
26 
27 /// File path and line number (used to align and highlight a line)
28 type FileLocation = (PathBuf, Option<(usize, usize)>);
29 
30 pub struct FilePicker<T> {
31     picker: Picker<T>,
32     /// Caches paths to documents
33     preview_cache: HashMap<PathBuf, Document>,
34     /// Given an item in the picker, return the file path and line number to display.
35     file_fn: Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>,
36 }
37 
38 impl<T> FilePicker<T> {
new( options: Vec<T>, format_fn: impl Fn(&T) -> Cow<str> + 'static, callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static, ) -> Self39     pub fn new(
40         options: Vec<T>,
41         format_fn: impl Fn(&T) -> Cow<str> + 'static,
42         callback_fn: impl Fn(&mut Editor, &T, Action) + 'static,
43         preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
44     ) -> Self {
45         Self {
46             picker: Picker::new(false, options, format_fn, callback_fn),
47             preview_cache: HashMap::new(),
48             file_fn: Box::new(preview_fn),
49         }
50     }
51 
current_file(&self, editor: &Editor) -> Option<FileLocation>52     fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
53         self.picker
54             .selection()
55             .and_then(|current| (self.file_fn)(editor, current))
56             .and_then(|(path, line)| {
57                 helix_core::path::get_canonicalized_path(&path)
58                     .ok()
59                     .zip(Some(line))
60             })
61     }
62 
calculate_preview(&mut self, editor: &Editor)63     fn calculate_preview(&mut self, editor: &Editor) {
64         if let Some((path, _line)) = self.current_file(editor) {
65             if !self.preview_cache.contains_key(&path) && editor.document_by_path(&path).is_none() {
66                 // TODO: enable syntax highlighting; blocked by async rendering
67                 let doc = Document::open(&path, None, Some(&editor.theme), None).unwrap();
68                 self.preview_cache.insert(path, doc);
69             }
70         }
71     }
72 }
73 
74 impl<T: 'static> Component for FilePicker<T> {
render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context)75     fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
76         // +---------+ +---------+
77         // |prompt   | |preview  |
78         // +---------+ |         |
79         // |picker   | |         |
80         // |         | |         |
81         // +---------+ +---------+
82         self.calculate_preview(cx.editor);
83         let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW;
84         let area = inner_rect(area);
85         // -- Render the frame:
86         // clear area
87         let background = cx.editor.theme.get("ui.background");
88         surface.clear_with(area, background);
89 
90         let picker_width = if render_preview {
91             area.width / 2
92         } else {
93             area.width
94         };
95 
96         let picker_area = area.with_width(picker_width);
97         self.picker.render(picker_area, surface, cx);
98 
99         if !render_preview {
100             return;
101         }
102 
103         let preview_area = area.clip_left(picker_width);
104 
105         // don't like this but the lifetime sucks
106         let block = Block::default().borders(Borders::ALL);
107 
108         // calculate the inner area inside the box
109         let inner = block.inner(preview_area);
110         // 1 column gap on either side
111         let margin = Margin {
112             vertical: 0,
113             horizontal: 1,
114         };
115         let inner = inner.inner(&margin);
116 
117         block.render(preview_area, surface);
118 
119         if let Some((doc, line)) = self.current_file(cx.editor).and_then(|(path, range)| {
120             cx.editor
121                 .document_by_path(&path)
122                 .or_else(|| self.preview_cache.get(&path))
123                 .zip(Some(range))
124         }) {
125             // align to middle
126             let first_line = line
127                 .map(|(start, end)| {
128                     let height = end.saturating_sub(start) + 1;
129                     let middle = start + (height.saturating_sub(1) / 2);
130                     middle.saturating_sub(inner.height as usize / 2).min(start)
131                 })
132                 .unwrap_or(0);
133 
134             let offset = Position::new(first_line, 0);
135 
136             let highlights = EditorView::doc_syntax_highlights(
137                 doc,
138                 offset,
139                 area.height,
140                 &cx.editor.theme,
141                 &cx.editor.syn_loader,
142             );
143             EditorView::render_text_highlights(
144                 doc,
145                 offset,
146                 inner,
147                 surface,
148                 &cx.editor.theme,
149                 highlights,
150             );
151 
152             // highlight the line
153             if let Some((start, end)) = line {
154                 let offset = start.saturating_sub(first_line) as u16;
155                 surface.set_style(
156                     Rect::new(
157                         inner.x,
158                         inner.y + offset,
159                         inner.width,
160                         (end.saturating_sub(start) as u16 + 1)
161                             .min(inner.height.saturating_sub(offset)),
162                     ),
163                     cx.editor
164                         .theme
165                         .try_get("ui.highlight")
166                         .unwrap_or_else(|| cx.editor.theme.get("ui.selection")),
167                 );
168             }
169         }
170     }
171 
handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult172     fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult {
173         // TODO: keybinds for scrolling preview
174         self.picker.handle_event(event, ctx)
175     }
176 
cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind)177     fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
178         self.picker.cursor(area, ctx)
179     }
180 }
181 
182 pub struct Picker<T> {
183     options: Vec<T>,
184     // filter: String,
185     matcher: Box<Matcher>,
186     /// (index, score)
187     matches: Vec<(usize, i64)>,
188     /// Filter over original options.
189     filters: Vec<usize>, // could be optimized into bit but not worth it now
190 
191     cursor: usize,
192     // pattern: String,
193     prompt: Prompt,
194     /// Whether to render in the middle of the area
195     render_centered: bool,
196 
197     format_fn: Box<dyn Fn(&T) -> Cow<str>>,
198     callback_fn: Box<dyn Fn(&mut Editor, &T, Action)>,
199 }
200 
201 impl<T> Picker<T> {
new( render_centered: bool, options: Vec<T>, format_fn: impl Fn(&T) -> Cow<str> + 'static, callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, ) -> Self202     pub fn new(
203         render_centered: bool,
204         options: Vec<T>,
205         format_fn: impl Fn(&T) -> Cow<str> + 'static,
206         callback_fn: impl Fn(&mut Editor, &T, Action) + 'static,
207     ) -> Self {
208         let prompt = Prompt::new(
209             "".into(),
210             None,
211             |_pattern: &str| Vec::new(),
212             |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {
213                 //
214             },
215         );
216 
217         let mut picker = Self {
218             options,
219             matcher: Box::new(Matcher::default()),
220             matches: Vec::new(),
221             filters: Vec::new(),
222             cursor: 0,
223             prompt,
224             render_centered,
225             format_fn: Box::new(format_fn),
226             callback_fn: Box::new(callback_fn),
227         };
228 
229         // TODO: scoring on empty input should just use a fastpath
230         picker.score();
231 
232         picker
233     }
234 
score(&mut self)235     pub fn score(&mut self) {
236         let pattern = &self.prompt.line;
237 
238         // reuse the matches allocation
239         self.matches.clear();
240         self.matches.extend(
241             self.options
242                 .iter()
243                 .enumerate()
244                 .filter_map(|(index, option)| {
245                     // filter options first before matching
246                     if !self.filters.is_empty() {
247                         self.filters.binary_search(&index).ok()?;
248                     }
249                     // TODO: maybe using format_fn isn't the best idea here
250                     let text = (self.format_fn)(option);
251                     // TODO: using fuzzy_indices could give us the char idx for match highlighting
252                     self.matcher
253                         .fuzzy_match(&text, pattern)
254                         .map(|score| (index, score))
255                 }),
256         );
257         self.matches.sort_unstable_by_key(|(_, score)| -score);
258 
259         // reset cursor position
260         self.cursor = 0;
261     }
262 
move_up(&mut self)263     pub fn move_up(&mut self) {
264         if self.matches.is_empty() {
265             return;
266         }
267         let len = self.matches.len();
268         let pos = ((self.cursor + len.saturating_sub(1)) % len) % len;
269         self.cursor = pos;
270     }
271 
move_down(&mut self)272     pub fn move_down(&mut self) {
273         if self.matches.is_empty() {
274             return;
275         }
276         let len = self.matches.len();
277         let pos = (self.cursor + 1) % len;
278         self.cursor = pos;
279     }
280 
selection(&self) -> Option<&T>281     pub fn selection(&self) -> Option<&T> {
282         self.matches
283             .get(self.cursor)
284             .map(|(index, _score)| &self.options[*index])
285     }
286 
save_filter(&mut self)287     pub fn save_filter(&mut self) {
288         self.filters.clear();
289         self.filters
290             .extend(self.matches.iter().map(|(index, _)| *index));
291         self.filters.sort_unstable(); // used for binary search later
292         self.prompt.clear();
293     }
294 }
295 
296 // process:
297 // - read all the files into a list, maxed out at a large value
298 // - on input change:
299 //  - score all the names in relation to input
300 
inner_rect(area: Rect) -> Rect301 fn inner_rect(area: Rect) -> Rect {
302     let margin = Margin {
303         vertical: area.height * 10 / 100,
304         horizontal: area.width * 10 / 100,
305     };
306     area.inner(&margin)
307 }
308 
309 impl<T: 'static> Component for Picker<T> {
handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult310     fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
311         let key_event = match event {
312             Event::Key(event) => event,
313             Event::Resize(..) => return EventResult::Consumed(None),
314             _ => return EventResult::Ignored,
315         };
316 
317         let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
318             // remove the layer
319             compositor.last_picker = compositor.pop();
320         })));
321 
322         match key_event {
323             KeyEvent {
324                 code: KeyCode::Up, ..
325             }
326             | KeyEvent {
327                 code: KeyCode::BackTab,
328                 ..
329             }
330             | KeyEvent {
331                 code: KeyCode::Char('k'),
332                 modifiers: KeyModifiers::CONTROL,
333             }
334             | KeyEvent {
335                 code: KeyCode::Char('p'),
336                 modifiers: KeyModifiers::CONTROL,
337             } => {
338                 self.move_up();
339             }
340             KeyEvent {
341                 code: KeyCode::Down,
342                 ..
343             }
344             | KeyEvent {
345                 code: KeyCode::Tab, ..
346             }
347             | KeyEvent {
348                 code: KeyCode::Char('j'),
349                 modifiers: KeyModifiers::CONTROL,
350             }
351             | KeyEvent {
352                 code: KeyCode::Char('n'),
353                 modifiers: KeyModifiers::CONTROL,
354             } => {
355                 self.move_down();
356             }
357             KeyEvent {
358                 code: KeyCode::Esc, ..
359             }
360             | KeyEvent {
361                 code: KeyCode::Char('c'),
362                 modifiers: KeyModifiers::CONTROL,
363             } => {
364                 return close_fn;
365             }
366             KeyEvent {
367                 code: KeyCode::Enter,
368                 ..
369             } => {
370                 if let Some(option) = self.selection() {
371                     (self.callback_fn)(&mut cx.editor, option, Action::Replace);
372                 }
373                 return close_fn;
374             }
375             KeyEvent {
376                 code: KeyCode::Char('s'),
377                 modifiers: KeyModifiers::CONTROL,
378             } => {
379                 if let Some(option) = self.selection() {
380                     (self.callback_fn)(&mut cx.editor, option, Action::HorizontalSplit);
381                 }
382                 return close_fn;
383             }
384             KeyEvent {
385                 code: KeyCode::Char('v'),
386                 modifiers: KeyModifiers::CONTROL,
387             } => {
388                 if let Some(option) = self.selection() {
389                     (self.callback_fn)(&mut cx.editor, option, Action::VerticalSplit);
390                 }
391                 return close_fn;
392             }
393             KeyEvent {
394                 code: KeyCode::Char(' '),
395                 modifiers: KeyModifiers::CONTROL,
396             } => {
397                 self.save_filter();
398             }
399             _ => {
400                 if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
401                     // TODO: recalculate only if pattern changed
402                     self.score();
403                 }
404             }
405         }
406 
407         EventResult::Consumed(None)
408     }
409 
render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context)410     fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
411         let area = if self.render_centered {
412             inner_rect(area)
413         } else {
414             area
415         };
416 
417         let text_style = cx.editor.theme.get("ui.text");
418 
419         // -- Render the frame:
420         // clear area
421         let background = cx.editor.theme.get("ui.background");
422         surface.clear_with(area, background);
423 
424         // don't like this but the lifetime sucks
425         let block = Block::default().borders(Borders::ALL);
426 
427         // calculate the inner area inside the box
428         let inner = block.inner(area);
429 
430         block.render(area, surface);
431 
432         // -- Render the input bar:
433 
434         let area = inner.clip_left(1).with_height(1);
435 
436         let count = format!("{}/{}", self.matches.len(), self.options.len());
437         surface.set_stringn(
438             (area.x + area.width).saturating_sub(count.len() as u16 + 1),
439             area.y,
440             &count,
441             (count.len()).min(area.width as usize),
442             text_style,
443         );
444 
445         self.prompt.render(area, surface, cx);
446 
447         // -- Separator
448         let sep_style = Style::default().fg(Color::Rgb(90, 89, 119));
449         let borders = BorderType::line_symbols(BorderType::Plain);
450         for x in inner.left()..inner.right() {
451             surface
452                 .get_mut(x, inner.y + 1)
453                 .set_symbol(borders.horizontal)
454                 .set_style(sep_style);
455         }
456 
457         // -- Render the contents:
458         // subtract area of prompt from top and current item marker " > " from left
459         let inner = inner.clip_top(2).clip_left(3);
460 
461         let selected = cx.editor.theme.get("ui.text.focus");
462 
463         let rows = inner.height;
464         let offset = self.cursor / (rows as usize) * (rows as usize);
465 
466         let files = self.matches.iter().skip(offset).map(|(index, _score)| {
467             (index, self.options.get(*index).unwrap()) // get_unchecked
468         });
469 
470         for (i, (_index, option)) in files.take(rows as usize).enumerate() {
471             if i == (self.cursor - offset) {
472                 surface.set_string(inner.x - 2, inner.y + i as u16, ">", selected);
473             }
474 
475             surface.set_string_truncated(
476                 inner.x,
477                 inner.y + i as u16,
478                 (self.format_fn)(option),
479                 inner.width as usize,
480                 if i == (self.cursor - offset) {
481                     selected
482                 } else {
483                     text_style
484                 },
485                 true,
486             );
487         }
488     }
489 
cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind)490     fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
491         // TODO: this is mostly duplicate code
492         let area = inner_rect(area);
493         let block = Block::default().borders(Borders::ALL);
494         // calculate the inner area inside the box
495         let inner = block.inner(area);
496 
497         // prompt area
498         let area = inner.clip_left(1).with_height(1);
499 
500         self.prompt.cursor(area, editor)
501     }
502 }
503