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/mod.rs (11839B)

      1 pub mod layout;
      2 pub mod pane;
      3 pub mod panes;
      4 pub mod text;
      5 pub mod theme;
      6 pub mod widgets;
      7 
      8 use ratatui::Frame;
      9 use widgets::{about_modal, help_modal, playlist_modal, search_modal};
     10 use widgets::playlist_modal::PlaylistModalMode;
     11 
     12 use crate::app::App;
     13 use crate::app::state::{FocusedPane, InfoView, Tab};
     14 use layout::LayoutAreas;
     15 use pane::Pane;
     16 use panes::dir_browser_pane::DirBrowserPane;
     17 use panes::library_pane::LibraryPane;
     18 use panes::list_pane::{self, ListPane};
     19 use panes::track_info_pane::TrackInfoPane;
     20 use panes::queue_pane::QueuePane;
     21 
     22 /// Splash timeline, in seconds: fade in, hold, then fade out until the end.
     23 /// Always played in full — the scan runs behind it and does not cut it short.
     24 pub const SPLASH_FADE_IN: f32 = 0.5;
     25 pub const SPLASH_FADE_OUT_AT: f32 = 1.5;
     26 pub const SPLASH_END: f32 = 2.0;
     27 
     28 /// Index into `Ui::list_panes` for each list tab. Queue and Directories have
     29 /// their own pane types and never reach here.
     30 pub fn list_slot(tab: Tab) -> usize {
     31     match tab {
     32         Tab::Artists => 0,
     33         Tab::Albums => 1,
     34         Tab::Genre => 2,
     35         Tab::Format => 3,
     36         _ => 4, // Playlists
     37     }
     38 }
     39 use theme::Theme;
     40 use widgets::info_pane;
     41 use widgets::progress_bar;
     42 use widgets::status_bar;
     43 use widgets::tab_bar;
     44 
     45 pub struct Ui {
     46     pub theme: Theme,
     47     pub library_pane: LibraryPane,
     48     pub dir_browser_pane: DirBrowserPane,
     49     pub queue_pane: QueuePane,
     50     /// One per list tab, keyed by Tab; each keeps its own selection and scroll.
     51     pub list_panes: [ListPane; 5],
     52     pub track_info_pane: TrackInfoPane,
     53     pub last_click: Option<(std::time::Instant, u16, u16)>,
     54     /// Last known mouse position (column, row) for hover tracking
     55     pub mouse_pos: Option<(u16, u16)>,
     56     /// Tab index currently hovered by mouse
     57     pub hovered_tab: Option<usize>,
     58     /// Pane width percentages [Library, Playlist, Lyrics], sum = 100
     59     pub pane_widths: [u16; 3],
     60     /// Border being dragged: 0 = lib|playlist, 1 = playlist|lyrics, 2 = info|lyrics (horizontal), None = not dragging
     61     pub dragging_border: Option<u8>,
     62     /// Right column split: info pane height percentage (top), lyrics gets the rest
     63     pub right_split: u16,
     64     /// Help modal visible
     65     pub show_help_modal: bool,
     66     /// Search modal visible
     67     pub show_search_modal: bool,
     68     /// Search modal input text
     69     pub search_modal_input: String,
     70     /// Search modal filtered results (track indices)
     71     pub search_modal_results: Vec<usize>,
     72     /// Search modal selected result index
     73     pub search_modal_selected: usize,
     74     /// Search modal scroll offset
     75     pub search_modal_scroll: usize,
     76     /// Search modal visible result row count (set during render)
     77     pub search_modal_result_height: usize,
     78     /// Search modal result area rect (set during render, for mouse hit-testing)
     79     pub search_modal_result_area: ratatui::layout::Rect,
     80     /// Search modal hovered row index
     81     pub search_modal_hover_row: Option<usize>,
     82     /// Playlist modal visible ("b" key)
     83     pub show_playlist_modal: bool,
     84     /// Playlist modal selected index
     85     pub playlist_modal_selected: usize,
     86     /// Playlist modal mode (List / Create / Rename)
     87     pub playlist_modal_mode: PlaylistModalMode,
     88     /// Playlist modal text input (for create/rename)
     89     pub playlist_modal_input: String,
     90     /// About modal visible
     91     pub show_about_modal: bool,
     92     /// Splash screen visible at startup
     93     pub show_splash: bool,
     94     /// Splash screen start time
     95     pub splash_start: Option<std::time::Instant>,
     96     /// Current info pane view (Clock / AlbumArt / TrackInfo)
     97     pub info_view: InfoView,
     98     /// Album art pixel cache
     99     pub album_art_cache: info_pane::AlbumArtCache,
    100 }
    101 
    102 impl Ui {
    103     pub fn new(music_dir: std::path::PathBuf, picker: ratatui_image::picker::Picker) -> Self {
    104         Self {
    105             theme: Theme::default(),
    106             library_pane: LibraryPane::new(),
    107             dir_browser_pane: DirBrowserPane::new(music_dir),
    108             queue_pane: QueuePane::new(),
    109             list_panes: Default::default(),
    110             track_info_pane: TrackInfoPane::new(),
    111             last_click: None,
    112             mouse_pos: None,
    113             hovered_tab: None,
    114             pane_widths: [20, 60, 20],
    115             dragging_border: None,
    116             right_split: 50,
    117             show_help_modal: false,
    118             show_search_modal: false,
    119             search_modal_input: String::new(),
    120             search_modal_results: Vec::new(),
    121             search_modal_selected: 0,
    122             search_modal_scroll: 0,
    123             search_modal_result_height: 10,
    124             search_modal_result_area: ratatui::layout::Rect::default(),
    125             search_modal_hover_row: None,
    126             show_playlist_modal: false,
    127             playlist_modal_selected: 0,
    128             playlist_modal_mode: PlaylistModalMode::List,
    129             playlist_modal_input: String::new(),
    130             show_about_modal: false,
    131             show_splash: true,
    132             splash_start: Some(std::time::Instant::now()),
    133             info_view: InfoView::Clock,
    134             album_art_cache: info_pane::AlbumArtCache::new(picker),
    135         }
    136     }
    137 
    138     /// Drop the splash immediately. Only a keypress does this; left alone the
    139     /// timeline always runs in full.
    140     pub fn dismiss_splash(&mut self) {
    141         self.show_splash = false;
    142         self.splash_start = None;
    143     }
    144 
    145     pub fn render(&mut self, frame: &mut Frame, app: &App) {
    146         if self.show_splash {
    147             let elapsed = self.splash_start
    148                 .map(|s| s.elapsed().as_secs_f32())
    149                 .unwrap_or(SPLASH_END);
    150             let opacity = if elapsed < SPLASH_FADE_IN {
    151                 elapsed / SPLASH_FADE_IN
    152             } else if elapsed < SPLASH_FADE_OUT_AT {
    153                 1.0
    154             } else {
    155                 (1.0 - (elapsed - SPLASH_FADE_OUT_AT) / (SPLASH_END - SPLASH_FADE_OUT_AT)).max(0.0)
    156             };
    157             about_modal::render_splash_screen(frame, frame.area(), &self.theme, opacity);
    158             return;
    159         }
    160 
    161         let areas = LayoutAreas::compute(frame.area(), self.pane_widths, self.right_split);
    162 
    163         // Status bar
    164         status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme);
    165 
    166         // Tab bar
    167         tab_bar::render_tab_bar(frame, areas.tab_bar, app.tab, self.hovered_tab, &self.theme);
    168 
    169         // Left pane (varies by tab)
    170         let lib_focused = app.focus == FocusedPane::Library;
    171         match app.tab {
    172             Tab::Queue => self.library_pane.render(frame, areas.library, lib_focused, app, &self.theme),
    173             Tab::Directories => self.dir_browser_pane.render(frame, areas.library, lib_focused, app, &self.theme),
    174             tab => {
    175                 let rows = list_pane::rows_for(app, tab);
    176                 let theme = &self.theme;
    177                 self.list_panes[list_slot(tab)]
    178                     .render(frame, areas.library, lib_focused, theme, &rows);
    179             }
    180         }
    181 
    182         // Center pane (Queue)
    183         let playlist_focused = app.focus == FocusedPane::Playlist;
    184         self.queue_pane.render(frame, areas.playlist, playlist_focused, app, &self.theme);
    185 
    186         // Right pane top (Info)
    187         info_pane::render_info_pane(frame, areas.info_pane, app, &self.theme, self.info_view, &mut self.album_art_cache);
    188 
    189         // Right pane bottom (Lyrics)
    190         let lyrics_focused = app.focus == FocusedPane::Lyrics;
    191         self.track_info_pane.render(frame, areas.lyrics, lyrics_focused, app, &self.theme);
    192 
    193         // Progress bar
    194         progress_bar::render_progress_bar(frame, areas.progress_bar, app, &self.theme);
    195 
    196 
    197         // Modal overlays (rendered last, on top of everything)
    198         if self.show_search_modal {
    199             let (rh, ra) = search_modal::render_search_modal(
    200                 frame,
    201                 frame.area(),
    202                 &self.search_modal_input,
    203                 &self.search_modal_results,
    204                 self.search_modal_selected,
    205                 self.search_modal_scroll,
    206                 self.search_modal_hover_row,
    207                 app,
    208                 &self.theme,
    209             );
    210             self.search_modal_result_height = rh;
    211             self.search_modal_result_area = ra;
    212         }
    213 
    214         if self.show_help_modal {
    215             help_modal::render_help_modal(frame, frame.area(), &self.theme);
    216         }
    217 
    218         if self.show_about_modal {
    219             about_modal::render_about_modal(frame, frame.area(), &self.theme);
    220         }
    221 
    222         if self.show_playlist_modal {
    223             playlist_modal::render_playlist_modal(
    224                 frame,
    225                 frame.area(),
    226                 self.playlist_modal_selected,
    227                 &self.playlist_modal_mode,
    228                 &self.playlist_modal_input,
    229                 app,
    230                 &self.theme,
    231             );
    232         }
    233     }
    234 
    235     /// The list pane backing `tab`. Panics only for Queue/Directories, which
    236     /// have their own pane types — `list_slot` maps those to Playlists, so
    237     /// callers must check the tab first.
    238     pub fn list_pane_mut(&mut self, tab: Tab) -> &mut ListPane {
    239         &mut self.list_panes[list_slot(tab)]
    240     }
    241 
    242     /// Scroll offset of whichever pane the library column is currently showing.
    243     pub fn library_pane_scroll(&self, app: &App) -> usize {
    244         match app.tab {
    245             Tab::Queue => self.library_pane.scroll_offset,
    246             Tab::Directories => self.dir_browser_pane.scroll_offset,
    247             tab => self.list_panes[list_slot(tab)].scroll_offset,
    248         }
    249     }
    250 
    251     pub fn refresh_dir_browser(&mut self, app: &App) {
    252         self.dir_browser_pane.refresh(app);
    253     }
    254 
    255     /// Re-run the open search against the new library. `search_modal_results`
    256     /// holds raw library indices and lives on `Ui`, so `App::replace_library`
    257     /// never sees it — stale entries would panic the next render.
    258     pub fn refresh_search_results(&mut self, app: &App) {
    259         if !self.show_search_modal {
    260             self.search_modal_results.clear();
    261             return;
    262         }
    263         self.search_modal_results = app.library.search(&self.search_modal_input);
    264         self.search_modal_selected = 0;
    265         self.search_modal_scroll = 0;
    266         self.search_modal_hover_row = None;
    267     }
    268 
    269     pub fn clamp_selections(&mut self, app: &App) {
    270         for tab in [Tab::Artists, Tab::Albums, Tab::Genre, Tab::Format, Tab::Playlists] {
    271             let len = list_pane::row_count(app, tab);
    272             let pane = &mut self.list_panes[list_slot(tab)];
    273             pane.selected = pane.selected.min(len.saturating_sub(1));
    274             pane.scroll_offset = pane.scroll_offset.min(len.saturating_sub(1));
    275         }
    276 
    277         // Reset library/dir browser to top since track indices changed
    278         self.library_pane.selected = 0;
    279         self.library_pane.scroll_offset = 0;
    280         self.dir_browser_pane.selected = 0;
    281         self.dir_browser_pane.scroll_offset = 0;
    282 
    283         // Clamp queue pane scroll
    284         let queue_len = app.queue.tracks.len();
    285         if queue_len == 0 {
    286             self.queue_pane.scroll_offset = 0;
    287         } else {
    288             self.queue_pane.scroll_offset = self.queue_pane.scroll_offset.min(queue_len - 1);
    289         }
    290     }
    291 }
    292 
    293 #[cfg(test)]
    294 mod tests {
    295     use super::*;
    296 
    297     fn ui() -> Ui {
    298         Ui::new(
    299             std::path::PathBuf::from("."),
    300             ratatui_image::picker::Picker::from_fontsize((8, 16)),
    301         )
    302     }
    303 
    304     #[test]
    305     fn a_keypress_drops_the_splash_immediately() {
    306         let mut ui = ui();
    307         assert!(ui.show_splash);
    308 
    309         ui.dismiss_splash();
    310 
    311         // No trailing fade: the hint says the key skips to the app.
    312         assert!(!ui.show_splash);
    313         assert!(ui.splash_start.is_none());
    314     }
    315 
    316     #[test]
    317     fn dismissing_twice_is_harmless() {
    318         let mut ui = ui();
    319         ui.dismiss_splash();
    320         ui.dismiss_splash();
    321         assert!(!ui.show_splash);
    322     }
    323 }