From 248c5701fb727d04d2ffaac9b5ed5601ad65fe5a Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 7 Dec 2025 13:56:52 +0100 Subject: [PATCH] Refactor mouse and keyboard handlers with shared actions - Extract common action functions to eliminate code duplication - Mouse left-click toggles folder open/close - Mouse right-click plays selection (identical to Enter key) - Add confirmation popup for library refresh operation - Improve popup visibility with Clear widget --- Cargo.toml | 2 +- src/main.rs | 246 ++++++++++++++++++++++++++++++++++++----------- src/state/mod.rs | 2 + src/ui/mod.rs | 68 ++++++++++++- 4 files changed, 257 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4e9ceb6..d51f61c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.10" +version = "0.1.11" edition = "2021" [dependencies] diff --git a/src/main.rs b/src/main.rs index c78c12f..c3fe5df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ mod ui; use anyhow::{Context, Result}; use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, EnableMouseCapture, DisableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -56,7 +56,7 @@ async fn main() -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -67,13 +67,83 @@ async fn main() -> Result<()> { disable_raw_mode()?; execute!( terminal.backend_mut(), - LeaveAlternateScreen + LeaveAlternateScreen, + DisableMouseCapture )?; terminal.show_cursor()?; result } +// Common action functions that both keyboard and mouse handlers can call + +fn action_toggle_folder(state: &mut AppState) { + if let Some(item) = state.get_selected_item() { + if item.node.is_dir { + let path = item.node.path.clone(); + if state.expanded_dirs.contains(&path) { + // Folder is open, close it + state.collapse_selected(); + } else { + // Folder is closed, open it + state.expand_selected(); + } + } + } +} + +fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> Result<()> { + state.play_selection(); + if let Some(ref path) = state.current_file { + player.play(path)?; + state.player_state = PlayerState::Playing; + player.update_metadata(); + tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); + } + if state.visual_mode { + state.visual_mode = false; + state.marked_files.clear(); + } + Ok(()) +} + +fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> { + match state.player_state { + PlayerState::Playing => { + player.pause()?; + state.player_state = PlayerState::Paused; + tracing::info!("Paused"); + } + PlayerState::Paused => { + player.resume()?; + state.player_state = PlayerState::Playing; + tracing::info!("Resumed"); + } + PlayerState::Stopped => { + // Restart playback from current playlist position + if !state.playlist.is_empty() { + state.current_file = Some(state.playlist[state.playlist_index].clone()); + state.player_state = PlayerState::Playing; + if let Some(ref path) = state.current_file { + player.play(path)?; + player.update_metadata(); + tracing::info!("Restarting playback: {:?}", path); + } + } + } + } + Ok(()) +} + +fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> { + player.stop()?; + state.player_state = PlayerState::Stopped; + state.current_position = 0.0; + state.current_duration = 0.0; + tracing::info!("Stopped"); + Ok(()) +} + async fn run_app( terminal: &mut Terminal, state: &mut AppState, @@ -82,6 +152,8 @@ async fn run_app( let mut metadata_update_counter = 0u32; let mut last_position = 0.0f64; let mut needs_redraw = true; + let mut title_bar_area = ratatui::layout::Rect::default(); + let mut file_panel_area = ratatui::layout::Rect::default(); loop { let mut state_changed = false; @@ -142,7 +214,11 @@ async fn run_app( // Only redraw if something changed or forced if needs_redraw || state_changed { - terminal.draw(|f| ui::render(f, state, player))?; + terminal.draw(|f| { + let areas = ui::render(f, state, player); + title_bar_area = areas.0; + file_panel_area = areas.1; + })?; needs_redraw = false; } @@ -154,11 +230,18 @@ async fn run_app( }; if event::poll(poll_duration)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - handle_key_event(terminal, state, player, key).await?; - needs_redraw = true; // Force redraw after key event + match event::read()? { + Event::Key(key) => { + if key.kind == KeyEventKind::Press { + handle_key_event(terminal, state, player, key).await?; + needs_redraw = true; // Force redraw after key event + } } + Event::Mouse(mouse) => { + handle_mouse_event(state, mouse, title_bar_area, file_panel_area, player)?; + needs_redraw = true; // Force redraw after mouse event + } + _ => {} } } @@ -171,6 +254,30 @@ async fn run_app( } async fn handle_key_event(terminal: &mut Terminal, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> { + // Handle confirmation popup + if state.show_refresh_confirm { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + state.show_refresh_confirm = false; + state.is_refreshing = true; + terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately + tracing::info!("Rescanning..."); + let cache_dir = cache::get_cache_dir()?; + let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?; + new_cache.save(&cache_dir)?; + state.cache = new_cache; + state.refresh_flattened_items(); + state.is_refreshing = false; + tracing::info!("Rescan complete"); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + state.show_refresh_confirm = false; + } + _ => {} + } + return Ok(()); + } + // Handle search mode separately if state.search_mode { match key.code { @@ -322,50 +429,13 @@ async fn handle_key_event(terminal: &mut Terminal< state.clear_playlist(); } (KeyCode::Enter, _) => { - state.play_selection(); - if let Some(ref path) = state.current_file { - player.play(path)?; - player.update_metadata(); // Update metadata immediately when playing new file - tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); - } - if state.visual_mode { - state.visual_mode = false; - state.marked_files.clear(); - } + action_play_selection(state, player)?; } (KeyCode::Char('s'), _) => { - // s: Stop playback - player.stop()?; - state.player_state = PlayerState::Stopped; - state.current_position = 0.0; - state.current_duration = 0.0; - tracing::info!("Stopped"); + action_stop(state, player)?; } (KeyCode::Char(' '), _) => { - match state.player_state { - PlayerState::Playing => { - player.pause()?; - state.player_state = PlayerState::Paused; - tracing::info!("Paused"); - } - PlayerState::Paused => { - player.resume()?; - state.player_state = PlayerState::Playing; - tracing::info!("Resumed"); - } - PlayerState::Stopped => { - // Restart playback from current playlist position - if !state.playlist.is_empty() { - state.current_file = Some(state.playlist[state.playlist_index].clone()); - state.player_state = PlayerState::Playing; - if let Some(ref path) = state.current_file { - player.play(path)?; - player.update_metadata(); // Update metadata immediately - tracing::info!("Restarting playback: {:?}", path); - } - } - } - } + action_toggle_play_pause(state, player)?; } (KeyCode::Char('H'), KeyModifiers::SHIFT) => { if state.player_state != PlayerState::Stopped { @@ -392,19 +462,79 @@ async fn handle_key_event(terminal: &mut Terminal< tracing::info!("Volume: {}%", new_volume); } (KeyCode::Char('r'), _) => { - state.is_refreshing = true; - terminal.draw(|f| ui::render(f, state, player))?; // Show "Refreshing library..." immediately - tracing::info!("Rescanning..."); - let cache_dir = cache::get_cache_dir()?; - let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?; - new_cache.save(&cache_dir)?; - state.cache = new_cache; - state.refresh_flattened_items(); - state.is_refreshing = false; - tracing::info!("Rescan complete"); + state.show_refresh_confirm = true; } _ => {} } Ok(()) } + +fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, player: &mut player::Player) -> Result<()> { + use crossterm::event::MouseButton; + + match mouse.kind { + MouseEventKind::ScrollDown => { + // Scroll down - move selection down + state.move_selection_down(); + } + MouseEventKind::ScrollUp => { + // Scroll up - move selection up + state.move_selection_up(); + } + MouseEventKind::Down(button) => { + let x = mouse.column; + let y = mouse.row; + + // Check if click is on title bar + if x >= title_bar_area.x + && x < title_bar_area.x + title_bar_area.width + && y >= title_bar_area.y + && y < title_bar_area.y + title_bar_area.height + { + match button { + MouseButton::Left => { + // Left click on title bar = play/pause toggle + action_toggle_play_pause(state, player)?; + } + MouseButton::Right => { + // Right click on title bar = stop + action_stop(state, player)?; + } + _ => {} + } + } + // Check if click is within file panel area + else if x >= file_panel_area.x + && x < file_panel_area.x + file_panel_area.width + && y >= file_panel_area.y + && y < file_panel_area.y + file_panel_area.height + { + // Calculate which item was clicked (accounting for borders and scroll offset) + // Border takes 1 line at top, so subtract 1 from y position + let relative_y = (y - file_panel_area.y).saturating_sub(1); + let clicked_index = state.scroll_offset + relative_y as usize; + + // Set selection to clicked item if valid + if clicked_index < state.flattened_items.len() { + state.selected_index = clicked_index; + + // Handle different mouse buttons + match button { + MouseButton::Left => { + // Left click = toggle folder open/close + action_toggle_folder(state); + } + MouseButton::Right => { + // Right click = play (like Enter key) + action_play_selection(state, player)?; + } + _ => {} + } + } + } + } + _ => {} + } + Ok(()) +} diff --git a/src/state/mod.rs b/src/state/mod.rs index ba3c497..d56983b 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -36,6 +36,7 @@ pub struct AppState { pub visual_mode: bool, pub visual_anchor: usize, pub saved_expanded_dirs: HashSet, + pub show_refresh_confirm: bool, } #[derive(Debug, Clone)] @@ -77,6 +78,7 @@ impl AppState { visual_mode: false, visual_anchor: 0, saved_expanded_dirs: HashSet::new(), + show_refresh_confirm: false, } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4399d2a..289360b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,12 +6,12 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear}, Frame, }; use theme::Theme; -pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) { +pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect) { // Clear background frame.render_widget( Block::default().style(Theme::secondary()), @@ -38,6 +38,14 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) { render_file_panel(frame, state, content_chunks[0]); render_right_panel(frame, state, content_chunks[1]); render_status_bar(frame, state, player, main_chunks[2]); + + // Show confirmation popup if needed + if state.show_refresh_confirm { + render_confirm_popup(frame, "Refresh library?", "This may take a while"); + } + + // Return title bar area and file panel area for mouse event handling + (main_chunks[0], content_chunks[0]) } fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is_selected: bool) -> Vec> { @@ -420,3 +428,59 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: frame.render_widget(right_bar, chunks[1]); } } + +fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) { + // Create centered popup area + let area = frame.area(); + let popup_width = 52; + let popup_height = 7; + + let popup_area = Rect { + x: (area.width.saturating_sub(popup_width)) / 2, + y: (area.height.saturating_sub(popup_height)) / 2, + width: popup_width.min(area.width), + height: popup_height.min(area.height), + }; + + // Use Clear widget to completely erase the background + frame.render_widget(Clear, popup_area); + + // Render the popup block with solid background + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default() + .bg(Theme::background()) + .fg(Theme::bright_foreground())) + .title_style(Style::default() + .fg(Theme::bright_foreground()) + .add_modifier(Modifier::BOLD)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + // Render message and prompt + let text_area = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Empty line + Constraint::Length(1), // Message + Constraint::Length(1), // Empty line + Constraint::Length(1), // Prompt + ]) + .split(inner); + + let message_text = Paragraph::new(message) + .style(Style::default() + .fg(Theme::bright_foreground()) + .bg(Theme::background())) + .alignment(Alignment::Center); + frame.render_widget(message_text, text_area[1]); + + let prompt_text = Paragraph::new("Press 'y' to confirm or 'n' to cancel") + .style(Style::default() + .fg(Theme::foreground()) + .bg(Theme::background())) + .alignment(Alignment::Center); + frame.render_widget(prompt_text, text_area[3]); +}