返回 CodeWhale
spinner.rs
1 //! Motion policy and the working spinner.
2 //!
3 //! Frames, cadence and the earned-marker delay come from the engine's
4 //! `crates/tui/src/tui/spinner.rs` and `motion/mode.rs` (`Hmbown/CodeWhale`
5 //! `58b1dd3dd`; last changed in `9b34ab54b` and `ad20493c0e`). Motion shows
6 //! something real: the spinner runs only while work runs, and reduced motion
7 //! holds one readable frame.
8
9 use std::time::Duration;
10
11 use ratatui::{buffer::Buffer, layout::Rect, text::Span, widgets::Widget};
12
13 use crate::{Paint, Role, Theme, text};
14
15 /// How much the interface moves.
16 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
17 pub enum MotionMode {
18 /// Spinners and the whale's frames run.
19 #[default]
20 Full,
21 /// Static markers only (`low_motion`).
22 Reduced,
23 /// Nothing animates; redraw on state change only.
24 Still,
25 }
26
27 impl MotionMode {
28 /// From the engine's two settings: `low_motion` wins over
29 /// `fancy_animations = false`.
30 #[must_use]
31 pub const fn from_settings(low_motion: bool, fancy_animations: bool) -> Self {
32 if low_motion {
33 Self::Reduced
34 } else if !fancy_animations {
35 Self::Still
36 } else {
37 Self::Full
38 }
39 }
40
41 #[must_use]
42 pub const fn animates(self) -> bool {
43 matches!(self, Self::Full)
44 }
45 }
46
47 /// A small swell: rises and recedes through adjacent dot counts, and never
48 /// flashes from full to empty.
49 pub const FRAMES: [&str; 8] = ["⣀", "⣄", "⣤", "⣦", "⣶", "⣦", "⣤", "⣄"];
50 /// Held under reduced motion: the charter's mark for current work, so a
51 /// still spinner says "working" instead of showing a stopped animation.
52 pub const STILL_FRAME: &str = crate::glyphs::CURRENT;
53 /// The swell in ASCII-safe terminals. `*` would read as "needs you" there.
54 pub const ASCII_FRAMES: [&str; 4] = ["-", "\\", "|", "/"];
55 /// Shown before the spinner is earned: fast work lands as a receipt.
56 pub const PENDING_FRAME: &str = "›";
57 /// Work must survive this long before anything moves.
58 pub const EARN_DELAY: Duration = Duration::from_millis(400);
59 /// Five steps a second.
60 pub const FRAME_INTERVAL: Duration = Duration::from_millis(200);
61
62 /// The frame for work that has run for `elapsed`.
63 #[must_use]
64 pub fn frame(elapsed: Duration, motion: MotionMode, ascii: bool) -> &'static str {
65 if !motion.animates() {
66 return crate::glyphs::pick(STILL_FRAME, ascii);
67 }
68 if elapsed < EARN_DELAY {
69 return crate::glyphs::pick(PENDING_FRAME, ascii);
70 }
71 let steps = (elapsed - EARN_DELAY).as_millis() / FRAME_INTERVAL.as_millis();
72 let frames: &[&str] = if ascii { &ASCII_FRAMES } else { &FRAMES };
73 frames[(steps % frames.len() as u128) as usize]
74 }
75
76 /// When the next frame is due, or `None` when nothing will change: idle and
77 /// reduced motion schedule no redraws.
78 #[must_use]
79 pub fn next_frame_in(elapsed: Duration, motion: MotionMode) -> Option<Duration> {
80 if !motion.animates() {
81 return None;
82 }
83 if elapsed < EARN_DELAY {
84 return Some(EARN_DELAY - elapsed);
85 }
86 let into = (elapsed - EARN_DELAY).as_millis() % FRAME_INTERVAL.as_millis();
87 Some(FRAME_INTERVAL - Duration::from_millis(into as u64))
88 }
89
90 /// `⣦ Working · 38 s`: the spinner, a verb, and the measured time.
91 #[derive(Clone, Debug, PartialEq, Eq)]
92 pub struct Spinner {
93 pub verb: String,
94 pub elapsed: Duration,
95 pub motion: MotionMode,
96 }
97
98 impl Spinner {
99 #[must_use]
100 pub fn new(verb: impl Into<String>, elapsed: Duration, motion: MotionMode) -> Self {
101 Self {
102 verb: verb.into(),
103 elapsed,
104 motion,
105 }
106 }
107
108 #[must_use]
109 pub fn spans(&self, theme: &Theme) -> Vec<Span<'static>> {
110 let mut spans = vec![
111 Span::styled(
112 frame(self.elapsed, self.motion, theme.ascii()),
113 theme.fg(Role::Live),
114 ),
115 Span::raw(" "),
116 Span::styled(
117 text::display_safe(&self.verb).into_owned(),
118 theme.fg(Role::Foreground),
119 ),
120 ];
121 // Time is shown once it is worth reading, because it is measured.
122 if self.elapsed >= Duration::from_secs(1) {
123 let sep = if theme.ascii() { " - " } else { " · " };
124 spans.push(Span::styled(sep, theme.fg(Role::Border)));
125 spans.push(Span::styled(duration(self.elapsed), theme.fg(Role::Muted)));
126 }
127 spans
128 }
129 }
130
131 impl Paint for Spinner {
132 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
133 ratatui::text::Line::from(self.spans(theme)).render(area, buf);
134 }
135 }
136
137 /// `850 ms`, `12 s`, `4m 06s`, `1h 02m`.
138 #[must_use]
139 pub fn duration(d: Duration) -> String {
140 let secs = d.as_secs();
141 if secs == 0 {
142 format!("{} ms", d.as_millis())
143 } else if secs < 60 {
144 format!("{secs} s")
145 } else if secs < 3600 {
146 format!("{}m {:02}s", secs / 60, secs % 60)
147 } else {
148 format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60)
149 }
150 }
151
152 #[cfg(test)]
153 mod tests {
154 use super::*;
155
156 #[test]
157 fn spinner_is_earned_then_swells() {
158 let ms = Duration::from_millis;
159 assert_eq!(frame(ms(100), MotionMode::Full, false), PENDING_FRAME);
160 assert_eq!(frame(ms(400), MotionMode::Full, false), FRAMES[0]);
161 assert_eq!(frame(ms(600), MotionMode::Full, false), FRAMES[1]);
162 assert_eq!(frame(ms(5000), MotionMode::Reduced, false), STILL_FRAME);
163 assert_eq!(frame(ms(5000), MotionMode::Still, false), STILL_FRAME);
164 }
165
166 #[test]
167 fn ascii_frames_never_borrow_a_state_mark() {
168 let ms = Duration::from_millis;
169 assert_eq!(frame(ms(100), MotionMode::Full, true), ">");
170 assert_eq!(frame(ms(400), MotionMode::Full, true), "-");
171 assert_eq!(frame(ms(600), MotionMode::Full, true), "\\");
172 // Held still, the spinner is the ASCII mark for current work.
173 assert_eq!(frame(ms(5000), MotionMode::Reduced, true), ".");
174 let needs_you = crate::glyphs::pick(crate::glyphs::ATTENTION, true);
175 for t in (0..4000).step_by(100) {
176 assert_ne!(frame(ms(t), MotionMode::Full, true), needs_you);
177 }
178 }
179
180 #[test]
181 fn reduced_motion_schedules_nothing() {
182 assert_eq!(
183 next_frame_in(Duration::from_secs(3), MotionMode::Reduced),
184 None
185 );
186 assert_eq!(
187 next_frame_in(Duration::from_millis(450), MotionMode::Full),
188 Some(Duration::from_millis(150))
189 );
190 }
191
192 #[test]
193 fn durations_read_as_words() {
194 assert_eq!(duration(Duration::from_millis(850)), "850 ms");
195 assert_eq!(duration(Duration::from_secs(12)), "12 s");
196 assert_eq!(duration(Duration::from_secs(246)), "4m 06s");
197 assert_eq!(duration(Duration::from_secs(3720)), "1h 02m");
198 }
199
200 #[test]
201 fn motion_settings_match_the_engine() {
202 assert_eq!(MotionMode::from_settings(true, true), MotionMode::Reduced);
203 assert_eq!(MotionMode::from_settings(false, false), MotionMode::Still);
204 assert_eq!(MotionMode::from_settings(false, true), MotionMode::Full);
205 }
206 }
207
207 lines RUST