From de839f743da1c11939875eb9a48eb66ee05ed302 Mon Sep 17 00:00:00 2001 From: Philipp Mildenberger Date: Mon, 13 Feb 2023 01:51:15 +0100 Subject: [PATCH] Refactor picker/menu logic into an options manager which can be fed with multiple different item sources (async, sync, refetch) This PR unifies the functionality of the `Picker` and the `Menu` in the struct `OptionsManager` (i.e. matching/filtering, sorting, cursor management etc.), and extends the logic of `score` by being able to retain the cursor position (which is useful, if new entries are added async). The main reason for this PR though is to have multiple option `ItemSource`s, which can be async and which may be refetched (see #5055) to provide the options for the `OptionsManager`. It currently allows 3 different input sources: synchronous data, asynchronous data, and asynchronous functions which provide the data, which are reexecuted after an `IdleTimeout` occured (similar as #5055). This PR is or will be a requirement for #2507 (currently provided by #3082), which I will rebase onto this PR soon. Noticeable effects right now: * The `Menu` now has the more sophisticated logic of the picker (mostly better caching while changing the query) * The `Menu` currently *doesn't* reset the cursor position, if `score()` is called (effectively different pattern query), I'm not sure if we should keep this behavior, I guess it needs some testing and feedback. I have kept the original behavior of the Picker (everytime `score()` changes it resets the cursor) This PR is an alternative to #3082, which I wasn't completely happy with (adding options async was/is kind of a hack to keep it simple) thus this PR will supersede it. It was originally motivated by #5055 and how to integrate that logic into #2507 (which is not done yet). The PR unfortunately grew a little bit more than I've anticipated, but in general I think it's the cleaner and more future proof way to be able to have async item sources, which may change the options overtime. For example the same logic of #5055 could be simply implemented for the completion menu with different multiple completion sources, whether it makes sense or not (though also one of the motivations of this PR to have this possibility). This should also cleanup and greatly (hopefully) simplify #2507. To make reviewing easier (I hope) I provide a technical summary of this PR: * `OptionsManager` contains all logic that is necessary to manage options (d'oh), that means: * Filtering/matching of options with `score()` which takes a few more parameters now: * *Optional* pattern to be able to recalculate the score without providing a pattern (necessary for async addition of the options) * Boolean flag to retain the cursor, since it will likely be annoying, if an item source takes so long that the user is already selecting options of a different item source and it gets resetted because score has to be called again for matches that respect the new option source). * force_recalculation, kind of an optimization, if nothing external has changed (except new async options) * The `OptionsManager` is fed by `ItemSource`s * Fetching of items from the item source is started by the creation of the `OptionsManager` (`create_from_item_sources`), and as soon one item source provides some items a construction callback is executed (see below) * Async fetching is done by looping over `FuturesUnordered` async requests and adding the options via an `mpsc`, I wanted to avoid any `Mutex` or something alike to keep the rest of the logic as is (and likely to be more performant as well). Thus instead the editor `redraw_handle` is notified when new options are available and in the render functions new options are polled (via `poll_for_new_options`). Although not optimal, it was IMHO the best tradeoff (I tried different solutions, and came to that conclusion). * Options are stored with an additional index to the item source to be able to easily change the options on the fly, it provides some iterator wrapper functions to access the options/matches outside as usual. * When using the `OptionManager` with async sources, I have decided (to mostly mirror current logic) to use a "constructor callback" in the `create_from_item_sources` for creating e.g. the actual `Menu` or `Picker` when at least one item is available (this callback is only then executed), otherwise an optional other callback can show e.g. editor status messages, when there was no item source provided any data. * The `(File-)Picker` and `Menu` now takes the `OptionsManager` instead of the items or editor data (this is now provided in the `ItemSource`) * For sync data it's also possible to directly create the `OptionsManager` without a callback using `create_from_items` but only with this function. * I've imported the `CompletionItem` from #2507 to provide additional information where the item came from (which language server id and offset encoding) when multiple language servers are used as Item source, it's currently an enum to avoid unnecessary refactoring if other completion sources are added (such as #2608 or #1015), though it's not really relevant for this PR currently (as the doc has just one language server anyway), but I think it may be easier to review it here separated from #2507 and it's also the (only) dependency of #2507 for #2608 * The `DynamicPicker` is removed as this behavior can now be modeled with a `ItemSource::AsyncRefetchOnIdleTimeoutWithPattern` which is done for the `workspace_symbol_picker` --- helix-lsp/src/lib.rs | 2 +- helix-term/src/application.rs | 4 +- helix-term/src/commands.rs | 84 ++-- helix-term/src/commands/dap.rs | 46 +- helix-term/src/commands/lsp.rs | 262 +++++----- helix-term/src/commands/typed.rs | 3 +- helix-term/src/job.rs | 11 +- helix-term/src/ui/completion.rs | 137 ++++-- helix-term/src/ui/editor.rs | 8 +- helix-term/src/ui/menu.rs | 805 +++++++++++++++++++++++++++---- helix-term/src/ui/mod.rs | 9 +- helix-term/src/ui/picker.rs | 284 +++-------- helix-view/src/editor.rs | 2 +- 13 files changed, 1090 insertions(+), 567 deletions(-) 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