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/app/handler.rs (38775B)

      1 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
      2 use ratatui::layout::Rect;
      3 use std::time::{Duration, Instant};
      4 
      5 use crate::app::state::{FocusedPane, Tab};
      6 use crate::app::{App, AppAction};
      7 use crate::ui::layout::LayoutAreas;
      8 use crate::ui::pane::Pane;
      9 use crate::ui::panes::list_pane;
     10 use crate::ui::widgets::{progress_bar, tab_bar};
     11 use crate::ui::widgets::playlist_modal::PlaylistModalMode;
     12 use crate::ui::Ui;
     13 
     14 pub const MIN_PANE_WIDTH: u16 = 10;
     15 
     16 /// Split the 100% budget left over after `fixed` between the two panes flanking
     17 /// a dragged border, placing the boundary at `boundary_pct`. Returns None when
     18 /// there is not enough room for both.
     19 ///
     20 /// Every arm of the drag math used to do this inline with bare `u16`
     21 /// subtraction, which underflows as soon as the pointer leaves the dashboard —
     22 /// dragging the playlist|lyrics border to the left edge panicked outright.
     23 fn split_widths(boundary_pct: u16, fixed: u16, min_w: u16) -> Option<(u16, u16)> {
     24     let budget = 100u16.checked_sub(fixed)?;
     25     if budget < min_w * 2 {
     26         return None;
     27     }
     28     let first = boundary_pct.clamp(min_w, budget - min_w);
     29     Some((first, budget - first))
     30 }
     31 
     32 /// True for a plain character keystroke, i.e. one that should be typed into a
     33 /// text field. Without the check, Ctrl+C inside the search box inserted a
     34 /// literal "c" instead of quitting.
     35 fn is_text_input(key: KeyEvent) -> bool {
     36     !key.modifiers.contains(KeyModifiers::CONTROL)
     37 }
     38 
     39 pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction> {
     40     let mut actions = Vec::new();
     41 
     42     // Quit works from anywhere, including with a modal open.
     43     if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
     44         actions.push(AppAction::Quit);
     45         return actions;
     46     }
     47 
     48     // About modal: Esc to close, g/s to open URLs
     49     if ui.show_about_modal {
     50         match key.code {
     51             KeyCode::Esc | KeyCode::Char('q') => {
     52                 ui.show_about_modal = false;
     53             }
     54             KeyCode::Char('g') => {
     55                 let _ = std::process::Command::new("xdg-open")
     56                     .arg("https://github.com/sqzer-x/ommp")
     57                     .stdin(std::process::Stdio::null())
     58                     .stdout(std::process::Stdio::null())
     59                     .stderr(std::process::Stdio::null())
     60                     .spawn();
     61             }
     62             KeyCode::Char('s') => {
     63                 let _ = std::process::Command::new("xdg-open")
     64                     .arg("https://github.com/sponsors/sqzer-x")
     65                     .stdin(std::process::Stdio::null())
     66                     .stdout(std::process::Stdio::null())
     67                     .stderr(std::process::Stdio::null())
     68                     .spawn();
     69             }
     70             _ => {}
     71         }
     72         return actions;
     73     }
     74 
     75     // Help modal: Esc to close
     76     if ui.show_help_modal {
     77         if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
     78             ui.show_help_modal = false;
     79         }
     80         return actions;
     81     }
     82 
     83     // Playlist modal ("b" key) — list, create, rename modes
     84     if ui.show_playlist_modal {
     85         match ui.playlist_modal_mode {
     86             PlaylistModalMode::Create | PlaylistModalMode::Rename => {
     87                 match key.code {
     88                     KeyCode::Esc => {
     89                         ui.playlist_modal_mode = PlaylistModalMode::List;
     90                         ui.playlist_modal_input.clear();
     91                     }
     92                     KeyCode::Enter => {
     93                         let name = ui.playlist_modal_input.trim().to_string();
     94                         if !name.is_empty() {
     95                             if ui.playlist_modal_mode == PlaylistModalMode::Create {
     96                                 actions.push(AppAction::CreatePlaylist(name));
     97                             } else {
     98                                 actions.push(AppAction::RenamePlaylist {
     99                                     idx: ui.playlist_modal_selected,
    100                                     name,
    101                                 });
    102                             }
    103                         }
    104                         ui.playlist_modal_mode = PlaylistModalMode::List;
    105                         ui.playlist_modal_input.clear();
    106                     }
    107                     KeyCode::Backspace => {
    108                         ui.playlist_modal_input.pop();
    109                     }
    110                     KeyCode::Char(c) if is_text_input(key) => {
    111                         ui.playlist_modal_input.push(c);
    112                     }
    113                     _ => {}
    114                 }
    115             }
    116             PlaylistModalMode::List => {
    117                 match key.code {
    118                     KeyCode::Esc => {
    119                         ui.show_playlist_modal = false;
    120                         ui.playlist_modal_selected = 0;
    121                     }
    122                     KeyCode::Up | KeyCode::Char('k') => {
    123                         if ui.playlist_modal_selected > 0 {
    124                             ui.playlist_modal_selected -= 1;
    125                         }
    126                     }
    127                     KeyCode::Down | KeyCode::Char('j') => {
    128                         if !app.playlists.is_empty()
    129                             && ui.playlist_modal_selected < app.playlists.len() - 1
    130                         {
    131                             ui.playlist_modal_selected += 1;
    132                         }
    133                     }
    134                     KeyCode::Enter => {
    135                         // Toggle track in selected playlist
    136                         if let Some(track_idx) = app.queue.current_index
    137                             .and_then(|qi| app.queue.tracks.get(qi).copied())
    138                         {
    139                             let pl_idx = ui.playlist_modal_selected;
    140                             if pl_idx < app.playlists.len() {
    141                                 if app.playlists[pl_idx].tracks.contains(&track_idx) {
    142                                     actions.push(AppAction::RemoveFromPlaylist {
    143                                         playlist_idx: pl_idx,
    144                                         track_idx,
    145                                     });
    146                                 } else {
    147                                     actions.push(AppAction::AddToPlaylist {
    148                                         playlist_idx: pl_idx,
    149                                         track_idx,
    150                                     });
    151                                 }
    152                             }
    153                         }
    154                     }
    155                     KeyCode::Char('a') => {
    156                         ui.playlist_modal_mode = PlaylistModalMode::Create;
    157                         ui.playlist_modal_input.clear();
    158                     }
    159                     KeyCode::Char('d') => {
    160                         if !app.playlists.is_empty() {
    161                             actions.push(AppAction::DeletePlaylist(ui.playlist_modal_selected));
    162                             if ui.playlist_modal_selected > 0
    163                                 && ui.playlist_modal_selected >= app.playlists.len() - 1
    164                             {
    165                                 ui.playlist_modal_selected -= 1;
    166                             }
    167                         }
    168                     }
    169                     KeyCode::Char('r')
    170                         if !app.playlists.is_empty() => {
    171                             ui.playlist_modal_mode = PlaylistModalMode::Rename;
    172                             ui.playlist_modal_input =
    173                                 app.playlists[ui.playlist_modal_selected].name.clone();
    174                         }
    175                     _ => {}
    176                 }
    177             }
    178         }
    179         return actions;
    180     }
    181 
    182     // Search modal: input handling
    183     if ui.show_search_modal {
    184         match key.code {
    185             KeyCode::Esc => {
    186                 ui.show_search_modal = false;
    187                 ui.search_modal_input.clear();
    188                 ui.search_modal_results.clear();
    189                 ui.search_modal_selected = 0;
    190                 ui.search_modal_scroll = 0;
    191             }
    192             KeyCode::Enter => {
    193                 if !ui.search_modal_results.is_empty() {
    194                     let track_idx = ui.search_modal_results[ui.search_modal_selected];
    195                     actions.push(AppAction::AddToQueue(vec![track_idx]));
    196                     ui.show_search_modal = false;
    197                     ui.search_modal_input.clear();
    198                     ui.search_modal_results.clear();
    199                     ui.search_modal_selected = 0;
    200                     ui.search_modal_scroll = 0;
    201                 }
    202             }
    203             KeyCode::Up | KeyCode::BackTab => {
    204                 if ui.search_modal_selected > 0 {
    205                     ui.search_modal_selected -= 1;
    206                     if ui.search_modal_selected < ui.search_modal_scroll {
    207                         ui.search_modal_scroll = ui.search_modal_selected;
    208                     }
    209                 }
    210             }
    211             KeyCode::Down | KeyCode::Tab => {
    212                 if !ui.search_modal_results.is_empty()
    213                     && ui.search_modal_selected < ui.search_modal_results.len() - 1
    214                 {
    215                     ui.search_modal_selected += 1;
    216                     let h = ui.search_modal_result_height;
    217                     if h > 0 && ui.search_modal_selected >= ui.search_modal_scroll + h {
    218                         ui.search_modal_scroll = ui.search_modal_selected - h + 1;
    219                     }
    220                 }
    221             }
    222             KeyCode::Backspace => {
    223                 ui.search_modal_input.pop();
    224                 ui.search_modal_results = app.library.search(&ui.search_modal_input);
    225                 ui.search_modal_selected = 0;
    226                 ui.search_modal_scroll = 0;
    227             }
    228             KeyCode::Char(c) if is_text_input(key) => {
    229                 ui.search_modal_input.push(c);
    230                 ui.search_modal_results = app.library.search(&ui.search_modal_input);
    231                 ui.search_modal_selected = 0;
    232                 ui.search_modal_scroll = 0;
    233             }
    234             _ => {}
    235         }
    236         return actions;
    237     }
    238 
    239     // Ctrl-modified commands. These must come before the unmodified bindings
    240     // below, which match on any modifier — Ctrl+S would otherwise be swallowed
    241     // by the plain `s` shuffle arm.
    242     //
    243     // Ctrl+I and Ctrl+M are deliberately absent: terminals send them as the same
    244     // bytes as Tab (0x09) and Enter (0x0D), so they cannot be told apart.
    245     if key.modifiers.contains(KeyModifiers::CONTROL) {
    246         match key.code {
    247             KeyCode::Char('s') => ui.show_search_modal = true,
    248             KeyCode::Char('h') => ui.show_help_modal = true,
    249             KeyCode::Char('a') => ui.show_about_modal = true,
    250             KeyCode::Char('l') => actions.push(AppAction::LibrarySync),
    251             _ => {}
    252         }
    253         return actions;
    254     }
    255 
    256     // Global keybindings first
    257     match (key.modifiers, key.code) {
    258         (_, KeyCode::Char('q')) => {
    259             actions.push(AppAction::Quit);
    260             return actions;
    261         }
    262         (_, KeyCode::Char(' ')) => {
    263             actions.push(AppAction::PauseResume);
    264             return actions;
    265         }
    266         (_, KeyCode::Char('n')) => {
    267             actions.push(AppAction::NextTrack);
    268             return actions;
    269         }
    270         (KeyModifiers::SHIFT, KeyCode::Char('N')) => {
    271             actions.push(AppAction::PrevTrack);
    272             return actions;
    273         }
    274         (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
    275             actions.push(AppAction::VolumeUp);
    276             return actions;
    277         }
    278         (_, KeyCode::Char('-')) => {
    279             actions.push(AppAction::VolumeDown);
    280             return actions;
    281         }
    282         (_, KeyCode::Right) => {
    283             actions.push(AppAction::SeekForward);
    284             return actions;
    285         }
    286         (_, KeyCode::Left) => {
    287             actions.push(AppAction::SeekBackward);
    288             return actions;
    289         }
    290         (_, KeyCode::Char('s')) => {
    291             actions.push(AppAction::ToggleShuffle);
    292             return actions;
    293         }
    294         (_, KeyCode::Char('r')) => {
    295             actions.push(AppAction::CycleRepeat);
    296             return actions;
    297         }
    298         (_, KeyCode::Char('b')) => {
    299             // Only open if a track is playing
    300             if app.queue.current_index.is_some() {
    301                 ui.show_playlist_modal = true;
    302                 ui.playlist_modal_selected = 0;
    303             }
    304             return actions;
    305         }
    306         (_, KeyCode::Char('p')) => {
    307             ui.info_view = ui.info_view.next();
    308             return actions;
    309         }
    310         (_, KeyCode::Tab) => {
    311             actions.push(AppAction::FocusNext);
    312             return actions;
    313         }
    314         (KeyModifiers::SHIFT, KeyCode::BackTab) => {
    315             actions.push(AppAction::FocusPrev);
    316             return actions;
    317         }
    318         // Tab switching with number keys
    319         (_, KeyCode::Char('1')) => {
    320             actions.push(AppAction::SwitchTab(Tab::Queue));
    321             return actions;
    322         }
    323         (_, KeyCode::Char('2')) => {
    324             actions.push(AppAction::SwitchTab(Tab::Directories));
    325             return actions;
    326         }
    327         (_, KeyCode::Char('3')) => {
    328             actions.push(AppAction::SwitchTab(Tab::Artists));
    329             return actions;
    330         }
    331         (_, KeyCode::Char('4')) => {
    332             actions.push(AppAction::SwitchTab(Tab::Albums));
    333             return actions;
    334         }
    335         (_, KeyCode::Char('5')) => {
    336             actions.push(AppAction::SwitchTab(Tab::Genre));
    337             return actions;
    338         }
    339         (_, KeyCode::Char('6')) => {
    340             actions.push(AppAction::SwitchTab(Tab::Format));
    341             return actions;
    342         }
    343         (_, KeyCode::Char('7')) => {
    344             actions.push(AppAction::SwitchTab(Tab::Playlists));
    345             return actions;
    346         }
    347         // h/l for pane focus
    348         (_, KeyCode::Char('h')) => {
    349             actions.push(AppAction::FocusPrev);
    350             return actions;
    351         }
    352         (_, KeyCode::Char('l')) => {
    353             actions.push(AppAction::FocusNext);
    354             return actions;
    355         }
    356         _ => {}
    357     }
    358 
    359     // Route to focused pane
    360     let action = match app.focus {
    361         FocusedPane::Library => library_key(key, app, ui),
    362         FocusedPane::Playlist => {
    363             match key.code {
    364                 KeyCode::Char('j') | KeyCode::Down => None,
    365                 KeyCode::Char('k') | KeyCode::Up => None,
    366                 _ => ui.queue_pane.handle_key(key, app),
    367             }
    368         }
    369         FocusedPane::Lyrics => ui.track_info_pane.handle_key(key, app),
    370     };
    371 
    372     // Auto-focus to Queue pane when adding tracks from Library
    373     if let Some(ref a) = action {
    374         if matches!(a, AppAction::AddToQueue(_)) && app.focus == FocusedPane::Library {
    375             actions.push(AppAction::FocusPane(FocusedPane::Playlist));
    376         }
    377     }
    378 
    379     if let Some(a) = action {
    380         actions.push(a);
    381     }
    382 
    383     actions
    384 }
    385 
    386 pub fn handle_mouse_event(
    387     mouse: MouseEvent,
    388     app: &App,
    389     ui: &mut Ui,
    390     terminal_area: ratatui::layout::Rect,
    391 ) -> Vec<AppAction> {
    392     let mut actions = Vec::new();
    393     let areas = LayoutAreas::compute(terminal_area, ui.pane_widths, ui.right_split);
    394 
    395     let x = mouse.column;
    396     let y = mouse.row;
    397 
    398     // Store mouse position for hover tracking across all event types
    399     ui.mouse_pos = Some((x, y));
    400 
    401     // Search modal mouse handling
    402     if ui.show_search_modal {
    403         let ra = ui.search_modal_result_area;
    404         let in_results = x >= ra.x && x < ra.x + ra.width
    405             && y >= ra.y && y < ra.y + ra.height;
    406 
    407         // Hover tracking
    408         if in_results && !ui.search_modal_results.is_empty() {
    409             let row = ui.search_modal_scroll + (y - ra.y) as usize;
    410             if row < ui.search_modal_results.len() {
    411                 ui.search_modal_hover_row = Some(row);
    412             } else {
    413                 ui.search_modal_hover_row = None;
    414             }
    415         } else {
    416             ui.search_modal_hover_row = None;
    417         }
    418 
    419         match mouse.kind {
    420             MouseEventKind::Down(MouseButton::Left) => {
    421                 if in_results && !ui.search_modal_results.is_empty() {
    422                     let clicked = ui.search_modal_scroll + (y - ra.y) as usize;
    423                     if clicked < ui.search_modal_results.len() {
    424                         let is_double = is_double_click(ui.last_click, x, y);
    425                         ui.last_click = if is_double {
    426                             None
    427                         } else {
    428                             Some((Instant::now(), x, y))
    429                         };
    430 
    431                         if is_double {
    432                             // Double-click: select and confirm (add to queue)
    433                             let track_idx = ui.search_modal_results[clicked];
    434                             actions.push(AppAction::AddToQueue(vec![track_idx]));
    435                             ui.show_search_modal = false;
    436                             ui.search_modal_input.clear();
    437                             ui.search_modal_results.clear();
    438                             ui.search_modal_selected = 0;
    439                             ui.search_modal_scroll = 0;
    440                         } else {
    441                             // Single click: select
    442                             ui.search_modal_selected = clicked;
    443                         }
    444                     }
    445                 }
    446             }
    447             MouseEventKind::ScrollDown => {
    448                 if in_results && !ui.search_modal_results.is_empty() {
    449                     let max_scroll = ui.search_modal_results.len()
    450                         .saturating_sub(ui.search_modal_result_height);
    451                     if ui.search_modal_scroll < max_scroll {
    452                         ui.search_modal_scroll += 1;
    453                     }
    454                 }
    455             }
    456             MouseEventKind::ScrollUp
    457                 if in_results && ui.search_modal_scroll > 0 => {
    458                     ui.search_modal_scroll -= 1;
    459                 }
    460             _ => {}
    461         }
    462         return actions;
    463     }
    464 
    465     // Block all mouse events when any other modal is open
    466     if ui.show_about_modal || ui.show_help_modal || ui.show_playlist_modal {
    467         return actions;
    468     }
    469 
    470     // Determine which pane the mouse is in
    471     let hit = PaneHit::at(&areas, x, y);
    472     let in_library = hit.library;
    473     let in_playlist = hit.playlist;
    474     let in_lyrics = hit.lyrics;
    475 
    476     // --- Hover tracking (runs on every mouse event including Moved) ---
    477     update_hover(ui, &areas, app, x, y, in_library, in_playlist);
    478     update_tab_hover(ui, &areas, x, y);
    479 
    480     // Focus follows the click, not the pointer. Deriving it from hover meant a
    481     // scroll — or a tick replaying the last known position — immediately undid
    482     // Tab, and undid the auto-focus after adding to the queue.
    483     if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
    484         if let Some(pane) = hit.pane() {
    485             if app.focus != pane {
    486                 actions.push(AppAction::FocusPane(pane));
    487             }
    488         }
    489     }
    490 
    491     // --- Border drag resize ---
    492     let border0_x = areas.library.x + areas.library.width; // lib|playlist boundary
    493     let border1_x = areas.playlist.x + areas.playlist.width; // playlist|lyrics boundary
    494     let in_dashboard_y = y >= areas.library.y && y < areas.library.y + areas.library.height;
    495 
    496     // Handle active drag (before normal mouse processing)
    497     if ui.dragging_border.is_some() {
    498         match mouse.kind {
    499             MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
    500                 if let Some(border_idx) = ui.dragging_border {
    501                     let dashboard_x = areas.library.x;
    502                     let dashboard_w = areas.library.width + areas.playlist.width + areas.lyrics.width;
    503                     if dashboard_w > 0 {
    504                         let rel_x = x.saturating_sub(dashboard_x);
    505                         let pct = ((rel_x as u32 * 100) / dashboard_w as u32) as u16;
    506                         let min_w: u16 = MIN_PANE_WIDTH;
    507                         if border_idx == 0 {
    508                             // Dragging lib|playlist border
    509                             if let Some((lib, play)) = split_widths(pct, ui.pane_widths[2], min_w) {
    510                                 ui.pane_widths[0] = lib;
    511                                 ui.pane_widths[1] = play;
    512                             }
    513                         } else if border_idx == 1 {
    514                             // Dragging playlist|lyrics border. `pct` is measured
    515                             // from the dashboard's left edge, so subtract the
    516                             // library pane to get the boundary inside the budget.
    517                             let boundary = pct.saturating_sub(ui.pane_widths[0]);
    518                             if let Some((play, right)) =
    519                                 split_widths(boundary, ui.pane_widths[0], min_w)
    520                             {
    521                                 ui.pane_widths[1] = play;
    522                                 ui.pane_widths[2] = right;
    523                             }
    524                         } else if border_idx == 2 {
    525                             // Dragging info|lyrics horizontal border
    526                             let right_top = areas.info_pane.y;
    527                             let right_h = areas.info_pane.height + areas.lyrics.height;
    528                             if right_h > 0 {
    529                                 let rel_y = y.saturating_sub(right_top);
    530                                 let pct_v = ((rel_y as u32 * 100) / right_h as u32).clamp(10, 90) as u16;
    531                                 ui.right_split = pct_v;
    532                             }
    533                         }
    534                     }
    535                 }
    536                 return actions;
    537             }
    538             MouseEventKind::Up(MouseButton::Left) => {
    539                 ui.dragging_border = None;
    540                 return actions;
    541             }
    542             _ => {
    543                 ui.dragging_border = None;
    544             }
    545         }
    546     }
    547 
    548     // --- Handle specific event kinds ---
    549     match mouse.kind {
    550         MouseEventKind::Down(MouseButton::Left) => {
    551             // Border drag start detection. Ctrl is required so that an ordinary
    552             // click one column from a border selects a list row instead of
    553             // silently starting a resize.
    554             if in_dashboard_y && mouse.modifiers.contains(KeyModifiers::CONTROL) {
    555                 if x.abs_diff(border0_x) <= 1 {
    556                     ui.dragging_border = Some(0);
    557                     return actions;
    558                 }
    559                 if x.abs_diff(border1_x) <= 1 {
    560                     ui.dragging_border = Some(1);
    561                     return actions;
    562                 }
    563                 // Horizontal border between info_pane and lyrics (within right column)
    564                 let border2_y = areas.info_pane.y + areas.info_pane.height;
    565                 if x >= areas.info_pane.x
    566                     && x < areas.info_pane.x + areas.info_pane.width
    567                     && y.abs_diff(border2_y) <= 1
    568                 {
    569                     ui.dragging_border = Some(2);
    570                     return actions;
    571                 }
    572             }
    573 
    574             // Double-click detection. The column has to match too: comparing
    575             // only the row made a click in the library followed by a click on
    576             // the same screen row of the queue register as a double-click.
    577             let is_double_click = is_double_click(ui.last_click, x, y);
    578             // Cleared once it fires, so three fast clicks are one double-click
    579             // and one single, not two doubles.
    580             ui.last_click = if is_double_click {
    581                 None
    582             } else {
    583                 Some((Instant::now(), x, y))
    584             };
    585 
    586             // Tab bar click
    587             if y >= areas.tab_bar.y && y < areas.tab_bar.y + areas.tab_bar.height {
    588                 if let Some(tab_idx) = tab_bar::tab_hit_test(areas.tab_bar, x, y) {
    589                     actions.push(AppAction::SwitchTab(Tab::from_index(tab_idx)));
    590                 }
    591                 return actions;
    592             }
    593 
    594             // Progress bar click
    595             if y >= areas.progress_bar.y && y < areas.progress_bar.y + areas.progress_bar.height {
    596                 let gauge_area = progress_bar::progress_gauge_area(areas.progress_bar);
    597                 if x >= gauge_area.x && x < gauge_area.x + gauge_area.width {
    598                     let ratio = (x - gauge_area.x) as f64 / gauge_area.width as f64;
    599                     let seek_pos = ratio * app.playback.duration_secs;
    600                     actions.push(AppAction::Seek(seek_pos));
    601                 }
    602                 return actions;
    603             }
    604 
    605             // Double-click in playlist → play that track
    606             if is_double_click && in_playlist {
    607                 let block = ratatui::widgets::Block::default()
    608                     .borders(ratatui::widgets::Borders::ALL);
    609                 let inner = block.inner(areas.playlist);
    610                 if y >= inner.y && y < inner.y + inner.height {
    611                     let clicked = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
    612                     if clicked < app.queue.tracks.len() {
    613                         actions.push(AppAction::PlayQueueIndex(clicked));
    614                         return actions;
    615                     }
    616                 }
    617             }
    618 
    619             // Single click in library → select + activate (Enter).
    620             // Only when the click actually landed on a row: the synthesized
    621             // Enter is unconditional, so a click on the border or the blank
    622             // space under a short list used to activate whatever was selected
    623             // before — which with a queue-replacing activation meant one stray
    624             // click wiped the queue.
    625             let clicked_library_row = clicked_row_in(areas.library, y)
    626                 .map(|row| ui.library_pane_scroll(app) + row)
    627                 .filter(|&row| row < library_row_count(app, ui));
    628             if in_library && clicked_library_row.is_some() {
    629                 // First, route mouse to pane for selection update
    630                 library_mouse(mouse, areas.library, app, ui);
    631                 // Then, trigger Enter action to activate the clicked item
    632                 let enter_key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
    633                 if let Some(action) = library_key(enter_key, app, ui) {
    634                     if matches!(action, AppAction::AddToQueue(_)) {
    635                         actions.push(AppAction::FocusPane(FocusedPane::Playlist));
    636                     }
    637                     actions.push(action);
    638                 }
    639             } else if in_playlist {
    640                 if let Some(a) = ui.queue_pane.handle_mouse(mouse, areas.playlist, app) {
    641                     actions.push(a);
    642                 }
    643                 // Update queue selection on click
    644                 let block = ratatui::widgets::Block::default()
    645                     .borders(ratatui::widgets::Borders::ALL);
    646                 let inner = block.inner(areas.playlist);
    647                 if y >= inner.y && y < inner.y + inner.height {
    648                     let clicked = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
    649                     if clicked < app.queue.tracks.len() {
    650                         actions.push(AppAction::SetQueueSelection(clicked));
    651                     }
    652                 }
    653             } else if in_lyrics {
    654                 if let Some(a) = ui.track_info_pane.handle_mouse(mouse, areas.lyrics, app) {
    655                     actions.push(a);
    656                 }
    657             }
    658         }
    659         MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
    660             if in_library {
    661                 if let Some(a) = library_mouse(mouse, areas.library, app, ui) {
    662                     actions.push(a);
    663                 }
    664             } else if in_playlist {
    665                 if let Some(a) = ui.queue_pane.handle_mouse(mouse, areas.playlist, app) {
    666                     actions.push(a);
    667                 }
    668             } else if in_lyrics {
    669                 if let Some(a) = ui.track_info_pane.handle_mouse(mouse, areas.lyrics, app) {
    670                     actions.push(a);
    671                 }
    672             }
    673         }
    674         _ => {
    675             // Moved and other events — hover already handled above
    676         }
    677     }
    678 
    679     actions
    680 }
    681 
    682 const DOUBLE_CLICK: Duration = Duration::from_millis(400);
    683 
    684 /// Row index under `y` inside a bordered pane, or None for the border itself.
    685 fn clicked_row_in(area: ratatui::layout::Rect, y: u16) -> Option<usize> {
    686     let inner = ratatui::widgets::Block::default()
    687         .borders(ratatui::widgets::Borders::ALL)
    688         .inner(area);
    689     (y >= inner.y && y < inner.y + inner.height).then(|| (y - inner.y) as usize)
    690 }
    691 
    692 /// How many rows the library pane is currently showing, so a click below the
    693 /// last one can be ignored instead of activating whatever was selected before.
    694 fn library_row_count(app: &App, ui: &Ui) -> usize {
    695     match app.tab {
    696         Tab::Queue => ui.library_pane.row_count(app),
    697         Tab::Directories => ui.dir_browser_pane.entries.len(),
    698         tab => list_pane::row_count(app, tab),
    699     }
    700 }
    701 
    702 /// Route a key to whichever pane the library column is showing.
    703 fn library_key(key: KeyEvent, app: &App, ui: &mut Ui) -> Option<AppAction> {
    704     match app.tab {
    705         Tab::Queue => ui.library_pane.handle_key(key, app),
    706         Tab::Directories => ui.dir_browser_pane.handle_key(key, app),
    707         tab => {
    708             let count = list_pane::row_count(app, tab);
    709             let pane = ui.list_pane_mut(tab);
    710             pane.handle_key(key, count)
    711                 .then(|| list_pane::activate(app, tab, pane.selected))
    712                 .flatten()
    713         }
    714     }
    715 }
    716 
    717 /// Route a mouse event to whichever pane the library column is showing.
    718 fn library_mouse(mouse: MouseEvent, area: Rect, app: &App, ui: &mut Ui) -> Option<AppAction> {
    719     match app.tab {
    720         Tab::Queue => ui.library_pane.handle_mouse(mouse, area, app),
    721         Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, area, app),
    722         tab => {
    723             let count = list_pane::row_count(app, tab);
    724             ui.list_pane_mut(tab).handle_mouse(mouse, area, count);
    725             None
    726         }
    727     }
    728 }
    729 
    730 /// A second click counts only if it lands on the same cell in time. Matching on
    731 /// the row alone let a click in one pane pair up with a click in another.
    732 fn is_double_click(last: Option<(Instant, u16, u16)>, x: u16, y: u16) -> bool {
    733     last.is_some_and(|(at, lx, ly)| lx == x && ly == y && at.elapsed() < DOUBLE_CLICK)
    734 }
    735 
    736 /// Which dashboard pane a point lands in. Both the mouse handler and the tick
    737 /// refresh need this; they used to carry byte-identical copies of the maths.
    738 struct PaneHit {
    739     library: bool,
    740     playlist: bool,
    741     lyrics: bool,
    742 }
    743 
    744 impl PaneHit {
    745     fn at(areas: &LayoutAreas, x: u16, y: u16) -> Self {
    746         let inside = |r: ratatui::layout::Rect| {
    747             x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
    748         };
    749         Self {
    750             library: inside(areas.library),
    751             playlist: inside(areas.playlist),
    752             lyrics: inside(areas.lyrics),
    753         }
    754     }
    755 
    756     fn pane(&self) -> Option<FocusedPane> {
    757         if self.library {
    758             Some(FocusedPane::Library)
    759         } else if self.playlist {
    760             Some(FocusedPane::Playlist)
    761         } else if self.lyrics {
    762             Some(FocusedPane::Lyrics)
    763         } else {
    764             None
    765         }
    766     }
    767 }
    768 
    769 fn update_tab_hover(ui: &mut Ui, areas: &LayoutAreas, x: u16, y: u16) {
    770     ui.hovered_tab = if y >= areas.tab_bar.y && y < areas.tab_bar.y + areas.tab_bar.height {
    771         tab_bar::tab_hit_test(areas.tab_bar, x, y)
    772     } else {
    773         None
    774     };
    775 }
    776 
    777 /// Clear all hover_row state across all panes
    778 fn clear_all_hovers(ui: &mut Ui) {
    779     ui.queue_pane.hover_row = None;
    780     ui.library_pane.hover_row = None;
    781     ui.dir_browser_pane.hover_row = None;
    782     for pane in &mut ui.list_panes {
    783         pane.hover_row = None;
    784     }
    785 }
    786 
    787 /// Update hover_row state for panes based on mouse position
    788 fn update_hover(
    789     ui: &mut Ui,
    790     areas: &LayoutAreas,
    791     app: &App,
    792     x: u16,
    793     y: u16,
    794     in_library: bool,
    795     in_playlist: bool,
    796 ) {
    797     clear_all_hovers(ui);
    798 
    799     if in_playlist {
    800         let block = ratatui::widgets::Block::default()
    801             .borders(ratatui::widgets::Borders::ALL);
    802         let inner = block.inner(areas.playlist);
    803         if x >= inner.x && x < inner.x + inner.width
    804             && y >= inner.y && y < inner.y + inner.height
    805         {
    806             let row = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
    807             if row < app.queue.tracks.len() {
    808                 ui.queue_pane.hover_row = Some(row);
    809             }
    810         }
    811     } else if in_library {
    812         let block = ratatui::widgets::Block::default()
    813             .borders(ratatui::widgets::Borders::ALL);
    814         let inner = block.inner(areas.library);
    815         // Bounded like the queue branch above: hovering the blank space below a
    816         // short list used to light up a row that isn't there.
    817         let row_count = library_row_count(app, ui);
    818         let scroll = ui.library_pane_scroll(app);
    819         if x >= inner.x && x < inner.x + inner.width
    820             && y >= inner.y && y < inner.y + inner.height
    821             && (scroll + (y - inner.y) as usize) < row_count
    822         {
    823             let row = scroll + (y - inner.y) as usize;
    824             match app.tab {
    825                 Tab::Queue => ui.library_pane.hover_row = Some(row),
    826                 Tab::Directories => ui.dir_browser_pane.hover_row = Some(row),
    827                 tab => ui.list_pane_mut(tab).hover_row = Some(row),
    828             }
    829         }
    830     }
    831 }
    832 
    833 /// Refresh row highlighting from the stored mouse position, so hover stays
    834 /// current on terminals that only report motion sporadically.
    835 ///
    836 /// This deliberately does not touch focus. It used to re-derive focus from
    837 /// `ui.mouse_pos` on every 200ms tick, and since that position is latched on the
    838 /// first mouse event and never cleared, pressing Tab moved focus for one frame
    839 /// before the next tick dragged it back under the pointer.
    840 pub fn refresh_hover(app: &App, ui: &mut Ui, terminal_area: ratatui::layout::Rect) {
    841     // Skip hover updates when any modal is open
    842     if ui.show_about_modal || ui.show_help_modal || ui.show_search_modal || ui.show_playlist_modal {
    843         return;
    844     }
    845     let Some((x, y)) = ui.mouse_pos else { return };
    846     let areas = LayoutAreas::compute(terminal_area, ui.pane_widths, ui.right_split);
    847     let hit = PaneHit::at(&areas, x, y);
    848     update_hover(ui, &areas, app, x, y, hit.library, hit.playlist);
    849     update_tab_hover(ui, &areas, x, y);
    850 }
    851 
    852 /// Update queue selection based on keyboard in playlist focus
    853 pub fn update_queue_selection(app: &mut App, key: KeyEvent) {
    854     let count = app.queue.tracks.len();
    855     if count == 0 {
    856         return;
    857     }
    858 
    859     match key.code {
    860         KeyCode::Char('j') | KeyCode::Down => {
    861             if app.queue.selected_index < count - 1 {
    862                 app.queue.selected_index += 1;
    863             }
    864         }
    865         KeyCode::Char('k') | KeyCode::Up
    866             if app.queue.selected_index > 0 => {
    867                 app.queue.selected_index -= 1;
    868             }
    869         _ => {}
    870     }
    871 }
    872 
    873 #[cfg(test)]
    874 mod tests {
    875     use super::*;
    876     use std::path::PathBuf;
    877 
    878     fn new_ui() -> Ui {
    879         Ui::new(
    880             PathBuf::from("."),
    881             ratatui_image::picker::Picker::from_fontsize((8, 16)),
    882         )
    883     }
    884 
    885     fn ctrl(c: char) -> KeyEvent {
    886         KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
    887     }
    888 
    889     fn plain(c: char) -> KeyEvent {
    890         KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
    891     }
    892 
    893     #[test]
    894     fn ctrl_commands_open_their_modals() {
    895         let app = App::new(PathBuf::from("."));
    896 
    897         let mut ui = new_ui();
    898         handle_key_event(ctrl('s'), &app, &mut ui);
    899         assert!(ui.show_search_modal);
    900 
    901         let mut ui = new_ui();
    902         handle_key_event(ctrl('h'), &app, &mut ui);
    903         assert!(ui.show_help_modal);
    904 
    905         let mut ui = new_ui();
    906         handle_key_event(ctrl('a'), &app, &mut ui);
    907         assert!(ui.show_about_modal);
    908 
    909         let mut ui = new_ui();
    910         let actions = handle_key_event(ctrl('l'), &app, &mut ui);
    911         assert!(matches!(actions.as_slice(), [AppAction::LibrarySync]));
    912     }
    913 
    914     #[test]
    915     fn ctrl_shortcuts_do_not_fall_through_to_the_unmodified_binding() {
    916         let app = App::new(PathBuf::from("."));
    917         let mut ui = new_ui();
    918 
    919         // The plain-`s` arm matches any modifier, so without ordering the Ctrl
    920         // block first, Ctrl+S would toggle shuffle instead of opening search.
    921         let actions = handle_key_event(ctrl('s'), &app, &mut ui);
    922         assert!(actions.is_empty(), "Ctrl+S must not emit ToggleShuffle");
    923         assert!(ui.show_search_modal);
    924 
    925         // Unmodified keys are untouched.
    926         let mut ui = new_ui();
    927         let actions = handle_key_event(plain('s'), &app, &mut ui);
    928         assert!(matches!(actions.as_slice(), [AppAction::ToggleShuffle]));
    929         assert!(!ui.show_search_modal);
    930     }
    931 
    932     #[test]
    933     fn ctrl_c_quits_even_from_inside_a_text_field() {
    934         let app = App::new(PathBuf::from("."));
    935         let mut ui = new_ui();
    936         ui.show_search_modal = true;
    937 
    938         let actions = handle_key_event(ctrl('c'), &app, &mut ui);
    939 
    940         assert!(matches!(actions.as_slice(), [AppAction::Quit]));
    941         // It used to be typed into the query as a literal "c".
    942         assert!(ui.search_modal_input.is_empty());
    943     }
    944 
    945     #[test]
    946     fn split_widths_keeps_the_total_at_100_and_both_panes_above_the_minimum() {
    947         let (a, b) = split_widths(50, 20, MIN_PANE_WIDTH).unwrap();
    948         assert_eq!((a, b), (50, 30));
    949         assert_eq!(a + b + 20, 100);
    950     }
    951 
    952     #[test]
    953     fn split_widths_survives_dragging_past_either_edge() {
    954         // Pointer at or left of the dashboard: used to underflow `100 - fixed - 100`.
    955         let (a, b) = split_widths(0, 20, MIN_PANE_WIDTH).unwrap();
    956         assert_eq!((a, b), (MIN_PANE_WIDTH, 80 - MIN_PANE_WIDTH));
    957         // Pointer dragged far past the right edge.
    958         let (a, b) = split_widths(500, 20, MIN_PANE_WIDTH).unwrap();
    959         assert_eq!((a, b), (80 - MIN_PANE_WIDTH, MIN_PANE_WIDTH));
    960     }
    961 
    962     #[test]
    963     fn split_widths_refuses_when_the_third_pane_leaves_no_room() {
    964         // 85 fixed leaves 15, which cannot hold two 10% panes. The old code
    965         // called clamp(10, 5) here, and `Ord::clamp` panics when min > max.
    966         assert_eq!(split_widths(50, 85, MIN_PANE_WIDTH), None);
    967         assert_eq!(split_widths(50, 120, MIN_PANE_WIDTH), None);
    968         // Exactly enough room is allowed.
    969         assert_eq!(split_widths(50, 80, MIN_PANE_WIDTH), Some((10, 10)));
    970     }
    971 }