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 40ce1fb2337c46b7c1e7423070f40851ebf38e90
parent 3de0dadf2030be54bcea7dfd04a7a4a9a8366aaa
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 15:54:22 +0900

Collapse the five duplicated list panes into one

artists, albums, genre, format and playlists were 942 lines across five
files. Normalising identifiers made three of them byte-identical over all
185 lines; the other two differed only inside the row-render closure and one
call in the Enter arm. They are now one ListPane parameterised by rows and an
activation, with the per-tab differences expressed as rows_for/activate. The
seven-arm matches in handler.rs and ui/mod.rs collapse with them.

That duplication was actively hiding bugs. Only one of the five clamped the
selection at render time, and it survived only because a separate clamp ran
on library reload. Every list clamped its scroll offset to count - 1 rather
than count - viewport, so scrolling to the end left a single row above a
screen of blank space — the correct formula already existed in the search
modal and had never been back-ported. The format pane called
get_tracks_by_format inside its render closure, rescanning and lowercasing
every track for every visible row on every frame; it is one pass now. The
albums pane emitted unpadded columns, so a CJK title and an ASCII one put the
artist column at different offsets.

get_tracks_by_album matched on the album title alone while get_albums pairs
each album with its artist, so two different records named "Greatest Hits"
merged and clicking either queued both.

fit_to_width, format_time and centered_rect had two to four copies each and
now live in ui::text.

Diffstat:
Msrc/app/handler.rs | 119++++++++++++++++++++++++++++---------------------------------------------------
Msrc/library/mod.rs | 40++++++++++++++++++++++++++++++++--------
Msrc/ui/mod.rs | 104++++++++++++++++++++++++++++----------------------------------------------------
Dsrc/ui/panes/albums_pane.rs | 190-------------------------------------------------------------------------------
Dsrc/ui/panes/artists_pane.rs | 185-------------------------------------------------------------------------------
Msrc/ui/panes/dir_browser_pane.rs | 4++++
Dsrc/ui/panes/format_pane.rs | 196-------------------------------------------------------------------------------
Dsrc/ui/panes/genre_pane.rs | 185-------------------------------------------------------------------------------
Msrc/ui/panes/library_pane.rs | 8++++++--
Asrc/ui/panes/list_pane.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/panes/mod.rs | 6+-----
Dsrc/ui/panes/playlists_pane.rs | 196-------------------------------------------------------------------------------
Msrc/ui/panes/queue_pane.rs | 32+++++---------------------------
Asrc/ui/text.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/widgets/about_modal.rs | 22++--------------------
Msrc/ui/widgets/help_modal.rs | 22++--------------------
Msrc/ui/widgets/playlist_modal.rs | 20+-------------------
Msrc/ui/widgets/progress_bar.rs | 7+------
Msrc/ui/widgets/search_modal.rs | 44+-------------------------------------------
Msrc/ui/widgets/status_bar.rs | 7+------
20 files changed, 518 insertion(+), 1253 deletion(-)

diff --git a/src/app/handler.rs b/src/app/handler.rs
@@ -1,10 +1,12 @@
 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
 use std::time::{Duration, Instant};
 
 use crate::app::state::{FocusedPane, Tab};
 use crate::app::{App, AppAction};
 use crate::ui::layout::LayoutAreas;
 use crate::ui::pane::Pane;
+use crate::ui::panes::list_pane;
 use crate::ui::widgets::{progress_bar, tab_bar};
 use crate::ui::widgets::playlist_modal::PlaylistModalMode;
 use crate::ui::Ui;
