返回 CodeWhale
spinner.rs
根目录 / crates / tui / src / tui / spinner.rs
1 //! Working and verification indicators from the shared terminal component kit.
2 //!
3 //! This replaces the Engine's duplicate frame tables, earned-marker delay and
4 //! frame indexing. The Engine still supplies clocks and motion policy, and its
5 //! existing backend performs ASCII/color adaptation. Quiet indicators use the
6 //! kit's semantic current-work mark rather than a frozen animation frame.
7
8 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10 use codewhale_ratatui::{MotionMode, VerificationSpinner, spin};
11
12 #[cfg(test)]
13 pub(crate) const BRAILLE_SPINNER_FRAMES: [&str; 8] = spin::FRAMES;
14 #[cfg(test)]
15 const VERIFY_TICK_FRAMES: [&str; 8] = VerificationSpinner::FRAMES;
16 pub(crate) const LIVE_MARKER_DELAY_MS: u64 = spin::EARN_DELAY.as_millis() as u64;
17 pub(crate) const LIVE_STATIC_MARKER: &str = spin::PENDING_FRAME;
18 pub(crate) const BRAILLE_SPINNER_STILL_FRAME: &str = spin::STILL_FRAME;
19 pub(crate) const BRAILLE_SPINNER_FRAME_MS: u64 = spin::FRAME_INTERVAL.as_millis() as u64;
20
21 #[must_use]
22 pub(crate) fn braille_spinner_frame_for_elapsed_ms(
23 elapsed_ms: u128,
24 low_motion: bool,
25 ) -> &'static str {
26 spin::frame(elapsed(elapsed_ms), motion(low_motion), false)
27 }
28
29 #[must_use]
30 pub(crate) fn braille_spinner_frame(started_at: Option<Instant>, low_motion: bool) -> &'static str {
31 braille_spinner_frame_for_elapsed_ms(marker_elapsed_ms(started_at), low_motion)
32 }
33
34 #[must_use]
35 pub(crate) fn verification_tick_frame(
36 started_at: Option<Instant>,
37 low_motion: bool,
38 ) -> &'static str {
39 VerificationSpinner::frame(
40 elapsed(marker_elapsed_ms(started_at)),
41 motion(low_motion),
42 false,
43 )
44 }
45
46 fn motion(low_motion: bool) -> MotionMode {
47 if low_motion {
48 MotionMode::Reduced
49 } else {
50 MotionMode::Full
51 }
52 }
53
54 fn elapsed(milliseconds: u128) -> Duration {
55 Duration::from_millis(u64::try_from(milliseconds).unwrap_or(u64::MAX))
56 }
57
58 fn marker_elapsed_ms(started_at: Option<Instant>) -> u128 {
59 started_at.map_or_else(
60 || {
61 SystemTime::now()
62 .duration_since(UNIX_EPOCH)
63 .map_or(0, |duration| duration.as_millis())
64 },
65 |started| started.elapsed().as_millis(),
66 )
67 }
68
69 #[cfg(test)]
70 mod tests {
71 use super::*;
72
73 #[test]
74 fn braille_spinner_advances_at_shared_cadence() {
75 // Assert cadence behavior against the frame table rather than specific
76 // glyphs so the whale-spout pattern can be retuned without churn here.
77 assert_eq!(
78 braille_spinner_frame_for_elapsed_ms(0, false),
79 LIVE_STATIC_MARKER
80 );
81 assert_eq!(
82 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS) - 1, false),
83 LIVE_STATIC_MARKER
84 );
85 assert_eq!(
86 braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS), false),
87 BRAILLE_SPINNER_FRAMES[0]
88 );
89 assert_eq!(
90 braille_spinner_frame_for_elapsed_ms(
91 u128::from(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS),
92 false,
93 ),
94 BRAILLE_SPINNER_FRAMES[1]
95 );
96 }
97
98 #[test]
99 fn active_marker_uses_a_stable_five_hertz_wall_clock() {
100 assert_eq!(BRAILLE_SPINNER_FRAME_MS, 200);
101 for (index, frame) in BRAILLE_SPINNER_FRAMES.iter().enumerate() {
102 assert_eq!(
103 braille_spinner_frame_for_elapsed_ms(
104 u128::from(LIVE_MARKER_DELAY_MS)
105 + u128::from(BRAILLE_SPINNER_FRAME_MS) * index as u128,
106 false,
107 ),
108 *frame
109 );
110 assert_eq!(
111 unicode_width::UnicodeWidthStr::width(*frame),
112 1,
113 "active marker frames must never shift adjacent text"
114 );
115 }
116 }
117
118 #[test]
119 fn working_swell_has_no_blank_flash_or_loop_seam() {
120 let dots: Vec<u32> = BRAILLE_SPINNER_FRAMES
121 .iter()
122 .map(|frame| (u32::from(frame.chars().next().unwrap()) - 0x2800).count_ones())
123 .collect();
124 for index in 0..dots.len() {
125 assert!((2..=6).contains(&dots[index]));
126 assert_eq!(dots[index].abs_diff(dots[(index + 1) % dots.len()]), 1);
127 }
128 }
129
130 #[test]
131 fn braille_spinner_respects_low_motion() {
132 assert_eq!(
133 braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS) * 3, true),
134 BRAILLE_SPINNER_STILL_FRAME
135 );
136 }
137
138 #[test]
139 fn verification_tick_is_distinct_and_freezes_legibly() {
140 let start = Instant::now() - std::time::Duration::from_millis(LIVE_MARKER_DELAY_MS);
141 assert_eq!(
142 verification_tick_frame(Some(start), false),
143 VERIFY_TICK_FRAMES[0]
144 );
145 assert_eq!(
146 verification_tick_frame(Some(start), true),
147 BRAILLE_SPINNER_STILL_FRAME
148 );
149 assert_ne!(VERIFY_TICK_FRAMES[0], BRAILLE_SPINNER_FRAMES[0]);
150 }
151 }
152
152 lines RUST