Line data Source code
1 : //! Startup splash: the title and a little diff hunk rain down onto the screen,
2 : //! a diagonal shimmer sweeps across them, and then everything falls away.
3 : //!
4 : //! The animator only depends on ratatui, so the native terminal loop and the
5 : //! browser build drive the same code: native calls [`SplashAnimator::tick`] at
6 : //! roughly 30 fps, while the browser advances by elapsed host time.
7 :
8 : use ratatui::{
9 : buffer::Buffer,
10 : layout::Rect,
11 : style::{Color, Style},
12 : Frame,
13 : };
14 :
15 : /// Duration of one animation frame.
16 : pub const FRAME_MS: u32 = 33;
17 :
18 : const HINT: &str = "Press any key to skip";
19 :
20 : /// Big block letter "HUNKY".
21 : const TITLE_ART: &[&str] = &[
22 : "██ ██ ██ ██ ███ ██ ██ ██ ██ ██",
23 : "██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ",
24 : "███████ ██ ██ ██ ██ ██ █████ ████ ",
25 : "██ ██ ██ ██ ██ ████ ██ ██ ██ ",
26 : "██ ██ █████ ██ ███ ██ ██ ██ ",
27 : ];
28 :
29 : /// A small diff hunk. Letters are colour codes (see [`hunk_style`]); everything
30 : /// else is drawn literally in the header colour.
31 : const HUNK_ART: &[&str] = &[
32 : "@@ -12,6 +12,9 @@",
33 : "",
34 : " ccccccc cccccc",
35 : " cccc ccccccccc",
36 : "M rrrrrr rrrrrrr",
37 : "M rrrr rrrrr",
38 : "P aaaaaa aaaaaaaaa",
39 : "P aaaa aaaaaaa aaa",
40 : "P aaaaaaaa aaaa",
41 : " cccccc",
42 : " ccc",
43 : ];
44 :
45 : const TITLE_COLOR: Color = Color::Cyan;
46 : const SHIMMER_COLOR: Color = Color::White;
47 :
48 468 : fn hunk_style(ch: char) -> (char, Style) {
49 468 : match ch {
50 140 : 'c' => ('▀', Style::default().fg(Color::DarkGray)),
51 88 : 'r' => ('▀', Style::default().fg(Color::Red)),
52 164 : 'a' => ('▀', Style::default().fg(Color::Green)),
53 8 : 'M' => ('-', Style::default().fg(Color::Red)),
54 12 : 'P' => ('+', Style::default().fg(Color::Green)),
55 56 : other => (other, Style::default().fg(Color::Cyan)),
56 : }
57 468 : }
58 :
59 : /// A styled glyph positioned relative to the top-left of the content block.
60 : struct Glyph {
61 : ch: char,
62 : style: Style,
63 : col: u16,
64 : row: u16,
65 : }
66 :
67 12 : fn art_width(lines: &[&str]) -> usize {
68 96 : lines.iter().map(|l| l.chars().count()).max().unwrap_or(0)
69 12 : }
70 :
71 : /// Lay out the title beside the hunk, or the title alone when the terminal is
72 : /// too narrow for both.
73 6 : fn build_content(width: u16) -> (Vec<Glyph>, u16, u16) {
74 : const GAP: usize = 6;
75 6 : let title_w = art_width(TITLE_ART);
76 6 : let hunk_w = art_width(HUNK_ART);
77 6 : let with_hunk = title_w + GAP + hunk_w <= width as usize;
78 :
79 6 : let height = if with_hunk {
80 4 : TITLE_ART.len().max(HUNK_ART.len())
81 : } else {
82 2 : TITLE_ART.len()
83 : };
84 6 : let title_top = (height - TITLE_ART.len()) / 2;
85 :
86 6 : let mut glyphs = Vec::new();
87 30 : for (row, line) in TITLE_ART.iter().enumerate() {
88 1230 : for (col, ch) in line.chars().enumerate() {
89 1230 : if ch != ' ' {
90 654 : glyphs.push(Glyph {
91 654 : ch,
92 654 : style: Style::default().fg(TITLE_COLOR),
93 654 : col: col as u16,
94 654 : row: (title_top + row) as u16,
95 654 : });
96 654 : }
97 : }
98 : }
99 6 : let mut total_w = title_w;
100 6 : if with_hunk {
101 4 : let left = title_w + GAP;
102 4 : let hunk_top = (height - HUNK_ART.len()) / 2;
103 44 : for (row, line) in HUNK_ART.iter().enumerate() {
104 620 : for (col, ch) in line.chars().enumerate() {
105 620 : if ch != ' ' {
106 468 : let (ch, style) = hunk_style(ch);
107 468 : glyphs.push(Glyph {
108 468 : ch,
109 468 : style,
110 468 : col: (left + col) as u16,
111 468 : row: (hunk_top + row) as u16,
112 468 : });
113 468 : }
114 : }
115 : }
116 4 : total_w = left + hunk_w;
117 2 : }
118 6 : (glyphs, total_w as u16, height as u16)
119 6 : }
120 :
121 : /// Small xorshift generator so the animation needs no extra dependencies and is
122 : /// reproducible from a seed (the browser host supplies one).
123 : struct Rng(u32);
124 :
125 : impl Rng {
126 5610 : fn next(&mut self) -> u32 {
127 5610 : let mut x = self.0;
128 5610 : x ^= x << 13;
129 5610 : x ^= x >> 17;
130 5610 : x ^= x << 5;
131 5610 : self.0 = x;
132 5610 : x
133 5610 : }
134 :
135 3366 : fn range(&mut self, lo: f32, hi: f32) -> f32 {
136 3366 : lo + (self.next() as f32 / u32::MAX as f32) * (hi - lo)
137 3366 : }
138 :
139 2244 : fn below(&mut self, n: u32) -> u16 {
140 2244 : (self.next() % n) as u16
141 2244 : }
142 : }
143 :
144 : struct Cell {
145 : glyph: Glyph,
146 : /// Vertical position relative to the content's resting top row.
147 : y: f32,
148 : speed_in: f32,
149 : delay_in: u16,
150 : speed_out: f32,
151 : delay_out: u16,
152 : }
153 :
154 : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
155 : enum Phase {
156 : FallingIn,
157 : HoldPreShimmer,
158 : Shimmer,
159 : HoldPostShimmer,
160 : FallingOut,
161 : Done,
162 : }
163 :
164 : const HOLD_TICKS: u16 = 10;
165 : const SHIMMER_WIDTH: f32 = 6.0;
166 : const SHIMMER_SPEED: f32 = 4.0;
167 :
168 : /// Falling-glyph startup animation with a diagonal shimmer.
169 : ///
170 : /// 1. **Fall in**: glyphs drop from above the screen at random speeds.
171 : /// 2. **Hold**: brief pause.
172 : /// 3. **Shimmer**: a white diagonal band sweeps left to right.
173 : /// 4. **Hold**: brief pause.
174 : /// 5. **Fall out**: glyphs drop off the bottom in a random order.
175 : pub struct SplashAnimator {
176 : cells: Vec<Cell>,
177 : content_w: u16,
178 : content_h: u16,
179 : area: Rect,
180 : phase: Phase,
181 : tick: u64,
182 : out_tick: u64,
183 : hold: u16,
184 : shimmer_x: f32,
185 : shimmer_end: f32,
186 : /// Leftover host time not yet turned into whole frames.
187 : pending_ms: f64,
188 : }
189 :
190 : impl SplashAnimator {
191 6 : pub fn new(area: Rect, seed: u32) -> Self {
192 6 : let mut rng = Rng(seed.max(1));
193 6 : let (glyphs, content_w, content_h) = build_content(area.width);
194 6 : let top = area.height.saturating_sub(content_h) / 2;
195 6 : let cells = glyphs
196 6 : .into_iter()
197 6 : .map(|glyph| Cell {
198 1122 : glyph,
199 : // Start somewhere above the visible screen.
200 1122 : y: rng.range(-(area.height as f32), 0.0) - top as f32 - 1.0,
201 1122 : speed_in: rng.range(1.6, 5.0),
202 1122 : delay_in: rng.below(4),
203 1122 : speed_out: rng.range(1.6, 6.0),
204 1122 : delay_out: rng.below(10),
205 1122 : })
206 6 : .collect();
207 6 : let shimmer_end = content_w as f32 + content_h as f32 + SHIMMER_WIDTH;
208 6 : Self {
209 6 : cells,
210 6 : content_w,
211 6 : content_h,
212 6 : area,
213 6 : phase: Phase::FallingIn,
214 6 : tick: 0,
215 6 : out_tick: 0,
216 6 : hold: HOLD_TICKS,
217 6 : shimmer_x: -SHIMMER_WIDTH,
218 6 : shimmer_end,
219 6 : pending_ms: 0.0,
220 6 : }
221 6 : }
222 :
223 : /// Seed from the clock, for the native binary.
224 : #[cfg(not(feature = "browser"))]
225 0 : pub fn from_clock(area: Rect) -> Self {
226 0 : let nanos = std::time::SystemTime::now()
227 0 : .duration_since(std::time::UNIX_EPOCH)
228 0 : .map(|d| d.subsec_nanos())
229 0 : .unwrap_or(1);
230 0 : Self::new(area, nanos)
231 0 : }
232 :
233 : /// Track the latest terminal size so the content stays centred.
234 1 : pub fn set_area(&mut self, area: Rect) {
235 1 : self.area = area;
236 1 : }
237 :
238 61 : fn origin(&self) -> (u16, u16) {
239 61 : (
240 61 : self.area.width.saturating_sub(self.content_w) / 2,
241 61 : self.area.height.saturating_sub(self.content_h) / 2,
242 61 : )
243 61 : }
244 :
245 : /// Advance by host time, stepping whole frames and carrying the remainder.
246 3 : pub fn advance(&mut self, elapsed_ms: f64) {
247 3 : self.pending_ms += elapsed_ms.max(0.0);
248 95 : while self.pending_ms >= FRAME_MS as f64 && !self.is_done() {
249 92 : self.pending_ms -= FRAME_MS as f64;
250 92 : self.tick();
251 92 : }
252 3 : }
253 :
254 : /// Advance the animation by one frame.
255 638 : pub fn tick(&mut self) {
256 638 : self.tick += 1;
257 638 : match self.phase {
258 : Phase::FallingIn => {
259 146 : let mut landed = true;
260 30539 : for cell in &mut self.cells {
261 30539 : let rest = cell.glyph.row as f32;
262 30539 : if self.tick <= cell.delay_in as u64 {
263 1674 : landed = false;
264 28865 : } else if cell.y < rest {
265 11010 : cell.y = (cell.y + cell.speed_in).min(rest);
266 11010 : landed &= cell.y >= rest;
267 17855 : }
268 : }
269 146 : if landed {
270 6 : self.phase = Phase::HoldPreShimmer;
271 140 : }
272 : }
273 : Phase::HoldPreShimmer | Phase::HoldPostShimmer => {
274 90 : self.hold = self.hold.saturating_sub(1);
275 90 : if self.hold == 0 {
276 9 : self.phase = if self.phase == Phase::HoldPreShimmer {
277 5 : Phase::Shimmer
278 : } else {
279 4 : Phase::FallingOut
280 : };
281 81 : }
282 : }
283 : Phase::Shimmer => {
284 88 : self.shimmer_x += SHIMMER_SPEED;
285 88 : if self.shimmer_x > self.shimmer_end {
286 4 : self.hold = HOLD_TICKS;
287 4 : self.phase = Phase::HoldPostShimmer;
288 84 : }
289 : }
290 : Phase::FallingOut => {
291 61 : self.out_tick += 1;
292 61 : let (_, top) = self.origin();
293 61 : let bottom = self.area.height as f32 - top as f32;
294 61 : let mut gone = true;
295 12616 : for cell in &mut self.cells {
296 12616 : if self.out_tick <= cell.delay_out as u64 {
297 3460 : gone = false;
298 9156 : } else if cell.y <= bottom {
299 2692 : cell.y += cell.speed_out;
300 2692 : gone &= cell.y > bottom;
301 6464 : }
302 : }
303 61 : if gone {
304 4 : self.phase = Phase::Done;
305 57 : }
306 : }
307 253 : Phase::Done => {}
308 : }
309 638 : }
310 :
311 186 : pub fn is_done(&self) -> bool {
312 186 : self.phase == Phase::Done
313 186 : }
314 :
315 : /// Draw the current frame over the whole of `area`.
316 0 : pub fn render(&self, frame: &mut Frame, area: Rect) {
317 0 : self.render_to_buffer(frame.buffer_mut(), area);
318 0 : }
319 :
320 403 : pub fn render_to_buffer(&self, buf: &mut Buffer, area: Rect) {
321 403 : buf.set_style(area, Style::reset());
322 1272 : for y in area.top()..area.bottom() {
323 26800 : for x in area.left()..area.right() {
324 26800 : buf[(x, y)].set_symbol(" ");
325 26800 : }
326 : }
327 :
328 403 : let left = area.width.saturating_sub(self.content_w) / 2;
329 403 : let top = area.height.saturating_sub(self.content_h) / 2;
330 403 : let shimmering = self.phase == Phase::Shimmer;
331 403 : let mut symbol = [0u8; 4];
332 67561 : for cell in &self.cells {
333 67561 : let y = top as i32 + cell.y.round() as i32;
334 67561 : let x = left as u32 + cell.glyph.col as u32;
335 67561 : if y < 0 || y >= area.height as i32 || x >= area.width as u32 {
336 65130 : continue;
337 2431 : }
338 : // The band leans left as it goes down, like light across glass.
339 2431 : let style = if shimmering {
340 770 : let band = self.shimmer_x - cell.glyph.row as f32;
341 770 : let col = cell.glyph.col as f32;
342 770 : if col >= band && col < band + SHIMMER_WIDTH {
343 46 : Style::default().fg(SHIMMER_COLOR)
344 : } else {
345 724 : cell.glyph.style
346 : }
347 : } else {
348 1661 : cell.glyph.style
349 : };
350 2431 : buf[(area.x + x as u16, area.y + y as u16)]
351 2431 : .set_symbol(cell.glyph.ch.encode_utf8(&mut symbol))
352 2431 : .set_style(style);
353 : }
354 :
355 403 : if area.height >= 2 && area.width as usize >= HINT.len() {
356 3 : let x = area.x + (area.width - HINT.len() as u16) / 2;
357 3 : let y = area.bottom() - 2;
358 3 : buf.set_string(x, y, HINT, Style::default().fg(Color::DarkGray));
359 400 : }
360 403 : }
361 : }
362 :
363 : #[cfg(test)]
364 : mod tests {
365 : use super::*;
366 :
367 3 : fn text(buf: &Buffer) -> String {
368 4200 : buf.content().iter().map(|c| c.symbol()).collect()
369 3 : }
370 :
371 1 : fn run(anim: &mut SplashAnimator) -> Vec<Phase> {
372 1 : let mut phases = vec![anim.phase];
373 1 : for _ in 0..1000 {
374 91 : if anim.is_done() {
375 1 : break;
376 90 : }
377 90 : anim.tick();
378 90 : if phases.last() != Some(&anim.phase) {
379 5 : phases.push(anim.phase);
380 85 : }
381 : }
382 1 : phases
383 1 : }
384 :
385 : #[test]
386 1 : fn plays_every_phase_in_order_and_finishes_quickly() {
387 1 : let area = Rect::new(0, 0, 100, 30);
388 1 : let mut anim = SplashAnimator::new(area, 7);
389 1 : assert_eq!(
390 1 : run(&mut anim),
391 1 : vec![
392 1 : Phase::FallingIn,
393 1 : Phase::HoldPreShimmer,
394 1 : Phase::Shimmer,
395 1 : Phase::HoldPostShimmer,
396 1 : Phase::FallingOut,
397 1 : Phase::Done
398 : ]
399 : );
400 : // Keep the whole thing brief: well under four seconds at 30 fps.
401 1 : assert!(anim.tick < 110, "took {} ticks", anim.tick);
402 1 : }
403 :
404 : #[test]
405 1 : fn settles_title_and_hunk_centred_then_shimmers_white() {
406 1 : let area = Rect::new(0, 0, 100, 30);
407 1 : let mut anim = SplashAnimator::new(area, 3);
408 29 : while anim.phase == Phase::FallingIn {
409 28 : anim.tick();
410 28 : }
411 1 : let mut buf = Buffer::empty(area);
412 1 : anim.render_to_buffer(&mut buf, area);
413 1 : let rendered = text(&buf);
414 1 : assert!(rendered.contains("@@ -12,6 +12,9 @@"));
415 1 : assert!(rendered.contains(HINT));
416 1 : assert!(buf
417 1 : .content()
418 1 : .iter()
419 1217 : .any(|c| c.symbol() == "█" && c.fg == TITLE_COLOR));
420 3000 : assert!(!buf.content().iter().any(|c| c.fg == SHIMMER_COLOR));
421 :
422 11 : while anim.phase != Phase::Shimmer {
423 10 : anim.tick();
424 10 : }
425 4 : for _ in 0..4 {
426 4 : anim.tick();
427 4 : }
428 1 : anim.render_to_buffer(&mut buf, area);
429 1 : assert!(buf
430 1 : .content()
431 1 : .iter()
432 1225 : .any(|c| c.symbol() == "█" && c.fg == SHIMMER_COLOR));
433 1 : }
434 :
435 : #[test]
436 1 : fn narrow_terminals_show_only_the_title() {
437 1 : let area = Rect::new(0, 0, 50, 12);
438 1 : let mut anim = SplashAnimator::new(area, 9);
439 15 : while anim.phase == Phase::FallingIn {
440 14 : anim.tick();
441 14 : }
442 1 : let mut buf = Buffer::empty(area);
443 1 : anim.render_to_buffer(&mut buf, area);
444 1 : assert!(!text(&buf).contains("@@"));
445 1 : assert!(text(&buf).contains('█'));
446 1 : }
447 :
448 : #[test]
449 1 : fn advance_steps_whole_frames_and_carries_the_remainder() {
450 1 : let area = Rect::new(0, 0, 100, 30);
451 1 : let mut anim = SplashAnimator::new(area, 5);
452 1 : anim.advance(FRAME_MS as f64 * 2.5);
453 1 : assert_eq!(anim.tick, 2);
454 1 : anim.advance(FRAME_MS as f64 * 0.5);
455 1 : assert_eq!(anim.tick, 3);
456 1 : anim.advance(1_000_000.0);
457 1 : assert!(anim.is_done());
458 1 : }
459 :
460 : #[test]
461 1 : fn tiny_or_resized_areas_do_not_panic() {
462 1 : let mut anim = SplashAnimator::new(Rect::new(0, 0, 1, 1), 1);
463 1 : let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
464 200 : for _ in 0..200 {
465 200 : let area = buf.area;
466 200 : anim.render_to_buffer(&mut buf, area);
467 200 : anim.tick();
468 200 : }
469 1 : let mut anim = SplashAnimator::new(Rect::new(0, 0, 120, 40), 1);
470 1 : anim.set_area(Rect::new(0, 0, 20, 5));
471 1 : let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
472 200 : for _ in 0..200 {
473 200 : let area = buf.area;
474 200 : anim.render_to_buffer(&mut buf, area);
475 200 : anim.tick();
476 200 : }
477 1 : assert!(anim.is_done());
478 1 : }
479 : }
|