aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 19:16:39 +0200
committeruser <user@clank>2026-08-11 19:16:39 +0200
commit731c011d79b375329ebb40e80fadf1c884e778f1 (patch)
treece66b52a7736cab6f7883099b72579ecdc9f4fa8
parentsl0p: synthesize a stack layout when none exists to collapse into (diff)
downloadsl0ppij-731c011d79b375329ebb40e80fadf1c884e778f1.tar.gz
sl0ppij-731c011d79b375329ebb40e80fadf1c884e778f1.tar.xz
sl0ppij-731c011d79b375329ebb40e80fadf1c884e778f1.zip
sl0p: keep expanding possible after the pane count changes
A tab restores into whatever layout was active before it collapsed, which for a bare layout is the implicit BASE. BASE is pinned to ExactPanes of the count it was written with, so adding one pane while collapsed left nothing to expand into and the tab stayed stacked on every subsequent widening. Two fixes. When the stored layout no longer applies, synthesize a grid to spread the panes out rather than leaving them stacked -- a wide terminal is not a state that retries, so failing here strands the tab for good. And only consume the restore position once a layout has actually been applied. It was cleared unconditionally, so after a failed restore the tab believed it had expanded while visibly still collapsed, and never tried again -- which is why this looked like "expand stopped working" rather than "expand did nothing once".
-rw-r--r--README.md6
-rw-r--r--zellij-server/src/tab/mod.rs48
-rw-r--r--zellij-server/src/tab/swap_layouts.rs42
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs93
-rw-r--r--zellij-utils/src/input/layout.rs13
5 files changed, 185 insertions, 17 deletions
diff --git a/README.md b/README.md
index b13afff0..99d58102 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,12 @@ for a bare layout, and growing the terminal back restores the original
arrangement. It is logged once per tab so a name that does not match its swap
file is not mistaken for the fallback.
+Expanding falls back too. A tab restores into whatever layout was active before
+it collapsed, which for a bare layout is the implicit `BASE` — and `BASE` is
+pinned to the pane count it was written with, so adding or closing a single pane
+leaves nothing to expand into. When that happens the panes are spread into a
+grid rather than left stacked.
+
Define the layout yourself when you want something other than a flat stack:
```kdl
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index ab89010c..5719ba8c 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -5486,6 +5486,8 @@ impl Tab {
was_damaged_before_resize: bool,
) -> Result<()> {
const RESPONSIVE_HYSTERESIS_COLS: usize = 10;
+ // internal, so it cannot collide with a name from a user's swap file
+ const RESPONSIVE_EXPAND_LAYOUT_NAME: &str = "sl0p:responsive-expand";
if self.responsive_min_cols == 0 {
return Ok(());
}
@@ -5517,7 +5519,8 @@ impl Tab {
let wide_again =
new_size.cols >= self.responsive_min_cols + RESPONSIVE_HYSTERESIS_COLS;
- let target_position = match (self.responsive_restore_position, too_narrow, wide_again) {
+ let mut is_restore = false;
+ let mut target_position = match (self.responsive_restore_position, too_narrow, wide_again) {
// collapse: remember where we were, go to the responsive layout
(None, true, _) => {
let position = match self
@@ -5557,25 +5560,48 @@ impl Tab {
},
// restore: back to whatever was active before collapsing
(Some(restore_to), _, true) => {
- self.responsive_restore_position = None;
+ is_restore = true;
Some(restore_to)
},
_ => None,
};
- let Some(target_position) = target_position else {
+ let Some(mut target_position) = target_position else {
return Ok(());
};
- self.swap_layouts.set_tiled_layout_position(target_position);
- if let Some(layout) = self
+ let mut layout = self
.swap_layouts
- .tiled_layout_at(target_position, &self.tiled_panes)
- {
- self.dissolve_stack_lists_for_classic_mutation();
- self.apply_tiled_layout_candidate(&layout);
- self.tiled_panes.reapply_pane_frames();
- self.set_should_clear_display_before_rendering();
+ .tiled_layout_at(target_position, &self.tiled_panes);
+ if layout.is_none() && is_restore {
+ // The layout that was active before the collapse no longer applies.
+ // Usually BASE: it is pinned to ExactPanes of the count it was
+ // written with, so adding or closing a single pane leaves nothing to
+ // expand into. Without a fallback the tab stays stacked for good,
+ // because a wide terminal is not a state that retries.
+ let position = self
+ .swap_layouts
+ .append_synthesized_grid_layout(RESPONSIVE_EXPAND_LAYOUT_NAME);
+ layout = self
+ .swap_layouts
+ .tiled_layout_at(position, &self.tiled_panes);
+ if layout.is_some() {
+ target_position = position;
+ }
}
+ let Some(layout) = layout else {
+ // Changed nothing, so leave the state alone: clearing the restore
+ // position here would mark the tab expanded while it is visibly
+ // still collapsed, and it would never try again.
+ return Ok(());
+ };
+ if is_restore {
+ self.responsive_restore_position = None;
+ }
+ self.swap_layouts.set_tiled_layout_position(target_position);
+ self.dissolve_stack_lists_for_classic_mutation();
+ self.apply_tiled_layout_candidate(&layout);
+ self.tiled_panes.reapply_pane_frames();
+ self.set_should_clear_display_before_rendering();
Ok(())
}
pub fn resize_whole_tab(&mut self, new_screen_size: Size) -> Result<()> {
diff --git a/zellij-server/src/tab/swap_layouts.rs b/zellij-server/src/tab/swap_layouts.rs
index 54bcf8f3..0fc45bb9 100644
--- a/zellij-server/src/tab/swap_layouts.rs
+++ b/zellij-server/src/tab/swap_layouts.rs
@@ -17,8 +17,8 @@ pub struct SwapLayouts {
current_tiled_layout_position: usize,
is_floating_damaged: bool,
is_tiled_damaged: bool,
- /// Position of the layout synthesized for the responsive collapse, if any.
- synthesized_tiled_position: Option<usize>,
+ /// Positions of layouts synthesized for the responsive collapse.
+ synthesized_tiled_positions: Vec<usize>,
display_area: Rc<RefCell<Size>>, // includes all panes (including eg. the status bar and tab bar in the default layout)
}
@@ -94,14 +94,44 @@ impl SwapLayouts {
/// Appended rather than inserted so it cannot shift the position of the BASE
/// layout at 0, which is what a collapse restores back to.
pub fn append_synthesized_stack_layout(&mut self, name: &str) -> usize {
- let mut constraints = BTreeMap::new();
// MinPanes(2) because a "stack" of one pane is just that pane, and
// collapsing to it would be a no-op that still counted as collapsed
- constraints.insert(LayoutConstraint::MinPanes(2), TiledPaneLayout::single_stack());
+ self.append_synthesized_layout(
+ name,
+ LayoutConstraint::MinPanes(2),
+ TiledPaneLayout::single_stack(),
+ )
+ }
+ /// Append a synthesized "spread into a grid" layout, and return its position.
+ ///
+ /// The way back out of a collapse when the layout that was active before it
+ /// no longer applies -- typically BASE, which is pinned to the pane count it
+ /// was written with, so adding a single pane leaves the tab with nothing to
+ /// expand into and stuck stacked.
+ pub fn append_synthesized_grid_layout(&mut self, name: &str) -> usize {
+ // NoConstraint: this is the fallback, so it must never be the thing that
+ // fails to apply. Feasibility is still checked against the display area.
+ self.append_synthesized_layout(
+ name,
+ LayoutConstraint::NoConstraint,
+ TiledPaneLayout::single_grid(),
+ )
+ }
+ fn append_synthesized_layout(
+ &mut self,
+ name: &str,
+ constraint: LayoutConstraint,
+ layout: TiledPaneLayout,
+ ) -> usize {
+ if let Some(existing) = self.position_of_tiled_layout_named(name) {
+ return existing;
+ }
+ let mut constraints = BTreeMap::new();
+ constraints.insert(constraint, layout);
self.swap_tiled_layouts
.push((constraints, Some(name.to_string())));
let position = self.swap_tiled_layouts.len() - 1;
- self.synthesized_tiled_position = Some(position);
+ self.synthesized_tiled_positions.push(position);
position
}
/// Whether this tab has any tiled swap layout the USER defined.
@@ -118,7 +148,7 @@ impl SwapLayouts {
.iter()
.enumerate()
.any(|(position, (_constraints, name))| {
- Some(position) != self.synthesized_tiled_position
+ !self.synthesized_tiled_positions.contains(&position)
&& name.as_deref() != Some("BASE")
})
}
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index 159cf946..319fde2a 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -16531,3 +16531,96 @@ fn sl0p_responsive_collapse_synthesizes_a_stack_when_the_layout_has_no_swap_layo
"the synthesized layout should be reusable, not rebuilt each time"
);
}
+
+#[test]
+fn sl0p_responsive_expands_again_after_a_pane_is_added_while_collapsed() {
+ // REGRESSION: the tab restores into whatever layout was active before the
+ // collapse, which for a bare layout is BASE -- and BASE is pinned to
+ // ExactPanes of the count it was written with. Add one pane and it can never
+ // apply again, so the tab stayed stacked on every subsequent widening.
+ //
+ // Worse, the restore position was cleared regardless of whether anything was
+ // applied, so the tab believed it had expanded while visibly stacked and
+ // never retried.
+ let wide = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab_with_swap_layouts(
+ wide,
+ ModeInfo::default(),
+ (vec![], vec![]),
+ None,
+ true,
+ true,
+ );
+ tab.update_responsive_layout(80, String::from("stacked"));
+ for pid in [2, 3] {
+ tab.new_pane(
+ PaneId::Terminal(pid),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ }
+
+ let widths = |tab: &Tab| -> Vec<usize> {
+ let mut v: Vec<usize> = tab
+ .tiled_panes
+ .panes
+ .values()
+ .map(|p| p.position_and_size().cols.as_usize())
+ .collect();
+ v.sort_unstable();
+ v
+ };
+ let all_full_width = |tab: &Tab, cols: usize| widths(tab).iter().all(|w| *w == cols);
+
+ let narrow = Size { cols: 70, rows: 20 };
+ tab.resize_whole_tab(narrow).unwrap();
+ assert!(
+ all_full_width(&tab, narrow.cols),
+ "precondition: should have collapsed, got {:?}",
+ widths(&tab)
+ );
+
+ // add a pane WHILE collapsed, which is what makes BASE inapplicable
+ tab.new_pane(
+ PaneId::Terminal(4),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+
+ tab.resize_whole_tab(wide).unwrap();
+ assert!(
+ !all_full_width(&tab, wide.cols),
+ "a tab must still expand after a pane was added while collapsed, got widths {:?}",
+ widths(&tab)
+ );
+
+ // and it must still work a second time round
+ tab.resize_whole_tab(narrow).unwrap();
+ assert!(
+ all_full_width(&tab, narrow.cols),
+ "should collapse again after expanding, got {:?}",
+ widths(&tab)
+ );
+ tab.resize_whole_tab(wide).unwrap();
+ assert!(
+ !all_full_width(&tab, wide.cols),
+ "should expand again on the second round trip, got {:?}",
+ widths(&tab)
+ );
+}
diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs
index 912464f4..c3655151 100644
--- a/zellij-utils/src/input/layout.rs
+++ b/zellij-utils/src/input/layout.rs
@@ -959,6 +959,19 @@ pub struct TiledPaneLayout {
}
impl TiledPaneLayout {
+ /// A layout that spreads whatever panes it receives into a grid.
+ ///
+ /// Used to expand out of a responsive collapse when the layout that was
+ /// active beforehand no longer applies -- typically the tab's BASE layout,
+ /// which is pinned to the pane count it was written with and so stops
+ /// matching the moment a pane is added or closed.
+ pub fn single_grid() -> Self {
+ TiledPaneLayout {
+ children_arrangement: Some(PaneArrangement::Grid),
+ external_children_index: Some(0),
+ ..Default::default()
+ }
+ }
/// A layout that flattens whatever panes it receives into one stack, in the
/// order they appear.
///