diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 5fb4a70eb..2c9295f11 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 84794bedf..70a5ec212 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 8c3fd13b5..55153648a 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; +type SymbolPicker = Picker; fn sym_picker(symbols: Vec, current_path: Option) -> 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>, current_path: Option, format: DiagnosticsFormat, -) -> FilePicker { +) -> Picker { // 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 ec328ec55..155f24356 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 { +pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker { 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 d161f786c..04ed940cd 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -77,16 +77,6 @@ type FileCallback = Box Option>; /// File path and range of lines (used to align and highlight lines) pub type FileLocation = (PathOrId, Option<(usize, usize)>); -pub struct FilePicker { - picker: Picker, - pub truncate_start: bool, - /// Caches paths to documents - preview_cache: HashMap, - read_buffer: Vec, - /// Given an item in the picker, return the file path and line number to display. - file_fn: FileCallback, -} - pub enum CachedPreview { Document(Box), Binary, @@ -124,325 +114,6 @@ impl Preview<'_, '_> { } } -impl FilePicker { - pub fn new( - options: Vec, - editor_data: T::Data, - callback_fn: impl Fn(&mut Context, &T, Action) + 'static, - preview_fn: impl Fn(&Editor, &T) -> Option + '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 { - 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 ¤t_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 = 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::>() 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 Component for FilePicker { - 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> = 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, 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 { - Some(self.cmp(other)) - } -} - -impl Ord for PickerMatch { - fn cmp(&self, other: &Self) -> Ordering { - self.key().cmp(&other.key()) - } -} - -type PickerCallback = Box; - pub struct Picker { options: Vec, editor_data: T::Data, @@ -457,17 +128,22 @@ pub struct Picker { // 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, callback_fn: PickerCallback, + + pub truncate_start: bool, + /// Caches paths to documents + preview_cache: HashMap, + read_buffer: Vec, + /// Given an item in the picker, return the file path and line number to display. + file_fn: Option>, } -impl Picker { +impl Picker { pub fn new( options: Vec, editor_data: T::Data, @@ -493,6 +169,9 @@ impl Picker { 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 Picker { 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 + 'static, + ) -> Self { + self.file_fn = Some(Box::new(preview_fn)); + self + } + pub fn set_options(&mut self, new_options: Vec) { self.options = new_options; self.cursor = 0; @@ -679,92 +371,127 @@ impl Picker { } 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 - -impl Component for Picker { - fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - self.completion_height = viewport.1.saturating_sub(4); - Some(viewport) + fn current_file(&self, editor: &Editor) -> Option { + 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))) } - 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), - }; - - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _cx| { - // remove the layer - compositor.last_picker = compositor.pop(); - }))); - - // So that idle timeout retriggers - cx.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)(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); + /// 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); } - return close_fn; - } - ctrl!('v') => { - if let Some(option) = self.selection() { - (self.callback_fn)(cx, option, Action::VerticalSplit); + + if self.preview_cache.contains_key(path) { + return Preview::Cached(&self.preview_cache[path]); } - return close_fn; + + 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]) } - ctrl!('t') => { - self.toggle_preview(); + PathOrId::Id(id) => { + let doc = editor.documents.get(&id).unwrap(); + Preview::EditorDocument(doc) } - _ => { - self.prompt_handle_event(event, cx); + } + } + + 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 ¤t_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 = 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::>() 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 Component for Picker { ); } + 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> = 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, CursorKind) { let block = Block::default().borders(Borders::ALL); // calculate the inner area inside the box @@ -940,8 +839,67 @@ impl Component for Picker { 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 Component for Picker { + 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 { + Some(self.cmp(other)) + } } +impl Ord for PickerMatch { + fn cmp(&self, other: &Self) -> Ordering { + self.key().cmp(&other.key()) + } +} + +type PickerCallback = Box; + /// Returns a new list of options to replace the contents of the picker /// when called with the current picker query, pub type DynQueryCallback = @@ -950,7 +908,7 @@ pub type DynQueryCallback = /// A picker that updates its contents via a callback whenever the /// query string changes. Useful for live grep, workspace symbols, etc. pub struct DynamicPicker { - file_picker: FilePicker, + file_picker: Picker, query_callback: DynQueryCallback, query: String, } @@ -958,7 +916,7 @@ pub struct DynamicPicker { impl DynamicPicker { pub const ID: &'static str = "dynamic-picker"; - pub fn new(file_picker: FilePicker, query_callback: DynQueryCallback) -> Self { + pub fn new(file_picker: Picker, query_callback: DynQueryCallback) -> Self { Self { file_picker, query_callback, @@ -974,7 +932,7 @@ impl Component for DynamicPicker { 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 Component for DynamicPicker { // 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::>>(Self::ID) { - Some(overlay) => &mut overlay.content.file_picker.picker, + Some(overlay) => &mut overlay.content.file_picker, None => return, }; picker.set_options(new_options);