Skip to content

Commit 838afd2

Browse files
committed
Merge branch 'feat/animated-startup-logo'
2 parents 60eed4b + 232d648 commit 838afd2

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)