返回 CodeWhale
spinner.rs
根目录 / crates / tui / src / tui / spinner.rs
1 //! Shared animation frames for running-state UI chrome.
2 //!
3 //! Keep the braille spinner in one place so transcript tool cards, sidebars,
4 //! and any future running-job surfaces advance with the same cadence.
5 //!
6 //! Motion *policy* (whether to animate at all) lives in
7 //! [`crate::tui::motion::MotionPolicy`]. Callers that already have a policy
8 //! should prefer [`crate::tui::motion::MotionPolicy::spinner_glyph`]; the
9 //! helpers here remain the shared frame table + elapsed-time index.
10
11 use std::time::{Instant, SystemTime, UNIX_EPOCH};
12
13 /// A small swell for running tools and background jobs. Rise and recede
14 /// through adjacent dot counts, including across the loop boundary. The
15 /// marker stays visible and never flashes from a full block to empty.
16 /// Like Ratatui Spinner's pulse studies, the return path is part of the motion;
17 /// our quieter six-dot peak and existing clock keep it subordinate to the text.
18 pub(crate) const BRAILLE_SPINNER_FRAMES: [&str; 8] = ["⣀", "⣄", "⣤", "⣦", "⣶", "⣦", "⣤", "⣄"];
19 pub(crate) const VERIFY_TICK_FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
20
21 /// A motion marker is earned only after work survives the eye's quick-event
22 /// window. Faster work should simply land as a receipt.
23 pub(crate) const LIVE_MARKER_DELAY_MS: u64 = 400;
24 pub(crate) const LIVE_STATIC_MARKER: &str = "›";
25 pub(crate) const BRAILLE_SPINNER_STILL_FRAME: &str = "⣤";
26
27 /// Five stepped states per second. This is deliberately slower than the
28 /// underwater field's ~8fps caustic cadence: the marker communicates active
29 /// work, while the field stays subordinate atmosphere. Calmed from 8 Hz
30 /// (125 ms) for v0.9.4 — at 5 Hz the fill still reads as continuous motion
31 /// without the restless flicker the faster table produced.
32 pub(crate) const BRAILLE_SPINNER_FRAME_MS: u64 = 200;
33
34 #[must_use]
35 pub(crate) fn braille_spinner_frame_for_elapsed_ms(
36 elapsed_ms: u128,
37 low_motion: bool,
38 ) -> &'static str {
39 if low_motion {
40 return BRAILLE_SPINNER_STILL_FRAME;
41 }
42 if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
43 return LIVE_STATIC_MARKER;
44 }
45 let idx = elapsed_ms
46 .saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
47 .checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
48 .map_or(0, |frame| frame % BRAILLE_SPINNER_FRAMES.len() as u128);
49 BRAILLE_SPINNER_FRAMES[usize::try_from(idx).unwrap_or_default()]
50 }
51
52 #[must_use]
53 pub(crate) fn braille_spinner_frame(started_at: Option<Instant>, low_motion: bool) -> &'static str {
54 braille_spinner_frame_for_elapsed_ms(marker_elapsed_ms(started_at), low_motion)
55 }
56
57 #[must_use]
58 pub(crate) fn verification_tick_frame(
59 started_at: Option<Instant>,
60 low_motion: bool,
61 ) -> &'static str {
62 if low_motion {
63 return VERIFY_TICK_FRAMES[4];
64 }
65 let elapsed_ms = marker_elapsed_ms(started_at);
66 if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
67 return LIVE_STATIC_MARKER;
68 }
69 let idx = elapsed_ms
70 .saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
71 .checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
72 .map_or(0, |frame| frame % VERIFY_TICK_FRAMES.len() as u128);
73 VERIFY_TICK_FRAMES[usize::try_from(idx).unwrap_or_default()]
74 }
75
76 fn marker_elapsed_ms(started_at: Option<Instant>) -> u128 {
77 started_at.map_or_else(
78 || {
79 SystemTime::now()
80 .duration_since(UNIX_EPOCH)
81 .map_or(0, |duration| duration.as_millis())
82 },
83 |started| started.elapsed().as_millis(),
84 )
85 }
86
87 #[cfg(test)]
88 mod tests {
89 use super::*;
90
91 #[test]
92 fn braille_spinner_advances_at_shared_cadence() {
93 // Assert cadence behavior against the frame table rather than specific
94 // glyphs so the whale-spout pattern can be retuned without churn here.
95 assert_eq!(
96 braille_spinner_frame_for_elapsed_ms(0, false),
97 LIVE_STATIC_MARKER
98 );
99 assert_eq!(
100 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS) - 1, false),
101 LIVE_STATIC_MARKER
102 );
103 assert_eq!(
104 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS), false),
105 BRAILLE_SPINNER_FRAMES[0]
106 );
107 assert_eq!(
108 braille_spinner_frame_for_elapsed_ms(
109 u128::from(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS),
110 false,
111 ),
112 BRAILLE_SPINNER_FRAMES[1]
113 );
114 }
115
116 #[test]
117 fn active_marker_uses_a_stable_five_hertz_wall_clock() {
118 assert_eq!(BRAILLE_SPINNER_FRAME_MS, 200);
119 for (index, frame) in BRAILLE_SPINNER_FRAMES.iter().enumerate() {
120 assert_eq!(
121 braille_spinner_frame_for_elapsed_ms(
122 u128::from(LIVE_MARKER_DELAY_MS)
123 + u128::from(BRAILLE_SPINNER_FRAME_MS) * index as u128,
124 false,
125 ),
126 *frame
127 );
128 assert_eq!(
129 unicode_width::UnicodeWidthStr::width(*frame),
130 1,
131 "active marker frames must never shift adjacent text"
132 );
133 }
134 }
135
136 #[test]
137 fn working_swell_has_no_blank_flash_or_loop_seam() {
138 let dots: Vec<u32> = BRAILLE_SPINNER_FRAMES
139 .iter()
140 .map(|frame| (u32::from(frame.chars().next().unwrap()) - 0x2800).count_ones())
141 .collect();
142 for index in 0..dots.len() {
143 assert!((2..=6).contains(&dots[index]));
144 assert_eq!(dots[index].abs_diff(dots[(index + 1) % dots.len()]), 1);
145 }
146 }
147
148 #[test]
149 fn braille_spinner_respects_low_motion() {
150 assert_eq!(
151 braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS) * 3, true),
152 BRAILLE_SPINNER_STILL_FRAME
153 );
154 }
155
156 #[test]
157 fn verification_tick_is_distinct_and_freezes_legibly() {
158 let start = Instant::now() - std::time::Duration::from_millis(LIVE_MARKER_DELAY_MS);
159 assert_eq!(
160 verification_tick_frame(Some(start), false),
161 VERIFY_TICK_FRAMES[0]
162 );
163 assert_eq!(
164 verification_tick_frame(Some(start), true),
165 VERIFY_TICK_FRAMES[4]
166 );
167 assert_ne!(VERIFY_TICK_FRAMES[0], BRAILLE_SPINNER_FRAMES[0]);
168 }
169 }
170
170 lines RUST