blizz_ui/components/mascot/
bobbing.rs1use std::f64::consts::TAU;
2use std::io::Write;
3use std::time::{Duration, Instant};
4
5use crossterm::{
6 queue,
7 terminal::{Clear, ClearType},
8};
9
10#[cfg(not(tarpaulin_include))]
11use crossterm::event::{self, Event};
12
13use super::frames::{MascotFrames, queue_frame};
14use crate::layout::{Position, Rect, Size, centered_block_origin, position, size, top_two_thirds};
15
16const FLOAT_CYCLE: Duration = Duration::from_millis(6_400);
17const DEFAULT_TICK_RATE: Duration = Duration::from_millis(120);
18const BOB_AMPLITUDE_ROWS: f64 = 2.0;
19const VELOCITY_EPSILON: f64 = 0.000_001;
20const BOB_DISTANCE: i16 = BOB_AMPLITUDE_ROWS as i16;
21const BASELINE_ROW_OFFSET: i16 = 1;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AnimationExit {
25 KeyPress,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BobbingPhase {
30 Rising,
31 Falling,
32 Neutral,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct BobbingFrame {
37 pub phase: BobbingPhase,
38 pub row_offset: i16,
39}
40
41#[derive(Debug, Clone, Copy)]
42struct WaveSample {
43 row_offset: f64,
44 velocity: f64,
45}
46
47#[derive(Debug, Clone, Copy)]
48pub struct AnimationOptions {
49 pub tick_rate: Duration,
50}
51
52impl Default for AnimationOptions {
53 fn default() -> Self {
54 Self {
55 tick_rate: DEFAULT_TICK_RATE,
56 }
57 }
58}
59
60#[cfg(not(tarpaulin_include))]
61pub fn run_bobbing_animation<W: Write>(
62 writer: &mut W,
63 frames: MascotFrames,
64 options: AnimationOptions,
65) -> std::io::Result<AnimationExit> {
66 let started = Instant::now();
67 let mut terminal_size = crate::layout::terminal_size()?;
68 let mut last_render_was_static = false;
69
70 loop {
71 last_render_was_static = render_cycle_frame(
72 writer,
73 frames,
74 terminal_size,
75 started.elapsed(),
76 last_render_was_static,
77 )?;
78 writer.flush()?;
79
80 if !event::poll(options.tick_rate)? {
81 continue;
82 }
83
84 match event::read()? {
85 Event::Key(_) => return Ok(AnimationExit::KeyPress),
86 Event::Resize(width, height) => {
87 terminal_size = size(width, height);
88 last_render_was_static = false;
89 }
90 _ => {}
91 }
92 }
93}
94
95pub fn render_cycle_frame<W: Write>(
96 writer: &mut W,
97 frames: MascotFrames,
98 terminal_size: Size,
99 elapsed: Duration,
100 last_render_was_static: bool,
101) -> std::io::Result<bool> {
102 if should_render_static(terminal_size, frames.size()) {
103 if !last_render_was_static {
104 queue_static_frame(writer, frames, terminal_size)?;
105 }
106 return Ok(true);
107 }
108
109 queue_bobbing_frame(writer, frames, terminal_size, bobbing_frame(elapsed))?;
110 Ok(false)
111}
112
113pub fn bobbing_frame(elapsed: Duration) -> BobbingFrame {
114 let sample = wave_sample(elapsed);
115
116 frame(bobbing_phase(sample), rounded_row_offset(sample))
117}
118
119pub fn cycle_duration() -> Duration {
120 FLOAT_CYCLE
121}
122
123fn frame(phase: BobbingPhase, row_offset: i16) -> BobbingFrame {
124 BobbingFrame { phase, row_offset }
125}
126
127fn wave_sample(elapsed: Duration) -> WaveSample {
128 let angle = cycle_progress(elapsed) * TAU;
129
130 WaveSample {
131 row_offset: BOB_AMPLITUDE_ROWS * angle.cos(),
132 velocity: -BOB_AMPLITUDE_ROWS * angle.sin(),
133 }
134}
135
136fn cycle_progress(elapsed: Duration) -> f64 {
137 let cycle = cycle_duration().as_secs_f64();
138
139 elapsed.as_secs_f64().rem_euclid(cycle) / cycle
140}
141
142fn bobbing_phase(sample: WaveSample) -> BobbingPhase {
143 if sample.velocity.abs() <= VELOCITY_EPSILON {
144 return BobbingPhase::Neutral;
145 }
146
147 if sample.velocity < 0.0 {
148 return BobbingPhase::Rising;
149 }
150
151 if sample.velocity > 0.0 {
152 return BobbingPhase::Falling;
153 }
154
155 BobbingPhase::Neutral
156}
157
158fn rounded_row_offset(sample: WaveSample) -> i16 {
159 (sample.row_offset.round() as i16).clamp(-BOB_DISTANCE, BOB_DISTANCE) + BASELINE_ROW_OFFSET
160}
161
162fn queue_static_frame<W: Write>(
163 writer: &mut W,
164 frames: MascotFrames,
165 terminal_size: Size,
166) -> std::io::Result<()> {
167 queue!(writer, Clear(ClearType::All))?;
168 frames.queue_centered(writer, terminal_size)
169}
170
171fn queue_bobbing_frame<W: Write>(
172 writer: &mut W,
173 frames: MascotFrames,
174 terminal_size: Size,
175 frame: BobbingFrame,
176) -> std::io::Result<()> {
177 let origin = bobbing_origin(
178 top_two_thirds(terminal_size),
179 frames.size(),
180 frame.row_offset,
181 );
182
183 queue!(writer, Clear(ClearType::All))?;
184 queue_frame(writer, origin, frames.lines())
185}
186
187pub fn bobbing_origin(region: Rect, frame_size: Size, row_offset: i16) -> Position {
188 let origin = centered_block_origin(region, frame_size);
189 offset_row(origin, row_offset)
190}
191
192fn offset_row(origin: Position, row_offset: i16) -> Position {
193 if row_offset < 0 {
194 return position(
195 origin.column,
196 origin.row.saturating_sub(row_offset.unsigned_abs()),
197 );
198 }
199
200 position(origin.column, origin.row.saturating_add(row_offset as u16))
201}
202
203fn should_render_static(terminal_size: Size, frame_size: Size) -> bool {
204 top_two_thirds(terminal_size).size.height < frame_size.height
205 || terminal_size.width < frame_size.width
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn cycle_duration_stays_in_expected_range() {
214 let duration = cycle_duration();
215
216 assert!(duration >= Duration::from_secs(5));
217 assert!(duration <= Duration::from_secs(10));
218 }
219
220 #[test]
221 fn bobbing_frame_starts_at_bottom_extreme() {
222 assert_eq!(
223 bobbing_frame(Duration::ZERO),
224 frame(BobbingPhase::Neutral, BOB_DISTANCE + BASELINE_ROW_OFFSET)
225 );
226 }
227
228 #[test]
229 fn bobbing_frame_rises_through_center() {
230 assert_eq!(
231 bobbing_frame(cycle_at(1, 4)),
232 frame(BobbingPhase::Rising, BASELINE_ROW_OFFSET)
233 );
234 }
235
236 #[test]
237 fn bobbing_frame_reaches_top_extreme_halfway_through_cycle() {
238 assert_eq!(
239 bobbing_frame(cycle_at(1, 2)),
240 frame(BobbingPhase::Neutral, -BOB_DISTANCE + BASELINE_ROW_OFFSET)
241 );
242 }
243
244 #[test]
245 fn bobbing_frame_falls_through_center() {
246 assert_eq!(
247 bobbing_frame(cycle_at(3, 4)),
248 frame(BobbingPhase::Falling, BASELINE_ROW_OFFSET)
249 );
250 }
251
252 #[test]
253 fn sinusoidal_motion_changes_by_at_most_one_row_per_tick() {
254 let mut elapsed = DEFAULT_TICK_RATE;
255 let mut previous = bobbing_frame(Duration::ZERO).row_offset;
256
257 while elapsed < cycle_duration() {
258 let current = bobbing_frame(elapsed).row_offset;
259
260 assert!((current - previous).abs() <= 1);
261 previous = current;
262 elapsed += DEFAULT_TICK_RATE;
263 }
264 }
265
266 #[test]
267 fn bobbing_frame_wraps_cycle() {
268 assert_eq!(
269 bobbing_frame(cycle_duration()),
270 frame(BobbingPhase::Neutral, BOB_DISTANCE + BASELINE_ROW_OFFSET)
271 );
272 }
273
274 #[test]
275 fn small_terminal_renders_static_once() {
276 let mut buffer = Vec::new();
277 let frames = MascotFrames::blizz();
278
279 let first =
280 render_cycle_frame(&mut buffer, frames, size(10, 10), Duration::ZERO, false).unwrap();
281 let after_first = buffer.len();
282 let second =
283 render_cycle_frame(&mut buffer, frames, size(10, 10), Duration::ZERO, true).unwrap();
284
285 assert!(first);
286 assert!(second);
287 assert_eq!(buffer.len(), after_first);
288 }
289
290 #[test]
291 fn large_terminal_renders_bobbing_frame() {
292 let mut buffer = Vec::new();
293 let frames = MascotFrames::blizz();
294
295 let rendered_static =
296 render_cycle_frame(&mut buffer, frames, size(100, 60), Duration::ZERO, false).unwrap();
297
298 assert!(!rendered_static);
299 assert!(!buffer.is_empty());
300 }
301
302 #[test]
303 fn static_fallback_detects_small_width_or_height() {
304 let frame_size = size(38, 30);
305
306 assert!(should_render_static(size(37, 60), frame_size));
307 assert!(should_render_static(size(100, 44), frame_size));
308 assert!(!should_render_static(size(100, 60), frame_size));
309 }
310
311 #[test]
312 fn row_offsets_saturate_at_screen_top() {
313 assert_eq!(offset_row(position(5, 0), -2), position(5, 0));
314 assert_eq!(offset_row(position(5, 5), 2), position(5, 7));
315 }
316
317 #[test]
318 fn bobbing_origin_applies_row_offset() {
319 let region = crate::layout::rect(position(0, 0), size(100, 40));
320 let frame_sz = size(38, 30);
321
322 let origin_zero = bobbing_origin(region, frame_sz, 0);
323 let origin_pos = bobbing_origin(region, frame_sz, 2);
324 let origin_neg = bobbing_origin(region, frame_sz, -2);
325
326 assert_eq!(origin_pos.row, origin_zero.row + 2);
327 assert!(origin_neg.row <= origin_zero.row);
328 }
329
330 #[test]
331 fn animation_options_default_has_tick_rate() {
332 let opts = AnimationOptions::default();
333 assert_eq!(opts.tick_rate, DEFAULT_TICK_RATE);
334 }
335
336 fn cycle_at(numerator: u32, denominator: u32) -> Duration {
337 Duration::from_secs_f64(
338 cycle_duration().as_secs_f64() * f64::from(numerator) / f64::from(denominator),
339 )
340 }
341}