| 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 |