1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! Applying a [`PointerIcon`] to a winit window.
//!
//! Standard shapes go straight through as a winit `CursorIcon`; a custom shape
//! has to be uploaded to the windowing system first, which is what the cache
//! here avoids repeating. A skin whose every control carries its own cursor
//! would otherwise rebuild the same handful of images on each pointer move.
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;
/// Whether re-offering `icon` would be dropped before it reaches the platform.
///
/// A backend that already holds the cursor being set treats the call as a
/// no-op, and on macOS that skips the cursor-rectangle invalidation with it.
/// That is exactly the case a refresh is made of: the region under the pointer
/// has not changed, so the icon offered is the one the window already has, and
/// the refresh would do nothing. Offering a different cursor first makes the
/// one that matters a change again.
fn offering_the_same_icon_needs_a_nudge(applied: Option<&PointerIcon>, icon: &PointerIcon) -> bool {
applied.is_some_and(|applied| applied == icon)
}
/// A stock cursor that is never equal to `icon`, for the nudge.
fn a_cursor_that_is_not(icon: &PointerIcon) -> CursorIcon {
match icon {
PointerIcon::System(CursorIcon::Default) => CursorIcon::Pointer,
_ => CursorIcon::Default,
}
}
/// A custom cursor as the platform takes it: the icon's
/// [`CustomPointerIcon::id`] and the scale its image was handed over at.
type CursorKey = (u64, u64);
fn cursor_key(custom: &CustomPointerIcon, factor: f64) -> CursorKey {
(custom.id(), factor.to_bits())
}
/// What the platform under `window` does to a custom cursor image by itself.
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,
// Only macOS enlarges an app's own cursor images; the others show
// them pixel for pixel.
enlarges_custom_images: image_in_points,
}
}
/// Per-window store of the custom cursors the windowing system has accepted,
/// keyed by [`CursorKey`].
#[derive(Default)]
pub(crate) struct DesktopCursors {
size: CustomCursorSize,
uploaded: HashMap<CursorKey, CustomCursor>,
rejected: HashSet<CursorKey>,
applied: HashMap<WindowId, PointerIcon>,
/// Cursors winit cannot build, sized in points rather than one point to a
/// pixel.
#[cfg(target_os = "macos")]
as_drawn: HashMap<CursorKey, crate::macos_cursor::AsDrawnCursor>,
/// The window under the pointer showing one of those, with its cursor
/// rectangles held off, and which one it shows.
#[cfg(target_os = "macos")]
held: HashMap<WindowId, CursorKey>,
/// The pointer moved over a held window since the cursor was last shown.
#[cfg(target_os = "macos")]
moved_over_held: bool,
}
impl DesktopCursors {
pub(crate) fn new(size: CustomCursorSize) -> Self {
Self {
size,
..Self::default()
}
}
/// Sets `icon` as `window`'s cursor, uploading a custom image the first
/// time it appears, scaled so it shows at the size [`CustomCursorSize`]
/// asks for.
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());
// winit still holds the cursor it last set, which may be the
// one wanted now; offering another first makes it a change.
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());
}
/// Shows `icon` sized in points by `factor`, when it is a custom cursor
/// winit's one point to a pixel would show at the wrong size.
#[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
}
/// The pointer moved over, or into, the window `id`.
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;
}
/// Shows the held cursor again after the pointer moved over its window,
/// when the window server may have put its own arrow over it. Called once
/// per pass of the event loop.
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);
}
}
}
}
/// The pointer left `window`: a cursor it held goes back to the window's
/// own rectangles, so it is not kept over whatever the pointer is over now.
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;
}
/// The uploaded cursor for `custom`, uploading it if this is the first time
/// it is asked for.
///
/// A cursor the windowing system refuses is reported once and remembered as
/// rejected, so the window keeps the cursor it already has instead of
/// re-attempting the upload on every pointer move.
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)]
#[path = "tests/desktop_cursor_tests.rs"]
mod tests;