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/list_pane.rs (11072B)

      1 //! One scrollable list of labelled rows, shared by the Artists, Albums, Genre,
      2 //! Format and Playlists tabs.
      3 //!
      4 //! These were five near-identical files; normalising identifiers made three of
      5 //! them byte-for-byte the same over all 185 lines, and the other two differed
      6 //! only inside the row-render closure and one call in the Enter arm. Everything
      7 //! here beyond `ListRow` and `rows_for`/`activate` was copied five ways, which
      8 //! is how the render-time selection clamp ended up in exactly one of them.
      9 
     10 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
     11 use ratatui::layout::{Margin, Rect};
     12 use ratatui::style::{Color, Modifier, Style};
     13 use ratatui::text::{Line, Span};
     14 use ratatui::widgets::{
     15     Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
     16 };
     17 use ratatui::Frame;
     18 
     19 use crate::app::state::Tab;
     20 use crate::app::{App, AppAction};
     21 use crate::ui::text::{fit_to_width, max_scroll};
     22 use crate::ui::theme::Theme;
     23 
     24 const HOVER_BG: Color = Color::Indexed(238);
     25 const SCROLL_STEP: usize = 3;
     26 
     27 /// A row: an optional leading icon, the label, and dim trailing detail such as
     28 /// an album's artist or a track count.
     29 pub struct ListRow {
     30     pub icon: Option<(&'static str, Color)>,
     31     pub text: String,
     32     pub detail: Option<String>,
     33 }
     34 
     35 impl ListRow {
     36     pub fn plain(text: String) -> Self {
     37         Self { icon: None, text, detail: None }
     38     }
     39 
     40     pub fn with_detail(text: String, detail: String) -> Self {
     41         Self { icon: None, text, detail: Some(detail) }
     42     }
     43 }
     44 
     45 #[derive(Default)]
     46 pub struct ListPane {
     47     pub selected: usize,
     48     pub scroll_offset: usize,
     49     pub hover_row: Option<usize>,
     50 }
     51 
     52 impl ListPane {
     53     pub fn render(
     54         &mut self,
     55         frame: &mut Frame,
     56         area: Rect,
     57         focused: bool,
     58         theme: &Theme,
     59         rows: &[ListRow],
     60     ) {
     61         let count = rows.len();
     62         // Clamp here rather than trusting callers: only one of the five copies
     63         // did this, and it survived purely because a separate clamp ran on
     64         // library reload.
     65         if self.selected >= count {
     66             self.selected = count.saturating_sub(1);
     67         }
     68 
     69         let border_color = if focused {
     70             theme.border_focused
     71         } else {
     72             theme.border_unfocused
     73         };
     74         let block = Block::default()
     75             .borders(Borders::ALL)
     76             .border_style(Style::default().fg(border_color));
     77 
     78         let inner = block.inner(area);
     79         let inner_height = inner.height as usize;
     80         let inner_width = inner.width as usize;
     81 
     82         // Auto-scroll to keep the selection visible, then make sure the viewport
     83         // is still full.
     84         if count > 0 && inner_height > 0 {
     85             if self.selected < self.scroll_offset {
     86                 self.scroll_offset = self.selected;
     87             }
     88             if self.selected >= self.scroll_offset + inner_height {
     89                 self.scroll_offset = self.selected - inner_height + 1;
     90             }
     91             self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
     92         }
     93 
     94         let highlight = Style::default()
     95             .bg(theme.highlight_bg)
     96             .fg(theme.highlight_fg)
     97             .add_modifier(Modifier::BOLD);
     98 
     99         let items: Vec<ListItem> = rows
    100             .iter()
    101             .enumerate()
    102             .skip(self.scroll_offset)
    103             .take(inner_height)
    104             .map(|(i, row)| {
    105                 let selected = i == self.selected && focused;
    106                 let hovered = self.hover_row == Some(i);
    107                 let bg = if hovered { HOVER_BG } else { Color::Reset };
    108 
    109                 let base = if selected {
    110                     highlight
    111                 } else {
    112                     Style::default().fg(theme.fg).bg(bg)
    113                 };
    114                 let dim = if selected {
    115                     highlight
    116                 } else {
    117                     Style::default().fg(Color::DarkGray).bg(bg)
    118                 };
    119 
    120                 let mut spans = Vec::new();
    121                 let mut used = 2;
    122                 spans.push(Span::styled("  ", base));
    123                 if let Some((icon, color)) = row.icon {
    124                     used += icon.chars().count() + 1;
    125                     let style = if selected {
    126                         highlight
    127                     } else {
    128                         Style::default().fg(color).bg(bg)
    129                     };
    130                     spans.push(Span::styled(format!("{icon} "), style));
    131                 }
    132 
    133                 match &row.detail {
    134                     // Pad the label to a fixed column so the detail lines up on
    135                     // every row. Without this a CJK label and an ASCII one put
    136                     // the second column at different offsets.
    137                     Some(detail) => {
    138                         let detail_w = detail.chars().count() + 1;
    139                         let label_w = inner_width.saturating_sub(used + detail_w);
    140                         spans.push(Span::styled(fit_to_width(&row.text, label_w), base));
    141                         spans.push(Span::styled(format!(" {detail}"), dim));
    142                     }
    143                     None => spans.push(Span::styled(
    144                         fit_to_width(&row.text, inner_width.saturating_sub(used)),
    145                         base,
    146                     )),
    147                 }
    148 
    149                 ListItem::new(Line::from(spans))
    150             })
    151             .collect();
    152 
    153         frame.render_widget(List::new(items).block(block), area);
    154 
    155         if count > inner_height {
    156             let mut state = ScrollbarState::new(count).position(self.scroll_offset);
    157             frame.render_stateful_widget(
    158                 Scrollbar::new(ScrollbarOrientation::VerticalRight)
    159                     .begin_symbol(None)
    160                     .end_symbol(None),
    161                 area.inner(Margin { vertical: 1, horizontal: 0 }),
    162                 &mut state,
    163             );
    164         }
    165     }
    166 
    167     /// Returns true when the key selected an item and the caller should build an
    168     /// activation action.
    169     pub fn handle_key(&mut self, key: KeyEvent, count: usize) -> bool {
    170         if count == 0 {
    171             return false;
    172         }
    173         match key.code {
    174             KeyCode::Char('j') | KeyCode::Down => {
    175                 if self.selected < count - 1 {
    176                     self.selected += 1;
    177                 }
    178             }
    179             KeyCode::Char('k') | KeyCode::Up => {
    180                 self.selected = self.selected.saturating_sub(1);
    181             }
    182             KeyCode::Home | KeyCode::Char('g') => {
    183                 self.selected = 0;
    184                 self.scroll_offset = 0;
    185             }
    186             KeyCode::End | KeyCode::Char('G') => {
    187                 self.selected = count - 1;
    188             }
    189             KeyCode::Enter => return true,
    190             _ => {}
    191         }
    192         false
    193     }
    194 
    195     pub fn handle_mouse(&mut self, event: MouseEvent, area: Rect, count: usize) {
    196         match event.kind {
    197             MouseEventKind::Down(MouseButton::Left) => {
    198                 let inner = Block::default().borders(Borders::ALL).inner(area);
    199                 if event.column >= inner.x
    200                     && event.column < inner.x + inner.width
    201                     && event.row >= inner.y
    202                     && event.row < inner.y + inner.height
    203                 {
    204                     let clicked = self.scroll_offset + (event.row - inner.y) as usize;
    205                     if clicked < count {
    206                         self.selected = clicked;
    207                     }
    208                 }
    209             }
    210             MouseEventKind::ScrollDown => self.scroll(false, count, area.height as usize),
    211             MouseEventKind::ScrollUp => self.scroll(true, count, area.height as usize),
    212             _ => {}
    213         }
    214     }
    215 
    216     pub fn scroll(&mut self, up: bool, count: usize, viewport: usize) {
    217         if count == 0 {
    218             return;
    219         }
    220         // Two border rows.
    221         let inner_height = viewport.saturating_sub(2);
    222         if up {
    223             self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_STEP);
    224             self.selected = self.selected.saturating_sub(SCROLL_STEP);
    225         } else {
    226             self.scroll_offset =
    227                 (self.scroll_offset + SCROLL_STEP).min(max_scroll(count, inner_height));
    228             self.selected = (self.selected + SCROLL_STEP).min(count - 1);
    229         }
    230     }
    231 }
    232 
    233 /// How many rows `tab` will produce, without building any of them.
    234 ///
    235 /// Mouse handling needs only the count; it used to get it by materialising the
    236 /// whole row list, so every motion event rebuilt the artist or album view.
    237 pub fn row_count(app: &App, tab: Tab) -> usize {
    238     match tab {
    239         Tab::Artists => app.library.artists().len(),
    240         Tab::Albums => app.library.albums().len(),
    241         Tab::Genre => app.library.genres().len(),
    242         Tab::Format => app.library.formats().len(),
    243         Tab::Playlists => app.playlists.len(),
    244         Tab::Queue | Tab::Directories => 0,
    245     }
    246 }
    247 
    248 /// Rows for whichever of the five list tabs is showing.
    249 pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
    250     match tab {
    251         Tab::Artists => app
    252             .library
    253             .artists()
    254             .iter()
    255             .map(|a| ListRow::plain(a.clone()))
    256             .collect(),
    257         Tab::Albums => app
    258             .library
    259             .albums()
    260             .iter()
    261             .map(|(album, artist)| ListRow::with_detail(album.clone(), artist.clone()))
    262             .collect(),
    263         Tab::Genre => app
    264             .library
    265             .genres()
    266             .iter()
    267             .map(|g| ListRow::plain(g.clone()))
    268             .collect(),
    269         Tab::Format => app
    270             .library
    271             .formats()
    272             .iter()
    273             .map(|(fmt, n)| ListRow::with_detail(fmt.clone(), format!("({n})")))
    274             .collect(),
    275         Tab::Playlists => app
    276             .playlists
    277             .iter()
    278             .map(|pl| ListRow {
    279                 icon: Some(("\u{F005}", Color::Yellow)),
    280                 text: pl.name.clone(),
    281                 detail: Some(format!("({})", pl.tracks.len())),
    282             })
    283             .collect(),
    284         Tab::Queue | Tab::Directories => Vec::new(),
    285     }
    286 }
    287 
    288 /// The action for activating row `selected` of `tab`.
    289 pub fn activate(app: &App, tab: Tab, selected: usize) -> Option<AppAction> {
    290     let tracks = match tab {
    291         Tab::Artists => app.library.tracks_by_artist(app.library.artists().get(selected)?).to_vec(),
    292         Tab::Albums => {
    293             let (album, artist) = app.library.albums().get(selected)?;
    294             app.library.tracks_by_album(album, artist).to_vec()
    295         }
    296         Tab::Genre => app.library.tracks_by_genre(app.library.genres().get(selected)?).to_vec(),
    297         Tab::Format => app
    298             .library
    299             .tracks_by_format(&app.library.formats().get(selected)?.0)
    300             .to_vec(),
    301         Tab::Playlists => app.playlists.get(selected)?.tracks.clone(),
    302         Tab::Queue | Tab::Directories => return None,
    303     };
    304     (!tracks.is_empty()).then_some(AppAction::AddToQueue(tracks))
    305 }