@@ -391,15 +393,7 @@ pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction>
 
     // Route to focused pane
     let action = match app.focus {
-        FocusedPane::Library => match app.tab {
-            Tab::Queue => ui.library_pane.handle_key(key, app),
-            Tab::Directories => ui.dir_browser_pane.handle_key(key, app),
-            Tab::Artists => ui.artists_pane.handle_key(key, app),
-            Tab::Albums => ui.albums_pane.handle_key(key, app),
-            Tab::Genre => ui.genre_pane.handle_key(key, app),
-            Tab::Format => ui.format_pane.handle_key(key, app),
-            Tab::Playlists => ui.playlists_pane.handle_key(key, app),
-        },
+        FocusedPane::Library => library_key(key, app, ui),
         FocusedPane::Playlist => {
             match key.code {
                 KeyCode::Char('j') | KeyCode::Down => None,
@@ -666,27 +660,10 @@ pub fn handle_mouse_event(
                 .filter(|&row| row < library_row_count(app, ui));
             if in_library && clicked_library_row.is_some() {
                 // First, route mouse to pane for selection update
-                let _sel_action = match app.tab {
-                    Tab::Queue => ui.library_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Artists => ui.artists_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Albums => ui.albums_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Genre => ui.genre_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Format => ui.format_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Playlists => ui.playlists_pane.handle_mouse(mouse, areas.library, app),
-                };
+                library_mouse(mouse, areas.library, app, ui);
                 // Then, trigger Enter action to activate the clicked item
                 let enter_key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
-                let activate_action = match app.tab {
-                    Tab::Queue => ui.library_pane.handle_key(enter_key, app),
-                    Tab::Directories => ui.dir_browser_pane.handle_key(enter_key, app),
-                    Tab::Artists => ui.artists_pane.handle_key(enter_key, app),
-                    Tab::Albums => ui.albums_pane.handle_key(enter_key, app),
-                    Tab::Genre => ui.genre_pane.handle_key(enter_key, app),
-                    Tab::Format => ui.format_pane.handle_key(enter_key, app),
-                    Tab::Playlists => ui.playlists_pane.handle_key(enter_key, app),
-                };
-                if let Some(action) = activate_action {
+                if let Some(action) = library_key(enter_key, app, ui) {
                     if matches!(action, AppAction::AddToQueue(_)) {
                         actions.push(AppAction::FocusPane(FocusedPane::Playlist));
                     }
@@ -714,16 +691,7 @@ pub fn handle_mouse_event(
         }
         MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
             if in_library {
-                let action = match app.tab {
-                    Tab::Queue => ui.library_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Artists => ui.artists_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Albums => ui.albums_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Genre => ui.genre_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Format => ui.format_pane.handle_mouse(mouse, areas.library, app),
-                    Tab::Playlists => ui.playlists_pane.handle_mouse(mouse, areas.library, app),
-                };
-                if let Some(a) = action {
+                if let Some(a) = library_mouse(mouse, areas.library, app, ui) {
                     actions.push(a);
                 }
             } else if in_playlist {
@@ -760,11 +728,35 @@ fn library_row_count(app: &App, ui: &Ui) -> usize {
     match app.tab {
         Tab::Queue => ui.library_pane.row_count(app),
         Tab::Directories => ui.dir_browser_pane.entries.len(),
-        Tab::Artists => app.library.get_artists().len(),
-        Tab::Albums => app.library.get_albums().len(),
-        Tab::Genre => app.library.get_genres().len(),
-        Tab::Format => app.library.get_formats().len(),
-        Tab::Playlists => app.playlists.len(),
+        tab => list_pane::rows_for(app, tab).len(),
+    }
+}
+
+/// Route a key to whichever pane the library column is showing.
+fn library_key(key: KeyEvent, app: &App, ui: &mut Ui) -> Option<AppAction> {
+    match app.tab {
+        Tab::Queue => ui.library_pane.handle_key(key, app),
+        Tab::Directories => ui.dir_browser_pane.handle_key(key, app),
+        tab => {
+            let count = list_pane::rows_for(app, tab).len();
+            let pane = ui.list_pane_mut(tab);
+            pane.handle_key(key, count)
+                .then(|| list_pane::activate(app, tab, pane.selected))
+                .flatten()
+        }
+    }
+}
+
+/// Route a mouse event to whichever pane the library column is showing.
+fn library_mouse(mouse: MouseEvent, area: Rect, app: &App, ui: &mut Ui) -> Option<AppAction> {
+    match app.tab {
+        Tab::Queue => ui.library_pane.handle_mouse(mouse, area, app),
+        Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, area, app),
+        tab => {
+            let count = list_pane::rows_for(app, tab).len();
+            ui.list_pane_mut(tab).handle_mouse(mouse, area, count);
+            None
+        }
     }
 }
 
@@ -820,11 +812,9 @@ fn clear_all_hovers(ui: &mut Ui) {
     ui.queue_pane.hover_row = None;
     ui.library_pane.hover_row = None;
     ui.dir_browser_pane.hover_row = None;
-    ui.artists_pane.hover_row = None;
-    ui.albums_pane.hover_row = None;
-    ui.genre_pane.hover_row = None;
-    ui.format_pane.hover_row = None;
-    ui.playlists_pane.hover_row = None;
+    for pane in &mut ui.list_panes {
+        pane.hover_row = None;
+    }
 }
 
 /// Update hover_row state for panes based on mouse position
@@ -863,36 +853,11 @@ fn update_hover(
             && y >= inner.y && y < inner.y + inner.height
             && (scroll + (y - inner.y) as usize) < row_count
         {
-            let visual_row = (y - inner.y) as usize;
+            let row = scroll + (y - inner.y) as usize;
             match app.tab {
-                Tab::Queue => {
-                    let row = ui.library_pane.scroll_offset + visual_row;
-                    ui.library_pane.hover_row = Some(row);
-                }
-                Tab::Directories => {
-                    let row = ui.dir_browser_pane.scroll_offset + visual_row;
-                    ui.dir_browser_pane.hover_row = Some(row);
-                }
-                Tab::Artists => {
-                    let row = ui.artists_pane.scroll_offset + visual_row;
-                    ui.artists_pane.hover_row = Some(row);
-                }
-                Tab::Albums => {
-                    let row = ui.albums_pane.scroll_offset + visual_row;
-                    ui.albums_pane.hover_row = Some(row);
-                }
-                Tab::Genre => {
-                    let row = ui.genre_pane.scroll_offset + visual_row;
-                    ui.genre_pane.hover_row = Some(row);
-                }
-                Tab::Format => {
-                    let row = ui.format_pane.scroll_offset + visual_row;
-                    ui.format_pane.hover_row = Some(row);
-                }
-                Tab::Playlists => {
-                    let row = ui.playlists_pane.scroll_offset + visual_row;
-                    ui.playlists_pane.hover_row = Some(row);
-                }
+                Tab::Queue => ui.library_pane.hover_row = Some(row),
+                Tab::Directories => ui.dir_browser_pane.hover_row = Some(row),
+                tab => ui.list_pane_mut(tab).hover_row = Some(row),
             }
         }
     }
diff --git a/src/library/mod.rs b/src/library/mod.rs
@@ -54,12 +54,7 @@ impl Library {
         let mut set = BTreeSet::new();
         for t in &self.tracks {
             if !t.album.is_empty() {
-                let artist = if t.album_artist.is_empty() {
-                    t.artist.clone()
-                } else {
-                    t.album_artist.clone()
-                };
-                set.insert((t.album.clone(), artist));
+                set.insert((t.album.clone(), self.album_artist_of(t)));
             }
         }
         set.into_iter().collect()
@@ -80,15 +75,44 @@ impl Library {
             .collect()
     }
 
-    pub fn get_tracks_by_album(&self, album: &str) -> Vec<usize> {
+    /// Tracks on `album` by `artist`. `get_albums` pairs every album with its
+    /// artist, so matching on the title alone merged two different records that
+    /// happen to share a name — picking either "Greatest Hits" row queued both.
+    pub fn get_tracks_by_album(&self, album: &str, artist: &str) -> Vec<usize> {
         self.tracks
             .iter()
             .enumerate()
-            .filter(|(_, t)| t.album == album)
+            .filter(|(_, t)| t.album == album && self.album_artist_of(t) == artist)
             .map(|(i, _)| i)
             .collect()
     }
 
+    /// The artist an album is filed under: the album artist when tagged,
+    /// otherwise the track artist. `get_albums` groups by the same rule.
+    fn album_artist_of(&self, t: &track::Track) -> String {
+        if t.album_artist.is_empty() {
+            t.artist.clone()
+        } else {
+            t.album_artist.clone()
+        }
+    }
+
+    /// Every format with how many tracks use it, in one pass.
+    pub fn format_counts(&self) -> Vec<(String, usize)> {
+        let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
+        for t in &self.tracks {
+            let ext = t
+                .path
+                .extension()
+                .map(|e| e.to_string_lossy().to_lowercase())
+                .unwrap_or_default();
+            if !ext.is_empty() {
+                *counts.entry(ext).or_default() += 1;
+            }
+        }
+        counts.into_iter().collect()
+    }
+
     pub fn get_tracks_by_genre(&self, genre: &str) -> Vec<usize> {
         self.tracks
             .iter()
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -1,6 +1,7 @@
 pub mod layout;
 pub mod pane;
 pub mod panes;
+pub mod text;
 pub mod theme;
 pub mod widgets;
 
@@ -14,15 +15,23 @@ use crate::app::App;
 use crate::app::state::{FocusedPane, InfoView, Tab};
 use layout::LayoutAreas;
 use pane::Pane;
-use panes::albums_pane::AlbumsPane;
-use panes::artists_pane::ArtistsPane;
 use panes::dir_browser_pane::DirBrowserPane;
-use panes::format_pane::FormatPane;
-use panes::genre_pane::GenrePane;
 use panes::library_pane::LibraryPane;
+use panes::list_pane::{self, ListPane};
 use panes::lyrics_pane::LyricsPane;
-use panes::playlists_pane::PlaylistsPane;
 use panes::queue_pane::QueuePane;
+
+/// Index into `Ui::list_panes` for each list tab. Queue and Directories have
+/// their own pane types and never reach here.
+pub fn list_slot(tab: Tab) -> usize {
+    match tab {
+        Tab::Artists => 0,
+        Tab::Albums => 1,
+        Tab::Genre => 2,
+        Tab::Format => 3,
+        _ => 4, // Playlists
+    }
+}
 use theme::Theme;
 use widgets::info_pane;
 use widgets::progress_bar;
@@ -34,11 +43,8 @@ pub struct Ui {
     pub library_pane: LibraryPane,
     pub dir_browser_pane: DirBrowserPane,
     pub queue_pane: QueuePane,
-    pub artists_pane: ArtistsPane,
-    pub albums_pane: AlbumsPane,
-    pub genre_pane: GenrePane,
-    pub format_pane: FormatPane,
-    pub playlists_pane: PlaylistsPane,
+    /// One per list tab, keyed by Tab; each keeps its own selection and scroll.
+    pub list_panes: [ListPane; 5],
     pub lyrics_pane: LyricsPane,
     pub last_click: Option<(std::time::Instant, u16, u16)>,
     /// Last known mouse position (column, row) for hover tracking
@@ -100,11 +106,7 @@ impl Ui {
             library_pane: LibraryPane::new(),
             dir_browser_pane: DirBrowserPane::new(music_dir),
             queue_pane: QueuePane::new(),
-            artists_pane: ArtistsPane::new(),
-            albums_pane: AlbumsPane::new(),
-            genre_pane: GenrePane::new(),
-            format_pane: FormatPane::new(),
-            playlists_pane: PlaylistsPane::new(),
+            list_panes: Default::default(),
             lyrics_pane: LyricsPane::new(),
             last_click: None,
             mouse_pos: None,
@@ -165,11 +167,12 @@ impl Ui {
         match app.tab {
             Tab::Queue => self.library_pane.render(frame, areas.library, lib_focused, app, &self.theme),
             Tab::Directories => self.dir_browser_pane.render(frame, areas.library, lib_focused, app, &self.theme),
-            Tab::Artists => self.artists_pane.render(frame, areas.library, lib_focused, app, &self.theme),
-            Tab::Albums => self.albums_pane.render(frame, areas.library, lib_focused, app, &self.theme),
-            Tab::Genre => self.genre_pane.render(frame, areas.library, lib_focused, app, &self.theme),
-            Tab::Format => self.format_pane.render(frame, areas.library, lib_focused, app, &self.theme),
-            Tab::Playlists => self.playlists_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            tab => {
+                let rows = list_pane::rows_for(app, tab);
+                let theme = &self.theme;
+                self.list_panes[list_slot(tab)]
+                    .render(frame, areas.library, lib_focused, theme, &rows);
+            }
         }
 
         // Center pane (Queue)
@@ -237,16 +240,19 @@ impl Ui {
         }
     }
 
+    /// The list pane backing `tab`. Panics only for Queue/Directories, which
+    /// have their own pane types — `list_slot` maps those to Playlists, so
+    /// callers must check the tab first.
+    pub fn list_pane_mut(&mut self, tab: Tab) -> &mut ListPane {
+        &mut self.list_panes[list_slot(tab)]
+    }
+
     /// Scroll offset of whichever pane the library column is currently showing.
     pub fn library_pane_scroll(&self, app: &App) -> usize {
         match app.tab {
             Tab::Queue => self.library_pane.scroll_offset,
             Tab::Directories => self.dir_browser_pane.scroll_offset,
-            Tab::Artists => self.artists_pane.scroll_offset,
-            Tab::Albums => self.albums_pane.scroll_offset,
-            Tab::Genre => self.genre_pane.scroll_offset,
-            Tab::Format => self.format_pane.scroll_offset,
-            Tab::Playlists => self.playlists_pane.scroll_offset,
+            tab => self.list_panes[list_slot(tab)].scroll_offset,
         }
     }
 
@@ -269,49 +275,11 @@ impl Ui {
     }
 
     pub fn clamp_selections(&mut self, app: &App) {
-        let artists_len = app.library.get_artists().len();
-        if artists_len == 0 {
-            self.artists_pane.selected = 0;
-            self.artists_pane.scroll_offset = 0;
-        } else {
-            self.artists_pane.selected = self.artists_pane.selected.min(artists_len - 1);
-            self.artists_pane.scroll_offset = self.artists_pane.scroll_offset.min(artists_len - 1);
-        }
-
-        let albums_len = app.library.get_albums().len();
-        if albums_len == 0 {
-            self.albums_pane.selected = 0;
-            self.albums_pane.scroll_offset = 0;
-        } else {
-            self.albums_pane.selected = self.albums_pane.selected.min(albums_len - 1);
-            self.albums_pane.scroll_offset = self.albums_pane.scroll_offset.min(albums_len - 1);
-        }
-
-        let genres_len = app.library.get_genres().len();
-        if genres_len == 0 {
-            self.genre_pane.selected = 0;
-            self.genre_pane.scroll_offset = 0;
-        } else {
-            self.genre_pane.selected = self.genre_pane.selected.min(genres_len - 1);
-            self.genre_pane.scroll_offset = self.genre_pane.scroll_offset.min(genres_len - 1);
-        }
-
-        let formats_len = app.library.get_formats().len();
-        if formats_len == 0 {
-            self.format_pane.selected = 0;
-            self.format_pane.scroll_offset = 0;
-        } else {
-            self.format_pane.selected = self.format_pane.selected.min(formats_len - 1);
-            self.format_pane.scroll_offset = self.format_pane.scroll_offset.min(formats_len - 1);
-        }
-
-        let playlists_len = app.playlists.len();
-        if playlists_len == 0 {
-            self.playlists_pane.selected = 0;
-            self.playlists_pane.scroll_offset = 0;
-        } else {
-            self.playlists_pane.selected = self.playlists_pane.selected.min(playlists_len - 1);
-            self.playlists_pane.scroll_offset = self.playlists_pane.scroll_offset.min(playlists_len - 1);
+        for tab in [Tab::Artists, Tab::Albums, Tab::Genre, Tab::Format, Tab::Playlists] {
+            let len = list_pane::rows_for(app, tab).len();
+            let pane = &mut self.list_panes[list_slot(tab)];
+            pane.selected = pane.selected.min(len.saturating_sub(1));
+            pane.scroll_offset = pane.scroll_offset.min(len.saturating_sub(1));
         }
 
         // Reset library/dir browser to top since track indices changed
diff --git a/src/ui/panes/albums_pane.rs b/src/ui/panes/albums_pane.rs
@@ -1,190 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Rect;
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::text::{Line, Span};
-use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
-use ratatui::Frame;
-
-use crate::app::{App, AppAction};
-use crate::ui::pane::Pane;
-use crate::ui::theme::Theme;
-
-const HOVER_BG: Color = Color::Indexed(238);
-
-pub struct AlbumsPane {
-    pub selected: usize,
-    pub scroll_offset: usize,
-    pub hover_row: Option<usize>,
-}
-
-impl AlbumsPane {
-    pub fn new() -> Self {
-        Self {
-            selected: 0,
-            scroll_offset: 0,
-            hover_row: None,
-        }
-    }
-}
-
-impl Pane for AlbumsPane {
-    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
-        let albums = app.library.get_albums();
-        let count = albums.len();
-        let border_color = if focused {
-            theme.border_focused
-        } else {
-            theme.border_unfocused
-        };
-
-        let block = Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(border_color))
-            .title_style(Style::default().fg(if focused {
-                theme.border_focused
-            } else {
-                theme.fg
-            }));
-
-        let inner = block.inner(area);
-        let inner_height = inner.height as usize;
-
-        // Auto-scroll
-        if count > 0 {
-            if self.selected < self.scroll_offset {
-                self.scroll_offset = self.selected;
-            }
-            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
-                self.scroll_offset = self.selected - inner_height + 1;
-            }
-        }
-
-        let has_scrollbar = count > inner_height;
-        let highlight = Style::default()
-            .bg(theme.highlight_bg)
-            .fg(theme.highlight_fg)
-            .add_modifier(Modifier::BOLD);
-
-        let items: Vec<ListItem> = albums
-            .iter()
-            .enumerate()
-            .skip(self.scroll_offset)
-            .take(inner_height)
-            .map(|(i, (album, artist))| {
-                let is_selected = i == self.selected;
-                let is_hovered = self.hover_row == Some(i);
-
-                if is_selected && focused {
-                    ListItem::new(Line::from(vec![
-                        Span::styled(format!("  {}", album), highlight),
-                        Span::styled(format!("  {}", artist), highlight),
-                    ]))
-                } else {
-                    let bg = if is_hovered { HOVER_BG } else { Color::Reset };
-                    ListItem::new(Line::from(vec![
-                        Span::styled(format!("  {}", album), Style::default().fg(theme.fg).bg(bg)),
-                        Span::styled(format!("  {}", artist), Style::default().fg(Color::Gray).bg(bg)),
-                    ]))
-                }
-            })
-            .collect();
-
-        let list = List::new(items).block(block);
-        frame.render_widget(list, area);
-
-        if has_scrollbar {
-            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
-                .begin_symbol(None)
-                .end_symbol(None);
-            let mut scrollbar_state = ScrollbarState::new(count)
-                .position(self.scroll_offset);
-            frame.render_stateful_widget(
-                scrollbar,
-                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
-                &mut scrollbar_state,
-            );
-        }
-    }
-
-    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
-        let albums = app.library.get_albums();
-        let count = albums.len();
-        if count == 0 {
-            return None;
-        }
-
-        match key.code {
-            KeyCode::Char('j') | KeyCode::Down => {
-                if self.selected < count - 1 {
-                    self.selected += 1;
-                }
-                None
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                if self.selected > 0 {
-                    self.selected -= 1;
-                }
-                None
-            }
-            KeyCode::Enter => {
-                if self.selected < count {
-                    let tracks = app.library.get_tracks_by_album(&albums[self.selected].0);
-                    if !tracks.is_empty() {
-                        return Some(AppAction::AddToQueue(tracks));
-                    }
-                }
-                None
-            }
-            KeyCode::Home | KeyCode::Char('g') => {
-                self.selected = 0;
-                self.scroll_offset = 0;
-                None
-            }
-            KeyCode::End | KeyCode::Char('G') => {
-                self.selected = count.saturating_sub(1);
-                None
-            }
-            _ => None,
-        }
-    }
-
-    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
-        let block = Block::default().borders(Borders::ALL);
-        let inner = block.inner(area);
-        let count = app.library.get_albums().len();
-
-        match event.kind {
-            MouseEventKind::Down(MouseButton::Left) => {
-                if event.column >= inner.x
-                    && event.column < inner.x + inner.width
-                    && event.row >= inner.y
-                    && event.row < inner.y + inner.height
-                {
-                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
-                    if clicked < count {
-                        self.selected = clicked;
-                    }
-                }
-                None
-            }
-            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
-            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
-            _ => None,
-        }
-    }
-
-    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
-        let count = app.library.get_albums().len();
-        if count == 0 {
-            return None;
-        }
-        if up {
-            self.scroll_offset = self.scroll_offset.saturating_sub(3);
-            self.selected = self.selected.saturating_sub(3);
-        } else {
-            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
-            self.selected = (self.selected + 3).min(count.saturating_sub(1));
-        }
-        None
-    }
-}
diff --git a/src/ui/panes/artists_pane.rs b/src/ui/panes/artists_pane.rs
@@ -1,185 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Rect;
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::text::{Line, Span};
-use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
-use ratatui::Frame;
-
-use crate::app::{App, AppAction};
-use crate::ui::pane::Pane;
-use crate::ui::theme::Theme;
-
-const HOVER_BG: Color = Color::Indexed(238);
-
-pub struct ArtistsPane {
-    pub selected: usize,
-    pub scroll_offset: usize,
-    pub hover_row: Option<usize>,
-}
-
-impl ArtistsPane {
-    pub fn new() -> Self {
-        Self {
-            selected: 0,
-            scroll_offset: 0,
-            hover_row: None,
-        }
-    }
-}
-
-impl Pane for ArtistsPane {
-    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
-        let artists = app.library.get_artists();
-        let count = artists.len();
-        let border_color = if focused {
-            theme.border_focused
-        } else {
-            theme.border_unfocused
-        };
-
-        let block = Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(border_color))
-            .title_style(Style::default().fg(if focused {
-                theme.border_focused
-            } else {
-                theme.fg
-            }));
-
-        let inner = block.inner(area);
-        let inner_height = inner.height as usize;
-
-        // Auto-scroll
-        if count > 0 {
-            if self.selected < self.scroll_offset {
-                self.scroll_offset = self.selected;
-            }
-            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
-                self.scroll_offset = self.selected - inner_height + 1;
-            }
-        }
-
-        let has_scrollbar = count > inner_height;
-        let highlight = Style::default()
-            .bg(theme.highlight_bg)
-            .fg(theme.highlight_fg)
-            .add_modifier(Modifier::BOLD);
-
-        let items: Vec<ListItem> = artists
-            .iter()
-            .enumerate()
-            .skip(self.scroll_offset)
-            .take(inner_height)
-            .map(|(i, artist)| {
-                let is_selected = i == self.selected;
-                let is_hovered = self.hover_row == Some(i);
-                let style = if is_selected && focused {
-                    highlight
-                } else if is_hovered {
-                    Style::default().fg(theme.fg).bg(HOVER_BG)
-                } else {
-                    Style::default().fg(theme.fg)
-                };
-                ListItem::new(Line::from(Span::styled(format!("  {}", artist), style)))
-            })
-            .collect();
-
-        let list = List::new(items).block(block);
-        frame.render_widget(list, area);
-
-        if has_scrollbar {
-            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
-                .begin_symbol(None)
-                .end_symbol(None);
-            let mut scrollbar_state = ScrollbarState::new(count)
-                .position(self.scroll_offset);
-            frame.render_stateful_widget(
-                scrollbar,
-                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
-                &mut scrollbar_state,
-            );
-        }
-    }
-
-    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
-        let artists = app.library.get_artists();
-        let count = artists.len();
-        if count == 0 {
-            return None;
-        }
-
-        match key.code {
-            KeyCode::Char('j') | KeyCode::Down => {
-                if self.selected < count - 1 {
-                    self.selected += 1;
-                }
-                None
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                if self.selected > 0 {
-                    self.selected -= 1;
-                }
-                None
-            }
-            KeyCode::Enter => {
-                if self.selected < count {
-                    let tracks = app.library.get_tracks_by_artist(&artists[self.selected]);
-                    if !tracks.is_empty() {
-                        return Some(AppAction::AddToQueue(tracks));
-                    }
-                }
-                None
-            }
-            KeyCode::Home | KeyCode::Char('g') => {
-                self.selected = 0;
-                self.scroll_offset = 0;
-                None
-            }
-            KeyCode::End | KeyCode::Char('G') => {
-                self.selected = count.saturating_sub(1);
-                None
-            }
-            _ => None,
-        }
-    }
-
-    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
-        let block = Block::default().borders(Borders::ALL);
-        let inner = block.inner(area);
-        let count = app.library.get_artists().len();
-
-        match event.kind {
-            MouseEventKind::Down(MouseButton::Left) => {
-                if event.column >= inner.x
-                    && event.column < inner.x + inner.width
-                    && event.row >= inner.y
-                    && event.row < inner.y + inner.height
-                {
-                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
-                    if clicked < count {
-                        self.selected = clicked;
-                    }
-                }
-                None
-            }
-            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
-            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
-            _ => None,
-        }
-    }
-
-    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
-        let count = app.library.get_artists().len();
-        if count == 0 {
-            return None;
-        }
-        if up {
-            self.scroll_offset = self.scroll_offset.saturating_sub(3);
-            self.selected = self.selected.saturating_sub(3);
-        } else {
-            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
-            self.selected = (self.selected + 3).min(count.saturating_sub(1));
-        }
-        None
-    }
-}
diff --git a/src/ui/panes/dir_browser_pane.rs b/src/ui/panes/dir_browser_pane.rs
@@ -9,6 +9,7 @@ use std::path::PathBuf;
 use crate::app::{App, AppAction};
 use crate::ui::pane::Pane;
 use crate::ui::theme::Theme;
+use crate::ui::text::max_scroll;
 
 const HOVER_BG: Color = Color::Indexed(238);
 
@@ -84,6 +85,9 @@ impl Pane for DirBrowserPane {
             if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
                 self.scroll_offset = self.selected - inner_height + 1;
             }
+            // Keep the viewport full: the scroll handlers clamp to count - 1,
+            // which lets the list scroll until one row sits above a blank screen.
+            self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
         }
 
         let has_scrollbar = count > inner_height;
diff --git a/src/ui/panes/format_pane.rs b/src/ui/panes/format_pane.rs
@@ -1,196 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Rect;
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::text::{Line, Span};
-use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
-use ratatui::Frame;
-
-use crate::app::{App, AppAction};
-use crate::ui::pane::Pane;
-use crate::ui::theme::Theme;
-
-const HOVER_BG: Color = Color::Indexed(238);
-
-pub struct FormatPane {
-    pub selected: usize,
-    pub scroll_offset: usize,
-    pub hover_row: Option<usize>,
-}
-
-impl FormatPane {
-    pub fn new() -> Self {
-        Self {
-            selected: 0,
-            scroll_offset: 0,
-            hover_row: None,
-        }
-    }
-}
-
-impl Pane for FormatPane {
-    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
-        let formats = app.library.get_formats();
-        let count = formats.len();
-        let border_color = if focused {
-            theme.border_focused
-        } else {
-            theme.border_unfocused
-        };
-
-        let block = Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(border_color))
-            .title_style(Style::default().fg(if focused {
-                theme.border_focused
-            } else {
-                theme.fg
-            }));
-
-        let inner = block.inner(area);
-        let inner_height = inner.height as usize;
-
-        // Auto-scroll
-        if count > 0 {
-            if self.selected < self.scroll_offset {
-                self.scroll_offset = self.selected;
-            }
-            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
-                self.scroll_offset = self.selected - inner_height + 1;
-            }
-        }
-
-        let has_scrollbar = count > inner_height;
-        let highlight = Style::default()
-            .bg(theme.highlight_bg)
-            .fg(theme.highlight_fg)
-            .add_modifier(Modifier::BOLD);
-
-        let items: Vec<ListItem> = formats
-            .iter()
-            .enumerate()
-            .skip(self.scroll_offset)
-            .take(inner_height)
-            .map(|(i, fmt)| {
-                let is_selected = i == self.selected;
-                let is_hovered = self.hover_row == Some(i);
-                let track_count = app.library.get_tracks_by_format(fmt).len();
-                let style = if is_selected && focused {
-                    highlight
-                } else if is_hovered {
-                    Style::default().fg(theme.fg).bg(HOVER_BG)
-                } else {
-                    Style::default().fg(theme.fg)
-                };
-                let count_style = if is_selected && focused {
-                    highlight
-                } else if is_hovered {
-                    Style::default().fg(Color::DarkGray).bg(HOVER_BG)
-                } else {
-                    Style::default().fg(Color::DarkGray)
-                };
-                ListItem::new(Line::from(vec![
-                    Span::styled(format!("  {}", fmt), style),
-                    Span::styled(format!(" ({})", track_count), count_style),
-                ]))
-            })
-            .collect();
-
-        let list = List::new(items).block(block);
-        frame.render_widget(list, area);
-
-        if has_scrollbar {
-            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
-                .begin_symbol(None)
-                .end_symbol(None);
-            let mut scrollbar_state = ScrollbarState::new(count)
-                .position(self.scroll_offset);
-            frame.render_stateful_widget(
-                scrollbar,
-                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
-                &mut scrollbar_state,
-            );
-        }
-    }
-
-    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
-        let formats = app.library.get_formats();
-        let count = formats.len();
-        if count == 0 {
-            return None;
-        }
-
-        match key.code {
-            KeyCode::Char('j') | KeyCode::Down => {
-                if self.selected < count - 1 {
-                    self.selected += 1;
-                }
-                None
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                if self.selected > 0 {
-                    self.selected -= 1;
-                }
-                None
-            }
-            KeyCode::Enter => {
-                if self.selected < count {
-                    let tracks = app.library.get_tracks_by_format(&formats[self.selected]);
-                    if !tracks.is_empty() {
-                        return Some(AppAction::AddToQueue(tracks));
-                    }
-                }
-                None
-            }
-            KeyCode::Home | KeyCode::Char('g') => {
-                self.selected = 0;
-                self.scroll_offset = 0;
-                None
-            }
-            KeyCode::End | KeyCode::Char('G') => {
-                self.selected = count.saturating_sub(1);
-                None
-            }
-            _ => None,
-        }
-    }
-
-    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
-        let block = Block::default().borders(Borders::ALL);
-        let inner = block.inner(area);
-        let count = app.library.get_formats().len();
-
-        match event.kind {
-            MouseEventKind::Down(MouseButton::Left) => {
-                if event.column >= inner.x
-                    && event.column < inner.x + inner.width
-                    && event.row >= inner.y
-                    && event.row < inner.y + inner.height
-                {
-                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
-                    if clicked < count {
-                        self.selected = clicked;
-                    }
-                }
-                None
-            }
-            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
-            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
-            _ => None,
-        }
-    }
-
-    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
-        let count = app.library.get_formats().len();
-        if count == 0 {
-            return None;
-        }
-        if up {
-            self.scroll_offset = self.scroll_offset.saturating_sub(3);
-            self.selected = self.selected.saturating_sub(3);
-        } else {
-            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
-            self.selected = (self.selected + 3).min(count.saturating_sub(1));
-        }
-        None
-    }
-}
diff --git a/src/ui/panes/genre_pane.rs b/src/ui/panes/genre_pane.rs
@@ -1,185 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Rect;
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::text::{Line, Span};
-use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
-use ratatui::Frame;
-
-use crate::app::{App, AppAction};
-use crate::ui::pane::Pane;
-use crate::ui::theme::Theme;
-
-const HOVER_BG: Color = Color::Indexed(238);
-
-pub struct GenrePane {
-    pub selected: usize,
-    pub scroll_offset: usize,
-    pub hover_row: Option<usize>,
-}
-
-impl GenrePane {
-    pub fn new() -> Self {
-        Self {
-            selected: 0,
-            scroll_offset: 0,
-            hover_row: None,
-        }
-    }
-}
-
-impl Pane for GenrePane {
-    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
-        let genres = app.library.get_genres();
-        let count = genres.len();
-        let border_color = if focused {
-            theme.border_focused
-        } else {
-            theme.border_unfocused
-        };
-
-        let block = Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(border_color))
-            .title_style(Style::default().fg(if focused {
-                theme.border_focused
-            } else {
-                theme.fg
-            }));
-
-        let inner = block.inner(area);
-        let inner_height = inner.height as usize;
-
-        // Auto-scroll
-        if count > 0 {
-            if self.selected < self.scroll_offset {
-                self.scroll_offset = self.selected;
-            }
-            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
-                self.scroll_offset = self.selected - inner_height + 1;
-            }
-        }
-
-        let has_scrollbar = count > inner_height;
-        let highlight = Style::default()
-            .bg(theme.highlight_bg)
-            .fg(theme.highlight_fg)
-            .add_modifier(Modifier::BOLD);
-
-        let items: Vec<ListItem> = genres
-            .iter()
-            .enumerate()
-            .skip(self.scroll_offset)
-            .take(inner_height)
-            .map(|(i, genre)| {
-                let is_selected = i == self.selected;
-                let is_hovered = self.hover_row == Some(i);
-                let style = if is_selected && focused {
-                    highlight
-                } else if is_hovered {
-                    Style::default().fg(theme.fg).bg(HOVER_BG)
-                } else {
-                    Style::default().fg(theme.fg)
-                };
-                ListItem::new(Line::from(Span::styled(format!("  {}", genre), style)))
-            })
-            .collect();
-
-        let list = List::new(items).block(block);
-        frame.render_widget(list, area);
-
-        if has_scrollbar {
-            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
-                .begin_symbol(None)
-                .end_symbol(None);
-            let mut scrollbar_state = ScrollbarState::new(count)
-                .position(self.scroll_offset);
-            frame.render_stateful_widget(
-                scrollbar,
-                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
-                &mut scrollbar_state,
-            );
-        }
-    }
-
-    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
-        let genres = app.library.get_genres();
-        let count = genres.len();
-        if count == 0 {
-            return None;
-        }
-
-        match key.code {
-            KeyCode::Char('j') | KeyCode::Down => {
-                if self.selected < count - 1 {
-                    self.selected += 1;
-                }
-                None
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                if self.selected > 0 {
-                    self.selected -= 1;
-                }
-                None
-            }
-            KeyCode::Enter => {
-                if self.selected < count {
-                    let tracks = app.library.get_tracks_by_genre(&genres[self.selected]);
-                    if !tracks.is_empty() {
-                        return Some(AppAction::AddToQueue(tracks));
-                    }
-                }
-                None
-            }
-            KeyCode::Home | KeyCode::Char('g') => {
-                self.selected = 0;
-                self.scroll_offset = 0;
-                None
-            }
-            KeyCode::End | KeyCode::Char('G') => {
-                self.selected = count.saturating_sub(1);
-                None
-            }
-            _ => None,
-        }
-    }
-
-    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
-        let block = Block::default().borders(Borders::ALL);
-        let inner = block.inner(area);
-        let count = app.library.get_genres().len();
-
-        match event.kind {
-            MouseEventKind::Down(MouseButton::Left) => {
-                if event.column >= inner.x
-                    && event.column < inner.x + inner.width
-                    && event.row >= inner.y
-                    && event.row < inner.y + inner.height
-                {
-                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
-                    if clicked < count {
-                        self.selected = clicked;
-                    }
-                }
-                None
-            }
-            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
-            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
-            _ => None,
-        }
-    }
-
-    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
-        let count = app.library.get_genres().len();
-        if count == 0 {
-            return None;
-        }
-        if up {
-            self.scroll_offset = self.scroll_offset.saturating_sub(3);
-            self.selected = self.selected.saturating_sub(3);
-        } else {
-            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
-            self.selected = (self.selected + 3).min(count.saturating_sub(1));
-        }
-        None
-    }
-}
diff --git a/src/ui/panes/library_pane.rs b/src/ui/panes/library_pane.rs
@@ -8,6 +8,7 @@ use ratatui::Frame;
 use crate::app::{App, AppAction};
 use crate::ui::pane::Pane;
 use crate::ui::theme::Theme;
