ommp.git

ommp

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

src/ui/panes/queue_pane.rs (10087B)

      1 use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
      2 use ratatui::layout::Rect;
      3 use ratatui::style::{Color, Modifier, Style};
      4 use ratatui::text::{Line, Span};
      5 use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
      6 use ratatui::Frame;
      7 
      8 use crate::app::{App, AppAction};
      9 use crate::ui::pane::Pane;
     10 use crate::ui::theme::Theme;
     11 use crate::ui::text::fit_to_width;
     12 use crate::ui::text::max_scroll;
     13 
     14 const HOVER_BG: Color = Color::Indexed(238); // very dark gray
     15 
     16 pub struct QueuePane {
     17     pub scroll_offset: usize,
     18     pub hover_row: Option<usize>,
     19 }
     20 
     21 impl QueuePane {
     22     pub fn new() -> Self {
     23         Self {
     24             scroll_offset: 0,
     25             hover_row: None,
     26         }
     27     }
     28 }
     29 
     30 /// Color for each audio format extension
     31 fn format_color(ext: &str) -> Color {
     32     match ext {
     33         "FLAC" => Color::Green,
     34         "M4A" | "AAC" | "MP4" | "ALAC" => Color::Cyan,
     35         "MP3" => Color::Yellow,
     36         "OGG" => Color::Magenta,
     37         "WAV" | "WAVE" => Color::Blue,
     38         _ => Color::White,
     39     }
     40 }
     41 
     42 impl Pane for QueuePane {
     43     fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
     44         let count = app.queue.tracks.len();
     45         let border_color = if focused {
     46             theme.border_focused
     47         } else {
     48             theme.border_unfocused
     49         };
     50 
     51         let block = Block::default()
     52             .borders(Borders::ALL)
     53             .border_style(Style::default().fg(border_color))
     54             .title_style(Style::default().fg(if focused {
     55                 theme.border_focused
     56             } else {
     57                 theme.fg
     58             }));
     59 
     60         let inner = block.inner(area);
     61         let inner_height = inner.height as usize;
     62         let inner_width = inner.width as usize;
     63 
     64         // Auto-scroll to keep selected_index visible
     65         if count > 0 {
     66             if app.queue.selected_index < self.scroll_offset {
     67                 self.scroll_offset = app.queue.selected_index;
     68             }
     69             if inner_height > 0 && app.queue.selected_index >= self.scroll_offset + inner_height {
     70                 self.scroll_offset = app.queue.selected_index - inner_height + 1;
     71             }
     72             // Keep the viewport full: the scroll handlers clamp to count - 1,
     73             // which lets the list scroll until one row sits above a blank screen.
     74             self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
     75         }
     76 
     77         // Column layout: prefix(2) + title(55%) + artist(45%) + ext(4) + gap(1) + dur(5) + trail(1)
     78         let ext_col_width = 4;
     79         let dur_col_width = 5;
     80         let prefix_width = 2;
     81         let fixed_width = prefix_width + 1 + ext_col_width + 1 + dur_col_width + 1;
     82         let flex_total = inner_width.saturating_sub(fixed_width);
     83         let title_max = (flex_total * 55 / 100).max(4);
     84         let artist_max = flex_total.saturating_sub(title_max).max(4);
     85         let has_scrollbar = count > inner_height;
     86 
     87         let items: Vec<ListItem> = app
     88             .queue
     89             .tracks
     90             .iter()
     91             .enumerate()
     92             .skip(self.scroll_offset)
     93             .take(inner_height)
     94             .map(|(i, &track_idx)| {
     95                 let track = &app.library.tracks[track_idx];
     96                 let is_current = app.queue.current_index == Some(i);
     97                 let is_selected = i == app.queue.selected_index;
     98 
     99                 let artist = track.display_artist();
    100                 let ext = track
    101                     .path
    102                     .extension()
    103                     .and_then(|e| e.to_str())
    104                     .unwrap_or("?")
    105                     .to_uppercase();
    106                 let dur = track.format_duration();
    107 
    108                 // Base styles
    109                 let sel_style = Style::default()
    110                     .bg(theme.highlight_bg)
    111                     .fg(theme.highlight_fg)
    112                     .add_modifier(Modifier::BOLD);
    113                 let cur_style = theme.current_track_style;
    114                 let normal_style = Style::default().fg(theme.fg);
    115                 let dim_style = Style::default().fg(Color::Gray);
    116 
    117                 let is_hovered = self.hover_row == Some(i);
    118 
    119                 let (title_style, artist_style, ext_style, dur_style, prefix_style) =
    120                     if is_selected && focused {
    121                         (sel_style, sel_style, sel_style, sel_style, sel_style)
    122                     } else if is_current {
    123                         let bg = if is_hovered { HOVER_BG } else { Color::Reset };
    124                         (
    125                             cur_style.bg(bg),
    126                             dim_style.bg(bg),
    127                             Style::default().fg(format_color(&ext)).add_modifier(Modifier::BOLD).bg(bg),
    128                             Style::default().fg(Color::DarkGray).bg(bg),
    129                             cur_style.bg(bg),
    130                         )
    131                     } else if is_hovered {
    132                         (
    133                             normal_style.bg(HOVER_BG),
    134                             dim_style.bg(HOVER_BG),
    135                             Style::default().fg(format_color(&ext)).bg(HOVER_BG),
    136                             Style::default().fg(Color::DarkGray).bg(HOVER_BG),
    137                             normal_style.bg(HOVER_BG),
    138                         )
    139                     } else {
    140                         (
    141                             normal_style,
    142                             dim_style,
    143                             Style::default().fg(format_color(&ext)),
    144                             Style::default().fg(Color::DarkGray),
    145                             normal_style,
    146                         )
    147                     };
    148 
    149                 let in_playlist = app.is_in_playlist(track_idx);
    150                 let prefix = if is_current { "\u{F04B} " } else { "  " }; // nf-fa-play
    151 
    152                 // Star integrated into title text so it stays next to the title
    153                 let title_text = if in_playlist {
    154                     format!("{} \u{F005}", track.title) // "Title nf-fa-star"
    155                 } else {
    156                     track.title.clone()
    157                 };
    158                 let title_fitted = fit_to_width(&title_text, title_max);
    159                 let artist_fitted = fit_to_width(artist, artist_max);
    160 
    161                 // Right-align ext to ext_col_width
    162                 let ext_padded = format!("{:>width$}", ext, width = ext_col_width);
    163                 // Right-align dur to dur_col_width
    164                 let dur_padded = format!("{:>width$}", dur, width = dur_col_width);
    165 
    166                 // Row background for gap spans (keeps selection/hover highlight continuous)
    167                 let row_bg = if is_selected && focused {
    168                     sel_style
    169                 } else if is_hovered {
    170                     Style::default().bg(HOVER_BG)
    171                 } else {
    172                     Style::default()
    173                 };
    174 
    175                 ListItem::new(Line::from(vec![
    176                     Span::styled(prefix, prefix_style),
    177                     Span::styled(title_fitted, title_style),
    178                     Span::styled(artist_fitted, artist_style),
    179                     Span::styled(" ", row_bg),
    180                     Span::styled(ext_padded, ext_style),
    181                     Span::styled(" ", row_bg),
    182                     Span::styled(dur_padded, dur_style),
    183                     Span::styled(" ", row_bg),
    184                 ]))
    185             })
    186             .collect();
    187 
    188         let list = List::new(items).block(block);
    189         frame.render_widget(list, area);
    190 
    191         // Scrollbar (rendered over the right border)
    192         if has_scrollbar {
    193             let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
    194                 .begin_symbol(None)
    195                 .end_symbol(None);
    196             let mut scrollbar_state = ScrollbarState::new(count)
    197                 .position(self.scroll_offset);
    198             frame.render_stateful_widget(
    199                 scrollbar,
    200                 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
    201                 &mut scrollbar_state,
    202             );
    203         }
    204     }
    205 
    206     fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
    207         let count = app.queue.tracks.len();
    208         if count == 0 && key.code != KeyCode::Char('c') {
    209             return None;
    210         }
    211 
    212         match key.code {
    213             KeyCode::Char('j') | KeyCode::Down => None,
    214             KeyCode::Char('k') | KeyCode::Up => None,
    215             KeyCode::Enter => {
    216                 if count > 0 {
    217                     Some(AppAction::PlayQueueIndex(app.queue.selected_index))
    218                 } else {
    219                     None
    220                 }
    221             }
    222             KeyCode::Char('d') | KeyCode::Delete => {
    223                 if count > 0 {
    224                     Some(AppAction::RemoveFromQueue(app.queue.selected_index))
    225                 } else {
    226                     None
    227                 }
    228             }
    229             KeyCode::Char('c') => Some(AppAction::ClearQueue),
    230             _ => None,
    231         }
    232     }
    233 
    234     fn handle_mouse(&mut self, event: MouseEvent, _area: Rect, app: &App) -> Option<AppAction> {
    235         // Clicks are handled in handler.rs, which owns queue selection.
    236         match event.kind {
    237             MouseEventKind::ScrollDown => self.handle_scroll(false, app),
    238             MouseEventKind::ScrollUp => self.handle_scroll(true, app),
    239             _ => None,
    240         }
    241     }
    242 
    243     fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
    244         let count = app.queue.tracks.len();
    245         if count == 0 {
    246             return None;
    247         }
    248         if up {
    249             self.scroll_offset = self.scroll_offset.saturating_sub(3);
    250             let new_sel = app.queue.selected_index.saturating_sub(3);
    251             Some(AppAction::SetQueueSelection(new_sel))
    252         } else {
    253             self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
    254             let new_sel = (app.queue.selected_index + 3).min(count.saturating_sub(1));
    255             Some(AppAction::SetQueueSelection(new_sel))
    256         }
    257     }
    258 }