diff --git a/.cargo/config.toml b/.cargo/config.toml index b016eca31aec..af4312dc8790 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,17 @@ +# we use tokio_unstable to enable runtime::Handle::id so we can separate +# globals from multiple parallel tests. If that function ever does get removed +# its possible to replace (with some additional overhead and effort) +# Annoyingly build.rustflags doesn't work here because it gets overwritten +# if people have their own global target.<..> config (for example to enable mold) +# specifying flags this way is more robust as they get merged +# This still gets overwritten by RUST_FLAGS though, luckily it shouldn't be necessary +# to set those most of the time. If downstream does overwrite this its not a huge +# deal since it will only break tests anyway +[target."cfg(all())"] +rustflags = ["--cfg", "tokio_unstable", "-C", "target-feature=-crt-static"] + + [alias] xtask = "run --package xtask --" integration-test = "test --features integration --profile integration --workspace --test integration" + diff --git a/Cargo.lock b/Cargo.lock index 9884dadfb64a..96496125a683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,12 @@ dependencies = [ name = "helix-event" version = "23.10.0" dependencies = [ + "ahash", + "anyhow", + "futures-executor", + "hashbrown 0.14.3", + "log", + "once_cell", "parking_lot", "tokio", ] @@ -1159,6 +1165,7 @@ version = "23.10.0" dependencies = [ "dunce", "etcetera", + "ropey", "tempfile", ] diff --git a/book/src/configuration.md b/book/src/configuration.md index 36e2fee2e420..a43ede76abff 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -51,7 +51,8 @@ Its settings will be merged with the configuration directory `config.toml` and t | `auto-completion` | Enable automatic pop up of auto-completion | `true` | | `auto-format` | Enable automatic formatting on save | `true` | | `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | -| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `250` | +| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` | +| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` | | `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | diff --git a/helix-event/Cargo.toml b/helix-event/Cargo.toml index c20328246fa3..a5c88e93d0fd 100644 --- a/helix-event/Cargo.toml +++ b/helix-event/Cargo.toml @@ -12,5 +12,18 @@ homepage.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot"] } -parking_lot = { version = "0.12", features = ["send_guard"] } +ahash = "0.8.3" +hashbrown = "0.14.0" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] } +# the event registry is essentially read only but must be an rwlock so we can +# setup new events on initialization, hardware-lock-elision hugely benefits this case +# as it essentially makes the lock entirely free as long as there is no writes +parking_lot = { version = "0.12", features = ["hardware-lock-elision"] } +once_cell = "1.18" + +anyhow = "1" +log = "0.4" +futures-executor = "0.3.28" + +[features] +integration_test = [] diff --git a/helix-event/src/cancel.rs b/helix-event/src/cancel.rs new file mode 100644 index 000000000000..f027be80e8de --- /dev/null +++ b/helix-event/src/cancel.rs @@ -0,0 +1,19 @@ +use std::future::Future; + +pub use oneshot::channel as cancelation; +use tokio::sync::oneshot; + +pub type CancelTx = oneshot::Sender<()>; +pub type CancelRx = oneshot::Receiver<()>; + +pub async fn cancelable_future(future: impl Future, cancel: CancelRx) -> Option { + tokio::select! { + biased; + _ = cancel => { + None + } + res = future => { + Some(res) + } + } +} diff --git a/helix-event/src/debounce.rs b/helix-event/src/debounce.rs new file mode 100644 index 000000000000..30b6f671be71 --- /dev/null +++ b/helix-event/src/debounce.rs @@ -0,0 +1,67 @@ +//! Utilities for declaring an async (usually debounced) hook + +use std::time::Duration; + +use futures_executor::block_on; +use tokio::sync::mpsc::{self, error::TrySendError, Sender}; +use tokio::time::Instant; + +/// Async hooks provide a convenient framework for implementing (debounced) +/// async event handlers. Most synchronous event hooks will likely need to +/// debounce their events, coordinate multiple different hooks and potentially +/// track some state. `AsyncHooks` facilitate these use cases by running as +/// a background tokio task that waits for events (usually an enum) to be +/// sent through a channel. +pub trait AsyncHook: Sync + Send + 'static + Sized { + type Event: Sync + Send + 'static; + /// Called immediately whenever an event is received, this function can + /// consume the event immediately or debounce it. In case of debouncing, + /// it can either define a new debounce timeout or continue the current one + fn handle_event(&mut self, event: Self::Event, timeout: Option) -> Option; + + /// Called whenever the debounce timeline is reached + fn finish_debounce(&mut self); + + fn spawn(self) -> mpsc::Sender { + // the capacity doesn't matter too much here, unless the cpu is totally overwhelmed + // the cap will never be reached since we always immediately drain the channel + // so it should only be reached in case of total CPU overload. + // However, a bounded channel is much more efficient so it's nice to use here + let (tx, rx) = mpsc::channel(128); + tokio::spawn(run(self, rx)); + tx + } +} + +async fn run(mut hook: Hook, mut rx: mpsc::Receiver) { + let mut deadline = None; + loop { + let event = match deadline { + Some(deadline_) => { + let res = tokio::time::timeout_at(deadline_, rx.recv()).await; + match res { + Ok(event) => event, + Err(_) => { + hook.finish_debounce(); + deadline = None; + continue; + } + } + } + None => rx.recv().await, + }; + let Some(event) = event else { + break; + }; + deadline = hook.handle_event(event, deadline); + } +} + +pub fn send_blocking(tx: &Sender, data: T) { + // block_on has some overhead and in practice the channel should basically + // never be full anyway so first try sending without blocking + if let Err(TrySendError::Full(data)) = tx.try_send(data) { + // set a timeout so that we just drop a message instead of freezing the editor in the worst case + let _ = block_on(tx.send_timeout(data, Duration::from_millis(10))); + } +} diff --git a/helix-event/src/hook.rs b/helix-event/src/hook.rs new file mode 100644 index 000000000000..7fb68148368d --- /dev/null +++ b/helix-event/src/hook.rs @@ -0,0 +1,91 @@ +//! rust dynamic dispatch is extremely limited so we have to build our +//! own vtable implementation. Otherwise implementing the event system would not be possible. +//! A nice bonus of this approach is that we can optimize the vtable a bit more. Normally +//! a dyn Trait fat pointer contains two pointers: A pointer to the data itself and a +//! pointer to a global (static) vtable entry which itself contains multiple other pointers +//! (the various functions of the trait, drop, size and align). That makes dynamic +//! dispatch pretty slow (double pointer indirections). However, we only have a single function +//! in the hook trait and don't need a drop implementation (event system is global anyway +//! and never dropped) so we can just store the entire vtable inline. + +use anyhow::Result; +use std::ptr::{self, NonNull}; + +use crate::Event; + +/// Opaque handle type that represents an erased type parameter. +/// +/// If extern types were stable, this could be implemented as `extern { pub type Opaque; }` but +/// until then we can use this. +/// +/// Care should be taken that we don't use a concrete instance of this. It should only be used +/// through a reference, so we can maintain something else's lifetime. +struct Opaque(()); + +pub(crate) struct ErasedHook { + data: NonNull, + call: unsafe fn(NonNull, NonNull, NonNull), +} + +impl ErasedHook { + pub(crate) fn new_dynamic Result<()> + 'static + Send + Sync>( + hook: H, + ) -> ErasedHook { + unsafe fn call Result<()> + 'static + Send + Sync>( + hook: NonNull, + _event: NonNull, + result: NonNull, + ) { + let hook: NonNull = hook.cast(); + let result: NonNull> = result.cast(); + let hook: &F = hook.as_ref(); + let res = hook(); + ptr::write(result.as_ptr(), res) + } + + unsafe { + ErasedHook { + data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque), + call: call::, + } + } + } + + pub(crate) fn new Result<()>>(hook: F) -> ErasedHook { + unsafe fn call Result<()>>( + hook: NonNull, + event: NonNull, + result: NonNull, + ) { + let hook: NonNull = hook.cast(); + let mut event: NonNull = event.cast(); + let result: NonNull> = result.cast(); + let hook: &F = hook.as_ref(); + let res = hook(event.as_mut()); + ptr::write(result.as_ptr(), res) + } + + unsafe { + ErasedHook { + data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque), + call: call::, + } + } + } + + pub(crate) unsafe fn call(&self, event: &mut E) -> Result<()> { + let mut res = Ok(()); + + unsafe { + (self.call)( + self.data, + NonNull::from(event).cast(), + NonNull::from(&mut res).cast(), + ); + } + res + } +} + +unsafe impl Sync for ErasedHook {} +unsafe impl Send for ErasedHook {} diff --git a/helix-event/src/lib.rs b/helix-event/src/lib.rs index 9c082b93a474..894de5e8d628 100644 --- a/helix-event/src/lib.rs +++ b/helix-event/src/lib.rs @@ -1,8 +1,203 @@ //! `helix-event` contains systems that allow (often async) communication between -//! different editor components without strongly coupling them. Currently this -//! crate only contains some smaller facilities but the intend is to add more -//! functionality in the future ( like a generic hook system) +//! different editor components without strongly coupling them. Specifically +//! it allows defining synchronous hooks that run when certain editor events +//! occur. +//! +//! The core of the event system are hook callbacks and the [`Event`] trait. A +//! hook is essentially just a closure `Fn(event: &mut impl Event) -> Result<()>` +//! that gets called every time an appropriate event is dispatched. The implementation +//! details of the [`Event`] trait are considered private. The [`events`] macro is +//! provided which automatically declares event types. Similarly the `register_hook` +//! macro should be used to (safely) declare event hooks. +//! +//! Hooks run synchronously which can be advantageous since they can modify the +//! current editor state right away (for example to immediately hide the completion +//! popup). However, they can not contain their own state without locking since +//! they only receive immutable references. For handler that want to track state, do +//! expensive background computations or debouncing an [`AsyncHook`] is preferable. +//! Async hooks are based around a channels that receive events specific to +//! that `AsyncHook` (usually an enum). These events can be sent by synchronous +//! hooks. Due to some limitations around tokio channels the [`send_blocking`] +//! function exported in this crate should be used instead of the builtin +//! `blocking_send`. +//! +//! In addition to the core event system, this crate contains some message queues +//! that allow transfer of data back to the main event loop from async hooks and +//! hooks that may not have access to all application data (for example in helix-view). +//! This include the ability to control rendering ([`lock_frame`], [`request_redraw`]) and +//! display status messages ([`status`]). +//! +//! Hooks declared in helix-term can furthermore dispatch synchronous jobs to be run on the +//! main loop (including access to the compositor). Ideally that queue will be moved +//! to helix-view in the future if we manage to detach the compositor from its rendering backend. +use anyhow::Result; +pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx}; +pub use debounce::{send_blocking, AsyncHook}; pub use redraw::{lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard}; +pub use registry::Event; +mod cancel; +mod debounce; +mod hook; mod redraw; +mod registry; +#[doc(hidden)] +pub mod runtime; +pub mod status; + +#[cfg(test)] +mod test; + +pub fn register_event() { + registry::with_mut(|registry| registry.register_event::()) +} + +/// Registers a hook that will be called when an event of type `E` is dispatched. +/// This function should usually not be used directly, use the [`register_hook`] +/// macro instead. +/// +/// +/// # Safety +/// +/// `hook` must be totally generic over all lifetime parameters of `E`. For +/// example if `E` was a known type `Foo<'a, 'b>`, then the correct trait bound +/// would be `F: for<'a, 'b, 'c> Fn(&'a mut Foo<'b, 'c>)`, but there is no way to +/// express that kind of constraint for a generic type with the Rust type system +/// as of this writing. +pub unsafe fn register_hook_raw( + hook: impl Fn(&mut E) -> Result<()> + 'static + Send + Sync, +) { + registry::with_mut(|registry| registry.register_hook(hook)) +} + +/// Register a hook solely by event name +pub fn register_dynamic_hook( + hook: impl Fn() -> Result<()> + 'static + Send + Sync, + id: &str, +) -> Result<()> { + registry::with_mut(|reg| reg.register_dynamic_hook(hook, id)) +} + +pub fn dispatch(e: impl Event) { + registry::with(|registry| registry.dispatch(e)); +} + +/// Macro to declare events +/// +/// # Examples +/// +/// ``` no-compile +/// events! { +/// FileWrite(&Path) +/// ViewScrolled{ view: View, new_pos: ViewOffset } +/// DocumentChanged<'a> { old_doc: &'a Rope, doc: &'a mut Document, changes: &'a ChangeSet } +/// } +/// +/// fn init() { +/// register_event::(); +/// register_event::(); +/// register_event::(); +/// } +/// +/// fn save(path: &Path, content: &str){ +/// std::fs::write(path, content); +/// dispatch(FileWrite(path)); +/// } +/// ``` +#[macro_export] +macro_rules! events { + ($name: ident<$($lt: lifetime),*> { $($data:ident : $data_ty:ty),* } $($rem:tt)*) => { + pub struct $name<$($lt),*> { $(pub $data: $data_ty),* } + unsafe impl<$($lt),*> $crate::Event for $name<$($lt),*> { + const ID: &'static str = stringify!($name); + const LIFETIMES: usize = $crate::events!(@sum $(1, $lt),*); + type Static = $crate::events!(@replace_lt $name, $('static, $lt),*); + } + $crate::events!{ $($rem)* } + }; + ($name: ident { $($data:ident : $data_ty:ty),* } $($rem:tt)*) => { + pub struct $name { $(pub $data: $data_ty),* } + unsafe impl $crate::Event for $name { + const ID: &'static str = stringify!($name); + const LIFETIMES: usize = 0; + type Static = Self; + } + $crate::events!{ $($rem)* } + }; + () => {}; + (@replace_lt $name: ident, $($lt1: lifetime, $lt2: lifetime),* ) => {$name<$($lt1),*>}; + (@sum $($val: expr, $lt1: lifetime),* ) => {0 $(+ $val)*}; +} + +/// Safely register statically typed event hooks +#[macro_export] +macro_rules! register_hook { + // Safety: this is safe because we fully control the type of the event here and + // ensure all lifetime arguments are fully generic and the correct number of lifetime arguments + // is present + (move |$event:ident: &mut $event_ty: ident<$($lt: lifetime),*>| $body: expr) => { + let val = move |$event: &mut $event_ty<$($lt),*>| $body; + unsafe { + // Lifetimes are a bit of a pain. We want to allow events being + // non-static. Lifetimes don't actually exist at runtime so its + // fine to essentially transmute the lifetimes as long as we can + // prove soundness. The hook must therefore accept any combination + // of lifetimes. In other words fn(&'_ mut Event<'_, '_>) is ok + // but examples like fn(&'_ mut Event<'_, 'static>) or fn<'a>(&'a + // mut Event<'a, 'a>) are not. To make this safe we use a macro to + // forbid the user from specifying lifetimes manually (all lifetimes + // specified are always function generics and passed to the event so + // lifetimes can't be used multiple times and using 'static causes a + // syntax error). + // + // There is one soundness hole tough: Type Aliases allow + // "accidentally" creating these problems. For example: + // + // type Event2 = Event<'static>. + // type Event2<'a> = Event<'a, a>. + // + // These cases can be caught by counting the number of lifetimes + // parameters at the parameter declaration site and then at the hook + // declaration site. By asserting the number of lifetime parameters + // are equal we can catch all bad type aliases under one assumption: + // There are no unused lifetime parameters. Introducing a static + // would reduce the number of arguments of the alias by one in the + // above example Event2 has zero lifetime arguments while the original + // event has one lifetime argument. Similar logic applies to using + // a lifetime argument multiple times. The ASSERT below performs a + // a compile time assertion to ensure exactly this property. + // + // With unused lifetime arguments it is still one way to cause unsound code: + // + // type Event2<'a, 'b> = Event<'a, 'a>; + // + // However, this case will always emit a compiler warning/cause CI + // failures so a user would have to introduce #[allow(unused)] which + // is easily caught in review (and a very theoretical case anyway). + // If we want to be pedantic we can simply compile helix with + // forbid(unused). All of this is just a safety net to prevent + // very theoretical misuse. This won't come up in real code (and is + // easily caught in review). + #[allow(unused)] + const ASSERT: () = { + if <$event_ty as $crate::Event>::LIFETIMES != 0 + $crate::events!(@sum $(1, $lt),*){ + panic!("invalid type alias"); + } + }; + $crate::register_hook_raw::<$crate::events!(@replace_lt $event_ty, $('static, $lt),*)>(val); + } + }; + (move |$event:ident: &mut $event_ty: ident| $body: expr) => { + let val = move |$event: &mut $event_ty| $body; + unsafe { + #[allow(unused)] + const ASSERT: () = { + if <$event_ty as $crate::Event>::LIFETIMES != 0{ + panic!("invalid type alias"); + } + }; + $crate::register_hook_raw::<$event_ty>(val); + } + }; +} diff --git a/helix-event/src/redraw.rs b/helix-event/src/redraw.rs index a9915223887b..8fadb8aeaa64 100644 --- a/helix-event/src/redraw.rs +++ b/helix-event/src/redraw.rs @@ -5,16 +5,20 @@ use std::future::Future; use parking_lot::{RwLock, RwLockReadGuard}; use tokio::sync::Notify; -/// A `Notify` instance that can be used to (asynchronously) request -/// the editor the render a new frame. -static REDRAW_NOTIFY: Notify = Notify::const_new(); - -/// A `RwLock` that prevents the next frame from being -/// drawn until an exclusive (write) lock can be acquired. -/// This allows asynchsonous tasks to acquire `non-exclusive` -/// locks (read) to prevent the next frame from being drawn -/// until a certain computation has finished. -static RENDER_LOCK: RwLock<()> = RwLock::new(()); +use crate::runtime_local; + +runtime_local! { + /// A `Notify` instance that can be used to (asynchronously) request + /// the editor to render a new frame. + static REDRAW_NOTIFY: Notify = Notify::const_new(); + + /// A `RwLock` that prevents the next frame from being + /// drawn until an exclusive (write) lock can be acquired. + /// This allows asynchronous tasks to acquire `non-exclusive` + /// locks (read) to prevent the next frame from being drawn + /// until a certain computation has finished. + static RENDER_LOCK: RwLock<()> = RwLock::new(()); +} pub type RenderLockGuard = RwLockReadGuard<'static, ()>; diff --git a/helix-event/src/registry.rs b/helix-event/src/registry.rs new file mode 100644 index 000000000000..d43c48ac4375 --- /dev/null +++ b/helix-event/src/registry.rs @@ -0,0 +1,131 @@ +//! A global registry where events are registered and can be +//! subscribed to by registering hooks. The registry identifies event +//! types using their type name so multiple event with the same type name +//! may not be registered (will cause a panic to ensure soundness) + +use std::any::TypeId; + +use anyhow::{bail, Result}; +use hashbrown::hash_map::Entry; +use hashbrown::HashMap; +use parking_lot::RwLock; + +use crate::hook::ErasedHook; +use crate::runtime_local; + +pub struct Registry { + events: HashMap<&'static str, TypeId, ahash::RandomState>, + handlers: HashMap<&'static str, Vec, ahash::RandomState>, +} + +impl Registry { + pub fn register_event(&mut self) { + let ty = TypeId::of::(); + assert_eq!(ty, TypeId::of::()); + match self.events.entry(E::ID) { + Entry::Occupied(entry) => { + if entry.get() == &ty { + // don't warn during tests to avoid log spam + #[cfg(not(feature = "integration_test"))] + panic!("Event {} was registered multiple times", E::ID); + } else { + panic!("Multiple events with ID {} were registered", E::ID); + } + } + Entry::Vacant(ent) => { + ent.insert(ty); + self.handlers.insert(E::ID, Vec::new()); + } + } + } + + /// # Safety + /// + /// `hook` must be totally generic over all lifetime parameters of `E`. For + /// example if `E` was a known type `Foo<'a, 'b> then the correct trait bound + /// would be `F: for<'a, 'b, 'c> Fn(&'a mut Foo<'b, 'c>)` but there is no way to + /// express that kind of constraint for a generic type with the rust type system + /// right now. + pub unsafe fn register_hook( + &mut self, + hook: impl Fn(&mut E) -> Result<()> + 'static + Send + Sync, + ) { + // ensure event type ids match so we can rely on them always matching + let id = E::ID; + let Some(&event_id) = self.events.get(id) else { + panic!("Tried to register handler for unknown event {id}"); + }; + assert!( + TypeId::of::() == event_id, + "Tried to register invalid hook for event {id}" + ); + let hook = ErasedHook::new(hook); + self.handlers.get_mut(id).unwrap().push(hook); + } + + pub fn register_dynamic_hook( + &mut self, + hook: impl Fn() -> Result<()> + 'static + Send + Sync, + id: &str, + ) -> Result<()> { + // ensure event type ids match so we can rely on them always matching + if self.events.get(id).is_none() { + bail!("Tried to register handler for unknown event {id}"); + }; + let hook = ErasedHook::new_dynamic(hook); + self.handlers.get_mut(id).unwrap().push(hook); + Ok(()) + } + + pub fn dispatch(&self, mut event: E) { + let Some(hooks) = self.handlers.get(E::ID) else { + log::error!("Dispatched unknown event {}", E::ID); + return; + }; + let event_id = self.events[E::ID]; + + assert_eq!( + TypeId::of::(), + event_id, + "Tried to dispatch invalid event {}", + E::ID + ); + + for hook in hooks { + // safety: event type is the same + if let Err(err) = unsafe { hook.call(&mut event) } { + log::error!("{} hook failed: {err:#?}", E::ID); + crate::status::report_blocking(err); + } + } + } +} + +runtime_local! { + static REGISTRY: RwLock = RwLock::new(Registry { + // hardcoded random number is good enough here we don't care about DOS resistance + // and avoids the additional complexity of `Option` + events: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 9978, 38322, 3280080)), + handlers: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 99078, 382322, 3282938)), + }); +} + +pub(crate) fn with(f: impl FnOnce(&Registry) -> T) -> T { + f(®ISTRY.read()) +} + +pub(crate) fn with_mut(f: impl FnOnce(&mut Registry) -> T) -> T { + f(&mut REGISTRY.write()) +} + +/// # Safety +/// The number of specified lifetimes and the static type *must* be correct. +/// This is ensured automatically by the [`events`](crate::events) +/// macro. +pub unsafe trait Event: Sized { + /// Globally unique (case sensitive) string that identifies this type. + /// A good candidate is the events type name + const ID: &'static str; + const LIFETIMES: usize; + type Static: Event + 'static; +} diff --git a/helix-event/src/runtime.rs b/helix-event/src/runtime.rs new file mode 100644 index 000000000000..8da465ef345d --- /dev/null +++ b/helix-event/src/runtime.rs @@ -0,0 +1,88 @@ +//! The event system makes use of global to decouple different systems. +//! However, this can cause problems for the integration test system because +//! it runs multiple helix applications in parallel. Making the globals +//! thread-local does not work because a applications can/does have multiple +//! runtime threads. Instead this crate implements a similar notion to a thread +//! local but instead of being local to a single thread, the statics are local to +//! a single tokio-runtime. The implementation requires locking so it's not exactly efficient. +//! +//! Therefore this function is only enabled during integration tests and behaves like +//! a normal static otherwise. I would prefer this module to be fully private and to only +//! export the macro but the macro still need to construct these internals so it's marked +//! `doc(hidden)` instead + +use std::ops::Deref; + +#[cfg(not(feature = "integration_test"))] +pub struct RuntimeLocal { + /// inner API used in the macro, not part of public API + #[doc(hidden)] + pub __data: T, +} + +#[cfg(not(feature = "integration_test"))] +impl Deref for RuntimeLocal { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.__data + } +} + +#[cfg(not(feature = "integration_test"))] +#[macro_export] +macro_rules! runtime_local { + ($($(#[$attr:meta])* $vis: vis static $name:ident: $ty: ty = $init: expr;)*) => { + $($(#[$attr])* $vis static $name: $crate::runtime::RuntimeLocal<$ty> = $crate::runtime::RuntimeLocal { + __data: $init + };)* + }; +} + +#[cfg(feature = "integration_test")] +pub struct RuntimeLocal { + data: + parking_lot::RwLock>, + init: fn() -> T, +} + +#[cfg(feature = "integration_test")] +impl RuntimeLocal { + /// inner API used in the macro, not part of public API + #[doc(hidden)] + pub const fn __new(init: fn() -> T) -> Self { + Self { + data: parking_lot::RwLock::new(hashbrown::HashMap::with_hasher( + ahash::RandomState::with_seeds(423, 9978, 38322, 3280080), + )), + init, + } + } +} + +#[cfg(feature = "integration_test")] +impl Deref for RuntimeLocal { + type Target = T; + fn deref(&self) -> &T { + let id = tokio::runtime::Handle::current().id(); + let guard = self.data.read(); + match guard.get(&id) { + Some(res) => res, + None => { + drop(guard); + let data = Box::leak(Box::new((self.init)())); + let mut guard = self.data.write(); + guard.insert(id, data); + data + } + } + } +} + +#[cfg(feature = "integration_test")] +#[macro_export] +macro_rules! runtime_local { + ($($(#[$attr:meta])* $vis: vis static $name:ident: $ty: ty = $init: expr;)*) => { + $($(#[$attr])* $vis static $name: $crate::runtime::RuntimeLocal<$ty> = $crate::runtime::RuntimeLocal::__new(|| $init);)* + }; +} diff --git a/helix-event/src/status.rs b/helix-event/src/status.rs new file mode 100644 index 000000000000..fdca676244cd --- /dev/null +++ b/helix-event/src/status.rs @@ -0,0 +1,68 @@ +//! A queue of async messages/errors that will be shown in the editor + +use std::borrow::Cow; +use std::time::Duration; + +use crate::{runtime_local, send_blocking}; +use once_cell::sync::OnceCell; +use tokio::sync::mpsc::{Receiver, Sender}; + +/// Describes the severity level of a [`StatusMessage`]. +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] +pub enum Severity { + Hint, + Info, + Warning, + Error, +} + +pub struct StatusMessage { + pub severity: Severity, + pub message: Cow<'static, str>, +} + +impl From for StatusMessage { + fn from(err: anyhow::Error) -> Self { + StatusMessage { + severity: Severity::Error, + message: err.to_string().into(), + } + } +} + +impl From<&'static str> for StatusMessage { + fn from(msg: &'static str) -> Self { + StatusMessage { + severity: Severity::Info, + message: msg.into(), + } + } +} + +runtime_local! { + static MESSAGES: OnceCell> = OnceCell::new(); +} + +pub async fn report(msg: impl Into) { + // if the error channel overflows just ignore it + let _ = MESSAGES + .wait() + .send_timeout(msg.into(), Duration::from_millis(10)) + .await; +} + +pub fn report_blocking(msg: impl Into) { + let messages = MESSAGES.wait(); + send_blocking(messages, msg.into()) +} + +/// Must be called once during editor startup exactly once +/// before any of the messages in this module can be used +/// +/// # Panics +/// If called multiple times +pub fn setup() -> Receiver { + let (tx, rx) = tokio::sync::mpsc::channel(128); + let _ = MESSAGES.set(tx); + rx +} diff --git a/helix-event/src/test.rs b/helix-event/src/test.rs new file mode 100644 index 000000000000..a1283ada1074 --- /dev/null +++ b/helix-event/src/test.rs @@ -0,0 +1,90 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; + +use crate::{dispatch, events, register_dynamic_hook, register_event, register_hook}; +#[test] +fn smoke_test() { + events! { + Event1 { content: String } + Event2 { content: usize } + } + register_event::(); + register_event::(); + + // setup hooks + let res1: Arc> = Arc::default(); + let acc = Arc::clone(&res1); + register_hook!(move |event: &mut Event1| { + acc.lock().push_str(&event.content); + Ok(()) + }); + let res2: Arc = Arc::default(); + let acc = Arc::clone(&res2); + register_hook!(move |event: &mut Event2| { + acc.fetch_add(event.content, Ordering::Relaxed); + Ok(()) + }); + + // triggers events + let thread = std::thread::spawn(|| { + for i in 0..1000 { + dispatch(Event2 { content: i }); + } + }); + std::thread::sleep(Duration::from_millis(1)); + dispatch(Event1 { + content: "foo".to_owned(), + }); + dispatch(Event2 { content: 42 }); + dispatch(Event1 { + content: "bar".to_owned(), + }); + dispatch(Event1 { + content: "hello world".to_owned(), + }); + thread.join().unwrap(); + + // check output + assert_eq!(&**res1.lock(), "foobarhello world"); + assert_eq!( + res2.load(Ordering::Relaxed), + 42 + (0..1000usize).sum::() + ); +} + +#[test] +fn dynamic() { + events! { + Event3 {} + Event4 { count: usize } + }; + register_event::(); + register_event::(); + + let count = Arc::new(AtomicUsize::new(0)); + let count1 = count.clone(); + let count2 = count.clone(); + register_dynamic_hook( + move || { + count1.fetch_add(2, Ordering::Relaxed); + Ok(()) + }, + "Event3", + ) + .unwrap(); + register_dynamic_hook( + move || { + count2.fetch_add(3, Ordering::Relaxed); + Ok(()) + }, + "Event4", + ) + .unwrap(); + dispatch(Event3 {}); + dispatch(Event4 { count: 0 }); + dispatch(Event3 {}); + assert_eq!(count.load(Ordering::Relaxed), 7) +} diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 1af27c1d42c7..7eef2bf7a521 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -9,7 +9,7 @@ use helix_loader::{self, VERSION_AND_GIT_HASH}; use helix_stdx::path; use lsp::{ notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, - DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, WorkspaceFolder, + DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, WorkspaceFolder, WorkspaceFoldersChangeEvent, }; use lsp_types as lsp; @@ -999,6 +999,7 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, + context: lsp::CompletionContext, ) -> Option>> { let capabilities = self.capabilities.get().unwrap(); @@ -1010,13 +1011,12 @@ impl Client { text_document, position, }, + context: Some(context), // TODO: support these tokens by async receiving and updating the choice list work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, partial_result_params: lsp::PartialResultParams { partial_result_token: None, }, - context: None, - // lsp::CompletionContext { trigger_kind: , trigger_character: Some(), } }; Some(self.call::(params)) @@ -1063,7 +1063,7 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> Option>> { + ) -> Option>>> { let capabilities = self.capabilities.get().unwrap(); // Return early if the server does not support signature help. @@ -1079,7 +1079,8 @@ impl Client { // lsp::SignatureHelpContext }; - Some(self.call::(params)) + let res = self.call::(params); + Some(async move { Ok(serde_json::from_value(res.await?)?) }) } pub fn text_document_range_inlay_hints( diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index 216a3b407e7c..9b4de9fef927 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true [dependencies] dunce = "1.0" etcetera = "0.8" +ropey = { version = "1.6.1", default-features = false } [dev-dependencies] tempfile = "3.9" diff --git a/helix-stdx/src/lib.rs b/helix-stdx/src/lib.rs index ae3c3a981501..68fe3ec37702 100644 --- a/helix-stdx/src/lib.rs +++ b/helix-stdx/src/lib.rs @@ -1,2 +1,3 @@ pub mod env; pub mod path; +pub mod rope; diff --git a/helix-stdx/src/rope.rs b/helix-stdx/src/rope.rs new file mode 100644 index 000000000000..4ee39d4a897a --- /dev/null +++ b/helix-stdx/src/rope.rs @@ -0,0 +1,26 @@ +use ropey::RopeSlice; + +pub trait RopeSliceExt: Sized { + fn ends_with(self, text: &str) -> bool; + fn starts_with(self, text: &str) -> bool; +} + +impl RopeSliceExt for RopeSlice<'_> { + fn ends_with(self, text: &str) -> bool { + let len = self.len_bytes(); + if len < text.len() { + return false; + } + self.get_byte_slice(len - text.len()..) + .map_or(false, |end| end == text) + } + + fn starts_with(self, text: &str) -> bool { + let len = self.len_bytes(); + if len < text.len() { + return false; + } + self.get_byte_slice(..len - text.len()) + .map_or(false, |start| start == text) + } +} diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 21c35553cb8b..7bdd433ed2b7 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -15,7 +15,7 @@ homepage.workspace = true [features] default = ["git"] unicode-lines = ["helix-core/unicode-lines"] -integration = [] +integration = ["helix-event/integration_test"] git = ["helix-vcs/git"] [[bin]] diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 290441b41715..3f3e59c6a26c 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,6 +1,6 @@ use arc_swap::{access::Map, ArcSwap}; use futures_util::Stream; -use helix_core::{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, @@ -24,6 +24,7 @@ use crate::{ commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, + handlers, job::Jobs, keymap::Keymaps, ui::{self, overlay::overlaid}, @@ -138,6 +139,7 @@ impl Application { let area = terminal.size().expect("couldn't get terminal size"); let mut compositor = Compositor::new(area); let config = Arc::new(ArcSwap::from_pointee(config)); + let handlers = handlers::setup(config.clone()); let mut editor = Editor::new( area, theme_loader.clone(), @@ -145,6 +147,7 @@ impl Application { Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), + handlers, ); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { @@ -321,10 +324,21 @@ impl Application { Some(event) = input_stream.next() => { self.handle_terminal_events(event).await; } - Some(callback) = self.jobs.futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + Some(callback) = self.jobs.callbacks.recv() => { + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))); self.render().await; } + Some(msg) = self.jobs.status_messages.recv() => { + let severity = match msg.severity{ + helix_event::status::Severity::Hint => Severity::Hint, + helix_event::status::Severity::Info => Severity::Info, + helix_event::status::Severity::Warning => Severity::Warning, + helix_event::status::Severity::Error => Severity::Error, + }; + // TODO: show multiple status messages at once to avoid clobbering + self.editor.status_msg = Some((msg.message, severity)); + helix_event::request_redraw(); + } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render().await; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 53783e4e3024..4df3278b8bba 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, @@ -88,7 +83,7 @@ pub struct Context<'a> { pub count: Option, pub editor: &'a mut Editor, - pub callback: Option, + pub callback: Vec, pub on_next_key_callback: Option, pub jobs: &'a mut Jobs, } @@ -96,16 +91,18 @@ pub struct Context<'a> { impl<'a> Context<'a> { /// Push a new component onto the compositor. pub fn push_layer(&mut self, component: Box) { - self.callback = Some(Box::new(|compositor: &mut Compositor, _| { - compositor.push(component) - })); + self.callback + .push(Box::new(|compositor: &mut Compositor, _| { + compositor.push(component) + })); } /// Call `replace_or_push` on the Compositor pub fn replace_or_push_layer(&mut self, id: &'static str, component: T) { - self.callback = Some(Box::new(move |compositor: &mut Compositor, _| { - compositor.replace_or_push(id, component); - })); + self.callback + .push(Box::new(move |compositor: &mut Compositor, _| { + compositor.replace_or_push(id, component); + })); } #[inline] @@ -2591,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) { @@ -2665,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 @@ -2934,7 +2926,7 @@ pub fn command_palette(cx: &mut Context) { let register = cx.register; let count = cx.count; - cx.callback = Some(Box::new( + cx.callback.push(Box::new( move |compositor: &mut Compositor, cx: &mut compositor::Context| { let keymap = compositor.find::().unwrap().keymaps.map() [&cx.editor.mode] @@ -2954,7 +2946,7 @@ pub fn command_palette(cx: &mut Context) { register, count, editor: cx.editor, - callback: None, + callback: Vec::new(), on_next_key_callback: None, jobs: cx.jobs, }; @@ -2982,7 +2974,7 @@ pub fn command_palette(cx: &mut Context) { fn last_picker(cx: &mut Context) { // TODO: last picker does not seem to work well with buffer_picker - cx.callback = Some(Box::new(|compositor, cx| { + cx.callback.push(Box::new(|compositor, cx| { if let Some(picker) = compositor.last_picker.take() { compositor.push(picker); } else { @@ -3494,9 +3486,10 @@ 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; - pub type PostHook = fn(&mut Context, char); /// Exclude the cursor in range. fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { @@ -3510,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 { @@ -3621,12 +3532,7 @@ 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 }); } pub fn smart_tab(cx: &mut Context) { @@ -3851,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) { @@ -4506,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 = 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::().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::().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::>(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 @@ -4829,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(); } }; @@ -5820,7 +5583,7 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.push(reg); let count = cx.count(); - cx.callback = Some(Box::new(move |compositor, cx| { + cx.callback.push(Box::new(move |compositor, cx| { for _ in 0..count { for &key in keys.iter() { compositor.handle_event(&compositor::Event::Key(key), cx); diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 051cdcd35e5d..de2f0e5ec289 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>, - invoked: SignatureHelpInvoked, -) { - cx.callback( - future, - move |editor, compositor, response: Option| { - 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 ¶m.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::>(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::() - .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/events.rs b/helix-term/src/events.rs new file mode 100644 index 000000000000..49b44f775088 --- /dev/null +++ b/helix-term/src/events.rs @@ -0,0 +1,20 @@ +use helix_event::{events, register_event}; +use helix_view::document::Mode; +use helix_view::events::{DocumentDidChange, SelectionDidChange}; + +use crate::commands; +use crate::keymap::MappableCommand; + +events! { + OnModeSwitch<'a, 'cx> { old_mode: Mode, new_mode: Mode, cx: &'a mut commands::Context<'cx> } + PostInsertChar<'a, 'cx> { c: char, cx: &'a mut commands::Context<'cx> } + PostCommand<'a, 'cx> { command: & 'a MappableCommand, cx: &'a mut commands::Context<'cx> } +} + +pub fn register() { + register_event::(); + register_event::(); + register_event::(); + register_event::(); + register_event::(); +} diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs new file mode 100644 index 000000000000..ef5369f8505e --- /dev/null +++ b/helix-term/src/handlers.rs @@ -0,0 +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>) -> 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 000000000000..d71fd24fc4ea --- /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, + /// 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, + config: Arc>, +} + +impl CompletionHandler { + pub fn new(config: Arc>) -> 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, + ) -> Option { + 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::() + .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 = 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::().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, + trigger: Trigger, + savepoint: Arc, +) { + 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::().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::>(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, + 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) { + cx.callback.push(Box::new(move |compositor, cx| { + let editor_view = compositor.find::().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::().unwrap(); + editor_view.clear_completion(cx.editor); + })) +} + +fn completion_post_command_hook( + tx: &Sender, + 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 000000000000..3c746548ac8c --- /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, + 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, + ) -> Option { + 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, +) { + 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 ¶m.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::>(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::() + .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, + 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/job.rs b/helix-term/src/job.rs index 19f2521a5231..72ed892ddf9a 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -1,13 +1,37 @@ +use helix_event::status::StatusMessage; +use helix_event::{runtime_local, send_blocking}; use helix_view::Editor; +use once_cell::sync::OnceCell; use crate::compositor::Compositor; use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::stream::{FuturesUnordered, StreamExt}; +use tokio::sync::mpsc::{channel, Receiver, Sender}; pub type EditorCompositorCallback = Box; pub type EditorCallback = Box; +runtime_local! { + static JOB_QUEUE: OnceCell> = OnceCell::new(); +} + +pub async fn dispatch_callback(job: Callback) { + let _ = JOB_QUEUE.wait().send(job).await; +} + +pub async fn dispatch(job: impl FnOnce(&mut Editor, &mut Compositor) + Send + 'static) { + let _ = JOB_QUEUE + .wait() + .send(Callback::EditorCompositor(Box::new(job))) + .await; +} + +pub fn dispatch_blocking(job: impl FnOnce(&mut Editor, &mut Compositor) + Send + 'static) { + let jobs = JOB_QUEUE.wait(); + send_blocking(jobs, Callback::EditorCompositor(Box::new(job))) +} + pub enum Callback { EditorCompositor(EditorCompositorCallback), Editor(EditorCallback), @@ -21,11 +45,11 @@ pub struct Job { pub wait: bool, } -#[derive(Default)] pub struct Jobs { - pub futures: FuturesUnordered, - /// These are the ones that need to complete before we exit. + /// jobs that need to complete before we exit. pub wait_futures: FuturesUnordered, + pub callbacks: Receiver, + pub status_messages: Receiver, } impl Job { @@ -52,8 +76,16 @@ impl Job { } impl Jobs { + #[allow(clippy::new_without_default)] pub fn new() -> Self { - Self::default() + let (tx, rx) = channel(1024); + let _ = JOB_QUEUE.set(tx); + let status_messages = helix_event::status::setup(); + Self { + wait_futures: FuturesUnordered::new(), + callbacks: rx, + status_messages, + } } pub fn spawn> + Send + 'static>(&mut self, f: F) { @@ -85,18 +117,17 @@ impl Jobs { } } - pub async fn next_job(&mut self) -> Option>> { - tokio::select! { - event = self.futures.next() => { event } - event = self.wait_futures.next() => { event } - } - } - pub fn add(&self, j: Job) { if j.wait { self.wait_futures.push(j.future); } else { - self.futures.push(j.future); + tokio::spawn(async move { + match j.future.await { + Ok(Some(cb)) => dispatch_callback(cb).await, + Ok(None) => (), + Err(err) => helix_event::status::report(err).await, + } + }); } } diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index a1d603298ec7..b1413ed0d972 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -6,13 +6,17 @@ pub mod args; pub mod commands; pub mod compositor; pub mod config; +pub mod events; pub mod health; pub mod job; pub mod keymap; pub mod ui; + use std::path::Path; use futures_util::Future; +mod handlers; + use ignore::DirEntry; use url::Url; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 7c6a0055ea48..48d97fbd8e23 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>, - 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, mut items: Vec, - 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) { // 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 24fcdb014ffa..fef62a292910 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1,7 +1,7 @@ use crate::{ commands::{self, OnKeyCallback}, compositor::{Component, Context, Event, EventResult}, - job::{self, Callback}, + events::{OnModeSwitch, PostCommand}, key, keymap::{KeymapResult, Keymaps}, ui::{ @@ -33,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, @@ -835,35 +835,26 @@ 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, + new_mode: current_mode, + 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; }; @@ -991,12 +982,10 @@ impl EditorView { editor: &mut Editor, savepoint: Arc, items: Vec, - start_offset: usize, trigger_offset: usize, size: Rect, ) -> Option { - 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 @@ -1004,7 +993,7 @@ impl EditorView { } let area = completion.area(size, editor); - editor.last_completion = None; + editor.last_completion = Some(CompleteAction::Triggered); self.last_insert.1.push(InsertEvent::TriggerCompletion); // TODO : propagate required size on resize to completion too @@ -1017,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, @@ -1030,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 { @@ -1046,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) } } @@ -1265,7 +1246,7 @@ impl Component for EditorView { editor: context.editor, count: None, register: None, - callback: None, + callback: Vec::new(), on_next_key_callback: None, jobs: context.jobs, }; @@ -1338,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, - ); } } } @@ -1354,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), @@ -1375,7 +1342,7 @@ impl Component for EditorView { } // appease borrowck - let callback = cx.callback.take(); + let callbacks = take(&mut cx.callback); // if the command consumed the last view, skip the render. // on the next loop cycle the Application will then terminate. @@ -1394,6 +1361,16 @@ impl Component for EditorView { if mode != Mode::Insert { doc.append_changes_to_history(view); } + let callback = if callbacks.is_empty() { + None + } else { + let callback: crate::compositor::Callback = Box::new(move |compositor, cx| { + for callback in callbacks { + callback(compositor, cx) + } + }); + Some(callback) + }; EventResult::Consumed(callback) } diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 0ee64ce9e031..64127e3af8b3 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -96,20 +96,34 @@ impl Menu { } } - 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)); diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 6473c2d18530..388810b13621 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -36,6 +36,7 @@ use helix_core::{ }; use crate::editor::Config; +use crate::events::{DocumentDidChange, SelectionDidChange}; use crate::{DocumentId, Editor, Theme, View, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. @@ -114,19 +115,6 @@ pub struct SavePoint { /// The view this savepoint is associated with pub view: ViewId, revert: Mutex, - pub text: Rope, -} - -impl SavePoint { - pub fn cursor(&self) -> usize { - // we always create transactions with selections - self.revert - .lock() - .selection() - .unwrap() - .primary() - .cursor(self.text.slice(..)) - } } pub struct Document { @@ -1096,6 +1084,10 @@ impl Document { // TODO: use a transaction? self.selections .insert(view_id, selection.ensure_invariants(self.text().slice(..))); + helix_event::dispatch(SelectionDidChange { + doc: self, + view: view_id, + }) } /// Find the origin selection of the text in a document, i.e. where @@ -1149,6 +1141,14 @@ impl Document { let success = transaction.changes().apply(&mut self.text); if success { + if emit_lsp_notification { + helix_event::dispatch(DocumentDidChange { + doc: self, + view: view_id, + old_text: &old_doc, + }); + } + for selection in self.selections.values_mut() { *selection = selection .clone() @@ -1164,6 +1164,10 @@ impl Document { view_id, selection.clone().ensure_invariants(self.text.slice(..)), ); + helix_event::dispatch(SelectionDidChange { + doc: self, + view: view_id, + }); } self.modified_since_accessed = true; @@ -1276,6 +1280,7 @@ impl Document { } if emit_lsp_notification { + // TODO: move to hook // emit lsp notification for language_server in self.language_servers() { let notify = language_server.text_document_did_change( @@ -1386,7 +1391,6 @@ impl Document { let savepoint = Arc::new(SavePoint { view: view.id, revert: Mutex::new(revert), - text: self.text.clone(), }); self.savepoints.push(Arc::downgrade(&savepoint)); savepoint diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 0ab4be8b2fa5..dc10a6044185 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -2,6 +2,7 @@ use crate::{ align_view, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, graphics::{CursorKind, Rect}, + handlers::Handlers, info::Info, input::KeyEvent, register::Registers, @@ -30,10 +31,7 @@ use std::{ }; use tokio::{ - sync::{ - mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, - oneshot, - }, + sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, time::{sleep, Duration, Instant, Sleep}, }; @@ -243,12 +241,19 @@ pub struct Config { /// Set a global text_width pub text_width: usize, /// Time in milliseconds since last keypress before idle timers trigger. - /// Used for autocompletion, set to 0 for instant. Defaults to 250ms. + /// Used for various UI timeouts. Defaults to 250ms. #[serde( serialize_with = "serialize_duration_millis", deserialize_with = "deserialize_duration_millis" )] pub idle_timeout: Duration, + /// Time in milliseconds after typing a word character before auto completions + /// are shown, set to 5 for instant. Defaults to 250ms. + #[serde( + serialize_with = "serialize_duration_millis", + deserialize_with = "deserialize_duration_millis" + )] + pub completion_timeout: Duration, /// Whether to insert the completion suggestion on hover. Defaults to true. pub preview_completion_insert: bool, pub completion_trigger_len: u8, @@ -828,6 +833,7 @@ impl Default for Config { auto_format: true, auto_save: false, idle_timeout: Duration::from_millis(250), + completion_timeout: Duration::from_millis(250), preview_completion_insert: true, completion_trigger_len: 2, auto_info: true, @@ -952,14 +958,7 @@ pub struct Editor { /// avoid calculating the cursor position multiple /// times during rendering and should not be set by other functions. pub cursor_cache: Cell>>, - /// When a new completion request is sent to the server old - /// unfinished request must be dropped. Each completion - /// request is associated with a channel that cancels - /// when the channel is dropped. That channel is stored - /// here. When a new completion request is sent this - /// field is set and any old requests are automatically - /// canceled as a result - pub completion_request_handle: Option>, + pub handlers: Handlers, } pub type Motion = Box; @@ -987,13 +986,16 @@ enum ThemeAction { #[derive(Debug, Clone)] pub enum CompleteAction { + Triggered, + /// A savepoint of the currently selected completion. The savepoint + /// MUST be restored before sending any event to the LSP + Selected { + savepoint: Arc, + }, Applied { trigger_offset: usize, changes: Vec, }, - /// A savepoint of the currently selected completion. The savepoint - /// MUST be restored before sending any event to the LSP - Selected { savepoint: Arc }, } #[derive(Debug, Copy, Clone)] @@ -1027,6 +1029,7 @@ impl Editor { theme_loader: Arc, syn_loader: Arc, config: Arc>, + handlers: Handlers, ) -> Self { let language_servers = helix_lsp::Registry::new(syn_loader.clone()); let conf = config.load(); @@ -1071,7 +1074,7 @@ impl Editor { config_events: unbounded_channel(), needs_redraw: false, cursor_cache: Cell::new(None), - completion_request_handle: None, + handlers, } } diff --git a/helix-view/src/events.rs b/helix-view/src/events.rs new file mode 100644 index 000000000000..8b789cc0d21e --- /dev/null +++ b/helix-view/src/events.rs @@ -0,0 +1,9 @@ +use helix_core::Rope; +use helix_event::events; + +use crate::{Document, ViewId}; + +events! { + DocumentDidChange<'a> { doc: &'a mut Document, view: ViewId, old_text: &'a Rope } + SelectionDidChange<'a> { doc: &'a mut Document, view: ViewId } +} diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs new file mode 100644 index 000000000000..724e7b1921ca --- /dev/null +++ b/helix-view/src/handlers.rs @@ -0,0 +1,41 @@ +use helix_event::send_blocking; +use tokio::sync::mpsc::Sender; + +use crate::handlers::lsp::SignatureHelpInvoked; +use crate::{DocumentId, Editor, ViewId}; + +pub mod dap; +pub mod lsp; + +pub struct Handlers { + // only public because most of the actual implementation is in helix-term right now :/ + pub completions: Sender, + pub signature_hints: Sender, +} + +impl Handlers { + /// Manually trigger completion (c-x) + pub fn trigger_completions(&self, trigger_pos: usize, doc: DocumentId, view: ViewId) { + send_blocking( + &self.completions, + lsp::CompletionEvent::ManualTrigger { + cursor: trigger_pos, + doc, + view, + }, + ); + } + + pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) { + let event = match invocation { + SignatureHelpInvoked::Automatic => { + if !editor.config().lsp.auto_signature_help { + return; + } + lsp::SignatureHelpEvent::Trigger + } + SignatureHelpInvoked::Manual => lsp::SignatureHelpEvent::Invoked, + }; + send_blocking(&self.signature_hints, event) + } +} diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 8b137891791f..1dae45dd50cb 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -1 +1,41 @@ +use crate::{DocumentId, ViewId}; +pub enum CompletionEvent { + /// Auto completion was triggered by typing a word char + AutoTrigger { + cursor: usize, + doc: DocumentId, + view: ViewId, + }, + /// Auto completion was triggered by typing a trigger char + /// specified by the LSP + TriggerChar { + cursor: usize, + doc: DocumentId, + view: ViewId, + }, + /// A completion was manually requested (c-x) + ManualTrigger { + cursor: usize, + doc: DocumentId, + view: ViewId, + }, + /// Some text was deleted and the cursor is now at `pos` + DeleteText { cursor: usize }, + /// Invalidate the current auto completion trigger + Cancel, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum SignatureHelpInvoked { + Automatic, + Manual, +} + +pub enum SignatureHelpEvent { + Invoked, + Trigger, + ReTrigger, + Cancel, + RequestComplete { open: bool }, +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 6a68e7d6f9bd..82827b5d53df 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -1,17 +1,15 @@ #[macro_use] pub mod macros; +pub mod base64; pub mod clipboard; pub mod document; pub mod editor; pub mod env; +pub mod events; pub mod graphics; pub mod gutter; -pub mod handlers { - pub mod dap; - pub mod lsp; -} -pub mod base64; +pub mod handlers; pub mod info; pub mod input; pub mod keyboard;