From 08bef881ccd0e9fb91a0b24963b7343d8cbab46c Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Fri, 27 Sep 2024 13:09:20 -0700 Subject: [PATCH] Handle prompt completions with AsyncHook. This change allows prompt completions to be calculated in the background, to avoid blocking the UI on slow file IO such as over a networked FS. --- helix-term/src/commands/typed.rs | 29 +++--- helix-term/src/ui/mod.rs | 149 +++++++++++++++++-------------- helix-term/src/ui/prompt.rs | 92 +++++++++++++++++-- 3 files changed, 184 insertions(+), 86 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 7ad0369fc..599f1104b 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -12,7 +12,7 @@ use helix_core::{line_ending, shellwords::Shellwords}; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; use serde_json::Value; -use ui::completers::{self, Completer}; +use ui::completers::{self, Completer, CompletionResult}; #[derive(Clone)] pub struct TypableCommand { @@ -3187,19 +3187,22 @@ pub(super) fn command_mode(cx: &mut Context) { .get(&words[0] as &str) .map(|tc| tc.completer_for_argument_number(argument_number)) { - completer(editor, word) - .into_iter() - .map(|(range, file)| { - let file = shellwords::escape(file); - - // offset ranges to input - let offset = input.len() - word_len; - let range = (range.start + offset)..; - (range, file) - }) - .collect() + let input = String::from(input); + completer(editor, word).map(move |completion| { + completion + .into_iter() + .map(|(range, file)| { + let file = shellwords::escape(file); + + // offset ranges to input + let offset = input.len() - word_len; + let range = (range.start + offset)..; + (range, file) + }) + .collect() + }) } else { - Vec::new() + CompletionResult::Immediate(Vec::new()) } } }, // completion diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 6a3e198c1..5370fb2ee 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -17,6 +17,7 @@ mod text_decorations; use crate::compositor::Compositor; use crate::filter_picker_entry; use crate::job::{self, Callback}; +use crate::ui::completers::CompletionResult; pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; use helix_stdx::rope; @@ -36,7 +37,7 @@ pub fn prompt( cx: &mut crate::commands::Context, prompt: std::borrow::Cow<'static, str>, history_register: Option, - completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + completion_fn: impl FnMut(&Editor, &str) -> CompletionResult + 'static, callback_fn: impl FnMut(&mut crate::compositor::Context, &str, PromptEvent) + 'static, ) { let mut prompt = Prompt::new(prompt, history_register, completion_fn, callback_fn); @@ -45,24 +46,11 @@ pub fn prompt( cx.push_layer(Box::new(prompt)); } -pub fn prompt_with_input( - cx: &mut crate::commands::Context, - prompt: std::borrow::Cow<'static, str>, - input: String, - history_register: Option, - completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, - callback_fn: impl FnMut(&mut crate::compositor::Context, &str, PromptEvent) + 'static, -) { - let prompt = Prompt::new(prompt, history_register, completion_fn, callback_fn) - .with_line(input, cx.editor); - cx.push_layer(Box::new(prompt)); -} - pub fn regex_prompt( cx: &mut crate::commands::Context, prompt: std::borrow::Cow<'static, str>, history_register: Option, - completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + completion_fn: impl FnMut(&Editor, &str) -> CompletionResult + 'static, fun: impl Fn(&mut crate::compositor::Context, rope::Regex, PromptEvent) + 'static, ) { raw_regex_prompt( @@ -77,7 +65,7 @@ pub fn raw_regex_prompt( cx: &mut crate::commands::Context, prompt: std::borrow::Cow<'static, str>, history_register: Option, - completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + completion_fn: impl FnMut(&Editor, &str) -> CompletionResult + 'static, fun: impl Fn(&mut crate::compositor::Context, rope::Regex, &str, PromptEvent) + 'static, ) { let (view, doc) = current!(cx.editor); @@ -275,13 +263,40 @@ pub mod completers { use once_cell::sync::Lazy; use std::borrow::Cow; - pub type Completer = fn(&Editor, &str) -> Vec; + pub enum CompletionResult { + Immediate(Vec), + Callback(Box Vec + Send + Sync>), + } + + fn callback(f: impl FnOnce() -> Vec + Send + Sync + 'static) -> CompletionResult { + CompletionResult::Callback(Box::new(f)) + } + + impl CompletionResult { + pub fn map( + self, + f: impl FnOnce(Vec) -> Vec + Send + Sync + 'static, + ) -> CompletionResult { + match self { + CompletionResult::Immediate(v) => CompletionResult::Immediate(f(v)), + CompletionResult::Callback(v) => callback(move || f(v())), + } + } + } - pub fn none(_editor: &Editor, _input: &str) -> Vec { - Vec::new() + impl FromIterator for CompletionResult { + fn from_iter>(items: T) -> Self { + Self::Immediate(items.into_iter().collect()) + } } - pub fn buffer(editor: &Editor, input: &str) -> Vec { + pub type Completer = fn(&Editor, &str) -> CompletionResult; + + pub fn none(_editor: &Editor, _input: &str) -> CompletionResult { + CompletionResult::Immediate(Vec::new()) + } + + pub fn buffer(editor: &Editor, input: &str) -> CompletionResult { let names = editor.documents.values().map(|doc| { doc.relative_path() .map(|p| p.display().to_string().into()) @@ -294,20 +309,23 @@ pub mod completers { .collect() } - pub fn theme(_editor: &Editor, input: &str) -> Vec { - let mut names = theme::Loader::read_names(&helix_loader::config_dir().join("themes")); - for rt_dir in helix_loader::runtime_dirs() { - names.extend(theme::Loader::read_names(&rt_dir.join("themes"))); - } - names.push("default".into()); - names.push("base16_default".into()); - names.sort(); - names.dedup(); + pub fn theme(_editor: &Editor, input: &str) -> CompletionResult { + let input = String::from(input); + callback(move || { + let mut names = theme::Loader::read_names(&helix_loader::config_dir().join("themes")); + for rt_dir in helix_loader::runtime_dirs() { + names.extend(theme::Loader::read_names(&rt_dir.join("themes"))); + } + names.push("default".into()); + names.push("base16_default".into()); + names.sort(); + names.dedup(); - fuzzy_match(input, names, false) - .into_iter() - .map(|(name, _)| ((0..), name.into())) - .collect() + fuzzy_match(&input, names, false) + .into_iter() + .map(|(name, _)| ((0..), name.into())) + .collect() + }) } /// Recursive function to get all keys from this value and add them to vec @@ -326,7 +344,7 @@ pub mod completers { } } - pub fn setting(_editor: &Editor, input: &str) -> Vec { + pub fn setting(_editor: &Editor, input: &str) -> CompletionResult { static KEYS: Lazy> = Lazy::new(|| { let mut keys = Vec::new(); let json = serde_json::json!(Config::default()); @@ -340,27 +358,30 @@ pub mod completers { .collect() } - pub fn filename(editor: &Editor, input: &str) -> Vec { + pub fn filename(editor: &Editor, input: &str) -> CompletionResult { filename_with_git_ignore(editor, input, true) } pub fn filename_with_git_ignore( - editor: &Editor, + _editor: &Editor, input: &str, git_ignore: bool, - ) -> Vec { - filename_impl(editor, input, git_ignore, |entry| { - let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); - - if is_dir { - FileMatch::AcceptIncomplete - } else { - FileMatch::Accept - } + ) -> CompletionResult { + let input = String::from(input); + callback(move || { + filename_impl(&input, git_ignore, |entry| { + let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); + + if is_dir { + FileMatch::AcceptIncomplete + } else { + FileMatch::Accept + } + }) }) } - pub fn language(editor: &Editor, input: &str) -> Vec { + pub fn language(editor: &Editor, input: &str) -> CompletionResult { let text: String = "text".into(); let loader = editor.syn_loader.load(); @@ -375,7 +396,7 @@ pub mod completers { .collect() } - pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec { + pub fn lsp_workspace_command(editor: &Editor, input: &str) -> CompletionResult { let commands = doc!(editor) .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) .flat_map(|ls| { @@ -391,23 +412,26 @@ pub mod completers { .collect() } - pub fn directory(editor: &Editor, input: &str) -> Vec { + pub fn directory(editor: &Editor, input: &str) -> CompletionResult { directory_with_git_ignore(editor, input, true) } pub fn directory_with_git_ignore( - editor: &Editor, + _editor: &Editor, input: &str, git_ignore: bool, - ) -> Vec { - filename_impl(editor, input, git_ignore, |entry| { - let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); - - if is_dir { - FileMatch::Accept - } else { - FileMatch::Reject - } + ) -> CompletionResult { + let input = String::from(input); + callback(move || { + filename_impl(&input, git_ignore, |entry| { + let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); + + if is_dir { + FileMatch::Accept + } else { + FileMatch::Reject + } + }) }) } @@ -423,12 +447,7 @@ pub mod completers { } // TODO: we could return an iter/lazy thing so it can fetch as many as it needs. - fn filename_impl( - _editor: &Editor, - input: &str, - git_ignore: bool, - filter_fn: F, - ) -> Vec + fn filename_impl(input: &str, git_ignore: bool, filter_fn: F) -> Vec where F: Fn(&ignore::DirEntry) -> FileMatch, { @@ -522,7 +541,7 @@ pub mod completers { } } - pub fn register(editor: &Editor, input: &str) -> Vec { + pub fn register(editor: &Editor, input: &str) -> CompletionResult { let iter = editor .registers .iter_preview() diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 6ba2fcb9e..58560cb30 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,12 +1,16 @@ use crate::compositor::{Component, Compositor, Context, Event, EventResult}; -use crate::{alt, ctrl, key, shift, ui}; +use crate::ui::completers::CompletionResult; +use crate::{alt, ctrl, job, key, shift, ui}; use arc_swap::ArcSwap; use helix_core::syntax; +use helix_event::{AsyncHook, CancelRx, CancelTx}; use helix_view::document::Mode; use helix_view::input::KeyEvent; use helix_view::keyboard::KeyCode; use std::sync::Arc; +use std::time::Duration; use std::{borrow::Cow, ops::RangeFrom}; +use tokio::time::Instant; use tui::buffer::Buffer as Surface; use tui::widgets::{Block, Widget}; @@ -18,9 +22,39 @@ use helix_view::{ Editor, }; -type PromptCharHandler = Box; pub type Completion = (RangeFrom, Cow<'static, str>); -type CompletionFn = Box Vec>; +struct CompletionEvent { + cancel: CancelRx, + callback: Box Vec + Send + Sync>, + send: std::sync::mpsc::SyncSender>, +} + +struct CompletionHandler {} + +impl helix_event::AsyncHook for CompletionHandler { + type Event = CompletionEvent; + + fn handle_event(&mut self, mut event: CompletionEvent, _: Option) -> Option { + let Err(tokio::sync::oneshot::error::TryRecvError::Empty) = event.cancel.try_recv() else { + return None; + }; + let completion = (event.callback)(); + if event.send.send(completion).is_err() { + return None; + } + job::dispatch_blocking(move |_editor, compositor| { + if let Some(prompt) = compositor.find::() { + prompt.process_async_completion(); + } + }); + None + } + + fn finish_debounce(&mut self) {} +} + +type PromptCharHandler = Box; +type CompletionFn = Box CompletionResult>; type CallbackFn = Box; pub type DocFn = Box Option>>; @@ -28,11 +62,14 @@ pub struct Prompt { prompt: Cow<'static, str>, line: String, cursor: usize, + completion_fn: CompletionFn, + completion_hook: tokio::sync::mpsc::Sender, + completion_ctx: Option, + receive_completion: Option>>, completion: Vec, selection: Option, history_register: Option, history_pos: Option, - completion_fn: CompletionFn, callback_fn: CallbackFn, pub doc_fn: DocFn, next_char_handler: Option, @@ -73,18 +110,21 @@ impl Prompt { pub fn new( prompt: Cow<'static, str>, history_register: Option, - completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + completion_fn: impl FnMut(&Editor, &str) -> CompletionResult + 'static, callback_fn: impl FnMut(&mut Context, &str, PromptEvent) + 'static, ) -> Self { Self { prompt, line: String::new(), cursor: 0, + completion_fn: Box::new(completion_fn), + completion_hook: CompletionHandler {}.spawn(), + completion_ctx: None, + receive_completion: None, completion: Vec::new(), selection: None, history_register, history_pos: None, - completion_fn: Box::new(completion_fn), callback_fn: Box::new(callback_fn), doc_fn: Box::new(|_| None), next_char_handler: None, @@ -141,8 +181,35 @@ impl Prompt { } pub fn recalculate_completion(&mut self, editor: &Editor) { + // Cancel any pending async completions. + self.completion_ctx = None; + self.receive_completion = None; + self.exit_selection(); - self.completion = (self.completion_fn)(editor, &self.line); + match (self.completion_fn)(editor, &self.line) { + CompletionResult::Immediate(completion) => self.completion = completion, + CompletionResult::Callback(f) => { + let (cancel_tx, cancel_rx) = helix_event::cancelation(); + let (send_completion, recv_completion) = std::sync::mpsc::sync_channel(1); + helix_event::send_blocking( + &self.completion_hook, + CompletionEvent { + cancel: cancel_rx, + callback: f, + send: send_completion, + }, + ); + // To avoid flicker, give the completion handler a small timeout to + // complete immediately. + if let Ok(completion) = recv_completion.recv_timeout(Duration::from_millis(100)) { + self.completion = completion; + return; + } + self.completion.clear(); + self.completion_ctx = Some(cancel_tx); + self.receive_completion = Some(recv_completion); + } + } } /// Compute the cursor position after applying movement @@ -390,6 +457,16 @@ impl Prompt { pub fn exit_selection(&mut self) { self.selection = None; } + + fn process_async_completion(&mut self) { + let Some(receive_completion) = &self.receive_completion else { + return; + }; + if let Ok(completion) = receive_completion.try_recv() { + self.completion = completion; + helix_event::request_redraw(); + } + } } const BASE_WIDTH: u16 = 30; @@ -402,7 +479,6 @@ impl Prompt { let selected_color = theme.get("ui.menu.selected"); let suggestion_color = theme.get("ui.text.inactive"); let background = theme.get("ui.background"); - // completion let max_len = self .completion