+use crate::ui::text::max_scroll;
 
 /// An entry in the flattened library list
 #[derive(Debug, Clone)]
@@ -124,6 +125,9 @@ impl Pane for LibraryPane {
             if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
                 self.scroll_offset = self.selected - inner_height + 1;
             }
+            // Keep the viewport full: the scroll handlers clamp to count - 1,
+            // which lets the list scroll until one row sits above a blank screen.
+            self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
         }
 
         let has_scrollbar = count > inner_height;
@@ -349,8 +353,8 @@ impl Pane for LibraryPane {
                             None
                         }
                     }
-                    LibraryEntry::Album { name, .. } => {
-                        let tracks = app.library.get_tracks_by_album(name);
+                    LibraryEntry::Album { name, artist } => {
+                        let tracks = app.library.get_tracks_by_album(name, artist);
                         if !tracks.is_empty() {
                             Some(AppAction::AddToQueue(tracks))
                         } else {
diff --git a/src/ui/panes/list_pane.rs b/src/ui/panes/list_pane.rs
@@ -0,0 +1,282 @@
+//! One scrollable list of labelled rows, shared by the Artists, Albums, Genre,
+//! Format and Playlists tabs.
+//!
+//! These were five near-identical files; normalising identifiers made three of
+//! them byte-for-byte the same over all 185 lines, and the other two differed
+//! only inside the row-render closure and one call in the Enter arm. Everything
+//! here beyond `ListRow` and `rows_for`/`activate` was copied five ways, which
+//! is how the render-time selection clamp ended up in exactly one of them.
+
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Margin, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{
+    Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
+};
+use ratatui::Frame;
+
+use crate::app::state::Tab;
+use crate::app::{App, AppAction};
+use crate::ui::text::{fit_to_width, max_scroll};
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+const SCROLL_STEP: usize = 3;
+
+/// A row: an optional leading icon, the label, and dim trailing detail such as
+/// an album's artist or a track count.
+pub struct ListRow {
+    pub icon: Option<(&'static str, Color)>,
+    pub text: String,
+    pub detail: Option<String>,
+}
+
+impl ListRow {
+    pub fn plain(text: String) -> Self {
+        Self { icon: None, text, detail: None }
+    }
+
+    pub fn with_detail(text: String, detail: String) -> Self {
+        Self { icon: None, text, detail: Some(detail) }
+    }
+}
+
+#[derive(Default)]
+pub struct ListPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl ListPane {
+    pub fn render(
+        &mut self,
+        frame: &mut Frame,
+        area: Rect,
+        focused: bool,
+        theme: &Theme,
+        rows: &[ListRow],
+    ) {
+        let count = rows.len();
+        // Clamp here rather than trusting callers: only one of the five copies
+        // did this, and it survived purely because a separate clamp ran on
+        // library reload.
+        if self.selected >= count {
+            self.selected = count.saturating_sub(1);
+        }
+
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+        let inner_width = inner.width as usize;
+
+        // Auto-scroll to keep the selection visible, then make sure the viewport
+        // is still full.
+        if count > 0 && inner_height > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+            self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
+        }
+
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = rows
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, row)| {
+                let selected = i == self.selected && focused;
+                let hovered = self.hover_row == Some(i);
+                let bg = if hovered { HOVER_BG } else { Color::Reset };
+
+                let base = if selected {
+                    highlight
+                } else {
+                    Style::default().fg(theme.fg).bg(bg)
+                };
+                let dim = if selected {
+                    highlight
+                } else {
+                    Style::default().fg(Color::DarkGray).bg(bg)
+                };
+
+                let mut spans = Vec::new();
+                let mut used = 2;
+                spans.push(Span::styled("  ", base));
+                if let Some((icon, color)) = row.icon {
+                    used += icon.chars().count() + 1;
+                    let style = if selected {
+                        highlight
+                    } else {
+                        Style::default().fg(color).bg(bg)
+                    };
+                    spans.push(Span::styled(format!("{icon} "), style));
+                }
+
+                match &row.detail {
+                    // Pad the label to a fixed column so the detail lines up on
+                    // every row. Without this a CJK label and an ASCII one put
+                    // the second column at different offsets.
+                    Some(detail) => {
+                        let detail_w = detail.chars().count() + 1;
+                        let label_w = inner_width.saturating_sub(used + detail_w);
+                        spans.push(Span::styled(fit_to_width(&row.text, label_w), base));
+                        spans.push(Span::styled(format!(" {detail}"), dim));
+                    }
+                    None => spans.push(Span::styled(
+                        fit_to_width(&row.text, inner_width.saturating_sub(used)),
+                        base,
+                    )),
+                }
+
+                ListItem::new(Line::from(spans))
+            })
+            .collect();
+
+        frame.render_widget(List::new(items).block(block), area);
+
+        if count > inner_height {
+            let mut state = ScrollbarState::new(count).position(self.scroll_offset);
+            frame.render_stateful_widget(
+                Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                    .begin_symbol(None)
+                    .end_symbol(None),
+                area.inner(Margin { vertical: 1, horizontal: 0 }),
+                &mut state,
+            );
+        }
+    }
+
+    /// Returns true when the key selected an item and the caller should build an
+    /// activation action.
+    pub fn handle_key(&mut self, key: KeyEvent, count: usize) -> bool {
+        if count == 0 {
+            return false;
+        }
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                self.selected = self.selected.saturating_sub(1);
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count - 1;
+            }
+            KeyCode::Enter => return true,
+            _ => {}
+        }
+        false
+    }
+
+    pub fn handle_mouse(&mut self, event: MouseEvent, area: Rect, count: usize) {
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                let inner = Block::default().borders(Borders::ALL).inner(area);
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+            }
+            MouseEventKind::ScrollDown => self.scroll(false, count, area.height as usize),
+            MouseEventKind::ScrollUp => self.scroll(true, count, area.height as usize),
+            _ => {}
+        }
+    }
+
+    pub fn scroll(&mut self, up: bool, count: usize, viewport: usize) {
+        if count == 0 {
+            return;
+        }
+        // Two border rows.
+        let inner_height = viewport.saturating_sub(2);
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_STEP);
+            self.selected = self.selected.saturating_sub(SCROLL_STEP);
+        } else {
+            self.scroll_offset =
+                (self.scroll_offset + SCROLL_STEP).min(max_scroll(count, inner_height));
+            self.selected = (self.selected + SCROLL_STEP).min(count - 1);
+        }
+    }
+}
+
+/// Rows for whichever of the five list tabs is showing.
+pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
+    match tab {
+        Tab::Artists => app.library.get_artists().into_iter().map(ListRow::plain).collect(),
+        Tab::Albums => app
+            .library
+            .get_albums()
+            .into_iter()
+            .map(|(album, artist)| ListRow::with_detail(album, artist))
+            .collect(),
+        Tab::Genre => app.library.get_genres().into_iter().map(ListRow::plain).collect(),
+        Tab::Format => {
+            // One pass over the library instead of one per visible row: the old
+            // format pane called get_tracks_by_format inside the render closure,
+            // rescanning and lowercasing every track for every row, every frame.
+            let counts = app.library.format_counts();
+            counts
+                .into_iter()
+                .map(|(fmt, n)| ListRow::with_detail(fmt, format!("({n})")))
+                .collect()
+        }
+        Tab::Playlists => app
+            .playlists
+            .iter()
+            .map(|pl| ListRow {
+                icon: Some(("\u{F005}", Color::Yellow)),
+                text: pl.name.clone(),
+                detail: Some(format!("({})", pl.tracks.len())),
+            })
+            .collect(),
+        Tab::Queue | Tab::Directories => Vec::new(),
+    }
+}
+
+/// The action for activating row `selected` of `tab`.
+pub fn activate(app: &App, tab: Tab, selected: usize) -> Option<AppAction> {
+    let tracks = match tab {
+        Tab::Artists => app.library.get_tracks_by_artist(app.library.get_artists().get(selected)?),
+        Tab::Albums => {
+            let albums = app.library.get_albums();
+            let (album, artist) = albums.get(selected)?;
+            app.library.get_tracks_by_album(album, artist)
+        }
+        Tab::Genre => app.library.get_tracks_by_genre(app.library.get_genres().get(selected)?),
+        Tab::Format => app.library.get_tracks_by_format(app.library.get_formats().get(selected)?),
+        Tab::Playlists => app.playlists.get(selected)?.tracks.clone(),
+        Tab::Queue | Tab::Directories => return None,
+    };
+    (!tracks.is_empty()).then_some(AppAction::AddToQueue(tracks))
+}
diff --git a/src/ui/panes/mod.rs b/src/ui/panes/mod.rs
@@ -1,9 +1,5 @@
 pub mod library_pane;
 pub mod dir_browser_pane;
 pub mod queue_pane;
