返回 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 /// Braille bubble frames used for running tools and background jobs. Dots fill
14 /// upward, then release. Eight distinct states at five hertz keep the motion
15 /// continuous without turning the one-cell marker into a high-frequency
16 /// spinner.
17 pub(crate) const BRAILLE_SPINNER_FRAMES: [&str; 8] = ["⠀", "⢀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣿"];
18 pub(crate) const VERIFY_TICK_FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
19
20 /// A motion marker is earned only after work survives the eye's quick-event
21 /// window. Faster work should simply land as a receipt.
22 pub(crate) const LIVE_MARKER_DELAY_MS: u64 = 400;
23 pub(crate) const LIVE_STATIC_MARKER: &str = "›";
24 pub(crate) const BRAILLE_SPINNER_STILL_FRAME: &str = "⣤";
25
26 /// Five stepped states per second. This is deliberately slower than the
27 /// underwater field's ~8fps caustic cadence: the marker communicates active
28 /// work, while the field stays subordinate atmosphere. Calmed from 8 Hz
29 /// (125 ms) for v0.9.4 — at 5 Hz the fill still reads as continuous motion
30 /// without the restless flicker the faster table produced.
31 pub(crate) const BRAILLE_SPINNER_FRAME_MS: u64 = 200;
32
33 #[must_use]
34 pub(crate) fn braille_spinner_frame_for_elapsed_ms(
35 elapsed_ms: u128,
36 low_motion: bool,
37 ) -> &'static str {
38 if low_motion {
39 return BRAILLE_SPINNER_STILL_FRAME;
40 }
41 if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
42 return LIVE_STATIC_MARKER;
43 }
44 let idx = elapsed_ms
45 .saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
46 .checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
47 .map_or(0, |frame| frame % BRAILLE_SPINNER_FRAMES.len() as u128);
48 BRAILLE_SPINNER_FRAMES[usize::try_from(idx).unwrap_or_default()]
49 }
50
51 #[must_use]
52 pub(crate) fn braille_spinner_frame(started_at: Option<Instant>, low_motion: bool) -> &'static str {
53 braille_spinner_frame_for_elapsed_ms(marker_elapsed_ms(started_at), low_motion)
54 }
55
56 #[must_use]
57 pub(crate) fn verification_tick_frame(
58 started_at: Option<Instant>,
59 low_motion: bool,
60 ) -> &'static str {
61 if low_motion {
62 return VERIFY_TICK_FRAMES[4];
63 }
64 let elapsed_ms = marker_elapsed_ms(started_at);
65 if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
66 return LIVE_STATIC_MARKER;
67 }
68 let idx = elapsed_ms
69 .saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
70 .checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
71 .map_or(0, |frame| frame % VERIFY_TICK_FRAMES.len() as u128);
72 VERIFY_TICK_FRAMES[usize::try_from(idx).unwrap_or_default()]
73 }
74
75 fn marker_elapsed_ms(started_at: Option<Instant>) -> u128 {
76 started_at.map_or_else(
77 || {
78 SystemTime::now()
79 .duration_since(UNIX_EPOCH)
80 .map_or(0, |duration| duration.as_millis())
81 },
82 |started| started.elapsed().as_millis(),
83 )
84 }
85
86 #[cfg(test)]
87 mod tests {
88 use super::*;
89
90 #[test]
91 fn braille_spinner_advances_at_shared_cadence() {
92 // Assert cadence behavior against the frame table rather than specific
93 // glyphs so the whale-spout pattern can be retuned without churn here.
94 assert_eq!(
95 braille_spinner_frame_for_elapsed_ms(0, false),
96 LIVE_STATIC_MARKER
97 );
98 assert_eq!(
99 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS) - 1, false),
100 LIVE_STATIC_MARKER
101 );
102 assert_eq!(
103 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS), false),
104 BRAILLE_SPINNER_FRAMES[0]
105 );
106 assert_eq!(
107 braille_spinner_frame_for_elapsed_ms(
108 u128::from(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS),
109 false,
110 ),
111 BRAILLE_SPINNER_FRAMES[1]
112 );
113 }
114
115 #[test]
116 fn active_marker_uses_a_stable_five_hertz_wall_clock() {
117 assert_eq!(BRAILLE_SPINNER_FRAME_MS, 200);
118 for (index, frame) in BRAILLE_SPINNER_FRAMES.iter().enumerate() {
119 assert_eq!(
120 braille_spinner_frame_for_elapsed_ms(
121 u128::from(LIVE_MARKER_DELAY_MS)
122 + u128::from(BRAILLE_SPINNER_FRAME_MS) * index as u128,
123 false,
124 ),
125 *frame
126 );
127 assert_eq!(
128 unicode_width::UnicodeWidthStr::width(*frame),
129 1,
130 "active marker frames must never shift adjacent text"
131 );
132 }
133 }
134
135 #[test]
136 fn braille_spinner_respects_low_motion() {
137 assert_eq!(
138 braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS) * 3, true),
139 BRAILLE_SPINNER_STILL_FRAME
140 );
141 }
142
143 #[test]
144 fn verification_tick_is_distinct_and_freezes_legibly() {
145 let start = Instant::now() - std::time::Duration::from_millis(LIVE_MARKER_DELAY_MS);
146 assert_eq!(
147 verification_tick_frame(Some(start), false),
148 VERIFY_TICK_FRAMES[0]
149 );
150 assert_eq!(
151 verification_tick_frame(Some(start), true),
152 VERIFY_TICK_FRAMES[4]
153 );
154 assert_ne!(VERIFY_TICK_FRAMES[0], BRAILLE_SPINNER_FRAMES[0]);
155 }
156 }
157
157 lines RUST