Skip to main content

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}