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)
-}
|