aboutsummaryrefslogtreecommitdiff
path: root/helix-term
diff options
context:
space:
mode:
authorPascal Kuthe2023-11-30 23:03:27 +0000
committerBlaž Hrastnik2024-01-23 02:20:19 +0000
commit8e592a151fe7adfbf3fb35ae134b7f2a70700f09 (patch)
tree603a94042068620e52f50cb26cf881d5461d1c8d /helix-term
parent13ed4f6c4748019787d24c2b686d417b71604242 (diff)
refactor completion and signature help using hooks
Diffstat (limited to 'helix-term')
-rw-r--r--helix-term/src/application.rs6
-rw-r--r--helix-term/src/commands.rs259
-rw-r--r--helix-term/src/commands/lsp.rs156
-rw-r--r--helix-term/src/handlers.rs17
-rw-r--r--helix-term/src/handlers/completion.rs465
-rw-r--r--helix-term/src/handlers/signature_help.rs335
-rw-r--r--helix-term/src/ui/completion.rs87
-rw-r--r--helix-term/src/ui/editor.rs59
-rw-r--r--helix-term/src/ui/menu.rs34
9 files changed, 916 insertions, 502 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
index 8215eeaa..3f3e59c6 100644
--- a/helix-term/src/application.rs
+++ b/helix-term/src/application.rs
@@ -1,10 +1,6 @@
use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream;
-use helix_core::{
- chars::char_is_word,
- diagnostic::{DiagnosticTag, NumberOrString},
- pos_at_coords, syntax, Selection,
-};
+use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
use helix_lsp::{
lsp::{self, notification::Notification},
util::lsp_range_to_range,
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 48ceb23b..4df3278b 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -5,7 +5,6 @@ pub(crate) mod typed;
pub use dap::*;
use helix_vcs::Hunk;
pub use lsp::*;
-use tokio::sync::oneshot;
use tui::widgets::Row;
pub use typed::*;
@@ -33,7 +32,7 @@ use helix_core::{
};
use helix_view::{
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
- editor::{Action, CompleteAction},
+ editor::Action,
info::Info,
input::KeyEvent,
keyboard::KeyCode,
@@ -52,14 +51,10 @@ use crate::{
filter_picker_entry,
job::Callback,
keymap::ReverseKeymap,
- ui::{
- self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
- Popup, Prompt, PromptEvent,
- },
+ ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
};
use crate::job::{self, Jobs};
-use futures_util::{stream::FuturesUnordered, TryStreamExt};
use std::{
collections::{HashMap, HashSet},
fmt,
@@ -2593,7 +2588,6 @@ fn delete_by_selection_insert_mode(
);
}
doc.apply(&transaction, view.id);
- lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
fn delete_selection(cx: &mut Context) {
@@ -2667,10 +2661,6 @@ fn insert_mode(cx: &mut Context) {
.transform(|range| Range::new(range.to(), range.from()));
doc.set_selection(view.id, selection);
-
- // [TODO] temporary workaround until we're not using the idle timer to
- // trigger auto completions any more
- cx.editor.clear_idle_timer();
}
// inserts at the end of each selection
@@ -3497,9 +3487,9 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range {
pub mod insert {
use crate::events::PostInsertChar;
+
use super::*;
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
- pub type PostHook = fn(&mut Context, char);
/// Exclude the cursor in range.
fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range {
@@ -3513,88 +3503,6 @@ pub mod insert {
}
}
- // It trigger completion when idle timer reaches deadline
- // Only trigger completion if the word under cursor is longer than n characters
- pub fn idle_completion(cx: &mut Context) {
- let config = cx.editor.config();
- let (view, doc) = current!(cx.editor);
- let text = doc.text().slice(..);
- let cursor = doc.selection(view.id).primary().cursor(text);
-
- use helix_core::chars::char_is_word;
- let mut iter = text.chars_at(cursor);
- iter.reverse();
- for _ in 0..config.completion_trigger_len {
- match iter.next() {
- Some(c) if char_is_word(c) => {}
- _ => return,
- }
- }
- super::completion(cx);
- }
-
- fn language_server_completion(cx: &mut Context, ch: char) {
- let config = cx.editor.config();
- if !config.auto_completion {
- return;
- }
-
- use helix_lsp::lsp;
- // if ch matches completion char, trigger completion
- let doc = doc_mut!(cx.editor);
- let trigger_completion = doc
- .language_servers_with_feature(LanguageServerFeature::Completion)
- .any(|ls| {
- // TODO: what if trigger is multiple chars long
- matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
- trigger_characters: Some(triggers),
- ..
- }) if triggers.iter().any(|trigger| trigger.contains(ch)))
- });
-
- if trigger_completion {
- cx.editor.clear_idle_timer();
- super::completion(cx);
- }
- }
-
- fn signature_help(cx: &mut Context, ch: char) {
- use helix_lsp::lsp;
- // if ch matches signature_help char, trigger
- let doc = doc_mut!(cx.editor);
- // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
- let Some(language_server) = doc
- .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
- .next()
- else {
- return;
- };
-
- let capabilities = language_server.capabilities();
-
- if let lsp::ServerCapabilities {
- signature_help_provider:
- Some(lsp::SignatureHelpOptions {
- trigger_characters: Some(triggers),
- // TODO: retrigger_characters
- ..
- }),
- ..
- } = capabilities
- {
- // TODO: what if trigger is multiple chars long
- let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch));
- // lsp doesn't tell us when to close the signature help, so we request
- // the help information again after common close triggers which should
- // return None, which in turn closes the popup.
- let close_triggers = &[')', ';', '.'];
-
- if is_trigger || close_triggers.contains(&ch) {
- super::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
- }
- }
- }
-
// The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
@@ -3624,12 +3532,6 @@ pub mod insert {
doc.apply(&t, view.id);
}
- // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc)
- // this could also generically look at Transaction, but it's a bit annoying to look at
- // Operation instead of Change.
- for hook in &[language_server_completion, signature_help] {
- hook(cx, c);
- }
helix_event::dispatch(PostInsertChar { c, cx });
}
@@ -3855,8 +3757,6 @@ pub mod insert {
});
let (view, doc) = current!(cx.editor);
doc.apply(&transaction, view.id);
-
- lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
pub fn delete_char_forward(cx: &mut Context) {
@@ -4510,151 +4410,14 @@ fn remove_primary_selection(cx: &mut Context) {
}
pub fn completion(cx: &mut Context) {
- use helix_lsp::{lsp, util::pos_to_lsp_pos};
-
let (view, doc) = current!(cx.editor);
+ let range = doc.selection(view.id).primary();
+ let text = doc.text().slice(..);
+ let cursor = range.cursor(text);
- let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion
- {
- savepoint.clone()
- } else {
- doc.savepoint(view)
- };
-
- let text = savepoint.text.clone();
- let cursor = savepoint.cursor();
-
- let mut seen_language_servers = HashSet::new();
-
- let mut futures: FuturesUnordered<_> = doc
- .language_servers_with_feature(LanguageServerFeature::Completion)
- .filter(|ls| seen_language_servers.insert(ls.id()))
- .map(|language_server| {
- let language_server_id = language_server.id();
- let offset_encoding = language_server.offset_encoding();
- let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);
- let doc_id = doc.identifier();
- let completion_request = language_server.completion(doc_id, pos, None).unwrap();
-
- async move {
- let json = completion_request.await?;
- let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
-
- let items = match response {
- Some(lsp::CompletionResponse::Array(items)) => items,
- // TODO: do something with is_incomplete
- Some(lsp::CompletionResponse::List(lsp::CompletionList {
- is_incomplete: _is_incomplete,
- items,
- })) => items,
- None => Vec::new(),
- }
- .into_iter()
- .map(|item| CompletionItem {
- item,
- language_server_id,
- resolved: false,
- })
- .collect();
-
- anyhow::Ok(items)
- }
- })
- .collect();
-
- // setup a channel that allows the request to be canceled
- let (tx, rx) = oneshot::channel();
- // set completion_request so that this request can be canceled
- // by setting completion_request, the old channel stored there is dropped
- // and the associated request is automatically dropped
- cx.editor.completion_request_handle = Some(tx);
- let future = async move {
- let items_future = async move {
- let mut items = Vec::new();
- // TODO if one completion request errors, all other completion requests are discarded (even if they're valid)
- while let Some(mut lsp_items) = futures.try_next().await? {
- items.append(&mut lsp_items);
- }
- anyhow::Ok(items)
- };
- tokio::select! {
- biased;
- _ = rx => {
- Ok(Vec::new())
- }
- res = items_future => {
- res
- }
- }
- };
-
- let trigger_offset = cursor;
-
- // TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply
- // completion filtering. For example logger.te| should filter the initial suggestion list with "te".
-
- use helix_core::chars;
- let mut iter = text.chars_at(cursor);
- iter.reverse();
- let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
- let start_offset = cursor.saturating_sub(offset);
-
- let trigger_doc = doc.id();
- let trigger_view = view.id;
-
- // FIXME: The commands Context can only have a single callback
- // which means it gets overwritten when executing keybindings
- // with multiple commands or macros. This would mean that completion
- // might be incorrectly applied when repeating the insertmode action
- //
- // TODO: to solve this either make cx.callback a Vec of callbacks or
- // alternatively move `last_insert` to `helix_view::Editor`
- cx.callback = Some(Box::new(
- move |compositor: &mut Compositor, _cx: &mut compositor::Context| {
- let ui = compositor.find::<ui::EditorView>().unwrap();
- ui.last_insert.1.push(InsertEvent::RequestCompletion);
- },
- ));
-
- cx.jobs.callback(async move {
- let items = future.await?;
- let call = move |editor: &mut Editor, compositor: &mut Compositor| {
- let (view, doc) = current_ref!(editor);
- // check if the completion request is stale.
- //
- // Completions are completed asynchronously and therefore the user could
- //switch document/view or leave insert mode. In all of thoise cases the
- // completion should be discarded
- if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc {
- return;
- }
-
- if items.is_empty() {
- // editor.set_error("No completion available");
- return;
- }
- let size = compositor.size();
- let ui = compositor.find::<ui::EditorView>().unwrap();
- let completion_area = ui.set_completion(
- editor,
- savepoint,
- items,
- start_offset,
- trigger_offset,
- size,
- );
- let size = compositor.size();
- let signature_help_area = compositor
- .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
- .map(|signature_help| signature_help.area(size, editor));
- // Delete the signature help popup if they intersect.
- if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b))
- {
- compositor.remove(SignatureHelp::ID);
- }
- };
- Ok(Callback::EditorCompositor(Box::new(call)))
- });
+ cx.editor
+ .handlers
+ .trigger_completions(cursor, doc.id(), view.id);
}
// comments
@@ -4833,10 +4596,6 @@ fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) {
);
doc.set_selection(view.id, selection);
-
- // [TODO] temporary workaround until we're not using the idle timer to
- // trigger auto completions any more
- editor.clear_idle_timer();
}
};
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index 051cdcd3..de2f0e5e 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -1,4 +1,4 @@
-use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt};
+use futures_util::{stream::FuturesUnordered, FutureExt};
use helix_lsp::{
block_on,
lsp::{
@@ -8,21 +8,21 @@ use helix_lsp::{
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
Client, OffsetEncoding,
};
-use serde_json::Value;
use tokio_stream::StreamExt;
use tui::{
text::{Span, Spans},
widgets::Row,
};
-use super::{align_view, push_jump, Align, Context, Editor, Open};
+use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection};
use helix_stdx::path;
use helix_view::{
- document::{DocumentInlayHints, DocumentInlayHintsId, Mode},
+ document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action,
graphics::Margin,
+ handlers::lsp::SignatureHelpInvoked,
theme::Style,
Document, View,
};
@@ -30,10 +30,7 @@ use helix_view::{
use crate::{
compositor::{self, Compositor},
job::Callback,
- ui::{
- self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
- PromptEvent,
- },
+ ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent},
};
use std::{
@@ -42,7 +39,6 @@ use std::{
fmt::Write,
future::Future,
path::PathBuf,
- sync::Arc,
};
/// Gets the first language server that is attached to a document which supports a specific feature.
@@ -1132,146 +1128,10 @@ pub fn goto_reference(cx: &mut Context) {
);
}
-#[derive(PartialEq, Eq, Clone, Copy)]
-pub enum SignatureHelpInvoked {
- Manual,
- Automatic,
-}
-
pub fn signature_help(cx: &mut Context) {
- signature_help_impl(cx, SignatureHelpInvoked::Manual)
-}
-
-pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
- let (view, doc) = current!(cx.editor);
-
- // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
- let future = doc
- .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
- .find_map(|language_server| {
- let pos = doc.position(view.id, language_server.offset_encoding());
- language_server.text_document_signature_help(doc.identifier(), pos, None)
- });
-
- let Some(future) = future else {
- // Do not show the message if signature help was invoked
- // automatically on backspace, trigger characters, etc.
- if invoked == SignatureHelpInvoked::Manual {
- cx.editor
- .set_error("No configured language server supports signature-help");
- }
- return;
- };
- signature_help_impl_with_future(cx, future.boxed(), invoked);
-}
-
-pub fn signature_help_impl_with_future(
- cx: &mut Context,
- future: BoxFuture<'static, helix_lsp::Result<Value>>,
- invoked: SignatureHelpInvoked,
-) {
- cx.callback(
- future,
- move |editor, compositor, response: Option<lsp::SignatureHelp>| {
- let config = &editor.config();
-
- if !(config.lsp.auto_signature_help
- || SignatureHelp::visible_popup(compositor).is_some()
- || invoked == SignatureHelpInvoked::Manual)
- {
- return;
- }
-
- // If the signature help invocation is automatic, don't show it outside of Insert Mode:
- // it very probably means the server was a little slow to respond and the user has
- // already moved on to something else, making a signature help popup will just be an
- // annoyance, see https://github.com/helix-editor/helix/issues/3112
- if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
- return;
- }
-
- let response = match response {
- // According to the spec the response should be None if there
- // are no signatures, but some servers don't follow this.
- Some(s) if !s.signatures.is_empty() => s,
- _ => {
- compositor.remove(SignatureHelp::ID);
- return;
- }
- };
- let doc = doc!(editor);
- let language = doc.language_name().unwrap_or("");
-
- let signature = match response
- .signatures
- .get(response.active_signature.unwrap_or(0) as usize)
- {
- Some(s) => s,
- None => return,
- };
- let mut contents = SignatureHelp::new(
- signature.label.clone(),
- language.to_string(),
- Arc::clone(&editor.syn_loader),
- );
-
- let signature_doc = if config.lsp.display_signature_help_docs {
- signature.documentation.as_ref().map(|doc| match doc {
- lsp::Documentation::String(s) => s.clone(),
- lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
- })
- } else {
- None
- };
-
- contents.set_signature_doc(signature_doc);
-
- let active_param_range = || -> Option<(usize, usize)> {
- let param_idx = signature
- .active_parameter
- .or(response.active_parameter)
- .unwrap_or(0) as usize;
- let param = signature.parameters.as_ref()?.get(param_idx)?;
- match &param.label {
- lsp::ParameterLabel::Simple(string) => {
- let start = signature.label.find(string.as_str())?;
- Some((start, start + string.len()))
- }
- lsp::ParameterLabel::LabelOffsets([start, end]) => {
- // LS sends offsets based on utf-16 based string representation
- // but highlighting in helix is done using byte offset.
- use helix_core::str_utils::char_to_byte_idx;
- let from = char_to_byte_idx(&signature.label, *start as usize);
- let to = char_to_byte_idx(&signature.label, *end as usize);
- Some((from, to))
- }
- }
- };
- contents.set_active_param_range(active_param_range());
-
- let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
- let mut popup = Popup::new(SignatureHelp::ID, contents)
- .position(old_popup.and_then(|p| p.get_position()))
- .position_bias(Open::Above)
- .ignore_escape_key(true);
-
- // Don't create a popup if it intersects the auto-complete menu.
- let size = compositor.size();
- if compositor
- .find::<ui::EditorView>()
- .unwrap()
- .completion
- .as_mut()
- .map(|completion| completion.area(size, editor))
- .filter(|area| area.intersects(popup.area(size, editor)))
- .is_some()
- {
- return;
- }
-
- compositor.replace_or_push(SignatureHelp::ID, popup);
- },
- );
+ cx.editor
+ .handlers
+ .trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor)
}
pub fn hover(cx: &mut Context) {
diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index ab2d724f..ef5369f8 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -1,15 +1,30 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
+use helix_event::AsyncHook;
use crate::config::Config;
use crate::events;
+use crate::handlers::completion::CompletionHandler;
+use crate::handlers::signature_help::SignatureHelpHandler;
+pub use completion::trigger_auto_completion;
+pub use helix_view::handlers::lsp::SignatureHelpInvoked;
+pub use helix_view::handlers::Handlers;
+
+mod completion;
+mod signature_help;
- }
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
+
+ let completions = CompletionHandler::new(config).spawn();
+ let signature_hints = SignatureHelpHandler::new().spawn();
let handlers = Handlers {
+ completions,
+ signature_hints,
};
+ completion::register_hooks(&handlers);
+ signature_help::register_hooks(&handlers);
handlers
}
diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs
new file mode 100644
index 00000000..d71fd24f
--- /dev/null
+++ b/helix-term/src/handlers/completion.rs
@@ -0,0 +1,465 @@
+use std::collections::HashSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+use arc_swap::ArcSwap;
+use futures_util::stream::FuturesUnordered;
+use helix_core::chars::char_is_word;
+use helix_core::syntax::LanguageServerFeature;
+use helix_event::{
+ cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
+};
+use helix_lsp::lsp;
+use helix_lsp::util::pos_to_lsp_pos;
+use helix_stdx::rope::RopeSliceExt;
+use helix_view::document::{Mode, SavePoint};
+use helix_view::handlers::lsp::CompletionEvent;
+use helix_view::{DocumentId, Editor, ViewId};
+use tokio::sync::mpsc::Sender;
+use tokio::time::Instant;
+use tokio_stream::StreamExt;
+
+use crate::commands;
+use crate::compositor::Compositor;
+use crate::config::Config;
+use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
+use crate::job::{dispatch, dispatch_blocking};
+use crate::keymap::MappableCommand;
+use crate::ui::editor::InsertEvent;
+use crate::ui::lsp::SignatureHelp;
+use crate::ui::{self, CompletionItem, Popup};
+
+use super::Handlers;
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum TriggerKind {
+ Auto,
+ TriggerChar,
+ Manual,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct Trigger {
+ pos: usize,
+ view: ViewId,
+ doc: DocumentId,
+ kind: TriggerKind,
+}
+
+#[derive(Debug)]
+pub(super) struct CompletionHandler {
+ /// currently active trigger which will cause a
+ /// completion request after the timeout
+ trigger: Option<Trigger>,
+ /// A handle for currently active completion request.
+ /// This can be used to determine whether the current
+ /// request is still active (and new triggers should be
+ /// ignored) and can also be used to abort the current
+ /// request (by dropping the handle)
+ request: Option<CancelTx>,
+ config: Arc<ArcSwap<Config>>,
+}
+
+impl CompletionHandler {
+ pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
+ Self {
+ config,
+ request: None,
+ trigger: None,
+ }
+ }
+}
+
+impl helix_event::AsyncHook for CompletionHandler {
+ type Event = CompletionEvent;
+
+ fn handle_event(
+ &mut self,
+ event: Self::Event,
+ _old_timeout: Option<Instant>,
+ ) -> Option<Instant> {
+ match event {
+ CompletionEvent::AutoTrigger {
+ cursor: trigger_pos,
+ doc,
+ view,
+ } => {
+ // techically it shouldn't be possible to switch views/documents in insert mode
+ // but people may create weird keymaps/use the mouse so lets be extra careful
+ if self
+ .trigger
+ .as_ref()
+ .map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
+ {
+ self.trigger = Some(Trigger {
+ pos: trigger_pos,
+ view,
+ doc,
+ kind: TriggerKind::Auto,
+ });
+ }
+ }
+ CompletionEvent::TriggerChar { cursor, doc, view } => {
+ // immediately request completions and drop all auto completion requests
+ self.request = None;
+ self.trigger = Some(Trigger {
+ pos: cursor,
+ view,
+ doc,
+ kind: TriggerKind::TriggerChar,
+ });
+ }
+ CompletionEvent::ManualTrigger { cursor, doc, view } => {
+ // immediately request completions and drop all auto completion requests
+ self.request = None;
+ self.trigger = Some(Trigger {
+ pos: cursor,
+ view,
+ doc,
+ kind: TriggerKind::Manual,
+ });
+ // stop debouncing immediately and request the completion
+ self.finish_debounce();
+ return None;
+ }
+ CompletionEvent::Cancel => {
+ self.trigger = None;
+ self.request = None;
+ }
+ CompletionEvent::DeleteText { cursor } => {
+ // if we deleted the original trigger, abort the completion
+ if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) {
+ self.trigger = None;
+ self.request = None;
+ }
+ }
+ }
+ self.trigger.map(|trigger| {
+ // if the current request was closed forget about it
+ // otherwise immediately restart the completion request
+ let cancel = self.request.take().map_or(false, |req| !req.is_closed());
+ let timeout = if trigger.kind == TriggerKind::Auto && !cancel {
+ self.config.load().editor.completion_timeout
+ } else {
+ // we want almost instant completions for trigger chars
+ // and restarting completion requests. The small timeout here mainly
+ // serves to better handle cases where the completion handler
+ // may fall behind (so multiple events in the channel) and macros
+ Duration::from_millis(5)
+ };
+ Instant::now() + timeout
+ })
+ }
+
+ fn finish_debounce(&mut self) {
+ let trigger = self.trigger.take().expect("debounce always has a trigger");
+ let (tx, rx) = cancelation();
+ self.request = Some(tx);
+ dispatch_blocking(move |editor, compositor| {
+ request_completion(trigger, rx, editor, compositor)
+ });
+ }
+}
+
+fn request_completion(
+ mut trigger: Trigger,
+ cancel: CancelRx,
+ editor: &mut Editor,
+ compositor: &mut Compositor,
+) {
+ let (view, doc) = current!(editor);
+
+ if compositor
+ .find::<ui::EditorView>()
+ .unwrap()
+ .completion
+ .is_some()
+ || editor.mode != Mode::Insert
+ {
+ return;
+ }
+
+ let text = doc.text();
+ let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
+ if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
+ return;
+ }
+ // this looks odd... Why are we not using the trigger position from
+ // the `trigger` here? Won't that mean that the trigger char doesn't get
+ // send to the LS if we type fast enougn? Yes that is true but it's
+ // not actually a problem. The LSP will resolve the completion to the identifier
+ // anyway (in fact sending the later position is necessary to get the right results
+ // from LSPs that provide incomplete completion list). We rely on trigger offset
+ // and primary cursor matching for multi-cursor completions so this is definitely
+ // necessary from our side too.
+ trigger.pos = cursor;
+ let trigger_text = text.slice(..cursor);
+
+ let mut seen_language_servers = HashSet::new();
+ let mut futures: FuturesUnordered<_> = doc
+ .language_servers_with_feature(LanguageServerFeature::Completion)
+ .filter(|ls| seen_language_servers.insert(ls.id()))
+ .map(|ls| {
+ let language_server_id = ls.id();
+ let offset_encoding = ls.offset_encoding();
+ let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
+ let doc_id = doc.identifier();
+ let context = if trigger.kind == TriggerKind::Manual {
+ lsp::CompletionContext {
+ trigger_kind: lsp::CompletionTriggerKind::INVOKED,
+ trigger_character: None,
+ }
+ } else {
+ let trigger_char =
+ ls.capabilities()
+ .completion_provider
+ .as_ref()
+ .and_then(|provider| {
+ provider
+ .trigger_characters
+ .as_deref()?
+ .iter()
+ .find(|&trigger| trigger_text.ends_with(trigger))
+ });
+ lsp::CompletionContext {
+ trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
+ trigger_character: trigger_char.cloned(),
+ }
+ };
+
+ let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
+ async move {
+ let json = completion_response.await?;
+ let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
+ let items = match response {
+ Some(lsp::CompletionResponse::Array(items)) => items,
+ // TODO: do something with is_incomplete
+ Some(lsp::CompletionResponse::List(lsp::CompletionList {
+ is_incomplete: _is_incomplete,
+ items,
+ })) => items,
+ None => Vec::new(),
+ }
+ .into_iter()
+ .map(|item| CompletionItem {
+ item,
+ language_server_id,
+ resolved: false,
+ })
+ .collect();
+ anyhow::Ok(items)
+ }
+ })
+ .collect();
+
+ let future = async move {
+ let mut items = Vec::new();
+ while let Some(lsp_items) = futures.next().await {
+ match lsp_items {
+ Ok(mut lsp_items) => items.append(&mut lsp_items),
+ Err(err) => {
+ log::debug!("completion request failed: {err:?}");
+ }
+ };
+ }
+ items
+ };
+
+ let savepoint = doc.savepoint(view);
+
+ let ui = compositor.find::<ui::EditorView>().unwrap();
+ ui.last_insert.1.push(InsertEvent::RequestCompletion);
+ tokio::spawn(async move {
+ let items = cancelable_future(future, cancel).await.unwrap_or_default();
+ if items.is_empty() {
+ return;
+ }
+ dispatch(move |editor, compositor| {
+ show_completion(editor, compositor, items, trigger, savepoint)
+ })
+ .await
+ });
+}
+
+fn show_completion(
+ editor: &mut Editor,
+ compositor: &mut Compositor,
+ items: Vec<CompletionItem>,
+ trigger: Trigger,
+ savepoint: Arc<SavePoint>,
+) {
+ let (view, doc) = current_ref!(editor);
+ // check if the completion request is stale.
+ //
+ // Completions are completed asynchronously and therefore the user could
+ //switch document/view or leave insert mode. In all of thoise cases the
+ // completion should be discarded
+ if editor.mode != Mode::Insert || view.id != trigger.view || doc.id() != trigger.doc {
+ return;
+ }
+
+ let size = compositor.size();
+ let ui = compositor.find::<ui::EditorView>().unwrap();
+ if ui.completion.is_some() {
+ return;
+ }
+
+ let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
+ let signature_help_area = compositor
+ .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
+ .map(|signature_help| signature_help.area(size, editor));
+ // Delete the signature help popup if they intersect.
+ if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) {
+ compositor.remove(SignatureHelp::ID);
+ }
+}
+
+pub fn trigger_auto_completion(
+ tx: &Sender<CompletionEvent>,
+ editor: &Editor,
+ trigger_char_only: bool,
+) {
+ let config = editor.config.load();
+ if !config.auto_completion {
+ return;
+ }
+ let (view, doc): (&helix_view::View, &helix_view::Document) = current_ref!(editor);
+ let mut text = doc.text().slice(..);
+ let cursor = doc.selection(view.id).primary().cursor(text);
+ text = doc.text().slice(..cursor);
+
+ let is_trigger_char = doc
+ .language_servers_with_feature(LanguageServerFeature::Completion)
+ .any(|ls| {
+ matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
+ trigger_characters: Some(triggers),
+ ..
+ }) if triggers.iter().any(|trigger| text.ends_with(trigger)))
+ });
+ if is_trigger_char {
+ send_blocking(
+ tx,
+ CompletionEvent::TriggerChar {
+ cursor,
+ doc: doc.id(),
+ view: view.id,
+ },
+ );
+ return;
+ }
+
+ let is_auto_trigger = !trigger_char_only
+ && doc
+ .text()
+ .chars_at(cursor)
+ .reversed()
+ .take(config.completion_trigger_len as usize)
+ .all(char_is_word);
+
+ if is_auto_trigger {
+ send_blocking(
+ tx,
+ CompletionEvent::AutoTrigger {
+ cursor,
+ doc: doc.id(),
+ view: view.id,
+ },
+ );
+ }
+}
+
+fn update_completions(cx: &mut commands::Context, c: Option<char>) {
+ cx.callback.push(Box::new(move |compositor, cx| {
+ let editor_view = compositor.find::<ui::EditorView>().unwrap();
+ if let Some(completion) = &mut editor_view.completion {
+ completion.update_filter(c);
+ if completion.is_empty() {
+ editor_view.clear_completion(cx.editor);
+ // clearing completions might mean we want to immediately rerequest them (usually
+ // this occurs if typing a trigger char)
+ if c.is_some() {
+ trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
+ }
+ }
+ }
+ }))
+}
+
+fn clear_completions(cx: &mut commands::Context) {
+ cx.callback.push(Box::new(|compositor, cx| {
+ let editor_view = compositor.find::<ui::EditorView>().unwrap();
+ editor_view.clear_completion(cx.editor);
+ }))
+}
+
+fn completion_post_command_hook(
+ tx: &Sender<CompletionEvent>,
+ PostCommand { command, cx }: &mut PostCommand<'_, '_>,
+) -> anyhow::Result<()> {
+ if cx.editor.mode == Mode::Insert {
+ if cx.editor.last_completion.is_some() {
+ match command {
+ MappableCommand::Static {
+ name: "delete_word_forward" | "delete_char_forward" | "completion",
+ ..
+ } => (),
+ MappableCommand::Static {
+ name: "delete_char_backward",
+ ..
+ } => update_completions(cx, None),
+ _ => clear_completions(cx),
+ }
+ } else {
+ let event = match command {
+ MappableCommand::Static {
+ name: "delete_char_backward" | "delete_word_forward" | "delete_char_forward",
+ ..
+ } => {
+ let (view, doc) = current!(cx.editor);
+ let primary_cursor = doc
+ .selection(view.id)
+ .primary()
+ .cursor(doc.text().slice(..));
+ CompletionEvent::DeleteText {
+ cursor: primary_cursor,
+ }
+ }
+ // hacks: some commands are handeled elsewhere and we don't want to
+ // cancel in that case
+ MappableCommand::Static {
+ name: "completion" | "insert_mode" | "append_mode",
+ ..
+ } => return Ok(()),
+ _ => CompletionEvent::Cancel,
+ };
+ send_blocking(tx, event);
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+ let tx = handlers.completions.clone();
+ register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event));
+
+ let tx = handlers.completions.clone();
+ register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
+ if event.old_mode == Mode::Insert {
+ send_blocking(&tx, CompletionEvent::Cancel);
+ clear_completions(event.cx);
+ } else if event.new_mode == Mode::Insert {
+ trigger_auto_completion(&tx, event.cx.editor, false)
+ }
+ Ok(())
+ });
+
+ let tx = handlers.completions.clone();
+ register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
+ if event.cx.editor.last_completion.is_some() {
+ update_completions(event.cx, Some(event.c))
+ } else {
+ trigger_auto_completion(&tx, event.cx.editor, false);
+ }
+ Ok(())
+ });
+}
diff --git a/helix-term/src/handlers/signature_help.rs b/helix-term/src/handlers/signature_help.rs
new file mode 100644
index 00000000..3c746548
--- /dev/null
+++ b/helix-term/src/handlers/signature_help.rs
@@ -0,0 +1,335 @@
+use std::sync::Arc;
+use std::time::Duration;
+
+use helix_core::syntax::LanguageServerFeature;
+use helix_event::{
+ cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
+};
+use helix_lsp::lsp;
+use helix_stdx::rope::RopeSliceExt;
+use helix_view::document::Mode;
+use helix_view::events::{DocumentDidChange, SelectionDidChange};
+use helix_view::handlers::lsp::{SignatureHelpEvent, SignatureHelpInvoked};
+use helix_view::Editor;
+use tokio::sync::mpsc::Sender;
+use tokio::time::Instant;
+
+use crate::commands::Open;
+use crate::compositor::Compositor;
+use crate::events::{OnModeSwitch, PostInsertChar};
+use crate::handlers::Handlers;
+use crate::ui::lsp::SignatureHelp;
+use crate::ui::Popup;
+use crate::{job, ui};
+
+#[derive(Debug)]
+enum State {
+ Open,
+ Closed,
+ Pending { request: CancelTx },
+}
+
+/// debounce timeout in ms, value taken from VSCode
+/// TODO: make this configurable?
+const TIMEOUT: u64 = 120;
+
+#[derive(Debug)]
+pub(super) struct SignatureHelpHandler {
+ trigger: Option<SignatureHelpInvoked>,
+ state: State,
+}
+
+impl SignatureHelpHandler {
+ pub fn new() -> SignatureHelpHandler {
+ SignatureHelpHandler {
+ trigger: None,
+ state: State::Closed,
+ }
+ }
+}
+
+impl helix_event::AsyncHook for SignatureHelpHandler {
+ type Event = SignatureHelpEvent;
+
+ fn handle_event(
+ &mut self,
+ event: Self::Event,
+ timeout: Option<tokio::time::Instant>,
+ ) -> Option<Instant> {
+ match event {
+ SignatureHelpEvent::Invoked => {
+ self.trigger = Some(SignatureHelpInvoked::Manual);
+ self.state = State::Closed;
+ self.finish_debounce();
+ return None;
+ }
+ SignatureHelpEvent::Trigger => {}
+ SignatureHelpEvent::ReTrigger => {
+ // don't retrigger if we aren't open/pending yet
+ if matches!(self.state, State::Closed) {
+ return timeout;
+ }
+ }
+ SignatureHelpEvent::Cancel => {
+ self.state = State::Closed;
+ return None;
+ }
+ SignatureHelpEvent::RequestComplete { open } => {
+ // don't cancel rerequest that was already triggered
+ if let State::Pending { request } = &self.state {
+ if !request.is_closed() {
+ return timeout;
+ }
+ }
+ self.state = if open { State::Open } else { State::Closed };
+ return timeout;
+ }
+ }
+ if self.trigger.is_none() {
+ self.trigger = Some(SignatureHelpInvoked::Automatic)
+ }
+ Some(Instant::now() + Duration::from_millis(TIMEOUT))
+ }
+
+ fn finish_debounce(&mut self) {
+ let invocation = self.trigger.take().unwrap();
+ let (tx, rx) = cancelation();
+ self.state = State::Pending { request: tx };
+ job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx))
+ }
+}
+
+pub fn request_signature_help(
+ editor: &mut Editor,
+ invoked: SignatureHelpInvoked,
+ cancel: CancelRx,
+) {
+ let (view, doc) = current!(editor);
+
+ // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
+ let future = doc
+ .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
+ .find_map(|language_server| {
+ let pos = doc.position(view.id, language_server.offset_encoding());
+ language_server.text_document_signature_help(doc.identifier(), pos, None)
+ });
+
+ let Some(future) = future else {
+ // Do not show the message if signature help was invoked
+ // automatically on backspace, trigger characters, etc.
+ if invoked == SignatureHelpInvoked::Manual {
+ editor
+ .set_error("No configured language server supports signature-help");
+ }
+ return;
+ };
+
+ tokio::spawn(async move {
+ match cancelable_future(future, cancel).await {
+ Some(Ok(res)) => {
+ job::dispatch(move |editor, compositor| {
+ show_signature_help(editor, compositor, invoked, res)
+ })
+ .await
+ }
+ Some(Err(err)) => log::error!("signature help request failed: {err}"),
+ None => (),
+ }
+ });
+}
+
+pub fn show_signature_help(
+ editor: &mut Editor,
+ compositor: &mut Compositor,
+ invoked: SignatureHelpInvoked,
+ response: Option<lsp::SignatureHelp>,
+) {
+ let config = &editor.config();
+
+ if !(config.lsp.auto_signature_help
+ || SignatureHelp::visible_popup(compositor).is_some()
+ || invoked == SignatureHelpInvoked::Manual)
+ {
+ return;
+ }
+
+ // If the signature help invocation is automatic, don't show it outside of Insert Mode:
+ // it very probably means the server was a little slow to respond and the user has
+ // already moved on to something else, making a signature help popup will just be an
+ // annoyance, see https://github.com/helix-editor/helix/issues/3112
+ // For the most part this should not be needed as the request gets canceled automatically now
+ // but it's technically possible for the mode change to just preempt this callback so better safe than sorry
+ if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
+ return;
+ }
+
+ let response = match response {
+ // According to the spec the response should be None if there
+ // are no signatures, but some servers don't follow this.
+ Some(s) if !s.signatures.is_empty() => s,
+ _ => {
+ send_blocking(
+ &editor.handlers.signature_hints,
+ SignatureHelpEvent::RequestComplete { open: false },
+ );
+ compositor.remove(SignatureHelp::ID);
+ return;
+ }
+ };
+ send_blocking(
+ &editor.handlers.signature_hints,
+ SignatureHelpEvent::RequestComplete { open: true },
+ );
+
+ let doc = doc!(editor);
+ let language = doc.language_name().unwrap_or("");
+
+ let signature = match response
+ .signatures
+ .get(response.active_signature.unwrap_or(0) as usize)
+ {
+ Some(s) => s,
+ None => return,
+ };
+ let mut contents = SignatureHelp::new(
+ signature.label.clone(),
+ language.to_string(),
+ Arc::clone(&editor.syn_loader),
+ );
+
+ let signature_doc = if config.lsp.display_signature_help_docs {
+ signature.documentation.as_ref().map(|doc| match doc {
+ lsp::Documentation::String(s) => s.clone(),
+ lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
+ })
+ } else {
+ None
+ };
+
+ contents.set_signature_doc(signature_doc);
+
+ let active_param_range = || -> Option<(usize, usize)> {
+ let param_idx = signature
+ .active_parameter
+ .or(response.active_parameter)
+ .unwrap_or(0) as usize;
+ let param = signature.parameters.as_ref()?.get(param_idx)?;
+ match &param.label {
+ lsp::ParameterLabel::Simple(string) => {
+ let start = signature.label.find(string.as_str())?;
+ Some((start, start + string.len()))
+ }
+ lsp::ParameterLabel::LabelOffsets([start, end]) => {
+ // LS sends offsets based on utf-16 based string representation
+ // but highlighting in helix is done using byte offset.
+ use helix_core::str_utils::char_to_byte_idx;
+ let from = char_to_byte_idx(&signature.label, *start as usize);
+ let to = char_to_byte_idx(&signature.label, *end as usize);
+ Some((from, to))
+ }
+ }
+ };
+ contents.set_active_param_range(active_param_range());
+
+ let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
+ let mut popup = Popup::new(SignatureHelp::ID, contents)
+ .position(old_popup.and_then(|p| p.get_position()))
+ .position_bias(Open::Above)
+ .ignore_escape_key(true);
+
+ // Don't create a popup if it intersects the auto-complete menu.
+ let size = compositor.size();
+ if compositor
+ .find::<ui::EditorView>()
+ .unwrap()
+ .completion
+ .as_mut()
+ .map(|completion| completion.area(size, editor))
+ .filter(|area| area.intersects(popup.area(size, editor)))
+ .is_some()
+ {
+ return;
+ }
+
+ compositor.replace_or_push(SignatureHelp::ID, popup);
+}
+
+fn signature_help_post_insert_char_hook(
+ tx: &Sender<SignatureHelpEvent>,
+ PostInsertChar { cx, .. }: &mut PostInsertChar<'_, '_>,
+) -> anyhow::Result<()> {
+ if !cx.editor.config().lsp.auto_signature_help {
+ return Ok(());
+ }
+ let (view, doc) = current!(cx.editor);
+ // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
+ let Some(language_server) = doc
+ .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
+ .next()
+ else {
+ return Ok(());
+ };
+
+ let capabilities = language_server.capabilities();
+
+ if let lsp::ServerCapabilities {
+ signature_help_provider:
+ Some(lsp::SignatureHelpOptions {
+ trigger_characters: Some(triggers),
+ // TODO: retrigger_characters
+ ..
+ }),
+ ..
+ } = capabilities
+ {
+ let mut text = doc.text().slice(..);
+ let cursor = doc.selection(view.id).primary().cursor(text);
+ text = text.slice(..cursor);
+ if triggers.iter().any(|trigger| text.ends_with(trigger)) {
+ send_blocking(tx, SignatureHelpEvent::Trigger)
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+ let tx = handlers.signature_hints.clone();
+ register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
+ match (event.old_mode, event.new_mode) {
+ (Mode::Insert, _) => {
+ send_blocking(&tx, SignatureHelpEvent::Cancel);
+ event.cx.callback.push(Box::new(|compositor, _| {
+ compositor.remove(SignatureHelp::ID);
+ }));
+ }
+ (_, Mode::Insert) => {
+ if event.cx.editor.config().lsp.auto_signature_help {
+ send_blocking(&tx, SignatureHelpEvent::Trigger);
+ }
+ }
+ _ => (),
+ }
+ Ok(())
+ });
+
+ let tx = handlers.signature_hints.clone();
+ register_hook!(
+ move |event: &mut PostInsertChar<'_, '_>| signature_help_post_insert_char_hook(&tx, event)
+ );
+
+ let tx = handlers.signature_hints.clone();
+ register_hook!(move |event: &mut DocumentDidChange<'_>| {
+ if event.doc.config.load().lsp.auto_signature_help {
+ send_blocking(&tx, SignatureHelpEvent::ReTrigger);
+ }
+ Ok(())
+ });
+
+ let tx = handlers.signature_hints.clone();
+ register_hook!(move |event: &mut SelectionDidChange<'_>| {
+ if event.doc.config.load().lsp.auto_signature_help {
+ send_blocking(&tx, SignatureHelpEvent::ReTrigger);
+ }
+ Ok(())
+ });
+}
diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs
index 7c6a0055..48d97fbd 100644
--- a/helix-term/src/ui/completion.rs
+++ b/helix-term/src/ui/completion.rs
@@ -1,8 +1,12 @@
-use crate::compositor::{Component, Context, Event, EventResult};
+use crate::{
+ compositor::{Component, Context, Event, EventResult},
+ handlers::trigger_auto_completion,
+};
use helix_view::{
document::SavePoint,
editor::CompleteAction,
graphics::Margin,
+ handlers::lsp::SignatureHelpInvoked,
theme::{Modifier, Style},
ViewId,
};
@@ -10,7 +14,7 @@ use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc};
-use helix_core::{Change, Transaction};
+use helix_core::{chars, Change, Transaction};
use helix_view::{graphics::Rect, Document, Editor};
use crate::commands;
@@ -95,10 +99,9 @@ pub struct CompletionItem {
/// Wraps a Menu.
pub struct Completion {
popup: Popup<Menu<CompletionItem>>,
- start_offset: usize,
#[allow(dead_code)]
trigger_offset: usize,
- // TODO: maintain a completioncontext with trigger kind & trigger char
+ filter: String,
}
impl Completion {
@@ -108,7 +111,6 @@ impl Completion {
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
- start_offset: usize,
trigger_offset: usize,
) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert;
@@ -246,7 +248,7 @@ impl Completion {
// (also without sending the transaction to the LS) *before any further transaction is applied*.
// Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction
// is applied to).
- if editor.last_completion.is_none() {
+ if matches!(editor.last_completion, Some(CompleteAction::Triggered)) {
editor.last_completion = Some(CompleteAction::Selected {
savepoint: doc.savepoint(view),
})
@@ -324,8 +326,18 @@ impl Completion {
doc.apply(&transaction, view.id);
}
}
+ // we could have just inserted a trigger char (like a `crate::` completion for rust
+ // so we want to retrigger immediately when accepting a completion.
+ trigger_auto_completion(&editor.handlers.completions, editor, true);
}
};
+
+ // In case the popup was deleted because of an intersection w/ the auto-complete menu.
+ if event != PromptEvent::Update {
+ editor
+ .handlers
+ .trigger_signature_help(SignatureHelpInvoked::Automatic, editor);
+ }
});
let margin = if editor.menu_border() {
@@ -339,14 +351,30 @@ impl Completion {
.ignore_escape_key(true)
.margin(margin);
+ let (view, doc) = current_ref!(editor);
+ let text = doc.text().slice(..);
+ let cursor = doc.selection(view.id).primary().cursor(text);
+ let offset = text
+ .chars_at(cursor)
+ .reversed()
+ .take_while(|ch| chars::char_is_word(*ch))
+ .count();
+ let start_offset = cursor.saturating_sub(offset);
+
+ let fragment = doc.text().slice(start_offset..cursor);
let mut completion = Self {
popup,
- start_offset,
trigger_offset,
+ // TODO: expand nucleo api to allow moving straight to a Utf32String here
+ // and avoid allocation during matching
+ filter: String::from(fragment),
};
// need to recompute immediately in case start_offset != trigger_offset
- completion.recompute_filter(editor);
+ completion
+ .popup
+ .contents_mut()
+ .score(&completion.filter, false);
completion
}
@@ -366,39 +394,22 @@ impl Completion {
}
}
- pub fn recompute_filter(&mut self, editor: &Editor) {
+ /// Appends (`c: Some(c)`) or removes (`c: None`) a character to/from the filter
+ /// this should be called whenever the user types or deletes a character in insert mode.
+ pub fn update_filter(&mut self, c: Option<char>) {
// recompute menu based on matches
let menu = self.popup.contents_mut();
- let (view, doc) = current_ref!(editor);
-
- // cx.hooks()
- // cx.add_hook(enum type, ||)
- // cx.trigger_hook(enum type, &str, ...) <-- there has to be enough to identify doc/view
- // callback with editor & compositor
- //
- // trigger_hook sends event into channel, that's consumed in the global loop and
- // triggers all registered callbacks
- // TODO: hooks should get processed immediately so maybe do it after select!(), before
- // looping?
-
- let cursor = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
- if self.trigger_offset <= cursor {
- let fragment = doc.text().slice(self.start_offset..cursor);
- let text = Cow::from(fragment);
- // TODO: logic is same as ui/picker
- menu.score(&text);
- } else {
- // we backspaced before the start offset, clear the menu
- // this will cause the editor to remove the completion popup
- menu.clear();
+ match c {
+ Some(c) => self.filter.push(c),
+ None => {
+ self.filter.pop();
+ if self.filter.is_empty() {
+ menu.clear();
+ return;
+ }
+ }
}
- }
-
- pub fn update(&mut self, cx: &mut commands::Context) {
- self.recompute_filter(cx.editor)
+ menu.score(&self.filter, c.is_some());
}
pub fn is_empty(&self) -> bool {
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 9f186d14..fef62a29 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -1,7 +1,6 @@
use crate::{
commands::{self, OnKeyCallback},
compositor::{Component, Context, Event, EventResult},
- job::{self, Callback},
events::{OnModeSwitch, PostCommand},
key,
keymap::{KeymapResult, Keymaps},
@@ -34,8 +33,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use tui::{buffer::Buffer as Surface, text::Span};
+use super::document::LineDecoration;
use super::{completion::CompletionItem, statusline};
-use super::{document::LineDecoration, lsp::SignatureHelp};
pub struct EditorView {
pub keymaps: Keymaps,
@@ -837,11 +836,8 @@ impl EditorView {
let mut execute_command = |command: &commands::MappableCommand| {
command.execute(cxt);
helix_event::dispatch(PostCommand { command, cx: cxt });
+
let current_mode = cxt.editor.mode();
- match (last_mode, current_mode) {
- (Mode::Normal, Mode::Insert) => {
- // HAXX: if we just entered insert mode from normal, clear key buf
- // and record the command that got us into this mode.
if current_mode != last_mode {
helix_event::dispatch(OnModeSwitch {
old_mode: last_mode,
@@ -849,29 +845,16 @@ impl EditorView {
cx: cxt,
});
+ // HAXX: if we just entered insert mode from normal, clear key buf
+ // and record the command that got us into this mode.
+ if current_mode == Mode::Insert {
// how we entered insert mode is important, and we should track that so
// we can repeat the side effect.
self.last_insert.0 = command.clone();
self.last_insert.1.clear();
-
- commands::signature_help_impl(cxt, commands::SignatureHelpInvoked::Automatic);
- }
- (Mode::Insert, Mode::Normal) => {
- // if exiting insert mode, remove completion
- self.clear_completion(cxt.editor);
- cxt.editor.completion_request_handle = None;
-
- // TODO: Use an on_mode_change hook to remove signature help
- cxt.jobs.callback(async {
- let call: job::Callback =
- Callback::EditorCompositor(Box::new(|_editor, compositor| {
- compositor.remove(SignatureHelp::ID);
- }));
- Ok(call)
- });
}
- _ => (),
}
+
last_mode = current_mode;
};
@@ -999,12 +982,10 @@ impl EditorView {
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>,
- start_offset: usize,
trigger_offset: usize,
size: Rect,
) -> Option<Rect> {
- let mut completion =
- Completion::new(editor, savepoint, items, start_offset, trigger_offset);
+ let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
if completion.is_empty() {
// skip if we got no completion results
@@ -1025,6 +1006,7 @@ impl EditorView {
self.completion = None;
if let Some(last_completion) = editor.last_completion.take() {
match last_completion {
+ CompleteAction::Triggered => (),
CompleteAction::Applied {
trigger_offset,
changes,
@@ -1038,9 +1020,6 @@ impl EditorView {
}
}
}
-
- // Clear any savepoints
- editor.clear_idle_timer(); // don't retrigger
}
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@@ -1054,13 +1033,7 @@ impl EditorView {
};
}
- if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion {
- return EventResult::Ignored(None);
- }
-
- crate::commands::insert::idle_completion(cx);
-
- EventResult::Consumed(None)
+ EventResult::Ignored(None)
}
}
@@ -1346,12 +1319,6 @@ impl Component for EditorView {
if callback.is_some() {
// assume close_fn
self.clear_completion(cx.editor);
-
- // In case the popup was deleted because of an intersection w/ the auto-complete menu.
- commands::signature_help_impl(
- &mut cx,
- commands::SignatureHelpInvoked::Automatic,
- );
}
}
}
@@ -1362,14 +1329,6 @@ impl Component for EditorView {
// record last_insert key
self.last_insert.1.push(InsertEvent::Key(key));
-
- // lastly we recalculate completion
- if let Some(completion) = &mut self.completion {
- completion.update(&mut cx);
- if completion.is_empty() {
- self.clear_completion(cx.editor);
- }
- }
}
}
mode => self.command_mode(mode, &mut cx, key),
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index 0ee64ce9..64127e3a 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -96,20 +96,34 @@ impl<T: Item> Menu<T> {
}
}
- pub fn score(&mut self, pattern: &str) {
- // reuse the matches allocation
- self.matches.clear();
+ pub fn score(&mut self, pattern: &str, incremental: bool) {
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false);
let mut buf = Vec::new();
- let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
- let text = option.filter_text(&self.editor_data);
- pattern
- .score(Utf32Str::new(&text, &mut buf), &mut matcher)
- .map(|score| (i as u32, score as u32))
- });
- self.matches.extend(matches);
+ if incremental {
+ self.matches.retain_mut(|(index, score)| {
+ let option = &self.options[*index as usize];
+ let text = option.filter_text(&self.editor_data);
+ let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher);
+ match new_score {
+ Some(new_score) => {
+ *score = new_score as u32;
+ true
+ }
+ None => false,
+ }
+ })
+ } else {
+ self.matches.clear();
+ let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
+ let text = option.filter_text(&self.editor_data);
+ pattern
+ .score(Utf32Str::new(&text, &mut buf), &mut matcher)
+ .map(|score| (i as u32, score as u32))
+ });
+ self.matches.extend(matches);
+ }
self.matches
.sort_unstable_by_key(|&(i, score)| (Reverse(score), i));