From 47c4e3084afb3765c73f5382910e7295aef8c2a1 Mon Sep 17 00:00:00 2001 From: Eero Lehtinen Date: Mon, 12 Aug 2024 18:49:03 +0300 Subject: [PATCH] Add custom cursors (#14284) # Objective - Add custom images as cursors - Fixes #9557 ## Solution - Change cursor type to accommodate both native and image cursors - I don't really like this solution because I couldn't use `Handle` directly. I would need to import `bevy_assets` and that causes a circular dependency. Alternatively we could use winit's `CustomCursor` smart pointers, but that seems hard because the event loop is needed to create those and is not easily accessable for users. So now I need to copy around rgba buffers which is sad. - I use a cache because especially on the web creating cursor images is really slow - Sorry to #14196 for yoinking, I just wanted to make a quick solution for myself and thought that I should probably share it too. Update: - Now uses `Handle`, reads rgba data in `bevy_render` and uses resources to send the data to `bevy_winit`, where the final cursors are created. ## Testing - Added example which works fine at least on Linux Wayland (winit side has been tested with all platforms). - I haven't tested if the url cursor works. ## Migration Guide - `CursorIcon` is no longer a field in `Window`, but a separate component can be inserted to a window entity. It has been changed to an enum that can hold custom images in addition to system icons. - `Cursor` is renamed to `CursorOptions` and `cursor` field of `Window` is renamed to `cursor_options` - `CursorIcon` is renamed to `SystemCursorIcon` --------- Co-authored-by: Alice Cecile Co-authored-by: Jan Hohenheim --- crates/bevy_render/Cargo.toml | 1 + crates/bevy_render/src/view/window/cursor.rs | 175 ++++++++++++++++++ crates/bevy_render/src/view/window/mod.rs | 10 +- crates/bevy_window/src/lib.rs | 6 +- .../src/{cursor.rs => system_cursor.rs} | 6 +- crates/bevy_window/src/window.rs | 33 ++-- crates/bevy_winit/src/converters.rs | 71 +++---- crates/bevy_winit/src/lib.rs | 5 + crates/bevy_winit/src/state.rs | 75 +++++++- crates/bevy_winit/src/system.rs | 21 +-- crates/bevy_winit/src/winit_windows.rs | 10 +- examples/games/desk_toy.rs | 4 +- examples/helpers/camera_controller.rs | 8 +- examples/input/mouse_grab.rs | 8 +- examples/ui/window_fallthrough.rs | 2 +- examples/window/window_settings.rs | 51 +++-- 16 files changed, 375 insertions(+), 111 deletions(-) create mode 100644 crates/bevy_render/src/view/window/cursor.rs rename crates/bevy_window/src/{cursor.rs => system_cursor.rs} (97%) diff --git a/crates/bevy_render/Cargo.toml b/crates/bevy_render/Cargo.toml index c48697ff69..8f0419e6d7 100644 --- a/crates/bevy_render/Cargo.toml +++ b/crates/bevy_render/Cargo.toml @@ -58,6 +58,7 @@ bevy_render_macros = { path = "macros", version = "0.15.0-dev" } bevy_time = { path = "../bevy_time", version = "0.15.0-dev" } bevy_transform = { path = "../bevy_transform", version = "0.15.0-dev" } bevy_window = { path = "../bevy_window", version = "0.15.0-dev" } +bevy_winit = { path = "../bevy_winit", version = "0.15.0-dev" } bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev" } bevy_tasks = { path = "../bevy_tasks", version = "0.15.0-dev" } diff --git a/crates/bevy_render/src/view/window/cursor.rs b/crates/bevy_render/src/view/window/cursor.rs new file mode 100644 index 0000000000..eed41cec11 --- /dev/null +++ b/crates/bevy_render/src/view/window/cursor.rs @@ -0,0 +1,175 @@ +use bevy_asset::{AssetId, Assets, Handle}; +use bevy_ecs::{ + change_detection::DetectChanges, + component::Component, + entity::Entity, + query::With, + reflect::ReflectComponent, + system::{Commands, Local, Query, Res}, + world::Ref, +}; +use bevy_reflect::{std_traits::ReflectDefault, Reflect}; +use bevy_utils::{tracing::warn, HashSet}; +use bevy_window::{SystemCursorIcon, Window}; +use bevy_winit::{ + convert_system_cursor_icon, CursorSource, CustomCursorCache, CustomCursorCacheKey, + PendingCursor, +}; +use wgpu::TextureFormat; + +use crate::prelude::Image; + +/// Insert into a window entity to set the cursor for that window. +#[derive(Component, Debug, Clone, Reflect, PartialEq, Eq)] +#[reflect(Component, Debug, Default)] +pub enum CursorIcon { + /// Custom cursor image. + Custom(CustomCursor), + /// System provided cursor icon. + System(SystemCursorIcon), +} + +impl Default for CursorIcon { + fn default() -> Self { + CursorIcon::System(Default::default()) + } +} + +impl From for CursorIcon { + fn from(icon: SystemCursorIcon) -> Self { + CursorIcon::System(icon) + } +} + +impl From for CursorIcon { + fn from(cursor: CustomCursor) -> Self { + CursorIcon::Custom(cursor) + } +} + +/// Custom cursor image data. +#[derive(Debug, Clone, Reflect, PartialEq, Eq, Hash)] +pub enum CustomCursor { + /// Image to use as a cursor. + Image { + /// The image must be in 8 bit int or 32 bit float rgba. PNG images + /// work well for this. + handle: Handle, + /// X and Y coordinates of the hotspot in pixels. The hotspot must be + /// within the image bounds. + hotspot: (u16, u16), + }, + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + /// A URL to an image to use as the cursor. + Url { + /// Web URL to an image to use as the cursor. PNGs preferred. Cursor + /// creation can fail if the image is invalid or not reachable. + url: String, + /// X and Y coordinates of the hotspot in pixels. The hotspot must be + /// within the image bounds. + hotspot: (u16, u16), + }, +} + +pub fn update_cursors( + mut commands: Commands, + mut windows: Query<(Entity, Ref), With>, + cursor_cache: Res, + images: Res>, + mut queue: Local>, +) { + for (entity, cursor) in windows.iter_mut() { + if !(queue.remove(&entity) || cursor.is_changed()) { + continue; + } + + let cursor_source = match cursor.as_ref() { + CursorIcon::Custom(CustomCursor::Image { handle, hotspot }) => { + let cache_key = match handle.id() { + AssetId::Index { index, .. } => { + CustomCursorCacheKey::AssetIndex(index.to_bits()) + } + AssetId::Uuid { uuid } => CustomCursorCacheKey::AssetUuid(uuid.as_u128()), + }; + + if cursor_cache.0.contains_key(&cache_key) { + CursorSource::CustomCached(cache_key) + } else { + let Some(image) = images.get(handle) else { + warn!( + "Cursor image {handle:?} is not loaded yet and couldn't be used. Trying again next frame." + ); + queue.insert(entity); + continue; + }; + let Some(rgba) = image_to_rgba_pixels(image) else { + warn!("Cursor image {handle:?} not accepted because it's not rgba8 or rgba32float format"); + continue; + }; + + let width = image.texture_descriptor.size.width; + let height = image.texture_descriptor.size.height; + let source = match bevy_winit::WinitCustomCursor::from_rgba( + rgba, + width as u16, + height as u16, + hotspot.0, + hotspot.1, + ) { + Ok(source) => source, + Err(err) => { + warn!("Cursor image {handle:?} is invalid: {err}"); + continue; + } + }; + + CursorSource::Custom((cache_key, source)) + } + } + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + CursorIcon::Custom(CustomCursor::Url { url, hotspot }) => { + let cache_key = CustomCursorCacheKey::Url(url.clone()); + + if cursor_cache.0.contains_key(&cache_key) { + CursorSource::CustomCached(cache_key) + } else { + use bevy_winit::CustomCursorExtWebSys; + let source = + bevy_winit::WinitCustomCursor::from_url(url.clone(), hotspot.0, hotspot.1); + CursorSource::Custom((cache_key, source)) + } + } + CursorIcon::System(system_cursor_icon) => { + CursorSource::System(convert_system_cursor_icon(*system_cursor_icon)) + } + }; + + commands + .entity(entity) + .insert(PendingCursor(Some(cursor_source))); + } +} + +/// Returns the image data as a `Vec`. +/// Only supports rgba8 and rgba32float formats. +fn image_to_rgba_pixels(image: &Image) -> Option> { + match image.texture_descriptor.format { + TextureFormat::Rgba8Unorm + | TextureFormat::Rgba8UnormSrgb + | TextureFormat::Rgba8Snorm + | TextureFormat::Rgba8Uint + | TextureFormat::Rgba8Sint => Some(image.data.clone()), + TextureFormat::Rgba32Float => Some( + image + .data + .chunks(4) + .map(|chunk| { + let chunk = chunk.try_into().unwrap(); + let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk); + (num * 255.0) as u8 + }) + .collect(), + ), + _ => None, + } +} diff --git a/crates/bevy_render/src/view/window/mod.rs b/crates/bevy_render/src/view/window/mod.rs index a0cd1fc62e..0e756c200b 100644 --- a/crates/bevy_render/src/view/window/mod.rs +++ b/crates/bevy_render/src/view/window/mod.rs @@ -6,7 +6,7 @@ use crate::{ texture::TextureFormatPixelInfo, Extract, ExtractSchedule, Render, RenderApp, RenderSet, WgpuWrapper, }; -use bevy_app::{App, Plugin}; +use bevy_app::{App, Last, Plugin}; use bevy_ecs::{entity::EntityHashMap, prelude::*}; #[cfg(target_os = "linux")] use bevy_utils::warn_once; @@ -14,6 +14,7 @@ use bevy_utils::{default, tracing::debug, HashSet}; use bevy_window::{ CompositeAlphaMode, PresentMode, PrimaryWindow, RawHandleWrapper, Window, WindowClosing, }; +use bevy_winit::CustomCursorCache; use std::{ num::NonZeroU32, ops::{Deref, DerefMut}, @@ -24,17 +25,22 @@ use wgpu::{ TextureViewDescriptor, }; +pub mod cursor; pub mod screenshot; use screenshot::{ ScreenshotManager, ScreenshotPlugin, ScreenshotPreparedState, ScreenshotToScreenPipeline, }; +use self::cursor::update_cursors; + pub struct WindowRenderPlugin; impl Plugin for WindowRenderPlugin { fn build(&self, app: &mut App) { - app.add_plugins(ScreenshotPlugin); + app.add_plugins(ScreenshotPlugin) + .init_resource::() + .add_systems(Last, update_cursors); if let Some(render_app) = app.get_sub_app_mut(RenderApp) { render_app diff --git a/crates/bevy_window/src/lib.rs b/crates/bevy_window/src/lib.rs index 23fff1e5b2..f27af26699 100644 --- a/crates/bevy_window/src/lib.rs +++ b/crates/bevy_window/src/lib.rs @@ -15,19 +15,19 @@ use std::sync::{Arc, Mutex}; use bevy_a11y::Focus; -mod cursor; mod event; mod monitor; mod raw_handle; mod system; +mod system_cursor; mod window; pub use crate::raw_handle::*; -pub use cursor::*; pub use event::*; pub use monitor::*; pub use system::*; +pub use system_cursor::*; pub use window::*; #[allow(missing_docs)] @@ -35,7 +35,7 @@ pub mod prelude { #[allow(deprecated)] #[doc(hidden)] pub use crate::{ - CursorEntered, CursorIcon, CursorLeft, CursorMoved, FileDragAndDrop, Ime, MonitorSelection, + CursorEntered, CursorLeft, CursorMoved, FileDragAndDrop, Ime, MonitorSelection, ReceivedCharacter, Window, WindowMoved, WindowPlugin, WindowPosition, WindowResizeConstraints, }; diff --git a/crates/bevy_window/src/cursor.rs b/crates/bevy_window/src/system_cursor.rs similarity index 97% rename from crates/bevy_window/src/cursor.rs rename to crates/bevy_window/src/system_cursor.rs index 3a68b7ee92..b3865c4a35 100644 --- a/crates/bevy_window/src/cursor.rs +++ b/crates/bevy_window/src/system_cursor.rs @@ -73,7 +73,7 @@ use bevy_reflect::{prelude::ReflectDefault, Reflect}; #[cfg(feature = "serialize")] use bevy_reflect::{ReflectDeserialize, ReflectSerialize}; -/// The icon to display for a [`Window`](crate::window::Window)'s [`Cursor`](crate::window::Cursor). +/// The icon to display for a window. /// /// Examples of all of these cursors can be found [here](https://www.w3schools.com/cssref/playit.php?filename=playcss_cursor&preval=crosshair). /// This `enum` is simply a copy of a similar `enum` found in [`winit`](https://docs.rs/winit/latest/winit/window/enum.CursorIcon.html). @@ -89,7 +89,7 @@ use bevy_reflect::{ReflectDeserialize, ReflectSerialize}; reflect(Serialize, Deserialize) )] #[reflect(Debug, PartialEq, Default)] -pub enum CursorIcon { +pub enum SystemCursorIcon { /// The platform-dependent default cursor. Often rendered as arrow. #[default] Default, @@ -107,7 +107,7 @@ pub enum CursorIcon { Pointer, /// A progress indicator. The program is performing some processing, but is - /// different from [`CursorIcon::Wait`] in that the user may still interact + /// different from [`SystemCursorIcon::Wait`] in that the user may still interact /// with the program. Progress, diff --git a/crates/bevy_window/src/window.rs b/crates/bevy_window/src/window.rs index f6196f70c3..f578658cdf 100644 --- a/crates/bevy_window/src/window.rs +++ b/crates/bevy_window/src/window.rs @@ -12,8 +12,6 @@ use bevy_reflect::{ReflectDeserialize, ReflectSerialize}; use bevy_utils::tracing::warn; -use crate::CursorIcon; - /// Marker [`Component`] for the window considered the primary window. /// /// Currently this is assumed to only exist on 1 entity at a time. @@ -107,16 +105,16 @@ impl NormalizedWindowRef { /// /// Because this component is synchronized with `winit`, it can be used to perform /// OS-integrated windowing operations. For example, here's a simple system -/// to change the cursor type: +/// to change the window mode: /// /// ``` /// # use bevy_ecs::query::With; /// # use bevy_ecs::system::Query; -/// # use bevy_window::{CursorIcon, PrimaryWindow, Window}; -/// fn change_cursor(mut windows: Query<&mut Window, With>) { +/// # use bevy_window::{WindowMode, PrimaryWindow, Window, MonitorSelection}; +/// fn change_window_mode(mut windows: Query<&mut Window, With>) { /// // Query returns one window typically. /// for mut window in windows.iter_mut() { -/// window.cursor.icon = CursorIcon::Wait; +/// window.mode = WindowMode::Fullscreen(MonitorSelection::Current); /// } /// } /// ``` @@ -128,8 +126,9 @@ impl NormalizedWindowRef { )] #[reflect(Component, Default)] pub struct Window { - /// The cursor of this window. - pub cursor: Cursor, + /// The cursor options of this window. Cursor icons are set with the `Cursor` component on the + /// window entity. + pub cursor_options: CursorOptions, /// What presentation mode to give the window. pub present_mode: PresentMode, /// Which fullscreen or windowing mode should be used. @@ -316,7 +315,7 @@ impl Default for Window { Self { title: "App".to_owned(), name: None, - cursor: Default::default(), + cursor_options: Default::default(), present_mode: Default::default(), mode: Default::default(), position: Default::default(), @@ -543,23 +542,20 @@ impl WindowResizeConstraints { } /// Cursor data for a [`Window`]. -#[derive(Debug, Copy, Clone, Reflect)] +#[derive(Debug, Clone, Reflect)] #[cfg_attr( feature = "serialize", derive(serde::Serialize, serde::Deserialize), reflect(Serialize, Deserialize) )] #[reflect(Debug, Default)] -pub struct Cursor { - /// What the cursor should look like while inside the window. - pub icon: CursorIcon, - +pub struct CursorOptions { /// Whether the cursor is visible or not. /// /// ## Platform-specific /// /// - **`Windows`**, **`X11`**, and **`Wayland`**: The cursor is hidden only when inside the window. - /// To stop the cursor from leaving the window, change [`Cursor::grab_mode`] to [`CursorGrabMode::Locked`] or [`CursorGrabMode::Confined`] + /// To stop the cursor from leaving the window, change [`CursorOptions::grab_mode`] to [`CursorGrabMode::Locked`] or [`CursorGrabMode::Confined`] /// - **`macOS`**: The cursor is hidden only when the window is focused. /// - **`iOS`** and **`Android`** do not have cursors pub visible: bool, @@ -583,10 +579,9 @@ pub struct Cursor { pub hit_test: bool, } -impl Default for Cursor { +impl Default for CursorOptions { fn default() -> Self { - Cursor { - icon: CursorIcon::Default, + CursorOptions { visible: true, grab_mode: CursorGrabMode::None, hit_test: true, @@ -870,7 +865,7 @@ impl From for WindowResolution { } } -/// Defines if and how the [`Cursor`] is grabbed by a [`Window`]. +/// Defines if and how the cursor is grabbed by a [`Window`]. /// /// ## Platform-specific /// diff --git a/crates/bevy_winit/src/converters.rs b/crates/bevy_winit/src/converters.rs index a6358433c3..78b52b8eb4 100644 --- a/crates/bevy_winit/src/converters.rs +++ b/crates/bevy_winit/src/converters.rs @@ -6,7 +6,7 @@ use bevy_input::{ ButtonState, }; use bevy_math::Vec2; -use bevy_window::{CursorIcon, EnabledButtons, WindowLevel, WindowTheme}; +use bevy_window::{EnabledButtons, SystemCursorIcon, WindowLevel, WindowTheme}; use winit::keyboard::{Key, NamedKey, NativeKey}; pub fn convert_keyboard_input( @@ -628,41 +628,42 @@ pub fn convert_native_key(native_key: &NativeKey) -> bevy_input::keyboard::Nativ } } -pub fn convert_cursor_icon(cursor_icon: CursorIcon) -> winit::window::CursorIcon { +/// Converts a [`SystemCursorIcon`] to a [`winit::window::CursorIcon`]. +pub fn convert_system_cursor_icon(cursor_icon: SystemCursorIcon) -> winit::window::CursorIcon { match cursor_icon { - CursorIcon::Crosshair => winit::window::CursorIcon::Crosshair, - CursorIcon::Pointer => winit::window::CursorIcon::Pointer, - CursorIcon::Move => winit::window::CursorIcon::Move, - CursorIcon::Text => winit::window::CursorIcon::Text, - CursorIcon::Wait => winit::window::CursorIcon::Wait, - CursorIcon::Help => winit::window::CursorIcon::Help, - CursorIcon::Progress => winit::window::CursorIcon::Progress, - CursorIcon::NotAllowed => winit::window::CursorIcon::NotAllowed, - CursorIcon::ContextMenu => winit::window::CursorIcon::ContextMenu, - CursorIcon::Cell => winit::window::CursorIcon::Cell, - CursorIcon::VerticalText => winit::window::CursorIcon::VerticalText, - CursorIcon::Alias => winit::window::CursorIcon::Alias, - CursorIcon::Copy => winit::window::CursorIcon::Copy, - CursorIcon::NoDrop => winit::window::CursorIcon::NoDrop, - CursorIcon::Grab => winit::window::CursorIcon::Grab, - CursorIcon::Grabbing => winit::window::CursorIcon::Grabbing, - CursorIcon::AllScroll => winit::window::CursorIcon::AllScroll, - CursorIcon::ZoomIn => winit::window::CursorIcon::ZoomIn, - CursorIcon::ZoomOut => winit::window::CursorIcon::ZoomOut, - CursorIcon::EResize => winit::window::CursorIcon::EResize, - CursorIcon::NResize => winit::window::CursorIcon::NResize, - CursorIcon::NeResize => winit::window::CursorIcon::NeResize, - CursorIcon::NwResize => winit::window::CursorIcon::NwResize, - CursorIcon::SResize => winit::window::CursorIcon::SResize, - CursorIcon::SeResize => winit::window::CursorIcon::SeResize, - CursorIcon::SwResize => winit::window::CursorIcon::SwResize, - CursorIcon::WResize => winit::window::CursorIcon::WResize, - CursorIcon::EwResize => winit::window::CursorIcon::EwResize, - CursorIcon::NsResize => winit::window::CursorIcon::NsResize, - CursorIcon::NeswResize => winit::window::CursorIcon::NeswResize, - CursorIcon::NwseResize => winit::window::CursorIcon::NwseResize, - CursorIcon::ColResize => winit::window::CursorIcon::ColResize, - CursorIcon::RowResize => winit::window::CursorIcon::RowResize, + SystemCursorIcon::Crosshair => winit::window::CursorIcon::Crosshair, + SystemCursorIcon::Pointer => winit::window::CursorIcon::Pointer, + SystemCursorIcon::Move => winit::window::CursorIcon::Move, + SystemCursorIcon::Text => winit::window::CursorIcon::Text, + SystemCursorIcon::Wait => winit::window::CursorIcon::Wait, + SystemCursorIcon::Help => winit::window::CursorIcon::Help, + SystemCursorIcon::Progress => winit::window::CursorIcon::Progress, + SystemCursorIcon::NotAllowed => winit::window::CursorIcon::NotAllowed, + SystemCursorIcon::ContextMenu => winit::window::CursorIcon::ContextMenu, + SystemCursorIcon::Cell => winit::window::CursorIcon::Cell, + SystemCursorIcon::VerticalText => winit::window::CursorIcon::VerticalText, + SystemCursorIcon::Alias => winit::window::CursorIcon::Alias, + SystemCursorIcon::Copy => winit::window::CursorIcon::Copy, + SystemCursorIcon::NoDrop => winit::window::CursorIcon::NoDrop, + SystemCursorIcon::Grab => winit::window::CursorIcon::Grab, + SystemCursorIcon::Grabbing => winit::window::CursorIcon::Grabbing, + SystemCursorIcon::AllScroll => winit::window::CursorIcon::AllScroll, + SystemCursorIcon::ZoomIn => winit::window::CursorIcon::ZoomIn, + SystemCursorIcon::ZoomOut => winit::window::CursorIcon::ZoomOut, + SystemCursorIcon::EResize => winit::window::CursorIcon::EResize, + SystemCursorIcon::NResize => winit::window::CursorIcon::NResize, + SystemCursorIcon::NeResize => winit::window::CursorIcon::NeResize, + SystemCursorIcon::NwResize => winit::window::CursorIcon::NwResize, + SystemCursorIcon::SResize => winit::window::CursorIcon::SResize, + SystemCursorIcon::SeResize => winit::window::CursorIcon::SeResize, + SystemCursorIcon::SwResize => winit::window::CursorIcon::SwResize, + SystemCursorIcon::WResize => winit::window::CursorIcon::WResize, + SystemCursorIcon::EwResize => winit::window::CursorIcon::EwResize, + SystemCursorIcon::NsResize => winit::window::CursorIcon::NsResize, + SystemCursorIcon::NeswResize => winit::window::CursorIcon::NeswResize, + SystemCursorIcon::NwseResize => winit::window::CursorIcon::NwseResize, + SystemCursorIcon::ColResize => winit::window::CursorIcon::ColResize, + SystemCursorIcon::RowResize => winit::window::CursorIcon::RowResize, _ => winit::window::CursorIcon::Default, } } diff --git a/crates/bevy_winit/src/lib.rs b/crates/bevy_winit/src/lib.rs index afea7e9db1..1055a4dfb7 100644 --- a/crates/bevy_winit/src/lib.rs +++ b/crates/bevy_winit/src/lib.rs @@ -24,9 +24,14 @@ use bevy_app::{App, Last, Plugin}; use bevy_ecs::prelude::*; #[allow(deprecated)] use bevy_window::{exit_on_all_closed, Window, WindowCreated}; +pub use converters::convert_system_cursor_icon; +pub use state::{CursorSource, CustomCursorCache, CustomCursorCacheKey, PendingCursor}; use system::{changed_windows, despawn_windows}; pub use system::{create_monitors, create_windows}; pub use winit::event_loop::EventLoopProxy; +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub use winit::platform::web::CustomCursorExtWebSys; +pub use winit::window::{CustomCursor as WinitCustomCursor, CustomCursorSource}; pub use winit_config::*; pub use winit_event::*; pub use winit_windows::*; diff --git a/crates/bevy_winit/src/state.rs b/crates/bevy_winit/src/state.rs index 92fc03f652..871a559b06 100644 --- a/crates/bevy_winit/src/state.rs +++ b/crates/bevy_winit/src/state.rs @@ -15,7 +15,7 @@ use bevy_log::{error, trace, warn}; use bevy_math::{ivec2, DVec2, Vec2}; #[cfg(not(target_arch = "wasm32"))] use bevy_tasks::tick_global_task_pools_on_main_thread; -use bevy_utils::Instant; +use bevy_utils::{HashMap, Instant}; use std::marker::PhantomData; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize; @@ -85,7 +85,7 @@ struct WinitAppRunnerState { impl WinitAppRunnerState { fn new(mut app: App) -> Self { - app.add_event::(); + app.add_event::().init_resource::(); let event_writer_system_state: SystemState<( EventWriter, @@ -131,6 +131,39 @@ impl WinitAppRunnerState { } } +/// Identifiers for custom cursors used in caching. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum CustomCursorCacheKey { + /// u64 is used instead of `AssetId`, because `bevy_asset` can't be imported here. + AssetIndex(u64), + /// u128 is used instead of `AssetId`, because `bevy_asset` can't be imported here. + AssetUuid(u128), + /// A URL to a cursor. + Url(String), +} + +/// Caches custom cursors. On many platforms, creating custom cursors is expensive, especially on +/// the web. +#[derive(Debug, Clone, Default, Resource)] +pub struct CustomCursorCache(pub HashMap); + +/// A source for a cursor. Is created in `bevy_render` and consumed by the winit event loop. +#[derive(Debug)] +pub enum CursorSource { + /// A custom cursor was identified to be cached, no reason to recreate it. + CustomCached(CustomCursorCacheKey), + /// A custom cursor was not cached, so it needs to be created by the winit event loop. + Custom((CustomCursorCacheKey, winit::window::CustomCursorSource)), + /// A system cursor was requested. + System(winit::window::CursorIcon), +} + +/// Component that indicates what cursor should be used for a window. Inserted +/// automatically after changing `CursorIcon` and consumed by the winit event +/// loop. +#[derive(Component, Debug)] +pub struct PendingCursor(pub Option); + impl ApplicationHandler for WinitAppRunnerState { fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { if event_loop.exiting() { @@ -520,6 +553,7 @@ impl ApplicationHandler for WinitAppRunnerState { // This is a temporary solution, full solution is mentioned here: https://github.com/bevyengine/bevy/issues/1343#issuecomment-770091684 if !self.ran_update_since_last_redraw || all_invisible { self.run_app_update(); + self.update_cursors(event_loop); self.ran_update_since_last_redraw = true; } else { self.redraw_requested = true; @@ -528,7 +562,6 @@ impl ApplicationHandler for WinitAppRunnerState { // Running the app may have changed the WinitSettings resource, so we have to re-extract it. let (config, windows) = focused_windows_state.get(self.world()); let focused = windows.iter().any(|(_, window)| window.focused); - update_mode = config.update_mode(focused); } @@ -750,6 +783,42 @@ impl WinitAppRunnerState { .resource_mut::>() .send_batch(buffered_events); } + + fn update_cursors(&mut self, event_loop: &ActiveEventLoop) { + let mut windows_state: SystemState<( + NonSendMut, + ResMut, + Query<(Entity, &mut PendingCursor), Changed>, + )> = SystemState::new(self.world_mut()); + let (winit_windows, mut cursor_cache, mut windows) = + windows_state.get_mut(self.world_mut()); + + for (entity, mut pending_cursor) in windows.iter_mut() { + let Some(winit_window) = winit_windows.get_window(entity) else { + continue; + }; + let Some(pending_cursor) = pending_cursor.0.take() else { + continue; + }; + + let final_cursor: winit::window::Cursor = match pending_cursor { + CursorSource::CustomCached(cache_key) => { + let Some(cached_cursor) = cursor_cache.0.get(&cache_key) else { + error!("Cursor should have been cached, but was not found"); + continue; + }; + cached_cursor.clone().into() + } + CursorSource::Custom((cache_key, cursor)) => { + let custom_cursor = event_loop.create_custom_cursor(cursor); + cursor_cache.0.insert(cache_key, custom_cursor.clone()); + custom_cursor.into() + } + CursorSource::System(system_cursor) => system_cursor.into(), + }; + winit_window.set_cursor(final_cursor); + } + } } /// The default [`App::runner`] for the [`WinitPlugin`](crate::WinitPlugin) plugin. diff --git a/crates/bevy_winit/src/system.rs b/crates/bevy_winit/src/system.rs index d0f39ef3f0..86d3b24acb 100644 --- a/crates/bevy_winit/src/system.rs +++ b/crates/bevy_winit/src/system.rs @@ -29,8 +29,7 @@ use crate::state::react_to_resize; use crate::winit_monitors::WinitMonitors; use crate::{ converters::{ - self, convert_enabled_buttons, convert_window_level, convert_window_theme, - convert_winit_theme, + convert_enabled_buttons, convert_window_level, convert_window_theme, convert_winit_theme, }, get_best_videomode, get_fitting_videomode, select_monitor, CreateMonitorParams, CreateWindowParams, WinitWindows, @@ -365,21 +364,17 @@ pub(crate) fn changed_windows( } } - if window.cursor.icon != cache.window.cursor.icon { - winit_window.set_cursor(converters::convert_cursor_icon(window.cursor.icon)); + if window.cursor_options.grab_mode != cache.window.cursor_options.grab_mode { + crate::winit_windows::attempt_grab(winit_window, window.cursor_options.grab_mode); } - if window.cursor.grab_mode != cache.window.cursor.grab_mode { - crate::winit_windows::attempt_grab(winit_window, window.cursor.grab_mode); + if window.cursor_options.visible != cache.window.cursor_options.visible { + winit_window.set_cursor_visible(window.cursor_options.visible); } - if window.cursor.visible != cache.window.cursor.visible { - winit_window.set_cursor_visible(window.cursor.visible); - } - - if window.cursor.hit_test != cache.window.cursor.hit_test { - if let Err(err) = winit_window.set_cursor_hittest(window.cursor.hit_test) { - window.cursor.hit_test = cache.window.cursor.hit_test; + if window.cursor_options.hit_test != cache.window.cursor_options.hit_test { + if let Err(err) = winit_window.set_cursor_hittest(window.cursor_options.hit_test) { + window.cursor_options.hit_test = cache.window.cursor_options.hit_test; warn!( "Could not set cursor hit test for window {:?}: {:?}", window.title, err diff --git a/crates/bevy_winit/src/winit_windows.rs b/crates/bevy_winit/src/winit_windows.rs index 24d467dfcd..fd1811f1ce 100644 --- a/crates/bevy_winit/src/winit_windows.rs +++ b/crates/bevy_winit/src/winit_windows.rs @@ -247,16 +247,16 @@ impl WinitWindows { ); // Do not set the grab mode on window creation if it's none. It can fail on mobile. - if window.cursor.grab_mode != CursorGrabMode::None { - attempt_grab(&winit_window, window.cursor.grab_mode); + if window.cursor_options.grab_mode != CursorGrabMode::None { + attempt_grab(&winit_window, window.cursor_options.grab_mode); } - winit_window.set_cursor_visible(window.cursor.visible); + winit_window.set_cursor_visible(window.cursor_options.visible); // Do not set the cursor hittest on window creation if it's false, as it will always fail on // some platforms and log an unfixable warning. - if !window.cursor.hit_test { - if let Err(err) = winit_window.set_cursor_hittest(window.cursor.hit_test) { + if !window.cursor_options.hit_test { + if let Err(err) = winit_window.set_cursor_hittest(window.cursor_options.hit_test) { warn!( "Could not set cursor hit test for window {:?}: {:?}", window.title, err diff --git a/examples/games/desk_toy.rs b/examples/games/desk_toy.rs index ec9094feb2..7da9859919 100644 --- a/examples/games/desk_toy.rs +++ b/examples/games/desk_toy.rs @@ -237,7 +237,7 @@ fn update_cursor_hit_test( // If the window has decorations (e.g. a border) then it should be clickable if primary_window.decorations { - primary_window.cursor.hit_test = true; + primary_window.cursor_options.hit_test = true; return; } @@ -248,7 +248,7 @@ fn update_cursor_hit_test( // If the cursor is within the radius of the Bevy logo make the window clickable otherwise the window is not clickable let bevy_logo_transform = q_bevy_logo.single(); - primary_window.cursor.hit_test = bevy_logo_transform + primary_window.cursor_options.hit_test = bevy_logo_transform .translation .truncate() .distance(cursor_world_pos) diff --git a/examples/helpers/camera_controller.rs b/examples/helpers/camera_controller.rs index 86defefb90..62c2a2d2e6 100644 --- a/examples/helpers/camera_controller.rs +++ b/examples/helpers/camera_controller.rs @@ -198,13 +198,13 @@ fn run_camera_controller( continue; } - window.cursor.grab_mode = CursorGrabMode::Locked; - window.cursor.visible = false; + window.cursor_options.grab_mode = CursorGrabMode::Locked; + window.cursor_options.visible = false; } } else { for mut window in &mut windows { - window.cursor.grab_mode = CursorGrabMode::None; - window.cursor.visible = true; + window.cursor_options.grab_mode = CursorGrabMode::None; + window.cursor_options.visible = true; } } } diff --git a/examples/input/mouse_grab.rs b/examples/input/mouse_grab.rs index a812f37f67..d15773804e 100644 --- a/examples/input/mouse_grab.rs +++ b/examples/input/mouse_grab.rs @@ -19,12 +19,12 @@ fn grab_mouse( let mut window = windows.single_mut(); if mouse.just_pressed(MouseButton::Left) { - window.cursor.visible = false; - window.cursor.grab_mode = CursorGrabMode::Locked; + window.cursor_options.visible = false; + window.cursor_options.grab_mode = CursorGrabMode::Locked; } if key.just_pressed(KeyCode::Escape) { - window.cursor.visible = true; - window.cursor.grab_mode = CursorGrabMode::None; + window.cursor_options.visible = true; + window.cursor_options.grab_mode = CursorGrabMode::None; } } diff --git a/examples/ui/window_fallthrough.rs b/examples/ui/window_fallthrough.rs index bfe3c6df0c..9b56d1131e 100644 --- a/examples/ui/window_fallthrough.rs +++ b/examples/ui/window_fallthrough.rs @@ -53,6 +53,6 @@ fn toggle_mouse_passthrough( ) { if keyboard_input.just_pressed(KeyCode::KeyP) { let mut window = windows.single_mut(); - window.cursor.hit_test = !window.cursor.hit_test; + window.cursor_options.hit_test = !window.cursor_options.hit_test; } } diff --git a/examples/window/window_settings.rs b/examples/window/window_settings.rs index 40fdf69d51..4492dd7705 100644 --- a/examples/window/window_settings.rs +++ b/examples/window/window_settings.rs @@ -5,7 +5,8 @@ use bevy::{ core::FrameCount, diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin}, prelude::*, - window::{CursorGrabMode, PresentMode, WindowLevel, WindowTheme}, + render::view::cursor::{CursorIcon, CustomCursor}, + window::{CursorGrabMode, PresentMode, SystemCursorIcon, WindowLevel, WindowTheme}, }; fn main() { @@ -37,6 +38,7 @@ fn main() { LogDiagnosticsPlugin::default(), FrameTimeDiagnosticsPlugin, )) + .add_systems(Startup, init_cursor_icons) .add_systems( Update, ( @@ -137,8 +139,8 @@ fn toggle_cursor(mut windows: Query<&mut Window>, input: Res CursorGrabMode::Locked, CursorGrabMode::Locked | CursorGrabMode::Confined => CursorGrabMode::None, }; @@ -159,31 +161,46 @@ fn toggle_theme(mut windows: Query<&mut Window>, input: Res } } +#[derive(Resource)] +struct CursorIcons(Vec); + +fn init_cursor_icons(mut commands: Commands, asset_server: Res) { + commands.insert_resource(CursorIcons(vec![ + SystemCursorIcon::Default.into(), + SystemCursorIcon::Pointer.into(), + SystemCursorIcon::Wait.into(), + SystemCursorIcon::Text.into(), + CustomCursor::Image { + handle: asset_server.load("branding/icon.png"), + hotspot: (128, 128), + } + .into(), + ])); +} + /// This system cycles the cursor's icon through a small set of icons when clicking fn cycle_cursor_icon( - mut windows: Query<&mut Window>, + mut commands: Commands, + windows: Query>, input: Res>, mut index: Local, + cursor_icons: Res, ) { - let mut window = windows.single_mut(); - - const ICONS: &[CursorIcon] = &[ - CursorIcon::Default, - CursorIcon::Pointer, - CursorIcon::Wait, - CursorIcon::Text, - CursorIcon::Copy, - ]; + let window_entity = windows.single(); if input.just_pressed(MouseButton::Left) { - *index = (*index + 1) % ICONS.len(); + *index = (*index + 1) % cursor_icons.0.len(); + commands + .entity(window_entity) + .insert(cursor_icons.0[*index].clone()); } else if input.just_pressed(MouseButton::Right) { *index = if *index == 0 { - ICONS.len() - 1 + cursor_icons.0.len() - 1 } else { *index - 1 }; + commands + .entity(window_entity) + .insert(cursor_icons.0[*index].clone()); } - - window.cursor.icon = ICONS[*index]; }