From 15f88ad7bf7d971ca85aaff82ae2c27639db3fbb Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Thu, 17 Aug 2023 02:45:17 +0200 Subject: [PATCH] Add hook/event system --- .cargo/config.toml | 14 +++ Cargo.lock | 15 +++ helix-event/Cargo.toml | 17 +++- helix-event/src/cancel.rs | 19 ++++ helix-event/src/debounce.rs | 67 ++++++++++++++ helix-event/src/hook.rs | 91 ++++++++++++++++++ helix-event/src/lib.rs | 162 ++++++++++++++++++++++++++++++++- helix-event/src/redraw.rs | 24 +++-- helix-event/src/registry.rs | 127 ++++++++++++++++++++++++++ helix-event/src/runtime.rs | 88 ++++++++++++++++++ helix-event/src/status.rs | 68 ++++++++++++++ helix-event/src/test.rs | 90 ++++++++++++++++++ helix-term/Cargo.toml | 2 +- helix-term/src/application.rs | 20 +++- helix-term/src/commands.rs | 26 +++--- helix-term/src/events.rs | 20 ++++ helix-term/src/handlers.rs | 15 +++ helix-term/src/job.rs | 55 ++++++++--- helix-term/src/lib.rs | 4 + helix-term/src/ui/editor.rs | 24 ++++- helix-view/src/document.rs | 18 ++++ helix-view/src/editor.rs | 2 + helix-view/src/events.rs | 9 ++ helix-view/src/handlers.rs | 12 +++ helix-view/src/handlers/lsp.rs | 39 ++++++++ helix-view/src/lib.rs | 8 +- 26 files changed, 986 insertions(+), 50 deletions(-) create mode 100644 helix-event/src/cancel.rs create mode 100644 helix-event/src/debounce.rs create mode 100644 helix-event/src/hook.rs create mode 100644 helix-event/src/registry.rs create mode 100644 helix-event/src/runtime.rs create mode 100644 helix-event/src/status.rs create mode 100644 helix-event/src/test.rs create mode 100644 helix-term/src/events.rs create mode 100644 helix-term/src/handlers.rs create mode 100644 helix-view/src/events.rs create mode 100644 helix-view/src/handlers.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index b016eca31..ab4741fff 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 seperate +# gloablsfrom mul1tiple 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 examble to enable mold) +# specificying flags this way is more robust as they get merged +# This still gets overwritten by RUST_FLAGS tough, 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 2b0527c3a..0d491e049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,6 +1227,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", +] + [[package]] name = "hashbrown" version = "0.14.0" @@ -1291,6 +1300,12 @@ dependencies = [ name = "helix-event" version = "0.6.0" dependencies = [ + "ahash 0.8.3", + "anyhow", + "futures-executor", + "hashbrown 0.13.2", + "log", + "once_cell", "parking_lot", "tokio", ] diff --git a/helix-event/Cargo.toml b/helix-event/Cargo.toml index 5cd955588..2065c1141 100644 --- a/helix-event/Cargo.toml +++ b/helix-event/Cargo.toml @@ -11,5 +11,18 @@ homepage = "https://helix-editor.com" # 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.13.2" +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 intalization, hardware-lock-elision hugnly benefits this case +# as is 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 000000000..f027be80e --- /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 000000000..052e0e2cf --- /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 usecases by running as +/// a background tokio task that waits for events (usually an enum) to be +/// sent trough 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 sine 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 its 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 ovherhead 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 000000000..ac486f7d4 --- /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 annd 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 9c082b93a..e7854c244 100644 --- a/helix-event/src/lib.rs +++ b/helix-event/src/lib.rs @@ -1,8 +1,164 @@ //! `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 approriate 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 send by synchronous +//! [`Hook`]s. Due to some limtations 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 delclare 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::(); +/// 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),* } + 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),* } + 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 { + #[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 a99152238..72817d57c 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 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(()); +} pub type RenderLockGuard = RwLockReadGuard<'static, ()>; diff --git a/helix-event/src/registry.rs b/helix-event/src/registry.rs new file mode 100644 index 000000000..9b2547ecb --- /dev/null +++ b/helix-event/src/registry.rs @@ -0,0 +1,127 @@ +//! 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()) +} + +pub 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 000000000..bbeabfa1f --- /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 its 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 its 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 000000000..fdca67624 --- /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 000000000..a1283ada1 --- /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-term/Cargo.toml b/helix-term/Cargo.toml index 5c7fd0dea..7be20444e 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -15,7 +15,7 @@ rust-version = "1.65" [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 3e6da5bbd..1a0014ab8 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,7 +1,7 @@ use arc_swap::{access::Map, ArcSwap}; use futures_util::Stream; use helix_core::{ - diagnostic::{DiagnosticTag, NumberOrString}, + diagnostic::{DiagnosticTag, NumberOrString, Severity}, path::get_relative_path, pos_at_coords, syntax, Selection, }; @@ -27,6 +27,7 @@ use crate::{ commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, + handlers, job::Jobs, keymap::Keymaps, ui::{self, overlay::overlaid}, @@ -141,6 +142,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(), @@ -148,6 +150,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| { @@ -318,10 +321,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 0fd011cc9..2dc2d5442 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -82,7 +82,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, } @@ -90,16 +90,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] @@ -2874,7 +2876,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] @@ -2894,7 +2896,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, }; @@ -2922,7 +2924,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 { @@ -3430,6 +3432,7 @@ 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); @@ -3563,6 +3566,7 @@ pub mod insert { for hook in &[language_server_completion, signature_help] { hook(cx, c); } + helix_event::dispatch(PostInsertChar { c, cx }); } pub fn smart_tab(cx: &mut Context) { @@ -5731,7 +5735,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/events.rs b/helix-term/src/events.rs new file mode 100644 index 000000000..49b44f775 --- /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 000000000..ab2d724ff --- /dev/null +++ b/helix-term/src/handlers.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::config::Config; +use crate::events; + + + } +pub fn setup(config: Arc>) -> Handlers { + events::register(); + let handlers = Handlers { + }; + handlers +} diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index 19f2521a5..bb85c3520 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 the ones 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 2f6ec12b1..ddf5e168c 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -6,12 +6,16 @@ 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; +mod handlers; + use ignore::DirEntry; pub use keymap::macros::*; diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index dd6fff087..0fe9df961 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -2,6 +2,7 @@ use crate::{ commands::{self, OnKeyCallback}, compositor::{Component, Context, Event, EventResult}, job::{self, Callback}, + events::{OnModeSwitch, PostCommand}, key, keymap::{KeymapResult, Keymaps}, ui::{ @@ -809,11 +810,18 @@ 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, + }); // how we entered insert mode is important, and we should track that so // we can repeat the side effect. @@ -978,7 +986,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 @@ -1239,7 +1247,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, }; @@ -1351,7 +1359,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. @@ -1374,6 +1382,16 @@ impl Component for EditorView { 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-view/src/document.rs b/helix-view/src/document.rs index bb61eaa6a..043206058 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -34,6 +34,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. @@ -1102,6 +1103,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 @@ -1155,6 +1160,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() @@ -1170,6 +1183,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; @@ -1260,6 +1277,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( diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 6963867af..25af9351f 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, @@ -947,6 +948,7 @@ pub struct Editor { /// 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; diff --git a/helix-view/src/events.rs b/helix-view/src/events.rs new file mode 100644 index 000000000..8b789cc0d --- /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 000000000..ae3eb545a --- /dev/null +++ b/helix-view/src/handlers.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; + +use helix_event::send_blocking; +use tokio::sync::mpsc::Sender; + +use crate::handlers::lsp::SignatureHelpInvoked; +use crate::Editor; + +pub mod dap; +pub mod lsp; + +pub struct Handlers {} diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 8b1378917..958385646 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -1 +1,40 @@ +use crate::{DocumentId, ViewId}; +#[derive(Debug, Clone, Copy)] +pub struct CompletionTrigger { + /// The char position of the primary cursor when the + /// completion was triggered + pub trigger_pos: usize, + pub doc: DocumentId, + pub view: ViewId, + /// Whether the cause of the trigger was an automatic completion (any word + /// char for words longer than minimum word length). + /// This is false for trigger chars send by the LS + pub auto: bool, +} + +pub enum CompletionEvent { + /// Auto completion was triggered by typing a word char + /// or a completion trigger + Trigger(CompletionTrigger), + /// A completion was manually requested (c-x) + Manual, + /// Some text was deleted and the cursor is now at `pos` + DeleteText { pos: 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 6a68e7d6f..82827b5d5 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;