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