diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-01-27 10:18:31 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-27 10:18:31 +0100 |
| commit | f0a8f4659c825ca8fb34fa6f89ecab414b6c6d3f (patch) | |
| tree | efccf839dfa4bbd36a9713082a7d604a3e5ba874 | |
| parent | feat(assets): add svg logo (#4196) (diff) | |
| download | sl0ppij-f0a8f4659c825ca8fb34fa6f89ecab414b6c6d3f.tar.gz sl0ppij-f0a8f4659c825ca8fb34fa6f89ecab414b6c6d3f.tar.xz sl0ppij-f0a8f4659c825ca8fb34fa6f89ecab414b6c6d3f.zip | |
fix: properly occlude cursor in floating pane when pinned pane covers it (#4644)
* fix: properly occlude cursor in floating pane when pinned pane covers it
* add pr
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | zellij-server/src/output/mod.rs | 18 | ||||
| -rw-r--r-- | zellij-server/src/output/unit/output_tests.rs | 134 | ||||
| -rw-r--r-- | zellij-server/src/panes/floating_panes/mod.rs | 3 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 13 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 156 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_tests.rs | 187 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_contents_and_ui.rs | 7 |
8 files changed, 505 insertions, 14 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3497f3b8..b0211c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: allow disabling automatic OSC8 hyperlink formatting (https://github.com/zellij-org/zellij/pull/4624) * fix: properly set cwd for new sessions started from the `session-manager` (https://github.com/zellij-org/zellij/pull/4625) * fix: get `zellij run --session <X>` to work even when the specified session has no connected users (https://github.com/zellij-org/zellij/pull/4626) +* fix: properly occlude cursor in floating panes when (partially) covered by another pinned pane (https://github.com/zellij-org/zellij/pull/4644) * fix: resolve short layout names as files if possible (https://github.com/zellij-org/zellij/pull/4640) diff --git a/zellij-server/src/output/mod.rs b/zellij-server/src/output/mod.rs index 180514ae..5ca283a7 100644 --- a/zellij-server/src/output/mod.rs +++ b/zellij-server/src/output/mod.rs @@ -595,11 +595,16 @@ impl Output { self.client_character_chunks.values().any(|c| !c.is_empty()) || self.sixel_chunks.values().any(|c| !c.is_empty()) } - pub fn cursor_is_visible(&mut self, cursor_x: usize, cursor_y: usize) -> bool { + pub fn cursor_is_visible( + &mut self, + cursor_x: usize, + cursor_y: usize, + z_index: Option<usize>, + ) -> bool { self.cursor_coordinates = Some((cursor_x, cursor_y)); self.floating_panes_stack .as_ref() - .map(|s| s.cursor_is_visible(cursor_x, cursor_y)) + .map(|s| s.cursor_is_visible(cursor_x, cursor_y, z_index)) .unwrap_or(true) } pub fn add_pane_contents( @@ -910,8 +915,13 @@ impl FloatingPanesStack { } uncovered_chunks } - pub fn cursor_is_visible(&self, cursor_x: usize, cursor_y: usize) -> bool { - let z_index = 0; // TODO: receive z_index + pub fn cursor_is_visible( + &self, + cursor_x: usize, + cursor_y: usize, + z_index: Option<usize>, + ) -> bool { + let z_index = z_index.map(|z| z + 1).unwrap_or(0); // +1 because we only check panes above the active pane let panes_to_check = self.layers.iter().skip(z_index); for pane_geom in panes_to_check { let pane_top_edge = pane_geom.y; diff --git a/zellij-server/src/output/unit/output_tests.rs b/zellij-server/src/output/unit/output_tests.rs index 4a25748d..bb0bb664 100644 --- a/zellij-server/src/output/unit/output_tests.rs +++ b/zellij-server/src/output/unit/output_tests.rs @@ -508,7 +508,7 @@ fn test_serialize_with_size_hides_cursor_when_cropped() { let max_size = Some(Size { rows: 10, cols: 20 }); // Set cursor outside max_size - output.cursor_is_visible(25, 5); + output.cursor_is_visible(25, 5, None); let chunk = create_character_chunk_from_str("Test", 0, 0); output @@ -796,18 +796,146 @@ fn test_cursor_is_visible_with_floating_panes() { // Cursor inside pane bounds assert!( - !stack.cursor_is_visible(7, 7), + !stack.cursor_is_visible(7, 7, None), "Cursor should not be visible when covered by pane" ); // Cursor outside pane bounds assert!( - stack.cursor_is_visible(20, 20), + stack.cursor_is_visible(20, 20, None), "Cursor should be visible when not covered by pane" ); } #[test] +fn test_cursor_visibility_with_z_index_bottom_layer() { + // Two overlapping panes: bottom at (5,5) and top at (5,5) + let bottom_pane = create_pane_geom(5, 5, 10, 10); + let top_pane = create_pane_geom(5, 5, 10, 10); + + let stack = FloatingPanesStack { + layers: vec![bottom_pane, top_pane], + }; + + // Cursor at position (7,7) in the bottom layer (z-index 0) + // Should be hidden because there's a pane above it (z-index 1) at the same position + assert!( + !stack.cursor_is_visible(7, 7, Some(0)), + "Cursor in bottom layer should be hidden by pane above it" + ); +} + +#[test] +fn test_cursor_visibility_with_z_index_top_layer() { + // Two overlapping panes: bottom at (5,5) and top at (5,5) + let bottom_pane = create_pane_geom(5, 5, 10, 10); + let top_pane = create_pane_geom(5, 5, 10, 10); + + let stack = FloatingPanesStack { + layers: vec![bottom_pane, top_pane], + }; + + // Cursor at position (7,7) in the top layer (z-index 1) + // Should be visible even though there's a pane below it at the same position + assert!( + stack.cursor_is_visible(7, 7, Some(1)), + "Cursor in top layer should be visible (not affected by panes below)" + ); +} + +#[test] +fn test_cursor_visibility_with_multiple_layers() { + // Three panes stacked vertically with different layers + // Layer 0 (bottom): pane at (5,5) + // Layer 1 (middle): pane at (10,10) + // Layer 2 (top): pane at (5,5) - overlaps with layer 0 + let layer0_pane = create_pane_geom(5, 5, 10, 10); + let layer1_pane = create_pane_geom(10, 10, 10, 10); + let layer2_pane = create_pane_geom(5, 5, 10, 10); + + let stack = FloatingPanesStack { + layers: vec![layer0_pane, layer1_pane, layer2_pane], + }; + + // Cursor at (7,7) in layer 0 - should be hidden by layer 2 + assert!( + !stack.cursor_is_visible(7, 7, Some(0)), + "Cursor in layer 0 should be hidden by layer 2 above it" + ); + + // Cursor at (16,16) in layer 1 - should be visible (layer 2 doesn't cover this position) + // Layer 1 is at (10,10) size 10x10, so covers (10,10) to (19,19) + // Layer 2 is at (5,5) size 10x10, so covers (5,5) to (14,14) + // Position (16,16) is inside layer 1 but outside layer 2 + assert!( + stack.cursor_is_visible(16, 16, Some(1)), + "Cursor in layer 1 should be visible when not covered by layers above" + ); + + // Cursor at (7,7) in layer 2 - should be visible (no layers above) + assert!( + stack.cursor_is_visible(7, 7, Some(2)), + "Cursor in top layer should always be visible" + ); +} + +#[test] +fn test_cursor_visibility_pinned_pane_over_floating() { + // Simulates a floating pane (z-index 0) with a pinned pane (z-index 1) on top + // Both panes overlap at the same position + let floating_pane = create_pane_geom(10, 10, 20, 20); + let mut pinned_pane = create_pane_geom(10, 10, 20, 20); + pinned_pane.is_pinned = true; + + let stack = FloatingPanesStack { + layers: vec![floating_pane, pinned_pane], + }; + + // Cursor in the floating pane (z-index 0) at position covered by pinned pane + assert!( + !stack.cursor_is_visible(15, 15, Some(0)), + "Cursor in floating pane should be hidden when covered by pinned pane above" + ); + + // Cursor in the pinned pane (z-index 1) at the same position + assert!( + stack.cursor_is_visible(15, 15, Some(1)), + "Cursor in pinned pane should be visible" + ); +} + +#[test] +fn test_cursor_visibility_partial_overlap() { + // Two panes with partial overlap + // Layer 0: pane at (0, 0) size 10x10 + // Layer 1: pane at (5, 5) size 10x10 (overlaps bottom-right of layer 0) + let layer0_pane = create_pane_geom(0, 0, 10, 10); + let layer1_pane = create_pane_geom(5, 5, 10, 10); + + let stack = FloatingPanesStack { + layers: vec![layer0_pane, layer1_pane], + }; + + // Cursor at (2,2) in layer 0 - not covered by layer 1, should be visible + assert!( + stack.cursor_is_visible(2, 2, Some(0)), + "Cursor in non-overlapping area should be visible" + ); + + // Cursor at (7,7) in layer 0 - covered by layer 1, should be hidden + assert!( + !stack.cursor_is_visible(7, 7, Some(0)), + "Cursor in overlapping area should be hidden by pane above" + ); + + // Cursor at (7,7) in layer 1 - should be visible (top layer) + assert!( + stack.cursor_is_visible(7, 7, Some(1)), + "Cursor in top layer should be visible in overlapping area" + ); +} + +#[test] fn test_output_buffer_update_line() { let mut buffer = OutputBuffer::default(); buffer.clear(); // Clear the initial "update all lines" state diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs index 0303399f..d28e0574 100644 --- a/zellij-server/src/panes/floating_panes/mod.rs +++ b/zellij-server/src/panes/floating_panes/mod.rs @@ -230,6 +230,9 @@ impl FloatingPanes { pub fn active_pane_id(&self, client_id: ClientId) -> Option<PaneId> { self.active_panes.get(&client_id).copied() } + pub fn get_pane_z_index(&self, pane_id: PaneId) -> Option<usize> { + self.z_indices.iter().position(|id| id == &pane_id) + } pub fn active_pane_id_or_focused_pane_id(&self, client_id: Option<ClientId>) -> Option<PaneId> { // returns the focused pane of any client_id - should be safe because the way things are // set up at the time of writing, all clients are focused on the same floating pane due to diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index ab794853..02c26362 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -3057,11 +3057,14 @@ impl Tab { match self .get_active_terminal_cursor_position(client_id) .and_then(|(cursor_position_x, cursor_position_y)| { - // TODO: get active_pane_z_index and pass it to cursor_is_visible so we do the - // right thing if the cursor is in a floating pane - if self.floating_panes.panes_are_visible() { - Some((cursor_position_x, cursor_position_y)) - } else if output.cursor_is_visible(cursor_position_x, cursor_position_y) { + let active_pane_z_index = self + .get_active_pane_id(client_id) + .and_then(|pane_id| self.floating_panes.get_pane_z_index(pane_id)); + if output.cursor_is_visible( + cursor_position_x, + cursor_position_y, + active_pane_z_index, + ) { Some((cursor_position_x, cursor_position_y)) } else { None diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index 627af6af..8b109def 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -10050,3 +10050,159 @@ fn borderless_pinned_floating_pane() { ); assert_snapshot!(snapshot); } + +#[test] +fn cursor_hidden_when_floating_pane_is_under_pinned_pane() { + // Test that cursor is correctly hidden when focused on a floating pane that's under a pinned pane + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let mut output = Output::default(); + + // Create first floating pane (will be underneath) + let bottom_pane_coordinates = FloatingPaneCoordinates { + x: Some(PercentOrFixed::Fixed(10)), + y: Some(PercentOrFixed::Fixed(5)), + width: Some(PercentOrFixed::Fixed(40)), + height: Some(PercentOrFixed::Fixed(10)), + pinned: Some(false), + borderless: Some(false), + }; + + tab.new_floating_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + Some(bottom_pane_coordinates), + None, + ) + .unwrap(); + + // Create overlapping pinned pane on top + let top_pane_coordinates = FloatingPaneCoordinates { + x: Some(PercentOrFixed::Fixed(15)), + y: Some(PercentOrFixed::Fixed(6)), + width: Some(PercentOrFixed::Fixed(30)), + height: Some(PercentOrFixed::Fixed(8)), + pinned: Some(true), + borderless: Some(false), + }; + + tab.new_floating_pane( + PaneId::Terminal(3), + None, + None, + false, + true, + Some(top_pane_coordinates), + None, + ) + .unwrap(); + + // Add some text to both panes so we can see them + tab.handle_pty_bytes(2, Vec::from("Bottom floating pane".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(3, Vec::from("Top pinned pane".as_bytes())) + .unwrap(); + + // Explicitly focus the bottom floating pane (PaneId::Terminal(2)) + // Use move_focus_left to switch from pane 3 to pane 2 + tab.move_focus_left(client_id).unwrap(); + + // Render + tab.render(&mut output, None).unwrap(); + let (_snapshot, cursor_coordinates) = take_snapshot_and_cursor_position( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + + // The cursor should be hidden because the focused pane (2) is under the pinned pane (3) + // and the cursor position falls within the overlapping area + assert_eq!( + cursor_coordinates, None, + "Cursor should be hidden when focused floating pane is under a pinned pane that covers it" + ); +} + +#[test] +fn cursor_visible_when_pinned_pane_is_focused() { + // Test that cursor is visible when focused on a pinned pane that's on top + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let mut output = Output::default(); + + // Create first floating pane (will be underneath) + let bottom_pane_coordinates = FloatingPaneCoordinates { + x: Some(PercentOrFixed::Fixed(10)), + y: Some(PercentOrFixed::Fixed(5)), + width: Some(PercentOrFixed::Fixed(40)), + height: Some(PercentOrFixed::Fixed(10)), + pinned: Some(false), + borderless: Some(false), + }; + + tab.new_floating_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + Some(bottom_pane_coordinates), + None, + ) + .unwrap(); + + // Create overlapping pinned pane on top + let top_pane_coordinates = FloatingPaneCoordinates { + x: Some(PercentOrFixed::Fixed(15)), + y: Some(PercentOrFixed::Fixed(6)), + width: Some(PercentOrFixed::Fixed(30)), + height: Some(PercentOrFixed::Fixed(8)), + pinned: Some(true), + borderless: Some(false), + }; + + tab.new_floating_pane( + PaneId::Terminal(3), + None, + None, + false, + true, + Some(top_pane_coordinates), + None, + ) + .unwrap(); + + // Add some text to both panes + tab.handle_pty_bytes(2, Vec::from("Bottom floating pane".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(3, Vec::from("Top pinned pane".as_bytes())) + .unwrap(); + + // The pinned pane should be focused by default (last created) + // Render + tab.render(&mut output, None).unwrap(); + let (_snapshot, cursor_coordinates) = take_snapshot_and_cursor_position( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + + // The cursor should be visible because the focused pane is the pinned pane on top + assert!( + cursor_coordinates.is_some(), + "Cursor should be visible when focused on pinned pane that's on top" + ); +} diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs index fec63764..eea12a55 100644 --- a/zellij-server/src/tab/unit/tab_tests.rs +++ b/zellij-server/src/tab/unit/tab_tests.rs @@ -15066,3 +15066,190 @@ fn correctly_resize_frameless_panes_on_pane_close() { let content_size = (pane.get_content_columns(), pane.get_content_rows()); assert_eq!(content_size, (cols, rows)); } + +#[test] +fn floating_pane_z_index_is_tracked() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + let _client_id = 1; + + // Create first floating pane (should_float = true means it will be a floating pane) + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + + // Create second floating pane + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + + // Verify z-indices exist and are different + let z_index_pane2 = tab.floating_panes.get_pane_z_index(PaneId::Terminal(2)); + let z_index_pane3 = tab.floating_panes.get_pane_z_index(PaneId::Terminal(3)); + + assert!( + z_index_pane2.is_some(), + "First floating pane should have a z-index" + ); + assert!( + z_index_pane3.is_some(), + "Second floating pane should have a z-index" + ); + assert_ne!( + z_index_pane2, z_index_pane3, + "Different panes should have different z-indices" + ); +} + +#[test] +fn pinned_floating_pane_has_higher_z_index() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + let _client_id = 1; + + // Create first floating pane (will be unpinned) + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + + // Create second floating pane and pin it + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + tab.set_floating_pane_pinned(PaneId::Terminal(3), true); + + // Get z-indices + let z_index_unpinned = tab + .floating_panes + .get_pane_z_index(PaneId::Terminal(2)) + .expect("Unpinned pane should have z-index"); + let z_index_pinned = tab + .floating_panes + .get_pane_z_index(PaneId::Terminal(3)) + .expect("Pinned pane should have z-index"); + + assert!( + z_index_pinned > z_index_unpinned, + "Pinned pane should have higher z-index than unpinned pane (pinned: {}, unpinned: {})", + z_index_pinned, + z_index_unpinned + ); +} + +#[test] +fn pinned_pane_z_index_higher_than_regular_floating_panes() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + let _client_id = 1; + + // Create first floating pane + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + + // Create second floating pane + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + + // Pin the second pane so it's on top + tab.set_floating_pane_pinned(PaneId::Terminal(3), true); + + // Verify that get_pane_z_index returns correct values for both panes + let z_index_bottom = tab.floating_panes.get_pane_z_index(PaneId::Terminal(2)); + let z_index_top = tab.floating_panes.get_pane_z_index(PaneId::Terminal(3)); + + assert!( + z_index_bottom.is_some(), + "Regular floating pane should have z-index" + ); + assert!( + z_index_top.is_some(), + "Pinned floating pane should have z-index" + ); + assert!( + z_index_top.unwrap() > z_index_bottom.unwrap(), + "Pinned pane should have higher z-index than regular floating pane (pinned: {}, regular: {})", + z_index_top.unwrap(), + z_index_bottom.unwrap() + ); +} + +#[test] +fn active_pane_z_index_retrieved_for_cursor_visibility() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + let client_id = 1; + + // Start with tiled panes - active pane should not have z-index + let active_pane_id_tiled = tab.get_active_pane_id(client_id).unwrap(); + let z_index_tiled = tab.floating_panes.get_pane_z_index(active_pane_id_tiled); + assert!( + z_index_tiled.is_none(), + "Tiled pane should not have z-index in floating panes" + ); + + // Create a floating pane + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + + // Active pane should now have a z-index + let active_pane_id_floating = tab.get_active_pane_id(client_id).unwrap(); + let z_index_floating = tab.floating_panes.get_pane_z_index(active_pane_id_floating); + assert!( + z_index_floating.is_some(), + "Active floating pane should have a z-index" + ); +} + +#[test] +fn get_pane_z_index_returns_none_for_nonexistent_pane() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + let _client_id = 1; + + // Create two floating panes + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + + // Query for a pane that doesn't exist + let z_index_nonexistent = tab.floating_panes.get_pane_z_index(PaneId::Terminal(999)); + + assert!( + z_index_nonexistent.is_none(), + "Non-existent pane should return None for z-index" + ); + + // Query for existing panes + let z_index_2 = tab.floating_panes.get_pane_z_index(PaneId::Terminal(2)); + let z_index_3 = tab.floating_panes.get_pane_z_index(PaneId::Terminal(3)); + + assert!( + z_index_2.is_some(), + "Existing floating pane 2 should return Some for z-index" + ); + assert!( + z_index_3.is_some(), + "Existing floating pane 3 should return Some for z-index" + ); + assert_ne!( + z_index_2, z_index_3, + "Different floating panes should have different z-indices" + ); +} diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs index 326dd96f..6e4f8c74 100644 --- a/zellij-server/src/ui/pane_contents_and_ui.rs +++ b/zellij-server/src/ui/pane_contents_and_ui.rs @@ -161,8 +161,11 @@ impl<'a> PaneContentsAndUi<'a> { .pane .cursor_coordinates(Some(*fake_cursor_client_id)) .map(|(x, y)| { - self.output - .cursor_is_visible(self.pane.x() + x, self.pane.y() + y) + self.output.cursor_is_visible( + self.pane.x() + x, + self.pane.y() + y, + self.z_index, + ) }) .unwrap_or(false); if cursor_is_visible { |
