aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij/zellij-server/src/tab/unit
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 00:12:10 +0200
committeruser <user@clank>2026-08-12 19:34:19 +0200
commit38e8682dac1066063df6bc797a00ce5eff39e34d (patch)
treed92a6eb5204a5db3a63ab1b541cc3f83a4196fe4 /sl0ppij/zellij-server/src/tab/unit
parentsl0p: refresh snapshots for the compact scroll indicator (diff)
downloadsl0ppi-38e8682dac1066063df6bc797a00ce5eff39e34d.tar.gz
sl0ppi-38e8682dac1066063df6bc797a00ce5eff39e34d.tar.xz
sl0ppi-38e8682dac1066063df6bc797a00ce5eff39e34d.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.
Diffstat (limited to 'sl0ppij/zellij-server/src/tab/unit')
-rw-r--r--sl0ppij/zellij-server/src/tab/unit/tab_integration_tests.rs287
1 files changed, 287 insertions, 0 deletions
diff --git a/sl0ppij/zellij-server/src/tab/unit/tab_integration_tests.rs b/sl0ppij/zellij-server/src/tab/unit/tab_integration_tests.rs
index 461af52..145cb62 100644
--- a/sl0ppij/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/sl0ppij/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)
+ );
+}