cranpose_ui/
cursor_animation.rs1use std::cell::Cell;
12use web_time::{Duration, Instant};
13
14pub const BLINK_INTERVAL_MS: u64 = 500;
16
17pub struct CursorAnimationState {
23 cursor_alpha: Cell<f32>,
25 next_blink_time: Cell<Option<Instant>>,
27}
28
29impl CursorAnimationState {
30 pub const BLINK_INTERVAL: Duration = Duration::from_millis(BLINK_INTERVAL_MS);
32
33 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 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 pub fn stop(&self) {
52 self.cursor_alpha.set(1.0); self.next_blink_time.set(None);
54 }
55
56 #[cfg(test)]
58 pub fn is_active(&self) -> bool {
59 self.next_blink_time.get().is_some()
60 }
61
62 pub fn is_visible(&self) -> bool {
64 self.cursor_alpha.get() > 0.5
65 }
66
67 pub fn tick(&self, now: Instant) -> bool {
70 if let Some(next) = self.next_blink_time.get() {
71 if now >= next {
72 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 self.next_blink_time.set(Some(now + Self::BLINK_INTERVAL));
81 return true;
82 }
83 }
84 false
85 }
86
87 pub fn next_blink_time(&self) -> Option<Instant> {
90 self.next_blink_time.get()
91 }
92}
93
94pub fn start_cursor_blink() {
101 crate::render_state::with_cursor_animation(|state| state.start());
102}
103
104pub fn stop_cursor_blink() {
107 crate::render_state::with_cursor_animation(|state| state.stop());
108}
109
110#[inline]
113pub fn reset_cursor_blink() {
114 start_cursor_blink();
115}
116
117pub fn is_cursor_visible() -> bool {
119 crate::render_state::with_cursor_animation(|state| state.is_visible())
120}
121
122pub fn tick_cursor_blink() -> bool {
125 crate::render_state::with_cursor_animation(|state| state.tick(Instant::now()))
126}
127
128pub 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()); }
162
163 #[test]
164 fn tick_toggles_visibility() {
165 let state = CursorAnimationState::new();
166 state.start();
167 assert!(state.is_visible());
168
169 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()); 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()); }
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}