Skip to main content

blizz_ui/components/mascot/
bobbing.rs

1use 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}