diff options
| author | user <user@clank> | 2026-08-11 17:00:37 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-11 17:00:37 +0200 |
| commit | 508b5aa87ced4c7aab38bcb0e34c4158e5b4e101 (patch) | |
| tree | 511485d45371596801f25354f62bc938e882a890 | |
| parent | sl0p: warn when the responsive layout has no swap layout to collapse into (diff) | |
| download | sl0ppij-508b5aa87ced4c7aab38bcb0e34c4158e5b4e101.tar.gz sl0ppij-508b5aa87ced4c7aab38bcb0e34c4158e5b4e101.tar.xz sl0ppij-508b5aa87ced4c7aab38bcb0e34c4158e5b4e101.zip | |
sl0p: synthesize a stack layout when none exists to collapse into
The collapse selects a swap layout by name, and a custom layout file only gets
swap layouts from a hand-written sibling "<layout>.swap.kdl". So for most
layouts there was nothing to select and the feature silently did nothing --
requiring a boilerplate file per layout to use it at all.
Now a missing layout is synthesized: every pane flattened into one stack, in
layout order. Appended rather than inserted, so it cannot shift the BASE layout
at position 0, which is what the collapse restores back to.
Synthesized lazily, at the first collapse, rather than up front: a stack sitting
in the swap cycle would let auto_layout apply it spontaneously when a pane is
added, stacking panes on a perfectly wide terminal.
Also scopes the damaged-layout guard. With no swap layouts, relayout finds
nothing applicable on the first pane added, gives up and marks the tab damaged
-- permanently, and with nothing to do with user intent. Honouring that flag
disabled the collapse for exactly the bare layouts this fallback targets, so it
is now only consulted when the tab has swap layouts the user actually defined.
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | quaddie.kdl | 21 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 56 | ||||
| -rw-r--r-- | zellij-server/src/tab/swap_layouts.rs | 41 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 73 | ||||
| -rw-r--r-- | zellij-utils/src/input/layout.rs | 23 |
6 files changed, 204 insertions, 21 deletions
@@ -75,8 +75,15 @@ swap layout named by `responsive_layout`; when it grows back, the layout that wa active before the collapse is restored. Splitting a wide window into thirds is great until the window is 70 columns wide and each pane gets 23. -This selects a swap layout **by name**, so it only does something if your layout -file defines one with that name: +If no swap layout by that name exists, one is **synthesized**: every pane +flattened into a single stack, in the order they appear in the layout. That is +the normal case, because a custom layout file only picks up swap layouts from a +hand-written sibling `<layout>.swap.kdl` — so the collapse works out of the box +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. + +Define the layout yourself when you want something other than a flat stack: ```kdl layout { diff --git a/quaddie.kdl b/quaddie.kdl new file mode 100644 index 00000000..c9e06c09 --- /dev/null +++ b/quaddie.kdl @@ -0,0 +1,21 @@ +layout { + pane split_direction="vertical" { + pane { + command "nvim" + } + + pane { + command "top" + } + } + + pane split_direction="vertical" { + pane { + command "btop" + } + + pane { + command "htop" + } + } +} diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index ad17c76d..ab89010c 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -5503,7 +5503,14 @@ impl Tab { // The restore_position exemption matters for the same reason: once we // have collapsed, a later resize that damages the layout must not strand // the user in the narrow layout forever, so restore stays reachable. - if was_damaged_before_resize && self.responsive_restore_position.is_none() { + // Only meaningful when the tab actually has user-defined swap layouts; + // see SwapLayouts::has_user_defined_tiled_layouts for why the flag says + // nothing about user intent otherwise. + let damage_means_user_intent = self.swap_layouts.has_user_defined_tiled_layouts(); + if damage_means_user_intent + && was_damaged_before_resize + && self.responsive_restore_position.is_none() + { return Ok(()); } let too_narrow = new_size.cols < self.responsive_min_cols; @@ -5513,25 +5520,36 @@ impl Tab { let 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 = self + let position = match self .swap_layouts - .position_of_tiled_layout_named(&self.responsive_layout_name); - let Some(position) = position else { - // Loud, because the failure is otherwise invisible: the - // terminal just does not collapse, and the usual cause is a - // layout with no swap layouts at all rather than anything - // about the responsive settings. Once per tab, since this - // runs on every resize. - if !self.responsive_missing_layout_warned { - self.responsive_missing_layout_warned = true; - log::warn!( - "responsive_min_cols is set, but this tab has no swap layout named {:?}, so it cannot collapse. \ - Swap layouts come from a `<layout>.swap.kdl` file next to your layout file, \ - or from `swap_tiled_layout` nodes inside it.", - self.responsive_layout_name - ); - } - return Ok(()); + .position_of_tiled_layout_named(&self.responsive_layout_name) + { + Some(position) => position, + None => { + // No layout by that name, which is the normal case: a + // custom layout file only gets swap layouts from a + // hand-written sibling `<layout>.swap.kdl`. Synthesize + // the one thing the collapse needs -- every pane + // flattened into a single stack, in layout order -- + // rather than making the feature depend on that file. + // + // Logged once per tab, because a synthesized layout is + // not what a user who DID write a swap file expects, and + // a silent fallback would hide the name not matching. + if !self.responsive_missing_layout_warned { + self.responsive_missing_layout_warned = true; + log::info!( + "no swap layout named {:?} in this tab, so the responsive collapse \ + synthesized one that flattens every pane into a single stack. \ + Define `swap_tiled_layout name={:?}` in a `<layout>.swap.kdl` \ + beside your layout file to control this yourself.", + self.responsive_layout_name, + self.responsive_layout_name + ); + } + self.swap_layouts + .append_synthesized_stack_layout(&self.responsive_layout_name) + }, }; self.responsive_restore_position = Some(self.swap_layouts.current_tiled_layout_position()); diff --git a/zellij-server/src/tab/swap_layouts.rs b/zellij-server/src/tab/swap_layouts.rs index 25e02190..54bcf8f3 100644 --- a/zellij-server/src/tab/swap_layouts.rs +++ b/zellij-server/src/tab/swap_layouts.rs @@ -17,6 +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>, display_area: Rc<RefCell<Size>>, // includes all panes (including eg. the status bar and tab bar in the default layout) } @@ -81,6 +83,45 @@ impl SwapLayouts { pub fn is_tiled_damaged(&self) -> bool { self.is_tiled_damaged } + /// Append a synthesized "flatten into one stack" layout under `name`, and + /// return its position. + /// + /// A custom layout file only gets swap layouts from a hand-written sibling + /// `<layout>.swap.kdl`, so most layouts have none and the responsive + /// collapse has nothing to select. Rather than require that file, synthesize + /// the one layout the collapse actually needs. + /// + /// 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.swap_tiled_layouts + .push((constraints, Some(name.to_string()))); + let position = self.swap_tiled_layouts.len() - 1; + self.synthesized_tiled_position = Some(position); + position + } + /// Whether this tab has any tiled swap layout the USER defined. + /// + /// "BASE" is inserted from the tab's own layout, and the synthesized stack is + /// ours, so neither says anything about user intent. Matters because the + /// damaged flag means two different things: with user swap layouts it tracks + /// "the arrangement was changed by hand", but with none it is set on the + /// first pane added -- relayout finds nothing applicable and gives up -- and + /// then never clears, which would disable the responsive collapse entirely + /// for exactly the bare layouts it is most useful for. + pub fn has_user_defined_tiled_layouts(&self) -> bool { + self.swap_tiled_layouts + .iter() + .enumerate() + .any(|(position, (_constraints, name))| { + Some(position) != self.synthesized_tiled_position + && name.as_deref() != Some("BASE") + }) + } /// 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-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index 3868f8fb..159cf946 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -16458,3 +16458,76 @@ fn sl0p_stack_list_gaps_cover_every_column_no_cell_draws_to() { } } } + +#[test] +fn sl0p_responsive_collapse_synthesizes_a_stack_when_the_layout_has_no_swap_layouts() { + // The common case, and the one that made this look broken: a custom layout + // file only gets swap layouts from a hand-written sibling + // "<layout>.swap.kdl", so most layouts have NONE and the collapse had + // nothing to select by name. It must synthesize one instead. + let wide = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + // deliberately NO swap layouts, exactly like a bare layout file + 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); + + assert!( + !all_full_width(&tab, wide.cols), + "precondition: panes should be side by side while wide, got {:?}", + widths(&tab) + ); + + let narrow = Size { cols: 70, rows: 20 }; + tab.resize_whole_tab(narrow).unwrap(); + assert!( + all_full_width(&tab, narrow.cols), + "a layout with no swap layouts should still collapse, got widths {:?}", + widths(&tab) + ); + + // and the synthesis must not be repeated on every resize + tab.resize_whole_tab(Size { cols: 65, rows: 20 }).unwrap(); + tab.resize_whole_tab(narrow).unwrap(); + assert_eq!( + tab.swap_layouts + .position_of_tiled_layout_named("stacked") + .is_some(), + true, + "the synthesized layout should be reusable, not rebuilt each time" + ); +} diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs index 4162811f..912464f4 100644 --- a/zellij-utils/src/input/layout.rs +++ b/zellij-utils/src/input/layout.rs @@ -959,6 +959,29 @@ pub struct TiledPaneLayout { } impl TiledPaneLayout { + /// A layout that flattens whatever panes it receives into one stack, in the + /// order they appear. + /// + /// Synthesized when a responsive collapse is configured but the layout has + /// no swap layout to collapse into -- which is the normal case, since a + /// custom layout only gets swap layouts from a hand-written sibling + /// `<layout>.swap.kdl`. Equivalent to: + /// + /// ```kdl + /// pane stacked=true { children; } + /// ``` + pub fn single_stack() -> Self { + TiledPaneLayout { + children_are_stacked: true, + // where the tab's existing panes get inserted; the stack keeps them + // in layout order + external_children_index: Some(0), + // NB: a stacked pane may not split vertically, and Horizontal is the + // Default, but state it rather than rely on that staying true + children_split_direction: SplitDirection::Horizontal, + ..Default::default() + } + } pub fn insert_children_layout( &mut self, children_layout: &mut TiledPaneLayout, |
