use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use cranpose_ui::{CursorIcon, CustomPointerIcon, PointerIcon};
use winit::{
cursor::{Cursor, CustomCursor, CustomCursorSource},
event_loop::ActiveEventLoop,
window::{Window, WindowId},
};
use crate::CustomCursorSize;
fn offering_the_same_icon_needs_a_nudge(applied: Option<&PointerIcon>, icon: &PointerIcon) -> bool {
applied.is_some_and(|applied| applied == icon)
}
fn a_cursor_that_is_not(icon: &PointerIcon) -> CursorIcon {
match icon {
PointerIcon::System(CursorIcon::Default) => CursorIcon::Pointer,
_ => CursorIcon::Default,
}
}
type CursorKey = (u64, u64);
fn cursor_key(custom: &CustomPointerIcon, factor: f64) -> CursorKey {
(custom.id(), factor.to_bits())
}
fn cursor_surface(window: &dyn Window) -> crate::cursor_scale::CursorSurface {
#[cfg(target_os = "macos")]
let (pointer_scale, image_in_points) = (crate::macos_cursor::pointer_scale(), true);
#[cfg(target_os = "windows")]
let (pointer_scale, image_in_points) = (crate::windows_cursor::pointer_scale(), false);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let (pointer_scale, image_in_points) = (
crate::cursor_scale::xcursor_scale(std::env::var("XCURSOR_SIZE").ok().as_deref()),
false,
);
crate::cursor_scale::CursorSurface {
pointer_scale,
density: window.scale_factor(),
image_in_points,
enlarges_custom_images: image_in_points,
}
}
#[derive(Default)]
pub(crate) struct DesktopCursors {
size: CustomCursorSize,
uploaded: HashMap<CursorKey, CustomCursor>,
rejected: HashSet<CursorKey>,
applied: HashMap<WindowId, PointerIcon>,
#[cfg(target_os = "macos")]
as_drawn: HashMap<CursorKey, crate::macos_cursor::AsDrawnCursor>,
#[cfg(target_os = "macos")]
held: HashMap<WindowId, CursorKey>,
#[cfg(target_os = "macos")]
moved_over_held: bool,
}
impl DesktopCursors {
pub(crate) fn new(size: CustomCursorSize) -> Self {
Self {
size,
..Self::default()
}
}
pub(crate) fn apply(
&mut self,
event_loop: &dyn ActiveEventLoop,
window: &Arc<dyn Window>,
icon: &PointerIcon,
) {
let id = window.id();
let factor = match icon {
PointerIcon::Custom(_) => {
crate::cursor_scale::image_scale(self.size, cursor_surface(window.as_ref()))
}
PointerIcon::System(_) => 1.0,
};
#[cfg(target_os = "macos")]
{
if self.hold_as_drawn(window, icon, factor) {
self.applied.insert(id, icon.clone());
return;
}
if self.held.remove(&id).is_some() {
crate::macos_cursor::release(window.as_ref());
window.set_cursor(Cursor::Icon(a_cursor_that_is_not(icon)));
}
}
if offering_the_same_icon_needs_a_nudge(self.applied.get(&id), icon) {
window.set_cursor(Cursor::Icon(a_cursor_that_is_not(icon)));
}
match icon {
PointerIcon::System(system) => window.set_cursor(Cursor::Icon(*system)),
PointerIcon::Custom(custom) => {
if let Some(cursor) = self.custom_cursor(event_loop, custom, factor) {
window.set_cursor(Cursor::Custom(cursor));
}
}
}
self.applied.insert(id, icon.clone());
}
#[cfg(target_os = "macos")]
fn hold_as_drawn(&mut self, window: &Arc<dyn Window>, icon: &PointerIcon, factor: f64) -> bool {
let PointerIcon::Custom(custom) = icon else {
return false;
};
if !crate::cursor_scale::rescales(factor) {
return false;
}
let key = cursor_key(custom, factor);
let cursor = match self.as_drawn.entry(key) {
std::collections::hash_map::Entry::Occupied(built) => built.into_mut(),
std::collections::hash_map::Entry::Vacant(slot) => {
let image = custom.image();
let Some(cursor) = crate::macos_cursor::as_drawn(
image.pixels(),
image.width(),
image.height(),
(custom.hotspot_x(), custom.hotspot_y()),
factor,
) else {
return false;
};
slot.insert(cursor)
}
};
crate::macos_cursor::hold(window.as_ref(), cursor);
self.held.insert(window.id(), key);
true
}
pub(crate) fn pointer_moved(&mut self, id: WindowId) {
#[cfg(target_os = "macos")]
if self.held.contains_key(&id) {
self.moved_over_held = true;
}
#[cfg(not(target_os = "macos"))]
let _ = id;
}
pub(crate) fn keep_held(&mut self) {
#[cfg(target_os = "macos")]
if std::mem::take(&mut self.moved_over_held) {
for key in self.held.values() {
if let Some(cursor) = self.as_drawn.get(key) {
crate::macos_cursor::keep(cursor);
}
}
}
}
pub(crate) fn pointer_left(&mut self, window: &Arc<dyn Window>) {
#[cfg(target_os = "macos")]
if self.held.remove(&window.id()).is_some() {
crate::macos_cursor::release(window.as_ref());
}
#[cfg(not(target_os = "macos"))]
let _ = window;
}
fn custom_cursor(
&mut self,
event_loop: &dyn ActiveEventLoop,
custom: &CustomPointerIcon,
factor: f64,
) -> Option<CustomCursor> {
let id = cursor_key(custom, factor);
if let Some(cursor) = self.uploaded.get(&id) {
return Some(cursor.clone());
}
if self.rejected.contains(&id) {
return None;
}
let image = custom.image();
let hotspot = (custom.hotspot_x(), custom.hotspot_y());
let (pixels, width, height, hotspot) = if crate::cursor_scale::rescales(factor) {
crate::cursor_scale::scaled_image(
image.pixels(),
image.width(),
image.height(),
hotspot,
factor,
)
} else {
(
image.pixels().to_vec(),
image.width(),
image.height(),
hotspot,
)
};
let source = CustomCursorSource::from_rgba(
pixels,
width as u16,
height as u16,
hotspot.0 as u16,
hotspot.1 as u16,
);
let cursor = match source {
Ok(source) => event_loop.create_custom_cursor(source).map_err(|error| {
log::warn!("the windowing system refused a custom cursor: {error}");
}),
Err(error) => {
log::warn!("a custom cursor image was rejected: {error}");
Err(())
}
};
match cursor {
Ok(cursor) => {
self.uploaded.insert(id, cursor.clone());
Some(cursor)
}
Err(()) => {
self.rejected.insert(id);
None
}
}
}
}
#[cfg(test)]
mod tests {
use cranpose_ui::{CursorIcon, PointerIcon};
use super::{a_cursor_that_is_not, offering_the_same_icon_needs_a_nudge};
#[test]
fn re_offering_the_icon_a_window_already_has_needs_a_nudge() {
let icon = PointerIcon::System(CursorIcon::Grab);
assert!(
offering_the_same_icon_needs_a_nudge(Some(&icon), &icon),
"a refresh offers the icon the window already holds, and the \
backend drops that call along with the invalidation the platform \
needs, so the cursor would never be re-drawn"
);
}
#[test]
fn a_genuine_change_goes_straight_through() {
let held = PointerIcon::System(CursorIcon::Grab);
let wanted = PointerIcon::System(CursorIcon::Text);
assert!(!offering_the_same_icon_needs_a_nudge(Some(&held), &wanted));
assert!(!offering_the_same_icon_needs_a_nudge(None, &wanted));
}
#[test]
fn the_nudge_is_never_the_icon_it_is_making_room_for() {
for icon in [
PointerIcon::DEFAULT,
PointerIcon::System(CursorIcon::Grab),
PointerIcon::System(CursorIcon::Text),
] {
assert_ne!(
PointerIcon::System(a_cursor_that_is_not(&icon)),
icon,
"a nudge equal to the target would be dropped exactly like the \
call it is making room for: {icon:?}"
);
}
}
}