on 2158366 help sign in
helix/helix-term/src/ui/picker.rs
481 lines 15 kB history raw
1use crate::{
2 compositor::{Component, Compositor, Context, EventResult},
3 ui::EditorView,
4};
5use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
6use tui::{
7 buffer::Buffer as Surface,
8 widgets::{Block, BorderType, Borders},
9};
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;
19use helix_view::{
20 document::canonicalize_path,
21 editor::Action,
22 graphics::{Color, CursorKind, Rect, Style},
23 Document, Editor,
24};
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> {
32 picker: Picker<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> {
40 pub fn new(
41 options: Vec<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,
45 ) -> Self {
46 Self {
47 picker: Picker::new(false, options, format_fn, callback_fn),
48 preview_cache: HashMap::new(),
49 file_fn: Box::new(preview_fn),
50 }
51 }
53 fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
54 self.picker
55 .selection()
56 .and_then(|current| (self.file_fn)(editor, current))
57 .and_then(|(path, line)| canonicalize_path(&path).ok().zip(Some(line)))
58 }
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);
66 }
67 }
68 }
71impl<T: 'static> Component for FilePicker<T> {
72 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
73 // +---------+ +---------+
74 // |prompt | |preview |
75 // +---------+ | |
76 // |picker | | |
77 // | | | |
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:
83 // clear area
84 let background = cx.editor.theme.get("ui.background");
85 surface.clear_with(area, background);
87 let picker_width = if render_preview {
88 area.width / 2
89 } else {
90 area.width
91 };
93 let picker_area = Rect::new(area.x, area.y, picker_width, area.height);
94 self.picker.render(picker_area, surface, cx);
96 if !render_preview {
97 return;
98 }
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
108 inner.x += 1;
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)| {
114 cx.editor
115 .document_by_path(&path)
116 .or_else(|| self.preview_cache.get(&path))
117 .zip(Some(line))
118 }) {
119 // align to middle
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(
124 doc,
125 offset,
126 area.height,
127 &cx.editor.theme,
128 &cx.editor.syn_loader,
129 );
130 EditorView::render_text_highlights(
131 doc,
132 offset,
133 inner,
134 surface,
135 &cx.editor.theme,
136 highlights,
137 );
139 // highlight the line
140 if let Some(line) = line {
141 for x in inner.left()..inner.right() {
142 surface
143 .get_mut(x, inner.y + line.saturating_sub(first_line) as u16)
144 .set_style(cx.editor.theme.get("ui.selection"));
145 }
146 }
147 }
148 }
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)
153 }
155 fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
156 self.picker.cursor(area, ctx)
157 }
160pub struct Picker<T> {
161 options: Vec<T>,
162 // filter: String,
163 matcher: Box<Matcher>,
164 /// (index, score)
165 matches: Vec<(usize, i64)>,
166 /// Filter over original options.
167 filters: Vec<usize>, // could be optimized into bit but not worth it now
169 cursor: usize,
170 // pattern: String,
171 prompt: Prompt,
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)>,
179impl<T> Picker<T> {
180 pub fn new(
181 render_centered: bool,
182 options: Vec<T>,
183 format_fn: impl Fn(&T) -> Cow<str> + 'static,
184 callback_fn: impl Fn(&mut Editor, &T, Action) + 'static,
185 ) -> Self {
186 let prompt = Prompt::new(
187 "".to_string(),
188 None,
189 |_pattern: &str| Vec::new(),
190 |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {
191 //
192 },
193 );
195 let mut picker = Self {
196 options,
197 matcher: Box::new(Matcher::default()),
198 matches: Vec::new(),
199 filters: Vec::new(),
200 cursor: 0,
201 prompt,
202 render_centered,
203 format_fn: Box::new(format_fn),
204 callback_fn: Box::new(callback_fn),
205 };
207 // TODO: scoring on empty input should just use a fastpath
208 picker.score();
210 picker
211 }
213 pub fn score(&mut self) {
214 // need to borrow via pattern match otherwise it complains about simultaneous borrow
215 let Self {
216 ref mut matcher,
217 ref mut matches,
218 ref filters,
219 ref format_fn,
220 ..
221 } = *self;
223 let pattern = &self.prompt.line;
225 // reuse the matches allocation
226 matches.clear();
227 matches.extend(
228 self.options
229 .iter()
230 .enumerate()
231 .filter_map(|(index, option)| {
232 // filter options first before matching
233 if !filters.is_empty() {
234 filters.binary_search(&index).ok()?;
235 }
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
239 matcher
240 .fuzzy_match(&text, pattern)
241 .map(|score| (index, score))
242 }),
243 );
244 matches.sort_unstable_by_key(|(_, score)| -score);
246 // reset cursor position
247 self.cursor = 0;
248 }
250 pub fn move_up(&mut self) {
251 self.cursor = self.cursor.saturating_sub(1);
252 }
254 pub fn move_down(&mut self) {
255 if self.matches.is_empty() {
256 return;
257 }
259 if self.cursor < self.matches.len() - 1 {
260 self.cursor += 1;
261 }
262 }
264 pub fn selection(&self) -> Option<&T> {
265 self.matches
266 .get(self.cursor)
267 .map(|(index, _score)| &self.options[*index])
268 }
270 pub fn save_filter(&mut self) {
271 self.filters.clear();
272 self.filters
273 .extend(self.matches.iter().map(|(index, _)| *index));
274 self.filters.sort_unstable(); // used for binary search later
275 self.prompt.clear();
276 }
279// process:
280// - read all the files into a list, maxed out at a large value
281// - on input change:
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;
288 Rect::new(
289 area.x + padding_horizontal,
290 area.y + padding_vertical,
291 area.width - padding_horizontal * 2,
292 area.height - padding_vertical * 2,
293 )
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,
302 };
304 let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
305 // remove the layer
306 compositor.last_picker = compositor.pop();
307 })));
309 match key_event {
310 KeyEvent {
311 code: KeyCode::Up, ..
312 }
313 | KeyEvent {
314 code: KeyCode::BackTab,
315 ..
316 }
317 | KeyEvent {
318 code: KeyCode::Char('p'),
319 modifiers: KeyModifiers::CONTROL,
320 } => {
321 self.move_up();
322 }
323 KeyEvent {
324 code: KeyCode::Down,
325 ..
326 }
327 | KeyEvent {
328 code: KeyCode::Tab, ..
329 }
330 | KeyEvent {
331 code: KeyCode::Char('n'),
332 modifiers: KeyModifiers::CONTROL,
333 } => {
334 self.move_down();
335 }
336 KeyEvent {
337 code: KeyCode::Esc, ..
338 }
339 | KeyEvent {
340 code: KeyCode::Char('c'),
341 modifiers: KeyModifiers::CONTROL,
342 } => {
343 return close_fn;
344 }
345 KeyEvent {
346 code: KeyCode::Enter,
347 ..
348 } => {
349 if let Some(option) = self.selection() {
350 (self.callback_fn)(&mut cx.editor, option, Action::Replace);
351 }
352 return close_fn;
353 }
354 KeyEvent {
355 code: KeyCode::Char('h'),
356 modifiers: KeyModifiers::CONTROL,
357 } => {
358 if let Some(option) = self.selection() {
359 (self.callback_fn)(&mut cx.editor, option, Action::HorizontalSplit);
360 }
361 return close_fn;
362 }
363 KeyEvent {
364 code: KeyCode::Char('v'),
365 modifiers: KeyModifiers::CONTROL,
366 } => {
367 if let Some(option) = self.selection() {
368 (self.callback_fn)(&mut cx.editor, option, Action::VerticalSplit);
369 }
370 return close_fn;
371 }
372 KeyEvent {
373 code: KeyCode::Char(' '),
374 modifiers: KeyModifiers::CONTROL,
375 } => {
376 self.save_filter();
377 }
378 _ => {
379 if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
380 // TODO: recalculate only if pattern changed
381 self.score();
382 }
383 }
384 }
386 EventResult::Consumed(None)
387 }
389 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
390 let area = if self.render_centered {
391 inner_rect(area)
392 } else {
393 area
394 };
396 let text_style = cx.editor.theme.get("ui.text");
398 // -- Render the frame:
399 // clear area
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());
416 surface.set_stringn(
417 (area.x + area.width).saturating_sub(count.len() as u16 + 1),
418 area.y,
419 &count,
420 (count.len()).min(area.width as usize),
421 text_style,
422 );
424 self.prompt.render(area, surface, cx);
426 // -- Separator
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() {
430 surface
431 .get_mut(x, inner.y + 1)
432 .set_symbol(borders.horizontal)
433 .set_style(sep_style);
434 }
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
447 });
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);
452 }
454 surface.set_string_truncated(
455 inner.x,
456 inner.y + i as u16,
457 (self.format_fn)(option),
458 inner.width as usize,
459 if i == (self.cursor - offset) {
460 selected
461 } else {
462 text_style
463 },
464 true,
465 );
466 }
467 }
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);
476 // prompt area
477 let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1);
479 self.prompt.cursor(area, editor)
480 }