diff options
Diffstat (limited to 'src/app/input/overlays.rs')
| -rw-r--r-- | src/app/input/overlays.rs | 483 |
1 files changed, 483 insertions, 0 deletions
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs new file mode 100644 index 00000000..6475895e --- /dev/null +++ b/src/app/input/overlays.rs @@ -0,0 +1,483 @@ +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<Rect> { + 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<Rect> { + 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<Rect> { + self.onboarding_modal_inner(56, 7) + } + + fn release_notes_body_rect(&self) -> Option<Rect> { + 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<crate::pane::ScrollMetrics> { + 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::<usize>() + }; + + 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<ScrollbarClickTarget> { + 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<usize> { + 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; + } + + match self.onboarding_step { + 0 => { + 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 matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && modal_action_from_buttons( + mouse.column, + mouse.row, + &[(button, ModalAction::Continue)], + ) == Some(ModalAction::Continue) + { + self.onboarding_step = 1; + } + } + _ => { + let Some(inner) = self.onboarding_modal_inner(56, 14) else { + return; + }; + let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1); + if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 { + self.onboarding_list + .select((mouse.row - stack.content.y) as usize); + return; + } + + let (back, save) = crate::ui::onboarding_notification_button_rects( + stack.actions.unwrap_or_default(), + ); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[(back, ModalAction::Back), (save, ModalAction::Save)], + ) { + Some(ModalAction::Back) => self.onboarding_step = 0, + Some(ModalAction::Save) => 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<Rect> { + 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<Rect> { + 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<crate::pane::ScrollMetrics> { + 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::<usize>(); + 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<ScrollbarClickTarget> { + 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<usize> { + 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; + app.state.onboarding_step = 1; + app.state.onboarding_list.select(1); + + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); + let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); + + assert_eq!(app.state.onboarding_list.selected, 1); + } + + #[test] + fn onboarding_click_selects_notification_option() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::Onboarding; + app.state.onboarding_step = 1; + app.state.onboarding_list.select(0); + + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); + let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + content.x + 2, + content.y + 2, + )); + + assert_eq!(app.state.onboarding_list.selected, 2); + } +} |