-pub mod artists_pane;
-pub mod albums_pane;
-pub mod genre_pane;
-pub mod format_pane;
-pub mod playlists_pane;
+pub mod list_pane;
 pub mod lyrics_pane;
diff --git a/src/ui/panes/playlists_pane.rs b/src/ui/panes/playlists_pane.rs
@@ -1,196 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Rect;
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::text::{Line, Span};
-use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
-use ratatui::Frame;
-
-use crate::app::{App, AppAction};
-use crate::ui::pane::Pane;
-use crate::ui::theme::Theme;
-
-const HOVER_BG: Color = Color::Indexed(238);
-
-pub struct PlaylistsPane {
-    pub selected: usize,
-    pub scroll_offset: usize,
-    pub hover_row: Option<usize>,
-}
-
-impl PlaylistsPane {
-    pub fn new() -> Self {
-        Self {
-            selected: 0,
-            scroll_offset: 0,
-            hover_row: None,
-        }
-    }
-}
-
-impl Pane for PlaylistsPane {
-    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
-        let count = app.playlists.len();
-        let border_color = if focused {
-            theme.border_focused
-        } else {
-            theme.border_unfocused
-        };
-
-        let block = Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(border_color))
-            .title_style(Style::default().fg(if focused {
-                theme.border_focused
-            } else {
-                theme.fg
-            }));
-
-        let inner = block.inner(area);
-        let inner_height = inner.height as usize;
-
-        // Auto-scroll
-        if count > 0 {
-            if self.selected >= count {
-                self.selected = count.saturating_sub(1);
-            }
-            if self.selected < self.scroll_offset {
-                self.scroll_offset = self.selected;
-            }
-            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
-                self.scroll_offset = self.selected - inner_height + 1;
-            }
-        }
-
-        let has_scrollbar = count > inner_height;
-        let highlight = Style::default()
-            .bg(theme.highlight_bg)
-            .fg(theme.highlight_fg)
-            .add_modifier(Modifier::BOLD);
-
-        let items: Vec<ListItem> = app
-            .playlists
-            .iter()
-            .enumerate()
-            .skip(self.scroll_offset)
-            .take(inner_height)
-            .map(|(i, pl)| {
-                let is_selected = i == self.selected;
-                let is_hovered = self.hover_row == Some(i);
-
-                if is_selected && focused {
-                    ListItem::new(Line::from(vec![
-                        Span::styled(format!("  \u{F005} {}", pl.name), highlight),
-                        Span::styled(format!(" ({})", pl.tracks.len()), highlight),
-                    ]))
-                } else {
-                    let bg = if is_hovered { HOVER_BG } else { Color::Reset };
-                    ListItem::new(Line::from(vec![
-                        Span::styled(
-                            "  \u{F005} ",
-                            Style::default().fg(Color::Yellow).bg(bg),
-                        ),
-                        Span::styled(
-                            pl.name.as_str(),
-                            Style::default().fg(theme.fg).bg(bg),
-                        ),
-                        Span::styled(
-                            format!(" ({})", pl.tracks.len()),
-                            Style::default().fg(Color::DarkGray).bg(bg),
-                        ),
-                    ]))
-                }
-            })
-            .collect();
-
-        let list = List::new(items).block(block);
-        frame.render_widget(list, area);
-
-        if has_scrollbar {
-            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
-                .begin_symbol(None)
-                .end_symbol(None);
-            let mut scrollbar_state = ScrollbarState::new(count)
-                .position(self.scroll_offset);
-            frame.render_stateful_widget(
-                scrollbar,
-                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
-                &mut scrollbar_state,
-            );
-        }
-    }
-
-    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
-        let count = app.playlists.len();
-        if count == 0 {
-            return None;
-        }
-
-        match key.code {
-            KeyCode::Char('j') | KeyCode::Down => {
-                if self.selected < count - 1 {
-                    self.selected += 1;
-                }
-                None
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                if self.selected > 0 {
-                    self.selected -= 1;
-                }
-                None
-            }
-            KeyCode::Enter => {
-                if let Some(pl) = app.playlists.get(self.selected) {
-                    if !pl.tracks.is_empty() {
-                        return Some(AppAction::AddToQueue(pl.tracks.clone()));
-                    }
-                }
-                None
-            }
-            KeyCode::Home | KeyCode::Char('g') => {
-                self.selected = 0;
-                self.scroll_offset = 0;
-                None
-            }
-            KeyCode::End | KeyCode::Char('G') => {
-                self.selected = count.saturating_sub(1);
-                None
-            }
-            _ => None,
-        }
-    }
-
-    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
-        let block = Block::default().borders(Borders::ALL);
-        let inner = block.inner(area);
-        let count = app.playlists.len();
-
-        match event.kind {
-            MouseEventKind::Down(MouseButton::Left) => {
-                if event.column >= inner.x
-                    && event.column < inner.x + inner.width
-                    && event.row >= inner.y
-                    && event.row < inner.y + inner.height
-                {
-                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
-                    if clicked < count {
-                        self.selected = clicked;
-                    }
-                }
-                None
-            }
-            MouseEventKind::ScrollDown => {
-                if count > 0 {
-                    self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
-                    self.selected = (self.selected + 3).min(count.saturating_sub(1));
-                }
-                None
-            }
-            MouseEventKind::ScrollUp => {
-                self.scroll_offset = self.scroll_offset.saturating_sub(3);
-                self.selected = self.selected.saturating_sub(3);
-                None
-            }
-            _ => None,
-        }
-    }
-}
diff --git a/src/ui/panes/queue_pane.rs b/src/ui/panes/queue_pane.rs
@@ -4,11 +4,12 @@ use ratatui::style::{Color, Modifier, Style};
 use ratatui::text::{Line, Span};
 use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
 use ratatui::Frame;
