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
commit 3a6429990ef360ddc4ae6df626181f656590d55a
parent 25db58e89789322ca2be5a7e91aecc5573e4e7a0
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:26:06 +0900

Tell the user the splash can be skipped, and skip it outright

The splash could always be dismissed with a key, but nothing said so.
A dim italic "Press any key to skip" now sits at the bottom, fading in and
out with the rest rather than appearing at full strength. Terminals too short
to fit it below the centred block simply do not get it.

The key now drops the splash immediately instead of jumping to the fade-out.
Half a second of animation after the prompt says the key skips is not what it
promises.

Diffstat:
Msrc/main.rs | 6++++--
Msrc/ui/mod.rs | 57++++++++++++++-------------------------------------------
Msrc/ui/widgets/about_modal.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 81 insertion(+), 47 deletion(-)

diff --git a/src/main.rs b/src/main.rs
@@ -198,9 +198,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
             Ok(event) => {
                 let actions = match event {
                     Event::Key(key) => {
-                        // On key press during splash: jump to fade-out phase
+                        // A key during the splash dismisses it outright — the
+                        // hint promises the app, not another half second of
+                        // animation.
                         if ui.show_splash {
-                            ui.begin_splash_fade_out();
+                            ui.dismiss_splash();
                             vec![]
                         } else {
                         // Handle queue selection directly for playlist focus
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -135,24 +135,11 @@ impl Ui {
         }
     }
 
-    /// Skip the splash forward to the start of its fade-out, if it has not got
-    /// there already. Only a keypress does this: the timeline is otherwise
-    /// fixed, and the fade-out still plays so the skip is not a hard cut.
-    pub fn begin_splash_fade_out(&mut self) {
-        if !self.show_splash {
-            return;
-        }
-        let Some(start) = self.splash_start else { return };
-        if start.elapsed().as_secs_f32() >= SPLASH_FADE_OUT_AT {
-            return;
-        }
-        // checked_sub because Instant is CLOCK_MONOTONIC: started within
-        // SPLASH_FADE_OUT_AT of boot, plain `-` panics.
-        if let Some(t) = std::time::Instant::now()
-            .checked_sub(std::time::Duration::from_secs_f32(SPLASH_FADE_OUT_AT))
-        {
-            self.splash_start = Some(t);
-        }
+    /// Drop the splash immediately. Only a keypress does this; left alone the
+    /// timeline always runs in full.
+    pub fn dismiss_splash(&mut self) {
+        self.show_splash = false;
+        self.splash_start = None;
     }
 
     pub fn render(&mut self, frame: &mut Frame, app: &App) {
@@ -314,39 +301,23 @@ mod tests {
         )
     }
 
-    fn elapsed(ui: &Ui) -> f32 {
-        ui.splash_start.map(|s| s.elapsed().as_secs_f32()).unwrap_or(0.0)
-    }
-
     #[test]
-    fn a_keypress_skips_the_splash_hold() {
+    fn a_keypress_drops_the_splash_immediately() {
         let mut ui = ui();
         assert!(ui.show_splash);
-        assert!(elapsed(&ui) < SPLASH_FADE_OUT_AT);
 
-        ui.begin_splash_fade_out();
+        ui.dismiss_splash();
 
-        // Straight to the fade-out rather than sitting through the hold.
-        assert!(elapsed(&ui) >= SPLASH_FADE_OUT_AT);
-        assert!(elapsed(&ui) < SPLASH_END, "the fade-out still plays");
-    }
-
-    #[test]
-    fn skipping_twice_does_not_cut_the_fade_out_short() {
-        let mut ui = ui();
-        ui.begin_splash_fade_out();
-        let first = elapsed(&ui);
-        ui.begin_splash_fade_out();
-        assert!(elapsed(&ui) >= first, "must not jump past the end");
-        assert!(elapsed(&ui) < SPLASH_END);
+        // No trailing fade: the hint says the key skips to the app.
+        assert!(!ui.show_splash);
+        assert!(ui.splash_start.is_none());
     }
 
     #[test]
-    fn skipping_a_dismissed_splash_is_a_no_op() {
+    fn dismissing_twice_is_harmless() {
         let mut ui = ui();
-        ui.show_splash = false;
-        ui.splash_start = None;
-        ui.begin_splash_fade_out();
-        assert!(ui.splash_start.is_none());
+        ui.dismiss_splash();
+        ui.dismiss_splash();
+        assert!(!ui.show_splash);
     }
 }
diff --git a/src/ui/widgets/about_modal.rs b/src/ui/widgets/about_modal.rs
@@ -37,6 +37,9 @@ const LOGO: [&str; 5] = [
 ];
 const LOGO_DISPLAY_W: u16 = 35;
 
+/// Shown at the bottom of the splash so the skip is discoverable.
+const SKIP_HINT: &str = "Press any key to skip";
+
 /// Fill area with dark background + scattered music notes
 fn render_background(buf: &mut Buffer, area: Rect) {
     let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
@@ -349,13 +352,71 @@ pub fn render_splash_screen(frame: &mut Frame, area: Rect, _theme: &Theme, opaci
 
     // --- Tagline (faded) ---
     let tagline = "Terminal music, your way";
-    let tag_w = tagline.len() as u16;
     if cur_y < area.y + area.height {
-        let x = center_x(area.x, area.width, tag_w);
+        let x = center_x(area.x, area.width, tagline.len() as u16);
         buf.set_string(
             x, cur_y, tagline,
             Style::default().fg(fade_color(NEON_CYAN, opacity)).bg(BG_DARK),
         );
     }
+
+    // --- Skip hint, pinned to the bottom ---
+    if area.height > content_h + 2 {
+        let y = area.y + area.height - 2;
+        let x = center_x(area.x, area.width, SKIP_HINT.len() as u16);
+        buf.set_string(
+            x, y, SKIP_HINT,
+            // Dimmer than the rest so it reads as a footnote, and it fades with
+            // everything else rather than popping in at full strength.
+            Style::default()
+                .fg(fade_color(Color::Rgb(120, 130, 150), opacity))
+                .bg(BG_DARK)
+                .add_modifier(Modifier::ITALIC),
+        );
+    }
 }
 
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use ratatui::backend::TestBackend;
+    use ratatui::Terminal;
+
+    fn rows(w: u16, h: u16) -> Vec<String> {
+        let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
+        terminal
+            .draw(|f| render_splash_screen(f, f.area(), &Theme::default(), 1.0))
+            .unwrap();
+        let buf = terminal.backend().buffer();
+        (0..h)
+            .map(|y| {
+                (0..w)
+                    .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
+                    .collect::<String>()
+            })
+            .collect()
+    }
+
+    #[test]
+    fn the_skip_hint_sits_below_the_logo_without_covering_it() {
+        let rows = rows(80, 24);
+        let hint = rows
+            .iter()
+            .position(|r| r.contains(SKIP_HINT))
+            .expect("hint is drawn");
+        let tagline = rows
+            .iter()
+            .position(|r| r.contains("Terminal music, your way"))
+            .expect("tagline is drawn");
+        assert!(hint > tagline, "hint must not land on the centred block");
+        assert!(hint < rows.len(), "hint stays on screen");
+    }
+
+    #[test]
+    fn a_terminal_too_short_for_the_hint_drops_it_rather_than_overlapping() {
+        // Nine rows of content plus the hint's own two do not fit in eleven.
+        assert!(!rows(60, 11).iter().any(|r| r.contains(SKIP_HINT)));
+        assert!(rows(60, 12).iter().any(|r| r.contains(SKIP_HINT)));
+    }
+}