Skip to content

Commit 232d648

Browse files
program247365claude
andcommitted
Add animated dither-fade startup logo
Replace the static figlet wordmark on the startup screen with a "dither fade" logo that resolves out of static as the track buffers: pure noise at 0%, a clean LOOPER block wordmark at 100%. Ties the splash to the app's actual job of resolving a signal into focus. The new startup_logo module renders the logo as a pure function of frame_count (shimmer) and download fraction (how much has resolved), so it carries no state and drops into the existing startup render path on the tick loop already in place. Each ink cell has a fixed reveal threshold, so the word dissolves in raggedly rather than wiping uniformly. Cells at the resolving wavefront flash white-hot and twinkle per-frame before settling into the amber wordmark color. An indeterminate fraction keeps the word solid with a faint drifting hiss in the field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 60eed4b commit 232d648

3 files changed

Lines changed: 237 additions & 14 deletions

File tree

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod play_loop;
1111
mod playback_input;
1212
mod plugin;
1313
mod schema;
14+
mod startup_logo;
1415
mod storage;
1516
mod tui;
1617
use play_loop::{browse_history, play_file, PlaybackContext};

src/startup_logo.rs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
}

src/tui.rs

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -226,21 +226,17 @@ pub fn draw_startup(frame: &mut ratatui::Frame, state: &StartupScreenState) {
226226
])
227227
.split(panel_inner);
228228

229-
let title = vec![
230-
Line::from(" _ .-''''-. .-''''-. "),
231-
Line::from(" | | ___ ___ _ __ ___ _ __ .' .-. '. .' .-. '."),
232-
Line::from(" | | / _ \\ / _ \\| '_ \\ / _ \\ '__|/ / \\ V / \\ \\"),
233-
Line::from(" | |__| (_) | (_) | |_) | __/ | \\ \\ / \\ / /"),
234-
Line::from(" |_____\\___/ \\___/| .__/ \\___|_| '. '-' .' '. '-' .' "),
235-
Line::from(" |_| '-.__.-' '-.__.-' "),
236-
];
237-
229+
let logo_fraction = state
230+
.progress
231+
.as_ref()
232+
.and_then(|p| p.progress.as_ref())
233+
.and_then(DownloadProgress::fraction);
238234
frame.render_widget(
239-
Paragraph::new(title).alignment(Alignment::Center).style(
240-
Style::default()
241-
.fg(Color::Rgb(255, 180, 80))
242-
.add_modifier(Modifier::BOLD),
243-
),
235+
Paragraph::new(crate::startup_logo::dither_logo(
236+
state.frame_count,
237+
logo_fraction,
238+
))
239+
.alignment(Alignment::Center),
244240
chunks[0],
245241
);
246242

0 commit comments

Comments
 (0)