-use unicode_width::UnicodeWidthStr;
 
 use crate::app::{App, AppAction};
 use crate::ui::pane::Pane;
 use crate::ui::theme::Theme;
+use crate::ui::text::fit_to_width;
+use crate::ui::text::max_scroll;
 
 const HOVER_BG: Color = Color::Indexed(238); // very dark gray
 
@@ -38,32 +39,6 @@ fn format_color(ext: &str) -> Color {
     }
 }
 
-/// Truncate a string to fit within `max_width` columns, adding "…" if needed.
-/// Pads with spaces to exactly fill `max_width`.
-fn fit_to_width(s: &str, max_width: usize) -> String {
-    let str_width = UnicodeWidthStr::width(s);
-    if str_width <= max_width {
-        let padding = max_width - str_width;
-        format!("{}{}", s, " ".repeat(padding))
-    } else {
-        let mut w = 0;
-        let mut result = String::new();
-        for ch in s.chars() {
-            let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
-            if w + ch_w + 1 > max_width {
-                result.push('\u{2026}'); // …
-                w += 1;
-                break;
-            }
-            w += ch_w;
-            result.push(ch);
-        }
-        let pad = max_width.saturating_sub(w);
-        result.push_str(&" ".repeat(pad));
-        result
-    }
-}
-
 impl Pane for QueuePane {
     fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
         let count = app.queue.tracks.len();
@@ -94,6 +69,9 @@ impl Pane for QueuePane {
             if inner_height > 0 && app.queue.selected_index >= self.scroll_offset + inner_height {
                 self.scroll_offset = app.queue.selected_index - inner_height + 1;
             }
+            // Keep the viewport full: the scroll handlers clamp to count - 1,
+            // which lets the list scroll until one row sits above a blank screen.
+            self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
         }
 
         // Column layout: prefix(2) + title(55%) + artist(45%) + ext(4) + gap(1) + dur(5) + trail(1)
diff --git a/src/ui/text.rs b/src/ui/text.rs
@@ -0,0 +1,102 @@
+//! Small formatting and layout helpers shared across the UI.
+//!
+//! Each of these existed as two to four byte-identical copies scattered through
+//! the panes and modals.
+
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+/// Truncate `s` to `max_width` terminal columns, appending "…" when it does not
+/// fit, and pad with spaces so the result occupies exactly `max_width`.
+///
+/// Column-aware rather than byte- or char-aware: a CJK title is twice as wide as
+/// its character count, so padding by length misaligns every following column.
+pub fn fit_to_width(s: &str, max_width: usize) -> String {
+    let str_width = UnicodeWidthStr::width(s);
+    if str_width <= max_width {
+        return format!("{}{}", s, " ".repeat(max_width - str_width));
+    }
+
+    let mut w = 0;
+    let mut result = String::new();
+    for ch in s.chars() {
+        let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
+        // Reserve one column for the ellipsis.
+        if w + ch_w + 1 > max_width {
+            result.push('\u{2026}');
+            w += 1;
+            break;
+        }
+        w += ch_w;
+        result.push(ch);
+    }
+    result.push_str(&" ".repeat(max_width.saturating_sub(w)));
+    result
+}
+
+/// Seconds as `m:ss`.
+pub fn format_time(secs: f64) -> String {
+    let total = secs.max(0.0) as u64;
+    format!("{}:{:02}", total / 60, total % 60)
+}
+
+/// A rect of the given percentage size, centred in `area`.
+pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
+    let vertical = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Percentage((100 - percent_y) / 2),
+            Constraint::Percentage(percent_y),
+            Constraint::Percentage((100 - percent_y) / 2),
+        ])
+        .split(area);
+
+    Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage((100 - percent_x) / 2),
+            Constraint::Percentage(percent_x),
+            Constraint::Percentage((100 - percent_x) / 2),
+        ])
+        .split(vertical[1])[1]
+}
+
+/// Largest scroll offset that still fills the viewport.
+///
+/// The panes each clamped to `count - 1`, which let the list scroll until a
+/// single row sat above a screen of blank space.
+pub fn max_scroll(count: usize, viewport: usize) -> usize {
+    count.saturating_sub(viewport)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn fit_pads_and_truncates_by_column_not_by_character() {
+        assert_eq!(fit_to_width("abc", 5), "abc  ");
+        assert_eq!(UnicodeWidthStr::width(fit_to_width("abcdefgh", 5).as_str()), 5);
+        // Four wide characters are eight columns; two fit alongside the ellipsis.
+        assert_eq!(fit_to_width("日本語テスト", 5), "日本\u{2026}");
+        assert_eq!(UnicodeWidthStr::width(fit_to_width("日本語テスト", 5).as_str()), 5);
+        // A wide string that exactly fits is left alone.
+        assert_eq!(fit_to_width("肺", 2), "肺");
+    }
+
+    #[test]
+    fn max_scroll_stops_before_the_list_runs_out() {
+        // 100 items in a 30-row viewport: the last full screen starts at 70.
+        assert_eq!(max_scroll(100, 30), 70);
+        // Everything already visible means no scrolling.
+        assert_eq!(max_scroll(10, 30), 0);
+        assert_eq!(max_scroll(0, 30), 0);
+    }
+
+    #[test]
+    fn format_time_pads_seconds() {
+        assert_eq!(format_time(0.0), "0:00");
+        assert_eq!(format_time(65.9), "1:05");
+        assert_eq!(format_time(-1.0), "0:00");
+    }
+}
diff --git a/src/ui/widgets/about_modal.rs b/src/ui/widgets/about_modal.rs
@@ -1,10 +1,11 @@
 use ratatui::buffer::Buffer;
