|
| 1 | +//! Animated "dither fade" startup logo. |
| 2 | +//! |
| 3 | +//! The `looper` wordmark resolves out of static as the track buffers: at 0% |
| 4 | +//! the logo is pure noise, at 100% it's a clean block wordmark. This ties the |
| 5 | +//! splash directly to the app's actual job — a signal tuning into focus. |
| 6 | +//! |
| 7 | +//! Rendering is a pure function of `frame_count` (for shimmer) and the download |
| 8 | +//! `fraction` (for how much has resolved), so it has no state of its own and |
| 9 | +//! drops straight into the existing startup render path. |
| 10 | +
|
| 11 | +use ratatui::{ |
| 12 | + style::{Color, Modifier, Style}, |
| 13 | + text::{Line, Span}, |
| 14 | +}; |
| 15 | + |
| 16 | +/// Wordmark bitmap: `#` is ink (part of the letters), everything else is field. |
| 17 | +/// Five rows of 3-wide block letters, single-space separated: L O O P E R. |
| 18 | +const LOGO: [&str; 5] = [ |
| 19 | + "# ### ### ### ### ###", |
| 20 | + "# # # # # # # # # #", |
| 21 | + "# # # # # ### ## ## ", |
| 22 | + "# # # # # # # # #", |
| 23 | + "### ### ### # ### # #", |
| 24 | +]; |
| 25 | + |
| 26 | +/// Columns of static padding on each side of the word, so the noise has room |
| 27 | +/// to burn away from the edges inward as the logo resolves. |
| 28 | +const FIELD_PAD: usize = 10; |
| 29 | + |
| 30 | +/// Static ramp, sparse to dense. Picked per-cell by the shimmer value. |
| 31 | +const SHADES: [char; 3] = ['░', '▒', '▓']; |
| 32 | + |
| 33 | +/// How far past its reveal threshold a cell still counts as "just locked in". |
| 34 | +/// Cells in this band are the resolving wavefront and flash bright. |
| 35 | +const FLASH_WINDOW: f32 = 0.08; |
| 36 | + |
| 37 | +/// Build the logo as ratatui lines, ready to drop into a centered `Paragraph`. |
| 38 | +/// |
| 39 | +/// `fraction` is the buffer/download progress in `0.0..=1.0`. `None` means the |
| 40 | +/// length is unknown (indeterminate) — the word stays solid with a faint |
| 41 | +/// drifting hiss in the field instead of resolving. |
| 42 | +pub fn dither_logo(frame_count: u64, fraction: Option<f64>) -> Vec<Line<'static>> { |
| 43 | + let logo_w = LOGO[0].len(); |
| 44 | + let width = logo_w + FIELD_PAD * 2; |
| 45 | + |
| 46 | + (0..LOGO.len()) |
| 47 | + .map(|row| { |
| 48 | + let spans = (0..width) |
| 49 | + .map(|col| { |
| 50 | + let is_ink = col |
| 51 | + .checked_sub(FIELD_PAD) |
| 52 | + .and_then(|c| LOGO[row].as_bytes().get(c)) |
| 53 | + .map(|b| *b == b'#') |
| 54 | + .unwrap_or(false); |
| 55 | + cell_span(is_ink, row, col, width, frame_count, fraction) |
| 56 | + }) |
| 57 | + .collect::<Vec<_>>(); |
| 58 | + Line::from(spans) |
| 59 | + }) |
| 60 | + .collect() |
| 61 | +} |
| 62 | + |
| 63 | +fn cell_span( |
| 64 | + is_ink: bool, |
| 65 | + row: usize, |
| 66 | + col: usize, |
| 67 | + width: usize, |
| 68 | + frame: u64, |
| 69 | + fraction: Option<f64>, |
| 70 | +) -> Span<'static> { |
| 71 | + match fraction { |
| 72 | + // Determinate: each cell has a fixed reveal threshold, so the word |
| 73 | + // dissolves in raggedly (grain order) rather than wiping uniformly. |
| 74 | + Some(p) => { |
| 75 | + let p = p.clamp(0.0, 1.0) as f32; |
| 76 | + let threshold = reveal_threshold(row, col); |
| 77 | + if p < threshold { |
| 78 | + static_span(is_ink, row, col, frame) |
| 79 | + } else if is_ink && p < 1.0 && p - threshold < FLASH_WINDOW && flickers(row, col, frame) { |
| 80 | + // Freshly crossed the threshold: pop white-hot for a frame |
| 81 | + // before settling into the wordmark color. |
| 82 | + flash_span() |
| 83 | + } else { |
| 84 | + resolved_span(is_ink, col, width) |
| 85 | + } |
| 86 | + } |
| 87 | + // Indeterminate: solid word + ambient hiss in the surrounding field. |
| 88 | + None => { |
| 89 | + if is_ink { |
| 90 | + resolved_span(true, col, width) |
| 91 | + } else if noise01(row, col, (frame / 4) as usize) < 0.06 { |
| 92 | + Span::styled("░", Style::default().fg(Color::Rgb(70, 70, 95))) |
| 93 | + } else { |
| 94 | + Span::raw(" ") |
| 95 | + } |
| 96 | + } |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/// A cell that has locked into its final state: a colored block for ink, |
| 101 | +/// empty space for field. |
| 102 | +fn resolved_span(is_ink: bool, col: usize, width: usize) -> Span<'static> { |
| 103 | + if is_ink { |
| 104 | + Span::styled( |
| 105 | + "█", |
| 106 | + Style::default() |
| 107 | + .fg(ink_color(col, width)) |
| 108 | + .add_modifier(Modifier::BOLD), |
| 109 | + ) |
| 110 | + } else { |
| 111 | + Span::raw(" ") |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +/// White-hot block for a cell at the resolving wavefront. |
| 116 | +fn flash_span() -> Span<'static> { |
| 117 | + Span::styled( |
| 118 | + "█", |
| 119 | + Style::default() |
| 120 | + .fg(Color::Rgb(255, 246, 230)) |
| 121 | + .add_modifier(Modifier::BOLD), |
| 122 | + ) |
| 123 | +} |
| 124 | + |
| 125 | +/// Per-frame twinkle gate so the wavefront sparkles instead of holding a |
| 126 | +/// steady bright band. |
| 127 | +fn flickers(row: usize, col: usize, frame: u64) -> bool { |
| 128 | + noise01(row, col, frame as usize) > 0.5 |
| 129 | +} |
| 130 | + |
| 131 | +/// A cell still buried in static. Ink cells get a warmer tint so the word's |
| 132 | +/// shape faintly pre-echoes through the noise before it resolves. |
| 133 | +fn static_span(is_ink: bool, row: usize, col: usize, frame: u64) -> Span<'static> { |
| 134 | + let shimmer = noise01(row, col, (frame / 3) as usize); |
| 135 | + let idx = ((shimmer * SHADES.len() as f32) as usize).min(SHADES.len() - 1); |
| 136 | + let color = if is_ink { |
| 137 | + Color::Rgb(150, 120, 90) |
| 138 | + } else { |
| 139 | + Color::Rgb(70, 70, 95) |
| 140 | + }; |
| 141 | + Span::styled(SHADES[idx].to_string(), Style::default().fg(color)) |
| 142 | +} |
| 143 | + |
| 144 | +/// Warm amber→pink gradient across the wordmark, echoing the scatter palette. |
| 145 | +fn ink_color(col: usize, width: usize) -> Color { |
| 146 | + let t = if width > 1 { |
| 147 | + col as f32 / (width - 1) as f32 |
| 148 | + } else { |
| 149 | + 0.0 |
| 150 | + }; |
| 151 | + let g = (180.0 + t * (110.0 - 180.0)) as u8; |
| 152 | + let b = (80.0 + t * (120.0 - 80.0)) as u8; |
| 153 | + Color::Rgb(255, g, b) |
| 154 | +} |
| 155 | + |
| 156 | +/// Fixed per-cell reveal point in `0.0..1.0`. Stable across frames so the |
| 157 | +/// dissolve order doesn't jitter as progress climbs. |
| 158 | +fn reveal_threshold(row: usize, col: usize) -> f32 { |
| 159 | + hash(row, col, 0) as f32 / u32::MAX as f32 |
| 160 | +} |
| 161 | + |
| 162 | +/// Per-cell noise that drifts with `t`, for the shimmer in unresolved static. |
| 163 | +fn noise01(row: usize, col: usize, t: usize) -> f32 { |
| 164 | + hash(row, col, t as u32) as f32 / u32::MAX as f32 |
| 165 | +} |
| 166 | + |
| 167 | +fn hash(row: usize, col: usize, t: u32) -> u32 { |
| 168 | + let mut h = (row as u32) |
| 169 | + .wrapping_mul(2246822519) |
| 170 | + .wrapping_add((col as u32).wrapping_mul(3266489917)) |
| 171 | + .wrapping_add(t.wrapping_mul(1664525)); |
| 172 | + h ^= h >> 13; |
| 173 | + h = h.wrapping_mul(1274126177); |
| 174 | + h ^= h >> 16; |
| 175 | + h |
| 176 | +} |
| 177 | + |
| 178 | +#[cfg(test)] |
| 179 | +mod tests { |
| 180 | + use super::*; |
| 181 | + |
| 182 | + fn row_text(line: &Line) -> String { |
| 183 | + line.spans.iter().map(|s| s.content.as_ref()).collect() |
| 184 | + } |
| 185 | + |
| 186 | + #[test] |
| 187 | + fn dimensions_are_stable() { |
| 188 | + let lines = dither_logo(0, Some(0.5)); |
| 189 | + assert_eq!(lines.len(), LOGO.len()); |
| 190 | + let width = LOGO[0].len() + FIELD_PAD * 2; |
| 191 | + for line in &lines { |
| 192 | + assert_eq!(row_text(&line).chars().count(), width); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn fully_resolved_is_clean_wordmark() { |
| 198 | + // At 100% every ink cell is a solid block and the field is blank. |
| 199 | + let lines = dither_logo(0, Some(1.0)); |
| 200 | + for (row, line) in lines.iter().enumerate() { |
| 201 | + let text = row_text(line); |
| 202 | + let inked = &LOGO[row].as_bytes(); |
| 203 | + for (col, ch) in text.chars().enumerate() { |
| 204 | + let is_ink = col |
| 205 | + .checked_sub(FIELD_PAD) |
| 206 | + .and_then(|c| inked.get(c)) |
| 207 | + .map(|b| *b == b'#') |
| 208 | + .unwrap_or(false); |
| 209 | + if is_ink { |
| 210 | + assert_eq!(ch, '█'); |
| 211 | + } else { |
| 212 | + assert_eq!(ch, ' '); |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + #[test] |
| 219 | + fn zero_progress_has_no_solid_blocks() { |
| 220 | + // At 0% nothing has resolved yet — it's all static. |
| 221 | + let lines = dither_logo(7, Some(0.0)); |
| 222 | + for line in &lines { |
| 223 | + assert!(!row_text(line).contains('█')); |
| 224 | + } |
| 225 | + } |
| 226 | +} |
0 commit comments