diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f683eb84..f6475e3d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Unreleased` header. - Add `CustomCursor` - Add `CustomCursor::from_rgba` to allow creating cursor images from RGBA data. - Add `CustomCursorExtWebSys::from_url` to allow loading cursor images from URLs. + - Add `CustomCursorExtWebSys::from_animation` to allow creating animated cursors from other `CustomCursor`s. - On macOS, add services menu. - **Breaking:** On Web, remove queuing fullscreen request in absence of transient activation. - On Web, fix setting cursor icon overriding cursor visibility. diff --git a/Cargo.toml b/Cargo.toml index b74698d96d..c2075c9792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -205,6 +205,7 @@ features = [ 'console', 'CssStyleDeclaration', 'Document', + 'DomException', 'DomRect', 'DomRectReadOnly', 'Element', @@ -240,12 +241,16 @@ features = [ ] [target.'cfg(target_family = "wasm")'.dependencies] -atomic-waker = "1" js-sys = "0.3.64" +pin-project = "1" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-time = "0.2" +[target.'cfg(all(target_family = "wasm", target_feature = "atomics"))'.dependencies] +atomic-waker = "1" +concurrent-queue = { version = "2", default-features = false } + [target.'cfg(target_family = "wasm")'.dev-dependencies] console_log = "1" web-sys = { version = "0.3.22", features = ['CanvasRenderingContext2d'] } diff --git a/examples/custom_cursors.rs b/examples/custom_cursors.rs index 4c47f362d7..6f4cbb824d 100644 --- a/examples/custom_cursors.rs +++ b/examples/custom_cursors.rs @@ -8,6 +8,15 @@ use winit::{ keyboard::Key, window::{CursorIcon, CustomCursor, WindowBuilder}, }; +#[cfg(wasm_platform)] +use { + std::sync::atomic::{AtomicU64, Ordering}, + std::time::Duration, + winit::platform::web::CustomCursorExtWebSys, +}; + +#[cfg(wasm_platform)] +static COUNTER: AtomicU64 = AtomicU64::new(0); fn decode_cursor(bytes: &[u8], window_target: &EventLoopWindowTarget) -> CustomCursor { let img = image::load_from_memory(bytes).unwrap().to_rgba8(); @@ -74,6 +83,45 @@ fn main() -> Result<(), impl std::error::Error> { log::debug!("Setting cursor visibility to {:?}", cursor_visible); window.set_cursor_visible(cursor_visible); } + #[cfg(wasm_platform)] + Key::Character("4") => { + log::debug!("Setting cursor to a random image from an URL"); + window.set_cursor( + CustomCursor::from_url( + format!( + "https://picsum.photos/128?random={}", + COUNTER.fetch_add(1, Ordering::Relaxed) + ), + 64, + 64, + ) + .build(_elwt), + ); + } + #[cfg(wasm_platform)] + Key::Character("5") => { + log::debug!("Setting cursor to an animation"); + window.set_cursor( + CustomCursor::from_animation( + Duration::from_secs(3), + vec![ + custom_cursors[0].clone(), + custom_cursors[1].clone(), + CustomCursor::from_url( + format!( + "https://picsum.photos/128?random={}", + COUNTER.fetch_add(1, Ordering::Relaxed) + ), + 64, + 64, + ) + .build(_elwt), + ], + ) + .unwrap() + .build(_elwt), + ); + } _ => {} }, WindowEvent::RedrawRequested => { diff --git a/src/platform/web.rs b/src/platform/web.rs index f24eb51287..b1f030c45d 100644 --- a/src/platform/web.rs +++ b/src/platform/web.rs @@ -27,17 +27,24 @@ //! [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border //! [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding -use crate::cursor::CustomCursorBuilder; -use crate::event::Event; -use crate::event_loop::EventLoop; -use crate::event_loop::EventLoopWindowTarget; -use crate::platform_impl::PlatformCustomCursorBuilder; -use crate::window::CustomCursor; -use crate::window::{Window, WindowBuilder}; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; #[cfg(wasm_platform)] use web_sys::HtmlCanvasElement; +use crate::cursor::CustomCursorBuilder; +use crate::event::Event; +use crate::event_loop::{EventLoop, EventLoopWindowTarget}; +#[cfg(wasm_platform)] +use crate::platform_impl::CustomCursorFuture as PlatformCustomCursorFuture; +use crate::platform_impl::{PlatformCustomCursor, PlatformCustomCursorBuilder}; +use crate::window::{CustomCursor, Window, WindowBuilder}; + #[cfg(not(wasm_platform))] #[doc(hidden)] pub struct HtmlCanvasElement; @@ -234,15 +241,29 @@ pub enum PollStrategy { } pub trait CustomCursorExtWebSys { + /// Returns if this cursor is an animation. + fn is_animation(&self) -> bool; + /// Creates a new cursor from a URL pointing to an image. /// It uses the [url css function](https://developer.mozilla.org/en-US/docs/Web/CSS/url), /// but browser support for image formats is inconsistent. Using [PNG] is recommended. /// /// [PNG]: https://en.wikipedia.org/wiki/PNG fn from_url(url: String, hotspot_x: u16, hotspot_y: u16) -> CustomCursorBuilder; + + /// Crates a new animated cursor from multiple [`CustomCursor`]s. + /// Supplied `cursors` can't be empty or other animations. + fn from_animation( + duration: Duration, + cursors: Vec, + ) -> Result; } impl CustomCursorExtWebSys for CustomCursor { + fn is_animation(&self) -> bool { + self.inner.animation + } + fn from_url(url: String, hotspot_x: u16, hotspot_y: u16) -> CustomCursorBuilder { CustomCursorBuilder { inner: PlatformCustomCursorBuilder::Url { @@ -252,4 +273,92 @@ impl CustomCursorExtWebSys for CustomCursor { }, } } + + fn from_animation( + duration: Duration, + cursors: Vec, + ) -> Result { + if cursors.is_empty() { + return Err(BadAnimation::Empty); + } + + if cursors.iter().any(CustomCursor::is_animation) { + return Err(BadAnimation::Animation); + } + + Ok(CustomCursorBuilder { + inner: PlatformCustomCursorBuilder::Animation { duration, cursors }, + }) + } +} + +/// An error produced when using [`CustomCursor::from_animation`] with invalid arguments. +#[derive(Debug, Clone)] +pub enum BadAnimation { + /// Produced when no cursors were supplied. + Empty, + /// Produced when a supplied cursor is an animation. + Animation, +} + +impl fmt::Display for BadAnimation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "No cursors supplied"), + Self::Animation => write!(f, "A supplied cursor is an animtion"), + } + } +} + +impl Error for BadAnimation {} + +pub trait CustomCursorBuilderExtWebSys { + /// Async version of [`CustomCursorBuilder::build()`] which waits until the + /// cursor has completely finished loading. + fn build_async(self, window_target: &EventLoopWindowTarget) -> CustomCursorFuture; +} + +impl CustomCursorBuilderExtWebSys for CustomCursorBuilder { + fn build_async(self, window_target: &EventLoopWindowTarget) -> CustomCursorFuture { + CustomCursorFuture(PlatformCustomCursor::build_async( + self.inner, + &window_target.p, + )) + } +} + +#[cfg(not(wasm_platform))] +struct PlatformCustomCursorFuture; + +#[derive(Debug)] +pub struct CustomCursorFuture(PlatformCustomCursorFuture); + +impl Future for CustomCursorFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut self.0) + .poll(cx) + .map_ok(|cursor| CustomCursor { inner: cursor }) + } +} + +#[derive(Clone, Debug)] +pub enum CustomCursorError { + Blob, + Decode(String), + Animation, +} + +impl Display for CustomCursorError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Blob => write!(f, "failed to create `Blob`"), + Self::Decode(error) => write!(f, "failed to decode image: {error}"), + Self::Animation => write!( + f, + "found `CustomCursor` that is an animation when building an animation" + ), + } + } } diff --git a/src/platform_impl/web/async/abortable.rs b/src/platform_impl/web/async/abortable.rs new file mode 100644 index 0000000000..2608fc4046 --- /dev/null +++ b/src/platform_impl/web/async/abortable.rs @@ -0,0 +1,102 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use pin_project::pin_project; + +use super::AtomicWaker; + +#[pin_project] +pub struct Abortable { + #[pin] + future: F, + shared: Arc, +} + +impl Abortable { + pub fn new(handle: AbortHandle, future: F) -> Self { + Self { + future, + shared: handle.0, + } + } +} + +impl Future for Abortable { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.shared.aborted.load(Ordering::Relaxed) { + return Poll::Ready(Err(Aborted)); + } + + if let Poll::Ready(value) = self.as_mut().project().future.poll(cx) { + return Poll::Ready(Ok(value)); + } + + self.shared.waker.register(cx.waker()); + + if self.shared.aborted.load(Ordering::Relaxed) { + return Poll::Ready(Err(Aborted)); + } + + Poll::Pending + } +} + +#[derive(Debug)] +struct Shared { + waker: AtomicWaker, + aborted: AtomicBool, +} + +#[derive(Clone, Debug)] +pub struct AbortHandle(Arc); + +impl AbortHandle { + pub fn new() -> Self { + Self(Arc::new(Shared { + waker: AtomicWaker::new(), + aborted: AtomicBool::new(false), + })) + } + + pub fn abort(&self) { + self.0.aborted.store(true, Ordering::Relaxed); + self.0.waker.wake() + } +} + +#[derive(Debug)] +pub struct DropAbortHandle(AbortHandle); + +impl DropAbortHandle { + pub fn new(handle: AbortHandle) -> Self { + Self(handle) + } + + pub fn handle(&self) -> AbortHandle { + self.0.clone() + } +} + +impl Drop for DropAbortHandle { + fn drop(&mut self) { + self.0.abort() + } +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct Aborted; + +impl Display for Aborted { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "`Abortable` future has been aborted") + } +} + +impl Error for Aborted {} diff --git a/src/platform_impl/web/async/atomic_waker.rs b/src/platform_impl/web/async/atomic_waker.rs new file mode 100644 index 0000000000..87b836121e --- /dev/null +++ b/src/platform_impl/web/async/atomic_waker.rs @@ -0,0 +1,35 @@ +use std::cell::RefCell; +use std::ops::Deref; +use std::task::Waker; + +#[derive(Debug)] +pub struct AtomicWaker(RefCell>); + +impl AtomicWaker { + pub const fn new() -> Self { + Self(RefCell::new(None)) + } + + pub fn register(&self, waker: &Waker) { + let mut this = self.0.borrow_mut(); + + if let Some(old_waker) = this.deref() { + if old_waker.will_wake(waker) { + return; + } + } + + *this = Some(waker.clone()); + } + + pub fn wake(&self) { + if let Some(waker) = self.0.borrow_mut().take() { + waker.wake(); + } + } +} + +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Send for AtomicWaker {} +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Sync for AtomicWaker {} diff --git a/src/platform_impl/web/async/channel.rs b/src/platform_impl/web/async/channel.rs index 0592a20822..6b313f32e7 100644 --- a/src/platform_impl/web/async/channel.rs +++ b/src/platform_impl/web/async/channel.rs @@ -1,23 +1,24 @@ -use atomic_waker::AtomicWaker; use std::future; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{self, Receiver, RecvError, SendError, Sender, TryRecvError}; +use std::sync::mpsc::{self, RecvError, SendError, TryRecvError}; use std::sync::{Arc, Mutex}; use std::task::Poll; -pub fn channel() -> (AsyncSender, AsyncReceiver) { +use super::AtomicWaker; + +pub fn channel() -> (Sender, Receiver) { let (sender, receiver) = mpsc::channel(); let shared = Arc::new(Shared { closed: AtomicBool::new(false), waker: AtomicWaker::new(), }); - let sender = AsyncSender(Arc::new(SenderInner { + let sender = Sender(Arc::new(SenderInner { sender: Mutex::new(sender), shared: Arc::clone(&shared), })); - let receiver = AsyncReceiver { + let receiver = Receiver { receiver: Rc::new(receiver), shared, }; @@ -25,18 +26,18 @@ pub fn channel() -> (AsyncSender, AsyncReceiver) { (sender, receiver) } -pub struct AsyncSender(Arc>); +pub struct Sender(Arc>); struct SenderInner { // We need to wrap it into a `Mutex` to make it `Sync`. So the sender can't // be accessed on the main thread, as it could block. Additionally we need // to wrap `Sender` in an `Arc` to make it clonable on the main thread without // having to block. - sender: Mutex>, + sender: Mutex>, shared: Arc, } -impl AsyncSender { +impl Sender { pub fn send(&self, event: T) -> Result<(), SendError> { self.0.sender.lock().unwrap().send(event)?; self.0.shared.waker.wake(); @@ -52,7 +53,7 @@ impl SenderInner { } } -impl Clone for AsyncSender { +impl Clone for Sender { fn clone(&self) -> Self { Self(Arc::clone(&self.0)) } @@ -64,12 +65,12 @@ impl Drop for SenderInner { } } -pub struct AsyncReceiver { - receiver: Rc>, +pub struct Receiver { + receiver: Rc>, shared: Arc, } -impl AsyncReceiver { +impl Receiver { pub async fn next(&self) -> Result { future::poll_fn(|cx| match self.receiver.try_recv() { Ok(event) => Poll::Ready(Ok(event)), @@ -102,7 +103,7 @@ impl AsyncReceiver { } } -impl Clone for AsyncReceiver { +impl Clone for Receiver { fn clone(&self) -> Self { Self { receiver: Rc::clone(&self.receiver), @@ -111,7 +112,7 @@ impl Clone for AsyncReceiver { } } -impl Drop for AsyncReceiver { +impl Drop for Receiver { fn drop(&mut self) { self.shared.closed.store(true, Ordering::Relaxed); } diff --git a/src/platform_impl/web/async/concurrent_queue.rs b/src/platform_impl/web/async/concurrent_queue.rs new file mode 100644 index 0000000000..2d7efacd50 --- /dev/null +++ b/src/platform_impl/web/async/concurrent_queue.rs @@ -0,0 +1,55 @@ +use std::cell::{Cell, RefCell}; + +#[derive(Debug)] +pub struct ConcurrentQueue { + queue: RefCell>, + closed: Cell, +} + +pub enum PushError { + #[allow(dead_code)] + Full(T), + Closed(T), +} + +pub enum PopError { + Empty, + Closed, +} + +impl ConcurrentQueue { + pub fn unbounded() -> Self { + Self { + queue: RefCell::new(Vec::new()), + closed: Cell::new(false), + } + } + + pub fn push(&self, value: T) -> Result<(), PushError> { + if self.closed.get() { + return Err(PushError::Closed(value)); + } + + self.queue.borrow_mut().push(value); + Ok(()) + } + + pub fn pop(&self) -> Result { + self.queue.borrow_mut().pop().ok_or_else(|| { + if self.closed.get() { + PopError::Closed + } else { + PopError::Empty + } + }) + } + + pub fn close(&self) -> bool { + !self.closed.replace(true) + } +} + +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Send for ConcurrentQueue {} +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Sync for ConcurrentQueue {} diff --git a/src/platform_impl/web/async/dispatcher.rs b/src/platform_impl/web/async/dispatcher.rs index 9ec9b6a365..f7307d9f3a 100644 --- a/src/platform_impl/web/async/dispatcher.rs +++ b/src/platform_impl/web/async/dispatcher.rs @@ -1,11 +1,11 @@ use super::super::main_thread::MainThreadMarker; -use super::{channel, AsyncReceiver, AsyncSender, Wrapper}; +use super::{channel, Receiver, Sender, Wrapper}; use std::{ cell::Ref, sync::{Arc, Condvar, Mutex}, }; -pub struct Dispatcher(Wrapper>, Closure>); +pub struct Dispatcher(Wrapper>, Closure>); struct Closure(Box); @@ -85,8 +85,8 @@ impl Dispatcher { } pub struct DispatchRunner { - wrapper: Wrapper>, Closure>, - receiver: AsyncReceiver>, + wrapper: Wrapper>, Closure>, + receiver: Receiver>, } impl DispatchRunner { diff --git a/src/platform_impl/web/async/mod.rs b/src/platform_impl/web/async/mod.rs index b4fe698daf..4681cf9240 100644 --- a/src/platform_impl/web/async/mod.rs +++ b/src/platform_impl/web/async/mod.rs @@ -1,9 +1,19 @@ +mod abortable; +#[cfg(not(target_feature = "atomics"))] +mod atomic_waker; mod channel; +#[cfg(not(target_feature = "atomics"))] +mod concurrent_queue; mod dispatcher; +mod notifier; mod waker; mod wrapper; -pub use self::channel::{channel, AsyncReceiver, AsyncSender}; +pub use self::abortable::{AbortHandle, Abortable, DropAbortHandle}; +pub use self::channel::{channel, Receiver, Sender}; pub use self::dispatcher::{DispatchRunner, Dispatcher}; +pub use self::notifier::{Notified, Notifier}; pub use self::waker::{Waker, WakerSpawner}; use self::wrapper::Wrapper; +use atomic_waker::AtomicWaker; +use concurrent_queue::{ConcurrentQueue, PushError}; diff --git a/src/platform_impl/web/async/notifier.rs b/src/platform_impl/web/async/notifier.rs new file mode 100644 index 0000000000..6387b7e5da --- /dev/null +++ b/src/platform_impl/web/async/notifier.rs @@ -0,0 +1,78 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::OnceLock; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; + +use super::{ConcurrentQueue, PushError}; + +#[derive(Debug)] +pub struct Notifier(Arc>); + +impl Notifier { + pub fn new() -> Self { + Self(Arc::new(Inner { + queue: ConcurrentQueue::unbounded(), + value: OnceLock::new(), + })) + } + + pub fn notify(self, value: T) { + if self.0.value.set(value).is_err() { + unreachable!("value set before") + } + + self.0.queue.close(); + + while let Ok(waker) = self.0.queue.pop() { + waker.wake() + } + } + + pub fn notified(&self) -> Notified { + Notified(Some(Arc::clone(&self.0))) + } +} + +#[derive(Clone, Debug)] +pub struct Notified(Option>>); + +impl Future for Notified { + type Output = T; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.0.take().expect("`Receiver` polled after completion"); + + if this.value.get().is_none() { + match this.queue.push(cx.waker().clone()) { + Ok(()) => { + if this.value.get().is_none() { + self.0 = Some(this); + return Poll::Pending; + } + } + Err(PushError::Closed(_)) => (), + Err(PushError::Full(_)) => { + unreachable!("found full queue despite using unbounded queue") + } + } + } + + let (Ok(Some(value)) | Err(Some(value))) = Arc::try_unwrap(this) + .map(|mut inner| inner.value.take()) + .map_err(|this| this.value.get().cloned()) + else { + unreachable!("found no value despite being ready") + }; + + Poll::Ready(value) + } +} + +#[derive(Debug)] +struct Inner { + queue: ConcurrentQueue, + value: OnceLock, +} diff --git a/src/platform_impl/web/async/waker.rs b/src/platform_impl/web/async/waker.rs index 79062d8456..fd8733af97 100644 --- a/src/platform_impl/web/async/waker.rs +++ b/src/platform_impl/web/async/waker.rs @@ -1,11 +1,11 @@ -use super::super::main_thread::MainThreadMarker; -use super::Wrapper; -use atomic_waker::AtomicWaker; use std::future; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::task::Poll; +use super::super::main_thread::MainThreadMarker; +use super::{AtomicWaker, Wrapper}; + pub struct WakerSpawner(Wrapper, Sender, usize>); pub struct Waker(Wrapper, Sender, usize>); diff --git a/src/platform_impl/web/cursor.rs b/src/platform_impl/web/cursor.rs index e077429740..8881c8cd33 100644 --- a/src/platform_impl/web/cursor.rs +++ b/src/platform_impl/web/cursor.rs @@ -1,28 +1,31 @@ -use super::backend::Style; -use super::event_loop::runner::{EventWrapper, WeakShared}; -use super::main_thread::{MainThreadMarker, MainThreadSafe}; -use super::EventLoopWindowTarget; -use crate::cursor::{BadImage, Cursor, CursorImage}; +use std::cell::RefCell; +use std::future::{self, Future}; +use std::hash::{Hash, Hasher}; +use std::mem; +use std::ops::{Deref, DerefMut}; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::Arc; +use std::task::{ready, Context, Poll, Waker}; +use std::time::Duration; + use cursor_icon::CursorIcon; -use std::ops::Deref; -use std::sync::Weak; -use std::{ - cell::RefCell, - future, - hash::{Hash, Hasher}, - mem, - ops::DerefMut, - rc::Rc, - sync::Arc, - task::{Poll, Waker}, -}; +use js_sys::{Array, Object}; +use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{closure::Closure, JsCast}; use wasm_bindgen_futures::JsFuture; use web_sys::{ - Blob, Document, HtmlCanvasElement, HtmlImageElement, ImageBitmap, ImageBitmapOptions, - ImageBitmapRenderingContext, ImageData, PremultiplyAlpha, Url, Window, + Blob, Document, DomException, HtmlCanvasElement, HtmlImageElement, ImageBitmap, + ImageBitmapOptions, ImageBitmapRenderingContext, ImageData, PremultiplyAlpha, Url, Window, }; +use super::backend::Style; +use super::main_thread::{MainThreadMarker, MainThreadSafe}; +use super::r#async::{AbortHandle, Abortable, DropAbortHandle, Notified, Notifier}; +use super::EventLoopWindowTarget; +use crate::cursor::{BadImage, Cursor, CursorImage, CustomCursor as RootCustomCursor}; +use crate::platform::web::CustomCursorError; + #[derive(Debug)] pub(crate) enum CustomCursorBuilder { Image(CursorImage), @@ -31,6 +34,10 @@ pub(crate) enum CustomCursorBuilder { hotspot_x: u16, hotspot_y: u16, }, + Animation { + duration: Duration, + cursors: Vec, + }, } impl CustomCursorBuilder { @@ -48,196 +55,392 @@ impl CustomCursorBuilder { } #[derive(Clone, Debug)] -pub struct CustomCursor(Arc>>); +pub struct CustomCursor { + pub(crate) animation: bool, + state: Arc>>, +} impl Hash for CustomCursor { fn hash(&self, state: &mut H) { - Arc::as_ptr(&self.0).hash(state); + Arc::as_ptr(&self.state).hash(state); } } impl PartialEq for CustomCursor { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.0, &other.0) + Arc::ptr_eq(&self.state, &other.state) } } impl Eq for CustomCursor {} impl CustomCursor { - fn new(main_thread: MainThreadMarker) -> Self { - Self(Arc::new(MainThreadSafe::new( - main_thread, - RefCell::new(ImageState::Loading(None)), - ))) - } - pub(crate) fn build( builder: CustomCursorBuilder, window_target: &EventLoopWindowTarget, ) -> Self { - let main_thread = window_target.runner.main_thread(); - match builder { - CustomCursorBuilder::Image(image) => ImageState::from_rgba( - main_thread, - window_target.runner.window(), - window_target.runner.document().clone(), - &image, + CustomCursorBuilder::Image(image) => Self::build_spawn( + window_target, + from_rgba( + window_target.runner.window(), + window_target.runner.document().clone(), + &image, + ), + false, ), CustomCursorBuilder::Url { url, hotspot_x, hotspot_y, - } => ImageState::from_url(main_thread, url, hotspot_x, hotspot_y), + } => Self::build_spawn( + window_target, + from_url(UrlType::Plain(url), hotspot_x, hotspot_y), + false, + ), + CustomCursorBuilder::Animation { duration, cursors } => Self::build_spawn( + window_target, + from_animation( + window_target.runner.main_thread(), + duration, + cursors.into_iter().map(|cursor| cursor.inner), + ), + true, + ), + } + } + + fn build_spawn( + window_target: &EventLoopWindowTarget, + task: F, + animation: bool, + ) -> CustomCursor + where + F: 'static + Future>, + S: Into, + { + let handle = AbortHandle::new(); + let this = CustomCursor { + animation, + state: Arc::new(MainThreadSafe::new( + window_target.runner.main_thread(), + RefCell::new(ImageState::Loading { + notifier: Notifier::new(), + _handle: DropAbortHandle::new(handle.clone()), + }), + )), + }; + let weak = Arc::downgrade(&this.state); + let main_thread = window_target.runner.main_thread(); + + let task = Abortable::new(handle, { + async move { + let result = task.await; + + let this = weak + .upgrade() + .expect("`CursorHandler` invalidated without aborting"); + let mut this = this.get(main_thread).borrow_mut(); + + match result { + Ok(new_state) => { + let ImageState::Loading { notifier, .. } = + mem::replace(this.deref_mut(), new_state.into()) + else { + unreachable!("found invalid state"); + }; + notifier.notify(Ok(())); + } + Err(error) => { + let ImageState::Loading { notifier, .. } = + mem::replace(this.deref_mut(), ImageState::Failed(error.clone())) + else { + unreachable!("found invalid state"); + }; + notifier.notify(Err(error)); + } + } + } + }); + + wasm_bindgen_futures::spawn_local(async move { + let _ = task.await; + }); + + this + } + + pub(crate) fn build_async( + builder: CustomCursorBuilder, + window_target: &EventLoopWindowTarget, + ) -> CustomCursorFuture { + let CustomCursor { animation, state } = Self::build(builder, window_target); + let binding = state.get(window_target.runner.main_thread()).borrow(); + let ImageState::Loading { notifier, .. } = binding.deref() else { + unreachable!("found invalid state") + }; + let notified = notifier.notified(); + drop(binding); + + CustomCursorFuture { + notified, + animation, + state: Some(state), } } } #[derive(Debug)] -pub struct CursorHandler { +pub struct CustomCursorFuture { + notified: Notified>, + animation: bool, + state: Option>>>, +} + +impl Future for CustomCursorFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.state.is_none() { + panic!("`CustomCursorFuture` polled after completion") + } + + let result = ready!(Pin::new(&mut self.notified).poll(cx)); + let state = self + .state + .take() + .expect("`CustomCursorFuture` polled after completion"); + + Poll::Ready(result.map(|_| CustomCursor { + animation: self.animation, + state, + })) + } +} + +#[derive(Debug)] +pub struct CursorHandler(Rc>); + +#[derive(Debug)] +struct Inner { main_thread: MainThreadMarker, - runner: WeakShared, + canvas: HtmlCanvasElement, style: Style, visible: bool, cursor: SelectedCursor, } impl CursorHandler { - pub(crate) fn new(main_thread: MainThreadMarker, runner: WeakShared, style: Style) -> Self { - Self { + pub(crate) fn new( + main_thread: MainThreadMarker, + canvas: HtmlCanvasElement, + style: Style, + ) -> Self { + Self(Rc::new(RefCell::new(Inner { main_thread, - runner, + canvas, style, visible: true, cursor: SelectedCursor::default(), - } + }))) } - pub fn set_cursor(&mut self, cursor: Cursor) { + pub fn set_cursor(&self, cursor: Cursor) { + let mut this = self.0.borrow_mut(); + match cursor { Cursor::Icon(icon) => { if let SelectedCursor::Icon(old_icon) - | SelectedCursor::ImageLoading { + | SelectedCursor::Loading { previous: Previous::Icon(old_icon), .. - } = &self.cursor + } = &this.cursor { if *old_icon == icon { return; } } - self.cursor = SelectedCursor::Icon(icon); - self.set_style(); + this.cursor = SelectedCursor::Icon(icon); + this.set_style(); } Cursor::Custom(cursor) => { let cursor = cursor.inner; - if let SelectedCursor::ImageLoading { + if let SelectedCursor::Loading { cursor: old_cursor, .. } - | SelectedCursor::ImageReady(old_cursor) = &self.cursor + | SelectedCursor::Image(old_cursor) + | SelectedCursor::Animation { + cursor: old_cursor, .. + } = &this.cursor { if *old_cursor == cursor { return; } } - let mut image = cursor.0.get(self.main_thread).borrow_mut(); - match image.deref_mut() { - ImageState::Loading(state) => { - *state = Some(self.runner.clone()); - drop(image); - self.cursor = SelectedCursor::ImageLoading { + let state = cursor.state.get(this.main_thread).borrow(); + + match state.deref() { + ImageState::Loading { notifier, .. } => { + let notified = notifier.notified(); + let handle = DropAbortHandle::new(AbortHandle::new()); + let task = Abortable::new(handle.handle(), { + let weak = Rc::downgrade(&self.0); + async move { + let _ = notified.await; + let handler = weak + .upgrade() + .expect("`CursorHandler` invalidated without aborting"); + handler.borrow_mut().notify(); + } + }); + wasm_bindgen_futures::spawn_local(async move { + let _ = task.await; + }); + + drop(state); + this.cursor = SelectedCursor::Loading { cursor, - previous: mem::take(&mut self.cursor).into(), + previous: mem::take(&mut this.cursor).into(), + _handle: handle, }; } - ImageState::Failed => log::error!("tried to load invalid cursor"), - ImageState::Ready { .. } => { - drop(image); - self.cursor = SelectedCursor::ImageReady(cursor); - self.set_style(); + ImageState::Failed(error) => { + log::error!("trying to load custom cursor that has failed to load: {error}") + } + ImageState::Image(_) => { + drop(state); + this.cursor = SelectedCursor::Image(cursor); + this.set_style(); + } + ImageState::Animation(animation) => { + let canvas: &CanvasAnimateExt = this.canvas.unchecked_ref(); + let animation = canvas.animate_with_keyframe_animation_options( + Some(&animation.keyframes), + &animation.options, + ); + drop(state); + + if !this.visible { + animation.cancel(); + } + + this.cursor = SelectedCursor::Animation { + animation: AnimationDropper(animation), + cursor, + }; + this.set_style(); } }; } } } - pub fn set_cursor_visible(&mut self, visible: bool) { - if !visible && self.visible { - self.visible = false; - self.style.set("cursor", "none"); - } else if visible && !self.visible { - self.visible = true; - self.set_style(); - } - } + pub fn set_cursor_visible(&self, visible: bool) { + let mut this = self.0.borrow_mut(); - pub fn handle_cursor_ready(&mut self, result: Result) { - if let SelectedCursor::ImageLoading { - cursor: current_cursor, - .. - } = &self.cursor - { - let current_cursor = Arc::downgrade(¤t_cursor.0); + if !visible && this.visible { + this.visible = false; + this.style.set("cursor", "none"); - let (Ok(new_cursor) | Err(new_cursor)) = &result; - - if !new_cursor.0.ptr_eq(¤t_cursor) { - return; - } - - let SelectedCursor::ImageLoading { cursor, previous } = mem::take(&mut self.cursor) - else { - unreachable!("found wrong state") - }; - - match result { - Ok(_) => { - self.cursor = SelectedCursor::ImageReady(cursor); - self.set_style(); - } - Err(_) => self.cursor = previous.into(), + if let SelectedCursor::Animation { animation, .. } = &this.cursor { + animation.0.cancel(); } + } else if visible && !this.visible { + this.visible = true; + this.set_style(); } } +} +impl Inner { fn set_style(&self) { if self.visible { match &self.cursor { SelectedCursor::Icon(icon) - | SelectedCursor::ImageLoading { + | SelectedCursor::Loading { previous: Previous::Icon(icon), .. } => self.style.set("cursor", icon.name()), - SelectedCursor::ImageLoading { + SelectedCursor::Loading { previous: Previous::Image(cursor), .. } - | SelectedCursor::ImageReady(cursor) => { - if let ImageState::Ready { style, .. } = - cursor.0.get(self.main_thread).borrow().deref() - { - self.style.set("cursor", style) - } else { - unreachable!("found invalid saved state") + | SelectedCursor::Image(cursor) => { + match cursor.state.get(self.main_thread).borrow().deref() { + ImageState::Image(Image { style, .. }) => self.style.set("cursor", style), + _ => unreachable!("found invalid saved state"), } } + SelectedCursor::Loading { + previous: Previous::Animation { animation, .. }, + .. + } + | SelectedCursor::Animation { animation, .. } => { + self.style.remove("cursor"); + animation.0.play() + } } } } + + fn notify(&mut self) { + let SelectedCursor::Loading { + cursor, previous, .. + } = mem::take(&mut self.cursor) + else { + unreachable!("found wrong state") + }; + + let state = cursor.state.get(self.main_thread).borrow(); + match state.deref() { + ImageState::Image(_) => { + drop(state); + self.cursor = SelectedCursor::Image(cursor); + self.set_style(); + } + ImageState::Animation(animation) => { + let canvas: &CanvasAnimateExt = self.canvas.unchecked_ref(); + let animation = canvas.animate_with_keyframe_animation_options( + Some(&animation.keyframes), + &animation.options, + ); + drop(state); + + if !self.visible { + animation.cancel(); + } + + self.cursor = SelectedCursor::Animation { + animation: AnimationDropper(animation), + cursor, + }; + self.set_style(); + } + ImageState::Failed(error) => { + log::error!("custom cursor failed to load: {error}"); + self.cursor = previous.into() + } + ImageState::Loading { .. } => unreachable!("notified without being ready"), + } + } } #[derive(Debug)] enum SelectedCursor { Icon(CursorIcon), - ImageLoading { + Loading { cursor: CustomCursor, previous: Previous, + _handle: DropAbortHandle, + }, + Image(CustomCursor), + Animation { + cursor: CustomCursor, + animation: AnimationDropper, }, - ImageReady(CustomCursor), } impl Default for SelectedCursor { @@ -250,302 +453,348 @@ impl From for SelectedCursor { fn from(previous: Previous) -> Self { match previous { Previous::Icon(icon) => Self::Icon(icon), - Previous::Image(cursor) => Self::ImageReady(cursor), + Previous::Image(cursor) => Self::Image(cursor), + Previous::Animation { cursor, animation } => Self::Animation { cursor, animation }, } } } #[derive(Debug)] -pub enum Previous { +enum Previous { Icon(CursorIcon), Image(CustomCursor), + Animation { + cursor: CustomCursor, + animation: AnimationDropper, + }, } impl From for Previous { fn from(value: SelectedCursor) -> Self { match value { SelectedCursor::Icon(icon) => Self::Icon(icon), - SelectedCursor::ImageLoading { previous, .. } => previous, - SelectedCursor::ImageReady(image) => Self::Image(image), + SelectedCursor::Loading { previous, .. } => previous, + SelectedCursor::Image(image) => Self::Image(image), + SelectedCursor::Animation { cursor, animation } => { + Self::Animation { cursor, animation } + } } } } #[derive(Debug)] enum ImageState { - Loading(Option), - Failed, - Ready { - style: String, - _object_url: Option, - _image: HtmlImageElement, + Loading { + notifier: Notifier>, + _handle: DropAbortHandle, }, + Failed(CustomCursorError), + Image(Image), + Animation(Animation), } -impl ImageState { - fn from_rgba( - main_thread: MainThreadMarker, - window: &Window, - document: Document, - image: &CursorImage, - ) -> CustomCursor { - // 1. Create an `ImageData` from the RGBA data. - // 2. Create an `ImageBitmap` from the `ImageData`. - // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. - // 4. Create a `Blob` from the `HTMLCanvasElement`. - // 5. Create an object URL from the `Blob`. - // 6. Decode the image on an `HTMLImageElement` from the URL. - // 7. Notify event loop if one is registered. - - // 1. Create an `ImageData` from the RGBA data. - // Adapted from https://github.com/rust-windowing/softbuffer/blob/ab7688e2ed2e2eca51b3c4e1863a5bd7fe85800e/src/web.rs#L196-L223 - #[cfg(target_feature = "atomics")] - // Can't share `SharedArrayBuffer` with `ImageData`. - let result = { - use js_sys::{Uint8Array, Uint8ClampedArray}; - use wasm_bindgen::prelude::wasm_bindgen; - use wasm_bindgen::JsValue; - - #[wasm_bindgen] - extern "C" { - #[wasm_bindgen(js_namespace = ImageData)] - type ImageDataExt; - #[wasm_bindgen(catch, constructor, js_class = ImageData)] - fn new(array: Uint8ClampedArray, sw: u32) -> Result; - } - - let array = Uint8Array::new_with_length(image.rgba.len() as u32); - array.copy_from(&image.rgba); - let array = Uint8ClampedArray::new(&array); - ImageDataExt::new(array, image.width as u32) - .map(JsValue::from) - .map(ImageData::unchecked_from_js) - }; - #[cfg(not(target_feature = "atomics"))] - let result = ImageData::new_with_u8_clamped_array( - wasm_bindgen::Clamped(&image.rgba), - image.width as u32, - ); - let image_data = result.expect("found wrong image size"); - - // 2. Create an `ImageBitmap` from the `ImageData`. - // - // We call `createImageBitmap()` before spawning the future, - // to not have to clone the image buffer. - let mut options = ImageBitmapOptions::new(); - options.premultiply_alpha(PremultiplyAlpha::None); - let bitmap = JsFuture::from( - window - .create_image_bitmap_with_image_data_and_image_bitmap_options(&image_data, &options) - .expect("unexpected exception in `createImageBitmap()`"), - ); - - let this = CustomCursor::new(main_thread); - - wasm_bindgen_futures::spawn_local({ - let weak = Arc::downgrade(&this.0); - let CursorImage { - width, - height, - hotspot_x, - hotspot_y, - .. - } = *image; - async move { - // Keep checking if all references are dropped between every `await` call. - if weak.strong_count() == 0 { - return; - } - - let bitmap: ImageBitmap = bitmap - .await - .expect("found invalid state in `ImageData`") - .unchecked_into(); - - if weak.strong_count() == 0 { - return; - } +#[derive(Debug)] +struct Image { + style: String, + _object_url: Option, + _image: HtmlImageElement, +} - let canvas: HtmlCanvasElement = document - .create_element("canvas") - .expect("invalid tag name") - .unchecked_into(); - #[allow(clippy::disallowed_methods)] - canvas.set_width(width as u32); - #[allow(clippy::disallowed_methods)] - canvas.set_height(height as u32); - - // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. - let context: ImageBitmapRenderingContext = canvas - .get_context("bitmaprenderer") - .expect("unexpected exception in `HTMLCanvasElement.getContext()`") - .expect("`bitmaprenderer` context unsupported") - .unchecked_into(); - context.transfer_from_image_bitmap(&bitmap); - drop(bitmap); - drop(context); - - // 4. Create a `Blob` from the `HTMLCanvasElement`. - // - // To keep the `Closure` alive until `HTMLCanvasElement.toBlob()` is done, - // we do the whole `Waker` strategy. Commonly on `Drop` the callback is aborted, - // but it would increase complexity and isn't possible in this case. - // Keep in mind that `HTMLCanvasElement.toBlob()` can call the callback immediately. - let value = Rc::new(RefCell::new(None)); - let waker = Rc::new(RefCell::>::new(None)); - let callback = Closure::once({ - let value = value.clone(); - let waker = waker.clone(); - move |blob: Option| { - *value.borrow_mut() = Some(blob); - if let Some(waker) = waker.borrow_mut().take() { - waker.wake(); - } - } - }); - canvas - .to_blob(callback.as_ref().unchecked_ref()) - .expect("failed with `SecurityError` despite only source coming from memory"); - let blob = future::poll_fn(|cx| { - if let Some(blob) = value.borrow_mut().take() { - Poll::Ready(blob) - } else { - *waker.borrow_mut() = Some(cx.waker().clone()); - Poll::Pending - } - }) - .await; - drop(canvas); +impl From for ImageState { + fn from(image: Image) -> Self { + Self::Image(image) + } +} - if weak.strong_count() == 0 { - return; - } +#[derive(Debug)] +struct Animation { + keyframes: Array, + options: KeyframeAnimationOptions, + _images: Vec, +} - let Some(blob) = blob else { - log::error!("creating object URL from custom cursor failed"); - let Some(this) = weak.upgrade() else { - return; - }; - let mut this = this.get(main_thread).borrow_mut(); - let ImageState::Loading(runner) = this.deref_mut() else { - unreachable!("found invalid state"); - }; - let runner = runner.take(); - *this = ImageState::Failed; - - if let Some(runner) = runner.and_then(|weak| weak.upgrade()) { - runner.send_event(EventWrapper::CursorReady(Err(CustomCursorHandle(weak)))); - } +impl From for ImageState { + fn from(animation: Animation) -> Self { + Self::Animation(animation) + } +} - return; - }; +#[derive(Debug)] +enum UrlType { + Plain(String), + Object(ObjectUrl), +} - // 5. Create an object URL from the `Blob`. - let url = Url::create_object_url_with_blob(&blob) - .expect("unexpected exception in `URL.createObjectURL()`"); - let url = UrlType::Object(ObjectUrl(url)); +impl UrlType { + fn url(&self) -> &str { + match &self { + UrlType::Plain(url) => url, + UrlType::Object(object_url) => &object_url.0, + } + } +} - Self::decode(main_thread, weak, url, hotspot_x, hotspot_y).await; - } - }); +#[derive(Debug)] +struct ObjectUrl(String); - this +impl Drop for ObjectUrl { + fn drop(&mut self) { + Url::revoke_object_url(&self.0).expect("unexpected exception in `URL.revokeObjectURL()`"); } +} - fn from_url( - main_thread: MainThreadMarker, - url: String, - hotspot_x: u16, - hotspot_y: u16, - ) -> CustomCursor { - let this = CustomCursor::new(main_thread); - wasm_bindgen_futures::spawn_local(Self::decode( - main_thread, - Arc::downgrade(&this.0), - UrlType::Plain(url), - hotspot_x, - hotspot_y, - )); +#[derive(Debug)] +struct AnimationDropper(WebAnimation); - this +impl Drop for AnimationDropper { + fn drop(&mut self) { + self.0.cancel() } +} - async fn decode( - main_thread: MainThreadMarker, - weak: Weak>>, - url: UrlType, - hotspot_x: u16, - hotspot_y: u16, - ) { - if weak.strong_count() == 0 { - return; +fn from_rgba( + window: &Window, + document: Document, + image: &CursorImage, +) -> impl Future> { + // 1. Create an `ImageData` from the RGBA data. + // 2. Create an `ImageBitmap` from the `ImageData`. + // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. + // 4. Create a `Blob` from the `HTMLCanvasElement`. + // 5. Create an object URL from the `Blob`. + // 6. Decode the image on an `HTMLImageElement` from the URL. + + // 1. Create an `ImageData` from the RGBA data. + // Adapted from https://github.com/rust-windowing/softbuffer/blob/ab7688e2ed2e2eca51b3c4e1863a5bd7fe85800e/src/web.rs#L196-L223 + #[cfg(target_feature = "atomics")] + // Can't share `SharedArrayBuffer` with `ImageData`. + let result = { + use js_sys::{Uint8Array, Uint8ClampedArray}; + use wasm_bindgen::JsValue; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_namespace = ImageData)] + type ImageDataExt; + #[wasm_bindgen(catch, constructor, js_class = ImageData)] + fn new(array: Uint8ClampedArray, sw: u32) -> Result; } - // 6. Decode the image on an `HTMLImageElement` from the URL. - let image = - HtmlImageElement::new().expect("unexpected exception in `new HtmlImageElement`"); - image.set_src(url.url()); - let result = JsFuture::from(image.decode()).await; - - let Some(this) = weak.upgrade() else { - return; - }; - let mut this = this.get(main_thread).borrow_mut(); + let array = Uint8Array::new_with_length(image.rgba.len() as u32); + array.copy_from(&image.rgba); + let array = Uint8ClampedArray::new(&array); + ImageDataExt::new(array, image.width as u32) + .map(JsValue::from) + .map(ImageData::unchecked_from_js) + }; + #[cfg(not(target_feature = "atomics"))] + let result = ImageData::new_with_u8_clamped_array( + wasm_bindgen::Clamped(&image.rgba), + image.width as u32, + ); + let image_data = result.expect("found wrong image size"); + + // 2. Create an `ImageBitmap` from the `ImageData`. + // + // We call `createImageBitmap()` before spawning the future, + // to not have to clone the image buffer. + let mut options = ImageBitmapOptions::new(); + options.premultiply_alpha(PremultiplyAlpha::None); + let bitmap = JsFuture::from( + window + .create_image_bitmap_with_image_data_and_image_bitmap_options(&image_data, &options) + .expect("unexpected exception in `createImageBitmap()`"), + ); + + let CursorImage { + width, + height, + hotspot_x, + hotspot_y, + .. + } = *image; + async move { + let bitmap: ImageBitmap = bitmap + .await + .expect("found invalid state in `ImageData`") + .unchecked_into(); + + let canvas: HtmlCanvasElement = document + .create_element("canvas") + .expect("invalid tag name") + .unchecked_into(); + #[allow(clippy::disallowed_methods)] + canvas.set_width(width as u32); + #[allow(clippy::disallowed_methods)] + canvas.set_height(height as u32); - let ImageState::Loading(runner) = this.deref_mut() else { - unreachable!("found invalid state"); - }; - let runner = runner.take(); - - if let Err(error) = result { - log::error!("decoding custom cursor failed: {error:?}"); - *this = ImageState::Failed; + // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. + let context: ImageBitmapRenderingContext = canvas + .get_context("bitmaprenderer") + .expect("unexpected exception in `HTMLCanvasElement.getContext()`") + .expect("`bitmaprenderer` context unsupported") + .unchecked_into(); + context.transfer_from_image_bitmap(&bitmap); + drop(bitmap); + drop(context); - if let Some(runner) = runner.and_then(|weak| weak.upgrade()) { - runner.send_event(EventWrapper::CursorReady(Err(CustomCursorHandle(weak)))); + // 4. Create a `Blob` from the `HTMLCanvasElement`. + // + // To keep the `Closure` alive until `HTMLCanvasElement.toBlob()` is done, + // we do the whole `Waker` strategy. Commonly on `Drop` the callback is aborted, + // but it would increase complexity and isn't possible in this case. + // Keep in mind that `HTMLCanvasElement.toBlob()` can call the callback immediately. + let value = Rc::new(RefCell::new(None)); + let waker = Rc::new(RefCell::>::new(None)); + let callback = Closure::once({ + let value = value.clone(); + let waker = waker.clone(); + move |blob: Option| { + *value.borrow_mut() = Some(blob); + if let Some(waker) = waker.borrow_mut().take() { + waker.wake(); + } } + }); + canvas + .to_blob(callback.as_ref().unchecked_ref()) + .expect("failed with `SecurityError` despite only source coming from memory"); + let blob = future::poll_fn(|cx| { + if let Some(blob) = value.borrow_mut().take() { + Poll::Ready(blob) + } else { + *waker.borrow_mut() = Some(cx.waker().clone()); + Poll::Pending + } + }) + .await; + drop(canvas); - return; - } - - *this = ImageState::Ready { - style: format!("url({}) {hotspot_x} {hotspot_y}, auto", url.url()), - _object_url: match url { - UrlType::Plain(_) => None, - UrlType::Object(object_url) => Some(object_url), - }, - _image: image, + let Some(blob) = blob else { + return Err(CustomCursorError::Blob); }; - // 7. Notify event loop if one is registered. - if let Some(runner) = runner.and_then(|weak| weak.upgrade()) { - runner.send_event(EventWrapper::CursorReady(Ok(CustomCursorHandle(weak)))); - } + // 5. Create an object URL from the `Blob`. + let url = Url::create_object_url_with_blob(&blob) + .expect("unexpected exception in `URL.createObjectURL()`"); + let url = UrlType::Object(ObjectUrl(url)); + + from_url(url, hotspot_x, hotspot_y).await } } -#[derive(Clone)] -pub struct CustomCursorHandle(Weak>>); +async fn from_url( + url: UrlType, + hotspot_x: u16, + hotspot_y: u16, +) -> Result { + // 6. Decode the image on an `HTMLImageElement` from the URL. + let image = HtmlImageElement::new().expect("unexpected exception in `new HtmlImageElement`"); + image.set_src(url.url()); + let result = JsFuture::from(image.decode()).await; + + if let Err(error) = result { + debug_assert!(error.has_type::()); + let error: DomException = error.unchecked_into(); + debug_assert_eq!(error.name(), "EncodingError"); + let error = error.message(); + + return Err(CustomCursorError::Decode(error)); + } -enum UrlType { - Plain(String), - Object(ObjectUrl), + Ok(Image { + style: format!("url({}) {hotspot_x} {hotspot_y}, auto", url.url()), + _object_url: match url { + UrlType::Plain(_) => None, + UrlType::Object(object_url) => Some(object_url), + }, + _image: image, + }) } -impl UrlType { - fn url(&self) -> &str { - match &self { - UrlType::Plain(url) => url, - UrlType::Object(object_url) => &object_url.0, +#[allow(clippy::await_holding_refcell_ref)] // false-positive +async fn from_animation( + main_thread: MainThreadMarker, + duration: Duration, + cursors: impl Iterator + ExactSizeIterator, +) -> Result { + let keyframes = Array::new(); + let mut images = Vec::with_capacity(cursors.len()); + + for cursor in cursors { + let state = cursor.state.get(main_thread).borrow(); + + match state.deref() { + ImageState::Loading { notifier, .. } => { + let notified = notifier.notified(); + drop(state); + notified.await?; + } + ImageState::Failed(error) => return Err(error.clone()), + ImageState::Image(_) => drop(state), + ImageState::Animation(_) => unreachable!("check in `CustomCursorBuilder` failed"), } + + let state = cursor.state.get(main_thread).borrow(); + let style = match state.deref() { + ImageState::Image(Image { style, .. }) => style, + _ => unreachable!("found invalid state"), + }; + + let keyframe: Keyframe = Object::new().unchecked_into(); + keyframe.set_cursor(style); + keyframes.push(&keyframe); + drop(state); + + images.push(cursor); } + + keyframes.push(&keyframes.get(0)); + + let options: KeyframeAnimationOptions = Object::new().unchecked_into(); + options.set_duration(duration.as_millis() as f64); + options.set_iterations(f64::INFINITY); + + Ok(Animation { + keyframes, + options, + _images: images, + }) } -#[derive(Debug)] -struct ObjectUrl(String); +#[wasm_bindgen] +extern "C" { + type CanvasAnimateExt; -impl Drop for ObjectUrl { - fn drop(&mut self) { - Url::revoke_object_url(&self.0).expect("unexpected exception in `URL.revokeObjectURL()`"); - } + #[wasm_bindgen(method, js_name = animate)] + fn animate_with_keyframe_animation_options( + this: &CanvasAnimateExt, + keyframes: Option<&Object>, + options: &KeyframeAnimationOptions, + ) -> WebAnimation; + + #[derive(Debug)] + type WebAnimation; + + #[wasm_bindgen(method)] + fn cancel(this: &WebAnimation); + + #[wasm_bindgen(method)] + fn play(this: &WebAnimation); + + #[wasm_bindgen(extends = Object)] + type Keyframe; + + #[wasm_bindgen(method, setter, js_name = cursor)] + fn set_cursor(this: &Keyframe, value: &str); + + #[derive(Debug)] + #[wasm_bindgen(extends = Object)] + type KeyframeAnimationOptions; + + #[wasm_bindgen(method, setter, js_name = duration)] + fn set_duration(this: &KeyframeAnimationOptions, value: f64); + + #[wasm_bindgen(method, setter, js_name = iterations)] + fn set_iterations(this: &KeyframeAnimationOptions, value: f64); } diff --git a/src/platform_impl/web/event_loop/runner.rs b/src/platform_impl/web/event_loop/runner.rs index 43e8c4a21d..f7932ff76e 100644 --- a/src/platform_impl/web/event_loop/runner.rs +++ b/src/platform_impl/web/event_loop/runner.rs @@ -1,4 +1,3 @@ -use super::super::cursor::CustomCursorHandle; use super::super::main_thread::MainThreadMarker; use super::super::DeviceId; use super::{backend, state::State}; @@ -140,16 +139,6 @@ impl Runner { ) } } - EventWrapper::CursorReady(result) => { - for (_, canvas, _) in runner.0.all_canvases.borrow().deref() { - if let Some(canvas) = canvas.upgrade() { - canvas - .borrow_mut() - .cursor - .handle_cursor_ready(result.clone()) - } - } - } } } } @@ -822,19 +811,6 @@ impl Shared { pub(crate) fn waker(&self) -> Waker> { self.0.proxy_spawner.waker() } - - pub(crate) fn weak(&self) -> WeakShared { - WeakShared(Rc::downgrade(&self.0)) - } -} - -#[derive(Clone, Debug)] -pub(crate) struct WeakShared(Weak); - -impl WeakShared { - pub(crate) fn upgrade(&self) -> Option { - self.0.upgrade().map(Shared) - } } pub(crate) enum EventWrapper { @@ -844,7 +820,6 @@ pub(crate) enum EventWrapper { size: PhysicalSize, scale: f64, }, - CursorReady(Result), } impl From> for EventWrapper { diff --git a/src/platform_impl/web/main_thread.rs b/src/platform_impl/web/main_thread.rs index 140a1e9e17..59a2ac59f8 100644 --- a/src/platform_impl/web/main_thread.rs +++ b/src/platform_impl/web/main_thread.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{JsCast, JsValue}; -use super::r#async::{self, AsyncSender}; +use super::r#async::{self, Sender}; thread_local! { static MAIN_THREAD: bool = { @@ -85,7 +85,7 @@ impl Drop for MainThreadSafe { unsafe impl Send for MainThreadSafe {} unsafe impl Sync for MainThreadSafe {} -static DROP_HANDLER: OnceLock> = OnceLock::new(); +static DROP_HANDLER: OnceLock> = OnceLock::new(); struct DropBox(#[allow(dead_code)] Box); diff --git a/src/platform_impl/web/mod.rs b/src/platform_impl/web/mod.rs index dc4b2a50a8..3d23351efe 100644 --- a/src/platform_impl/web/mod.rs +++ b/src/platform_impl/web/mod.rs @@ -43,3 +43,4 @@ pub(crate) use crate::icon::NoIcon as PlatformIcon; pub(crate) use crate::platform_impl::Fullscreen; pub(crate) use cursor::CustomCursor as PlatformCustomCursor; pub(crate) use cursor::CustomCursorBuilder as PlatformCustomCursorBuilder; +pub(crate) use cursor::CustomCursorFuture; diff --git a/src/platform_impl/web/web_sys/canvas.rs b/src/platform_impl/web/web_sys/canvas.rs index 195af6fe88..bbd59419e5 100644 --- a/src/platform_impl/web/web_sys/canvas.rs +++ b/src/platform_impl/web/web_sys/canvas.rs @@ -18,7 +18,6 @@ use crate::platform_impl::{OsError, PlatformSpecificWindowBuilderAttributes}; use crate::window::{WindowAttributes, WindowId as RootWindowId}; use super::super::cursor::CursorHandler; -use super::super::event_loop::runner::WeakShared; use super::super::main_thread::MainThreadMarker; use super::super::WindowId; use super::animation_frame::AnimationFrameHandler; @@ -71,7 +70,6 @@ pub struct Style { impl Canvas { pub(crate) fn create( main_thread: MainThreadMarker, - runner: WeakShared, id: WindowId, window: web_sys::Window, document: Document, @@ -111,7 +109,7 @@ impl Canvas { let style = Style::new(&window, &canvas); - let cursor = CursorHandler::new(main_thread, runner, style.clone()); + let cursor = CursorHandler::new(main_thread, canvas.clone(), style.clone()); let common = Common { window: window.clone(), diff --git a/src/platform_impl/web/window.rs b/src/platform_impl/web/window.rs index 1e82f21f3d..d391687f80 100644 --- a/src/platform_impl/web/window.rs +++ b/src/platform_impl/web/window.rs @@ -39,7 +39,6 @@ impl Window { let document = target.runner.document(); let canvas = backend::Canvas::create( target.runner.main_thread(), - target.runner.weak(), id, window.clone(), document.clone(),