2 compositor::{Component, Compositor, Context, EventResult},
5use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
7 buffer::Buffer as Surface,
8 widgets::{Block, BorderType, Borders},
11use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
12use fuzzy_matcher::FuzzyMatcher;
13use tui::widgets::Widget;
15use std::{borrow::Cow, collections::HashMap, path::PathBuf};
17use crate::ui::{Prompt, PromptEvent};
18use helix_core::Position;
20 document::canonicalize_path,
22 graphics::{Color, CursorKind, Rect, Style},
26pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80;
28/// File path and line number (used to align and highlight a line)
29type FileLocation = (PathBuf, Option<usize>);
31pub struct FilePicker<T> {
33 /// Caches paths to documents
34 preview_cache: HashMap<PathBuf, Document>,
35 /// Given an item in the picker, return the file path and line number to display.
36 file_fn: Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>,
39impl<T> FilePicker<T> {
42 format_fn: impl Fn(&T) -> Cow<str> + 'static,
43 callback_fn: impl Fn(&mut Editor, &T, Action) + 'static,
44 preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
47 picker: Picker::new(false, options, format_fn, callback_fn),
48 preview_cache: HashMap::new(),
49 file_fn: Box::new(preview_fn),
53 fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
56 .and_then(|current| (self.file_fn)(editor, current))
57 .and_then(|(path, line)| canonicalize_path(&path).ok().zip(Some(line)))
60 fn calculate_preview(&mut self, editor: &Editor) {
61 if let Some((path, _line)) = self.current_file(editor) {
62 if !self.preview_cache.contains_key(&path) && editor.document_by_path(&path).is_none() {
63 // TODO: enable syntax highlighting; blocked by async rendering
64 let doc = Document::open(&path, None, Some(&editor.theme), None).unwrap();
65 self.preview_cache.insert(path, doc);
71impl<T: 'static> Component for FilePicker<T> {
72 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
73 // +---------+ +---------+
74 // |prompt | |preview |
78 // +---------+ +---------+
79 self.calculate_preview(cx.editor);
80 let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW;
81 let area = inner_rect(area);
82 // -- Render the frame:
84 let background = cx.editor.theme.get("ui.background");
85 surface.clear_with(area, background);
87 let picker_width = if render_preview {
93 let picker_area = Rect::new(area.x, area.y, picker_width, area.height);
94 self.picker.render(picker_area, surface, cx);
100 let preview_area = Rect::new(area.x + picker_width, area.y, area.width / 2, area.height);
102 // don't like this but the lifetime sucks
103 let block = Block::default().borders(Borders::ALL);
105 // calculate the inner area inside the box
106 let mut inner = block.inner(preview_area);
107 // 1 column gap on either side
109 inner.width = inner.width.saturating_sub(2);
111 block.render(preview_area, surface);
113 if let Some((doc, line)) = self.current_file(cx.editor).and_then(|(path, line)| {
115 .document_by_path(&path)
116 .or_else(|| self.preview_cache.get(&path))
120 let first_line = line.unwrap_or(0).saturating_sub(inner.height as usize / 2);
121 let offset = Position::new(first_line, 0);
123 let highlights = EditorView::doc_syntax_highlights(
128 &cx.editor.syn_loader,
130 EditorView::render_text_highlights(
139 // highlight the line
140 if let Some(line) = line {
141 for x in inner.left()..inner.right() {
143 .get_mut(x, inner.y + line.saturating_sub(first_line) as u16)
144 .set_style(cx.editor.theme.get("ui.selection"));
150 fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult {
151 // TODO: keybinds for scrolling preview
152 self.picker.handle_event(event, ctx)
155 fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
156 self.picker.cursor(area, ctx)
160pub struct Picker<T> {
163 matcher: Box<Matcher>,
165 matches: Vec<(usize, i64)>,
166 /// Filter over original options.
167 filters: Vec<usize>, // could be optimized into bit but not worth it now
172 /// Whether to render in the middle of the area
173 render_centered: bool,
175 format_fn: Box<dyn Fn(&T) -> Cow<str>>,
176 callback_fn: Box<dyn Fn(&mut Editor, &T, Action)>,
181 render_centered: bool,
183 format_fn: impl Fn(&T) -> Cow<str> + 'static,
184 callback_fn: impl Fn(&mut Editor, &T, Action) + 'static,
186 let prompt = Prompt::new(
189 |_pattern: &str| Vec::new(),
190 |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {
195 let mut picker = Self {
197 matcher: Box::new(Matcher::default()),
203 format_fn: Box::new(format_fn),
204 callback_fn: Box::new(callback_fn),
207 // TODO: scoring on empty input should just use a fastpath
213 pub fn score(&mut self) {
214 // need to borrow via pattern match otherwise it complains about simultaneous borrow
223 let pattern = &self.prompt.line;
225 // reuse the matches allocation
231 .filter_map(|(index, option)| {
232 // filter options first before matching
233 if !filters.is_empty() {
234 filters.binary_search(&index).ok()?;
236 // TODO: maybe using format_fn isn't the best idea here
237 let text = (format_fn)(option);
238 // TODO: using fuzzy_indices could give us the char idx for match highlighting
240 .fuzzy_match(&text, pattern)
241 .map(|score| (index, score))
244 matches.sort_unstable_by_key(|(_, score)| -score);
246 // reset cursor position
250 pub fn move_up(&mut self) {
251 self.cursor = self.cursor.saturating_sub(1);
254 pub fn move_down(&mut self) {
255 if self.matches.is_empty() {
259 if self.cursor < self.matches.len() - 1 {
264 pub fn selection(&self) -> Option<&T> {
267 .map(|(index, _score)| &self.options[*index])
270 pub fn save_filter(&mut self) {
271 self.filters.clear();
273 .extend(self.matches.iter().map(|(index, _)| *index));
274 self.filters.sort_unstable(); // used for binary search later
280// - read all the files into a list, maxed out at a large value
282// - score all the names in relation to input
284fn inner_rect(area: Rect) -> Rect {
285 let padding_vertical = area.height * 10 / 100;
286 let padding_horizontal = area.width * 10 / 100;
289 area.x + padding_horizontal,
290 area.y + padding_vertical,
291 area.width - padding_horizontal * 2,
292 area.height - padding_vertical * 2,
296impl<T: 'static> Component for Picker<T> {
297 fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
298 let key_event = match event {
299 Event::Key(event) => event,
300 Event::Resize(..) => return EventResult::Consumed(None),
301 _ => return EventResult::Ignored,
304 let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
306 compositor.last_picker = compositor.pop();
311 code: KeyCode::Up, ..
314 code: KeyCode::BackTab,
318 code: KeyCode::Char('p'),
319 modifiers: KeyModifiers::CONTROL,
328 code: KeyCode::Tab, ..
331 code: KeyCode::Char('n'),
332 modifiers: KeyModifiers::CONTROL,
337 code: KeyCode::Esc, ..
340 code: KeyCode::Char('c'),
341 modifiers: KeyModifiers::CONTROL,
346 code: KeyCode::Enter,
349 if let Some(option) = self.selection() {
350 (self.callback_fn)(&mut cx.editor, option, Action::Replace);
355 code: KeyCode::Char('h'),
356 modifiers: KeyModifiers::CONTROL,
358 if let Some(option) = self.selection() {
359 (self.callback_fn)(&mut cx.editor, option, Action::HorizontalSplit);
364 code: KeyCode::Char('v'),
365 modifiers: KeyModifiers::CONTROL,
367 if let Some(option) = self.selection() {
368 (self.callback_fn)(&mut cx.editor, option, Action::VerticalSplit);
373 code: KeyCode::Char(' '),
374 modifiers: KeyModifiers::CONTROL,
379 if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
380 // TODO: recalculate only if pattern changed
386 EventResult::Consumed(None)
389 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
390 let area = if self.render_centered {
396 let text_style = cx.editor.theme.get("ui.text");
398 // -- Render the frame:
400 let background = cx.editor.theme.get("ui.background");
401 surface.clear_with(area, background);
403 // don't like this but the lifetime sucks
404 let block = Block::default().borders(Borders::ALL);
406 // calculate the inner area inside the box
407 let inner = block.inner(area);
409 block.render(area, surface);
411 // -- Render the input bar:
413 let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1);
415 let count = format!("{}/{}", self.matches.len(), self.options.len());
417 (area.x + area.width).saturating_sub(count.len() as u16 + 1),
420 (count.len()).min(area.width as usize),
424 self.prompt.render(area, surface, cx);
427 let sep_style = Style::default().fg(Color::Rgb(90, 89, 119));
428 let borders = BorderType::line_symbols(BorderType::Plain);
429 for x in inner.left()..inner.right() {
431 .get_mut(x, inner.y + 1)
432 .set_symbol(borders.horizontal)
433 .set_style(sep_style);
436 // -- Render the contents:
437 // subtract the area of the prompt (-2) and current item marker " > " (-3)
438 let inner = Rect::new(inner.x + 3, inner.y + 2, inner.width - 3, inner.height - 2);
440 let selected = cx.editor.theme.get("ui.text.focus");
442 let rows = inner.height;
443 let offset = self.cursor / (rows as usize) * (rows as usize);
445 let files = self.matches.iter().skip(offset).map(|(index, _score)| {
446 (index, self.options.get(*index).unwrap()) // get_unchecked
449 for (i, (_index, option)) in files.take(rows as usize).enumerate() {
450 if i == (self.cursor - offset) {
451 surface.set_string(inner.x - 2, inner.y + i as u16, ">", selected);
454 surface.set_string_truncated(
457 (self.format_fn)(option),
458 inner.width as usize,
459 if i == (self.cursor - offset) {
469 fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
470 // TODO: this is mostly duplicate code
471 let area = inner_rect(area);
472 let block = Block::default().borders(Borders::ALL);
473 // calculate the inner area inside the box
474 let inner = block.inner(area);
477 let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1);
479 self.prompt.cursor(area, editor)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list