use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, widgets::{Block, Borders}, }; use crate::app::{ state::{AppState, DragState, DragTarget, Mode}, App, }; use super::{ modal::{leave_modal, modal_action_from_buttons, ModalAction}, ScrollbarClickTarget, }; impl App { pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { if self.state.mode == Mode::ReleaseNotes { match mouse.kind { MouseEventKind::Down(MouseButton::Left) if self .state .release_notes_close_button_at(mouse.column, mouse.row) => { self.dismiss_release_notes(); } MouseEventKind::Down(MouseButton::Left) => { if let Some(target) = self .state .release_notes_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.state.drag = Some(DragState { target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.state .set_release_notes_offset_from_bottom(offset_from_bottom); } } } } MouseEventKind::Drag(MouseButton::Left) => { if let Some(DragState { target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, }) = &self.state.drag { if let Some(offset_from_bottom) = self .state .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) { self.state .set_release_notes_offset_from_bottom(offset_from_bottom); } } } MouseEventKind::Up(MouseButton::Left) => { self.state.drag = None; } MouseEventKind::ScrollUp => self.scroll_release_notes(-3), MouseEventKind::ScrollDown => self.scroll_release_notes(3), _ => {} } return true; } if self.state.mode == Mode::KeybindHelp { match mouse.kind { MouseEventKind::Down(MouseButton::Left) if self .state .keybind_help_close_button_at(mouse.column, mouse.row) => { leave_modal(&mut self.state); } MouseEventKind::Down(MouseButton::Left) => { if let Some(target) = self .state .keybind_help_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.state.drag = Some(DragState { target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.state .set_keybind_help_offset_from_bottom(offset_from_bottom); } } } else { let rect = self.state.keybind_help_popup_rect(); let inside = mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height; if !inside { leave_modal(&mut self.state); } } } MouseEventKind::Drag(MouseButton::Left) => { if let Some(DragState { target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, }) = &self.state.drag { if let Some(offset_from_bottom) = self .state .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) { self.state .set_keybind_help_offset_from_bottom(offset_from_bottom); } } } MouseEventKind::Up(MouseButton::Left) => { self.state.drag = None; } MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), _ => {} } return true; } false } } impl AppState { pub(super) fn onboarding_full_area(&self) -> Rect { self.view.sidebar_rect.union(self.view.terminal_area) } pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option { let area = self.onboarding_full_area(); let popup_w = popup_w.min(area.width.saturating_sub(4)); let popup_h = popup_h.min(area.height.saturating_sub(2)); if popup_w < 4 || popup_h < 4 { return None; } let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); Some(Block::default().borders(Borders::ALL).inner(popup)) } fn release_notes_modal_inner(&self) -> Option { self.onboarding_modal_inner( crate::ui::RELEASE_NOTES_MODAL_SIZE.0, crate::ui::RELEASE_NOTES_MODAL_SIZE.1, ) } fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { let Some(inner) = self.release_notes_modal_inner() else { return false; }; if inner.height < 4 || inner.width < 12 { return false; } let button = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); col >= button.x && col < button.x + button.width && row >= button.y && row < button.y + button.height } pub(super) fn rename_modal_inner(&self) -> Option { self.onboarding_modal_inner(56, 7) } fn release_notes_body_rect(&self) -> Option { let inner = self.release_notes_modal_inner()?; if inner.height < 8 || inner.width < 4 { return None; } let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; let preview = self .release_notes .as_ref() .is_some_and(|notes| notes.preview); Some(crate::ui::release_notes_sections(body, preview).notes_body) } fn release_notes_scroll_metrics(&self) -> Option { let notes = self.release_notes.as_ref()?; let body = self.release_notes_body_rect()?; let viewport_rows = body.height.max(1) as usize; let lines = crate::ui::release_notes_display_lines(notes, &self.palette); let rows_for_width = |wrap_width: usize| { lines .iter() .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) .sum::() }; let full_width = body.width.max(1) as usize; let mut total_rows = rows_for_width(full_width); let wrap_width = if total_rows > viewport_rows && full_width > 1 { body.width.saturating_sub(1).max(1) as usize } else { full_width }; total_rows = rows_for_width(wrap_width); let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); Some(crate::pane::ScrollMetrics { offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), max_offset_from_bottom, viewport_rows, }) } pub(crate) fn release_notes_max_scroll(&self) -> u16 { self.release_notes_scroll_metrics() .map(|metrics| metrics.max_offset_from_bottom as u16) .unwrap_or(0) } fn release_notes_scrollbar_target_at( &self, col: u16, row: u16, ) -> Option { let body = self.release_notes_body_rect()?; let metrics = self.release_notes_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; if !(col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height) { return None; } if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some(ScrollbarClickTarget::Thumb { grab_row_offset }) } else { Some(ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }) } } fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { let body = self.release_notes_body_rect()?; let metrics = self.release_notes_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { let max_scroll = self.release_notes_max_scroll() as usize; if let Some(notes) = &mut self.release_notes { notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; } } pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return; } let Some(inner) = self.onboarding_modal_inner(64, 16) else { return; }; let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) .actions .unwrap_or_default(); let button = crate::ui::onboarding_welcome_continue_rect(actions); if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)]) == Some(ModalAction::Continue) { self.request_complete_onboarding = true; } } pub(super) fn keybind_help_popup_rect(&self) -> Rect { crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() } fn keybind_help_modal_inner(&self) -> Option { self.onboarding_modal_inner(76, 22) } fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { let Some(inner) = self.keybind_help_modal_inner() else { return false; }; if inner.height < 4 || inner.width < 12 { return false; } let button = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); col >= button.x && col < button.x + button.width && row >= button.y && row < button.y + button.height } fn keybind_help_body_rect(&self) -> Option { let inner = self.keybind_help_modal_inner()?; if inner.height < 6 || inner.width < 4 { return None; } Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) } fn keybind_help_scroll_metrics(&self) -> Option { let body = self.keybind_help_body_rect()?; let viewport_rows = body.height.max(1) as usize; let wrap_width = body.width.max(1) as usize; let total_rows = crate::ui::keybind_help_lines(self) .into_iter() .map(|(width, _)| width.max(1).div_ceil(wrap_width)) .sum::(); let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); Some(crate::pane::ScrollMetrics { offset_from_bottom: max_offset_from_bottom .saturating_sub(self.keybind_help.scroll as usize), max_offset_from_bottom, viewport_rows, }) } fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option { let body = self.keybind_help_body_rect()?; let metrics = self.keybind_help_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; if !(col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height) { return None; } if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some(ScrollbarClickTarget::Thumb { grab_row_offset }) } else { Some(ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }) } } fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { let body = self.keybind_help_body_rect()?; let metrics = self.keybind_help_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } pub(crate) fn keybind_help_max_scroll(&self) -> u16 { self.keybind_help_scroll_metrics() .map(|metrics| metrics.max_offset_from_bottom as u16) .unwrap_or(0) } fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { let max_scroll = self.keybind_help_max_scroll() as usize; self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; } pub(super) fn scroll_keybind_help(&mut self, delta: i16) { let max_scroll = self.keybind_help_max_scroll(); let current = self.keybind_help.scroll as i16; self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; } } #[cfg(test)] mod tests { use crossterm::event::{MouseButton, MouseEventKind}; use ratatui::layout::Rect; use super::super::{app_for_mouse_test, mouse}; use super::*; #[test] fn clicking_keybind_help_close_button_closes_overlay() { let mut app = app_for_mouse_test(); app.state.mode = Mode::KeybindHelp; let rect = app.state.keybind_help_popup_rect(); let inner = Rect::new( rect.x + 1, rect.y + 1, rect.width.saturating_sub(2), rect.height.saturating_sub(2), ); let close = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), close.x, close.y, )); assert_eq!(app.state.mode, Mode::Navigate); } #[test] fn onboarding_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content; app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); assert!(!app.state.request_complete_onboarding); } #[test] fn onboarding_click_continue_requests_completion() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) .actions .unwrap(); let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), continue_rect.x, continue_rect.y, )); assert!(app.state.request_complete_onboarding); } }