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 | |
| parent | add3dd28f91db1a2b33e9582c60ac9c1809c1bf5 (diff) | |
fix: hide only kitty image placements overlapping overlays (#4081)
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 4 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/zh-cn/configuration.mdx | 4 | ||||
| -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 |
14 files changed, 915 insertions, 46 deletions
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 34b810b5..1975a43a 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -512,7 +512,9 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne ## Kitty graphics -Herdr renders pane images by default in compatible outer terminals. To disable graphics rendering and the pane graphics API: +Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +To disable graphics rendering and the pane graphics API: ```toml [terminal] diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 1bb7d541..9c1d5b2d 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -508,7 +508,9 @@ claude = "on" ## Kitty graphics -Herdr 默认在兼容的外层终端中渲染窗格图像。要禁用图形渲染和窗格图形 API,请设置: +Herdr 默认在兼容的外层终端中渲染窗格图像。图像放置区域与弹窗、菜单或通知重叠时,Herdr 会暂时隐藏整个图像放置区域,而不是只裁剪重叠部分。未被遮挡的图像保持可见,被隐藏的图像放置区域在遮挡消失后恢复显示。图像不会随对话框背景一起变暗。 + +要禁用图形渲染和窗格图形 API,请设置: ```toml [terminal] 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); |
