diff options
Diffstat (limited to 'src/client/shell')
| -rw-r--r-- | src/client/shell/composition.rs | 49 | ||||
| -rw-r--r-- | src/client/shell/graphics.rs | 21 | ||||
| -rw-r--r-- | src/client/shell/link_hover.rs | 18 | ||||
| -rw-r--r-- | src/client/shell/overlays.rs | 48 | ||||
| -rw-r--r-- | src/client/shell/settings_overlay.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/tests/graphics.rs | 453 | ||||
| -rw-r--r-- | src/client/shell/tests/input.rs | 5 | ||||
| -rw-r--r-- | src/client/shell/tests/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/worktree_overlays.rs | 3 |
9 files changed, 561 insertions, 38 deletions
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 8e99059b..cee57d96 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -314,6 +314,7 @@ impl ClientShellState { }); blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface); restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + let mut occlusion = crate::kitty_graphics::surface::Occlusion::default(); let has_selection = self .selection .as_ref() @@ -335,6 +336,7 @@ impl ClientShellState { hit, &self.config.palette, false, + &mut occlusion, ); } let selection_is_stale_copy_projection = !copy_surface_coherent @@ -346,6 +348,13 @@ impl ClientShellState { .is_some_and(|selection| selection.pane_id == hit.pane_id) }); if !selection_is_stale_copy_projection { + if let Some(selection) = + self.selection.as_ref().filter(|s| s.pane_id == hit.pane_id) + { + for rect in selection.visible_rects(hit.inner_rect, hit.scroll) { + occlusion.cover(rect); + } + } crate::ui::render_selection_highlight( self.selection.as_ref(), &mut composed, @@ -366,12 +375,13 @@ impl ClientShellState { hit, &self.config.palette, true, + &mut occlusion, ); } } frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } - self.render_link_hover(&mut frame); + self.render_link_hover(&mut frame, &mut occlusion); if self.mode == ClientShellMode::Copy { frame.cursor = None; if let Some(copy_mode) = self.copy_mode.as_ref() { @@ -390,6 +400,7 @@ impl ClientShellState { let mut composed = frame.to_ratatui_buffer()?; let x = hit.inner_rect.x + copy_mode.cursor.col; let y = hit.inner_rect.y + viewport_row as u16; + occlusion.cover(Rect::new(x, y, 1, 1)); composed[(x, y)].set_style( Style::default() .fg(match self.config.palette.panel_bg { @@ -433,17 +444,18 @@ impl ClientShellState { diagnostic_area, diagnostic, &self.config.palette, + |rect| occlusion.cover(rect), ); } let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| { - let _ = endpoint_notices::render_lifecycle_banner( + occlusion.cover(endpoint_notices::render_lifecycle_banner( &mut composed, Rect::new(0, 0, cols, rows), label, *status, u16::from(has_config_diagnostic) + layout.mobile_header.height, &self.config.palette, - ); + )); 1 }); if let Some(notice) = self.visible_endpoint_notice.as_ref() { @@ -484,6 +496,7 @@ impl ClientShellState { ) }; } + occlusion.cover(self.hits.notification_toast); frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } if let Some(feedback) = self.copy_feedback.as_ref() { @@ -502,14 +515,14 @@ impl ClientShellState { self.config.clipboard_toast_position, self.hits.notification_toast, ); - crate::ui::render_copy_feedback_buffer( + occlusion.cover(crate::ui::render_copy_feedback_buffer( &mut composed, feedback_area, feedback, offset, self.config.clipboard_toast_position, &self.config.palette, - ); + )); frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } self.hits.popup = None; @@ -519,6 +532,7 @@ impl ClientShellState { if let Some(geometry) = crate::popup_size::resolve_popup_geometry(width, height, layout.pane_surface) { + occlusion.start_popup(geometry.outer); let mut composed = frame.to_ratatui_buffer()?; let block = ratatui::widgets::Block::default() .borders(ratatui::widgets::Borders::ALL) @@ -552,6 +566,7 @@ impl ClientShellState { && self.overlay.is_none() { let mut composed = frame.to_ratatui_buffer()?; + occlusion.cover(composed.area); super::mobile::render_mobile_switcher( &mut composed, Rect::new(0, 0, cols, rows), @@ -591,20 +606,27 @@ impl ClientShellState { self.hits.popup = None; } restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + if let Some(bar) = mode_bar { + occlusion.cover(bar); + } if let Some(overlay) = self.overlay.as_ref() { let mut composed = frame.to_ratatui_buffer()?; let cursor = if let ClientShellOverlay::ContextMenu(menu) = overlay { - self.hits.context_menu_rows = + let rendered = render::render_context_menu(&mut composed, menu, &self.config.palette)?; + occlusion.cover(rendered.area); + self.hits.context_menu_rows = rendered.menu_rows; None } else if let ClientShellOverlay::GlobalMenu(menu) = overlay { - self.hits.global_menu_rows = render::render_global_menu( + let rendered = render::render_global_menu( &mut composed, self.hits.global_launcher, menu, snapshot, &self.config.palette, )?; + occlusion.cover(rendered.area); + self.hits.global_menu_rows = rendered.menu_rows; None } else { let rendered = render::render_client_overlay( @@ -616,6 +638,7 @@ impl ClientShellState { &self.config.keybinds, &self.config.palette, )?; + occlusion.cover(rendered.area); self.hits.overlay_primary = rendered.primary; self.hits.overlay_clear = rendered.clear; self.hits.overlay_cancel = rendered.cancel; @@ -662,7 +685,7 @@ impl ClientShellState { self.hits.pane_splits.clear(); self.hits.popup = None; } - self.compose_graphics(&mut frame, layout); + self.compose_graphics(&mut frame, layout, &occlusion); Some(frame) } } @@ -685,6 +708,7 @@ fn render_client_copy_search_highlights( hit: &PaneHit, palette: &Palette, current_only: bool, + occlusion: &mut crate::kitty_graphics::surface::Occlusion, ) { let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else { return; @@ -726,7 +750,14 @@ fn render_client_copy_search_highlights( } else { hit.inner_rect.width.saturating_sub(1) }; - for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) { + let end_col = end_col.min(hit.inner_rect.width.saturating_sub(1)); + occlusion.cover(Rect::new( + hit.inner_rect.x.saturating_add(start_col), + hit.inner_rect.y.saturating_add(viewport_row), + end_col.saturating_add(1).saturating_sub(start_col), + 1, + )); + for col in start_col..=end_col { buffer[( hit.inner_rect.x.saturating_add(col), hit.inner_rect.y.saturating_add(viewport_row), diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index 31061c9b..a7ae81e3 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -31,19 +31,13 @@ impl ClientShellState { }; } - pub(super) fn compose_graphics(&mut self, frame: &mut FrameData, layout: ClientShellLayout) { - let local_cover = self.overlay.is_some() - || self.mode != ClientShellMode::Terminal - || self.endpoint_error.is_some() - || self.config_diagnostic.is_some() - || self.visible_endpoint_notice.is_some() - || self.visible_notification.is_some() - || self.copy_feedback.is_some() - || self - .selection - .as_ref() - .is_some_and(|selection| selection.is_visible()); - let visibility = if local_cover { + pub(super) fn compose_graphics( + &mut self, + frame: &mut FrameData, + layout: ClientShellLayout, + occlusion: &crate::kitty_graphics::surface::Occlusion, + ) { + let visibility = if self.endpoint_error.is_some() { crate::kitty_graphics::surface::Visibility::Hidden } else if self.hits.popup.is_some() { crate::kitty_graphics::surface::Visibility::Popup @@ -60,6 +54,7 @@ impl ClientShellState { (layout.pane_surface.x, layout.pane_surface.y), popup_origin, self.graphics_cell_size, + occlusion, ); } } diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs index f35bfbc2..1a89d706 100644 --- a/src/client/shell/link_hover.rs +++ b/src/client/shell/link_hover.rs @@ -317,7 +317,11 @@ impl ClientShellState { }) } - pub(super) fn render_link_hover(&self, frame: &mut FrameData) { + pub(super) fn render_link_hover( + &self, + frame: &mut FrameData, + occlusion: &mut crate::kitty_graphics::surface::Occlusion, + ) { let Some(hover) = self .link_hover .as_ref() @@ -331,6 +335,18 @@ impl ClientShellState { if row >= frame.height { continue; } + occlusion.cover( + Rect::new( + rect.x.saturating_add(region.start_col), + row, + region + .end_col + .saturating_sub(region.start_col) + .saturating_add(1), + 1, + ) + .intersection(Rect::new(0, 0, frame.width, frame.height)), + ); for col in region.start_col..=region.end_col { let col = rect.x.saturating_add(col); if col >= frame.width { diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 4418c2c3..bc3772b1 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -5,6 +5,8 @@ mod worktree_overlays; #[derive(Default)] pub(crate) struct OverlayRender { + pub(crate) area: Rect, + pub(crate) menu_rows: Vec<(Rect, usize)>, pub(crate) primary: Rect, pub(crate) clear: Rect, pub(crate) cancel: Rect, @@ -85,7 +87,7 @@ pub(crate) fn render_global_menu( menu: &ClientGlobalMenuOverlay, snapshot: &ClientShellSnapshot, palette: &Palette, -) -> Option<Vec<(Rect, usize)>> { +) -> Option<OverlayRender> { let items = super::super::global_menu::global_menu_items(snapshot); let screen = buffer.area; let width = items @@ -108,12 +110,8 @@ pub(crate) fn render_global_menu( .saturating_sub(width) .min(screen.right().saturating_sub(width)); let y = launcher.y.saturating_sub(height).max(screen.y); - let inner = panel( - buffer, - Rect::new(x, y, width, height), - palette.accent, - palette.panel_bg, - )?; + let rect = Rect::new(x, y, width, height); + let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?; let mut rows = Vec::new(); for (index, (label, action)) in items.iter().enumerate() { let row_y = inner.y.saturating_add(index as u16); @@ -155,14 +153,18 @@ pub(crate) fn render_global_menu( } rows.push((row, index)); } - Some(rows) + Some(OverlayRender { + area: rect, + menu_rows: rows, + ..OverlayRender::default() + }) } pub(crate) fn render_context_menu( buffer: &mut Buffer, menu: &ClientContextMenuOverlay, palette: &Palette, -) -> Option<Vec<(Rect, usize)>> { +) -> Option<OverlayRender> { let items = menu.items(); let screen = buffer.area; let max_item_width = items @@ -207,7 +209,11 @@ pub(crate) fn render_context_menu( put_text(buffer, row.x, row.y, row.width, item.label, style); rows.push((row, index)); } - Some(rows) + Some(OverlayRender { + area: rect, + menu_rows: rows, + ..OverlayRender::default() + }) } fn panel( @@ -306,7 +312,10 @@ fn render_release_notes_overlay( )?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 8 || inner.width < 20 { - return Some(OverlayRender::default()); + return Some(OverlayRender { + area: outer, + ..OverlayRender::default() + }); } let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); @@ -397,6 +406,7 @@ fn render_release_notes_overlay( } Some(OverlayRender { + area: outer, primary: close, release_notes_scrollbar: track.unwrap_or_default(), release_notes_scroll_metrics: Some(metrics), @@ -417,7 +427,10 @@ fn render_product_announcement_overlay( )?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 8 || inner.width < 20 { - return Some(OverlayRender::default()); + return Some(OverlayRender { + area: outer, + ..OverlayRender::default() + }); } let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); @@ -509,6 +522,7 @@ fn render_product_announcement_overlay( } Some(OverlayRender { + area: outer, primary: close, product_announcement_scrollbar: track.unwrap_or_default(), product_announcement_scroll_metrics: Some(metrics), @@ -521,7 +535,10 @@ fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRende let outer = popup(b.area, 64, 16)?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 11 { - return Some(OverlayRender::default()); + return Some(OverlayRender { + area: outer, + ..OverlayRender::default() + }); } let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1); let base = Style::default() @@ -602,6 +619,7 @@ fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRende .remove_modifier(Modifier::DIM), ); Some(OverlayRender { + area: outer, primary, ..OverlayRender::default() }) @@ -655,6 +673,7 @@ fn render_rename_overlay( button(b, *clear, " ^c clear ", n); button(b, *cancel, " esc cancel ", n); Some(OverlayRender { + area: q, primary: *save, clear: *clear, cancel: *cancel, @@ -912,6 +931,7 @@ fn render_navigator_overlay( Style::default().fg(p.overlay0).bg(p.panel_bg), ); Some(OverlayRender { + area: q, primary: Rect::default(), clear: Rect::default(), cancel: Rect::default(), @@ -1113,6 +1133,7 @@ fn render_help_overlay( Style::default().fg(p.overlay0).bg(p.panel_bg), ); Some(OverlayRender { + area: q, cancel: close, help_popup: q, help_scrollbar: scrollbar.unwrap_or_default(), @@ -1176,6 +1197,7 @@ fn render_confirm_close_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: q, primary: *ok, clear: Rect::default(), cancel: *cancel, diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs index 8d0f779d..25f37b2a 100644 --- a/src/client/shell/settings_overlay.rs +++ b/src/client/shell/settings_overlay.rs @@ -244,6 +244,7 @@ pub(super) fn render_settings_overlay( ); Some(OverlayRender { + area: popup, primary, cancel: close, settings_popup: popup, diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs new file mode 100644 index 00000000..1b0071f1 --- /dev/null +++ b/src/client/shell/tests/graphics.rs @@ -0,0 +1,453 @@ +use super::*; +use crate::protocol::{ + SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement, + SurfaceGraphicsSource, SurfaceGraphicsTarget, +}; + +fn image( + target: SurfaceGraphicsTarget, + x: u16, + y: u16, + id: u32, +) -> (SurfaceGraphicsAsset, SurfaceGraphicsPlacement) { + let key = SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target, + image_id: id, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: u64::from(id), + }; + ( + SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 4], + }, + SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: id, + x, + y, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }, + ) +} + +fn add_main_image( + surface: &mut PaneSurfaceFrame, + layout: ClientShellLayout, + point: (u16, u16), + id: u32, +) { + let (asset, placement) = image( + SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + point.0 - layout.pane_surface.x, + point.1 - layout.pane_surface.y, + id, + ); + surface.graphics.assets.push(asset); + surface.graphics.placements.push(placement); +} + +fn is_placed(bytes: &[u8], point: (u16, u16)) -> bool { + String::from_utf8_lossy(bytes).contains(&format!("\x1b[{};{}H", point.1 + 1, point.0 + 1)) +} + +fn assert_graphics_cover(state: &mut ClientShellState, covered: Rect, cols: u16, rows: u16) { + let layout = state.layout(cols, rows); + let covered = covered.intersection(layout.pane_surface); + assert!(!covered.is_empty()); + let inside = (covered.right() - 1, covered.bottom() - 1); + let outside = (layout.pane_surface.y..layout.pane_surface.bottom()) + .find_map(|y| { + (layout.pane_surface.x..layout.pane_surface.right()) + .find(|&x| !covered.contains((x, y).into())) + .map(|x| (x, y)) + }) + .unwrap(); + let mut surface = surface(); + add_main_image(&mut surface, layout, outside, 1); + add_main_image(&mut surface, layout, inside, 2); + state.set_pane_surface(surface); + let frame = state.compose(cols, rows).unwrap(); + assert!( + is_placed(&frame.graphics, outside), + "outside={outside:?} cover={covered:?}" + ); + assert!( + !is_placed(&frame.graphics, inside), + "inside={inside:?} cover={covered:?}" + ); +} + +#[test] +fn notifications_and_clipboard_feedback_only_cover_their_drawn_corners() { + use crate::config::{ToastClipboardPosition as Clipboard, ToastHerdrPosition as Toast}; + for (cols, rows) in [(106, 40), (40, 24)] { + for position in [ + Toast::TopLeft, + Toast::TopRight, + Toast::BottomLeft, + Toast::BottomRight, + ] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notice".into(), + body: Some("body".into()), + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(position), + }, + deadline: std::time::Instant::now(), + }); + state.compose(cols, rows).unwrap(); + let rect = state.hits.notification_toast; + assert_graphics_cover(&mut state, rect, cols, rows); + } + for position in [ + Clipboard::TopLeft, + Clipboard::TopCenter, + Clipboard::TopRight, + Clipboard::BottomLeft, + Clipboard::BottomCenter, + Clipboard::BottomRight, + ] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.config.clipboard_toast_position = position; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied".into(), + }); + let layout = state.layout(cols, rows); + let area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + let rect = crate::ui::render_copy_feedback_buffer( + &mut buffer, + area, + state.copy_feedback.as_ref().unwrap(), + 0, + position, + &state.config.palette, + ); + assert_graphics_cover(&mut state, rect, cols, rows); + } + } +} + +#[test] +fn endpoint_notice_and_multiline_diagnostic_cover_the_actual_rows() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.visible_endpoint_notice = Some(ClientVisibleEndpointNotice { + key: ClientEndpointNoticeKey { + boot_id: "boot-1".into(), + kind: ClientEndpointNoticeKind::Rejected, + code: "test".into(), + }, + title: "error".into(), + body: "body".into(), + deadline: std::time::Instant::now(), + }); + state.compose(106, 20).unwrap(); + let rect = state.hits.notification_toast; + assert_graphics_cover(&mut state, rect, 106, 20); + state.visible_endpoint_notice = None; + state.config_diagnostic = Some("first\nsecond warning".into()); + assert_graphics_cover(&mut state, Rect::new(90, 1, 16, 1), 106, 20); +} + +#[test] +fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { + let palette = ClientShellConfig::from_config(&Config::default()).palette; + let overlays = vec![ + ClientShellOverlay::Onboarding, + ClientShellOverlay::ProductAnnouncement(crate::app::state::ProductAnnouncementState { + version: "1".into(), + id: "test".into(), + title: "news".into(), + body: "body".into(), + scroll: 0, + preview: false, + }), + ClientShellOverlay::ReleaseNotes(crate::app::state::ReleaseNotesState { + version: "1".into(), + body: "body".into(), + scroll: 0, + preview: false, + }), + ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename", + input: "name".into(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + }), + ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + workspace_id: "ws_1".into(), + title: "close".into(), + detail: "confirm".into(), + }), + ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + }), + ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: None, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + }), + ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: "branch".into(), + checkout_path: "path".into(), + replace_on_type: false, + error: None, + creating: false, + }), + ClientShellOverlay::WorktreeOpen(ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }), + ClientShellOverlay::WorktreeRemove(ClientWorktreeRemoveOverlay { + workspace_id: "ws_1".into(), + path: "path".into(), + error: None, + removing: false, + force_confirmation: false, + }), + ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Tab { + tab_id: "tab_1".into(), + workspace_id: "ws_1".into(), + }, + x: 35, + y: 8, + highlighted: 0, + }), + ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { highlighted: 0 }), + ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: 0, + original_theme_name: String::new(), + original_palette: palette, + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + }), + ]; + for overlay in overlays { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 40).unwrap(); + let layout = state.layout(106, 40); + let mut buffer = Buffer::empty(Rect::new(0, 0, 106, 40)); + let snapshot = state.snapshot.as_deref().unwrap(); + let rendered = match &overlay { + ClientShellOverlay::ContextMenu(menu) => { + render::render_context_menu(&mut buffer, menu, &state.config.palette) + } + ClientShellOverlay::GlobalMenu(menu) => render::render_global_menu( + &mut buffer, + state.hits.global_launcher, + menu, + snapshot, + &state.config.palette, + ), + _ => render::render_client_overlay( + &mut buffer, + &overlay, + snapshot, + &state.endpoints, + &state.active_endpoint_id, + &state.config.keybinds, + &state.config.palette, + ), + } + .unwrap(); + let area = rendered.area; + assert!(!area.is_empty(), "{overlay:?}"); + let covered = area.intersection(layout.pane_surface); + let outside = (layout.pane_surface.y..layout.pane_surface.bottom()) + .find_map(|y| { + (layout.pane_surface.x..layout.pane_surface.right()) + .find(|&x| !area.contains((x, y).into())) + .map(|x| (x, y)) + }) + .unwrap(); + let mut surface = surface(); + add_main_image(&mut surface, layout, outside, 1); + let border = (!covered.is_empty()).then(|| (covered.right() - 1, covered.bottom() - 1)); + if let Some(border) = border { + add_main_image(&mut surface, layout, border, 2); + } + state.set_pane_surface(surface); + state.compose(106, 40).unwrap(); + state.overlay = Some(overlay); + let frame = state.compose(106, 40).unwrap(); + assert!(is_placed(&frame.graphics, outside), "{:?}", state.overlay); + if let Some(border) = border { + assert!(!is_placed(&frame.graphics, border), "{:?}", state.overlay); + assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + } + state.overlay = None; + let restored = state.compose(106, 40).unwrap(); + assert!(is_placed(&restored.graphics, outside)); + if let Some(border) = border { + assert!(is_placed(&restored.graphics, border)); + } + assert!(!String::from_utf8_lossy(&restored.graphics).contains("a=t")); + } +} + +#[test] +fn selection_copy_cursor_and_search_hide_only_the_highlighted_images() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let layout = state.layout(106, 20); + let mut surface = surface(); + surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + let points = [(0, 0), (1, 1), (2, 0), (3, 0)] + .map(|(x, y)| (layout.pane_surface.x + x, layout.pane_surface.y + y)); + for (id, point) in points.iter().enumerate() { + add_main_image(&mut surface, layout, *point, id as u32 + 1); + } + state.set_pane_surface(surface); + state.compose(106, 20).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + state.selection = Some(crate::selection::Selection::absolute_range( + "pane_1".into(), + (0, 0), + (0, 0), + )); + state + .copy_mode + .as_mut() + .unwrap() + .search_matches + .push(crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 2 }, + }); + let frame = state.compose(106, 20).unwrap(); + for point in &points[..3] { + assert!(!is_placed(&frame.graphics, *point), "{point:?}"); + } + assert!(is_placed(&frame.graphics, points[3])); + state.mode = ClientShellMode::Terminal; + state.copy_mode = None; + state.selection = None; + let frame = state.compose(106, 20).unwrap(); + for point in points { + assert!(is_placed(&frame.graphics, point)); + } + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} + +#[test] +fn mobile_switcher_still_hides_the_entire_underlying_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let layout = state.layout(40, 24); + let point = (layout.pane_surface.x, layout.pane_surface.y); + let mut surface = surface(); + add_main_image(&mut surface, layout, point, 1); + state.set_pane_surface(surface); + let frame = state.compose(40, 24).unwrap(); + assert!(is_placed(&frame.graphics, point)); + state.mode = ClientShellMode::Navigate; + let frame = state.compose(40, 24).unwrap(); + assert!(!is_placed(&frame.graphics, point)); + assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + state.mode = ClientShellMode::Terminal; + let frame = state.compose(40, 24).unwrap(); + assert!(is_placed(&frame.graphics, point)); + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} + +#[test] +fn popup_terminal_keeps_own_graphics_and_hides_only_background_overlap() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 20).unwrap(); + let popup = state.hits.popup.clone().unwrap(); + let layout = state.layout(106, 20); + let outside = (layout.pane_surface.x, layout.pane_surface.y); + let border = (popup.rect.x, popup.rect.y); + let inside = (popup.inner_rect.x, popup.inner_rect.y); + let mut surface = surface_with_popup(); + add_main_image(&mut surface, layout, outside, 1); + add_main_image(&mut surface, layout, border, 2); + let (asset, placement) = image( + SurfaceGraphicsTarget::Popup { + terminal_id: "terminal-popup".into(), + }, + 0, + 0, + 3, + ); + surface.graphics.assets.push(asset); + surface.graphics.placements.push(placement); + state.set_pane_surface(surface); + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, outside)); + assert!(!is_placed(&frame.graphics, border)); + assert!(is_placed(&frame.graphics, inside)); + state.overlay = Some(ClientShellOverlay::Onboarding); + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, outside)); + assert!(!is_placed(&frame.graphics, inside)); + state.overlay = None; + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, inside)); + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index 3f831fa4..35916ecf 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -213,8 +213,9 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility( assert!(visible.contains("\u{1b}[2;27H")); state.overlay = Some(ClientShellOverlay::Onboarding); - let hidden = state.compose(106, 20).expect("overlay frame"); - assert!(String::from_utf8_lossy(&hidden.graphics).contains("a=d,d=i")); + let uncovered = state.compose(106, 20).expect("overlay frame"); + assert!(!String::from_utf8_lossy(&uncovered.graphics).contains("a=d")); + assert!(String::from_utf8_lossy(&uncovered.graphics).contains("a=p")); state.overlay = None; let restored = state.compose(106, 20).expect("restored graphics frame"); diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 6633494c..b6b600bc 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -248,6 +248,7 @@ mod chrome_context; mod copy; mod endpoint_requests; mod endpoints; +mod graphics; #[path = "input.rs"] mod input_domain; mod keybindings_settings; diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs index a2b5c65f..f4b9d611 100644 --- a/src/client/shell/worktree_overlays.rs +++ b/src/client/shell/worktree_overlays.rs @@ -94,6 +94,7 @@ pub(super) fn render_worktree_create_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, @@ -280,6 +281,7 @@ pub(super) fn render_worktree_open_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, @@ -396,6 +398,7 @@ pub(super) fn render_worktree_remove_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, |
