diff options
| author | user <user@clank> | 2026-08-11 20:26:57 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-11 20:26:57 +0200 |
| commit | 77818ea9b24eb5d1a57f85b94aedc7bba7cbe8b1 (patch) | |
| tree | b3a2f85123459fc7bb8016401c1c585c523b628d | |
| parent | sl0p: add a minimal style for the stacked-pane picker (diff) | |
| download | sl0ppij-77818ea9b24eb5d1a57f85b94aedc7bba7cbe8b1.tar.gz sl0ppij-77818ea9b24eb5d1a57f85b94aedc7bba7cbe8b1.tar.xz sl0ppij-77818ea9b24eb5d1a57f85b94aedc7bba7cbe8b1.zip | |
sl0p: keep plugin panes pinned when synthesizing a layout
Collapsing and expanding a tab from the default layout stranded the status bar
halfway down the screen, and left the collapsed stack three rows tall.
A synthesized layout describes only where the user's panes go. Applied bare, it
laid out EVERY pane in the tab -- including the status bar, which became an
ordinary grid cell. Real swap layouts do not hit this because their `tab` node
is expanded with the default tab template, which carries the pinned plugin
slots; a layout built at runtime has to take those from BASE instead.
Synthesis now builds into the tab's own structure: the first non-plugin leaf
becomes the stack or grid and inherits its size, further content leaves are
dropped since the replacement accounts for every pane itself, and plugin panes
keep their slots and sizes.
Falls back to the bare replacement when there is no content slot at all -- a
layout of nothing but plugin panes would otherwise be returned unchanged,
silently dropping every user pane. Found by the test, not by the drive.
Only reproducible with a layout that has plugin panes, which is why the
quaddie.kdl drives never showed it and the default layout did immediately.
| -rw-r--r-- | zellij-server/src/tab/swap_layouts.rs | 20 | ||||
| -rw-r--r-- | zellij-utils/src/input/layout.rs | 69 | ||||
| -rw-r--r-- | zellij-utils/src/input/unit/layout_test.rs | 94 |
3 files changed, 183 insertions, 0 deletions
diff --git a/zellij-server/src/tab/swap_layouts.rs b/zellij-server/src/tab/swap_layouts.rs index 2739444b..016ba5fa 100644 --- a/zellij-server/src/tab/swap_layouts.rs +++ b/zellij-server/src/tab/swap_layouts.rs @@ -129,12 +129,32 @@ impl SwapLayouts { if let Some(existing) = self.position_of_tiled_layout_named(name) { return existing; } + // Build it into the tab's own structure rather than replacing it, so + // plugin panes (status bar, tab bar) keep their pinned slots. Real swap + // layouts get this from the default tab template; a runtime-built one + // has to take it from BASE. + let layout = match self.base_tiled_layout() { + Some(base) => base.with_content_replaced_by(&layout), + None => layout, + }; let mut constraints = BTreeMap::new(); constraints.insert(constraint, layout); self.swap_tiled_layouts .push((constraints, Some(name.to_string()))); self.swap_tiled_layouts.len() - 1 } + /// The tab's own layout, as inserted by set_base_layout. Carries the plugin + /// pane slots a synthesized layout has to preserve. + fn base_tiled_layout(&self) -> Option<TiledPaneLayout> { + let position = self.position_of_tiled_layout_named(BASE_LAYOUT_NAME)?; + self.swap_tiled_layouts + .iter() + .nth(position)? + .0 + .values() + .next() + .cloned() + } /// Position of the swap tiled layout with this name, if any. /// /// Used by the responsive-layout switch, which selects a layout BY NAME diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs index c3655151..df43a2e8 100644 --- a/zellij-utils/src/input/layout.rs +++ b/zellij-utils/src/input/layout.rs @@ -959,6 +959,75 @@ pub struct TiledPaneLayout { } impl TiledPaneLayout { + /// Whether this node is a plugin pane, i.e. UI furniture like a tab bar or + /// status bar rather than one of the user's panes. + pub fn is_plugin_pane(&self) -> bool { + matches!(self.run, Some(Run::Plugin(_))) + } + /// This layout with its non-plugin content replaced by `replacement`. + /// + /// A synthesized layout describes only where the user's panes go, but the + /// tab it is applied to may also contain plugin panes -- a status bar pinned + /// to one row at the bottom, say. Applying a bare layout lays those out as + /// ordinary members, which strands the status bar in the middle of the + /// screen. Real swap layouts avoid this because their `tab` node is expanded + /// with the default tab template, which carries those slots; this does the + /// same for a layout built at runtime. + /// + /// The first non-plugin leaf becomes `replacement` and inherits its size; + /// any further non-plugin leaves are dropped, since `replacement` accounts + /// for every user pane on its own. + pub fn with_content_replaced_by(&self, replacement: &TiledPaneLayout) -> TiledPaneLayout { + let mut replaced = false; + let result = self.replace_content_inner(replacement, &mut replaced); + match (result, replaced) { + (Some(layout), true) => layout, + // No slot to put it in -- a layout of nothing but plugin panes, or an + // empty one. Returning it unchanged would silently drop every user + // pane, so fall back to the replacement on its own. + _ => replacement.clone(), + } + } + fn replace_content_inner( + &self, + replacement: &TiledPaneLayout, + replaced: &mut bool, + ) -> Option<TiledPaneLayout> { + if self.children.is_empty() && self.external_children_index.is_none() { + if self.is_plugin_pane() { + return Some(self.clone()); + } + if *replaced { + return None; + } + *replaced = true; + let mut replacement = replacement.clone(); + replacement.split_size = self.split_size; + return Some(replacement); + } + if self.children.is_empty() { + // the `children` placeholder itself is where the user's panes go + if *replaced { + return None; + } + *replaced = true; + let mut replacement = replacement.clone(); + replacement.split_size = self.split_size; + return Some(replacement); + } + let children: Vec<TiledPaneLayout> = self + .children + .iter() + .filter_map(|child| child.replace_content_inner(replacement, replaced)) + .collect(); + if children.is_empty() { + return None; + } + let mut node = self.clone(); + node.children = children; + node.external_children_index = None; + Some(node) + } /// A layout that spreads whatever panes it receives into a grid. /// /// Used to expand out of a responsive collapse when the layout that was diff --git a/zellij-utils/src/input/unit/layout_test.rs b/zellij-utils/src/input/unit/layout_test.rs index 45537bd8..6c219b2b 100644 --- a/zellij-utils/src/input/unit/layout_test.rs +++ b/zellij-utils/src/input/unit/layout_test.rs @@ -2691,3 +2691,97 @@ fn sl0p_arrangement_without_a_children_node_is_rejected() { "an arrangement without a children node must be rejected" ); } + +// --- sl0p: preserving plugin panes when synthesizing a layout ------------ + +#[cfg(test)] +fn plugin_pane_layout() -> TiledPaneLayout { + TiledPaneLayout { + run: Some(Run::Plugin(RunPluginOrAlias::Alias(PluginAlias::new( + "status-bar", + &None, + None, + )))), + split_size: Some(SplitSize::Fixed(1)), + ..Default::default() + } +} + +#[test] +fn sl0p_with_content_replaced_by_keeps_plugin_panes_pinned() { + // REGRESSION: a synthesized layout describes only where the user's panes go. + // Applied bare to a tab that also has a status bar, the status bar became an + // ordinary member and was stranded mid-screen. Real swap layouts avoid this + // because their `tab` node is expanded with the default tab template, which + // carries those slots -- a runtime-built layout has to do the same. + // + // Mirrors the default layout: `default_tab_template { children; pane size=1 + // borderless { plugin } }`. + let base = TiledPaneLayout { + children: vec![TiledPaneLayout::default(), plugin_pane_layout()], + ..Default::default() + }; + let replacement = TiledPaneLayout::single_stack(); + let result = base.with_content_replaced_by(&replacement); + + assert_eq!(result.children.len(), 2, "both slots should survive"); + assert!( + result.children[0].children_are_stacked, + "the content slot should have become the stack, got {:?}", + result.children[0] + ); + assert!( + result.children[1].is_plugin_pane(), + "the plugin pane must be preserved, got {:?}", + result.children[1] + ); + assert_eq!( + result.children[1].split_size, + Some(SplitSize::Fixed(1)), + "and keep its pinned size" + ); +} + +#[test] +fn sl0p_with_content_replaced_by_collapses_several_content_panes_into_one() { + // The replacement accounts for every user pane by itself, so the other + // content leaves must be dropped rather than left as empty slots that would + // steal screen space. + let base = TiledPaneLayout { + children: vec![ + TiledPaneLayout::default(), + TiledPaneLayout::default(), + plugin_pane_layout(), + TiledPaneLayout::default(), + ], + ..Default::default() + }; + let result = base.with_content_replaced_by(&TiledPaneLayout::single_grid()); + assert_eq!( + result.children.len(), + 2, + "three content panes collapse to one, plugin kept: {:?}", + result.children + ); + assert_eq!( + result.children[0].children_arrangement, + Some(PaneArrangement::Grid) + ); + assert!(result.children[1].is_plugin_pane()); +} + +#[test] +fn sl0p_with_content_replaced_by_falls_back_when_there_is_no_content_slot() { + // A layout of nothing but plugins has nowhere to put the panes; returning it + // unchanged would silently drop every user pane. + let base = TiledPaneLayout { + children: vec![plugin_pane_layout()], + ..Default::default() + }; + let result = base.with_content_replaced_by(&TiledPaneLayout::single_stack()); + assert!( + result.children_are_stacked, + "should fall back to the replacement itself, got {:?}", + result + ); +} |
