diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/input/overlays.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/app/input/overlays.rs')
| -rw-r--r-- | src/app/input/overlays.rs | 831 |
1 files changed, 0 insertions, 831 deletions
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs deleted file mode 100644 index 58f2ef71..00000000 --- a/src/app/input/overlays.rs +++ /dev/null @@ -1,831 +0,0 @@ -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - layout::Rect, - widgets::{Block, Borders}, -}; - -use crate::app::{ - state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, - App, -}; - -use super::{ - modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction}, - ScrollbarClickTarget, -}; - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height -} - -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::ProductAnnouncement { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .product_announcement_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_product_announcement(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .product_announcement_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => self - .state - .set_product_announcement_offset_from_bottom(offset_from_bottom), - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_product_announcement_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_product_announcement(-3), - MouseEventKind::ScrollDown => self.scroll_product_announcement(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::Navigator { - match mouse.kind { - MouseEventKind::Moved => { - if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - self.state - .ensure_navigator_selection_visible_from(&self.terminal_runtimes); - } - } - MouseEventKind::Down(MouseButton::Left) => { - if self - .state - .navigator_search_contains(mouse.column, mouse.row) - { - self.state.navigator.search_focused = true; - } else if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - let target = self - .state - .navigator_rows_from(&self.terminal_runtimes) - .get(idx) - .map(|row| (row.target.clone(), row.is_workspace)); - if let Some((NavigatorTarget::Workspace { .. }, true)) = target { - if self.state.navigator_row_caret_at(mouse.column) { - self.state.toggle_selected_navigator_workspace_from( - &self.terminal_runtimes, - ); - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { - leave_modal(&mut self.state); - } - } - MouseEventKind::ScrollUp => { - self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - MouseEventKind::ScrollDown => { - let viewport = self.state.navigator_body_rect().height as usize; - let max = self - .state - .navigator_max_scroll_from(&self.terminal_runtimes, viewport); - self.state.navigator.scroll = - self.state.navigator.scroll.saturating_add(3).min(max); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - _ => {} - } - 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) => - { - keybind_help_back(&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(crate) fn navigator_popup_rect(&self) -> Rect { - let area = self.onboarding_full_area(); - let margin_x = (area.width / 16).max(2); - let margin_y = (area.height / 10).max(1); - let width = area.width.saturating_sub(margin_x.saturating_mul(2)); - let height = area.height.saturating_sub(margin_y.saturating_mul(2)); - Rect::new( - area.x + margin_x, - area.y + margin_y, - width.max(4), - height.max(4), - ) - } - - pub(crate) fn navigator_inner_rect(&self) -> Rect { - Block::default() - .borders(Borders::ALL) - .inner(self.navigator_popup_rect()) - } - - pub(crate) fn navigator_search_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) - } - - pub(crate) fn navigator_body_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - if inner.height <= 4 { - return Rect::default(); - } - Rect::new( - inner.x, - inner.y + 2, - inner.width, - inner.height.saturating_sub(4), - ) - } - - pub(crate) fn navigator_detail_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_footer_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(1), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_popup_rect(), col, row) - } - - pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_search_rect(), col, row) - } - - pub(crate) fn navigator_row_index_at_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option<usize> { - let body = self.navigator_body_rect(); - if !rect_contains(body, col, row) { - return None; - } - let line_idx = self - .navigator - .scroll - .saturating_add(row.saturating_sub(body.y) as usize); - let lines = crate::app::state::navigator_display_lines( - &self.navigator_rows_from(terminal_runtimes), - ); - match lines.get(line_idx) { - Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx), - _ => None, - } - } - - pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { - let body = self.navigator_body_rect(); - col <= body.x.saturating_add(3) - } - - 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 product_announcement_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner( - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - crate::ui::PRODUCT_ANNOUNCEMENT_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; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - 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.update_install_command, - &self.palette, - ); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - 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) - } 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; - } - } - - fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.product_announcement_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 product_announcement_body_rect(&self) -> Option<Rect> { - let inner = self.product_announcement_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn product_announcement_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let announcement = self.product_announcement.as_ref()?; - let body = self.product_announcement_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::product_announcement_display_lines(announcement, &self.palette); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - 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) - } 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(announcement.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn product_announcement_max_scroll(&self) -> u16 { - self.product_announcement_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn product_announcement_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_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 product_announcement_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_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_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.product_announcement_max_scroll() as usize; - if let Some(announcement) = &mut self.product_announcement { - announcement.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<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 clicking_keybind_help_back_button_leaves_help_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - app.state.keybind_help.search_focused = true; - app.state.keybind_help.query = "work".into(); - - 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 back = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - back.x, - back.y, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - assert!(!app.state.keybind_help.search_focused); - assert!(app.state.keybind_help.query.is_empty()); - } - - #[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); - } - - #[test] - fn release_notes_preview_scrollbar_uses_full_content_body() { - let mut app = app_for_mouse_test(); - app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16); - app.state.view.terminal_area = Rect::new(24, 0, 96, 16); - app.state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: "9.9.9".into(), - body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it." - .into(), - scroll: 0, - preview: true, - }); - app.state.update_install_command = "brew update && brew upgrade herdr".into(); - - let inner = app.state.release_notes_modal_inner().unwrap(); - let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let body = app.state.release_notes_body_rect().unwrap(); - - assert_eq!(body, expected_body); - - let metrics = app.state.release_notes_scroll_metrics().unwrap(); - assert_eq!(metrics.viewport_rows, body.height as usize); - assert!(metrics.max_offset_from_bottom > 0); - - let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap(); - assert_eq!(track.y, body.y); - assert!(matches!( - app.state - .release_notes_scrollbar_target_at(track.x, track.y), - Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. }) - )); - } -} |