-use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::layout::Rect;
 use ratatui::style::{Color, Modifier, Style};
 use ratatui::widgets::{Block, Borders, Clear};
 use ratatui::Frame;
 
 use crate::ui::theme::Theme;
+use crate::ui::text::centered_rect;
 
 // Neon colors
 const NEON_PINK: Color = Color::Rgb(255, 50, 150);
@@ -358,22 +359,3 @@ pub fn render_splash_screen(frame: &mut Frame, area: Rect, _theme: &Theme, opaci
     }
 }
 
-fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
-    let vertical = Layout::default()
-        .direction(Direction::Vertical)
-        .constraints([
-            Constraint::Percentage((100 - percent_y) / 2),
-            Constraint::Percentage(percent_y),
-            Constraint::Percentage((100 - percent_y) / 2),
-        ])
-        .split(area);
-
-    Layout::default()
-        .direction(Direction::Horizontal)
-        .constraints([
-            Constraint::Percentage((100 - percent_x) / 2),
-            Constraint::Percentage(percent_x),
-            Constraint::Percentage((100 - percent_x) / 2),
-        ])
-        .split(vertical[1])[1]
-}
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -1,10 +1,11 @@
-use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::layout::Rect;
 use ratatui::style::{Color, Modifier, Style};
 use ratatui::text::{Line, Span};
 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
 use ratatui::Frame;
 
 use crate::ui::theme::Theme;
