summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--helix-term/src/commands.rs54
-rw-r--r--helix-term/src/commands/dap.rs94
-rw-r--r--helix-term/src/commands/lsp.rs90
-rw-r--r--helix-term/src/ui/mod.rs30
-rw-r--r--helix-term/src/ui/picker.rs776
5 files changed, 489 insertions, 555 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 5fb4a70e..2c9295f1 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -55,8 +55,8 @@ use crate::{
job::Callback,
keymap::ReverseKeymap,
ui::{
- self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem,
- FilePicker, Picker, Popup, Prompt, PromptEvent,
+ self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
+ Popup, Prompt, PromptEvent,
},
};
@@ -2156,7 +2156,7 @@ fn global_search(cx: &mut Context) {
return;
}
- let picker = FilePicker::new(
+ let picker = Picker::new(
all_matches,
current_path,
move |cx, FileResult { path, line_num }, action| {
@@ -2184,11 +2184,9 @@ fn global_search(cx: &mut Context) {
doc.set_selection(view.id, Selection::single(start, end));
align_view(doc, view, Align::Center);
- },
- |_editor, FileResult { path, line_num }| {
+ }).with_preview(|_editor, FileResult { path, line_num }| {
Some((path.clone().into(), Some((*line_num, *line_num))))
- },
- );
+ });
compositor.push(Box::new(overlaid(picker)));
},
));
@@ -2579,22 +2577,18 @@ fn buffer_picker(cx: &mut Context) {
// mru
items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at));
- let picker = FilePicker::new(
- items,
- (),
- |cx, meta, action| {
- cx.editor.switch(meta.id, action);
- },
- |editor, meta| {
- let doc = &editor.documents.get(&meta.id)?;
- let &view_id = doc.selections().keys().next()?;
- let line = doc
- .selection(view_id)
- .primary()
- .cursor_line(doc.text().slice(..));
- Some((meta.id.into(), Some((line, line))))
- },
- );
+ let picker = Picker::new(items, (), |cx, meta, action| {
+ cx.editor.switch(meta.id, action);
+ })
+ .with_preview(|editor, meta| {
+ let doc = &editor.documents.get(&meta.id)?;
+ let &view_id = doc.selections().keys().next()?;
+ let line = doc
+ .selection(view_id)
+ .primary()
+ .cursor_line(doc.text().slice(..));
+ Some((meta.id.into(), Some((line, line))))
+ });
cx.push_layer(Box::new(overlaid(picker)));
}
@@ -2660,7 +2654,7 @@ fn jumplist_picker(cx: &mut Context) {
}
};
- let picker = FilePicker::new(
+ let picker = Picker::new(
cx.editor
.tree
.views()
@@ -2678,12 +2672,12 @@ fn jumplist_picker(cx: &mut Context) {
doc.set_selection(view.id, meta.selection.clone());
view.ensure_cursor_in_view_center(doc, config.scrolloff);
},
- |editor, meta| {
- let doc = &editor.documents.get(&meta.id)?;
- let line = meta.selection.primary().cursor_line(doc.text().slice(..));
- Some((meta.id.into(), Some((line, line))))
- },
- );
+ )
+ .with_preview(|editor, meta| {
+ let doc = &editor.documents.get(&meta.id)?;
+ let line = meta.selection.primary().cursor_line(doc.text().slice(..));
+ Some((meta.id.into(), Some((line, line))))
+ });
cx.push_layer(Box::new(overlaid(picker)));
}
diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs
index 84794bed..70a5ec21 100644
--- a/helix-term/src/commands/dap.rs
+++ b/helix-term/src/commands/dap.rs
@@ -2,7 +2,7 @@ use super::{Context, Editor};
use crate::{
compositor::{self, Compositor},
job::{Callback, Jobs},
- ui::{self, overlay::overlaid, FilePicker, Picker, Popup, Prompt, PromptEvent, Text},
+ ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent, Text},
};
use dap::{StackFrame, Thread, ThreadStates};
use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
@@ -73,21 +73,19 @@ fn thread_picker(
let debugger = debugger!(editor);
let thread_states = debugger.thread_states.clone();
- let picker = FilePicker::new(
- threads,
- thread_states,
- move |cx, thread, _action| callback_fn(cx.editor, thread),
- move |editor, thread| {
- let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
- let frame = frames.get(0)?;
- let path = frame.source.as_ref()?.path.clone()?;
- let pos = Some((
- frame.line.saturating_sub(1),
- frame.end_line.unwrap_or(frame.line).saturating_sub(1),
- ));
- Some((path.into(), pos))
- },
- );
+ let picker = Picker::new(threads, thread_states, move |cx, thread, _action| {
+ callback_fn(cx.editor, thread)
+ })
+ .with_preview(move |editor, thread| {
+ let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
+ let frame = frames.get(0)?;
+ let path = frame.source.as_ref()?.path.clone()?;
+ let pos = Some((
+ frame.line.saturating_sub(1),
+ frame.end_line.unwrap_or(frame.line).saturating_sub(1),
+ ));
+ Some((path.into(), pos))
+ });
compositor.push(Box::new(picker));
},
);
@@ -728,39 +726,35 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
let frames = debugger.stack_frames[&thread_id].clone();
- let picker = FilePicker::new(
- frames,
- (),
- move |cx, frame, _action| {
- let debugger = debugger!(cx.editor);
- // TODO: this should be simpler to find
- let pos = debugger.stack_frames[&thread_id]
- .iter()
- .position(|f| f.id == frame.id);
- debugger.active_frame = pos;
-
- let frame = debugger.stack_frames[&thread_id]
- .get(pos.unwrap_or(0))
- .cloned();
- if let Some(frame) = &frame {
- jump_to_stack_frame(cx.editor, frame);
- }
- },
- move |_editor, frame| {
- frame
- .source
- .as_ref()
- .and_then(|source| source.path.clone())
- .map(|path| {
- (
- path.into(),
- Some((
- frame.line.saturating_sub(1),
- frame.end_line.unwrap_or(frame.line).saturating_sub(1),
- )),
- )
- })
- },
- );
+ let picker = Picker::new(frames, (), move |cx, frame, _action| {
+ let debugger = debugger!(cx.editor);
+ // TODO: this should be simpler to find
+ let pos = debugger.stack_frames[&thread_id]
+ .iter()
+ .position(|f| f.id == frame.id);
+ debugger.active_frame = pos;
+
+ let frame = debugger.stack_frames[&thread_id]
+ .get(pos.unwrap_or(0))
+ .cloned();
+ if let Some(frame) = &frame {
+ jump_to_stack_frame(cx.editor, frame);
+ }
+ })
+ .with_preview(move |_editor, frame| {
+ frame
+ .source
+ .as_ref()
+ .and_then(|source| source.path.clone())
+ .map(|path| {
+ (
+ path.into(),
+ Some((
+ frame.line.saturating_sub(1),
+ frame.end_line.unwrap_or(frame.line).saturating_sub(1),
+ )),
+ )
+ })
+ });
cx.push_layer(Box::new(picker))
}
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index 8c3fd13b..55153648 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -31,8 +31,8 @@ use crate::{
compositor::{self, Compositor},
job::Callback,
ui::{
- self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, FilePicker,
- Popup, PromptEvent,
+ self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
+ PromptEvent,
},
};
@@ -236,48 +236,44 @@ fn jump_to_location(
align_view(doc, view, Align::Center);
}
-type SymbolPicker = FilePicker<SymbolInformationItem>;
+type SymbolPicker = Picker<SymbolInformationItem>;
fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag?
- FilePicker::new(
- symbols,
- current_path.clone(),
- move |cx, item, action| {
- let (view, doc) = current!(cx.editor);
- push_jump(view, doc);
-
- if current_path.as_ref() != Some(&item.symbol.location.uri) {
- let uri = &item.symbol.location.uri;
- let path = match uri.to_file_path() {
- Ok(path) => path,
- Err(_) => {
- let err = format!("unable to convert URI to filepath: {}", uri);
- cx.editor.set_error(err);
- return;
- }
- };
- if let Err(err) = cx.editor.open(&path, action) {
- let err = format!("failed to open document: {}: {}", uri, err);
- log::error!("{}", err);
+ Picker::new(symbols, current_path.clone(), move |cx, item, action| {
+ let (view, doc) = current!(cx.editor);
+ push_jump(view, doc);
+
+ if current_path.as_ref() != Some(&item.symbol.location.uri) {
+ let uri = &item.symbol.location.uri;
+ let path = match uri.to_file_path() {
+ Ok(path) => path,
+ Err(_) => {
+ let err = format!("unable to convert URI to filepath: {}", uri);
cx.editor.set_error(err);
return;
}
+ };
+ if let Err(err) = cx.editor.open(&path, action) {
+ let err = format!("failed to open document: {}: {}", uri, err);
+ log::error!("{}", err);
+ cx.editor.set_error(err);
+ return;
}
+ }
- let (view, doc) = current!(cx.editor);
+ let (view, doc) = current!(cx.editor);
- if let Some(range) =
- lsp_range_to_range(doc.text(), item.symbol.location.range, item.offset_encoding)
- {
- // we flip the range so that the cursor sits on the start of the symbol
- // (for example start of the function).
- doc.set_selection(view.id, Selection::single(range.head, range.anchor));
- align_view(doc, view, Align::Center);
- }
- },
- move |_editor, item| Some(location_to_file_location(&item.symbol.location)),
- )
+ if let Some(range) =
+ lsp_range_to_range(doc.text(), item.symbol.location.range, item.offset_encoding)
+ {
+ // we flip the range so that the cursor sits on the start of the symbol
+ // (for example start of the function).
+ doc.set_selection(view.id, Selection::single(range.head, range.anchor));
+ align_view(doc, view, Align::Center);
+ }
+ })
+ .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location)))
.truncate_start(false)
}
@@ -292,7 +288,7 @@ fn diag_picker(
diagnostics: BTreeMap<lsp::Url, Vec<(lsp::Diagnostic, usize)>>,
current_path: Option<lsp::Url>,
format: DiagnosticsFormat,
-) -> FilePicker<PickerDiagnostic> {
+) -> Picker<PickerDiagnostic> {
// TODO: drop current_path comparison and instead use workspace: bool flag?
// flatten the map to a vec of (url, diag) pairs
@@ -318,7 +314,7 @@ fn diag_picker(
error: cx.editor.theme.get("error"),
};
- FilePicker::new(
+ Picker::new(
flat_diag,
(styles, format),
move |cx,
@@ -345,11 +341,11 @@ fn diag_picker(
align_view(doc, view, Align::Center);
}
},
- move |_editor, PickerDiagnostic { url, diag, .. }| {
- let location = lsp::Location::new(url.clone(), diag.range);
- Some(location_to_file_location(&location))
- },
)
+ .with_preview(move |_editor, PickerDiagnostic { url, diag, .. }| {
+ let location = lsp::Location::new(url.clone(), diag.range);
+ Some(location_to_file_location(&location))
+ })
.truncate_start(false)
}
@@ -1047,14 +1043,10 @@ fn goto_impl(
editor.set_error("No definition found.");
}
_locations => {
- let picker = FilePicker::new(
- locations,
- cwdir,
- move |cx, location, action| {
- jump_to_location(cx.editor, location, offset_encoding, action)
- },
- move |_editor, location| Some(location_to_file_location(location)),
- );
+ let picker = Picker::new(locations, cwdir, move |cx, location, action| {
+ jump_to_location(cx.editor, location, offset_encoding, action)
+ })
+ .with_preview(move |_editor, location| Some(location_to_file_location(location)));
compositor.push(Box::new(overlaid(picker)));
}
}
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index ec328ec5..155f2435 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -21,7 +21,7 @@ pub use completion::{Completion, CompletionItem};
pub use editor::EditorView;
pub use markdown::Markdown;
pub use menu::Menu;
-pub use picker::{DynamicPicker, FileLocation, FilePicker, Picker};
+pub use picker::{DynamicPicker, FileLocation, Picker};
pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner};
@@ -158,7 +158,7 @@ pub fn regex_prompt(
cx.push_layer(Box::new(prompt));
}
-pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker<PathBuf> {
+pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> {
use ignore::{types::TypesBuilder, WalkBuilder};
use std::time::Instant;
@@ -217,21 +217,17 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
- FilePicker::new(
- files,
- root,
- move |cx, path: &PathBuf, action| {
- if let Err(e) = cx.editor.open(path, action) {
- let err = if let Some(err) = e.source() {
- format!("{}", err)
- } else {
- format!("unable to open \"{}\"", path.display())
- };
- cx.editor.set_error(err);
- }
- },
- |_editor, path| Some((path.clone().into(), None)),
- )
+ Picker::new(files, root, move |cx, path: &PathBuf, action| {
+ if let Err(e) = cx.editor.open(path, action) {
+ let err = if let Some(err) = e.source() {
+ format!("{}", err)
+ } else {
+ format!("unable to open \"{}\"", path.display())
+ };
+ cx.editor.set_error(err);
+ }
+ })
+ .with_preview(|_editor, path| Some((path.clone().into(), None)))
}
pub mod completers {
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index d161f786..04ed940c 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -77,16 +77,6 @@ type FileCallback<T> = Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>;
/// File path and range of lines (used to align and highlight lines)
pub type FileLocation = (PathOrId, Option<(usize, usize)>);
-pub struct FilePicker<T: Item> {
- picker: Picker<T>,
- pub truncate_start: bool,
- /// Caches paths to documents
- preview_cache: HashMap<PathBuf, CachedPreview>,
- read_buffer: Vec<u8>,
- /// Given an item in the picker, return the file path and line number to display.
- file_fn: FileCallback<T>,
-}
-
pub enum CachedPreview {
Document(Box<Document>),
Binary,
@@ -124,325 +114,6 @@ impl Preview<'_, '_> {
}
}
-impl<T: Item + 'static> FilePicker<T> {
- pub fn new(
- options: Vec<T>,
- editor_data: T::Data,
- callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
- preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
- ) -> Self {
- let truncate_start = true;
- let mut picker = Picker::new(options, editor_data, callback_fn);
- picker.truncate_start = truncate_start;
-
- Self {
- picker,
- truncate_start,
- preview_cache: HashMap::new(),
- read_buffer: Vec::with_capacity(1024),
- file_fn: Box::new(preview_fn),
- }
- }
-
- pub fn truncate_start(mut self, truncate_start: bool) -> Self {
- self.truncate_start = truncate_start;
- self.picker.truncate_start = truncate_start;
- self
- }
-
- fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
- self.picker
- .selection()
- .and_then(|current| (self.file_fn)(editor, current))
- .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line)))
- }
-
- /// Get (cached) preview for a given path. If a document corresponding
- /// to the path is already open in the editor, it is used instead.
- fn get_preview<'picker, 'editor>(
- &'picker mut self,
- path_or_id: PathOrId,
- editor: &'editor Editor,
- ) -> Preview<'picker, 'editor> {
- match path_or_id {
- PathOrId::Path(path) => {
- let path = &path;
- if let Some(doc) = editor.document_by_path(path) {
- return Preview::EditorDocument(doc);
- }
-
- if self.preview_cache.contains_key(path) {
- return Preview::Cached(&self.preview_cache[path]);
- }
-
- let data = std::fs::File::open(path).and_then(|file| {
- let metadata = file.metadata()?;
- // Read up to 1kb to detect the content type
- let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
- let content_type = content_inspector::inspect(&self.read_buffer[..n]);
- self.read_buffer.clear();
- Ok((metadata, content_type))
- });
- let preview = data
- .map(
- |(metadata, content_type)| match (metadata.len(), content_type) {
- (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
- (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
- CachedPreview::LargeFile
- }
- _ => {
- // TODO: enable syntax highlighting; blocked by async rendering
- Document::open(path, None, None, editor.config.clone())
- .map(|doc| CachedPreview::Document(Box::new(doc)))
- .unwrap_or(CachedPreview::NotFound)
- }
- },
- )
- .unwrap_or(CachedPreview::NotFound);
- self.preview_cache.insert(path.to_owned(), preview);
- Preview::Cached(&self.preview_cache[path])
- }
- PathOrId::Id(id) => {
- let doc = editor.documents.get(&id).unwrap();
- Preview::EditorDocument(doc)
- }
- }
- }
-
- fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
- let Some((current_file, _)) = self.current_file(cx.editor) else {
- return EventResult::Consumed(None)
- };
-
- // Try to find a document in the cache
- let doc = match &current_file {
- PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id),
- PathOrId::Path(path) => match self.preview_cache.get_mut(path) {
- Some(CachedPreview::Document(ref mut doc)) => doc,
- _ => return EventResult::Consumed(None),
- },
- };
-
- let mut callback: Option<compositor::Callback> = None;
-
- // Then attempt to highlight it if it has no language set
- if doc.language_config().is_none() {
- if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) {
- doc.language = Some(language_config.clone());
- let text = doc.text().clone();
- let loader = cx.editor.syn_loader.clone();
- let job = tokio::task::spawn_blocking(move || {
- let syntax = language_config
- .highlight_config(&loader.scopes())
- .and_then(|highlight_config| Syntax::new(&text, highlight_config, loader));
- let callback = move |editor: &mut Editor, compositor: &mut Compositor| {
- let Some(syntax) = syntax else {
- log::info!("highlighting picker item failed");
- return
- };
- let Some(Overlay { content: picker, .. }) = compositor.find::<Overlay<Self>>() else {
- log::info!("picker closed before syntax highlighting finished");
- return
- };
- // Try to find a document in the cache
- let doc = match current_file {
- PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
- PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
- Some(CachedPreview::Document(ref mut doc)) => doc,
- _ => return,
- },
- };
- doc.syntax = Some(syntax);
- };
- Callback::EditorCompositor(Box::new(callback))
- });
- let tmp: compositor::Callback = Box::new(move |_, ctx| {
- ctx.jobs
- .callback(job.map(|res| res.map_err(anyhow::Error::from)))
- });
- callback = Some(Box::new(tmp))
- }
- }
-
- // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
- // but it could be interesting in the future
-
- EventResult::Consumed(callback)
- }
-}
-
-impl<T: Item + 'static> Component for FilePicker<T> {
- fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
- // +---------+ +---------+
- // |prompt | |preview |
- // +---------+ | |
- // |picker | | |
- // | | | |
- // +---------+ +---------+
-
- let render_preview = self.picker.show_preview && area.width > MIN_AREA_WIDTH_FOR_PREVIEW;
- // -- Render the frame:
- // clear area
- let background = cx.editor.theme.get("ui.background");
- let text = cx.editor.theme.get("ui.text");
- surface.clear_with(area, background);
-
- let picker_width = if render_preview {
- area.width / 2
- } else {
- area.width
- };
-
- let picker_area = area.with_width(picker_width);
- self.picker.render(picker_area, surface, cx);
-
- if !render_preview {
- return;
- }
-
- let preview_area = area.clip_left(picker_width);
-
- // don't like this but the lifetime sucks
- let block = Block::default().borders(Borders::ALL);
-
- // calculate the inner area inside the box
- let inner = block.inner(preview_area);
- // 1 column gap on either side
- let margin = Margin::horizontal(1);
- let inner = inner.inner(&margin);
- block.render(preview_area, surface);
-
- if let Some((path, range)) = self.current_file(cx.editor) {
- let preview = self.get_preview(path, cx.editor);
- let doc = match preview.document() {
- Some(doc) => doc,
- None => {
- let alt_text = preview.placeholder();
- let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2;
- let y = inner.y + inner.height / 2;
- surface.set_stringn(x, y, alt_text, inner.width as usize, text);
- return;
- }
- };
-
- // align to middle
- let first_line = range
- .map(|(start, end)| {
- let height = end.saturating_sub(start) + 1;
- let middle = start + (height.saturating_sub(1) / 2);
- middle.saturating_sub(inner.height as usize / 2).min(start)
- })
- .unwrap_or(0);
-
- let offset = ViewPosition {
- anchor: doc.text().line_to_char(first_line),
- horizontal_offset: 0,
- vertical_offset: 0,
- };
-
- let mut highlights = EditorView::doc_syntax_highlights(
- doc,
- offset.anchor,
- area.height,
- &cx.editor.theme,
- );
- for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
- if spans.is_empty() {
- continue;
- }
- highlights = Box::new(helix_core::syntax::merge(highlights, spans));
- }
- let mut decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
-
- if let Some((start, end)) = range {
- let style = cx
- .editor
- .theme
- .try_get("ui.highlight")
- .unwrap_or_else(|| cx.editor.theme.get("ui.selection"));
- let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| {
- if (start..=end).contains(&pos.doc_line) {
- let area = Rect::new(
- renderer.viewport.x,
- renderer.viewport.y + pos.visual_line,
- renderer.viewport.width,
- 1,
- );
- renderer.surface.set_style(area, style)
- }
- };
- decorations.push(Box::new(draw_highlight))
- }
-
- render_document(
- surface,
- inner,
- doc,
- offset,
- // TODO: compute text annotations asynchronously here (like inlay hints)
- &TextAnnotations::default(),
- highlights,
- &cx.editor.theme,
- &mut decorations,
- &mut [],
- );
- }
- }
-
- fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult {
- if let Event::IdleTimeout = event {
- return self.handle_idle_timeout(ctx);
- }
- // TODO: keybinds for scrolling preview
- self.picker.handle_event(event, ctx)
- }
-
- fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
- self.picker.cursor(area, ctx)
- }
-
- fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
- let picker_width = if width > MIN_AREA_WIDTH_FOR_PREVIEW {
- width / 2
- } else {
- width
- };
- self.picker.required_size((picker_width, height))?;
- Some((width, height))
- }
-
- fn id(&self) -> Option<&'static str> {
- Some("file-picker")
- }
-}
-
-#[derive(PartialEq, Eq, Debug)]
-struct PickerMatch {
- score: i64,
- index: usize,
- len: usize,
-}
-
-impl PickerMatch {
- fn key(&self) -> impl Ord {
- (cmp::Reverse(self.score), self.len, self.index)
- }
-}
-
-impl PartialOrd for PickerMatch {
- fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for PickerMatch {
- fn cmp(&self, other: &Self) -> Ordering {
- self.key().cmp(&other.key())
- }
-}
-
-type PickerCallback<T> = Box<dyn Fn(&mut Context, &T, Action)>;
-
pub struct Picker<T: Item> {
options: Vec<T>,
editor_data: T::Data,
@@ -457,17 +128,22 @@ pub struct Picker<T: Item> {
// pattern: String,
prompt: Prompt,
previous_pattern: (String, FuzzyQuery),
- /// Whether to truncate the start (default true)
- pub truncate_start: bool,
/// Whether to show the preview panel (default true)
show_preview: bool,
/// Constraints for tabular formatting
widths: Vec<Constraint>,
callback_fn: PickerCallback<T>,
+
+ pub truncate_start: bool,
+ /// Caches paths to documents
+ preview_cache: HashMap<PathBuf, CachedPreview>,
+ read_buffer: Vec<u8>,
+ /// Given an item in the picker, return the file path and line number to display.
+ file_fn: Option<FileCallback<T>>,
}
-impl<T: Item> Picker<T> {
+impl<T: Item + 'static> Picker<T> {
pub fn new(
options: Vec<T>,
editor_data: T::Data,
@@ -493,6 +169,9 @@ impl<T: Item> Picker<T> {
callback_fn: Box::new(callback_fn),
completion_height: 0,
widths: Vec::new(),
+ preview_cache: HashMap::new(),
+ read_buffer: Vec::with_capacity(1024),
+ file_fn: None,
};
picker.calculate_column_widths();
@@ -513,6 +192,19 @@ impl<T: Item> Picker<T> {
picker
}
+ pub fn truncate_start(mut self, truncate_start: bool) -> Self {
+ self.truncate_start = truncate_start;
+ self
+ }
+
+ pub fn with_preview(
+ mut self,
+ preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
+ ) -> Self {
+ self.file_fn = Some(Box::new(preview_fn));
+ self
+ }
+
pub fn set_options(&mut self, new_options: Vec<T>) {
self.options = new_options;
self.cursor = 0;
@@ -679,92 +371,127 @@ impl<T: Item> Picker<T> {
}
EventResult::Consumed(None)
}
-}
-// process:
-// - read all the files into a list, maxed out at a large value
-// - on input change:
-// - score all the names in relation to input
+ fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
+ self.selection()
+ .and_then(|current| (self.file_fn.as_ref()?)(editor, current))
+ .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line)))
+ }
-impl<T: Item + 'static> Component for Picker<T> {
- fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
- self.completion_height = viewport.1.saturating_sub(4);
- Some(viewport)
+ /// Get (cached) preview for a given path. If a document corresponding
+ /// to the path is already open in the editor, it is used instead.
+ fn get_preview<'picker, 'editor>(
+ &'picker mut self,
+ path_or_id: PathOrId,
+ editor: &'editor Editor,
+ ) -> Preview<'picker, 'editor> {
+ match path_or_id {
+ PathOrId::Path(path) => {
+ let path = &path;
+ if let Some(doc) = editor.document_by_path(path) {
+ return Preview::EditorDocument(doc);
+ }
+
+ if self.preview_cache.contains_key(path) {
+ return Preview::Cached(&self.preview_cache[path]);
+ }
+
+ let data = std::fs::File::open(path).and_then(|file| {
+ let metadata = file.metadata()?;
+ // Read up to 1kb to detect the content type
+ let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
+ let content_type = content_inspector::inspect(&self.read_buffer[..n]);
+ self.read_buffer.clear();
+ Ok((metadata, content_type))
+ });
+ let preview = data
+ .map(
+ |(metadata, content_type)| match (metadata.len(), content_type) {
+ (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
+ (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
+ CachedPreview::LargeFile
+ }
+ _ => {
+ // TODO: enable syntax highlighting; blocked by async rendering
+ Document::open(path, None, None, editor.config.clone())
+ .map(|doc| CachedPreview::Document(Box::new(doc)))
+ .unwrap_or(CachedPreview::NotFound)
+ }
+ },
+ )
+ .unwrap_or(CachedPreview::NotFound);
+ self.preview_cache.insert(path.to_owned(), preview);
+ Preview::Cached(&self.preview_cache[path])
+ }
+ PathOrId::Id(id) => {
+ let doc = editor.documents.get(&id).unwrap();
+ Preview::EditorDocument(doc)
+ }
+ }
}
- fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
- let key_event = match event {
- Event::Key(event) => *event,
- Event::Paste(..) => return self.prompt_handle_event(event, cx),
- Event::Resize(..) => return EventResult::Consumed(None),
- _ => return EventResult::Ignored(None),
+ fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
+ let Some((current_file, _)) = self.current_file(cx.editor) else {
+ return EventResult::Consumed(None)
};
- let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _cx| {
- // remove the layer
- compositor.last_picker = compositor.pop();
- })));
+ // Try to find a document in the cache
+ let doc = match &current_file {
+ PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id),
+ PathOrId::Path(path) => match self.preview_cache.get_mut(path) {
+ Some(CachedPreview::Document(ref mut doc)) => doc,
+ _ => return EventResult::Consumed(None),
+ },
+ };
- // So that idle timeout retriggers
- cx.editor.reset_idle_timer();
+ let mut callback: Option<compositor::Callback> = None;
- match key_event {
- shift!(Tab) | key!(Up) | ctrl!('p') => {
- self.move_by(1, Direction::Backward);
- }
- key!(Tab) | key!(Down) | ctrl!('n') => {
- self.move_by(1, Direction::Forward);
- }
- key!(PageDown) | ctrl!('d') => {
- self.page_down();
- }
- key!(PageUp) | ctrl!('u') => {
- self.page_up();
- }
- key!(Home) => {
- self.to_start();
- }
- key!(End) => {
- self.to_end();
- }
- key!(Esc) | ctrl!('c') => {
- return close_fn;
- }
- alt!(Enter) => {
- if let Some(option) = self.selection() {
- (self.callback_fn)(cx, option, Action::Load);
- }
- }
- key!(Enter) => {
- if let Some(option) = self.selection() {
- (self.callback_fn)(cx, option, Action::Replace);
- }
- return close_fn;
- }
- ctrl!('s') => {
- if let Some(option) = self.selection() {
- (self.callback_fn)(cx, option, Action::HorizontalSplit);
- }
- return close_fn;
- }
- ctrl!('v') => {
- if let Some(option) = self.selection() {
- (self.callback_fn)(cx, option, Action::VerticalSplit);
- }
- return close_fn;
- }
- ctrl!('t') => {
- self.toggle_preview();
- }
- _ => {
- self.prompt_handle_event(event, cx);
+ // Then attempt to highlight it if it has no language set
+ if doc.language_config().is_none() {
+ if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) {
+ doc.language = Some(language_config.clone());
+ let text = doc.text().clone();
+ let loader = cx.editor.syn_loader.clone();
+ let job = tokio::task::spawn_blocking(move || {
+ let syntax = language_config
+ .highlight_config(&loader.scopes())
+ .and_then(|highlight_config| Syntax::new(&text, highlight_config, loader));
+ let callback = move |editor: &mut Editor, compositor: &mut Compositor| {
+ let Some(syntax) = syntax else {
+ log::info!("highlighting picker item failed");
+ return
+ };
+ let Some(Overlay { content: picker, .. }) = compositor.find::<Overlay<Self>>() else {
+ log::info!("picker closed before syntax highlighting finished");
+ return
+ };
+ // Try to find a document in the cache
+ let doc = match current_file {
+ PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
+ PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
+ Some(CachedPreview::Document(ref mut doc)) => doc,
+ _ => return,
+ },
+ };
+ doc.syntax = Some(syntax);
+ };
+ Callback::EditorCompositor(Box::new(callback))
+ });
+ let tmp: compositor::Callback = Box::new(move |_, ctx| {
+ ctx.jobs
+ .callback(job.map(|res| res.map_err(anyhow::Error::from)))
+ });
+ callback = Some(Box::new(tmp))
}
}
- EventResult::Consumed(None)
+ // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
+ // but it could be interesting in the future
+
+ EventResult::Consumed(callback)
}
- fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ fn render_picker(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
let text_style = cx.editor.theme.get("ui.text");
let selected = cx.editor.theme.get("ui.text.focus");
let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD);
@@ -930,6 +657,178 @@ impl<T: Item + 'static> Component for Picker<T> {
);
}
+ fn render_preview(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ // -- Render the frame:
+ // clear area
+ let background = cx.editor.theme.get("ui.background");
+ let text = cx.editor.theme.get("ui.text");
+ surface.clear_with(area, background);
+
+ // don't like this but the lifetime sucks
+ let block = Block::default().borders(Borders::ALL);
+
+ // calculate the inner area inside the box
+ let inner = block.inner(area);
+ // 1 column gap on either side
+ let margin = Margin::horizontal(1);
+ let inner = inner.inner(&margin);
+ block.render(area, surface);
+
+ if let Some((path, range)) = self.current_file(cx.editor) {
+ let preview = self.get_preview(path, cx.editor);
+ let doc = match preview.document() {
+ Some(doc) => doc,
+ None => {
+ let alt_text = preview.placeholder();
+ let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2;
+ let y = inner.y + inner.height / 2;
+ surface.set_stringn(x, y, alt_text, inner.width as usize, text);
+ return;
+ }
+ };
+
+ // align to middle
+ let first_line = range
+ .map(|(start, end)| {
+ let height = end.saturating_sub(start) + 1;
+ let middle = start + (height.saturating_sub(1) / 2);
+ middle.saturating_sub(inner.height as usize / 2).min(start)
+ })
+ .unwrap_or(0);
+
+ let offset = ViewPosition {
+ anchor: doc.text().line_to_char(first_line),
+ horizontal_offset: 0,
+ vertical_offset: 0,
+ };
+
+ let mut highlights = EditorView::doc_syntax_highlights(
+ doc,
+ offset.anchor,
+ area.height,
+ &cx.editor.theme,
+ );
+ for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
+ if spans.is_empty() {
+ continue;
+ }
+ highlights = Box::new(helix_core::syntax::merge(highlights, spans));
+ }
+ let mut decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
+
+ if let Some((start, end)) = range {
+ let style = cx
+ .editor
+ .theme
+ .try_get("ui.highlight")
+ .unwrap_or_else(|| cx.editor.theme.get("ui.selection"));
+ let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| {
+ if (start..=end).contains(&pos.doc_line) {
+ let area = Rect::new(
+ renderer.viewport.x,
+ renderer.viewport.y + pos.visual_line,
+ renderer.viewport.width,
+ 1,
+ );
+ renderer.surface.set_style(area, style)
+ }
+ };
+ decorations.push(Box::new(draw_highlight))
+ }
+
+ render_document(
+ surface,
+ inner,
+ doc,
+ offset,
+ // TODO: compute text annotations asynchronously here (like inlay hints)
+ &TextAnnotations::default(),
+ highlights,
+ &cx.editor.theme,
+ &mut decorations,
+ &mut [],
+ );
+ }
+ }
+
+ fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult {
+ if let Event::IdleTimeout = event {
+ return self.handle_idle_timeout(ctx);
+ }
+ // TODO: keybinds for scrolling preview
+
+ let key_event = match event {
+ Event::Key(event) => *event,
+ Event::Paste(..) => return self.prompt_handle_event(event, ctx),
+ Event::Resize(..) => return EventResult::Consumed(None),
+ _ => return EventResult::Ignored(None),
+ };
+
+ let close_fn =
+ EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _ctx| {
+ // remove the layer
+ compositor.last_picker = compositor.pop();
+ })));
+
+ // So that idle timeout retriggers
+ ctx.editor.reset_idle_timer();
+
+ match key_event {
+ shift!(Tab) | key!(Up) | ctrl!('p') => {
+ self.move_by(1, Direction::Backward);
+ }
+ key!(Tab) | key!(Down) | ctrl!('n') => {
+ self.move_by(1, Direction::Forward);
+ }
+ key!(PageDown) | ctrl!('d') => {
+ self.page_down();
+ }
+ key!(PageUp) | ctrl!('u') => {
+ self.page_up();
+ }
+ key!(Home) => {
+ self.to_start();
+ }
+ key!(End) => {
+ self.to_end();
+ }
+ key!(Esc) | ctrl!('c') => {
+ return close_fn;
+ }
+ alt!(Enter) => {
+ if let Some(option) = self.selection() {
+ (self.callback_fn)(ctx, option, Action::Load);
+ }
+ }
+ key!(Enter) => {
+ if let Some(option) = self.selection() {
+ (self.callback_fn)(ctx, option, Action::Replace);
+ }
+ return close_fn;
+ }
+ ctrl!('s') => {
+ if let Some(option) = self.selection() {
+ (self.callback_fn)(ctx, option, Action::HorizontalSplit);
+ }
+ return close_fn;
+ }
+ ctrl!('v') => {
+ if let Some(option) = self.selection() {
+ (self.callback_fn)(ctx, option, Action::VerticalSplit);
+ }
+ return close_fn;
+ }
+ ctrl!('t') => {
+ self.toggle_preview();
+ }
+ _ => {
+ self.prompt_handle_event(event, ctx);
+ }
+ }
+
+ EventResult::Consumed(None)
+ }
+
fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
let block = Block::default().borders(Borders::ALL);
// calculate the inner area inside the box
@@ -940,8 +839,67 @@ impl<T: Item + 'static> Component for Picker<T> {
self.prompt.cursor(area, editor)
}
+
+ fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
+ self.completion_height = height.saturating_sub(4);
+ Some((width, height))
+ }
+}
+
+impl<T: Item + 'static> Component for Picker<T> {
+ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ // +---------+ +---------+
+ // |prompt | |preview |
+ // +---------+ | |
+ // |picker | | |
+ // | | | |
+ // +---------+ +---------+
+
+ let render_preview = self.show_preview && area.width > MIN_AREA_WIDTH_FOR_PREVIEW;
+
+ let picker_width = if render_preview {
+ area.width / 2
+ } else {
+ area.width
+ };
+
+ let picker_area = area.with_width(picker_width);
+ self.render_picker(picker_area, surface, cx);
+
+ if render_preview {
+ let preview_area = area.clip_left(picker_width);
+ self.render_preview(preview_area, surface, cx);
+ }
+ }
}
+#[derive(PartialEq, Eq, Debug)]
+struct PickerMatch {
+ score: i64,
+ index: usize,
+ len: usize,
+}
+
+impl PickerMatch {
+ fn key(&self) -> impl Ord {
+ (cmp::Reverse(self.score), self.len, self.index)
+ }
+}
+
+impl PartialOrd for PickerMatch {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for PickerMatch {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.key().cmp(&other.key())
+ }
+}
+
+type PickerCallback<T> = Box<dyn Fn(&mut Context, &T, Action)>;
+
/// Returns a new list of options to replace the contents of the picker
/// when called with the current picker query,
pub type DynQueryCallback<T> =
@@ -950,7 +908,7 @@ pub type DynQueryCallback<T> =
/// A picker that updates its contents via a callback whenever the
/// query string changes. Useful for live grep, workspace symbols, etc.
pub struct DynamicPicker<T: ui::menu::Item + Send> {
- file_picker: FilePicker<T>,
+ file_picker: Picker<T>,
query_callback: DynQueryCallback<T>,
query: String,
}
@@ -958,7 +916,7 @@ pub struct DynamicPicker<T: ui::menu::Item + Send> {
impl<T: ui::menu::Item + Send> DynamicPicker<T> {
pub const ID: &'static str = "dynamic-picker";
- pub fn new(file_picker: FilePicker<T>, query_callback: DynQueryCallback<T>) -> Self {
+ pub fn new(file_picker: Picker<T>, query_callback: DynQueryCallback<T>) -> Self {
Self {
file_picker,
query_callback,
@@ -974,7 +932,7 @@ impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
let event_result = self.file_picker.handle_event(event, cx);
- let current_query = self.file_picker.picker.prompt.line();
+ let current_query = self.file_picker.prompt.line();
if !matches!(event, Event::IdleTimeout) || self.query == *current_query {
return event_result;
@@ -990,7 +948,7 @@ impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
// Wrapping of pickers in overlay is done outside the picker code,
// so this is fragile and will break if wrapped in some other widget.
let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(Self::ID) {
- Some(overlay) => &mut overlay.content.file_picker.picker,
+ Some(overlay) => &mut overlay.content.file_picker,
None => return,
};
picker.set_options(new_options);