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/dir_browser_pane.rs (10188B)

      1 use crossterm::event::{KeyCode, KeyEvent, MouseButton, 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 use std::path::PathBuf;
      8 
      9 use crate::app::{App, AppAction};
     10 use crate::ui::pane::Pane;
     11 use crate::ui::theme::Theme;
     12 use crate::ui::text::max_scroll;
     13 
     14 const HOVER_BG: Color = Color::Indexed(238);
     15 
     16 pub struct DirBrowserPane {
     17     pub current_dir: PathBuf,
     18     pub entries: Vec<DirEntry>,
     19     pub selected: usize,
     20     pub scroll_offset: usize,
     21     pub hover_row: Option<usize>,
     22 }
     23 
     24 #[derive(Debug, Clone)]
     25 pub enum DirEntry {
     26     ParentDir,
     27     Directory(String),
     28     Track(usize),
     29 }
     30 
     31 impl DirBrowserPane {
     32     pub fn new(music_dir: PathBuf) -> Self {
     33         Self {
     34             current_dir: music_dir,
     35             entries: Vec::new(),
     36             selected: 0,
     37             scroll_offset: 0,
     38             hover_row: None,
     39         }
     40     }
     41 
     42     pub fn refresh(&mut self, app: &App) {
     43         self.entries.clear();
     44 
     45         if self.current_dir != app.music_dir {
     46             self.entries.push(DirEntry::ParentDir);
     47         }
     48 
     49         let (subdirs, tracks) = app.library.get_directory_entries(&self.current_dir);
     50         for dir in subdirs {
     51             self.entries.push(DirEntry::Directory(dir));
     52         }
     53         for track_idx in tracks {
     54             self.entries.push(DirEntry::Track(track_idx));
     55         }
     56     }
     57 }
     58 
     59 impl Pane for DirBrowserPane {
     60     fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
     61         let count = self.entries.len();
     62         let border_color = if focused {
     63             theme.border_focused
     64         } else {
     65             theme.border_unfocused
     66         };
     67 
     68         let block = Block::default()
     69             .borders(Borders::ALL)
     70             .border_style(Style::default().fg(border_color))
     71             .title_style(Style::default().fg(if focused {
     72                 theme.border_focused
     73             } else {
     74                 theme.fg
     75             }));
     76 
     77         let inner = block.inner(area);
     78         let inner_height = inner.height as usize;
     79 
     80         // Auto-scroll
     81         if count > 0 {
     82             if self.selected < self.scroll_offset {
     83                 self.scroll_offset = self.selected;
     84             }
     85             if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
     86                 self.scroll_offset = self.selected - inner_height + 1;
     87             }
     88             // Keep the viewport full: the scroll handlers clamp to count - 1,
     89             // which lets the list scroll until one row sits above a blank screen.
     90             self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
     91         }
     92 
     93         let has_scrollbar = count > inner_height;
     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> = self
    100             .entries
    101             .iter()
    102             .enumerate()
    103             .skip(self.scroll_offset)
    104             .take(inner_height)
    105             .map(|(i, entry)| {
    106                 let is_selected = i == self.selected;
    107                 let is_hovered = self.hover_row == Some(i);
    108 
    109                 match entry {
    110                     DirEntry::ParentDir => {
    111                         if is_selected && focused {
    112                             ListItem::new(Line::from(vec![
    113                                 Span::styled("  \u{F07B} ", highlight),
    114                                 Span::styled("..", highlight),
    115                             ]))
    116                         } else {
    117                             let bg = if is_hovered { HOVER_BG } else { Color::Reset };
    118                             ListItem::new(Line::from(vec![
    119                                 Span::styled("  \u{F07B} ", Style::default().fg(Color::Yellow).bg(bg)),
    120                                 Span::styled("..", Style::default().fg(theme.fg).bg(bg)),
    121                             ]))
    122                         }
    123                     }
    124                     DirEntry::Directory(name) => {
    125                         if is_selected && focused {
    126                             ListItem::new(Line::from(vec![
    127                                 Span::styled("  \u{F07B} ", highlight),
    128                                 Span::styled(format!("{}/", name), highlight),
    129                             ]))
    130                         } else {
    131                             let bg = if is_hovered { HOVER_BG } else { Color::Reset };
    132                             ListItem::new(Line::from(vec![
    133                                 Span::styled("  \u{F07B} ", Style::default().fg(Color::Green).bg(bg)),
    134                                 Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(bg)),
    135                             ]))
    136                         }
    137                     }
    138                     DirEntry::Track(idx) => {
    139                         let t = &app.library.tracks[*idx];
    140                         if is_selected && focused {
    141                             ListItem::new(Line::from(vec![
    142                                 Span::styled("  \u{F001} ", highlight),
    143                                 Span::styled(&t.title, highlight),
    144                             ]))
    145                         } else {
    146                             let bg = if is_hovered { HOVER_BG } else { Color::Reset };
    147                             ListItem::new(Line::from(vec![
    148                                 Span::styled("  \u{F001} ", Style::default().fg(Color::Cyan).bg(bg)),
    149                                 Span::styled(&t.title, Style::default().fg(theme.fg).bg(bg)),
    150                             ]))
    151                         }
    152                     }
    153                 }
    154             })
    155             .collect();
    156 
    157         let list = List::new(items).block(block);
    158         frame.render_widget(list, area);
    159 
    160         if has_scrollbar {
    161             let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
    162                 .begin_symbol(None)
    163                 .end_symbol(None);
    164             let mut scrollbar_state = ScrollbarState::new(count)
    165                 .position(self.scroll_offset);
    166             frame.render_stateful_widget(
    167                 scrollbar,
    168                 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
    169                 &mut scrollbar_state,
    170             );
    171         }
    172     }
    173 
    174     fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
    175         let count = self.entries.len();
    176         if count == 0 {
    177             return None;
    178         }
    179 
    180         match key.code {
    181             KeyCode::Char('j') | KeyCode::Down => {
    182                 if self.selected < count - 1 {
    183                     self.selected += 1;
    184                 }
    185                 None
    186             }
    187             KeyCode::Char('k') | KeyCode::Up => {
    188                 if self.selected > 0 {
    189                     self.selected -= 1;
    190                 }
    191                 None
    192             }
    193             KeyCode::Enter => {
    194                 match &self.entries[self.selected] {
    195                     DirEntry::ParentDir => {
    196                         if let Some(parent) = self.current_dir.parent() {
    197                             self.current_dir = parent.to_path_buf();
    198                             self.selected = 0;
    199                             self.scroll_offset = 0;
    200                             self.refresh(app);
    201                         }
    202                     }
    203                     DirEntry::Directory(name) => {
    204                         self.current_dir = self.current_dir.join(name);
    205                         self.selected = 0;
    206                         self.scroll_offset = 0;
    207                         self.refresh(app);
    208                     }
    209                     DirEntry::Track(idx) => {
    210                         return Some(AppAction::AddToQueue(vec![*idx]));
    211                     }
    212                 }
    213                 None
    214             }
    215             KeyCode::Backspace => {
    216                 if let Some(parent) = self.current_dir.parent() {
    217                     self.current_dir = parent.to_path_buf();
    218                     self.selected = 0;
    219                     self.scroll_offset = 0;
    220                     self.refresh(app);
    221                 }
    222                 None
    223             }
    224             KeyCode::Home | KeyCode::Char('g') => {
    225                 self.selected = 0;
    226                 self.scroll_offset = 0;
    227                 None
    228             }
    229             KeyCode::End | KeyCode::Char('G') => {
    230                 self.selected = count.saturating_sub(1);
    231                 None
    232             }
    233             _ => None,
    234         }
    235     }
    236 
    237     fn handle_mouse(&mut self, event: MouseEvent, area: Rect, _app: &App) -> Option<AppAction> {
    238         let block = Block::default().borders(Borders::ALL);
    239         let inner = block.inner(area);
    240         let count = self.entries.len();
    241 
    242         match event.kind {
    243             MouseEventKind::Down(MouseButton::Left) => {
    244                 if event.column >= inner.x
    245                     && event.column < inner.x + inner.width
    246                     && event.row >= inner.y
    247                     && event.row < inner.y + inner.height
    248                 {
    249                     let clicked = self.scroll_offset + (event.row - inner.y) as usize;
    250                     if clicked < count {
    251                         self.selected = clicked;
    252                     }
    253                 }
    254                 None
    255             }
    256             MouseEventKind::ScrollDown => self.handle_scroll(false, _app),
    257             MouseEventKind::ScrollUp => self.handle_scroll(true, _app),
    258             _ => None,
    259         }
    260     }
    261 
    262     fn handle_scroll(&mut self, up: bool, _app: &App) -> Option<AppAction> {
    263         let count = self.entries.len();
    264         if count == 0 {
    265             return None;
    266         }
    267         if up {
    268             self.scroll_offset = self.scroll_offset.saturating_sub(3);
    269             self.selected = self.selected.saturating_sub(3);
    270         } else {
    271             self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
    272             self.selected = (self.selected + 3).min(count.saturating_sub(1));
    273         }
    274         None
    275     }
    276 }