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 }