ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

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 }