Skip to main content

cranpose_ui/
pointer_icon_session.rs

1//! The pointer icon the platform should be drawing right now.
2//!
3//! The shell resolves the hovered region's [`PointerIcon`] on every pointer
4//! move and records it here; the platform layer reads the pending change back
5//! and applies it to the window it owns (winit's `Window::set_cursor` on
6//! desktop, the canvas's CSS `cursor` on the web). The indirection exists
7//! because neither side can call the other directly: `cranpose-ui` cannot reach
8//! a window handle, and the platform backend is not on the stack when a
9//! modifier declares its icon.
10//!
11//! Reading is a poll rather than a callback because a windowing system wants
12//! the cursor set from its own event loop, holding the handles only that loop
13//! has: the platform asks for the change at the point where it can act on it.
14//!
15//! The session is stored per [`AppContext`](crate::render_state::AppContext),
16//! like the clipboard and text-input sessions, so each window in a multi-window
17//! app carries its own pointer icon.
18
19use std::cell::RefCell;
20
21use cranpose_ui_graphics::PointerIcon;
22
23pub(crate) struct PointerIconState {
24    current: RefCell<PointerIcon>,
25    pending: RefCell<Option<PointerIcon>>,
26}
27
28impl PointerIconState {
29    pub(crate) fn new() -> Self {
30        Self {
31            current: RefCell::new(PointerIcon::DEFAULT),
32            pending: RefCell::new(None),
33        }
34    }
35
36    fn set(&self, icon: PointerIcon) {
37        if *self.current.borrow() == icon {
38            return;
39        }
40        *self.current.borrow_mut() = icon.clone();
41        *self.pending.borrow_mut() = Some(icon);
42    }
43
44    fn take_change(&self) -> Option<PointerIcon> {
45        self.pending.borrow_mut().take()
46    }
47
48    fn refresh(&self) {
49        *self.pending.borrow_mut() = Some(self.current.borrow().clone());
50    }
51
52    fn current(&self) -> PointerIcon {
53        self.current.borrow().clone()
54    }
55}
56
57/// Requests `icon` as the pointer's appearance.
58///
59/// Called by the shell once per pointer move with whatever the hovered region
60/// asks for. Setting the icon the session already holds does nothing, so a
61/// pointer travelling across one region does not re-upload its cursor.
62pub fn set_pointer_icon(icon: PointerIcon) {
63    crate::render_state::with_pointer_icon_session(|state| state.set(icon));
64}
65
66/// Takes the pointer icon change the platform has not applied yet, leaving
67/// nothing behind.
68///
69/// Returns `None` when the icon has not changed since the last call, which is
70/// the common case: a platform backend calls this after every batch of input
71/// and touches its window only when something comes back.
72pub fn take_pointer_icon_change() -> Option<PointerIcon> {
73    crate::render_state::with_pointer_icon_session(|state| state.take_change())
74}
75
76/// Offers the icon the session already holds to the platform again.
77///
78/// A windowing system resets the cursor to its own default on the way back
79/// into a window — when the application is activated, or when the pointer
80/// crosses in — without telling the application what it drew. Nothing in the
81/// hovered region has changed, so no change would be reported and the platform
82/// default would stay on screen over a region that names its own cursor. The
83/// platform layer calls this at those moments so the next poll re-applies what
84/// the region already asked for.
85pub fn refresh_pointer_icon() {
86    crate::render_state::with_pointer_icon_session(|state| state.refresh());
87}
88
89/// The pointer icon currently requested, whether or not the platform has
90/// applied it yet.
91pub fn current_pointer_icon() -> PointerIcon {
92    crate::render_state::with_pointer_icon_session(|state| state.current())
93}
94
95#[cfg(test)]
96mod tests {
97    use cranpose_ui_graphics::{CursorIcon, ImageBitmap};
98
99    use super::*;
100    use crate::render_state::AppContext;
101
102    fn custom_icon() -> PointerIcon {
103        PointerIcon::custom(
104            ImageBitmap::from_rgba8(4, 4, vec![255; 64]).expect("bitmap"),
105            1,
106            2,
107        )
108        .expect("icon")
109    }
110
111    #[test]
112    fn a_fresh_session_holds_the_default_icon_and_no_change() {
113        let context = AppContext::new();
114        context.enter(|| {
115            assert_eq!(current_pointer_icon(), PointerIcon::DEFAULT);
116            assert_eq!(take_pointer_icon_change(), None);
117        });
118    }
119
120    #[test]
121    fn setting_a_new_icon_yields_one_change() {
122        let context = AppContext::new();
123        context.enter(|| {
124            set_pointer_icon(PointerIcon::POINTER);
125            assert_eq!(current_pointer_icon(), PointerIcon::POINTER);
126            assert_eq!(take_pointer_icon_change(), Some(PointerIcon::POINTER));
127            assert_eq!(take_pointer_icon_change(), None);
128        });
129    }
130
131    #[test]
132    fn re_setting_the_same_icon_reports_no_change() {
133        let context = AppContext::new();
134        context.enter(|| {
135            set_pointer_icon(PointerIcon::TEXT);
136            assert_eq!(take_pointer_icon_change(), Some(PointerIcon::TEXT));
137            set_pointer_icon(PointerIcon::TEXT);
138            assert_eq!(take_pointer_icon_change(), None);
139        });
140    }
141
142    #[test]
143    fn the_latest_icon_wins_when_the_platform_has_not_polled() {
144        let context = AppContext::new();
145        context.enter(|| {
146            set_pointer_icon(PointerIcon::POINTER);
147            set_pointer_icon(PointerIcon::System(CursorIcon::Crosshair));
148            assert_eq!(
149                take_pointer_icon_change(),
150                Some(PointerIcon::System(CursorIcon::Crosshair))
151            );
152            assert_eq!(take_pointer_icon_change(), None);
153        });
154    }
155
156    #[test]
157    fn custom_icons_round_trip_through_the_session() {
158        let context = AppContext::new();
159        context.enter(|| {
160            let icon = custom_icon();
161            set_pointer_icon(icon.clone());
162            assert_eq!(take_pointer_icon_change(), Some(icon.clone()));
163            set_pointer_icon(icon);
164            assert_eq!(take_pointer_icon_change(), None);
165        });
166    }
167
168    #[test]
169    fn a_refresh_offers_the_icon_the_region_already_asked_for() {
170        let context = AppContext::new();
171        context.enter(|| {
172            set_pointer_icon(PointerIcon::POINTER);
173            assert_eq!(take_pointer_icon_change(), Some(PointerIcon::POINTER));
174            assert_eq!(take_pointer_icon_change(), None);
175
176            refresh_pointer_icon();
177            assert_eq!(
178                take_pointer_icon_change(),
179                Some(PointerIcon::POINTER),
180                "coming back to the window re-applies the region's own cursor"
181            );
182        });
183    }
184
185    #[test]
186    fn a_refresh_on_a_window_that_asked_for_nothing_restores_the_default() {
187        let context = AppContext::new();
188        context.enter(|| {
189            refresh_pointer_icon();
190            assert_eq!(take_pointer_icon_change(), Some(PointerIcon::DEFAULT));
191        });
192    }
193
194    #[test]
195    fn each_app_context_carries_its_own_icon() {
196        let first = AppContext::new();
197        let second = AppContext::new();
198        first.enter(|| set_pointer_icon(PointerIcon::POINTER));
199        second.enter(|| {
200            assert_eq!(current_pointer_icon(), PointerIcon::DEFAULT);
201            assert_eq!(take_pointer_icon_change(), None);
202        });
203        first.enter(|| {
204            assert_eq!(take_pointer_icon_change(), Some(PointerIcon::POINTER));
205        });
206    }
207}