cranpose_ui/cursor_animation.rs
1//! Cursor blink animation state.
2//!
3//! Provides timer-based cursor visibility for text fields,
4//! avoiding continuous redraw when a field is focused.
5//!
6//! This follows the pattern from Jetpack Compose's `CursorAnimationState.kt`:
7//! - Cursor visibility toggles on a fixed interval
8//! - Only requests redraw at transition times, not continuously
9//! - Uses platform timer scheduling (`WaitUntil`) instead of busy-polling
10
11use std::cell::Cell;
12use web_time::{Duration, Instant};
13
14/// Cursor blink interval in milliseconds.
15pub const BLINK_INTERVAL_MS: u64 = 500;
16
17thread_local! {
18 /// Global cursor animation state.
19 /// Shared by all text fields - only the focused one renders the cursor.
20 static CURSOR_STATE: CursorAnimationState = const { CursorAnimationState::new() };
21}
22
23/// Cursor blink animation state.
24///
25/// Manages timed visibility transitions instead of continuous redraw.
26/// The cursor alternates between visible (alpha=1.0) and hidden (alpha=0.0)
27/// on a fixed interval.
28pub struct CursorAnimationState {
29 /// Current cursor alpha (0.0 = hidden, 1.0 = visible)
30 cursor_alpha: Cell<f32>,
31 /// Next scheduled blink transition time
32 next_blink_time: Cell<Option<Instant>>,
33}
34
35impl CursorAnimationState {
36 /// Blink interval duration
37 pub const BLINK_INTERVAL: Duration = Duration::from_millis(BLINK_INTERVAL_MS);
38
39 /// Creates a new cursor animation state (cursor initially visible, not blinking).
40 pub const fn new() -> Self {
41 Self {
42 cursor_alpha: Cell::new(1.0),
43 next_blink_time: Cell::new(None),
44 }
45 }
46
47 /// Starts the blink animation (called when a text field gains focus).
48 /// Resets cursor to visible and schedules the first transition.
49 pub fn start(&self) {
50 self.cursor_alpha.set(1.0);
51 self.next_blink_time
52 .set(Some(Instant::now() + Self::BLINK_INTERVAL));
53 }
54
55 /// Stops the blink animation (called when text field loses focus).
56 /// Resets cursor to visible for next focus.
57 pub fn stop(&self) {
58 self.cursor_alpha.set(1.0); // Reset to visible for next focus
59 self.next_blink_time.set(None);
60 }
61
62 /// Returns whether blinking is active.
63 #[allow(dead_code)]
64 pub fn is_active(&self) -> bool {
65 self.next_blink_time.get().is_some()
66 }
67
68 /// Returns the current cursor alpha (0.0 or 1.0).
69 #[allow(dead_code)]
70 pub fn alpha(&self) -> f32 {
71 self.cursor_alpha.get()
72 }
73
74 /// Returns whether the cursor is currently visible.
75 pub fn is_visible(&self) -> bool {
76 self.cursor_alpha.get() > 0.5
77 }
78
79 /// Advances the blink state if the transition time has passed.
80 /// Returns `true` if the state changed (redraw needed).
81 pub fn tick(&self, now: Instant) -> bool {
82 if let Some(next) = self.next_blink_time.get() {
83 if now >= next {
84 // Toggle visibility
85 let new_alpha = if self.cursor_alpha.get() > 0.5 {
86 0.0
87 } else {
88 1.0
89 };
90 self.cursor_alpha.set(new_alpha);
91 // Schedule next transition
92 self.next_blink_time.set(Some(now + Self::BLINK_INTERVAL));
93 return true;
94 }
95 }
96 false
97 }
98
99 /// Returns the next blink transition time, if blinking is active.
100 /// Use this for `WaitUntil` scheduling.
101 pub fn next_blink_time(&self) -> Option<Instant> {
102 self.next_blink_time.get()
103 }
104}
105
106// ============================================================================
107// Global accessor functions (thread-local)
108// ============================================================================
109
110/// Starts the global cursor blink animation.
111/// Called when a text field gains focus.
112pub fn start_cursor_blink() {
113 CURSOR_STATE.with(|state| state.start());
114}
115
116/// Stops the global cursor blink animation.
117/// Called when no text field is focused.
118pub fn stop_cursor_blink() {
119 CURSOR_STATE.with(|state| state.stop());
120}
121
122/// Resets cursor to visible and restarts the blink timer.
123/// Call this on any input (key press, paste) so cursor stays visible while typing.
124#[inline]
125pub fn reset_cursor_blink() {
126 start_cursor_blink();
127}
128
129/// Returns whether the cursor should be visible right now.
130pub fn is_cursor_visible() -> bool {
131 CURSOR_STATE.with(|state| state.is_visible())
132}
133
134/// Advances the cursor blink state if needed.
135/// Returns `true` if a redraw is needed.
136pub fn tick_cursor_blink() -> bool {
137 CURSOR_STATE.with(|state| state.tick(Instant::now()))
138}
139
140/// Returns the next cursor blink transition time, if any.
141/// Use this for `WaitUntil` scheduling in the event loop.
142pub fn next_cursor_blink_time() -> Option<Instant> {
143 CURSOR_STATE.with(|state| state.next_blink_time())
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn cursor_starts_visible() {
152 let state = CursorAnimationState::new();
153 assert!(state.is_visible());
154 assert!(!state.is_active());
155 }
156
157 #[test]
158 fn start_schedules_blink() {
159 let state = CursorAnimationState::new();
160 state.start();
161 assert!(state.is_active());
162 assert!(state.next_blink_time().is_some());
163 }
164
165 #[test]
166 fn stop_clears_blink() {
167 let state = CursorAnimationState::new();
168 state.start();
169 state.stop();
170 assert!(!state.is_active());
171 assert!(state.next_blink_time().is_none());
172 assert!(state.is_visible()); // Should be visible after stop
173 }
174
175 #[test]
176 fn tick_toggles_visibility() {
177 let state = CursorAnimationState::new();
178 state.start();
179 assert!(state.is_visible());
180
181 // Simulate time passing beyond blink interval
182 let future_time =
183 Instant::now() + CursorAnimationState::BLINK_INTERVAL + Duration::from_millis(1);
184 let changed = state.tick(future_time);
185
186 assert!(changed);
187 assert!(!state.is_visible()); // Should have toggled
188
189 // Tick again after another interval
190 let future_time2 =
191 future_time + CursorAnimationState::BLINK_INTERVAL + Duration::from_millis(1);
192 let changed2 = state.tick(future_time2);
193
194 assert!(changed2);
195 assert!(state.is_visible()); // Should toggle back
196 }
197}