aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 00:12:10 +0200
committeruser <user@clank>2026-08-11 00:12:10 +0200
commit393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3 (patch)
tree09a651d4640e18398df90cdac0857e8602bcd5a0
parentsl0p: refresh snapshots for the compact scroll indicator (diff)
downloadsl0ppij-393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3.tar.gz
sl0ppij-393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3.tar.xz
sl0ppij-393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3.zip
sl0p: collapse to a swap layout when the terminal gets narrow
Splitting a window into thirds is great until the window is 70 columns wide and each pane gets 23. `responsive_min_cols` collapses the tab into the swap layout named by `responsive_layout` when it gets narrower than that, and restores the previously active layout when it grows back. Selects a swap layout BY NAME rather than adding a size-aware LayoutConstraint: that type crosses both the plugin protobuf API and the client/server contract, and the committed prost assets cannot be regenerated here (no protoc). Restoring uses a 10-column hysteresis band. Without a dead band, dragging a window edge that lands exactly on the threshold flips the layout on every motion event, and every flip resizes every PTY in the tab. "Did the user rearrange panes by hand" is judged from the damaged flag as sampled BEFORE each resize, not the live one: resize_whole_tab's own auto-layout pass sets that flag itself and clears it again only if it found a layout that fits, so on a terminal too narrow for any of them it latches true although nothing was rearranged. And once collapsed, restore stays reachable regardless of the flag, so damage occurring while collapsed cannot strand the tab in the narrow layout.
-rw-r--r--README.md85
-rw-r--r--zellij-server/src/lib.rs2
-rw-r--r--zellij-server/src/screen.rs30
-rw-r--r--zellij-server/src/tab/mod.rs165
-rw-r--r--zellij-server/src/tab/swap_layouts.rs50
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs287
-rw-r--r--zellij-server/src/unit/screen_tests.rs10
-rw-r--r--zellij-utils/src/input/config.rs23
-rw-r--r--zellij-utils/src/input/theme.rs13
-rw-r--r--zellij-utils/src/kdl/mod.rs10
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap2
15 files changed, 651 insertions, 34 deletions
diff --git a/README.md b/README.md
index 33fa9f28..3686df35 100644
--- a/README.md
+++ b/README.md
@@ -57,10 +57,80 @@ ui {
// Applies to `gap`, and to the 1-value form of `padding`. The 2- and
// 4-value padding forms are taken literally. Set to 1 to disable.
gap_aspect 2 // default: 2
+
+ // Collapse to a named swap layout when the terminal gets narrow, and
+ // restore the previous one when it grows back. 0 disables (default).
+ // See "Responsive layouts" below -- this one needs swap layouts in your
+ // layout file to do anything.
+ responsive_min_cols 90 // default: 0 (off)
+ responsive_layout "stacked" // default: "stacked"
+ }
+}
+```
+
+### Responsive layouts
+
+When the terminal is narrower than `responsive_min_cols`, the tab switches to the
+swap layout named by `responsive_layout`; when it grows back, the layout that was
+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:
+
+```kdl
+layout {
+ pane split_direction="vertical" {
+ pane
+ pane
+ pane
+ }
+ swap_tiled_layout name="tiled" {
+ tab {
+ pane split_direction="vertical" {
+ pane
+ pane
+ pane
+ }
+ }
+ }
+ swap_tiled_layout name="stacked" {
+ tab min_panes=2 {
+ pane stacked=true {
+ children
+ }
+ }
}
}
```
+(One node per line: zellij's KDL parser rejects the `{ pane; pane; }` one-liner form.)
+
+Name-based rather than a new size-aware `LayoutConstraint` on purpose: that type
+crosses both the plugin protobuf API and the client/server contract, and the
+committed prost assets cannot be regenerated without `protoc`.
+
+Two deliberate behaviours:
+
+- **Hysteresis.** Collapse happens below `responsive_min_cols`, but restore only
+ above `responsive_min_cols + 10`. Without that dead band, dragging a window edge
+ that lands exactly on the threshold flips the layout on every motion event, and
+ every flip resizes every PTY in the tab.
+- **Manual arrangement wins.** Once you have rearranged panes yourself (what zellij
+ calls a *damaged* swap layout), the responsive switch stops acting, on the same
+ principle as upstream's `auto_layout`. An explicit arrangement outranks an
+ automatic one. Note this includes opening a pane with an explicit direction
+ (`NewPane "Right"`, `zellij action new-pane -d right`): that is an explicit
+ arrangement, so a tab built that way will not collapse. A plain `NewPane` is
+ not, and will.
+- **Escaping the collapse is always possible.** "Damaged" is judged as of *before*
+ each resize, because `resize_whole_tab`'s own auto-layout pass sets that flag
+ itself and only clears it again if it found a layout that fits — on a very
+ narrow terminal it may find none, which would otherwise look like a manual
+ rearrangement that never happened. And once collapsed, restore stays reachable
+ regardless, so damage occurring while collapsed cannot strand the tab in the
+ narrow layout.
+
### Drag a pane title to rearrange it
Dragging a tiled pane by its title bar and dropping it on another pane swaps the
@@ -107,6 +177,7 @@ Also in the fork, not configurable:
| change | applies |
|---|---|
| `title_alignment`, `gap`, `padding`, `gap_aspect` | **live**, no restart |
+| `responsive_min_cols`, `responsive_layout` | **live**, applies on the next resize |
| `rounded_corners`, and other stock options | **live** (upstream behaviour) |
| anything requiring a new binary | **new session** — a running server keeps its old inode |
@@ -121,9 +192,14 @@ cargo build --profile fastdev -p zellij # ~3s incremental -> target/fastdev
cargo build --release -p zellij # ~3min -> ship this one
```
-See `.fastfeedback/SPEED.md` for narrow test commands, measured timings, and the
-pre-existing test-failure baselines (this tree has failures that are **not** caused by
-the fork — check against `upstream/main` before chasing one).
+See `.fastfeedback/SPEED.md` for narrow test commands and measured timings.
+
+The test suite is **green** (`cargo test -p zellij-server -p zellij-utils`), so any
+failure is a real one. It was not always: the fork carried 35 failing snapshot tests
+from its first commit until 5f2c28ab, because `aa51848d` changed the frame rendering
+without refreshing the `insta` snapshots. If you change anything about how frames or
+pane titles render, expect to regenerate snapshots — and read the diffs before
+accepting them.
## Known rough edges
@@ -138,6 +214,9 @@ the fork — check against `upstream/main` before chasing one).
inter-pane gap does not apply.
- **Stacked panes** apply the gap uniformly; stack header spacing may look slightly off
at larger gaps.
+- **Responsive layouts need a swap layout with a matching name.** `responsive_layout`
+ selects by name; if no swap layout has that name the tab silently stays as it is,
+ with nothing logged. It is also column-only — there is no `responsive_min_rows`.
- The fork's options are **not** exposed over the plugin protobuf API. Plugins have no
use for them and adding proto fields would mean regenerating the committed prost
assets in `zellij-utils/assets/prost/`.
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 43101ea7..c96c3b48 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -465,6 +465,8 @@ impl SessionMetaData {
pane_padding: new_config.ui.pane_frames.resolved_padding(),
inactive_pane_grayscale: new_config.ui.pane_frames.inactive_grayscale,
hide_default_pane_names: new_config.ui.pane_frames.hide_default_pane_names,
+ responsive_min_cols: new_config.ui.pane_frames.responsive_min_cols,
+ responsive_layout_name: new_config.ui.pane_frames.responsive_layout.clone(),
stacked_resize: new_config.options.stacked_resize.unwrap_or(true),
stacked_pane_list: new_config.options.stacked_pane_list.unwrap_or(true),
default_editor: new_config.options.scrollback_editor.clone(),
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 8083de12..20ddff41 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -782,6 +782,8 @@ pub enum ScreenInstruction {
pane_padding: PanePadding,
inactive_pane_grayscale: u8,
hide_default_pane_names: bool,
+ responsive_min_cols: usize,
+ responsive_layout_name: String,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -1556,6 +1558,8 @@ pub(crate) struct Screen {
visual_bell: bool,
focus_follows_mouse: bool,
mouse_click_through: bool,
+ responsive_min_cols: usize,
+ responsive_layout_name: String,
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
// the below are the configured values - the ones that will be set if and when the web server
// is brought online
@@ -1676,6 +1680,8 @@ impl Screen {
web_server_ip: IpAddr,
web_server_port: u16,
nested_session_handling: NestedSessionHandling,
+ responsive_min_cols: usize,
+ responsive_layout_name: String,
) -> Self {
let session_name = mode_info.session_name.clone().unwrap_or_default();
let session_info = SessionInfo::new(session_name.clone());
@@ -1740,6 +1746,8 @@ impl Screen {
visual_bell,
focus_follows_mouse,
mouse_click_through,
+ responsive_min_cols,
+ responsive_layout_name,
web_server_ip,
web_server_port,
render_blocker: RenderBlocker::new(100),
@@ -4498,6 +4506,10 @@ impl Screen {
self.web_server_port,
mobile_tab_count,
);
+ tab.update_responsive_layout(
+ self.responsive_min_cols,
+ self.responsive_layout_name.clone(),
+ );
for (client_id, mode_info) in &self.mode_info {
tab.change_mode_info(mode_info.clone(), *client_id);
}
@@ -6298,6 +6310,8 @@ impl Screen {
pane_padding: PanePadding,
inactive_pane_grayscale: u8,
hide_default_pane_names: bool,
+ responsive_min_cols: usize,
+ responsive_layout_name: String,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6334,6 +6348,8 @@ impl Screen {
self.visual_bell = visual_bell;
self.focus_follows_mouse = focus_follows_mouse;
self.mouse_click_through = mouse_click_through;
+ self.responsive_min_cols = responsive_min_cols;
+ self.responsive_layout_name = responsive_layout_name.clone();
self.nested_session_handling = nested_session_handling;
self.default_mode_info
.update_arrow_fonts(should_support_arrow_fonts);
@@ -6357,6 +6373,7 @@ impl Screen {
tab.update_copy_options(&self.copy_options);
// must precede set_pane_frames: gap/padding are baked into
// content_offset by the sweep that set_pane_frames triggers
+ tab.update_responsive_layout(responsive_min_cols, responsive_layout_name.clone());
tab.update_frame_layout(
pane_title_alignment,
pane_gap_x,
@@ -7457,6 +7474,13 @@ pub(crate) fn screen_thread_main(
);
}
+ // Responsive-layout settings, read BEFORE `config.options` is moved out below.
+ // These do NOT ride on `ClientAttributes.style` like the other pane_frames
+ // options do, because `Style` crosses the plugin protobuf API and the
+ // committed prost assets cannot be regenerated here (no protoc).
+ let responsive_min_cols = config.ui.pane_frames.responsive_min_cols;
+ let responsive_layout_name = config.ui.pane_frames.responsive_layout.clone();
+
let config_options = config.options;
let arrow_fonts = !config_options.simplified_ui.unwrap_or_default();
let pane_frame_style = PaneFrameStyle::from_options(&config_options);
@@ -7565,6 +7589,8 @@ pub(crate) fn screen_thread_main(
web_server_ip,
web_server_port,
nested_session_handling,
+ responsive_min_cols,
+ responsive_layout_name,
);
screen.host_theme_dark_styling = host_theme_dark_styling;
screen.host_theme_light_styling = host_theme_light_styling;
@@ -10886,6 +10912,8 @@ pub(crate) fn screen_thread_main(
pane_padding,
inactive_pane_grayscale,
hide_default_pane_names,
+ responsive_min_cols,
+ responsive_layout_name,
stacked_resize,
stacked_pane_list,
default_editor,
@@ -10919,6 +10947,8 @@ pub(crate) fn screen_thread_main(
pane_padding,
inactive_pane_grayscale,
hide_default_pane_names,
+ responsive_min_cols,
+ responsive_layout_name,
stacked_resize,
stacked_pane_list,
default_editor,
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index bc8337e1..2d8c00c8 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -271,6 +271,11 @@ pub(crate) struct Tab {
/// the live layout acts as the preview. Undone when the drag moves to a
/// different target.
drag_preview_swapped_with: HashMap<ClientId, PaneId>,
+ /// Swap layout position to restore when the terminal grows back, set while
+ /// the tab is collapsed into the responsive layout. None = not collapsed.
+ responsive_restore_position: Option<usize>,
+ responsive_min_cols: usize,
+ responsive_layout_name: String,
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
// the below are the configured values - the ones that will be set if and when the web server
@@ -1083,6 +1088,9 @@ impl Tab {
frame_clicks: HashMap::new(),
tiled_pane_being_dragged: HashMap::new(),
drag_preview_swapped_with: HashMap::new(),
+ responsive_restore_position: None,
+ responsive_min_cols: 0,
+ responsive_layout_name: String::from("stacked"),
connected_clients_in_app,
web_server_ip,
web_server_port,
@@ -1990,6 +1998,41 @@ impl Tab {
.with_context(|| format!("failed to apply cached resizes"))?;
Ok(())
}
+ /// Apply a resolved swap layout to the existing panes.
+ ///
+ /// Extracted from relayout_tiled_panes so the responsive-layout switch
+ /// applies layouts through exactly the same path rather than a lookalike.
+ fn apply_tiled_layout_candidate(&mut self, layout_candidate: &TiledPaneLayout) {
+ let application_res = LayoutApplier::new(
+ &self.viewport,
+ &self.senders,
+ &self.sixel_image_store,
+ &self.kitty_image_store,
+ &self.link_handler,
+ &self.terminal_emulator_colors,
+ &self.terminal_emulator_color_codes,
+ &self.character_cell_size,
+ &self.connected_clients_in_app,
+ &self.style,
+ &self.display_area,
+ &mut self.tiled_panes,
+ &mut self.floating_panes,
+ self.pane_frame_style,
+ &mut self.focus_pane_id,
+ &self.os_api,
+ self.debug,
+ self.arrow_fonts,
+ self.styled_underlines,
+ self.osc8_hyperlinks,
+ self.explicitly_disable_kitty_keyboard_protocol,
+ None,
+ )
+ .apply_tiled_panes_layout_to_existing_panes(layout_candidate);
+ if application_res.is_err() {
+ self.swap_layouts.set_is_tiled_damaged();
+ application_res.non_fatal();
+ }
+ }
fn relayout_tiled_panes(&mut self, search_backwards: bool) -> Result<()> {
if self.tiled_panes.fullscreen_is_active() {
self.tiled_panes.unset_fullscreen();
@@ -1999,35 +2042,7 @@ impl Tab {
.swap_layouts
.swap_tiled_panes(&self.tiled_panes, search_backwards)
{
- let application_res = LayoutApplier::new(
- &self.viewport,
- &self.senders,
- &self.sixel_image_store,
- &self.kitty_image_store,
- &self.link_handler,
- &self.terminal_emulator_colors,
- &self.terminal_emulator_color_codes,
- &self.character_cell_size,
- &self.connected_clients_in_app,
- &self.style,
- &self.display_area,
- &mut self.tiled_panes,
- &mut self.floating_panes,
- self.pane_frame_style,
- &mut self.focus_pane_id,
- &self.os_api,
- self.debug,
- self.arrow_fonts,
- self.styled_underlines,
- self.osc8_hyperlinks,
- self.explicitly_disable_kitty_keyboard_protocol,
- None,
- )
- .apply_tiled_panes_layout_to_existing_panes(&layout_candidate);
- if application_res.is_err() {
- self.swap_layouts.set_is_tiled_damaged();
- application_res.non_fatal();
- }
+ self.apply_tiled_layout_candidate(&layout_candidate);
} else {
self.swap_layouts.set_is_tiled_damaged();
}
@@ -5080,9 +5095,90 @@ impl Tab {
let selectable_tiled_panes = self.tiled_panes.get_panes().filter(|(_, p)| p.selectable());
selectable_tiled_panes.count() > 0
}
+ /// Collapse into the configured swap layout when the terminal is too narrow,
+ /// and restore the previous one when it grows back.
+ ///
+ /// Hysteresis: collapsing happens below `responsive_min_cols`, but restoring
+ /// only above `responsive_min_cols + RESPONSIVE_HYSTERESIS_COLS`. Without a
+ /// gap, dragging a terminal edge that lands exactly on the threshold flips
+ /// the layout on every motion event, and each flip resizes every PTY.
+ ///
+ /// Does nothing once the user has manually rearranged panes
+ /// (`is_tiled_damaged`), on the same principle as auto_layout: an explicit
+ /// arrangement outranks an automatic one.
+ fn apply_responsive_layout(
+ &mut self,
+ new_size: Size,
+ was_damaged_before_resize: bool,
+ ) -> Result<()> {
+ const RESPONSIVE_HYSTERESIS_COLS: usize = 10;
+ if self.responsive_min_cols == 0 {
+ return Ok(());
+ }
+ // NB: deliberately the flag as sampled at the TOP of resize_whole_tab,
+ // not the live `self.swap_layouts.is_tiled_damaged()`.
+ //
+ // By the time we run, resize_whole_tab's own auto_layout pass has
+ // already set the flag as bookkeeping and then cleared it only if it
+ // found a layout that fits. On a terminal too narrow for any of the wide
+ // layouts it can find none, which leaves the flag true even though the
+ // user never rearranged anything -- and that is exactly the case this
+ // feature exists to handle. Sampling before the resize keeps the flag
+ // meaning "the user rearranged panes by hand" and nothing else.
+ //
+ // 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() {
+ return Ok(());
+ }
+ let too_narrow = new_size.cols < self.responsive_min_cols;
+ 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) {
+ // collapse: remember where we were, go to the responsive layout
+ (None, true, _) => {
+ let Some(position) = self
+ .swap_layouts
+ .position_of_tiled_layout_named(&self.responsive_layout_name)
+ else {
+ return Ok(());
+ };
+ self.responsive_restore_position =
+ Some(self.swap_layouts.current_tiled_layout_position());
+ Some(position)
+ },
+ // restore: back to whatever was active before collapsing
+ (Some(restore_to), _, true) => {
+ self.responsive_restore_position = None;
+ Some(restore_to)
+ },
+ _ => None,
+ };
+
+ let Some(target_position) = target_position else {
+ return Ok(());
+ };
+ self.swap_layouts.set_tiled_layout_position(target_position);
+ if let Some(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();
+ }
+ Ok(())
+ }
pub fn resize_whole_tab(&mut self, new_screen_size: Size) -> Result<()> {
let err_context = || format!("failed to resize whole tab (id {})", self.id);
self.size = new_screen_size;
+ // Sample this BEFORE the auto_layout pass below mutates it, so the
+ // responsive switch can tell a genuine manual rearrangement from this
+ // function's own bookkeeping. See apply_responsive_layout.
+ let was_tiled_damaged = self.swap_layouts.is_tiled_damaged();
// 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
@@ -5147,6 +5243,11 @@ impl Tab {
}
}
self.resize_all_stack_list_hidden_members();
+ // after the geometry settles, decide whether this size warrants
+ // collapsing into (or restoring from) the responsive layout.
+ // was_tiled_damaged is sampled at the top of this function, before the
+ // auto_layout pass above overwrites it -- see apply_responsive_layout.
+ self.apply_responsive_layout(new_screen_size, was_tiled_damaged)?;
Ok(())
}
pub fn resize(&mut self, client_id: ClientId, strategy: ResizeStrategy) -> Result<()> {
@@ -7562,6 +7663,12 @@ impl Tab {
})
.unwrap_or(false)
}
+ /// Threshold + target layout for the responsive collapse. Set from config
+ /// at tab creation and on reconfigure.
+ pub fn update_responsive_layout(&mut self, min_cols: usize, layout_name: String) {
+ self.responsive_min_cols = min_cols;
+ self.responsive_layout_name = layout_name;
+ }
pub fn update_focus_follows_mouse(&mut self, focus_follows_mouse: bool) {
self.focus_follows_mouse = focus_follows_mouse;
}
diff --git a/zellij-server/src/tab/swap_layouts.rs b/zellij-server/src/tab/swap_layouts.rs
index af4e5590..25e02190 100644
--- a/zellij-server/src/tab/swap_layouts.rs
+++ b/zellij-server/src/tab/swap_layouts.rs
@@ -81,6 +81,56 @@ impl SwapLayouts {
pub fn is_tiled_damaged(&self) -> bool {
self.is_tiled_damaged
}
+ /// Position of the swap tiled layout with this name, if any.
+ ///
+ /// Used by the responsive-layout switch, which selects a layout BY NAME
+ /// rather than by cycling. Deliberately name-based: adding a size variant to
+ /// LayoutConstraint would change a type that crosses both the plugin
+ /// protobuf API and the client/server contract, and the committed prost
+ /// assets cannot be regenerated here (no protoc).
+ pub fn position_of_tiled_layout_named(&self, name: &str) -> Option<usize> {
+ self.swap_tiled_layouts
+ .iter()
+ .position(|(_constraints, layout_name)| {
+ layout_name.as_deref().map(str::trim) == Some(name.trim())
+ })
+ }
+ pub fn current_tiled_layout_position(&self) -> usize {
+ self.current_tiled_layout_position
+ }
+ /// Jump straight to a swap layout position, instead of cycling to it.
+ pub fn set_tiled_layout_position(&mut self, position: usize) {
+ if position < self.swap_tiled_layouts.len() {
+ self.current_tiled_layout_position = position;
+ }
+ }
+ /// The layout at a given position, resolved against the current pane count
+ /// and display area.
+ ///
+ /// Mirrors the per-position body of swap_tiled_panes (same constraint check,
+ /// same position_panes_in_space feasibility check) but WITHOUT the cycling,
+ /// so a specific layout can be selected directly.
+ pub fn tiled_layout_at(
+ &self,
+ position: usize,
+ tiled_panes: &TiledPanes,
+ ) -> Option<TiledPaneLayout> {
+ let swap_layout = self.swap_tiled_layouts.iter().nth(position)?;
+ for (constraint, layout) in swap_layout.0.iter() {
+ if self.state_fits_tiled_panes_constraint(constraint, tiled_panes) {
+ let display_area = self.display_area.borrow();
+ let pane_count = tiled_panes.visible_panes_count();
+ let display_area = PaneGeom::from(&*display_area);
+ if layout
+ .position_panes_in_space(&display_area, Some(pane_count), false, true)
+ .is_ok()
+ {
+ return Some(layout.clone());
+ }
+ }
+ }
+ None
+ }
pub fn tiled_layout_info(&self) -> (Option<String>, bool) {
// (swap_layout_name, is_swap_layout_dirty)
match self
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index 461af520..145cb621 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -15644,3 +15644,290 @@ fn hidden_cursor_still_emits_cup_for_host_terminal_positioning() {
"Show-cursor sequence must not be present when app has hidden the cursor"
);
}
+
+#[test]
+fn sl0p_spike_pane_order_is_stable_across_a_stacked_swap_round_trip() {
+ // SPIKE for size-responsive layouts: if panes do NOT return to their
+ // original slots after going stacked and back, then "collapse to a stack on
+ // a small terminal and pop back out when it grows" cannot work, because
+ // every resize would reshuffle the user's arrangement.
+ //
+ // This asserts the round trip only. It says nothing about resize triggering.
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let swap_layouts = r#"
+ layout {
+ swap_tiled_layout name="tiled" {
+ tab {
+ pane split_direction="vertical" {
+ pane
+ pane
+ pane
+ pane
+ }
+ }
+ }
+ swap_tiled_layout name="stacked" {
+ tab min_panes=2 {
+ pane stacked=true { children; }
+ }
+ }
+ }
+ "#;
+ let layout = Layout::from_kdl(swap_layouts, Some("file_name.kdl".into()), None, None).unwrap();
+ let swap_tiled_layouts = layout.swap_tiled_layouts.clone();
+ let swap_floating_layouts = layout.swap_floating_layouts.clone();
+ let mut tab = create_new_tab_with_swap_layouts(
+ size,
+ ModeInfo::default(),
+ (swap_tiled_layouts, swap_floating_layouts),
+ None,
+ true,
+ true,
+ );
+ for pid in [2, 3, 4] {
+ tab.new_pane(
+ PaneId::Terminal(pid),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ }
+
+ let geoms = |tab: &Tab| -> Vec<(PaneId, usize, usize, usize, usize)> {
+ let mut v: Vec<_> = tab
+ .tiled_panes
+ .panes
+ .iter()
+ .map(|(id, p)| {
+ let g = p.position_and_size();
+ (*id, g.x, g.y, g.cols.as_usize(), g.rows.as_usize())
+ })
+ .collect();
+ v.sort_by_key(|e| format!("{:?}", e.0));
+ v
+ };
+
+ let before = geoms(&tab);
+ eprintln!("BEFORE: {:?}", before);
+
+ // to the stacked layout and back
+ tab.next_swap_layout().unwrap();
+ let stacked = geoms(&tab);
+ eprintln!("STACKED: {:?}", stacked);
+
+ tab.next_swap_layout().unwrap();
+ let after = geoms(&tab);
+ eprintln!("AFTER: {:?}", after);
+
+ assert_ne!(
+ before, stacked,
+ "the stacked layout should actually change the geometry, or this spike proves nothing"
+ );
+ assert_eq!(
+ before, after,
+ "panes must return to their original slots after a stacked round trip"
+ );
+}
+
+#[test]
+fn sl0p_responsive_layout_collapses_when_narrow_and_restores_when_wide() {
+ let wide = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let swap_layouts = r#"
+ layout {
+ swap_tiled_layout name="tiled" {
+ tab {
+ pane split_direction="vertical" {
+ pane
+ pane
+ pane
+ }
+ }
+ }
+ swap_tiled_layout name="stacked" {
+ tab min_panes=2 {
+ pane stacked=true { children; }
+ }
+ }
+ }
+ "#;
+ let layout = Layout::from_kdl(swap_layouts, Some("file_name.kdl".into()), None, None).unwrap();
+ let mut tab = create_new_tab_with_swap_layouts(
+ wide,
+ ModeInfo::default(),
+ (
+ layout.swap_tiled_layouts.clone(),
+ layout.swap_floating_layouts.clone(),
+ ),
+ 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)
+ );
+
+ // shrink below the threshold -> collapse to the stacked layout
+ let narrow = Size { cols: 70, rows: 20 };
+ tab.resize_whole_tab(narrow).unwrap();
+ assert!(
+ all_full_width(&tab, narrow.cols),
+ "narrow terminal should collapse to a stack, got widths {:?}",
+ widths(&tab)
+ );
+
+ // just over the threshold is inside the hysteresis band -> stay collapsed
+ let borderline = Size { cols: 85, rows: 20 };
+ tab.resize_whole_tab(borderline).unwrap();
+ assert!(
+ all_full_width(&tab, borderline.cols),
+ "inside the hysteresis band it must NOT flip back, got widths {:?}",
+ widths(&tab)
+ );
+
+ // clearly wide again -> restore the previous layout
+ tab.resize_whole_tab(wide).unwrap();
+ assert!(
+ !all_full_width(&tab, wide.cols),
+ "a wide terminal should restore the side-by-side layout, got widths {:?}",
+ widths(&tab)
+ );
+}
+
+#[test]
+fn sl0p_responsive_layout_restores_even_if_the_layout_is_damaged_while_collapsed() {
+ // REGRESSION: the collapse/restore switch backs off when the user has
+ // rearranged panes by hand (a "damaged" swap layout). But the damaged flag
+ // is also set by resize_whole_tab's own auto_layout pass, which only clears
+ // it again if it found a layout that fits -- and on a very narrow terminal
+ // it can find none.
+ //
+ // If restore consulted that flag, a tab that had already collapsed would be
+ // stranded in the narrow layout for the rest of the session: every
+ // subsequent resize would see "damaged" and refuse to restore. Growing the
+ // terminal back must always get the wide layout back.
+ let wide = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let swap_layouts = r#"
+ layout {
+ swap_tiled_layout name="tiled" {
+ tab {
+ pane split_direction="vertical" {
+ pane
+ pane
+ pane
+ }
+ }
+ }
+ swap_tiled_layout name="stacked" {
+ tab min_panes=2 {
+ pane stacked=true { children; }
+ }
+ }
+ }
+ "#;
+ let layout = Layout::from_kdl(swap_layouts, Some("file_name.kdl".into()), None, None).unwrap();
+ let mut tab = create_new_tab_with_swap_layouts(
+ wide,
+ ModeInfo::default(),
+ (
+ layout.swap_tiled_layouts.clone(),
+ layout.swap_floating_layouts.clone(),
+ ),
+ 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);
+
+ // collapse
+ 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)
+ );
+
+ // now damage the layout WHILE collapsed, exactly as a failed auto_layout
+ // pass on a narrow terminal would
+ tab.swap_layouts.set_is_tiled_damaged();
+
+ // growing back must still restore, despite the damage
+ tab.resize_whole_tab(wide).unwrap();
+ assert!(
+ !all_full_width(&tab, wide.cols),
+ "a collapsed tab must still restore when it grows back even if the \
+ layout was damaged while collapsed, got widths {:?}",
+ widths(&tab)
+ );
+}
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 106b7695..04700cf4 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -330,6 +330,8 @@ fn create_new_screen_with_kitty_graphics(
web_server_ip,
web_server_port,
NestedSessionHandling::default(),
+ 0,
+ String::from("stacked"),
);
screen
}
@@ -5538,6 +5540,8 @@ fn create_new_screen_with_message_capture(
web_server_ip,
web_server_port,
NestedSessionHandling::default(),
+ 0,
+ String::from("stacked"),
);
(screen, messages)
}
@@ -8636,6 +8640,8 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture
web_server_ip,
web_server_port,
NestedSessionHandling::default(),
+ 0,
+ String::from("stacked"),
);
(
screen,
@@ -9309,6 +9315,8 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) {
web_server_ip,
web_server_port,
NestedSessionHandling::default(),
+ 0,
+ String::from("stacked"),
);
(
screen,
@@ -9839,6 +9847,8 @@ fn create_non_mirrored_screen(size: Size) -> Screen {
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
NestedSessionHandling::default(),
+ 0,
+ String::from("stacked"),
)
}
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index 7b870332..3a105937 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -1438,6 +1438,27 @@ mod config_test {
}
#[test]
+ fn responsive_layout_options_are_configurable() {
+ let contents =
+ "ui {\n pane_frames {\n responsive_min_cols 90\n responsive_layout \"squished\"\n }\n}";
+ let frames = Config::from_kdl(contents, None).unwrap().ui.pane_frames;
+ assert_eq!(frames.responsive_min_cols, 90);
+ assert_eq!(frames.responsive_layout, "squished");
+ }
+
+ #[test]
+ fn negative_responsive_min_cols_clamps_to_disabled() {
+ // parsed as i64 then clamped: a negative threshold is meaningless and
+ // must not wrap around to a huge usize, which would collapse always
+ let contents = "ui {\n pane_frames {\n responsive_min_cols -5\n }\n}";
+ let frames = Config::from_kdl(contents, None).unwrap().ui.pane_frames;
+ assert_eq!(
+ frames.responsive_min_cols, 0,
+ "negative min_cols must clamp to 0 (feature off), not wrap"
+ );
+ }
+
+ #[test]
fn can_define_ui_configuration_in_configfile() {
let config_contents = r#"
ui {
@@ -1458,6 +1479,8 @@ mod config_test {
gap_aspect: 2, // parser default; see FrameConfig::default()
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: String::from("stacked"),
},
};
assert_eq!(config.ui, expected_ui_config, "Ui config defined in config");
diff --git a/zellij-utils/src/input/theme.rs b/zellij-utils/src/input/theme.rs
index 45ae763b..161eb16d 100644
--- a/zellij-utils/src/input/theme.rs
+++ b/zellij-utils/src/input/theme.rs
@@ -9,7 +9,7 @@ use std::{
use crate::data::Styling;
-#[derive(Debug, Default, Clone, Copy, PartialEq, Deserialize, Serialize)]
+#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
pub struct UiConfig {
pub pane_frames: FrameConfig,
}
@@ -22,7 +22,7 @@ impl UiConfig {
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct FrameConfig {
pub rounded_corners: bool,
pub hide_session_name: bool,
@@ -43,6 +43,11 @@ pub struct FrameConfig {
/// Omit the auto-generated "Pane #N" placeholder title, so a pane shows no
/// name until the user sets one or the running program sets a title.
pub hide_default_pane_names: bool,
+ /// Collapse to the swap layout named `responsive_layout` when the terminal
+ /// is narrower than this many columns. 0 disables the whole feature.
+ pub responsive_min_cols: usize,
+ /// Name of the swap tiled layout to use when below the threshold.
+ pub responsive_layout: String,
}
/// Hand-written rather than derived: `gap_aspect` must default to 2 to match
@@ -60,6 +65,8 @@ impl Default for FrameConfig {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: String::from("stacked"),
}
}
}
@@ -88,6 +95,8 @@ impl FrameConfig {
merged.gap_aspect = other.gap_aspect;
merged.inactive_grayscale = other.inactive_grayscale;
merged.hide_default_pane_names = other.hide_default_pane_names;
+ merged.responsive_min_cols = other.responsive_min_cols;
+ merged.responsive_layout = other.responsive_layout.clone();
merged
}
}
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index 8d7f4d9d..d7ea2de7 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -5602,6 +5602,14 @@ impl UiConfig {
let hide_default_pane_names =
kdl_children_property_first_arg_as_bool!(pane_frames, "hide_default_pane_names")
.unwrap_or(false);
+ let responsive_min_cols =
+ kdl_children_property_first_arg_as_i64!(pane_frames, "responsive_min_cols")
+ .unwrap_or(0)
+ .max(0) as usize;
+ let responsive_layout =
+ kdl_children_property_first_arg_as_string!(pane_frames, "responsive_layout")
+ .unwrap_or("stacked")
+ .to_string();
let frame_config = FrameConfig {
rounded_corners,
hide_session_name,
@@ -5611,6 +5619,8 @@ impl UiConfig {
gap_aspect,
inactive_grayscale,
hide_default_pane_names,
+ responsive_min_cols,
+ responsive_layout,
};
ui_config.pane_frames = frame_config;
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index f7ded71b..b150f31f 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -6241,6 +6241,8 @@ Config {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: "stacked",
},
},
env: {},
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index e4ac75d2..824f9f9b 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -6241,6 +6241,8 @@ Config {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: "stacked",
},
},
env: {
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
index c796e32d..f64af607 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
@@ -303,6 +303,8 @@ Config {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: "stacked",
},
},
env: {},
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index 6d5d9481..7ccd7f44 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -8012,6 +8012,8 @@ Config {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: "stacked",
},
},
env: {},
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 88d469f5..42eeb89c 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -6241,6 +6241,8 @@ Config {
gap_aspect: 2,
inactive_grayscale: 0,
hide_default_pane_names: false,
+ responsive_min_cols: 0,
+ responsive_layout: "stacked",
},
},
env: {},