src/ui/widgets/about_modal.rs (15882B)
1 use ratatui::buffer::Buffer;
2 use ratatui::layout::Rect;
3 use ratatui::style::{Color, Modifier, Style};
4 use ratatui::widgets::{Block, Borders, Clear};
5 use ratatui::Frame;
6
7 use crate::ui::theme::Theme;
8 use crate::ui::text::centered_rect;
9
10 // Neon colors
11 const NEON_PINK: Color = Color::Rgb(255, 50, 150);
12 const NEON_CYAN: Color = Color::Rgb(0, 255, 255);
13 const BG_DARK: Color = Color::Rgb(10, 5, 30);
14
15 // Logo gradient endpoints: hot pink -> cyan (smooth per-character interpolation)
16 const LOGO_START: (f32, f32, f32) = (255.0, 50.0, 150.0); // hot pink
17 const LOGO_END: (f32, f32, f32) = (0.0, 255.0, 255.0); // cyan
18
19 // Dim background scatter colors
20 const DIM_PURPLE: Color = Color::Rgb(60, 20, 80);
21 const DIM_CYAN: Color = Color::Rgb(20, 60, 80);
22 const DIM_PINK: Color = Color::Rgb(80, 20, 50);
23
24 const NOTES: [char; 4] = ['♪', '♫', '♩', '◆'];
25 const DIM_COLORS: [Color; 3] = [DIM_PURPLE, DIM_CYAN, DIM_PINK];
26
27 // Waveform pattern for below-logo decoration
28 const WAVEFORM: &str = "▁▂▃▅▇█▇▅▃▂▁▂▃▅▇█▇▅▃▂▁";
29
30 // Unicode block-art logo — each row is 35 display columns
31 const LOGO: [&str; 5] = [
32 " ██████ ██ ██ ██ ██ ██████ ",
33 "██ ██ ███ ███ ███ ███ ██ ██ ",
34 "██ ██ ██ ██ ██ ██ ██ ██ ██████ ",
35 "██ ██ ██ ██ ██ ██ ██ ",
36 " ██████ ██ ██ ██ ██ ██ ",
37 ];
38 const LOGO_DISPLAY_W: u16 = 35;
39
40 /// Shown at the bottom of the splash so the skip is discoverable.
41 const SKIP_HINT: &str = "Press any key to skip";
42
43 /// Fill area with dark background + scattered music notes
44 fn render_background(buf: &mut Buffer, area: Rect) {
45 let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
46 for y in area.y..area.y + area.height {
47 for x in area.x..area.x + area.width {
48 buf.set_string(x, y, " ", bg);
49 }
50 }
51
52 for y in area.y..area.y + area.height {
53 for x in area.x..area.x + area.width {
54 let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
55 if hash.is_multiple_of(37) {
56 let note = NOTES[(hash as usize / 37) % NOTES.len()];
57 let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
58 buf.set_string(x, y, note.to_string(), Style::default().fg(color).bg(BG_DARK));
59 }
60 }
61 }
62 }
63
64 /// Render waveform with cyan->purple gradient, centered at given y
65 fn render_waveform(buf: &mut Buffer, area_x: u16, area_w: u16, y: u16, max_y: u16) {
66 if y >= max_y {
67 return;
68 }
69 let chars: Vec<char> = WAVEFORM.chars().collect();
70 let total_w = chars.len() as u16;
71 let start_x = area_x + area_w.saturating_sub(total_w) / 2;
72
73 for (i, ch) in chars.iter().enumerate() {
74 let x = start_x + i as u16;
75 if x >= area_x && x < area_x + area_w {
76 let frac = i as f32 / chars.len().max(1) as f32;
77 let r = (frac * 160.0) as u8;
78 let g = (255.0 - frac * 195.0) as u8;
79 let b = (255.0 - frac * 15.0) as u8;
80 buf.set_string(
81 x, y, ch.to_string(),
82 Style::default().fg(Color::Rgb(r, g, b)).bg(BG_DARK),
83 );
84 }
85 }
86 }
87
88 /// Lerp a foreground color toward BG_DARK by opacity (0.0 = invisible, 1.0 = full color)
89 fn fade_color(target: Color, opacity: f32) -> Color {
90 match target {
91 Color::Rgb(r, g, b) => {
92 let (br, bg, bb) = (10.0_f32, 5.0_f32, 30.0_f32);
93 Color::Rgb(
94 (br + (r as f32 - br) * opacity) as u8,
95 (bg + (g as f32 - bg) * opacity) as u8,
96 (bb + (b as f32 - bb) * opacity) as u8,
97 )
98 }
99 other => other,
100 }
101 }
102
103 /// Helper: center a string of `display_w` columns within `area_w`, returning x offset from area.x
104 fn center_x(area_x: u16, area_w: u16, display_w: u16) -> u16 {
105 area_x + area_w.saturating_sub(display_w) / 2
106 }
107
108 pub fn render_about_modal(frame: &mut Frame, area: Rect, _theme: &Theme) {
109 let modal = centered_rect(50, 60, area);
110 frame.render_widget(Clear, modal);
111
112 // Background with scattered music notes on entire modal
113 render_background(frame.buffer_mut(), modal);
114
115 // Modal border
116 let block = Block::default()
117 .borders(Borders::ALL)
118 .border_style(Style::default().fg(NEON_PINK).bg(BG_DARK))
119 .title(" ♪ OMMP ♪ ")
120 .title_style(
121 Style::default()
122 .fg(NEON_PINK)
123 .bg(BG_DARK)
124 .add_modifier(Modifier::BOLD),
125 );
126
127 let inner = block.inner(modal);
128 frame.render_widget(block, modal);
129
130 // Re-render background inside inner area (border rendering overwrites)
131 render_background(frame.buffer_mut(), inner);
132
133 // === Calculate total content height for vertical centering ===
134 // Logo(5) + waveform(1) + 1 + subtitle(1) + tagline(1) + 1 + divider(1) + 1 + info(2) + 1 + links(2) + 1 + divider(1) + 1 + footer(1) = 21
135 let content_h: u16 = 21;
136 let top_pad = inner.height.saturating_sub(content_h) / 2;
137 let mut cur_y = inner.y + top_pad;
138
139 let buf = frame.buffer_mut();
140
141 // --- Logo with smooth horizontal gradient (hot pink -> cyan, per-character RGB lerp) ---
142 for (row_idx, row) in LOGO.iter().enumerate() {
143 if cur_y < inner.y + inner.height {
144 let logo_x = center_x(inner.x, inner.width, LOGO_DISPLAY_W);
145 for (col, ch) in row.chars().enumerate() {
146 let t = (col as f32 + row_idx as f32 * 3.0)
147 / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
148 let t = t.clamp(0.0, 1.0);
149 let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
150 let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
151 let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
152 let style = Style::default()
153 .fg(Color::Rgb(r, g, b))
154 .bg(BG_DARK)
155 .add_modifier(Modifier::BOLD);
156 buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
157 }
158 }
159 cur_y += 1;
160 }
161
162 // --- Waveform below logo ---
163 render_waveform(buf, inner.x, inner.width, cur_y, inner.y + inner.height);
164 cur_y += 2;
165
166 // --- Subtitle ---
167 let subtitle = "Oh My Music Player";
168 let sub_w = subtitle.len() as u16;
169 if cur_y < inner.y + inner.height {
170 let x = center_x(inner.x, inner.width, sub_w);
171 buf.set_string(
172 x, cur_y, subtitle,
173 Style::default()
174 .fg(NEON_CYAN)
175 .bg(BG_DARK)
176 .add_modifier(Modifier::BOLD | Modifier::ITALIC),
177 );
178 }
179 cur_y += 1;
180
181 // --- Tagline ---
182 let tagline = "Terminal music, your way";
183 let tag_w = tagline.len() as u16;
184 if cur_y < inner.y + inner.height {
185 let x = center_x(inner.x, inner.width, tag_w);
186 buf.set_string(x, cur_y, tagline, Style::default().fg(NEON_CYAN).bg(BG_DARK));
187 }
188 cur_y += 2;
189
190 // --- Divider ---
191 let div_w = inner.width.saturating_sub(6) as usize;
192 let div_style = Style::default().fg(DIM_PURPLE).bg(BG_DARK);
193 if cur_y < inner.y + inner.height {
194 buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
195 }
196 cur_y += 2;
197
198 // --- Info rows ---
199 let label_style = Style::default()
200 .fg(NEON_PINK)
201 .bg(BG_DARK)
202 .add_modifier(Modifier::BOLD);
203 let value_style = Style::default().fg(NEON_CYAN).bg(BG_DARK);
204
205 // Read from Cargo.toml rather than a literal, so the About box cannot claim
206 // a version the build is not.
207 for (label, value) in &[("Version", env!("CARGO_PKG_VERSION")), ("License", "Apache-2.0")] {
208 if cur_y < inner.y + inner.height {
209 let lbl = format!(" {:12}", label);
210 let val = *value;
211 let total_w = (lbl.len() + val.len()) as u16;
212 let x = center_x(inner.x, inner.width, total_w);
213 buf.set_string(x, cur_y, &lbl, label_style);
214 buf.set_string(x + lbl.len() as u16, cur_y, val, value_style);
215 }
216 cur_y += 1;
217 }
218 cur_y += 1;
219
220 // --- Links ---
221 let link_style = Style::default()
222 .fg(Color::Rgb(100, 180, 255))
223 .bg(BG_DARK)
224 .add_modifier(Modifier::UNDERLINED);
225 let icon_style = Style::default()
226 .fg(NEON_PINK)
227 .bg(BG_DARK)
228 .add_modifier(Modifier::BOLD);
229
230 for (icon, url) in &[
231 ("\u{F09B} ", "https://github.com/sqzer-x/ommp"),
232 ("\u{F004} ", "https://github.com/sponsors/sqzer-x"),
233 ] {
234 if cur_y < inner.y + inner.height {
235 let icon_w = icon.chars().count() as u16;
236 let url_w = url.len() as u16;
237 let total_w = icon_w + url_w;
238 let x = center_x(inner.x, inner.width, total_w);
239 buf.set_string(x, cur_y, *icon, icon_style);
240 buf.set_string(x + icon_w, cur_y, *url, link_style);
241 }
242 cur_y += 1;
243 }
244 cur_y += 1;
245
246 // --- Divider ---
247 if cur_y < inner.y + inner.height {
248 buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
249 }
250 cur_y += 2;
251
252 // --- Footer ---
253 let footer = "g: Open GitHub s: Open Sponsor Esc: Close";
254 let fw = footer.len() as u16;
255 if cur_y < inner.y + inner.height {
256 let x = center_x(inner.x, inner.width, fw);
257 buf.set_string(
258 x, cur_y, footer,
259 Style::default().fg(Color::Rgb(120, 120, 140)).bg(BG_DARK),
260 );
261 }
262 }
263
264 /// Render a full-screen splash screen with fade-in/fade-out.
265 /// `opacity`: 0.0 = fully transparent (BG_DARK only), 1.0 = full brightness.
266 pub fn render_splash_screen(frame: &mut Frame, area: Rect, _theme: &Theme, opacity: f32) {
267 let opacity = opacity.clamp(0.0, 1.0);
268 let buf = frame.buffer_mut();
269
270 // Fill entire screen with dark background
271 let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
272 for y in area.y..area.y + area.height {
273 for x in area.x..area.x + area.width {
274 buf.set_string(x, y, " ", bg);
275 }
276 }
277
278 // Scattered music notes (faded)
279 for y in area.y..area.y + area.height {
280 for x in area.x..area.x + area.width {
281 let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
282 if hash.is_multiple_of(37) {
283 let note = NOTES[(hash as usize / 37) % NOTES.len()];
284 let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
285 buf.set_string(
286 x, y, note.to_string(),
287 Style::default().fg(fade_color(color, opacity)).bg(BG_DARK),
288 );
289 }
290 }
291 }
292
293 // Content height: logo(5) + waveform(1) + gap(1) + subtitle(1) + tagline(1) = 9
294 let content_h: u16 = 9;
295 let top_pad = area.height.saturating_sub(content_h) / 2;
296 let mut cur_y = area.y + top_pad;
297
298 // --- Logo with smooth horizontal gradient (faded) ---
299 for (row_idx, row) in LOGO.iter().enumerate() {
300 if cur_y < area.y + area.height {
301 let logo_x = center_x(area.x, area.width, LOGO_DISPLAY_W);
302 for (col, ch) in row.chars().enumerate() {
303 let t = (col as f32 + row_idx as f32 * 3.0)
304 / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
305 let t = t.clamp(0.0, 1.0);
306 let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
307 let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
308 let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
309 let style = Style::default()
310 .fg(fade_color(Color::Rgb(r, g, b), opacity))
311 .bg(BG_DARK)
312 .add_modifier(Modifier::BOLD);
313 buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
314 }
315 }
316 cur_y += 1;
317 }
318
319 // --- Waveform below logo (faded) ---
320 if cur_y < area.y + area.height {
321 let chars: Vec<char> = WAVEFORM.chars().collect();
322 let total_w = chars.len() as u16;
323 let start_x = area.x + area.width.saturating_sub(total_w) / 2;
324 for (i, ch) in chars.iter().enumerate() {
325 let x = start_x + i as u16;
326 if x >= area.x && x < area.x + area.width {
327 let frac = i as f32 / chars.len().max(1) as f32;
328 let r = (frac * 160.0) as u8;
329 let g = (255.0 - frac * 195.0) as u8;
330 let b = (255.0 - frac * 15.0) as u8;
331 buf.set_string(
332 x, cur_y, ch.to_string(),
333 Style::default().fg(fade_color(Color::Rgb(r, g, b), opacity)).bg(BG_DARK),
334 );
335 }
336 }
337 }
338 cur_y += 2;
339
340 // --- Subtitle (faded) ---
341 let subtitle = "Oh My Music Player";
342 let sub_w = subtitle.len() as u16;
343 if cur_y < area.y + area.height {
344 let x = center_x(area.x, area.width, sub_w);
345 buf.set_string(
346 x, cur_y, subtitle,
347 Style::default()
348 .fg(fade_color(NEON_CYAN, opacity))
349 .bg(BG_DARK)
350 .add_modifier(Modifier::BOLD | Modifier::ITALIC),
351 );
352 }
353 cur_y += 1;
354
355 // --- Tagline (faded) ---
356 let tagline = "Terminal music, your way";
357 if cur_y < area.y + area.height {
358 let x = center_x(area.x, area.width, tagline.len() as u16);
359 buf.set_string(
360 x, cur_y, tagline,
361 Style::default().fg(fade_color(NEON_CYAN, opacity)).bg(BG_DARK),
362 );
363 }
364
365 // --- Skip hint, pinned to the bottom ---
366 if area.height > content_h + 2 {
367 let y = area.y + area.height - 2;
368 let x = center_x(area.x, area.width, SKIP_HINT.len() as u16);
369 buf.set_string(
370 x, y, SKIP_HINT,
371 // Dimmer than the rest so it reads as a footnote, and it fades with
372 // everything else rather than popping in at full strength.
373 Style::default()
374 .fg(fade_color(Color::Rgb(120, 130, 150), opacity))
375 .bg(BG_DARK)
376 .add_modifier(Modifier::ITALIC),
377 );
378 }
379 }
380
381
382 #[cfg(test)]
383 mod tests {
384 use super::*;
385 use ratatui::backend::TestBackend;
386 use ratatui::Terminal;
387
388 fn rows(w: u16, h: u16) -> Vec<String> {
389 let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
390 terminal
391 .draw(|f| render_splash_screen(f, f.area(), &Theme::default(), 1.0))
392 .unwrap();
393 let buf = terminal.backend().buffer();
394 (0..h)
395 .map(|y| {
396 (0..w)
397 .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
398 .collect::<String>()
399 })
400 .collect()
401 }
402
403 #[test]
404 fn the_skip_hint_sits_below_the_logo_without_covering_it() {
405 let rows = rows(80, 24);
406 let hint = rows
407 .iter()
408 .position(|r| r.contains(SKIP_HINT))
409 .expect("hint is drawn");
410 let tagline = rows
411 .iter()
412 .position(|r| r.contains("Terminal music, your way"))
413 .expect("tagline is drawn");
414 assert!(hint > tagline, "hint must not land on the centred block");
415 assert!(hint < rows.len(), "hint stays on screen");
416 }
417
418 #[test]
419 fn a_terminal_too_short_for_the_hint_drops_it_rather_than_overlapping() {
420 // Nine rows of content plus the hint's own two do not fit in eleven.
421 assert!(!rows(60, 11).iter().any(|r| r.contains(SKIP_HINT)));
422 assert!(rows(60, 12).iter().any(|r| r.contains(SKIP_HINT)));
423 }
424 }