diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 341d4a547b35..d1d57a48f691 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -44,7 +44,7 @@ pub enum Error { Other(#[from] anyhow::Error), } -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum OffsetEncoding { /// UTF-8 code units aka bytes Utf8, diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index a1685fcfa956..65e639a2063d 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -348,11 +348,11 @@ impl Application { self.handle_signals(signal).await; } Some(callback) = self.jobs.futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, &self.jobs, callback); self.render().await; } Some(callback) = self.jobs.wait_futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, &self.jobs, callback); self.render().await; } event = self.editor.wait_event() => { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a3c9f0b4abed..d5ec68eb8e08 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -52,11 +52,16 @@ use crate::{ filter_picker_entry, job::Callback, keymap::ReverseKeymap, - ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, + ui::{ + self, + menu::{ItemSource, OptionsManager}, + overlay::overlayed, + FilePicker, Picker, Popup, Prompt, PromptEvent, + }, }; use crate::job::{self, Jobs}; -use futures_util::StreamExt; +use futures_util::{FutureExt, StreamExt}; use std::{collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; @@ -2097,9 +2102,9 @@ fn global_search(cx: &mut Context) { return; } + let option_manager = OptionsManager::create_from_items(all_matches, current_path); let picker = FilePicker::new( - all_matches, - current_path, + option_manager, move |cx, FileResult { path, line_num }, action| { match cx.editor.open(path, action) { Ok(_) => {} @@ -2473,13 +2478,16 @@ fn buffer_picker(cx: &mut Context) { is_current: doc.id() == current, }; - let picker = FilePicker::new( + let option_manager = OptionsManager::create_from_items( cx.editor .documents .values() .map(|doc| new_meta(doc)) .collect(), (), + ); + let picker = FilePicker::new( + option_manager, |cx, meta, action| { cx.editor.switch(meta.id, action); }, @@ -2551,7 +2559,7 @@ fn jumplist_picker(cx: &mut Context) { } }; - let picker = FilePicker::new( + let option_manager = OptionsManager::create_from_items( cx.editor .tree .views() @@ -2562,6 +2570,9 @@ fn jumplist_picker(cx: &mut Context) { }) .collect(), (), + ); + let picker = FilePicker::new( + option_manager, |cx, meta, action| { cx.editor.switch(meta.id, action); let config = cx.editor.config(); @@ -2623,7 +2634,8 @@ pub fn command_palette(cx: &mut Context) { } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let option_manager = OptionsManager::create_from_items(commands, keymap); + let picker = Picker::new(option_manager, move |cx, command, _action| { let mut ctx = Context { register: None, count: std::num::NonZeroUsize::new(1), @@ -4169,6 +4181,7 @@ pub fn completion(cx: &mut Context) { None => return, }; + let language_server_id = language_server.id(); let offset_encoding = language_server.offset_encoding(); let text = doc.text().slice(..); let cursor = doc.selection(view.id).primary().cursor(text); @@ -4191,39 +4204,52 @@ pub fn completion(cx: &mut Context) { let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count(); let start_offset = cursor.saturating_sub(offset); - cx.callback( - future, - move |editor, compositor, response: Option| { - if editor.mode != Mode::Insert { - // we're not in insert mode anymore - return; - } + let completion_item_source = ItemSource::from_async_data( + async move { + let json = future.await?; + let response: helix_lsp::lsp::CompletionResponse = serde_json::from_value(json)?; - let items = match response { - Some(lsp::CompletionResponse::Array(items)) => items, + let mut items = match response { + lsp::CompletionResponse::Array(items) => items, // TODO: do something with is_incomplete - Some(lsp::CompletionResponse::List(lsp::CompletionList { + lsp::CompletionResponse::List(helix_lsp::lsp::CompletionList { is_incomplete: _is_incomplete, items, - })) => items, - None => Vec::new(), + }) => items, }; - if items.is_empty() { - // editor.set_error("No completion available"); + // Sort completion items according to their preselect status (given by the LSP server) + items.sort_by_key(|item| !item.preselect.unwrap_or(false)); + Ok(items + .into_iter() + .map(|item| ui::CompletionItem::LSP { + item, + language_server_id, + offset_encoding, + }) + .collect()) + } + .boxed(), + (), + ); + + OptionsManager::create_from_item_sources( + vec![completion_item_source], + cx.editor, + cx.jobs, + move |editor, compositor, option_manager| { + if editor.mode != Mode::Insert { + // we're not in insert mode anymore return; } + let size = compositor.size(); let ui = compositor.find::().unwrap(); - ui.set_completion( - editor, - items, - offset_encoding, - start_offset, - trigger_offset, - size, - ); + ui.set_completion(editor, option_manager, start_offset, trigger_offset, size); }, + Some(Box::new(|editor: &mut Editor| { + editor.set_error("No completion available") + })), ); } diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index b3166e395d90..2cb02705905c 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -2,9 +2,15 @@ use super::{Context, Editor}; use crate::{ compositor::{self, Compositor}, job::{Callback, Jobs}, - ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent, Text}, + ui::{ + self, + menu::{ItemSource, OptionsManager}, + overlay::overlayed, + FilePicker, Picker, Popup, Prompt, PromptEvent, Text, + }, }; use dap::{StackFrame, Thread, ThreadStates}; +use futures_util::FutureExt; use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate}; use helix_dap::{self as dap, Client}; use helix_lsp::block_on; @@ -61,21 +67,30 @@ fn thread_picker( let debugger = debugger!(cx.editor); let future = debugger.threads(); - dap_callback( + + let thread_states = debugger!(cx.editor).thread_states.clone(); + let item_source = ItemSource::from_async_data( + async move { + let response: dap::requests::ThreadsResponse = serde_json::from_value(future.await?)?; + + Ok(response.threads) + } + .boxed(), + thread_states, + ); + + OptionsManager::create_from_item_sources( + vec![item_source], + cx.editor, cx.jobs, - future, - move |editor, compositor, response: dap::requests::ThreadsResponse| { - let threads = response.threads; - if threads.len() == 1 { - callback_fn(editor, &threads[0]); + move |editor, compositor, option_manager| { + if option_manager.options_len() == 1 { + callback_fn(editor, option_manager.options().next().unwrap().0); return; } - let debugger = debugger!(editor); - let thread_states = debugger.thread_states.clone(); let picker = FilePicker::new( - threads, - thread_states, + option_manager, move |cx, thread, _action| callback_fn(cx.editor, thread), move |editor, thread| { let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; @@ -90,6 +105,7 @@ fn thread_picker( ); compositor.push(Box::new(picker)); }, + None, ); } @@ -270,9 +286,9 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); + let option_manager = OptionsManager::create_from_items(templates, ()); cx.push_layer(Box::new(overlayed(Picker::new( - templates, - (), + option_manager, |cx, template, _action| { let completions = template.completion.clone(); let name = template.name.clone(); @@ -681,9 +697,9 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let frames = debugger.stack_frames[&thread_id].clone(); + let option_manager = OptionsManager::create_from_items(frames, ()); let picker = FilePicker::new( - frames, - (), + option_manager, move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index d1fb32a8ece8..1314bf6e01be 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,10 +1,7 @@ use futures_util::FutureExt; use helix_lsp::{ block_on, - lsp::{ - self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, - NumberOrString, - }, + lsp::{self, CodeAction, CodeActionTriggerKind, DiagnosticSeverity, NumberOrString}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; @@ -21,8 +18,11 @@ use helix_view::{document::Mode, editor::Action, theme::Style}; use crate::{ compositor::{self, Compositor}, ui::{ - self, lsp::SignatureHelp, overlay::overlayed, DynamicPicker, FileLocation, FilePicker, - Popup, PromptEvent, + self, + lsp::SignatureHelp, + menu::{ItemSource, Menu, OptionsManager}, + overlay::overlayed, + FileLocation, FilePicker, Popup, PromptEvent, }, }; @@ -208,14 +208,13 @@ fn jump_to_location( } fn sym_picker( - symbols: Vec, + option_manager: OptionsManager, current_path: Option, offset_encoding: OffsetEncoding, ) -> FilePicker { // TODO: drop current_path comparison and instead use workspace: bool flag? FilePicker::new( - symbols, - current_path.clone(), + option_manager, move |cx, symbol, action| { let (view, doc) = current!(cx.editor); push_jump(view, doc); @@ -288,9 +287,9 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; + let option_manager = OptionsManager::create_from_items(flat_diag, (styles, format)); FilePicker::new( - flat_diag, - (styles, format), + option_manager, move |cx, PickerDiagnostic { url, diag }, action| { if current_path.as_ref() == Some(url) { let (view, doc) = current!(cx.editor); @@ -369,7 +368,9 @@ pub fn symbol_picker(cx: &mut Context) { } }; - let picker = sym_picker(symbols, current_url, offset_encoding); + let option_manager = + OptionsManager::create_from_items(symbols, current_url.clone()); + let picker = sym_picker(option_manager, current_url, offset_encoding); compositor.push(Box::new(overlayed(picker))) } }, @@ -380,58 +381,50 @@ pub fn workspace_symbol_picker(cx: &mut Context) { let doc = doc!(cx.editor); let current_url = doc.url(); let language_server = language_server!(cx.editor, doc); + let language_server_id = language_server.id(); let offset_encoding = language_server.offset_encoding(); - let future = match language_server.workspace_symbols("".to_string()) { - Some(future) => future, - None => { - cx.editor - .set_error("Language server does not support workspace symbols"); - return; - } - }; - cx.callback( - future, - move |_editor, compositor, response: Option>| { - let symbols = response.unwrap_or_default(); - let picker = sym_picker(symbols, current_url, offset_encoding); - let get_symbols = |query: String, editor: &mut Editor| { - let doc = doc!(editor); - let language_server = match doc.language_server() { - Some(s) => s, - None => { - // This should not generally happen since the picker will not - // even open in the first place if there is no server. - return async move { Err(anyhow::anyhow!("LSP not active")) }.boxed(); - } - }; - let symbol_request = match language_server.workspace_symbols(query) { - Some(future) => future, - None => { - // This should also not happen since the language server must have - // supported workspace symbols before to reach this block. - return async move { - Err(anyhow::anyhow!( - "Language server does not support workspace symbols" - )) - } - .boxed(); - } - }; + let workspace_symbols_fetcher = Box::new(move |pattern: &str, editor: &mut Editor| { + let language_server = match editor.language_servers.get_by_id(language_server_id) { + Some(language_server) => language_server, + None => { + editor.set_error("Language server is not available"); + return async { Ok(vec![]) }.boxed(); + } + }; + let symbol_request = match language_server.workspace_symbols(pattern.to_string()) { + Some(future) => future, + None => { + editor.set_error("Language server does not support workspace symbols"); + return async { Ok(vec![]) }.boxed(); + } + }; + async move { + let json = symbol_request.await?; + let response: Option> = serde_json::from_value(json)?; - let future = async move { - let json = symbol_request.await?; - let response: Option> = - serde_json::from_value(json)?; + Ok(response.unwrap_or_default()) + } + .boxed() + }); + let item_source = ItemSource::from_async_refetch_on_idle_timeout_with_pattern( + workspace_symbols_fetcher, + current_url.clone(), + ); - Ok(response.unwrap_or_default()) - }; - future.boxed() - }; - let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); - compositor.push(Box::new(overlayed(dyn_picker))) + OptionsManager::create_from_item_sources( + vec![item_source], + cx.editor, + cx.jobs, + move |_editor, compositor, option_manager| { + compositor.push(Box::new(overlayed(sym_picker( + option_manager, + current_url, + offset_encoding, + )))) }, - ) + None, + ); } pub fn diagnostics_picker(cx: &mut Context) { @@ -472,10 +465,15 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) { cx.push_layer(Box::new(overlayed(picker))); } -impl ui::menu::Item for lsp::CodeActionOrCommand { +struct CodeActionOrCommandItem { + lsp_item: lsp::CodeActionOrCommand, + offset_encoding: OffsetEncoding, +} + +impl ui::menu::Item for CodeActionOrCommandItem { type Data = (); fn format(&self, _data: &Self::Data) -> Row { - match self { + match &self.lsp_item { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), } @@ -495,8 +493,8 @@ impl ui::menu::Item for lsp::CodeActionOrCommand { /// just without the headings. /// /// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>) -fn action_category(action: &CodeActionOrCommand) -> u32 { - if let CodeActionOrCommand::CodeAction(CodeAction { +fn action_category(action: &lsp::CodeActionOrCommand) -> u32 { + if let lsp::CodeActionOrCommand::CodeAction(CodeAction { kind: Some(kind), .. }) = action { @@ -519,26 +517,28 @@ fn action_category(action: &CodeActionOrCommand) -> u32 { } } -fn action_prefered(action: &CodeActionOrCommand) -> bool { +fn action_prefered(action: &lsp::CodeActionOrCommand) -> bool { matches!( action, - CodeActionOrCommand::CodeAction(CodeAction { + lsp::CodeActionOrCommand::CodeAction(CodeAction { is_preferred: Some(true), .. }) ) } -fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { +fn action_fixes_diagnostics(action: &lsp::CodeActionOrCommand) -> bool { matches!( action, - CodeActionOrCommand::CodeAction(CodeAction { + lsp::CodeActionOrCommand::CodeAction(CodeAction { diagnostics: Some(diagnostics), .. }) if !diagnostics.is_empty() ) } +pub struct CodeActionItemSource; + pub fn code_action(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -575,72 +575,83 @@ pub fn code_action(cx: &mut Context) { } }; - cx.callback( - future, - move |editor, compositor, response: Option| { - let mut actions = match response { - Some(a) => a, - None => return, - }; + let item_source = async move { + let json = future.await?; + let mut actions: lsp::CodeActionResponse = serde_json::from_value(json)?; + + // remove disabled code actions + actions.retain(|action| { + matches!( + action, + lsp::CodeActionOrCommand::Command(_) + | lsp::CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) + ) + }); - // remove disabled code actions - actions.retain(|action| { - matches!( - action, - CodeActionOrCommand::Command(_) - | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) - ) - }); + if actions.is_empty() { + return Ok(vec![]); + } - if actions.is_empty() { - editor.set_status("No code actions available"); - return; + // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. + // Many details are modeled after vscode because langauge servers are usually tested against it. + // VScode sorts the codeaction two times: + // + // First the codeactions that fix some diagnostics are moved to the front. + // If both codeactions fix some diagnostics (or both fix none) the codeaction + // that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate + // submenus that only contain a certain category (see `action_category`) of actions. + // + // Below this done in in a single sorting step + actions.sort_by(|action1, action2| { + // sort actions by category + let order = action_category(action1).cmp(&action_category(action2)); + if order != Ordering::Equal { + return order; + } + // within the categories sort by relevancy. + // Modeled after the `codeActionsComparator` function in vscode: + // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts + + // if one code action fixes a diagnostic but the other one doesn't show it first + let order = action_fixes_diagnostics(action1) + .cmp(&action_fixes_diagnostics(action2)) + .reverse(); + if order != Ordering::Equal { + return order; } - // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. - // Many details are modeled after vscode because langauge servers are usually tested against it. - // VScode sorts the codeaction two times: - // - // First the codeactions that fix some diagnostics are moved to the front. - // If both codeactions fix some diagnostics (or both fix none) the codeaction - // that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate - // submenus that only contain a certain category (see `action_category`) of actions. - // - // Below this done in in a single sorting step - actions.sort_by(|action1, action2| { - // sort actions by category - let order = action_category(action1).cmp(&action_category(action2)); - if order != Ordering::Equal { - return order; - } - // within the categories sort by relevancy. - // Modeled after the `codeActionsComparator` function in vscode: - // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts - - // if one code action fixes a diagnostic but the other one doesn't show it first - let order = action_fixes_diagnostics(action1) - .cmp(&action_fixes_diagnostics(action2)) - .reverse(); - if order != Ordering::Equal { - return order; - } - - // if one of the codeactions is marked as prefered show it first - // otherwise keep the original LSP sorting - action_prefered(action1) - .cmp(&action_prefered(action2)) - .reverse() - }); + // if one of the codeactions is marked as prefered show it first + // otherwise keep the original LSP sorting + action_prefered(action1) + .cmp(&action_prefered(action2)) + .reverse() + }); - let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { + Ok(actions + .into_iter() + .map(|lsp_item| CodeActionOrCommandItem { + lsp_item, + offset_encoding, + }) + .collect()) + } + .boxed(); + + OptionsManager::create_from_item_sources( + vec![ItemSource::from_async_data(item_source, ())], + cx.editor, + cx.jobs, + move |_editor, compositor, options_manager| { + let mut picker = Menu::new(options_manager, move |editor, code_action, event| { if event != PromptEvent::Validate { return; } // always present here let code_action = code_action.unwrap(); + let offset_encoding = code_action.offset_encoding; - match code_action { + match &code_action.lsp_item { lsp::CodeActionOrCommand::Command(command) => { log::debug!("code action command: {:?}", command); execute_lsp_command(editor, command.clone()); @@ -665,7 +676,10 @@ pub fn code_action(cx: &mut Context) { let popup = Popup::new("code-action", picker).with_scrollbar(false); compositor.replace_or_push("code-action", popup); }, - ) + Some(Box::new(|editor| { + editor.set_status("No code actions available") + })), + ); } impl ui::menu::Item for lsp::Command { @@ -890,9 +904,9 @@ fn goto_impl( editor.set_error("No definition found."); } _locations => { + let option_manager = OptionsManager::create_from_items(locations, cwdir); let picker = FilePicker::new( - locations, - cwdir, + option_manager, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 6b45b005aacc..36affde6f1aa 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1289,7 +1289,8 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), |cx, command, _action| { + let option_manager = OptionsManager::create_from_items(commands, ()); + let picker = ui::Picker::new(option_manager, |cx, command, _action| { execute_lsp_command(cx.editor, command.clone()); }); compositor.push(Box::new(overlayed(picker))) diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index 19f2521a5231..f2f4edd90e1d 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -6,10 +6,12 @@ use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::stream::{FuturesUnordered, StreamExt}; pub type EditorCompositorCallback = Box; +pub type EditorCompositorJobsCallback = Box; pub type EditorCallback = Box; pub enum Callback { EditorCompositor(EditorCompositorCallback), + EditorCompositorJobs(EditorCompositorJobsCallback), Editor(EditorCallback), } @@ -56,14 +58,11 @@ impl Jobs { Self::default() } - pub fn spawn> + Send + 'static>(&mut self, f: F) { + pub fn spawn> + Send + 'static>(&self, f: F) { self.add(Job::new(f)); } - pub fn callback> + Send + 'static>( - &mut self, - f: F, - ) { + pub fn callback> + Send + 'static>(&self, f: F) { self.add(Job::with_callback(f)); } @@ -71,11 +70,13 @@ impl Jobs { &self, editor: &mut Editor, compositor: &mut Compositor, + jobs: &Jobs, call: anyhow::Result>, ) { match call { Ok(None) => {} Ok(Some(call)) => match call { + Callback::EditorCompositorJobs(call) => call(editor, compositor, jobs), Callback::EditorCompositor(call) => call(editor, compositor), Callback::Editor(call) => call(editor), }, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index a24da20a9fac..bbbc0fcc034a 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -12,10 +12,18 @@ use helix_core::{Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; use crate::commands; -use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; +use crate::ui::{menu, menu::OptionsManager, Markdown, Menu, Popup, PromptEvent}; -use helix_lsp::{lsp, util}; -use lsp::CompletionItem; +use helix_lsp::{lsp, util, OffsetEncoding}; + +#[derive(Clone, PartialEq)] +pub enum CompletionItem { + LSP { + language_server_id: usize, + item: lsp::CompletionItem, + offset_encoding: OffsetEncoding, + }, +} impl menu::Item for CompletionItem { type Data = (); @@ -25,28 +33,36 @@ impl menu::Item for CompletionItem { #[inline] fn filter_text(&self, _data: &Self::Data) -> Cow { - self.filter_text - .as_ref() - .unwrap_or(&self.label) - .as_str() - .into() + match self { + CompletionItem::LSP { item, .. } => item + .filter_text + .as_ref() + .unwrap_or(&item.label) + .as_str() + .into(), + } } fn format(&self, _data: &Self::Data) -> menu::Row { - let deprecated = self.deprecated.unwrap_or_default() - || self.tags.as_ref().map_or(false, |tags| { + let item = match self { + CompletionItem::LSP { item, .. } => item, + }; + + let deprecated = item.deprecated.unwrap_or_default() + || item.tags.as_ref().map_or(false, |tags| { tags.contains(&lsp::CompletionItemTag::DEPRECATED) }); + menu::Row::new(vec![ menu::Cell::from(Span::styled( - self.label.as_str(), + item.label.as_str(), if deprecated { Style::default().add_modifier(Modifier::CROSSED_OUT) } else { Style::default() }, )), - menu::Cell::from(match self.kind { + menu::Cell::from(match item.kind { Some(lsp::CompletionItemKind::TEXT) => "text", Some(lsp::CompletionItemKind::METHOD) => "method", Some(lsp::CompletionItemKind::FUNCTION) => "function", @@ -78,11 +94,6 @@ impl menu::Item for CompletionItem { } None => "", }), - // self.detail.as_deref().unwrap_or("") - // self.label_details - // .as_ref() - // .or(self.detail()) - // .as_str(), ]) } } @@ -101,24 +112,27 @@ impl Completion { pub fn new( editor: &Editor, - mut items: Vec, - offset_encoding: helix_lsp::OffsetEncoding, + option_manager: OptionsManager, start_offset: usize, trigger_offset: usize, ) -> Self { - // Sort completion items according to their preselect status (given by the LSP server) - items.sort_by_key(|item| !item.preselect.unwrap_or(false)); - // Then create the menu - let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { + let menu = Menu::new(option_manager, move |editor: &mut Editor, item, event| { fn item_to_transaction( doc: &Document, view_id: ViewId, item: &CompletionItem, - offset_encoding: helix_lsp::OffsetEncoding, start_offset: usize, trigger_offset: usize, ) -> Transaction { + // for now only LSP support + let (item, offset_encoding) = match item { + CompletionItem::LSP { + item, + offset_encoding, + .. + } => (item, *offset_encoding), + }; let transaction = if let Some(edit) = &item.text_edit { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), @@ -183,14 +197,8 @@ impl Completion { // always present here let item = item.unwrap(); - let transaction = item_to_transaction( - doc, - view.id, - item, - offset_encoding, - start_offset, - trigger_offset, - ); + let transaction = + item_to_transaction(doc, view.id, item, start_offset, trigger_offset); // initialize a savepoint doc.savepoint(); @@ -205,14 +213,8 @@ impl Completion { // always present here let item = item.unwrap(); - let transaction = item_to_transaction( - doc, - view.id, - item, - offset_encoding, - start_offset, - trigger_offset, - ); + let transaction = + item_to_transaction(doc, view.id, item, start_offset, trigger_offset); doc.apply(&transaction, view.id); @@ -221,8 +223,16 @@ impl Completion { changes: completion_changes(&transaction, trigger_offset), }); + let (lsp_item, offset_encoding, language_server_id) = match item { + CompletionItem::LSP { + item, + offset_encoding, + language_server_id, + } => (item, *offset_encoding, *language_server_id), + }; + // apply additional edits, mostly used to auto import unqualified types - let resolved_item = if item + let resolved_item = if lsp_item .additional_text_edits .as_ref() .map(|edits| !edits.is_empty()) @@ -230,13 +240,17 @@ impl Completion { { None } else { - Self::resolve_completion_item(doc, item.clone()) + let language_server = editor + .language_servers + .get_by_id(language_server_id) + .unwrap(); + Self::resolve_completion_item(language_server, lsp_item.clone()) }; if let Some(additional_edits) = resolved_item .as_ref() .and_then(|item| item.additional_text_edits.as_ref()) - .or(item.additional_text_edits.as_ref()) + .or(lsp_item.additional_text_edits.as_ref()) { if !additional_edits.is_empty() { let transaction = util::generate_transaction_from_edits( @@ -266,10 +280,17 @@ impl Completion { } fn resolve_completion_item( - doc: &Document, + language_server: &helix_lsp::Client, completion_item: lsp::CompletionItem, - ) -> Option { - let language_server = doc.language_server()?; + ) -> Option { + let completion_resolve_provider = language_server + .capabilities() + .completion_provider + .as_ref()? + .resolve_provider; + if completion_resolve_provider != Some(true) { + return None; + } let future = language_server.resolve_completion_item(completion_item)?; let response = helix_lsp::block_on(future); @@ -321,7 +342,7 @@ impl Completion { self.popup.contents().is_empty() } - fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) { + fn replace_item(&mut self, old_item: CompletionItem, new_item: CompletionItem) { self.popup.contents_mut().replace_option(old_item, new_item); } @@ -336,12 +357,16 @@ impl Completion { // > 'completionItem/resolve' request is sent with the selected completion item as a parameter. // > The returned completion item should have the documentation property filled in. // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion - let current_item = match self.popup.contents().selection() { - Some(item) if item.documentation.is_none() => item.clone(), + let (current_item, ls_id, offset_encoding) = match self.popup.contents().selection() { + Some(CompletionItem::LSP { + item, + language_server_id: ls_id, + offset_encoding, + }) if item.documentation.is_none() => (item.clone(), *ls_id, *offset_encoding), _ => return false, }; - let language_server = match doc!(cx.editor).language_server() { + let language_server = match cx.editor.language_servers.get_by_id(ls_id) { Some(language_server) => language_server, None => return false, }; @@ -365,6 +390,16 @@ impl Completion { .unwrap() .completion { + let current_item = CompletionItem::LSP { + item: current_item, + language_server_id: ls_id, + offset_encoding, + }; + let resolved_item = CompletionItem::LSP { + item: resolved_item, + language_server_id: ls_id, + offset_encoding, + }; completion.replace_item(current_item, resolved_item); } }, @@ -388,7 +423,7 @@ impl Component for Completion { // if we have a selection, render a markdown popup on top/below with info let option = match self.popup.contents().selection() { - Some(option) => option, + Some(CompletionItem::LSP { item: option, .. }) => option, None => return, }; // need to render: diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 59f371bda5dc..97c113dcd397 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -32,8 +32,8 @@ use std::{num::NonZeroUsize, path::PathBuf, rc::Rc}; use tui::buffer::Buffer as Surface; -use super::statusline; use super::{document::LineDecoration, lsp::SignatureHelp}; +use super::{menu::OptionsManager, statusline, CompletionItem}; pub struct EditorView { pub keymaps: Keymaps, @@ -943,14 +943,12 @@ impl EditorView { pub fn set_completion( &mut self, editor: &mut Editor, - items: Vec, - offset_encoding: helix_lsp::OffsetEncoding, + option_manager: OptionsManager, start_offset: usize, trigger_offset: usize, size: Rect, ) { - let mut completion = - Completion::new(editor, items, offset_encoding, start_offset, trigger_offset); + let mut completion = Completion::new(editor, option_manager, start_offset, trigger_offset); if completion.is_empty() { // skip if we got no completion results diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 30625acee60c..a2c30f8b8efd 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,22 +1,34 @@ -use std::{borrow::Cow, path::PathBuf}; +use std::{ + borrow::Cow, + cmp::{self, Ordering}, + mem::swap, + path::PathBuf, + sync::Arc, +}; use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, - ctrl, key, shift, + ctrl, job, key, shift, +}; +use futures_util::{future::BoxFuture, stream::FuturesUnordered, Future, FutureExt, StreamExt}; + +use helix_core::movement::Direction; +use tokio::sync::{ + mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, + Notify, }; use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; -use fuzzy_matcher::FuzzyMatcher; use helix_view::{graphics::Rect, Editor}; use tui::layout::Constraint; -pub trait Item { +pub trait Item: Send + 'static { /// Additional editor state that is used for label calculation. - type Data; + type Data: Send; fn format(&self, data: &Self::Data) -> Row; @@ -45,16 +57,637 @@ impl Item for PathBuf { pub type MenuCallback = Box, MenuEvent)>; -pub struct Menu { - options: Vec, - editor_data: T::Data, +type AsyncData = BoxFuture<'static, anyhow::Result>>; +type AsyncRefetchWithQuery = Box AsyncData + Send>; - cursor: Option, +pub enum ItemSource { + AsyncData(Option>, ::Data), + // TODO maybe "generalize" this by using conditional functions + // uses the current pattern/query to refetch new data + AsyncRefetchOnIdleTimeoutWithPattern(AsyncRefetchWithQuery, ::Data), + Data(Vec, ::Data), +} + +impl ItemSource { + pub fn editor_data(&self) -> &::Data { + match self { + ItemSource::Data(_, editor_data) => editor_data, + ItemSource::AsyncData(_, editor_data) => editor_data, + ItemSource::AsyncRefetchOnIdleTimeoutWithPattern(_, editor_data) => editor_data, + } + } + pub fn from_async_data( + future: BoxFuture<'static, anyhow::Result>>, + editor_data: ::Data, + ) -> Self { + Self::AsyncData(Some(future), editor_data) + } + + pub fn from_data(data: Vec, editor_data: ::Data) -> Self { + Self::Data(data, editor_data) + } + + pub fn from_async_refetch_on_idle_timeout_with_pattern( + fetch: AsyncRefetchWithQuery, + editor_data: ::Data, + ) -> Self { + Self::AsyncRefetchOnIdleTimeoutWithPattern(fetch, editor_data) + } +} + +#[derive(PartialEq, Eq, Debug)] +struct Match { + option_index: usize, + score: i64, + option_source: usize, + len: usize, +} + +impl Match { + fn key(&self) -> impl Ord { + ( + cmp::Reverse(self.score), + self.len, + self.option_source, + self.option_index, + ) + } +} + +enum ItemSourceMessage { + Items { + item_source_idx: usize, + items: anyhow::Result>, + }, + NoFurtherItems, +} + +impl PartialOrd for Match { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Match { + fn cmp(&self, other: &Self) -> Ordering { + self.key().cmp(&other.key()) + } +} + +pub struct OptionsManager { + options: Vec>, + options_receiver: UnboundedReceiver>, + options_sender: UnboundedSender>, + matches: Vec, matcher: Box, - /// (index, score) - matches: Vec<(usize, i64)>, + cursor: Option, + item_sources: Vec>, + previous_pattern: (String, FuzzyQuery), + last_pattern_on_idle_timeout: String, + cursor_always_selects: bool, + awaiting_async_options: bool, + has_refetch_item_sources: bool, +} +// TODO Could be extended to a general error handling callback (e.g. errors while fetching) +pub type NoItemsAvailableCallback = Box; + +impl OptionsManager { + pub fn create_from_items(items: Vec, editor_data: ::Data) -> Self { + let item_source = ItemSource::Data(vec![], editor_data); + Self::create_with_item_sources(vec![item_source], [(0, items)], false) + } + + fn create_with_item_sources( + item_sources: Vec>, + items: I, + has_refetch_item_sources: bool, + ) -> Self + where + I: IntoIterator)>, + { + // vec![vec![]; item_sources.len()] requires T: Clone + let options = (0..item_sources.len()).map(|_| vec![]).collect(); + + let (options_sender, options_receiver) = unbounded_channel(); + let mut options_manager = Self { + item_sources, + matches: vec![], + matcher: Box::new(Matcher::default().ignore_case()), + cursor: None, + options, + options_receiver, + options_sender, + previous_pattern: (String::new(), FuzzyQuery::default()), + last_pattern_on_idle_timeout: String::new(), + cursor_always_selects: false, + awaiting_async_options: false, + has_refetch_item_sources, + }; + for (item_source_idx, items) in items { + options_manager.options[item_source_idx] = items; + } + options_manager.force_score(); + options_manager + } + + fn create_options_manager_async( + mut requests: FuturesUnordered< + impl Future>)> + Send + 'static, + >, + item_sources: Vec>, + has_refetch_item_sources: bool, + create_options_container: C, + no_items_available: Option, + ) -> BoxFuture<'static, anyhow::Result> + where + C: FnOnce(&mut Editor, &mut Compositor, OptionsManager) + Send + 'static, + { + async move { + let request = requests.next().await; + let call = + job::Callback::EditorCompositorJobs(Box::new(move |editor, compositor, jobs| { + let (item_source_idx, items) = match request { + Some(r) => r, + None => { + return if let Some(no_items_available) = no_items_available { + no_items_available(editor) + } + } + }; + let items = items.unwrap_or_default(); // TODO show error somewhere instead of swalloing it here? + if items.is_empty() { + // items are empty, try the next item source + jobs.callback(Self::create_options_manager_async( + requests, + item_sources, + has_refetch_item_sources, + create_options_container, + no_items_available, + )); + } else { + let mut option_manager = Self::create_with_item_sources( + item_sources, + [(item_source_idx, items)], + has_refetch_item_sources, + ); + let options_sender = option_manager.options_sender.clone(); + if !requests.is_empty() { + option_manager.awaiting_async_options = true; + jobs.spawn(Self::extend_options_manager_async( + requests, + options_sender, + editor.redraw_handle.0.clone(), + )); + } + // callback that adds ui like menu with the options_manager as argument + create_options_container(editor, compositor, option_manager); + } + })); + Ok(call) + } + .boxed() + } + + fn extend_options_manager_async( + mut requests: FuturesUnordered< + impl Future>)> + Send + 'static, + >, + options_sender: UnboundedSender>, + redraw_notify: Arc, + ) -> BoxFuture<'static, anyhow::Result<()>> { + async move { + while let Some((item_source_idx, items)) = requests.next().await { + // ignore error, as it just indicates that the options manager is gone (i.e. closed), so just discard this future + if options_sender + .send(ItemSourceMessage::Items { + item_source_idx, + items, + }) + .is_err() + { + return Ok(()); + }; + redraw_notify.notify_one(); + } + let _ = options_sender.send(ItemSourceMessage::NoFurtherItems); + Ok(()) + } + .boxed() + } + + pub fn create_from_item_sources( + mut item_sources: Vec>, + editor: &mut Editor, + jobs: &job::Jobs, + create_options_container: F, + no_items_available: Option, // It's a dynamic dispatch to avoid explicit typing with 'None' for the callbacks + ) where + F: FnOnce(&mut Editor, &mut Compositor, OptionsManager) + Send + 'static, + { + let async_requests: FuturesUnordered<_> = item_sources + .iter_mut() + .enumerate() + .filter_map(|(idx, item_source)| match item_source { + ItemSource::AsyncData(data, _) => data + .take() + .map(|data| async move { (idx, data.await) }.boxed()), + ItemSource::AsyncRefetchOnIdleTimeoutWithPattern(fetch, _) => { + let future = fetch("", editor); + Some(async move { (idx, future.await) }.boxed()) + } + _ => None, + }) + .collect(); + let sync_items: Vec<_> = item_sources + .iter_mut() + .enumerate() + .filter_map(|(idx, item_source)| match item_source { + ItemSource::Data(data, _) if !data.is_empty() => { + let mut new_data = vec![]; + swap(data, &mut new_data); + Some((idx, new_data)) + } + _ => None, + }) + .collect(); + let has_refetch_item_sources = item_sources.iter().any(|item_source| { + matches!( + item_source, + ItemSource::AsyncRefetchOnIdleTimeoutWithPattern(_, _) + ) + }); + + // no items available + if async_requests.is_empty() && sync_items.is_empty() { + if let Some(no_items_available) = no_items_available { + no_items_available(editor); + } + return; + } + + if !sync_items.is_empty() { + // TODO this could be done in sync, but it needs the compositor in scope + jobs.callback(async move { + Ok(job::Callback::EditorCompositorJobs(Box::new( + move |editor, compositor, jobs| { + let mut option_manager = Self::create_with_item_sources( + item_sources, + sync_items, + has_refetch_item_sources, + ); + let option_sender = option_manager.options_sender.clone(); + if !async_requests.is_empty() { + option_manager.awaiting_async_options = true; + jobs.spawn(Self::extend_options_manager_async( + async_requests, + option_sender, + editor.redraw_handle.0.clone(), + )) + } + create_options_container(editor, compositor, option_manager); + }, + ))) + }); + } else { + jobs.callback(Self::create_options_manager_async( + async_requests, + item_sources, + has_refetch_item_sources, + create_options_container, + no_items_available, + )); + } + } + + pub fn refetch_on_idle_timeout(&mut self, editor: &mut Editor, jobs: &job::Jobs) -> bool { + if !self.has_refetch_item_sources + || (self.last_pattern_on_idle_timeout == self.previous_pattern.0.clone()) + { + return false; + } + + let requests: FuturesUnordered<_> = self + .item_sources + .iter() + .enumerate() + .filter_map(|(idx, item_source)| match item_source { + ItemSource::AsyncRefetchOnIdleTimeoutWithPattern(fetch, _) => { + let future = fetch(&self.previous_pattern.0, editor); + Some(async move { (idx, future.await) }.boxed()) + } + _ => None, + }) + .collect(); + + if !requests.is_empty() { + self.last_pattern_on_idle_timeout = self.previous_pattern.0.clone(); + self.awaiting_async_options = true; + jobs.spawn(Self::extend_options_manager_async( + requests, + self.options_sender.clone(), + editor.redraw_handle.0.clone(), + )); + return true; + } + false + } + + pub fn poll_for_new_options(&mut self) -> bool { + if !self.awaiting_async_options { + return false; + } + let mut new_options_added = false; + // TODO handle errors somehow? + while let Ok(message) = self.options_receiver.try_recv() { + match message { + ItemSourceMessage::Items { + item_source_idx, + items: Ok(items), + } => { + if items.is_empty() && self.options[item_source_idx].is_empty() { + continue; + } + new_options_added = true; + // TODO this could be extended by getting the matched option and try to find it in the new options + let cursor_on_old_option = matches!(self.cursor.and_then(|cursor| self.matches.get(cursor)), + Some(Match { option_source, ..}) if *option_source == item_source_idx); + if cursor_on_old_option { + self.cursor = if self.cursor_always_selects { + Some(0) + } else { + None + }; + } + self.options[item_source_idx] = items; + } + ItemSourceMessage::NoFurtherItems => self.awaiting_async_options = false, + _ => (), // TODO handle error somehow? + } + } + if new_options_added { + self.force_score(); + } + new_options_added + } + + pub fn options(&self) -> impl Iterator { + self.options + .iter() + .enumerate() + .flat_map(move |(idx, options)| { + options + .iter() + .map(move |o| (o, self.item_sources[idx].editor_data())) + }) + } + + pub fn options_len(&self) -> usize { + self.options.iter().map(Vec::len).sum() + } + + pub fn matches(&self) -> impl Iterator { + self.matches.iter().map( + |Match { + option_index, + option_source, + .. + }| { + ( + &self.options[*option_source][*option_index], + self.item_sources[*option_source].editor_data(), + ) + }, + ) + } + + pub fn cursor(&self) -> Option { + self.cursor + } + + // TODO should probably be an enum + pub fn set_cursor_selection_mode(&mut self, cursor_always_selects: bool) { + self.cursor_always_selects = cursor_always_selects; + if cursor_always_selects && self.cursor.is_none() && !self.matches.is_empty() { + self.cursor = Some(0); + } + } + + // if pattern is None, use the previously used last pattern + pub fn score(&mut self, pattern: Option<&str>, reset_cursor: bool, force_recalculation: bool) { + if reset_cursor && self.cursor.is_some() { + self.cursor = if self.cursor_always_selects { + Some(0) + } else { + None + }; + } + + let pattern = match pattern { + Some(pattern) if pattern == self.previous_pattern.0 && !force_recalculation => return, + None if !force_recalculation => return, + None => &self.previous_pattern.0, + Some(pattern) => pattern, + }; + let prev_selected_option = if !reset_cursor { + self.cursor.and_then(|c| { + self.matches.get(c).map( + |Match { + option_source, + option_index, + .. + }| (*option_source, *option_index), + ) + }) + } else { + None + }; + + let (query, is_refined) = self + .previous_pattern + .1 + .refine(pattern, &self.previous_pattern.0); + + if pattern.is_empty() { + // Fast path for no pattern. + self.matches.clear(); + self.matches + .extend(self.item_sources.iter().enumerate().flat_map( + |(option_source, item_source)| { + self.options[option_source].iter().enumerate().map( + move |(option_index, option)| { + let text = option.filter_text(item_source.editor_data()); + Match { + option_index, + option_source, + score: 0, + len: text.chars().count(), + } + }, + ) + }, + )); + } else if is_refined && !force_recalculation { + // optimization: if the pattern is a more specific version of the previous one + // then we can score the filtered set. + self.matches.retain_mut(|omatch| { + let option = &self.options[omatch.option_source][omatch.option_index]; + let text = option.sort_text(self.item_sources[omatch.option_source].editor_data()); + + match query.fuzzy_match(&text, &self.matcher) { + Some(s) => { + // Update the score + omatch.score = s; + true + } + None => false, + } + }); + + self.matches.sort(); + } else { + self.matches.clear(); + let matcher = &self.matcher; + let query = &query; + self.matches + .extend(self.item_sources.iter().enumerate().flat_map( + |(option_source, item_source)| { + self.options[option_source].iter().enumerate().filter_map( + move |(option_index, option)| { + let text = option.filter_text(item_source.editor_data()); + query.fuzzy_match(&text, matcher).map(|score| Match { + option_index, + option_source, + score, + len: text.chars().count(), + }) + }, + ) + }, + )); + + self.matches.sort(); + } + + // reset cursor position or recover position based on previous matched option + if !reset_cursor { + self.cursor = self + .matches + .iter() + .enumerate() + .find_map(|(index, m)| { + if Some((m.option_source, m.option_index)) == prev_selected_option { + Some(index) + } else { + None + } + }) + .or(if self.cursor_always_selects { + Some(0) + } else { + None + }); + }; + if self.previous_pattern.0 != pattern { + self.previous_pattern.0 = pattern.to_owned(); + } + self.previous_pattern.1 = query; + } + + pub fn force_score(&mut self) { + self.score(None, false, true) + } + + pub fn clear(&mut self) { + self.matches.clear(); + + // reset cursor position + self.cursor = None; + } + + /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) + pub fn move_cursor_by(&mut self, amount: usize, direction: Direction) { + let len = self.matches.len(); + + if len == 0 { + // No results, can't move. + return; + } + + if amount != 0 { + self.cursor = Some(match (direction, self.cursor) { + (Direction::Forward, Some(cursor)) => cursor.saturating_add(amount) % len, + (Direction::Backward, Some(cursor)) => { + cursor.saturating_add(len).saturating_sub(amount) % len + } + (Direction::Forward, None) => amount - 1, + (Direction::Backward, None) => len.saturating_sub(amount), + }); + } + } + + /// Move the cursor to the first entry + pub fn to_start(&mut self) { + self.cursor = Some(0); + } + + /// Move the cursor to the last entry + pub fn to_end(&mut self) { + self.cursor = Some(self.matches.len().saturating_sub(1)); + } + + pub fn selection(&self) -> Option<&T> { + self.cursor.and_then(|cursor| { + self.matches.get(cursor).map( + |Match { + option_index, + option_source, + .. + }| &self.options[*option_source][*option_index], + ) + }) + } + + pub fn selection_mut(&mut self) -> Option<&mut T> { + self.cursor.and_then(|cursor| { + self.matches.get(cursor).map( + |Match { + option_index, + option_source, + .. + }| &mut self.options[*option_source][*option_index], + ) + }) + } + + pub fn is_empty(&self) -> bool { + self.matches.is_empty() + } + + pub fn matches_len(&self) -> usize { + self.matches.len() + } + + pub fn matcher(&self) -> &Matcher { + &self.matcher + } +} + +impl OptionsManager { + fn replace_option(&mut self, old_option: T, new_option: T) { + for options in &mut self.options { + for option in options { + if old_option == *option { + *option = new_option; + return; + } + } + } + } +} + +pub struct Menu { + options_manager: OptionsManager, widths: Vec, callback_fn: MenuCallback, @@ -65,23 +698,17 @@ pub struct Menu { recalculate: bool, } +use super::{fuzzy_match::FuzzyQuery, PromptEvent as MenuEvent}; + impl Menu { const LEFT_PADDING: usize = 1; - // TODO: it's like a slimmed down picker, share code? (picker = menu + prompt with different - // rendering) pub fn new( - options: Vec, - editor_data: ::Data, + options_manager: OptionsManager, callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static, ) -> Self { - let matches = (0..options.len()).map(|i| (i, 0)).collect(); Self { - options, - editor_data, - matcher: Box::new(Matcher::default().ignore_case()), - matches, - cursor: None, + options_manager, widths: Vec::new(), callback_fn: Box::new(callback_fn), scroll: 0, @@ -92,74 +719,53 @@ impl Menu { } pub fn score(&mut self, pattern: &str) { - // reuse the matches allocation - self.matches.clear(); - self.matches.extend( - self.options - .iter() - .enumerate() - .filter_map(|(index, option)| { - let text = option.filter_text(&self.editor_data); - // TODO: using fuzzy_indices could give us the char idx for match highlighting - self.matcher - .fuzzy_match(&text, pattern) - .map(|score| (index, score)) - }), - ); - // Order of equal elements needs to be preserved as LSP preselected items come in order of high to low priority - self.matches.sort_by_key(|(_, score)| -score); - - // reset cursor position - self.cursor = None; + // TODO reset cursor? + self.options_manager.score(Some(pattern), false, false); self.scroll = 0; self.recalculate = true; } pub fn clear(&mut self) { - self.matches.clear(); - - // reset cursor position - self.cursor = None; + self.options_manager.clear(); self.scroll = 0; } pub fn move_up(&mut self) { - let len = self.matches.len(); - let max_index = len.saturating_sub(1); - let pos = self.cursor.map_or(max_index, |i| (i + max_index) % len) % len; - self.cursor = Some(pos); + self.options_manager.move_cursor_by(1, Direction::Backward); self.adjust_scroll(); } pub fn move_down(&mut self) { - let len = self.matches.len(); - let pos = self.cursor.map_or(0, |i| i + 1) % len; - self.cursor = Some(pos); + self.options_manager.move_cursor_by(1, Direction::Forward); self.adjust_scroll(); } fn recalculate_size(&mut self, viewport: (u16, u16)) { let n = self - .options - .first() - .map(|option| option.format(&self.editor_data).cells.len()) + .options_manager + .options() + .next() + .map(|(option, editor_data)| option.format(editor_data).cells.len()) .unwrap_or_default(); - let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.format(&self.editor_data); - // maintain max for each column - for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { - let width = cell.content.width(); - if width > *acc { - *acc = width; - } - } - - acc - }); - - let height = self.matches.len().min(10).min(viewport.1 as usize); + let max_lens = + self.options_manager + .options() + .fold(vec![0; n], |mut acc, (option, editor_data)| { + let row = option.format(editor_data); + // maintain max for each column + for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { + let width = cell.content.width(); + if width > *acc { + *acc = width; + } + } + + acc + }); + + let height = self.len().min(10).min(viewport.1 as usize); // do all the matches fit on a single screen? - let fits = self.matches.len() <= height; + let fits = self.len() <= height; let mut len = max_lens.iter().sum::() + n; @@ -184,7 +790,7 @@ impl Menu { fn adjust_scroll(&mut self) { let win_height = self.size.1 as usize; - if let Some(cursor) = self.cursor { + if let Some(cursor) = self.options_manager.cursor() { let mut scroll = self.scroll; if cursor > (win_height + scroll).saturating_sub(1) { // scroll down @@ -198,47 +804,31 @@ impl Menu { } pub fn selection(&self) -> Option<&T> { - self.cursor.and_then(|cursor| { - self.matches - .get(cursor) - .map(|(index, _score)| &self.options[*index]) - }) + self.options_manager.selection() } pub fn selection_mut(&mut self) -> Option<&mut T> { - self.cursor.and_then(|cursor| { - self.matches - .get(cursor) - .map(|(index, _score)| &mut self.options[*index]) - }) + self.options_manager.selection_mut() } pub fn is_empty(&self) -> bool { - self.matches.is_empty() + self.options_manager.is_empty() } pub fn len(&self) -> usize { - self.matches.len() - } -} - -impl Menu { - pub fn replace_option(&mut self, old_option: T, new_option: T) { - for option in &mut self.options { - if old_option == *option { - *option = new_option; - break; - } - } + self.options_manager.matches_len() } } -use super::PromptEvent as MenuEvent; - -impl Component for Menu { +impl Component for Menu { fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { let event = match event { Event::Key(event) => *event, + Event::IdleTimeout => { + self.options_manager + .refetch_on_idle_timeout(cx.editor, cx.jobs); + return EventResult::Consumed(None); + } _ => return EventResult::Ignored(None), }; @@ -295,6 +885,7 @@ impl Component for Menu { } fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + self.recalculate |= self.options_manager.poll_for_new_options(); if viewport != self.viewport || self.recalculate { self.recalculate_size(viewport); } @@ -303,6 +894,7 @@ impl Component for Menu { } fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + self.options_manager.poll_for_new_options(); let theme = &cx.editor.theme; let style = theme .try_get("ui.menu") @@ -312,14 +904,7 @@ impl Component for Menu { let scroll = self.scroll; - let options: Vec<_> = self - .matches - .iter() - .map(|(index, _score)| { - // (index, self.options.get(*index).unwrap()) // get_unchecked - &self.options[*index] // get_unchecked - }) - .collect(); + let options: Vec<_> = self.options_manager.matches().collect(); let len = options.len(); @@ -331,7 +916,7 @@ impl Component for Menu { let rows = options .iter() - .map(|option| option.format(&self.editor_data)); + .map(|(option, editor_data)| option.format(editor_data)); let table = Table::new(rows) .style(style) .highlight_style(selected) @@ -345,11 +930,11 @@ impl Component for Menu { surface, &mut TableState { offset: scroll, - selected: self.cursor, + selected: self.options_manager.cursor(), }, ); - if let Some(cursor) = self.cursor { + if let Some(cursor) = self.options_manager.cursor() { let offset_from_top = cursor - scroll; let left = &mut surface[(area.left(), area.y + offset_from_top as u16)]; left.set_style(selected); @@ -385,3 +970,9 @@ impl Component for Menu { } } } + +impl Menu { + pub fn replace_option(&mut self, old_option: T, new_option: T) { + self.options_manager.replace_option(old_option, new_option); + } +} diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index d7717f8cf59c..3273119be117 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -17,11 +17,12 @@ mod text; use crate::compositor::{Component, Compositor}; use crate::filter_picker_entry; use crate::job::{self, Callback}; -pub use completion::Completion; +use crate::ui::menu::OptionsManager; +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::{FileLocation, FilePicker, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -217,9 +218,9 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); + let option_manager = OptionsManager::create_from_items(files, root); FilePicker::new( - files, - root, + option_manager, move |cx, path: &PathBuf, action| { if let Err(e) = cx.editor.open(path, action) { let err = if let Some(err) = e.source() { diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 803e2d65bb78..c7d9931afdbb 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -9,7 +9,7 @@ use crate::{ EditorView, }, }; -use futures_util::future::BoxFuture; + use tui::{ buffer::Buffer as Surface, layout::Constraint, @@ -17,7 +17,6 @@ use tui::{ widgets::{Block, BorderType, Borders, Cell, Table}, }; -use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use tui::widgets::Widget; use std::cmp::{self, Ordering}; @@ -36,7 +35,7 @@ use helix_view::{ Document, DocumentId, Editor, }; -use super::{menu::Item, overlay::Overlay}; +use super::menu::{Item, OptionsManager}; pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes @@ -124,13 +123,12 @@ impl Preview<'_, '_> { impl FilePicker { pub fn new( - options: Vec, - editor_data: T::Data, + option_manager: OptionsManager, 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); + let mut picker = Picker::new(option_manager, callback_fn); picker.truncate_start = truncate_start; Self { @@ -208,6 +206,11 @@ impl FilePicker { } fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { + // fetch new options if there are item sources that support it + self.picker + .options_manager + .refetch_on_idle_timeout(cx.editor, cx.jobs); + // Try to find a document in the cache let doc = self .current_file(cx.editor) @@ -231,7 +234,7 @@ impl FilePicker { } } -impl Component for FilePicker { +impl Component for FilePicker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // +---------+ +---------+ // |prompt | |preview | @@ -399,19 +402,12 @@ impl Ord for PickerMatch { type PickerCallback = Box; pub struct Picker { - options: Vec, - editor_data: T::Data, - // filter: String, - matcher: Box, - matches: Vec, - /// Current height of the completions box completion_height: u16, - - cursor: usize, + /// Contains the data state (options, current cursor position, matches etc.) + options_manager: OptionsManager, // 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) @@ -424,8 +420,7 @@ pub struct Picker { impl Picker { pub fn new( - options: Vec, - editor_data: T::Data, + mut options_manager: OptionsManager, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { let prompt = Prompt::new( @@ -434,35 +429,35 @@ impl Picker { ui::completers::none, |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + options_manager.set_cursor_selection_mode(true); - let n = options - .first() - .map(|option| option.format(&editor_data).cells.len()) + let n = options_manager + .options() + .next() + .map(|(option, editor_data)| option.format(editor_data).cells.len()) .unwrap_or_default(); - let max_lens = options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.format(&editor_data); - // maintain max for each column - for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { - let width = cell.content.width(); - if width > *acc { - *acc = width; - } - } - acc - }); + let max_lens = + options_manager + .options() + .fold(vec![0; n], |mut acc, (option, editor_data)| { + let row = option.format(editor_data); + // maintain max for each column + for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { + let width = cell.content.width(); + if width > *acc { + *acc = width; + } + } + acc + }); let widths = max_lens .into_iter() .map(|len| Constraint::Length(len as u16)) .collect(); let mut picker = Self { - options, - editor_data, - matcher: Box::default(), - matches: Vec::new(), - cursor: 0, + options_manager, prompt, - previous_pattern: (String::new(), FuzzyQuery::default()), truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), @@ -470,18 +465,7 @@ impl Picker { widths, }; - // scoring on empty input: - // TODO: just reuse score() - picker - .matches - .extend(picker.options.iter().enumerate().map(|(index, option)| { - let text = option.filter_text(&picker.editor_data); - PickerMatch { - index, - score: 0, - len: text.chars().count(), - } - })); + picker.options_manager.score(None, true, true); picker } @@ -489,98 +473,18 @@ impl Picker { pub fn score(&mut self) { let pattern = self.prompt.line(); - if pattern == &self.previous_pattern.0 { - return; - } - - let (query, is_refined) = self - .previous_pattern - .1 - .refine(pattern, &self.previous_pattern.0); - - if pattern.is_empty() { - // Fast path for no pattern. - self.matches.clear(); - self.matches - .extend(self.options.iter().enumerate().map(|(index, option)| { - let text = option.filter_text(&self.editor_data); - PickerMatch { - index, - score: 0, - len: text.chars().count(), - } - })); - } else if is_refined { - // optimization: if the pattern is a more specific version of the previous one - // then we can score the filtered set. - self.matches.retain_mut(|pmatch| { - let option = &self.options[pmatch.index]; - let text = option.sort_text(&self.editor_data); - - match query.fuzzy_match(&text, &self.matcher) { - Some(s) => { - // Update the score - pmatch.score = s; - true - } - None => false, - } - }); - - self.matches.sort_unstable(); - } else { - self.force_score(); - } - - // reset cursor position - self.cursor = 0; - let pattern = self.prompt.line(); - self.previous_pattern.0.clone_from(pattern); - self.previous_pattern.1 = query; + self.options_manager.score(Some(pattern), true, false); } pub fn force_score(&mut self) { let pattern = self.prompt.line(); - let query = FuzzyQuery::new(pattern); - self.matches.clear(); - self.matches.extend( - self.options - .iter() - .enumerate() - .filter_map(|(index, option)| { - let text = option.filter_text(&self.editor_data); - - query - .fuzzy_match(&text, &self.matcher) - .map(|score| PickerMatch { - index, - score, - len: text.chars().count(), - }) - }), - ); - - self.matches.sort_unstable(); + self.options_manager.score(Some(pattern), true, true); } /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) pub fn move_by(&mut self, amount: usize, direction: Direction) { - let len = self.matches.len(); - - if len == 0 { - // No results, can't move. - return; - } - - match direction { - Direction::Forward => { - self.cursor = self.cursor.saturating_add(amount) % len; - } - Direction::Backward => { - self.cursor = self.cursor.saturating_add(len).saturating_sub(amount) % len; - } - } + self.options_manager.move_cursor_by(amount, direction); } /// Move the cursor down by exactly one page. After the last page comes the first page. @@ -595,18 +499,16 @@ impl Picker { /// Move the cursor to the first entry pub fn to_start(&mut self) { - self.cursor = 0; + self.options_manager.to_start() } /// Move the cursor to the last entry pub fn to_end(&mut self) { - self.cursor = self.matches.len().saturating_sub(1); + self.options_manager.to_end() } pub fn selection(&self) -> Option<&T> { - self.matches - .get(self.cursor) - .map(|pmatch| &self.options[pmatch.index]) + self.options_manager.selection() } pub fn toggle_preview(&mut self) { @@ -627,7 +529,7 @@ impl Picker { // - on input change: // - score all the names in relation to input -impl Component for Picker { +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) @@ -638,6 +540,11 @@ impl Component for Picker { Event::Key(event) => *event, Event::Paste(..) => return self.prompt_handle_event(event, cx), Event::Resize(..) => return EventResult::Consumed(None), + Event::IdleTimeout => { + self.options_manager + .refetch_on_idle_timeout(cx.editor, cx.jobs); + return EventResult::Consumed(None); + } _ => return EventResult::Ignored(None), }; @@ -706,6 +613,8 @@ impl Component for Picker { } fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + self.options_manager.poll_for_new_options(); + 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); @@ -727,7 +636,11 @@ impl Component for Picker { let area = inner.clip_left(1).with_height(1); - let count = format!("{}/{}", self.matches.len(), self.options.len()); + let count = format!( + "{}/{}", + self.options_manager.matches_len(), + self.options_manager.options_len() + ); surface.set_stringn( (area.x + area.width).saturating_sub(count.len() as u16 + 1), area.y, @@ -752,16 +665,18 @@ impl Component for Picker { let inner = inner.clip_top(2); let rows = inner.height; - let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize)); - let cursor = self.cursor.saturating_sub(offset); + // TODO check None for sel.cursor + let cursor = self.options_manager.cursor().unwrap_or_default(); + + let offset = cursor - (cursor % std::cmp::max(1, rows as usize)); + let cursor = cursor.saturating_sub(offset); let options = self - .matches - .iter() + .options_manager + .matches() .skip(offset) .take(rows as usize) - .map(|pmatch| &self.options[pmatch.index]) - .map(|option| option.format(&self.editor_data)) + .map(|(option, editor_data)| option.format(editor_data)) .map(|mut row| { const TEMP_CELL_SEP: &str = " "; @@ -776,7 +691,7 @@ impl Component for Picker { // might be inconsistencies. This is the best we can do since only the // text in Row is displayed to the end user. let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) - .fuzzy_indicies(&line, &self.matcher) + .fuzzy_indicies(&line, self.options_manager.matcher()) .unwrap_or_default(); let highlight_byte_ranges: Vec<_> = line @@ -878,78 +793,3 @@ impl Component for Picker { self.prompt.cursor(area, editor) } } - -/// Returns a new list of options to replace the contents of the picker -/// when called with the current picker query, -pub type DynQueryCallback = - Box BoxFuture<'static, anyhow::Result>>>; - -/// 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, - query_callback: DynQueryCallback, - query: String, -} - -impl DynamicPicker { - pub const ID: &'static str = "dynamic-picker"; - - pub fn new(file_picker: FilePicker, query_callback: DynQueryCallback) -> Self { - Self { - file_picker, - query_callback, - query: String::new(), - } - } -} - -impl Component for DynamicPicker { - fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { - self.file_picker.render(area, surface, cx); - } - - 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(); - - if !matches!(event, Event::IdleTimeout) || self.query == *current_query { - return event_result; - } - - self.query.clone_from(current_query); - - let new_options = (self.query_callback)(current_query.to_owned(), cx.editor); - - cx.jobs.callback(async move { - let new_options = new_options.await?; - let callback = - crate::job::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::>>(Self::ID) { - Some(overlay) => &mut overlay.content.file_picker.picker, - None => return, - }; - picker.options = new_options; - picker.cursor = 0; - picker.force_score(); - editor.reset_idle_timer(); - })); - anyhow::Ok(callback) - }); - EventResult::Consumed(None) - } - - fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, CursorKind) { - self.file_picker.cursor(area, ctx) - } - - fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - self.file_picker.required_size(viewport) - } - - fn id(&self) -> Option<&'static str> { - Some(Self::ID) - } -} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 50da3ddeac2d..f8e1f99488e3 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -709,7 +709,7 @@ pub struct WhitespaceCharacters { impl Default for WhitespaceCharacters { fn default() -> Self { Self { - space: '·', // U+00B7 + space: '·', // U+00B7 nbsp: '⍽', // U+237D tab: '→', // U+2192 newline: '⏎', // U+23CE