diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-14 03:02:14 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-14 03:02:14 +0300 |
| commit | 21c1f8cf856affb875b5ab4302225a63f4563bdd (patch) | |
| tree | fc3fa607c4af3d9233f3ba7d905a4744ad25c215 /src | |
| parent | add3dd28f91db1a2b33e9582c60ac9c1809c1bf5 (diff) | |
fix: hide only kitty image placements overlapping overlays (#4081)
Diffstat (limited to 'src')
| -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 | ||||
| -rw-r--r-- | src/kitty_graphics/surface.rs | 263 | ||||
| -rw-r--r-- | src/selection.rs | 82 | ||||
| -rw-r--r-- | src/ui/status.rs | 9 |
12 files changed, 909 insertions, 44 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, diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index e5f00ac2..4df05ffb 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -38,6 +38,61 @@ pub(crate) enum Visibility { } #[derive(Debug, Default)] +pub(crate) struct Occlusion { + regions: Vec<Rect>, + popup_start: usize, +} + +impl Occlusion { + pub(crate) fn cover(&mut self, rect: Rect) { + if !rect.is_empty() { + self.regions.push(rect); + } + } + + pub(crate) fn start_popup(&mut self, rect: Rect) { + self.cover(rect); + self.popup_start = self.regions.len(); + } + + fn covers( + &self, + placement: &SurfaceGraphicsPlacement, + origin: (u16, u16), + cell: HostCellSize, + ) -> bool { + let regions = if matches!( + placement.asset.source, + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Popup { .. }, + .. + } + ) { + &self.regions[self.popup_start..] + } else { + &self.regions + }; + if regions.is_empty() { + return false; + } + let width = u64::from(cell.width_px); + let height = u64::from(cell.height_px); + let x = + u64::from(origin.0.saturating_add(placement.x)) * width + u64::from(placement.x_offset); + let y = u64::from(origin.1.saturating_add(placement.y)) * height + + u64::from(placement.y_offset); + let right = x + u64::from(placement.cols) * width; + let bottom = y + u64::from(placement.rows) * height; + regions.iter().any(|rect| { + x < u64::from(rect.right()) * width + && right > u64::from(rect.x) * width + && y < u64::from(rect.bottom()) * height + && bottom > u64::from(rect.y) * height + }) + } +} + +#[derive(Debug, Default)] pub(crate) struct ClientState { scope: String, scene: SurfaceGraphicsScene, @@ -166,6 +221,7 @@ impl ClientState { main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, cell_size: HostCellSize, + occlusion: &Occlusion, ) -> Vec<u8> { let mut bytes = self.take_pending_cleanup(); self.stale_images.sort_unstable(); @@ -198,6 +254,7 @@ impl ClientState { main_origin, popup_origin, cell_size, + occlusion, ) }) .collect::<Vec<_>>(); @@ -550,6 +607,7 @@ fn client_host_placement( main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, cell_size: HostCellSize, + occlusion: &Occlusion, ) -> Option<HostPlacement> { let origin = match (&placement.asset.source, visibility) { ( @@ -571,6 +629,9 @@ fn client_host_placement( } _ => return None, }; + if occlusion.covers(placement, origin, cell_size) { + return None; + } let source_key = HostSourceKey::ClientSurface { scope: scope.to_owned(), source: placement.asset.source.clone(), @@ -692,6 +753,182 @@ mod tests { } #[test] + fn occlusion_uses_final_pixel_bounds_and_strict_overlap() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let mut placement = scene(image, 1, 2).placements.remove(0); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(12, 8, 1, 1)); + // The image at (11, 7) just touches the cover's top-left corner. + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.x_offset = 1; + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.y_offset = 1; + assert!(cover.covers(&placement, (10, 5), cell)); + placement.x_offset = 0; + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.cols = 2; + assert!(cover.covers(&placement, (10, 5), cell)); + assert!(!cover.covers(&placement, (0, 0), cell)); + cover = Occlusion::default(); + cover.cover(Rect::new(11, 7, 0, 1)); + assert!(!cover.covers(&placement, (10, 5), cell)); + } + + #[test] + fn occlusion_keeps_shared_asset_placements_and_restores_without_upload() { + for direct in [false, true] { + let mut state = ClientState::default(); + state.set_scope("occlusion"); + let _ = state.take_pending_cleanup(); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let id = host_image_id("occlusion", &image.key); + let mut graphics = scene(image.clone(), 0, 0); + let mut second = graphics.placements[0].clone(); + second.logical_placement_id = 4; + second.x = 5; + graphics.placements.push(second); + if direct { + // Model an already uploaded asset, including on hosts without direct transport. + graphics.assets.clear(); + state.host.images.insert( + id, + ImageSignature { + image_width: 1, + image_height: 1, + format_code: 32, + data_len: 4, + data_fingerprint: 1, + }, + ); + } + state.set_scene(graphics); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let _ = state.encode(Visibility::Main, (10, 5), None, cell, &Occlusion::default()); + assert_eq!(state.host.placements.len(), 2); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(10, 5, 1, 1)); + let hidden = state.encode(Visibility::Main, (10, 5), None, cell, &cover); + let hidden = String::from_utf8_lossy(&hidden); + assert!(hidden.contains("a=d,d=i"), "{hidden}"); + assert!(!hidden.contains("a=d,d=I"), "{hidden}"); + assert!(hidden.contains("\u{1b}[6;16H"), "{hidden}"); + assert_eq!(state.host.placements.len(), 1); + cover.cover(Rect::new(15, 5, 1, 1)); + let _ = state.encode(Visibility::Main, (10, 5), None, cell, &cover); + assert!(state.host.placements.is_empty()); + assert!(state.host.images.contains_key(&id)); + let restored = + state.encode(Visibility::Main, (10, 5), None, cell, &Occlusion::default()); + let restored = String::from_utf8_lossy(&restored); + assert!(restored.contains("a=p"), "{restored}"); + assert!(!restored.contains("a=t"), "{restored}"); + assert_eq!(state.host.placements.len(), 2); + } + } + + #[test] + fn popup_occlusion_respects_draw_order_and_its_own_images() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let main = scene(image, 0, 0).placements.remove(0); + let image = asset( + SurfaceGraphicsTarget::Popup { + terminal_id: "popup".into(), + }, + 2, + vec![4, 3, 2, 1], + ); + let popup = scene(image, 0, 0).placements.remove(0); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(10, 5, 3, 3)); + cover.start_popup(Rect::new(9, 4, 5, 5)); + assert!(cover.covers(&main, (10, 5), cell)); + assert!(!cover.covers(&main, (0, 0), cell)); + assert!(!cover.covers(&popup, (10, 5), cell)); + cover.cover(Rect::new(10, 5, 1, 1)); + assert!(cover.covers(&popup, (10, 5), cell)); + } + + #[test] + #[ignore = "manual image placement overlap scaling profile"] + fn graphics_occlusion_render_scale_profile() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + for count in [1, 15] { + let mut graphics = SurfaceGraphicsScene::default(); + for index in 0..count { + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: format!("pane-{index}"), + }, + index as u64, + vec![1, 2, 3, 4], + ); + let pane = scene(image, index * 4, 0); + graphics.assets.extend(pane.assets); + graphics.placements.extend(pane.placements); + } + for covered in [false, true] { + let mut state = ClientState::default(); + state.set_scope("profile"); + state.set_scene(graphics.clone()); + let mut cover = Occlusion::default(); + if covered { + cover.cover(Rect::new(0, 10, 80, 3)); + cover.cover(Rect::new(60, 0, 20, 5)); + } + let _ = state.encode(Visibility::Main, (0, 0), None, cell, &cover); + let mut samples = Vec::new(); + for _ in 0..101 { + let start = std::time::Instant::now(); + for _ in 0..20 { + std::hint::black_box(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &cover, + )); + } + samples.push(start.elapsed().as_nanos() / 20); + } + samples.sort_unstable(); + eprintln!("graphics occlusion panes={count} disjoint_overlays={covered} median_ns={} p95_ns={}", samples[50], samples[95]); + } + } + } + + #[test] fn client_encodes_final_main_origin_upload_once_and_replays_placement() { let mut state = ClientState::default(); state.set_scope("endpoint-a:boot-1"); @@ -712,6 +949,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&first).contains("a=t,t=d")); assert!(String::from_utf8_lossy(&first).contains("\u{1b}[8;12H")); @@ -724,6 +962,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let second = String::from_utf8_lossy(&second); assert!(!second.contains("a=t,t=d")); @@ -747,12 +986,18 @@ mod tests { width_px: 8, height_px: 16, }; - let _ = state.encode(Visibility::Main, (4, 2), None, cell); + let _ = state.encode(Visibility::Main, (4, 2), None, cell, &Occlusion::default()); - let hidden = state.encode(Visibility::Hidden, (4, 2), None, cell); + let hidden = state.encode( + Visibility::Hidden, + (4, 2), + None, + cell, + &Occlusion::default(), + ); assert!(String::from_utf8_lossy(&hidden).contains("a=d,d=i")); - let restored = state.encode(Visibility::Main, (4, 2), None, cell); + let restored = state.encode(Visibility::Main, (4, 2), None, cell, &Occlusion::default()); let restored = String::from_utf8_lossy(&restored); assert!(restored.contains("a=p")); assert!(!restored.contains("a=t,t=d")); @@ -779,6 +1024,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&bytes).contains("\u{1b}[12;33H")); } @@ -796,6 +1042,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let image = asset( SurfaceGraphicsTarget::Pane { @@ -818,6 +1065,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains("a=p")); @@ -851,6 +1099,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains("a=p"), "{bytes}"); @@ -883,6 +1132,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); state.set_scene(SurfaceGraphicsScene { @@ -897,6 +1147,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(!hidden.contains(&format!("a=d,d=I,i={image_id}"))); @@ -911,6 +1162,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(restored.contains("a=p"), "{restored}"); @@ -925,6 +1177,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(removed.contains(&format!("a=d,d=I,i={image_id}"))); @@ -962,6 +1215,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(bytes.contains("\u{1b}[2;3H"), "{bytes}"); @@ -1004,6 +1258,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains(&format!("a=d,d=I,i={image_id}")), "{bytes}"); @@ -1029,6 +1284,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); state.set_scope("endpoint-a:boot-2"); @@ -1062,6 +1318,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&bytes).contains("a=t,t=d")); } diff --git a/src/selection.rs b/src/selection.rs index 7290b8f0..5f81fdcd 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -188,6 +188,55 @@ impl<P> Selection<P> { self.ordered() } + pub(crate) fn visible_rects(&self, inner: Rect, metrics: Option<ScrollMetrics>) -> [Rect; 3] { + let mut rects = [Rect::default(); 3]; + if !self.is_visible() || inner.is_empty() { + return rects; + } + let ((start_row, start_col), (end_row, end_col)) = self.ordered(); + let top = viewport_top_row(metrics); + let bottom = top.saturating_add(u32::from(inner.height)); + let rect = |first: u32, end: u32, left: u16, right: u16| { + let first = first.max(top); + let end = end.min(bottom); + let left = left.min(inner.width); + let right = right.min(inner.width); + if first >= end || left >= right { + Rect::default() + } else { + Rect::new( + inner.x + left, + inner.y + (first - top) as u16, + right - left, + (end - first) as u16, + ) + } + }; + if start_row == end_row { + rects[0] = rect( + start_row, + start_row.saturating_add(1), + start_col, + end_col.saturating_add(1), + ); + } else { + rects[0] = rect( + start_row, + start_row.saturating_add(1), + start_col, + inner.width, + ); + rects[1] = rect(start_row.saturating_add(1), end_row, 0, inner.width); + rects[2] = rect( + end_row, + end_row.saturating_add(1), + 0, + end_col.saturating_add(1), + ); + } + rects + } + /// Check whether a pane-relative cell (row, col) is inside the selection. pub fn contains(&self, viewport_row: u16, col: u16, metrics: Option<ScrollMetrics>) -> bool { if !self.is_visible() { @@ -323,6 +372,39 @@ pub fn write_osc52_bytes(bytes: &[u8]) { mod tests { use super::*; + #[test] + fn visible_rects_match_selection_cells_when_scrolled_and_reversed() { + let inner = Rect::new(10, 5, 8, 6); + for (start, end) in [ + ((0, 2), (0, 5)), + ((1, 3), (4, 2)), + ((4, 2), (1, 3)), + ((0, 0), (20, 7)), + ((20, 0), (22, 7)), + ] { + let selection = Selection::absolute_range((), start, end); + for top in [0, 2, 5, 20] { + let metrics = Some(ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: top, + viewport_rows: 6, + }); + let rects = selection.visible_rects(inner, metrics); + for row in 0..inner.height { + for col in 0..inner.width { + assert_eq!( + rects + .iter() + .any(|rect| rect.contains((inner.x + col, inner.y + row).into())), + selection.contains(row, col, metrics), + "start={start:?} end={end:?} top={top} row={row} col={col}", + ); + } + } + } + } + } + fn make_sel(sr: u32, sc: u16, er: u32, ec: u16) -> Selection { let mut sel = Selection::anchor(PaneId::from_raw(0), sr as u16, sc, None); sel.anchor = (sr, sc); diff --git a/src/ui/status.rs b/src/ui/status.rs index e47e0f90..d8371cdb 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -54,10 +54,10 @@ pub(crate) fn render_copy_feedback_buffer( offset_rows: u16, position: ToastClipboardPosition, palette: &Palette, -) { +) -> Rect { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { - return; + return feedback_area; } Clear.render(feedback_area, buffer); @@ -69,7 +69,7 @@ pub(crate) fn render_copy_feedback_buffer( block.render(feedback_area, buffer); if inner.height == 0 { - return; + return feedback_area; } let text = Line::from(vec![ @@ -84,6 +84,7 @@ pub(crate) fn render_copy_feedback_buffer( ), ]); Paragraph::new(text).render(inner, buffer); + feedback_area } pub(crate) fn render_config_diagnostic_buffer( @@ -91,6 +92,7 @@ pub(crate) fn render_config_diagnostic_buffer( area: Rect, message: &str, palette: &Palette, + mut covered: impl FnMut(Rect), ) -> u16 { let style = Style::default() .fg(panel_contrast_fg(palette)) @@ -113,6 +115,7 @@ pub(crate) fn render_config_diagnostic_buffer( 1, ); + covered(diagnostic_area); Clear.render(diagnostic_area, buffer); Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer); rendered_rows = rendered_rows.saturating_add(1); |
