diff --git a/src/ui/layout.rs b/src/ui/layout.rs
@@ -0,0 +1,58 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+
+#[derive(Debug, Clone)]
+pub struct LayoutAreas {
+ pub status_bar: Rect,
+ pub tab_bar: Rect,
+ pub library: Rect,
+ pub playlist: Rect,
+ pub info_pane: Rect,
+ pub lyrics: Rect,
+ pub progress_bar: Rect,
+}
+
+impl LayoutAreas {
+ pub fn compute(area: Rect, pane_widths: [u16; 3], right_split: u16) -> Self {
+ let vertical = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(4), // Status bar (bordered = 2 content + 2 border)
+ Constraint::Length(3), // Tab bar (bordered)
+ Constraint::Min(10), // Dashboard
+ Constraint::Length(3), // Progress bar (bordered)
+ ])
+ .split(area);
+
+ let status_bar = vertical[0];
+ let tab_bar = vertical[1];
+ let dashboard = vertical[2];
+ let progress_bar = vertical[3];
+
+ let columns = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(pane_widths[0]),
+ Constraint::Percentage(pane_widths[1]),
+ Constraint::Percentage(pane_widths[2]),
+ ])
+ .split(dashboard);
+
+ let right_col = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Percentage(right_split),
+ Constraint::Percentage(100 - right_split),
+ ])
+ .split(columns[2]);
+
+ Self {
+ status_bar,
+ tab_bar,
+ library: columns[0],
+ playlist: columns[1],
+ info_pane: right_col[0],
+ lyrics: right_col[1],
+ progress_bar,
+ }
+ }
+}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -0,0 +1,304 @@
+pub mod layout;
+pub mod pane;
+pub mod panes;
+pub mod theme;
+pub mod widgets;
+
+use ratatui::Frame;
+use ratatui::style::{Color, Style};
+use ratatui::widgets::{Block, Borders};
+use widgets::{about_modal, help_modal, playlist_modal, search_modal};
+use widgets::playlist_modal::PlaylistModalMode;
+
+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::lyrics_pane::LyricsPane;
+use panes::playlists_pane::PlaylistsPane;
+use panes::queue_pane::QueuePane;
+use theme::Theme;
+use widgets::info_pane;
+use widgets::progress_bar;
+use widgets::status_bar;
+use widgets::tab_bar;
+
+pub struct Ui {
+ pub theme: Theme,
+ 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,
+ pub lyrics_pane: LyricsPane,
+ pub last_click: Option<(std::time::Instant, u16, u16)>,
+ /// Last known mouse position (column, row) for hover tracking
+ pub mouse_pos: Option<(u16, u16)>,
+ /// Tab index currently hovered by mouse
+ pub hovered_tab: Option<usize>,
+ /// Pane width percentages [Library, Playlist, Lyrics], sum = 100
+ pub pane_widths: [u16; 3],
+ /// Resize mode active (Ctrl+E)
+ pub resize_mode: bool,
+ /// Border being dragged: 0 = lib|playlist, 1 = playlist|lyrics, 2 = info|lyrics (horizontal), None = not dragging
+ pub dragging_border: Option<u8>,
+ /// Right column split: info pane height percentage (top), lyrics gets the rest
+ pub right_split: u16,
+ /// Ctrl+E pressed, waiting for next key
+ pub chord_pending: bool,
+ /// Help modal visible
+ pub show_help_modal: bool,
+ /// Search modal visible
+ pub show_search_modal: bool,
+ /// Search modal input text
+ pub search_modal_input: String,
+ /// Search modal filtered results (track indices)
+ pub search_modal_results: Vec<usize>,
+ /// Search modal selected result index
+ pub search_modal_selected: usize,
+ /// Search modal scroll offset
+ pub search_modal_scroll: usize,
+ /// Search modal visible result row count (set during render)
+ pub search_modal_result_height: usize,
+ /// Search modal result area rect (set during render, for mouse hit-testing)
+ pub search_modal_result_area: ratatui::layout::Rect,
+ /// Search modal hovered row index
+ pub search_modal_hover_row: Option<usize>,
+ /// Playlist modal visible ("b" key)
+ pub show_playlist_modal: bool,
+ /// Playlist modal selected index
+ pub playlist_modal_selected: usize,
+ /// Playlist modal mode (List / Create / Rename)
+ pub playlist_modal_mode: PlaylistModalMode,
+ /// Playlist modal text input (for create/rename)
+ pub playlist_modal_input: String,
+ /// About modal visible
+ pub show_about_modal: bool,
+ /// Splash screen visible at startup
+ pub show_splash: bool,
+ /// Splash screen start time
+ pub splash_start: Option<std::time::Instant>,
+ /// Current info pane view (Clock / AlbumArt / TrackInfo)
+ pub info_view: InfoView,
+ /// Album art pixel cache
+ pub album_art_cache: info_pane::AlbumArtCache,
+}
+
+impl Ui {
+ pub fn new(music_dir: std::path::PathBuf, picker: ratatui_image::picker::Picker) -> Self {
+ Self {
+ theme: Theme::default(),
+ 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(),
+ lyrics_pane: LyricsPane::new(),
+ last_click: None,
+ mouse_pos: None,
+ hovered_tab: None,
+ pane_widths: [20, 60, 20],
+ resize_mode: false,
+ dragging_border: None,
+ right_split: 50,
+ chord_pending: false,
+ show_help_modal: false,
+ show_search_modal: false,
+ search_modal_input: String::new(),
+ search_modal_results: Vec::new(),
+ search_modal_selected: 0,
+ search_modal_scroll: 0,
+ search_modal_result_height: 10,
+ search_modal_result_area: ratatui::layout::Rect::default(),
+ search_modal_hover_row: None,
+ show_playlist_modal: false,
+ playlist_modal_selected: 0,
+ playlist_modal_mode: PlaylistModalMode::List,
+ playlist_modal_input: String::new(),
+ show_about_modal: false,
+ show_splash: true,
+ splash_start: Some(std::time::Instant::now()),
+ info_view: InfoView::Clock,
+ album_art_cache: info_pane::AlbumArtCache::new(picker),
+ }
+ }
+
+ pub fn render(&mut self, frame: &mut Frame, app: &App) {
+ if self.show_splash {
+ // Timeline: 0–0.5s fade-in, 0.5–1.5s hold, 1.5–2.0s fade-out
+ let elapsed = self.splash_start
+ .map(|s| s.elapsed().as_secs_f32())
+ .unwrap_or(2.0);
+ let opacity = if elapsed < 0.5 {
+ elapsed / 0.5
+ } else if elapsed < 1.5 {
+ 1.0
+ } else {
+ (1.0 - (elapsed - 1.5) / 0.5).max(0.0)
+ };
+ about_modal::render_splash_screen(frame, frame.area(), &self.theme, opacity);
+ return;
+ }
+
+ let areas = LayoutAreas::compute(frame.area(), self.pane_widths, self.right_split);
+
+ // Status bar
+ status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme, self.resize_mode);
+
+ // Tab bar
+ tab_bar::render_tab_bar(frame, areas.tab_bar, app.tab, self.hovered_tab, &self.theme);
+
+ // Left pane (varies by tab)
+ let lib_focused = app.focus == FocusedPane::Library;
+ 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),
+ }
+
+ // Center pane (Queue)
+ let playlist_focused = app.focus == FocusedPane::Playlist;
+ self.queue_pane.render(frame, areas.playlist, playlist_focused, app, &self.theme);
+
+ // Right pane top (Info)
+ info_pane::render_info_pane(frame, areas.info_pane, app, &self.theme, self.info_view, &mut self.album_art_cache);
+
+ // Right pane bottom (Lyrics)
+ let lyrics_focused = app.focus == FocusedPane::Lyrics;
+ self.lyrics_pane.render(frame, areas.lyrics, lyrics_focused, app, &self.theme);
+
+ // Progress bar
+ progress_bar::render_progress_bar(frame, areas.progress_bar, app, &self.theme);
+
+ // Resize mode: overlay yellow border on focused pane
+ if self.resize_mode {
+ let focused_area = match app.focus {
+ FocusedPane::Library => areas.library,
+ FocusedPane::Playlist => areas.playlist,
+ FocusedPane::Lyrics => areas.lyrics,
+ };
+ let overlay = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Yellow));
+ frame.render_widget(overlay, focused_area);
+ }
+
+ // Modal overlays (rendered last, on top of everything)
+ if self.show_search_modal {
+ let (rh, ra) = search_modal::render_search_modal(
+ frame,
+ frame.area(),
+ &self.search_modal_input,
+ &self.search_modal_results,
+ self.search_modal_selected,
+ self.search_modal_scroll,
+ self.search_modal_hover_row,
+ app,
+ &self.theme,
+ );
+ self.search_modal_result_height = rh;
+ self.search_modal_result_area = ra;
+ }
+
+ if self.show_help_modal {
+ help_modal::render_help_modal(frame, frame.area(), &self.theme);
+ }
+
+ if self.show_about_modal {
+ about_modal::render_about_modal(frame, frame.area(), &self.theme);
+ }
+
+ if self.show_playlist_modal {
+ playlist_modal::render_playlist_modal(
+ frame,
+ frame.area(),
+ self.playlist_modal_selected,
+ &self.playlist_modal_mode,
+ &self.playlist_modal_input,
+ app,
+ &self.theme,
+ );
+ }
+ }
+
+ pub fn refresh_dir_browser(&mut self, app: &App) {
+ self.dir_browser_pane.refresh(app);
+ }
+
+ 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);
+ }
+
+ // Reset library/dir browser to top since track indices changed
+ self.library_pane.selected = 0;
+ self.library_pane.scroll_offset = 0;
+ self.dir_browser_pane.selected = 0;
+ self.dir_browser_pane.scroll_offset = 0;
+
+ // Clamp queue pane scroll
+ let queue_len = app.queue.tracks.len();
+ if queue_len == 0 {
+ self.queue_pane.scroll_offset = 0;
+ } else {
+ self.queue_pane.scroll_offset = self.queue_pane.scroll_offset.min(queue_len - 1);
+ }
+ }
+}
diff --git a/src/ui/pane.rs b/src/ui/pane.rs
@@ -0,0 +1,15 @@
+use crossterm::event::{KeyEvent, MouseEvent};
+use ratatui::Frame;
+use ratatui::layout::Rect;
+
+use crate::app::{App, AppAction};
+use crate::ui::theme::Theme;
+
+pub trait Pane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme);
+ fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction>;
+ fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction>;
+ fn handle_scroll(&mut self, _up: bool, _app: &App) -> Option<AppAction> {
+ None
+ }
+}
diff --git a/src/ui/panes/album_artists_pane.rs b/src/ui/panes/album_artists_pane.rs
@@ -0,0 +1,186 @@
+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 AlbumArtistsPane {
+ pub selected: usize,
+ pub scroll_offset: usize,
+ pub hover_row: Option<usize>,
+}
+
+impl AlbumArtistsPane {
+ pub fn new() -> Self {
+ Self {
+ selected: 0,
+ scroll_offset: 0,
+ hover_row: None,
+ }
+ }
+}
+
+impl Pane for AlbumArtistsPane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+ let album_artists = app.library.get_album_artists();
+ let count = album_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> = album_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 album_artists = app.library.get_album_artists();
+ let count = album_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_album_artist(&album_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_album_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_album_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/albums_pane.rs b/src/ui/panes/albums_pane.rs
@@ -0,0 +1,190 @@
+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
@@ -0,0 +1,185 @@
+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
@@ -0,0 +1,272 @@
+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 std::path::PathBuf;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct DirBrowserPane {
+ pub current_dir: PathBuf,
+ pub entries: Vec<DirEntry>,
+ pub selected: usize,
+ pub scroll_offset: usize,
+ pub hover_row: Option<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub enum DirEntry {
+ ParentDir,
+ Directory(String),
+ Track(usize),
+}
+
+impl DirBrowserPane {
+ pub fn new(music_dir: PathBuf) -> Self {
+ Self {
+ current_dir: music_dir,
+ entries: Vec::new(),
+ selected: 0,
+ scroll_offset: 0,
+ hover_row: None,
+ }
+ }
+
+ pub fn refresh(&mut self, app: &App) {
+ self.entries.clear();
+
+ if self.current_dir != app.music_dir {
+ self.entries.push(DirEntry::ParentDir);
+ }
+
+ let (subdirs, tracks) = app.library.get_directory_entries(&self.current_dir);
+ for dir in subdirs {
+ self.entries.push(DirEntry::Directory(dir));
+ }
+ for track_idx in tracks {
+ self.entries.push(DirEntry::Track(track_idx));
+ }
+ }
+}
+
+impl Pane for DirBrowserPane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+ let count = self.entries.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> = self
+ .entries
+ .iter()
+ .enumerate()
+ .skip(self.scroll_offset)
+ .take(inner_height)
+ .map(|(i, entry)| {
+ let is_selected = i == self.selected;
+ let is_hovered = self.hover_row == Some(i);
+
+ match entry {
+ DirEntry::ParentDir => {
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", highlight),
+ Span::styled("..", highlight),
+ ]))
+ } else {
+ let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", Style::default().fg(Color::Yellow).bg(bg)),
+ Span::styled("..", Style::default().fg(theme.fg).bg(bg)),
+ ]))
+ }
+ }
+ DirEntry::Directory(name) => {
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", highlight),
+ Span::styled(format!("{}/", name), highlight),
+ ]))
+ } else {
+ let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", Style::default().fg(Color::Green).bg(bg)),
+ Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(bg)),
+ ]))
+ }
+ }
+ DirEntry::Track(idx) => {
+ let t = &app.library.tracks[*idx];
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F001} ", highlight),
+ Span::styled(&t.title, highlight),
+ ]))
+ } else {
+ let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F001} ", Style::default().fg(Color::Cyan).bg(bg)),
+ Span::styled(&t.title, Style::default().fg(theme.fg).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 = self.entries.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 => {
+ match &self.entries[self.selected] {
+ DirEntry::ParentDir => {
+ if let Some(parent) = self.current_dir.parent() {
+ self.current_dir = parent.to_path_buf();
+ self.selected = 0;
+ self.scroll_offset = 0;
+ self.refresh(app);
+ }
+ }
+ DirEntry::Directory(name) => {
+ self.current_dir = self.current_dir.join(name);
+ self.selected = 0;
+ self.scroll_offset = 0;
+ self.refresh(app);
+ }
+ DirEntry::Track(idx) => {
+ return Some(AppAction::AddToQueue(vec![*idx]));
+ }
+ }
+ None
+ }
+ KeyCode::Backspace => {
+ if let Some(parent) = self.current_dir.parent() {
+ self.current_dir = parent.to_path_buf();
+ self.selected = 0;
+ self.scroll_offset = 0;
+ self.refresh(app);
+ }
+ 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 = self.entries.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 = self.entries.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/format_pane.rs b/src/ui/panes/format_pane.rs
@@ -0,0 +1,196 @@
+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
@@ -0,0 +1,185 @@
+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
@@ -0,0 +1,408 @@
+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;
+
+/// An entry in the flattened library list
+#[derive(Debug, Clone)]
+enum LibraryEntry {
+ SectionHeader(String),
+ Separator,
+ AllTracks(usize),
+ PlaylistEntry { idx: usize, name: String, count: usize },
+ FavoriteDir(String),
+ Album { name: String, artist: String },
+}
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+/// Library browser for the Queue tab.
+/// Shows 4 sections: Playlist, Directories, Albums.
+pub struct LibraryPane {
+ pub selected: usize,
+ pub scroll_offset: usize,
+ pub hover_row: Option<usize>,
+}
+
+impl LibraryPane {
+ pub fn new() -> Self {
+ Self {
+ selected: 0,
+ scroll_offset: 0,
+ hover_row: None,
+ }
+ }
+
+ fn build_entries(app: &App) -> Vec<LibraryEntry> {
+ let mut entries = Vec::new();
+
+ // --- Playlist ---
+ entries.push(LibraryEntry::SectionHeader("\u{F054} Playlist".into()));
+ entries.push(LibraryEntry::AllTracks(app.library.tracks.len()));
+ for (idx, pl) in app.playlists.iter().enumerate() {
+ entries.push(LibraryEntry::PlaylistEntry {
+ idx,
+ name: pl.name.clone(),
+ count: pl.tracks.len(),
+ });
+ }
+
+ entries.push(LibraryEntry::Separator);
+
+ // --- Directories ---
+ let mut dirs = std::collections::BTreeSet::new();
+ for t in &app.library.tracks {
+ if let Some(parent) = t.path.parent() {
+ if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
+ dirs.insert(name.to_string());
+ }
+ }
+ }
+ entries.push(LibraryEntry::SectionHeader(format!(
+ "\u{F054} Directories ({})",
+ dirs.len()
+ )));
+ for d in &dirs {
+ entries.push(LibraryEntry::FavoriteDir(d.clone()));
+ }
+
+ entries.push(LibraryEntry::Separator);
+
+ // --- Albums ---
+ let albums = app.library.get_albums();
+ entries.push(LibraryEntry::SectionHeader(format!(
+ "\u{F054} Albums ({})",
+ albums.len()
+ )));
+ for (album, artist) in albums {
+ entries.push(LibraryEntry::Album { name: album, artist });
+ }
+
+ entries
+ }
+}
+
+impl Pane for LibraryPane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+ let entries = Self::build_entries(app);
+ let count = entries.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 items: Vec<ListItem> = entries
+ .iter()
+ .enumerate()
+ .skip(self.scroll_offset)
+ .take(inner_height)
+ .map(|(i, entry)| {
+ let is_selected = i == self.selected;
+ let is_hovered = self.hover_row == Some(i);
+ let highlight = Style::default()
+ .bg(theme.highlight_bg)
+ .fg(theme.highlight_fg)
+ .add_modifier(Modifier::BOLD);
+ let hover_bg = if is_hovered && !(is_selected && focused) {
+ HOVER_BG
+ } else {
+ Color::Reset
+ };
+
+ match entry {
+ LibraryEntry::SectionHeader(text) => {
+ let style = if is_selected && focused {
+ highlight
+ } else {
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD)
+ .bg(hover_bg)
+ };
+ ListItem::new(Line::from(Span::styled(text.as_str(), style)))
+ }
+ LibraryEntry::Separator => {
+ ListItem::new(Line::from(""))
+ }
+ LibraryEntry::AllTracks(track_count) => {
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F001} ", highlight),
+ Span::styled("All Tracks", highlight),
+ Span::styled(format!(" ({})", track_count), highlight),
+ ]))
+ } else {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F001} ", Style::default().fg(Color::Cyan).bg(hover_bg)),
+ Span::styled("All Tracks", Style::default().fg(theme.fg).bg(hover_bg)),
+ Span::styled(
+ format!(" ({})", track_count),
+ Style::default().fg(Color::DarkGray).bg(hover_bg),
+ ),
+ ]))
+ }
+ }
+ LibraryEntry::PlaylistEntry { name, count, .. } => {
+ let icon = "\u{F005} "; // ★
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(format!(" {}", icon), highlight),
+ Span::styled(name.as_str(), highlight),
+ Span::styled(format!(" ({})", count), highlight),
+ ]))
+ } else {
+ ListItem::new(Line::from(vec![
+ Span::styled(format!(" {}", icon), Style::default().fg(Color::Yellow).bg(hover_bg)),
+ Span::styled(name.as_str(), Style::default().fg(theme.fg).bg(hover_bg)),
+ Span::styled(
+ format!(" ({})", count),
+ Style::default().fg(Color::DarkGray).bg(hover_bg),
+ ),
+ ]))
+ }
+ }
+ LibraryEntry::FavoriteDir(name) => {
+ if is_selected && focused {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", highlight),
+ Span::styled(format!("{}/", name), highlight),
+ ]))
+ } else {
+ ListItem::new(Line::from(vec![
+ Span::styled(" \u{F07B} ", Style::default().fg(Color::Green).bg(hover_bg)),
+ Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(hover_bg)),
+ ]))
+ }
+ }
+ LibraryEntry::Album { name, artist } => {
+ let album_display = if name.is_empty() {
+ "Unknown Album"
+ } else {
+ name.as_str()
+ };
+ let artist_display = if artist.is_empty() {
+ ""
+ } else {
+ artist.as_str()
+ };
+
+ if is_selected && focused {
+ let mut spans = vec![
+ Span::styled(" \u{F0025} ", highlight),
+ Span::styled(album_display, highlight),
+ ];
+ if !artist_display.is_empty() {
+ spans.push(Span::styled(
+ format!(" {}", artist_display),
+ highlight,
+ ));
+ }
+ ListItem::new(Line::from(spans))
+ } else {
+ let mut spans = vec![
+ Span::styled(
+ " \u{F0025} ",
+ Style::default().fg(Color::Magenta).bg(hover_bg),
+ ),
+ Span::styled(album_display, Style::default().fg(theme.fg).bg(hover_bg)),
+ ];
+ if !artist_display.is_empty() {
+ spans.push(Span::styled(
+ format!(" {}", artist_display),
+ Style::default().fg(Color::Gray).bg(hover_bg),
+ ));
+ }
+ ListItem::new(Line::from(spans))
+ }
+ }
+ }
+ })
+ .collect();
+
+ let list = List::new(items).block(block);
+ frame.render_widget(list, area);
+
+ // Scrollbar
+ 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 entries = Self::build_entries(app);
+ let count = entries.len();
+ if count == 0 {
+ return None;
+ }
+
+ match key.code {
+ KeyCode::Char('j') | KeyCode::Down => {
+ if self.selected < count - 1 {
+ self.selected += 1;
+ // Skip separators when navigating
+ if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
+ && self.selected < count - 1
+ {
+ self.selected += 1;
+ }
+ }
+ None
+ }
+ KeyCode::Char('k') | KeyCode::Up => {
+ if self.selected > 0 {
+ self.selected -= 1;
+ // Skip separators when navigating
+ if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
+ && self.selected > 0
+ {
+ self.selected -= 1;
+ }
+ }
+ None
+ }
+ KeyCode::Enter => {
+ if self.selected >= count {
+ return None;
+ }
+ match &entries[self.selected] {
+ LibraryEntry::SectionHeader(_) | LibraryEntry::Separator => None,
+ LibraryEntry::PlaylistEntry { idx, .. } => {
+ if let Some(pl) = app.playlists.get(*idx) {
+ if !pl.tracks.is_empty() {
+ return Some(AppAction::AddToQueue(pl.tracks.clone()));
+ }
+ }
+ None
+ }
+ LibraryEntry::AllTracks(_) => {
+ let indices: Vec<usize> = (0..app.library.tracks.len()).collect();
+ if !indices.is_empty() {
+ Some(AppAction::AddToQueue(indices))
+ } else {
+ None
+ }
+ }
+ LibraryEntry::FavoriteDir(dir_name) => {
+ let indices: Vec<usize> = app
+ .library
+ .tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| {
+ t.path
+ .parent()
+ .and_then(|p| p.file_name())
+ .and_then(|n| n.to_str())
+ == Some(dir_name.as_str())
+ })
+ .map(|(i, _)| i)
+ .collect();
+ if !indices.is_empty() {
+ Some(AppAction::AddToQueue(indices))
+ } else {
+ None
+ }
+ }
+ LibraryEntry::Album { name, .. } => {
+ let tracks = app.library.get_tracks_by_album(name);
+ if !tracks.is_empty() {
+ Some(AppAction::AddToQueue(tracks))
+ } else {
+ 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 = Self::build_entries(app).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 = Self::build_entries(app).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/lyrics_pane.rs b/src/ui/panes/lyrics_pane.rs
@@ -0,0 +1,80 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::Style;
+use ratatui::widgets::{Block, Borders};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+use crate::ui::widgets::info_pane;
+
+pub struct LyricsPane {
+ pub scroll_offset: u16,
+}
+
+impl LyricsPane {
+ pub fn new() -> Self {
+ Self { scroll_offset: 0 }
+ }
+}
+
+impl Pane for LyricsPane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+ 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);
+ frame.render_widget(block, area);
+
+ info_pane::render_track_info(frame, inner, app, theme);
+ }
+
+ fn handle_key(&mut self, key: KeyEvent, _app: &App) -> Option<AppAction> {
+ match key.code {
+ KeyCode::Char('j') | KeyCode::Down => {
+ self.scroll_offset = self.scroll_offset.saturating_add(1);
+ None
+ }
+ KeyCode::Char('k') | KeyCode::Up => {
+ self.scroll_offset = self.scroll_offset.saturating_sub(1);
+ None
+ }
+ KeyCode::Home | KeyCode::Char('g') => {
+ self.scroll_offset = 0;
+ None
+ }
+ _ => None,
+ }
+ }
+
+ fn handle_mouse(&mut self, event: MouseEvent, _area: Rect, _app: &App) -> Option<AppAction> {
+ match event.kind {
+ MouseEventKind::ScrollDown => {
+ self.scroll_offset = self.scroll_offset.saturating_add(3);
+ None
+ }
+ MouseEventKind::ScrollUp => {
+ self.scroll_offset = self.scroll_offset.saturating_sub(3);
+ None
+ }
+ _ => None,
+ }
+ }
+
+ fn handle_scroll(&mut self, up: bool, _app: &App) -> Option<AppAction> {
+ if up {
+ self.scroll_offset = self.scroll_offset.saturating_sub(3);
+ } else {
+ self.scroll_offset = self.scroll_offset.saturating_add(3);
+ }
+ None
+ }
+}
diff --git a/src/ui/panes/mod.rs b/src/ui/panes/mod.rs
@@ -0,0 +1,9 @@
+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 lyrics_pane;
diff --git a/src/ui/panes/playlists_pane.rs b/src/ui/panes/playlists_pane.rs
@@ -0,0 +1,196 @@
+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
@@ -0,0 +1,296 @@
+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 unicode_width::UnicodeWidthStr;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238); // very dark gray
+
+pub struct QueuePane {
+ pub scroll_offset: usize,
+ pub hover_row: Option<usize>,
+}
+
+impl QueuePane {
+ pub fn new() -> Self {
+ Self {
+ scroll_offset: 0,
+ hover_row: None,
+ }
+ }
+}
+
+/// Color for each audio format extension
+fn format_color(ext: &str) -> Color {
+ match ext {
+ "FLAC" => Color::Green,
+ "M4A" | "AAC" | "MP4" | "ALAC" => Color::Cyan,
+ "MP3" => Color::Yellow,
+ "OGG" => Color::Magenta,
+ "WAV" | "WAVE" => Color::Blue,
+ _ => Color::White,
+ }
+}
+
+/// 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();
+ 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;
+ let inner_width = inner.width as usize;
+
+ // Auto-scroll to keep selected_index visible
+ if count > 0 {
+ if app.queue.selected_index < self.scroll_offset {
+ self.scroll_offset = app.queue.selected_index;
+ }
+ if inner_height > 0 && app.queue.selected_index >= self.scroll_offset + inner_height {
+ self.scroll_offset = app.queue.selected_index - inner_height + 1;
+ }
+ }
+
+ // Column layout: prefix(2) + title(55%) + artist(45%) + ext(4) + gap(1) + dur(5) + trail(1)
+ let ext_col_width = 4;
+ let dur_col_width = 5;
+ let prefix_width = 2;
+ let fixed_width = prefix_width + 1 + ext_col_width + 1 + dur_col_width + 1;
+ let flex_total = inner_width.saturating_sub(fixed_width);
+ let title_max = (flex_total * 55 / 100).max(4);
+ let artist_max = flex_total.saturating_sub(title_max).max(4);
+ let has_scrollbar = count > inner_height;
+
+ let items: Vec<ListItem> = app
+ .queue
+ .tracks
+ .iter()
+ .enumerate()
+ .skip(self.scroll_offset)
+ .take(inner_height)
+ .map(|(i, &track_idx)| {
+ let track = &app.library.tracks[track_idx];
+ let is_current = app.queue.current_index == Some(i);
+ let is_selected = i == app.queue.selected_index;
+
+ let artist = track.display_artist();
+ let ext = track
+ .path
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("?")
+ .to_uppercase();
+ let dur = track.format_duration();
+
+ // Base styles
+ let sel_style = Style::default()
+ .bg(theme.highlight_bg)
+ .fg(theme.highlight_fg)
+ .add_modifier(Modifier::BOLD);
+ let cur_style = theme.current_track_style;
+ let normal_style = Style::default().fg(theme.fg);
+ let dim_style = Style::default().fg(Color::Gray);
+
+ let is_hovered = self.hover_row == Some(i);
+
+ let (title_style, artist_style, ext_style, dur_style, prefix_style) =
+ if is_selected && focused {
+ (sel_style, sel_style, sel_style, sel_style, sel_style)
+ } else if is_current {
+ let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+ (
+ cur_style.bg(bg),
+ dim_style.bg(bg),
+ Style::default().fg(format_color(&ext)).add_modifier(Modifier::BOLD).bg(bg),
+ Style::default().fg(Color::DarkGray).bg(bg),
+ cur_style.bg(bg),
+ )
+ } else if is_hovered {
+ (
+ normal_style.bg(HOVER_BG),
+ dim_style.bg(HOVER_BG),
+ Style::default().fg(format_color(&ext)).bg(HOVER_BG),
+ Style::default().fg(Color::DarkGray).bg(HOVER_BG),
+ normal_style.bg(HOVER_BG),
+ )
+ } else {
+ (
+ normal_style,
+ dim_style,
+ Style::default().fg(format_color(&ext)),
+ Style::default().fg(Color::DarkGray),
+ normal_style,
+ )
+ };
+
+ let in_playlist = app.playlists.iter().any(|pl| pl.tracks.contains(&track_idx));
+ let prefix = if is_current { "\u{F04B} " } else { " " }; // nf-fa-play
+
+ // Star integrated into title text so it stays next to the title
+ let title_text = if in_playlist {
+ format!("{} \u{F005}", track.title) // "Title nf-fa-star"
+ } else {
+ track.title.clone()
+ };
+ let title_fitted = fit_to_width(&title_text, title_max);
+ let artist_fitted = fit_to_width(artist, artist_max);
+
+ // Right-align ext to ext_col_width
+ let ext_padded = format!("{:>width$}", ext, width = ext_col_width);
+ // Right-align dur to dur_col_width
+ let dur_padded = format!("{:>width$}", dur, width = dur_col_width);
+
+ // Row background for gap spans (keeps selection/hover highlight continuous)
+ let row_bg = if is_selected && focused {
+ sel_style
+ } else if is_hovered {
+ Style::default().bg(HOVER_BG)
+ } else {
+ Style::default()
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(prefix, prefix_style),
+ Span::styled(title_fitted, title_style),
+ Span::styled(artist_fitted, artist_style),
+ Span::styled(" ", row_bg),
+ Span::styled(ext_padded, ext_style),
+ Span::styled(" ", row_bg),
+ Span::styled(dur_padded, dur_style),
+ Span::styled(" ", row_bg),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items).block(block);
+ frame.render_widget(list, area);
+
+ // Scrollbar (rendered over the right border)
+ 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.queue.tracks.len();
+ if count == 0 && key.code != KeyCode::Char('c') {
+ return None;
+ }
+
+ match key.code {
+ KeyCode::Char('j') | KeyCode::Down => None,
+ KeyCode::Char('k') | KeyCode::Up => None,
+ KeyCode::Enter => {
+ if count > 0 {
+ Some(AppAction::PlayQueueIndex(app.queue.selected_index))
+ } else {
+ None
+ }
+ }
+ KeyCode::Char('d') | KeyCode::Delete => {
+ if count > 0 {
+ Some(AppAction::RemoveFromQueue(app.queue.selected_index))
+ } else {
+ None
+ }
+ }
+ KeyCode::Char('c') => Some(AppAction::ClearQueue),
+ _ => 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.queue.tracks.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 {
+ // Selection handled by handler.rs
+ }
+ }
+ 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.queue.tracks.len();
+ if count == 0 {
+ return None;
+ }
+ if up {
+ self.scroll_offset = self.scroll_offset.saturating_sub(3);
+ let new_sel = app.queue.selected_index.saturating_sub(3);
+ Some(AppAction::SetQueueSelection(new_sel))
+ } else {
+ self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+ let new_sel = (app.queue.selected_index + 3).min(count.saturating_sub(1));
+ Some(AppAction::SetQueueSelection(new_sel))
+ }
+ }
+}
diff --git a/src/ui/panes/search_pane.rs b/src/ui/panes/search_pane.rs
@@ -0,0 +1,207 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+pub struct SearchPane {
+ pub selected: usize,
+ pub scroll_offset: usize,
+ pub input_mode: bool,
+ pub hover_row: Option<usize>,
+}
+
+impl SearchPane {
+ pub fn new() -> Self {
+ Self {
+ selected: 0,
+ scroll_offset: 0,
+ input_mode: false,
+ hover_row: None,
+ }
+ }
+}
+
+impl Pane for SearchPane {
+ fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+ 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);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(1), Constraint::Min(1)])
+ .split(inner);
+
+ // Search input line
+ let input_style = if self.input_mode {
+ Style::default().fg(Color::White).bg(Color::DarkGray)
+ } else {
+ Style::default().fg(Color::Gray)
+ };
+
+ let cursor = if self.input_mode { "▎" } else { "" };
+ let input = Paragraph::new(Line::from(vec![
+ Span::styled(" / ", Style::default().fg(Color::Cyan)),
+ Span::styled(&app.search_query, input_style),
+ Span::styled(cursor, Style::default().fg(Color::White)),
+ ]));
+ frame.render_widget(input, chunks[0]);
+
+ // Results
+ let inner_height = chunks[1].height as usize;
+ let items: Vec<ListItem> = app
+ .search_results
+ .iter()
+ .enumerate()
+ .skip(self.scroll_offset)
+ .take(inner_height)
+ .map(|(i, &track_idx)| {
+ let track = &app.library.tracks[track_idx];
+ let is_selected = i == self.selected;
+ let style = if is_selected && focused {
+ Style::default()
+ .bg(theme.highlight_bg)
+ .fg(theme.highlight_fg)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg)
+ };
+
+ let artist_style = if is_selected && focused {
+ style
+ } else {
+ Style::default().fg(Color::Gray)
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(&track.title, style),
+ Span::styled(format!(" - {}", track.display_artist()), artist_style),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items);
+ frame.render_widget(list, chunks[1]);
+ }
+
+ fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+ if self.input_mode || app.search_mode {
+ match key.code {
+ KeyCode::Esc => {
+ self.input_mode = false;
+ return Some(AppAction::ExitSearchMode);
+ }
+ KeyCode::Enter => {
+ self.input_mode = false;
+ // Add selected search result to queue
+ if self.selected < app.search_results.len() {
+ let track_idx = app.search_results[self.selected];
+ return Some(AppAction::AddToQueue(vec![track_idx]));
+ }
+ return Some(AppAction::ExitSearchMode);
+ }
+ KeyCode::Backspace => {
+ let mut q = app.search_query.clone();
+ q.pop();
+ return Some(AppAction::SearchQuery(q));
+ }
+ KeyCode::Char(c) => {
+ let mut q = app.search_query.clone();
+ q.push(c);
+ return Some(AppAction::SearchQuery(q));
+ }
+ _ => {}
+ }
+ return None;
+ }
+
+ match key.code {
+ KeyCode::Char('/') | KeyCode::Char('i') => {
+ self.input_mode = true;
+ Some(AppAction::EnterSearchMode)
+ }
+ KeyCode::Char('j') | KeyCode::Down => {
+ if self.selected < app.search_results.len().saturating_sub(1) {
+ self.selected += 1;
+ }
+ None
+ }
+ KeyCode::Char('k') | KeyCode::Up => {
+ if self.selected > 0 {
+ self.selected -= 1;
+ }
+ None
+ }
+ KeyCode::Enter => {
+ if self.selected < app.search_results.len() {
+ let track_idx = app.search_results[self.selected];
+ Some(AppAction::AddToQueue(vec![track_idx]))
+ } else {
+ 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);
+
+ match event.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ if event.row == inner.y {
+ // Click on search input
+ self.input_mode = true;
+ return Some(AppAction::EnterSearchMode);
+ }
+ let results_y = inner.y + 1;
+ if event.row >= results_y && event.row < inner.y + inner.height {
+ let clicked = self.scroll_offset + (event.row - results_y) as usize;
+ if clicked < app.search_results.len() {
+ 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.search_results.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/theme.rs b/src/ui/theme.rs
@@ -0,0 +1,50 @@
+use ratatui::style::{Color, Modifier, Style};
+
+#[allow(dead_code)]
+pub struct Theme {
+ pub bg: Color,
+ pub fg: Color,
+ pub border_focused: Color,
+ pub border_unfocused: Color,
+ pub highlight_bg: Color,
+ pub highlight_fg: Color,
+ pub tab_active: Style,
+ pub tab_inactive: Style,
+ pub status_bar_bg: Color,
+ pub progress_filled: Color,
+ pub progress_empty: Color,
+ pub playing_indicator: Color,
+ pub title_style: Style,
+ pub artist_style: Style,
+ pub dim_style: Style,
+ pub current_track_style: Style,
+}
+
+impl Default for Theme {
+ fn default() -> Self {
+ Self {
+ bg: Color::Reset,
+ fg: Color::White,
+ border_focused: Color::Cyan,
+ border_unfocused: Color::Cyan,
+ highlight_bg: Color::Cyan,
+ highlight_fg: Color::Black,
+ tab_active: Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ tab_inactive: Style::default().fg(Color::DarkGray),
+ status_bar_bg: Color::DarkGray,
+ progress_filled: Color::Rgb(200, 80, 255),
+ progress_empty: Color::Indexed(236),
+ playing_indicator: Color::Rgb(80, 255, 120),
+ title_style: Style::default()
+ .fg(Color::White)
+ .add_modifier(Modifier::BOLD),
+ artist_style: Style::default().fg(Color::Gray),
+ dim_style: Style::default().fg(Color::DarkGray),
+ current_track_style: Style::default()
+ .fg(Color::Rgb(100, 220, 255))
+ .add_modifier(Modifier::BOLD),
+ }
+ }
+}
diff --git a/src/ui/widgets/about_modal.rs b/src/ui/widgets/about_modal.rs
@@ -0,0 +1,379 @@
+use ratatui::buffer::Buffer;
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::widgets::{Block, Borders, Clear};
+use ratatui::Frame;
+
+use crate::ui::theme::Theme;
+
+// Neon colors
+const NEON_PINK: Color = Color::Rgb(255, 50, 150);
+const NEON_CYAN: Color = Color::Rgb(0, 255, 255);
+const BG_DARK: Color = Color::Rgb(10, 5, 30);
+
+// Logo gradient endpoints: hot pink -> cyan (smooth per-character interpolation)
+const LOGO_START: (f32, f32, f32) = (255.0, 50.0, 150.0); // hot pink
+const LOGO_END: (f32, f32, f32) = (0.0, 255.0, 255.0); // cyan
+
+// Dim background scatter colors
+const DIM_PURPLE: Color = Color::Rgb(60, 20, 80);
+const DIM_CYAN: Color = Color::Rgb(20, 60, 80);
+const DIM_PINK: Color = Color::Rgb(80, 20, 50);
+
+const NOTES: [char; 4] = ['♪', '♫', '♩', '◆'];
+const DIM_COLORS: [Color; 3] = [DIM_PURPLE, DIM_CYAN, DIM_PINK];
+
+// Waveform pattern for below-logo decoration
+const WAVEFORM: &str = "▁▂▃▅▇█▇▅▃▂▁▂▃▅▇█▇▅▃▂▁";
+
+// Unicode block-art logo — each row is 35 display columns
+const LOGO: [&str; 5] = [
+ " ██████ ██ ██ ██ ██ ██████ ",
+ "██ ██ ███ ███ ███ ███ ██ ██ ",
+ "██ ██ ██ ██ ██ ██ ██ ██ ██████ ",
+ "██ ██ ██ ██ ██ ██ ██ ",
+ " ██████ ██ ██ ██ ██ ██ ",
+];
+const LOGO_DISPLAY_W: u16 = 35;
+
+/// Fill area with dark background + scattered music notes
+fn render_background(buf: &mut Buffer, area: Rect) {
+ let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
+ for y in area.y..area.y + area.height {
+ for x in area.x..area.x + area.width {
+ buf.set_string(x, y, " ", bg);
+ }
+ }
+
+ for y in area.y..area.y + area.height {
+ for x in area.x..area.x + area.width {
+ let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
+ if hash.is_multiple_of(37) {
+ let note = NOTES[(hash as usize / 37) % NOTES.len()];
+ let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
+ buf.set_string(x, y, note.to_string(), Style::default().fg(color).bg(BG_DARK));
+ }
+ }
+ }
+}
+
+/// Render waveform with cyan->purple gradient, centered at given y
+fn render_waveform(buf: &mut Buffer, area_x: u16, area_w: u16, y: u16, max_y: u16) {
+ if y >= max_y {
+ return;
+ }
+ let chars: Vec<char> = WAVEFORM.chars().collect();
+ let total_w = chars.len() as u16;
+ let start_x = area_x + area_w.saturating_sub(total_w) / 2;
+
+ for (i, ch) in chars.iter().enumerate() {
+ let x = start_x + i as u16;
+ if x >= area_x && x < area_x + area_w {
+ let frac = i as f32 / chars.len().max(1) as f32;
+ let r = (frac * 160.0) as u8;
+ let g = (255.0 - frac * 195.0) as u8;
+ let b = (255.0 - frac * 15.0) as u8;
+ buf.set_string(
+ x, y, ch.to_string(),
+ Style::default().fg(Color::Rgb(r, g, b)).bg(BG_DARK),
+ );
+ }
+ }
+}
+
+/// Lerp a foreground color toward BG_DARK by opacity (0.0 = invisible, 1.0 = full color)
+fn fade_color(target: Color, opacity: f32) -> Color {
+ match target {
+ Color::Rgb(r, g, b) => {
+ let (br, bg, bb) = (10.0_f32, 5.0_f32, 30.0_f32);
+ Color::Rgb(
+ (br + (r as f32 - br) * opacity) as u8,
+ (bg + (g as f32 - bg) * opacity) as u8,
+ (bb + (b as f32 - bb) * opacity) as u8,
+ )
+ }
+ other => other,
+ }
+}
+
+/// Helper: center a string of `display_w` columns within `area_w`, returning x offset from area.x
+fn center_x(area_x: u16, area_w: u16, display_w: u16) -> u16 {
+ area_x + area_w.saturating_sub(display_w) / 2
+}
+
+pub fn render_about_modal(frame: &mut Frame, area: Rect, _theme: &Theme) {
+ let modal = centered_rect(50, 60, area);
+ frame.render_widget(Clear, modal);
+
+ // Background with scattered music notes on entire modal
+ render_background(frame.buffer_mut(), modal);
+
+ // Modal border
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(NEON_PINK).bg(BG_DARK))
+ .title(" ♪ OMMP ♪ ")
+ .title_style(
+ Style::default()
+ .fg(NEON_PINK)
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let inner = block.inner(modal);
+ frame.render_widget(block, modal);
+
+ // Re-render background inside inner area (border rendering overwrites)
+ render_background(frame.buffer_mut(), inner);
+
+ // === Calculate total content height for vertical centering ===
+ // Logo(5) + waveform(1) + 1 + subtitle(1) + tagline(1) + 1 + divider(1) + 1 + info(2) + 1 + links(2) + 1 + divider(1) + 1 + footer(1) = 21
+ let content_h: u16 = 21;
+ let top_pad = inner.height.saturating_sub(content_h) / 2;
+ let mut cur_y = inner.y + top_pad;
+
+ let buf = frame.buffer_mut();
+
+ // --- Logo with smooth horizontal gradient (hot pink -> cyan, per-character RGB lerp) ---
+ for (row_idx, row) in LOGO.iter().enumerate() {
+ if cur_y < inner.y + inner.height {
+ let logo_x = center_x(inner.x, inner.width, LOGO_DISPLAY_W);
+ for (col, ch) in row.chars().enumerate() {
+ let t = (col as f32 + row_idx as f32 * 3.0)
+ / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
+ let t = t.clamp(0.0, 1.0);
+ let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
+ let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
+ let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
+ let style = Style::default()
+ .fg(Color::Rgb(r, g, b))
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD);
+ buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
+ }
+ }
+ cur_y += 1;
+ }
+
+ // --- Waveform below logo ---
+ render_waveform(buf, inner.x, inner.width, cur_y, inner.y + inner.height);
+ cur_y += 2;
+
+ // --- Subtitle ---
+ let subtitle = "Oh My Music Player";
+ let sub_w = subtitle.len() as u16;
+ if cur_y < inner.y + inner.height {
+ let x = center_x(inner.x, inner.width, sub_w);
+ buf.set_string(
+ x, cur_y, subtitle,
+ Style::default()
+ .fg(NEON_CYAN)
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD | Modifier::ITALIC),
+ );
+ }
+ cur_y += 1;
+
+ // --- Tagline ---
+ let tagline = "Terminal music, your way";
+ let tag_w = tagline.len() as u16;
+ if cur_y < inner.y + inner.height {
+ let x = center_x(inner.x, inner.width, tag_w);
+ buf.set_string(x, cur_y, tagline, Style::default().fg(NEON_CYAN).bg(BG_DARK));
+ }
+ cur_y += 2;
+
+ // --- Divider ---
+ let div_w = inner.width.saturating_sub(6) as usize;
+ let div_style = Style::default().fg(DIM_PURPLE).bg(BG_DARK);
+ if cur_y < inner.y + inner.height {
+ buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
+ }
+ cur_y += 2;
+
+ // --- Info rows ---
+ let label_style = Style::default()
+ .fg(NEON_PINK)
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD);
+ let value_style = Style::default().fg(NEON_CYAN).bg(BG_DARK);
+
+ for (label, value) in &[("Version", "0.1.0"), ("License", "MIT")] {
+ if cur_y < inner.y + inner.height {
+ let lbl = format!(" {:12}", label);
+ let val = *value;
+ let total_w = (lbl.len() + val.len()) as u16;
+ let x = center_x(inner.x, inner.width, total_w);
+ buf.set_string(x, cur_y, &lbl, label_style);
+ buf.set_string(x + lbl.len() as u16, cur_y, val, value_style);
+ }
+ cur_y += 1;
+ }
+ cur_y += 1;
+
+ // --- Links ---
+ let link_style = Style::default()
+ .fg(Color::Rgb(100, 180, 255))
+ .bg(BG_DARK)
+ .add_modifier(Modifier::UNDERLINED);
+ let icon_style = Style::default()
+ .fg(NEON_PINK)
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD);
+
+ for (icon, url) in &[
+ ("\u{F09B} ", "https://github.com/sqzer-x/ommp"),
+ ("\u{F004} ", "https://github.com/sponsors/sqzer-x"),
+ ] {
+ if cur_y < inner.y + inner.height {
+ let icon_w = icon.chars().count() as u16;
+ let url_w = url.len() as u16;
+ let total_w = icon_w + url_w;
+ let x = center_x(inner.x, inner.width, total_w);
+ buf.set_string(x, cur_y, *icon, icon_style);
+ buf.set_string(x + icon_w, cur_y, *url, link_style);
+ }
+ cur_y += 1;
+ }
+ cur_y += 1;
+
+ // --- Divider ---
+ if cur_y < inner.y + inner.height {
+ buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
+ }
+ cur_y += 2;
+
+ // --- Footer ---
+ let footer = "g: Open GitHub s: Open Sponsor Esc: Close";
+ let fw = footer.len() as u16;
+ if cur_y < inner.y + inner.height {
+ let x = center_x(inner.x, inner.width, fw);
+ buf.set_string(
+ x, cur_y, footer,
+ Style::default().fg(Color::Rgb(120, 120, 140)).bg(BG_DARK),
+ );
+ }
+}
+
+/// Render a full-screen splash screen with fade-in/fade-out.
+/// `opacity`: 0.0 = fully transparent (BG_DARK only), 1.0 = full brightness.
+pub fn render_splash_screen(frame: &mut Frame, area: Rect, _theme: &Theme, opacity: f32) {
+ let opacity = opacity.clamp(0.0, 1.0);
+ let buf = frame.buffer_mut();
+
+ // Fill entire screen with dark background
+ let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
+ for y in area.y..area.y + area.height {
+ for x in area.x..area.x + area.width {
+ buf.set_string(x, y, " ", bg);
+ }
+ }
+
+ // Scattered music notes (faded)
+ for y in area.y..area.y + area.height {
+ for x in area.x..area.x + area.width {
+ let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
+ if hash.is_multiple_of(37) {
+ let note = NOTES[(hash as usize / 37) % NOTES.len()];
+ let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
+ buf.set_string(
+ x, y, note.to_string(),
+ Style::default().fg(fade_color(color, opacity)).bg(BG_DARK),
+ );
+ }
+ }
+ }
+
+ // Content height: logo(5) + waveform(1) + gap(1) + subtitle(1) + tagline(1) = 9
+ let content_h: u16 = 9;
+ let top_pad = area.height.saturating_sub(content_h) / 2;
+ let mut cur_y = area.y + top_pad;
+
+ // --- Logo with smooth horizontal gradient (faded) ---
+ for (row_idx, row) in LOGO.iter().enumerate() {
+ if cur_y < area.y + area.height {
+ let logo_x = center_x(area.x, area.width, LOGO_DISPLAY_W);
+ for (col, ch) in row.chars().enumerate() {
+ let t = (col as f32 + row_idx as f32 * 3.0)
+ / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
+ let t = t.clamp(0.0, 1.0);
+ let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
+ let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
+ let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
+ let style = Style::default()
+ .fg(fade_color(Color::Rgb(r, g, b), opacity))
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD);
+ buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
+ }
+ }
+ cur_y += 1;
+ }
+
+ // --- Waveform below logo (faded) ---
+ if cur_y < area.y + area.height {
+ let chars: Vec<char> = WAVEFORM.chars().collect();
+ let total_w = chars.len() as u16;
+ let start_x = area.x + area.width.saturating_sub(total_w) / 2;
+ for (i, ch) in chars.iter().enumerate() {
+ let x = start_x + i as u16;
+ if x >= area.x && x < area.x + area.width {
+ let frac = i as f32 / chars.len().max(1) as f32;
+ let r = (frac * 160.0) as u8;
+ let g = (255.0 - frac * 195.0) as u8;
+ let b = (255.0 - frac * 15.0) as u8;
+ buf.set_string(
+ x, cur_y, ch.to_string(),
+ Style::default().fg(fade_color(Color::Rgb(r, g, b), opacity)).bg(BG_DARK),
+ );
+ }
+ }
+ }
+ cur_y += 2;
+
+ // --- Subtitle (faded) ---
+ let subtitle = "Oh My Music Player";
+ let sub_w = subtitle.len() as u16;
+ if cur_y < area.y + area.height {
+ let x = center_x(area.x, area.width, sub_w);
+ buf.set_string(
+ x, cur_y, subtitle,
+ Style::default()
+ .fg(fade_color(NEON_CYAN, opacity))
+ .bg(BG_DARK)
+ .add_modifier(Modifier::BOLD | Modifier::ITALIC),
+ );
+ }
+ cur_y += 1;
+
+ // --- Tagline (faded) ---
+ let tagline = "Terminal music, your way";
+ let tag_w = tagline.len() as u16;
+ if cur_y < area.y + area.height {
+ let x = center_x(area.x, area.width, tag_w);
+ buf.set_string(
+ x, cur_y, tagline,
+ Style::default().fg(fade_color(NEON_CYAN, opacity)).bg(BG_DARK),
+ );
+ }
+}
+
+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
@@ -0,0 +1,90 @@
+use ratatui::layout::{Constraint, Direction, 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;
+
+const KEYBINDINGS: &[(&str, &str)] = &[
+ ("Ctrl+E, s", "Search"),
+ ("Ctrl+E, h", "Help (this modal)"),
+ ("Ctrl+E, r", "Resize mode"),
+ ("Ctrl+E, i", "About OMMP"),
+ ("Ctrl+E, l", "Sync library"),
+ ("", ""),
+ ("Space", "Play / Pause"),
+ ("n / N", "Next / Previous track"),
+ ("+ / -", "Volume up / down"),
+ ("\u{2192} / \u{2190}", "Seek forward / backward"),
+ ("s", "Toggle shuffle"),
+ ("r", "Cycle repeat mode"),
+ ("b", "Add to playlist"),
+ ("", ""),
+ ("1-7", "Switch tab"),
+ ("Tab / Shift+Tab", "Cycle pane focus"),
+ ("j / k", "Navigate list"),
+ ("g / G", "Jump to first / last"),
+ ("Enter", "Select / Activate"),
+ ("d", "Remove from queue"),
+ ("c", "Clear queue"),
+ ("q", "Quit"),
+];
+
+pub fn render_help_modal(frame: &mut Frame, area: Rect, theme: &Theme) {
+ let modal = centered_rect(50, 70, area);
+
+ frame.render_widget(Clear, modal);
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan))
+ .title(" Keybindings ")
+ .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
+
+ let inner = block.inner(modal);
+ frame.render_widget(block, modal);
+
+ let lines: Vec<Line> = KEYBINDINGS
+ .iter()
+ .map(|(key, desc)| {
+ if key.is_empty() {
+ Line::from("")
+ } else {
+ Line::from(vec![
+ Span::styled(
+ format!(" {:20}", key),
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ desc.to_string(),
+ Style::default().fg(theme.fg),
+ ),
+ ])
+ }
+ })
+ .collect();
+
+ let help_text = Paragraph::new(lines);
+ 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/info_pane.rs b/src/ui/widgets/info_pane.rs
@@ -0,0 +1,382 @@
+use std::path::{Path, PathBuf};
+use std::time::SystemTime;
+
+use ratatui::layout::{Alignment, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::Frame;
+
+use ratatui_image::picker::Picker;
+use ratatui_image::protocol::StatefulProtocol;
+use ratatui_image::StatefulImage;
+
+use crate::app::state::InfoView;
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+// ── AlbumArtCache ────────────────────────────────────────────────────────
+
+pub struct AlbumArtCache {
+ track_dir: Option<PathBuf>,
+ picker: Picker,
+ protocol: Option<StatefulProtocol>,
+}
+
+impl AlbumArtCache {
+ pub fn new(picker: Picker) -> Self {
+ Self {
+ track_dir: None,
+ picker,
+ protocol: None,
+ }
+ }
+
+ fn needs_reload(&self, dir: Option<&Path>) -> bool {
+ match (&self.track_dir, dir) {
+ (Some(a), Some(b)) => a != b,
+ (None, None) => false,
+ _ => true,
+ }
+ }
+
+ fn load(&mut self, dir: Option<&Path>) {
+ self.track_dir = dir.map(|d| d.to_path_buf());
+ self.protocol = None;
+
+ let dir = match dir {
+ Some(d) => d,
+ None => return,
+ };
+
+ let cover_path = match find_cover_image(dir) {
+ Some(p) => p,
+ None => return,
+ };
+
+ let img = match image::open(&cover_path) {
+ Ok(i) => i,
+ Err(_) => return,
+ };
+
+ // StatefulProtocol handles resizing automatically per-frame
+ self.protocol = Some(self.picker.new_resize_protocol(img));
+ }
+}
+
+fn find_cover_image(dir: &Path) -> Option<PathBuf> {
+ let entries = std::fs::read_dir(dir).ok()?;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ // Check extension first (fast path)
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
+ let l = ext.to_ascii_lowercase();
+ if l == "jpg" || l == "jpeg" || l == "png" {
+ return Some(path);
+ }
+ }
+ // No extension or unknown ext — check magic bytes
+ if is_image_by_magic(&path) {
+ return Some(path);
+ }
+ }
+ None
+}
+
+/// Check file header bytes to detect JPEG/PNG regardless of extension.
+fn is_image_by_magic(path: &Path) -> bool {
+ use std::fs::File;
+ use std::io::Read;
+ let mut f = match File::open(path) {
+ Ok(f) => f,
+ Err(_) => return false,
+ };
+ let mut buf = [0u8; 8];
+ if f.read_exact(&mut buf).is_err() {
+ return false;
+ }
+ // JPEG: FF D8 FF
+ if buf[0] == 0xFF && buf[1] == 0xD8 && buf[2] == 0xFF {
+ return true;
+ }
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if buf == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
+ return true;
+ }
+ false
+}
+
+// ── Public render function ───────────────────────────────────────────────
+
+pub fn render_info_pane(
+ frame: &mut Frame,
+ area: Rect,
+ app: &App,
+ theme: &Theme,
+ view: InfoView,
+ art_cache: &mut AlbumArtCache,
+) {
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border_unfocused));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ match view {
+ InfoView::Clock => render_clock(frame, inner, theme),
+ InfoView::AlbumArt => render_album_art(frame, inner, app, art_cache),
+ }
+}
+
+// ── Clock View ───────────────────────────────────────────────────────────
+
+/// 5-line tall ASCII art digits using block characters
+const DIGIT_PATTERNS: [&[&str]; 11] = [
+ // 0
+ &["████", "█ █", "█ █", "█ █", "████"],
+ // 1
+ &[" █ ", " ██ ", " █ ", " █ ", " ███"],
+ // 2
+ &["████", " █", "████", "█ ", "████"],
+ // 3
+ &["████", " █", "████", " █", "████"],
+ // 4
+ &["█ █", "█ █", "████", " █", " █"],
+ // 5
+ &["████", "█ ", "████", " █", "████"],
+ // 6
+ &["████", "█ ", "████", "█ █", "████"],
+ // 7
+ &["████", " █", " █ ", " █ ", " █ "],
+ // 8
+ &["████", "█ █", "████", "█ █", "████"],
+ // 9
+ &["████", "█ █", "████", " █", "████"],
+ // : (colon, index 10)
+ &[" ", " ██ ", " ", " ██ ", " "],
+];
+
+fn render_clock(frame: &mut Frame, area: Rect, _theme: &Theme) {
+ if area.width < 4 || area.height < 5 {
+ return;
+ }
+
+ let now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+
+ let (hours, minutes, _) = {
+ let total_secs = now as i64;
+ let mut tm = unsafe { std::mem::zeroed::<libc::tm>() };
+ unsafe {
+ libc::localtime_r(&total_secs as *const i64, &mut tm);
+ }
+ (tm.tm_hour as u32, tm.tm_min as u32, tm.tm_sec as u32)
+ };
+
+ // Build digit indices: HH:MM
+ let digits = [
+ (hours / 10) as usize,
+ (hours % 10) as usize,
+ 10, // colon
+ (minutes / 10) as usize,
+ (minutes % 10) as usize,
+ ];
+
+ // Each digit is 4 chars wide + 1 space gap
+ // Total: 5 * 4 + 4 gaps = 24 chars
+ let total_width: u16 = 5 * 4 + 4;
+ let x_offset = if area.width > total_width {
+ (area.width - total_width) / 2
+ } else {
+ 0
+ };
+ let y_offset = if area.height > 5 {
+ (area.height - 5) / 2
+ } else {
+ 0
+ };
+
+ // Gradient colors per digit: sky blue → cyan → mint → purple → pink
+ let digit_colors: [Color; 5] = [
+ Color::Rgb(100, 180, 255), // sky blue
+ Color::Rgb(80, 220, 255), // cyan
+ Color::Rgb(200, 200, 200), // colon: white-gray
+ Color::Rgb(200, 130, 255), // purple
+ Color::Rgb(255, 130, 200), // pink
+ ];
+
+ for row in 0..5u16 {
+ let y = area.y + y_offset + row;
+ if y >= area.y + area.height {
+ break;
+ }
+ let mut x = area.x + x_offset;
+ for (i, &digit_idx) in digits.iter().enumerate() {
+ let style = Style::default().fg(digit_colors[i % digit_colors.len()]);
+ let pattern = DIGIT_PATTERNS[digit_idx][row as usize];
+ for ch in pattern.chars() {
+ if x >= area.x + area.width {
+ break;
+ }
+ let display_ch = if ch == '█' { '█' } else { ' ' };
+ if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
+ cell.set_char(display_ch).set_style(style);
+ }
+ x += 1;
+ }
+ // Gap between digits
+ if i < digits.len() - 1 {
+ x += 1;
+ }
+ }
+ }
+}
+
+// ── Album Art View ───────────────────────────────────────────────────────
+
+fn render_album_art(frame: &mut Frame, area: Rect, app: &App, cache: &mut AlbumArtCache) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+
+ let track_dir = app.current_track().and_then(|t| t.path.parent().map(|p| p.to_path_buf()));
+
+ if cache.needs_reload(track_dir.as_deref()) {
+ cache.load(track_dir.as_deref());
+ }
+
+ match cache.protocol {
+ Some(ref mut protocol) => {
+ // Center the image area: most album art is square, so
+ // compute a centered sub-rect matching the aspect ratio.
+ let font = cache.picker.font_size();
+ // Terminal cell aspect ratio: font_w / font_h
+ // For a square image, we need w_cells * font_w == h_cells * font_h
+ let (fw, fh) = (font.0.max(1) as u32, font.1.max(1) as u32);
+ // Desired square in pixels: min(area.width * fw, area.height * fh)
+ let px_w = area.width as u32 * fw;
+ let px_h = area.height as u32 * fh;
+ let side = px_w.min(px_h);
+ let fit_w = (side / fw) as u16;
+ let fit_h = (side / fh) as u16;
+ let x_off = (area.width.saturating_sub(fit_w)) / 2;
+ let y_off = (area.height.saturating_sub(fit_h)) / 2;
+ let centered = Rect {
+ x: area.x + x_off,
+ y: area.y + y_off,
+ width: fit_w.min(area.width),
+ height: fit_h.min(area.height),
+ };
+ let widget = StatefulImage::default();
+ frame.render_stateful_widget(widget, centered, protocol);
+ }
+ None => {
+ // No album art placeholder
+ let placeholder = vec![
+ Line::from(""),
+ Line::from(Span::styled(
+ "\u{266A}",
+ Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ Line::from(Span::styled("No Album Art", Style::default().fg(Color::DarkGray))),
+ ];
+ let para = Paragraph::new(placeholder).alignment(Alignment::Center);
+ let v_offset = if area.height > 4 { (area.height - 4) / 2 } else { 0 };
+ let centered = Rect {
+ x: area.x,
+ y: area.y + v_offset,
+ width: area.width,
+ height: area.height.saturating_sub(v_offset),
+ };
+ frame.render_widget(para, centered);
+ }
+ }
+}
+
+// ── Track Info View ──────────────────────────────────────────────────────
+
+pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+ let track = match app.current_track() {
+ Some(t) => t,
+ None => {
+ let para = Paragraph::new("No track playing")
+ .style(Style::default().fg(Color::DarkGray))
+ .alignment(Alignment::Center);
+ frame.render_widget(para, area);
+ return;
+ }
+ };
+
+ let format_ext = track
+ .path
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("unknown")
+ .to_ascii_uppercase();
+
+ let bitrate_str = track
+ .bitrate
+ .map(|b| format!("{} kbps", b))
+ .unwrap_or_else(|| "N/A".to_string());
+
+ let track_num_str = track
+ .track_number
+ .map(|n| n.to_string())
+ .unwrap_or_else(|| "N/A".to_string());
+
+ let duration_str = track.format_duration();
+
+
+ // Each field has a unique label color and value style
+ let fields: Vec<(&str, String, Color, Style)> = vec![
+ ("Title", track.title.clone(),
+ Color::Rgb(100, 180, 255),
+ Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)),
+ ("Artist", track.display_artist().to_string(),
+ Color::Rgb(255, 180, 100),
+ Style::default().fg(Color::Rgb(255, 210, 140))),
+ ("Album", track.display_album().to_string(),
+ Color::Rgb(200, 130, 255),
+ Style::default().fg(Color::Rgb(220, 170, 255))),
+ ("Album Artist",
+ if track.album_artist.is_empty() { "N/A".to_string() } else { track.album_artist.clone() },
+ Color::Rgb(200, 130, 255),
+ Style::default().fg(theme.fg)),
+ ("Genre",
+ if track.genre.is_empty() { "N/A".to_string() } else { track.genre.clone() },
+ Color::Rgb(255, 120, 150),
+ Style::default().fg(Color::Rgb(255, 170, 190))),
+ ("Track #", track_num_str,
+ Color::Rgb(120, 220, 180),
+ Style::default().fg(theme.fg)),
+ ("Duration", duration_str,
+ Color::Rgb(120, 220, 180),
+ Style::default().fg(theme.fg)),
+ ("Bitrate", bitrate_str,
+ Color::Rgb(255, 220, 100),
+ Style::default().fg(theme.fg)),
+ ("Format", format_ext,
+ Color::Rgb(255, 220, 100),
+ Style::default().fg(theme.fg)),
+ ];
+
+ let lines: Vec<Line> = fields
+ .iter()
+ .map(|(label, value, label_color, val_style)| {
+ Line::from(vec![
+ Span::styled(format!("{:>13}: ", label), Style::default().fg(*label_color)),
+ Span::styled(value.as_str(), *val_style),
+ ])
+ })
+ .collect();
+
+ let para = Paragraph::new(lines).wrap(Wrap { trim: false });
+ frame.render_widget(para, area);
+}
diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs
@@ -0,0 +1,8 @@
+pub mod status_bar;
+pub mod progress_bar;
+pub mod tab_bar;
+pub mod help_modal;
+pub mod search_modal;
+pub mod playlist_modal;
+pub mod about_modal;
+pub mod info_pane;
diff --git a/src/ui/widgets/playlist_modal.rs b/src/ui/widgets/playlist_modal.rs
@@ -0,0 +1,173 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum PlaylistModalMode {
+ List,
+ Create,
+ Rename,
+}
+
+pub fn render_playlist_modal(
+ frame: &mut Frame,
+ area: Rect,
+ selected: usize,
+ mode: &PlaylistModalMode,
+ input: &str,
+ app: &App,
+ theme: &Theme,
+) {
+ let modal = centered_rect(45, 45, area);
+
+ frame.render_widget(Clear, modal);
+
+ let title = match mode {
+ PlaylistModalMode::List => " Playlist ",
+ PlaylistModalMode::Create => " New Playlist ",
+ PlaylistModalMode::Rename => " Rename Playlist ",
+ };
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Yellow))
+ .title(title)
+ .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
+
+ let inner = block.inner(modal);
+ frame.render_widget(block, modal);
+
+ match mode {
+ PlaylistModalMode::Create | PlaylistModalMode::Rename => {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Min(1),
+ ])
+ .split(inner);
+
+ let prompt = if *mode == PlaylistModalMode::Create { "Name:" } else { "New name:" };
+ let input_line = Line::from(vec![
+ Span::styled(
+ format!(" {} ", prompt),
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(input, Style::default().fg(Color::White)),
+ Span::styled("_", Style::default().fg(Color::Yellow).add_modifier(Modifier::SLOW_BLINK)),
+ ]);
+ frame.render_widget(Paragraph::new(input_line), chunks[0]);
+
+ let hint = Line::from(Span::styled(
+ " Enter: confirm Esc: cancel",
+ Style::default().fg(Color::DarkGray),
+ ));
+ frame.render_widget(Paragraph::new(hint), chunks[2]);
+ }
+ PlaylistModalMode::List => {
+ if app.playlists.is_empty() {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Min(1), Constraint::Length(1)])
+ .split(inner);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ " No playlists. Press 'a' to create.",
+ Style::default().fg(Color::DarkGray),
+ ))),
+ chunks[0],
+ );
+ let hint = Line::from(Span::styled(
+ " a: add Esc: close ",
+ Style::default().fg(Color::DarkGray),
+ ));
+ frame.render_widget(Paragraph::new(hint), chunks[1]);
+ return;
+ }
+
+ let current_track_idx = app.queue.current_index
+ .and_then(|qi| app.queue.tracks.get(qi).copied());
+
+ let items: Vec<ListItem> = app
+ .playlists
+ .iter()
+ .enumerate()
+ .map(|(i, pl)| {
+ let is_selected = i == selected;
+ let already_in = current_track_idx
+ .is_some_and(|ti| pl.tracks.contains(&ti));
+
+ let check = if already_in { "\u{F00C} " } else { " " };
+ let icon = "\u{F005} ";
+
+ let style = if is_selected {
+ Style::default()
+ .bg(theme.highlight_bg)
+ .fg(theme.highlight_fg)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg)
+ };
+
+ let check_style = if is_selected {
+ style
+ } else if already_in {
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let count_style = if is_selected {
+ style
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(check, check_style),
+ Span::styled(icon, if is_selected { style } else { Style::default().fg(Color::Yellow) }),
+ Span::styled(&pl.name, style),
+ Span::styled(format!(" ({})", pl.tracks.len()), count_style),
+ ]))
+ })
+ .collect();
+
+ let hint = Line::from(Span::styled(
+ " Enter: toggle a: add d: delete r: rename Esc: close ",
+ Style::default().fg(Color::DarkGray),
+ ));
+
+ let list = List::new(items).block(
+ Block::default().title_bottom(hint)
+ );
+ frame.render_widget(list, 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/progress_bar.rs b/src/ui/widgets/progress_bar.rs
@@ -0,0 +1,90 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph, Gauge};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::app::state::PlayState;
+use crate::ui::theme::Theme;
+
+pub fn render_progress_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border_unfocused));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let cols = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Length(3), // Play icon
+ Constraint::Min(10), // Gauge bar
+ Constraint::Length(13), // Time display
+ ])
+ .split(inner);
+
+ // Play icon
+ let icon = match app.playback.state {
+ PlayState::Playing => "\u{F04B}", // nf-fa-play
+ PlayState::Paused => "\u{F04C}", // nf-fa-pause
+ PlayState::Stopped => "\u{F04D}", // nf-fa-stop
+ };
+ let icon_color = match app.playback.state {
+ PlayState::Playing => Color::Rgb(80, 255, 120),
+ PlayState::Paused => Color::Rgb(255, 200, 80),
+ PlayState::Stopped => Color::Rgb(255, 100, 100),
+ };
+ let icon_widget = Paragraph::new(Line::from(Span::styled(
+ format!(" {}", icon),
+ Style::default().fg(icon_color),
+ )));
+ frame.render_widget(icon_widget, cols[0]);
+
+ // Gauge
+ let ratio = if app.playback.duration_secs > 0.0 {
+ (app.playback.position_secs / app.playback.duration_secs).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+
+ let gauge = Gauge::default()
+ .ratio(ratio)
+ .label("")
+ .gauge_style(Style::default().fg(theme.progress_filled).bg(theme.progress_empty));
+ frame.render_widget(gauge, cols[1]);
+
+ // Time
+ let pos = format_time(app.playback.position_secs);
+ let dur = format_time(app.playback.duration_secs);
+ let time_widget = Paragraph::new(Line::from(Span::styled(
+ format!(" {} / {}", pos, dur),
+ Style::default().fg(Color::White),
+ )));
+ frame.render_widget(time_widget, cols[2]);
+}
+
+/// Returns the gauge area for mouse click seeking
+pub fn progress_gauge_area(area: Rect) -> Rect {
+ let block = Block::default().borders(Borders::ALL);
+ let inner = block.inner(area);
+
+ let cols = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Min(10),
+ Constraint::Length(13),
+ ])
+ .split(inner);
+
+ 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
@@ -0,0 +1,195 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+#[allow(clippy::too_many_arguments)]
+pub fn render_search_modal(
+ frame: &mut Frame,
+ area: Rect,
+ input: &str,
+ results: &[usize],
+ selected: usize,
+ scroll: usize,
+ hover_row: Option<usize>,
+ app: &App,
+ theme: &Theme,
+) -> (usize, Rect) {
+ let modal = centered_rect(50, 60, area);
+
+ frame.render_widget(Clear, modal);
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan))
+ .title(" Search ")
+ .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
+
+ let inner = block.inner(modal);
+ frame.render_widget(block, modal);
+
+ // Split inner: input(1) + separator(1) + results(rest)
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(1), // input line
+ Constraint::Length(1), // separator
+ Constraint::Min(1), // results
+ ])
+ .split(inner);
+
+ // Input line with cursor
+ let input_line = Line::from(vec![
+ Span::styled(" > ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
+ Span::styled(input, Style::default().fg(Color::White)),
+ Span::styled("_", Style::default().fg(Color::Cyan).add_modifier(Modifier::SLOW_BLINK)),
+ ]);
+ frame.render_widget(Paragraph::new(input_line), chunks[0]);
+
+ // Separator
+ let sep = "─".repeat(chunks[1].width as usize);
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(sep, Style::default().fg(Color::DarkGray)))),
+ chunks[1],
+ );
+
+ // Results list
+ let result_height = chunks[2].height as usize;
+ let result_width = chunks[2].width as usize;
+
+ if results.is_empty() {
+ let msg = if input.is_empty() {
+ "Type to search..."
+ } else {
+ "No results found"
+ };
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ format!(" {}", msg),
+ Style::default().fg(Color::DarkGray),
+ ))),
+ chunks[2],
+ );
+ } else {
+ let items: Vec<ListItem> = results
+ .iter()
+ .enumerate()
+ .skip(scroll)
+ .take(result_height)
+ .map(|(i, &track_idx)| {
+ let track = &app.library.tracks[track_idx];
+ let is_selected = i == selected;
+ let is_hovered = hover_row == Some(i);
+
+ let artist = track.display_artist();
+ let title_w = (result_width * 55 / 100).max(4);
+ let artist_w = result_width.saturating_sub(title_w + 3); // 3 = prefix + gap
+
+ let title_fitted = fit_to_width(&track.title, title_w);
+ let artist_fitted = fit_to_width(artist, artist_w);
+
+ let (style, artist_style) = if is_selected {
+ let s = Style::default()
+ .bg(theme.highlight_bg)
+ .fg(theme.highlight_fg)
+ .add_modifier(Modifier::BOLD);
+ (s, s)
+ } else if is_hovered {
+ (
+ Style::default().fg(theme.fg).bg(HOVER_BG),
+ Style::default().fg(Color::Gray).bg(HOVER_BG),
+ )
+ } else {
+ (
+ Style::default().fg(theme.fg),
+ Style::default().fg(Color::Gray),
+ )
+ };
+
+ let prefix = if is_selected { " > " } else { " " };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(prefix, style),
+ Span::styled(title_fitted, style),
+ Span::styled(artist_fitted, artist_style),
+ ]))
+ })
+ .collect();
+
+ let count_info = format!(" {}/{} ", results.len(), app.library.tracks.len());
+ let list = List::new(items).block(
+ Block::default()
+ .title_bottom(Line::from(Span::styled(
+ count_info,
+ Style::default().fg(Color::DarkGray),
+ )))
+ );
+ frame.render_widget(list, chunks[2]);
+
+ // Scrollbar
+ if results.len() > result_height {
+ let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+ .begin_symbol(None)
+ .end_symbol(None);
+ let mut scrollbar_state = ScrollbarState::new(results.len())
+ .position(scroll);
+ frame.render_stateful_widget(
+ scrollbar,
+ chunks[2],
+ &mut scrollbar_state,
+ );
+ }
+ }
+
+ (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
@@ -0,0 +1,175 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect, Alignment};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::app::state::{PlayState, SyncState};
+use crate::ui::theme::Theme;
+
+pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, resize_mode: bool) {
+ let block = if resize_mode {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Yellow))
+ .title(" [RESIZE] ")
+ .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
+ } else if app.sync_state == SyncState::Scanning {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Rgb(255, 200, 80)))
+ .title(" [SYNCING] ")
+ .title_style(Style::default().fg(Color::Rgb(255, 200, 80)).add_modifier(Modifier::BOLD))
+ } else {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border_unfocused))
+ };
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let cols = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(25),
+ Constraint::Percentage(50),
+ Constraint::Percentage(25),
+ ])
+ .split(inner);
+
+ // Left: Play state + time
+ let state_icon = match app.playback.state {
+ PlayState::Playing => "\u{F04B}", // nf-fa-play
+ PlayState::Paused => "\u{F04C}", // nf-fa-pause
+ PlayState::Stopped => "\u{F04D}", // nf-fa-stop
+ };
+
+ let pos = format_time(app.playback.position_secs);
+ let dur = format_time(app.playback.duration_secs);
+
+ let bitrate = app
+ .current_track()
+ .and_then(|t| t.bitrate)
+ .map(|b| format!(" ({}kbps)", b))
+ .unwrap_or_default();
+
+ let state_color = match app.playback.state {
+ PlayState::Playing => Color::Rgb(80, 255, 120), // bright green
+ PlayState::Paused => Color::Rgb(255, 200, 80), // amber
+ PlayState::Stopped => Color::Rgb(255, 100, 100), // soft red
+ };
+
+ let left_line1 = Line::from(vec![
+ Span::styled(
+ format!(" {} {}", state_icon, match app.playback.state {
+ PlayState::Playing => "Playing",
+ PlayState::Paused => "Paused",
+ PlayState::Stopped => "Stopped",
+ }),
+ Style::default().fg(state_color).add_modifier(Modifier::BOLD),
+ ),
+ ]);
+
+ let left_line2 = Line::from(vec![
+ Span::styled(
+ format!(" {}/{}{}", pos, dur, bitrate),
+ Style::default().fg(Color::Gray),
+ ),
+ ]);
+
+ let left = Paragraph::new(vec![left_line1, left_line2]);
+ frame.render_widget(left, cols[0]);
+
+ // Center: Track info
+ let (title, artist_album) = if let Some(track) = app.current_track() {
+ (
+ track.title.clone(),
+ format!("{} - {}", track.display_artist(), track.display_album()),
+ )
+ } else {
+ ("No track playing".to_string(), String::new())
+ };
+
+ let center_line1 = Line::from(Span::styled(
+ title,
+ Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD),
+ )).alignment(Alignment::Center);
+
+ let center_line2 = Line::from(Span::styled(
+ artist_album,
+ Style::default().fg(Color::Rgb(200, 170, 255)),
+ )).alignment(Alignment::Center);
+
+ let center = Paragraph::new(vec![center_line1, center_line2]);
+ frame.render_widget(center, cols[1]);
+
+ // Right: Volume + shuffle/repeat
+ let vol_pct = (app.playback.volume * 100.0) as u8;
+
+ let shuffle_style = if app.playback.shuffle {
+ Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let repeat_style = match app.playback.repeat {
+ crate::app::state::RepeatMode::Off => Style::default().fg(Color::DarkGray),
+ crate::app::state::RepeatMode::All => Style::default().fg(Color::Rgb(120, 255, 180)).add_modifier(Modifier::BOLD),
+ crate::app::state::RepeatMode::One => Style::default().fg(Color::Rgb(255, 220, 100)).add_modifier(Modifier::BOLD),
+ };
+
+ // Volume staircase with gradient: green → yellow → orange → red
+ const STEPS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
+ let vol_colors: [Color; 8] = [
+ Color::Rgb(80, 200, 120), // green
+ Color::Rgb(120, 220, 100), // green-yellow
+ Color::Rgb(180, 230, 80), // yellow-green
+ Color::Rgb(230, 220, 60), // yellow
+ Color::Rgb(255, 190, 50), // amber
+ Color::Rgb(255, 150, 40), // orange
+ Color::Rgb(255, 110, 50), // red-orange
+ Color::Rgb(255, 70, 70), // red
+ ];
+ let filled = (vol_pct as u16 * 8 / 100).min(8) as usize;
+ let mut vol_spans = Vec::with_capacity(10);
+ for (i, &ch) in STEPS.iter().enumerate() {
+ let style = if i < filled {
+ Style::default().fg(vol_colors[i]).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::Indexed(238))
+ };
+ vol_spans.push(Span::styled(String::from(ch), style));
+ }
+ vol_spans.push(Span::styled(format!(" {}% ", vol_pct), Style::default().fg(Color::White)));
+
+ let right_line1 = Line::from(vol_spans).alignment(Alignment::Right);
+
+ let is_bookmarked = app.queue.current_index
+ .and_then(|qi| app.queue.tracks.get(qi))
+ .is_some_and(|&ti| {
+ app.playlists.iter().any(|pl| pl.tracks.contains(&ti))
+ });
+ let bookmark_style = if is_bookmarked {
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let right_line2 = Line::from(vec![
+ Span::styled("\u{F005} ", bookmark_style), // nf-fa-star
+ Span::styled("\u{F074} ", shuffle_style), // nf-fa-random
+ Span::styled(format!("{} ", app.playback.repeat.symbol()), repeat_style),
+ ]).alignment(Alignment::Right);
+
+ let right = Paragraph::new(vec![right_line1, right_line2]);
+ 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)
+}
diff --git a/src/ui/widgets/tab_bar.rs b/src/ui/widgets/tab_bar.rs
@@ -0,0 +1,99 @@
+use ratatui::layout::{Alignment, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::Frame;
+
+use crate::app::state::Tab;
+use crate::ui::theme::Theme;
+
+pub fn render_tab_bar(
+ frame: &mut Frame,
+ area: Rect,
+ current: Tab,
+ hovered: Option<usize>,
+ theme: &Theme,
+) {
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border_unfocused));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ // Each tab has its own accent color
+ let tab_colors: [Color; 6] = [
+ Color::Rgb(100, 220, 255), // Queue - sky blue
+ Color::Rgb(120, 255, 180), // Directories - mint green
+ Color::Rgb(255, 180, 100), // Artists - orange
+ Color::Rgb(200, 130, 255), // Albums - purple
+ Color::Rgb(255, 120, 150), // Genre - pink
+ Color::Rgb(255, 220, 100), // Playlists - gold
+ ];
+
+ let mut spans = Vec::new();
+ for (i, tab) in Tab::ALL.iter().enumerate() {
+ if i > 0 {
+ spans.push(Span::styled(" \u{2502} ", theme.dim_style));
+ }
+ let color = tab_colors[i % tab_colors.len()];
+ let style = if i == current.index() {
+ Style::default().fg(color).add_modifier(Modifier::BOLD)
+ } else if hovered == Some(i) {
+ Style::default().fg(color)
+ } else {
+ theme.tab_inactive
+ };
+ spans.push(Span::styled(tab.title(), style));
+ }
+
+ let line = Line::from(spans);
+ let paragraph = Paragraph::new(line).alignment(Alignment::Center);
+ frame.render_widget(paragraph, inner);
+}
+
+/// Returns which tab index was clicked given mouse x position
+pub fn tab_hit_test(area: Rect, mouse_x: u16) -> Option<usize> {
+ let block = Block::default().borders(Borders::ALL);
+ let inner = block.inner(area);
+
+ if mouse_x < inner.x || mouse_x >= inner.x + inner.width {
+ return None;
+ }
+
+ // Calculate total content width to find center offset
+ let divider_len = 3; // " │ "
+ let mut total_width: usize = 0;
+ for (i, tab) in Tab::ALL.iter().enumerate() {
+ if i > 0 {
+ total_width += divider_len;
+ }
+ total_width += tab.title().len();
+ }
+
+ let inner_w = inner.width as usize;
+ let pad_left = if inner_w > total_width {
+ (inner_w - total_width) / 2
+ } else {
+ 0
+ };
+
+ let rel_x = (mouse_x - inner.x) as usize;
+ if rel_x < pad_left {
+ return None;
+ }
+
+ let content_x = rel_x - pad_left;
+ let mut pos = 0;
+ for (i, tab) in Tab::ALL.iter().enumerate() {
+ if i > 0 {
+ pos += divider_len;
+ }
+ let title_len = tab.title().len();
+ if content_x < pos + title_len {
+ return Some(i);
+ }
+ pos += title_len;
+ }
+ None
+}
|