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