From dae3841a75fc521c9e5d63ff8f3ffa32be4e41e1 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Thu, 15 Feb 2024 15:13:44 -0500 Subject: [PATCH] Use an AsyncHook for picker preview highlighting The picker previously used the IdleTimeout event as a trigger for syntax-highlighting the currently selected document in the preview pane. This is a bit ad-hoc now that the event system has landed and we can refactor towards an AsyncHook (like those used for LSP completion and signature-help). This should resolve some odd scenarios where the preview did not highlight because of a race between the idle timeout and items appearing in the picker. --- helix-term/src/ui/picker.rs | 147 ++++++++------------------- helix-term/src/ui/picker/handlers.rs | 117 +++++++++++++++++++++ 2 files changed, 161 insertions(+), 103 deletions(-) create mode 100644 helix-term/src/ui/picker/handlers.rs diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index b8ec57d51..cc86a4fad 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,3 +1,5 @@ +mod handlers; + use crate::{ alt, compositor::{self, Component, Compositor, Context, Event, EventResult}, @@ -10,9 +12,11 @@ use crate::{ EditorView, }, }; -use futures_util::{future::BoxFuture, FutureExt}; +use futures_util::future::BoxFuture; +use helix_event::AsyncHook; use nucleo::pattern::CaseMatching; use nucleo::{Config, Nucleo, Utf32String}; +use tokio::sync::mpsc::Sender; use tui::{ buffer::Buffer as Surface, layout::Constraint, @@ -25,7 +29,7 @@ use tui::widgets::Widget; use std::{ collections::HashMap, io::Read, - path::PathBuf, + path::{Path, PathBuf}, sync::{ atomic::{self, AtomicBool}, Arc, @@ -36,7 +40,6 @@ use crate::ui::{Prompt, PromptEvent}; use helix_core::{ char_idx_at_visual_offset, fuzzy::MATCHER, movement::Direction, text_annotations::TextAnnotations, unicode::segmentation::UnicodeSegmentation, Position, - Syntax, }; use helix_view::{ editor::Action, @@ -46,9 +49,12 @@ use helix_view::{ Document, DocumentId, Editor, }; -pub const ID: &str = "picker"; +use self::handlers::PreviewHighlightHandler; + use super::{menu::Item, overlay::Overlay}; +pub const ID: &str = "picker"; + pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; @@ -56,14 +62,14 @@ pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; #[derive(PartialEq, Eq, Hash)] pub enum PathOrId { Id(DocumentId), - Path(PathBuf), + Path(Arc), } impl PathOrId { fn get_canonicalized(self) -> Self { use PathOrId::*; match self { - Path(path) => Path(helix_stdx::path::canonicalize(path)), + Path(path) => Path(helix_stdx::path::canonicalize(&path).into()), Id(id) => Id(id), } } @@ -71,7 +77,7 @@ impl PathOrId { impl From for PathOrId { fn from(v: PathBuf) -> Self { - Self::Path(v) + Self::Path(v.as_path().into()) } } @@ -197,10 +203,12 @@ pub struct Picker { pub truncate_start: bool, /// Caches paths to documents - preview_cache: HashMap, + preview_cache: HashMap, CachedPreview>, read_buffer: Vec, /// Given an item in the picker, return the file path and line number to display. file_fn: Option>, + /// An event handler for syntax highlighting the currently previewed file. + preview_highlight_handler: Sender>, } impl Picker { @@ -280,6 +288,7 @@ impl Picker { preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: None, + preview_highlight_handler: PreviewHighlightHandler::::default().spawn(), } } @@ -403,16 +412,26 @@ impl Picker { ) -> Preview<'picker, 'editor> { match path_or_id { PathOrId::Path(path) => { - let path = &path; - if let Some(doc) = editor.document_by_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]); + if self.preview_cache.contains_key(&path) { + let preview = &self.preview_cache[&path]; + match preview { + // If the document isn't highlighted yet, attempt to highlight it. + CachedPreview::Document(doc) if doc.language_config().is_none() => { + helix_event::send_blocking( + &self.preview_highlight_handler, + path.clone(), + ); + } + _ => (), + } + return Preview::Cached(preview); } - let data = std::fs::File::open(path).and_then(|file| { + 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)?; @@ -427,14 +446,21 @@ impl Picker { (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => { CachedPreview::LargeFile } - _ => Document::open(path, None, None, editor.config.clone()) - .map(|doc| CachedPreview::Document(Box::new(doc))) + _ => Document::open(&path, None, None, editor.config.clone()) + .map(|doc| { + // Asynchronously highlight the new document + helix_event::send_blocking( + &self.preview_highlight_handler, + path.clone(), + ); + 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]) + self.preview_cache.insert(path.clone(), preview); + Preview::Cached(&self.preview_cache[&path]) } PathOrId::Id(id) => { let doc = editor.documents.get(&id).unwrap(); @@ -443,84 +469,6 @@ impl Picker { } } - 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.load()) - { - 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.load().scopes()) - .and_then(|highlight_config| { - Syntax::new(text.slice(..), 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 picker = match compositor.find::>() { - Some(Overlay { content, .. }) => Some(content), - None => compositor - .find::>>() - .map(|overlay| &mut overlay.content.file_picker), - }; - let Some(picker) = picker 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)) => { - let diagnostics = Editor::doc_diagnostics( - &editor.language_servers, - &editor.diagnostics, - doc, - ); - doc.replace_diagnostics(diagnostics, &[], None); - 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) - } - fn render_picker(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let status = self.matcher.tick(10); let snapshot = self.matcher.snapshot(); @@ -826,9 +774,6 @@ impl Component for Picker { } 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 { @@ -861,9 +806,6 @@ impl Component for Picker { EventResult::Consumed(Some(callback)) }; - // 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); @@ -989,7 +931,7 @@ impl Component for DynamicPicker { cx.jobs.callback(async move { let new_options = new_options.await?; - let callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { + let callback = Callback::EditorCompositor(Box::new(move |_editor, compositor| { // 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::>>(ID) { @@ -997,7 +939,6 @@ impl Component for DynamicPicker { None => return, }; picker.set_options(new_options); - editor.reset_idle_timer(); })); anyhow::Ok(callback) }); diff --git a/helix-term/src/ui/picker/handlers.rs b/helix-term/src/ui/picker/handlers.rs new file mode 100644 index 000000000..7a77efa44 --- /dev/null +++ b/helix-term/src/ui/picker/handlers.rs @@ -0,0 +1,117 @@ +use std::{path::Path, sync::Arc, time::Duration}; + +use helix_event::AsyncHook; +use tokio::time::Instant; + +use crate::{ + job, + ui::{menu::Item, overlay::Overlay}, +}; + +use super::{CachedPreview, DynamicPicker, Picker}; + +pub(super) struct PreviewHighlightHandler { + trigger: Option>, + phantom_data: std::marker::PhantomData, +} + +impl Default for PreviewHighlightHandler { + fn default() -> Self { + Self { + trigger: None, + phantom_data: Default::default(), + } + } +} + +impl AsyncHook for PreviewHighlightHandler { + type Event = Arc; + + fn handle_event( + &mut self, + path: Self::Event, + timeout: Option, + ) -> Option { + if self + .trigger + .as_ref() + .is_some_and(|trigger| trigger == &path) + { + // If the path hasn't changed, don't reset the debounce + timeout + } else { + self.trigger = Some(path); + Some(Instant::now() + Duration::from_millis(150)) + } + } + + fn finish_debounce(&mut self) { + let Some(path) = self.trigger.take() else { + return; + }; + + job::dispatch_blocking(move |editor, compositor| { + let picker = match compositor.find::>>() { + Some(Overlay { content, .. }) => content, + None => match compositor.find::>>() { + Some(Overlay { content, .. }) => &mut content.file_picker, + None => return, + }, + }; + + let Some(CachedPreview::Document(ref mut doc)) = picker.preview_cache.get_mut(&path) + else { + return; + }; + + if doc.language_config().is_some() { + return; + } + + let Some(language_config) = doc.detect_language_config(&editor.syn_loader.load()) + else { + return; + }; + doc.language = Some(language_config.clone()); + let text = doc.text().clone(); + let loader = editor.syn_loader.clone(); + + tokio::task::spawn_blocking(move || { + let Some(syntax) = language_config + .highlight_config(&loader.load().scopes()) + .and_then(|highlight_config| { + helix_core::Syntax::new(text.slice(..), highlight_config, loader) + }) + else { + log::info!("highlighting picker item failed"); + return; + }; + + job::dispatch_blocking(move |editor, compositor| { + let picker = match compositor.find::>>() { + Some(Overlay { content, .. }) => Some(content), + None => compositor + .find::>>() + .map(|overlay| &mut overlay.content.file_picker), + }; + let Some(picker) = picker else { + log::info!("picker closed before syntax highlighting finished"); + return; + }; + let Some(CachedPreview::Document(ref mut doc)) = + picker.preview_cache.get_mut(&path) + else { + return; + }; + let diagnostics = helix_view::Editor::doc_diagnostics( + &editor.language_servers, + &editor.diagnostics, + doc, + ); + doc.replace_diagnostics(diagnostics, &[], None); + doc.syntax = Some(syntax); + }); + }); + }); + } +}