aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-05-01 11:42:04 +0200
committerGitHub <noreply@github.com>2026-05-01 11:42:04 +0200
commitb5e39d54246a845bc293cf1521d367b39f476772 (patch)
treee390b2d6a3be3134eaff4b3d1ed28b0f4eb61516 /zellij-server
parentfix: buffer pty processing while waiting for host query reply (#5113) (diff)
downloadsl0ppij-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.rs10
-rw-r--r--zellij-server/src/tab/mod.rs19
-rw-r--r--zellij-server/src/tab/unit/tab_tests.rs328
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,