LCOV - code coverage report
Current view: top level - src - splash.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 96.6 % 293 283
Test Date: 2026-10-10 16:52:03 Functions: 55.3 % 47 26

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

Generated by: LCOV version 2.0-1