+use crate::ui::text::centered_rect;
 
 const KEYBINDINGS: &[(&str, &str)] = &[
     ("Ctrl+E, s", "Search"),
@@ -69,22 +70,3 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect, theme: &Theme) {
     frame.render_widget(help_text, inner);
 }
 
-fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
-    let vertical = Layout::default()
-        .direction(Direction::Vertical)
-        .constraints([
-            Constraint::Percentage((100 - percent_y) / 2),
-            Constraint::Percentage(percent_y),
-            Constraint::Percentage((100 - percent_y) / 2),
-        ])
-        .split(area);
-
-    Layout::default()
-        .direction(Direction::Horizontal)
-        .constraints([
-            Constraint::Percentage((100 - percent_x) / 2),
-            Constraint::Percentage(percent_x),
-            Constraint::Percentage((100 - percent_x) / 2),
-        ])
-        .split(vertical[1])[1]
-}
diff --git a/src/ui/widgets/playlist_modal.rs b/src/ui/widgets/playlist_modal.rs
@@ -6,6 +6,7 @@ use ratatui::Frame;
 
 use crate::app::App;
 use crate::ui::theme::Theme;
+use crate::ui::text::centered_rect;
 
 #[derive(Debug, Clone, PartialEq)]
 pub enum PlaylistModalMode {
@@ -152,22 +153,3 @@ pub fn render_playlist_modal(
     }
 }
 
-fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
-    let vertical = Layout::default()
-        .direction(Direction::Vertical)
-        .constraints([
-            Constraint::Percentage((100 - percent_y) / 2),
-            Constraint::Percentage(percent_y),
-            Constraint::Percentage((100 - percent_y) / 2),
-        ])
-        .split(area);
-
-    Layout::default()
-        .direction(Direction::Horizontal)
-        .constraints([
-            Constraint::Percentage((100 - percent_x) / 2),
-            Constraint::Percentage(percent_x),
-            Constraint::Percentage((100 - percent_x) / 2),
-        ])
-        .split(vertical[1])[1]
-}
diff --git a/src/ui/widgets/progress_bar.rs b/src/ui/widgets/progress_bar.rs
@@ -7,6 +7,7 @@ use ratatui::Frame;
 use crate::app::App;
 use crate::app::state::PlayState;
 use crate::ui::theme::Theme;
+use crate::ui::text::format_time;
 
 pub fn render_progress_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
     let block = Block::default()
@@ -82,9 +83,3 @@ pub fn progress_gauge_area(area: Rect) -> Rect {
     cols[1]
 }
 
-fn format_time(secs: f64) -> String {
-    let total = secs as u64;
-    let m = total / 60;
-    let s = total % 60;
-    format!("{}:{:02}", m, s)
-}
diff --git a/src/ui/widgets/search_modal.rs b/src/ui/widgets/search_modal.rs
@@ -6,6 +6,7 @@ use ratatui::Frame;
 
 use crate::app::App;
 use crate::ui::theme::Theme;
+use crate::ui::text::{centered_rect, fit_to_width};
 
 const HOVER_BG: Color = Color::Indexed(238);
 
@@ -150,46 +151,3 @@ pub fn render_search_modal(
     (result_height, chunks[2])
 }
 
-fn fit_to_width(s: &str, max_width: usize) -> String {
-    use unicode_width::UnicodeWidthStr;
-    let str_width = UnicodeWidthStr::width(s);
-    if str_width <= max_width {
-        format!("{}{}", s, " ".repeat(max_width - str_width))
-    } else {
-        let mut w = 0;
-        let mut result = String::new();
-        for ch in s.chars() {
-            let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
-            if w + ch_w + 1 > max_width {
-                result.push('\u{2026}');
-                w += 1;
-                break;
-            }
-            w += ch_w;
-            result.push(ch);
-        }
-        let pad = max_width.saturating_sub(w);
-        result.push_str(&" ".repeat(pad));
-        result
-    }
-}
-
-fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
-    let vertical = Layout::default()
-        .direction(Direction::Vertical)
-        .constraints([
-            Constraint::Percentage((100 - percent_y) / 2),
-            Constraint::Percentage(percent_y),
-            Constraint::Percentage((100 - percent_y) / 2),
-        ])
-        .split(area);
-
-    Layout::default()
-        .direction(Direction::Horizontal)
-        .constraints([
-            Constraint::Percentage((100 - percent_x) / 2),
-            Constraint::Percentage(percent_x),
-            Constraint::Percentage((100 - percent_x) / 2),
-        ])
-        .split(vertical[1])[1]
-}
diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs
@@ -7,6 +7,7 @@ use ratatui::Frame;
 use crate::app::App;
 use crate::app::state::{PlayState, SyncState};
 use crate::ui::theme::Theme;
+use crate::ui::text::format_time;
 
 pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, resize_mode: bool) {
     let block = if let Some(err) = &app.audio_error {
@@ -179,9 +180,3 @@ pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
     frame.render_widget(right, cols[2]);
 }
 
-fn format_time(secs: f64) -> String {
-    let total = secs as u64;
-    let m = total / 60;
-    let s = total % 60;
-    format!("{}:{:02}", m, s)
-}