aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-14 03:02:14 +0300
committerGitHub <noreply@github.com>2026-09-14 03:02:14 +0300
commit21c1f8cf856affb875b5ab4302225a63f4563bdd (patch)
treefc3fa607c4af3d9233f3ba7d905a4744ad25c215 /src
parentadd3dd28f91db1a2b33e9582c60ac9c1809c1bf5 (diff)
fix: hide only kitty image placements overlapping overlays (#4081)
Diffstat (limited to 'src')
-rw-r--r--src/client/shell/composition.rs49
-rw-r--r--src/client/shell/graphics.rs21
-rw-r--r--src/client/shell/link_hover.rs18
-rw-r--r--src/client/shell/overlays.rs48
-rw-r--r--src/client/shell/settings_overlay.rs1
-rw-r--r--src/client/shell/tests/graphics.rs453
-rw-r--r--src/client/shell/tests/input.rs5
-rw-r--r--src/client/shell/tests/mod.rs1
-rw-r--r--src/client/shell/worktree_overlays.rs3
-rw-r--r--src/kitty_graphics/surface.rs263
-rw-r--r--src/selection.rs82
-rw-r--r--src/ui/status.rs9
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);