diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-05-01 11:42:04 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 11:42:04 +0200 |
| commit | b5e39d54246a845bc293cf1521d367b39f476772 (patch) | |
| tree | e390b2d6a3be3134eaff4b3d1ed28b0f4eb61516 /zellij-server | |
| parent | fix: buffer pty processing while waiting for host query reply (#5113) (diff) | |
| download | sl0ppij-b5e39d54246a845bc293cf1521d367b39f476772.tar.gz sl0ppij-b5e39d54246a845bc293cf1521d367b39f476772.tar.xz sl0ppij-b5e39d54246a845bc293cf1521d367b39f476772.zip | |
Fullscreen resize fixes (#5117)
* fix: properly handle resize while pane is in fullscreen
* fix: re-update fullscreen after closing editor pane
* rustfmt
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/panes/tiled_panes/mod.rs | 10 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 19 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_tests.rs | 328 |
3 files changed, 356 insertions, 1 deletions
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index bb867c13..afb9608c 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -161,6 +161,13 @@ impl TiledPanes { None => with_pane.reset_size_and_position_override(), }; self.panes.insert(with_pane_id, with_pane); + // The replacement inherits the removed pane's geom_override (above); + // if the removed pane was the fullscreen pane, retarget the fullscreen + // bookkeeping so unset_fullscreen can later reset the override on the + // pane that actually carries it. + if self.fullscreen_is_active == Some(pane_id) { + self.fullscreen_is_active = Some(with_pane_id); + } removed_pane }); @@ -2494,6 +2501,9 @@ impl TiledPanes { pub fn fullscreen_is_active(&self) -> bool { self.fullscreen_is_active.is_some() } + pub fn fullscreen_pane_id(&self) -> Option<PaneId> { + self.fullscreen_is_active + } pub fn unset_fullscreen(&mut self) { if let Some(fullscreen_pane_id) = self.fullscreen_is_active { let panes_to_hide: Vec<_> = self.panes_to_hide.iter().copied().collect(); diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 026dee9e..1b8f12e9 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -3504,6 +3504,16 @@ impl Tab { } pub fn resize_whole_tab(&mut self, new_screen_size: Size) -> Result<()> { let err_context = || format!("failed to resize whole tab (id {})", self.id); + // If a tiled pane is fullscreen, exit fullscreen first so that *all* + // tiled panes (including the currently hidden ones) participate in the + // resize/relayout. We re-enter fullscreen on the same pane after the + // resize so the user-visible state is preserved. Without this, hidden + // panes retain stale geometry from before the resize and the layout + // solver fails when fullscreen is later toggled off. + let fullscreen_pane_to_restore = self.tiled_panes.fullscreen_pane_id(); + if fullscreen_pane_to_restore.is_some() { + self.tiled_panes.unset_fullscreen(); + } self.floating_panes.resize(new_screen_size); // we need to do this explicitly because floating_panes.resize does not do this self.floating_panes @@ -3516,7 +3526,9 @@ impl Tab { self.swap_layouts.set_is_floating_damaged(); let _ = self.relayout_floating_panes(false); } - if self.auto_layout && !self.swap_layouts.is_tiled_damaged() && !self.is_fullscreen_active() + if self.auto_layout + && !self.swap_layouts.is_tiled_damaged() + && fullscreen_pane_to_restore.is_none() { self.swap_layouts.set_is_tiled_damaged(); let _ = self.relayout_tiled_panes(false); @@ -3531,6 +3543,11 @@ impl Tab { &mut self.tiled_panes, self.draw_pane_frames, ); + if let Some(pane_id) = fullscreen_pane_to_restore { + if self.tiled_panes.panes_contain(&pane_id) { + self.tiled_panes.toggle_pane_fullscreen(pane_id); + } + } Ok(()) } pub fn resize(&mut self, client_id: ClientId, strategy: ResizeStrategy) -> Result<()> { diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs index 512b4a66..338035de 100644 --- a/zellij-server/src/tab/unit/tab_tests.rs +++ b/zellij-server/src/tab/unit/tab_tests.rs @@ -1083,6 +1083,334 @@ pub fn toggle_focused_pane_fullscreen_with_stacked_resizes() { } #[test] +pub fn resize_whole_tab_while_fullscreen_preserves_fullscreen() { + // A host-terminal resize (e.g. a font size change) that arrives while a + // pane is fullscreen must keep the active pane fullscreened, sized to the + // new display dimensions. + let initial_size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(initial_size, stacked_resize); + for i in 2..5 { + let new_pane_id = PaneId::Terminal(i); + tab.new_pane( + new_pane_id, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + tab.toggle_active_pane_fullscreen(1); + assert!( + tab.is_fullscreen_active(), + "Tab is fullscreen before the resize" + ); + + let new_size = Size { cols: 80, rows: 30 }; + tab.resize_whole_tab(new_size).unwrap(); + + assert!( + tab.is_fullscreen_active(), + "Fullscreen is preserved across a host-terminal resize" + ); + let active_pane = tab + .tiled_panes + .panes + .get(&PaneId::Terminal(4)) + .expect("Active fullscreen pane is still present"); + assert_eq!( + active_pane.cols(), + new_size.cols, + "Fullscreen pane cols match the new display cols" + ); + assert_eq!( + active_pane.rows(), + new_size.rows, + "Fullscreen pane rows match the new display rows" + ); + assert_eq!(active_pane.x(), 0, "Fullscreen pane x is at viewport edge"); + assert_eq!(active_pane.y(), 0, "Fullscreen pane y is at viewport edge"); +} + +#[test] +pub fn resize_while_fullscreen_updates_hidden_pane_geometry() { + // When a host-terminal resize arrives while a pane is fullscreen, every + // hidden pane's geometry must be updated to match the new display area. + // Otherwise their `inner` cell counts stay sized for the old display and + // toggling fullscreen off hands the cassowary solver coordinates that + // fall outside the viewport, producing layout-solve failures and a + // corrupt render. + // + // The assertion here is the direct invariant: after the resize, every + // pane that is currently hidden behind the fullscreen pane fits inside + // the new display area. + let initial_size = Size { + cols: 200, + rows: 60, + }; + let new_size = Size { cols: 60, rows: 18 }; + let stacked_resize = false; + let mut tab = create_new_tab(initial_size, stacked_resize); + for i in 2..6 { + tab.new_pane( + PaneId::Terminal(i), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + + let active_pane_id = tab + .get_active_pane_id(1) + .expect("an active pane exists before fullscreen"); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen is active"); + + tab.resize_whole_tab(new_size).unwrap(); + + // Collect the panes hidden by the fullscreen state and verify each one + // already fits the new display area; if any extends beyond it, exiting + // fullscreen would hand the cassowary solver an unsatisfiable layout. + let hidden_pane_ids: Vec<PaneId> = tab + .tiled_panes + .panes + .keys() + .copied() + .filter(|id| *id != active_pane_id && tab.tiled_panes.panes_to_hide_contains(*id)) + .collect(); + assert!( + !hidden_pane_ids.is_empty(), + "the test setup actually produced hidden panes" + ); + for pane_id in hidden_pane_ids { + let pane = tab.tiled_panes.panes.get(&pane_id).unwrap(); + let geom = pane.position_and_size(); + assert!( + geom.x + geom.cols.as_usize() <= new_size.cols, + "hidden pane {pane_id:?} fits horizontally after resize: \ + x={}, cols={}, display_cols={}", + geom.x, + geom.cols.as_usize(), + new_size.cols, + ); + assert!( + geom.y + geom.rows.as_usize() <= new_size.rows, + "hidden pane {pane_id:?} fits vertically after resize: \ + y={}, rows={}, display_rows={}", + geom.y, + geom.rows.as_usize(), + new_size.rows, + ); + } +} + +#[test] +pub fn closing_fullscreen_scrollback_editor_restores_consistent_layout() { + // Replacing a pane (e.g. opening or closing a scrollback editor) swaps + // the pane id occupying its tiled slot. If the replaced pane was the + // fullscreen pane, the fullscreen bookkeeping must follow the swap so + // that toggling fullscreen off later resets the geom_override on the + // pane that actually carries it. Otherwise the restored pane keeps the + // 100% override, the previously-hidden panes come back into a layout + // that overlaps it, and the screen renders incorrectly. + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let client_id = 1; + let mut tab = create_new_tab(size, stacked_resize); + for i in 2..5 { + tab.new_pane( + PaneId::Terminal(i), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + } + let active_pane_id = tab + .get_active_pane_id(client_id) + .expect("active pane exists before opening editor"); + + let editor_pane_id = PaneId::Terminal(99); + tab.replace_active_pane_with_editor_pane(editor_pane_id, client_id) + .unwrap(); + assert_eq!( + tab.get_active_pane_id(client_id), + Some(editor_pane_id), + "editor pane is now active", + ); + + tab.toggle_active_pane_fullscreen(client_id); + assert!( + tab.is_fullscreen_active(), + "fullscreen is active on the editor pane", + ); + assert_eq!( + tab.tiled_panes.fullscreen_pane_id(), + Some(editor_pane_id), + "fullscreen tracks the editor pane id", + ); + + // Close the editor: this restores the originally-suppressed pane in the + // editor's slot. Fullscreen bookkeeping must retarget to the restored + // pane id so subsequent fullscreen-off cleanup hits the right pane. + tab.close_pane(editor_pane_id, false, None); + assert!( + tab.is_fullscreen_active(), + "fullscreen is preserved after the editor is closed", + ); + assert_eq!( + tab.tiled_panes.fullscreen_pane_id(), + Some(active_pane_id), + "fullscreen now tracks the restored suppressed pane", + ); + + tab.toggle_active_pane_fullscreen(client_id); + assert!( + !tab.is_fullscreen_active(), + "fullscreen is cleared after the second toggle", + ); + assert_eq!( + tab.tiled_panes.panes_to_hide_count(), + 0, + "no panes remain hidden after exiting fullscreen", + ); + let restored_pane = tab + .tiled_panes + .panes + .get(&active_pane_id) + .expect("restored pane is present"); + assert!( + restored_pane.geom_override().is_none(), + "restored pane no longer carries the fullscreen geom_override", + ); + for pane in tab.tiled_panes.panes.values() { + let geom = pane.position_and_size(); + assert!( + geom.x + geom.cols.as_usize() <= size.cols, + "pane fits horizontally after exiting fullscreen: \ + x={}, cols={}, display_cols={}", + geom.x, + geom.cols.as_usize(), + size.cols, + ); + assert!( + geom.y + geom.rows.as_usize() <= size.rows, + "pane fits vertically after exiting fullscreen: \ + y={}, rows={}, display_rows={}", + geom.y, + geom.rows.as_usize(), + size.rows, + ); + } +} + +#[test] +pub fn opening_scrollback_editor_on_fullscreen_pane_retargets_fullscreen() { + // Reverse-direction variant: fullscreen the pane *first*, then open the + // scrollback editor on it. The editor takes the fullscreen pane's slot + // and inherits its 100% geom_override, so the fullscreen bookkeeping + // must follow the swap onto the editor's pane id. Otherwise toggling + // fullscreen off later cannot reset the override on the editor. + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let client_id = 1; + let mut tab = create_new_tab(size, stacked_resize); + for i in 2..5 { + tab.new_pane( + PaneId::Terminal(i), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + } + let active_pane_id = tab + .get_active_pane_id(client_id) + .expect("active pane exists"); + + tab.toggle_active_pane_fullscreen(client_id); + assert_eq!( + tab.tiled_panes.fullscreen_pane_id(), + Some(active_pane_id), + "fullscreen tracks the original pane", + ); + + let editor_pane_id = PaneId::Terminal(99); + tab.replace_active_pane_with_editor_pane(editor_pane_id, client_id) + .unwrap(); + assert!( + tab.is_fullscreen_active(), + "fullscreen state survives the editor swap", + ); + assert_eq!( + tab.tiled_panes.fullscreen_pane_id(), + Some(editor_pane_id), + "fullscreen now tracks the editor pane id, not the suppressed one", + ); + + tab.toggle_active_pane_fullscreen(client_id); + assert!( + !tab.is_fullscreen_active(), + "fullscreen is cleared after the second toggle", + ); + let editor_pane = tab + .tiled_panes + .panes + .get(&editor_pane_id) + .expect("editor pane is present in tiled panes"); + assert!( + editor_pane.geom_override().is_none(), + "editor pane no longer carries the fullscreen geom_override", + ); + assert_eq!( + tab.tiled_panes.panes_to_hide_count(), + 0, + "no panes remain hidden after exiting fullscreen", + ); + for pane in tab.tiled_panes.panes.values() { + let geom = pane.position_and_size(); + assert!( + geom.x + geom.cols.as_usize() <= size.cols + && geom.y + geom.rows.as_usize() <= size.rows, + "pane fits inside the display area: \ + x={}, y={}, cols={}, rows={}, display={}x{}", + geom.x, + geom.y, + geom.cols.as_usize(), + geom.rows.as_usize(), + size.cols, + size.rows, + ); + } +} + +#[test] fn switch_to_next_pane_fullscreen() { let size = Size { cols: 121, |
