aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 16:33:46 +0200
committeruser <user@clank>2026-08-11 16:33:46 +0200
commit4e001669f5a9dcc883f58bf0c3710c7460ae93cc (patch)
tree116fae3213fd781ea62b15c05256831f2ec9843e
parentsl0p: click the picker's indicator row to expand a capped stack (diff)
downloadsl0ppij-4e001669f5a9dcc883f58bf0c3710c7460ae93cc.tar.gz
sl0ppij-4e001669f5a9dcc883f58bf0c3710c7460ae93cc.tar.xz
sl0ppij-4e001669f5a9dcc883f58bf0c3710c7460ae93cc.zip
sl0p: optionally render the stacked-pane picker as a grid
`stack_list_layout "grid"` packs picker entries several per row, which turns stack_list_max_rows from a count into a space budget: eight panes in a wide terminal now fit on one row instead of spending three and hiding five. The entry renderer already drew each entry as a single chunk at its own x/y, sized by its geom and centring its own label, so a grid needed no rendering changes at all -- only the loop assigning every entry the same x and the full width. Column count is derived from the widest label rather than configured, so it adapts to the titles; one column is the floor and reproduces the previous full-width rows byte for byte. The row -> member mapping now has a column axis, and it had already caused one wrong-pane bug when two consumers disagreed. So the layout is computed once, by one function, into explicit cells; the renderer, the reserved-row budget and the mouse hit-test all read those same cells rather than each doing their own arithmetic. Windowing is by row, so a grid scrolls a whole row at a time and degrades to the old per-member window when there is one column. Cells are separated by a two-column gap: without it the focused entry's "> " marker sits flush against the previous cell and reads as belonging to it.
-rw-r--r--README.md9
-rw-r--r--zellij-server/src/lib.rs1
-rw-r--r--zellij-server/src/screen.rs12
-rw-r--r--zellij-server/src/tab/mod.rs282
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs189
-rw-r--r--zellij-server/src/unit/screen_tests.rs5
-rw-r--r--zellij-utils/src/input/config.rs1
-rw-r--r--zellij-utils/src/input/theme.rs5
-rw-r--r--zellij-utils/src/kdl/mod.rs19
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap1
14 files changed, 450 insertions, 78 deletions
diff --git a/README.md b/README.md
index 3eee2d7c..18c5df05 100644
--- a/README.md
+++ b/README.md
@@ -139,11 +139,18 @@ most of the screen listing itself. `stack_list_max_rows` caps it:
```kdl
ui {
pane_frames {
- stack_list_max_rows 3 // default: 0 (show every member)
+ stack_list_max_rows 3 // default: 0 (show every member)
+ stack_list_layout "grid" // "rows" (default) | "grid"
}
}
```
+In `grid` mode entries are packed several per row, so the cap becomes a **space
+budget** rather than a count: eight panes in a wide terminal fit on one row, and
+`+N more` never appears. Cell width comes from the widest label, so the column
+count adapts to the titles rather than being configured; long titles simply
+yield fewer columns, and one column is the floor.
+
The list windows around the focused entry and reports the remainder as `+N more`
in the padding row it already reserves, so the indicator never costs a row or
steals one from a real entry. **Click that row to expand** the stack to its full
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 55af19c5..6a88ff58 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -467,6 +467,7 @@ impl SessionMetaData {
hide_default_pane_names: new_config.ui.pane_frames.hide_default_pane_names,
responsive_min_cols: new_config.ui.pane_frames.responsive_min_cols,
stack_list_max_rows: new_config.ui.pane_frames.stack_list_max_rows,
+ stack_list_grid: new_config.ui.pane_frames.stack_list_grid,
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),
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index d054c057..5a96e652 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -785,6 +785,7 @@ pub enum ScreenInstruction {
responsive_min_cols: usize,
responsive_layout_name: String,
stack_list_max_rows: usize,
+ stack_list_grid: bool,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -1562,6 +1563,7 @@ pub(crate) struct Screen {
responsive_min_cols: usize,
responsive_layout_name: String,
stack_list_max_rows: usize,
+ stack_list_grid: bool,
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
@@ -1685,6 +1687,7 @@ impl Screen {
responsive_min_cols: usize,
responsive_layout_name: String,
stack_list_max_rows: usize,
+ stack_list_grid: bool,
) -> Self {
let session_name = mode_info.session_name.clone().unwrap_or_default();
let session_info = SessionInfo::new(session_name.clone());
@@ -1752,6 +1755,7 @@ impl Screen {
responsive_min_cols,
responsive_layout_name,
stack_list_max_rows,
+ stack_list_grid,
web_server_ip,
web_server_port,
render_blocker: RenderBlocker::new(100),
@@ -4515,6 +4519,7 @@ impl Screen {
self.responsive_layout_name.clone(),
);
tab.update_stack_list_max_rows(self.stack_list_max_rows);
+ tab.update_stack_list_grid(self.stack_list_grid);
for (client_id, mode_info) in &self.mode_info {
tab.change_mode_info(mode_info.clone(), *client_id);
}
@@ -6318,6 +6323,7 @@ impl Screen {
responsive_min_cols: usize,
responsive_layout_name: String,
stack_list_max_rows: usize,
+ stack_list_grid: bool,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6356,6 +6362,7 @@ impl Screen {
self.mouse_click_through = mouse_click_through;
self.responsive_min_cols = responsive_min_cols;
self.stack_list_max_rows = stack_list_max_rows;
+ self.stack_list_grid = stack_list_grid;
self.responsive_layout_name = responsive_layout_name.clone();
self.nested_session_handling = nested_session_handling;
self.default_mode_info
@@ -6382,6 +6389,7 @@ impl Screen {
// content_offset by the sweep that set_pane_frames triggers
tab.update_responsive_layout(responsive_min_cols, responsive_layout_name.clone());
tab.update_stack_list_max_rows(stack_list_max_rows);
+ tab.update_stack_list_grid(stack_list_grid);
tab.update_frame_layout(
pane_title_alignment,
pane_gap_x,
@@ -7488,6 +7496,7 @@ pub(crate) fn screen_thread_main(
// committed prost assets cannot be regenerated here (no protoc).
let responsive_min_cols = config.ui.pane_frames.responsive_min_cols;
let stack_list_max_rows = config.ui.pane_frames.stack_list_max_rows;
+ let stack_list_grid = config.ui.pane_frames.stack_list_grid;
let responsive_layout_name = config.ui.pane_frames.responsive_layout.clone();
let config_options = config.options;
@@ -7601,6 +7610,7 @@ pub(crate) fn screen_thread_main(
responsive_min_cols,
responsive_layout_name,
stack_list_max_rows,
+ stack_list_grid,
);
screen.host_theme_dark_styling = host_theme_dark_styling;
screen.host_theme_light_styling = host_theme_light_styling;
@@ -10925,6 +10935,7 @@ pub(crate) fn screen_thread_main(
responsive_min_cols,
responsive_layout_name,
stack_list_max_rows,
+ stack_list_grid,
stacked_resize,
stacked_pane_list,
default_editor,
@@ -10961,6 +10972,7 @@ pub(crate) fn screen_thread_main(
responsive_min_cols,
responsive_layout_name,
stack_list_max_rows,
+ stack_list_grid,
stacked_resize,
stacked_pane_list,
default_editor,
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index f1f5e630..5ad0fa20 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -176,6 +176,73 @@ impl StackList {
}
}
+/// One rendered cell of the stacked-pane picker.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct StackListCell {
+ /// Index into `StackList::members`.
+ pub member_index: usize,
+ /// Offset from the list's left edge.
+ pub x_offset: usize,
+ /// Offset from the list's top edge.
+ pub row: usize,
+ pub width: usize,
+}
+
+/// The picker's complete cell layout.
+///
+/// The single source of truth for "which member does this cell show, and where
+/// is it". The renderer, the reserved-row budget and the mouse hit-test all read
+/// it, so they cannot disagree -- a desync between just two of them already
+/// caused clicks to select the wrong pane once.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StackListLayout {
+ pub cells: Vec<StackListCell>,
+ /// Rows the cells occupy, NOT counting the indicator row beneath them.
+ pub rows: usize,
+ pub hidden: usize,
+}
+
+/// Columns a picker entry spends on decoration: the "> " selection marker, and a
+/// border plus padding on each side.
+const STACK_LIST_ENTRY_DECORATION_COLS: usize = 6;
+/// Below this an entry has no room for any label at all.
+const STACK_LIST_MIN_ENTRY_COLS: usize = 8;
+/// Blank columns between grid cells. Without a gap the focused entry's "> "
+/// marker sits flush against the previous cell and reads as belonging to it.
+const STACK_LIST_CELL_GAP_COLS: usize = 2;
+
+/// Which members are on screen, given a column count and a row budget.
+///
+/// Windows by ROW rather than by member so that a grid scrolls a whole row at a
+/// time; with one column this reduces to "window of N members around the focused
+/// one", which is what the list did before it could be a grid.
+///
+/// Returns (first member index, member count shown, rows occupied).
+fn stack_list_window_core(
+ member_count: usize,
+ visible_rank: usize,
+ columns: usize,
+ max_rows: usize,
+) -> (usize, usize, usize) {
+ let columns = columns.max(1);
+ if member_count == 0 {
+ return (0, 0, 0);
+ }
+ let total_rows = member_count.div_ceil(columns);
+ let rows = if max_rows == 0 {
+ total_rows
+ } else {
+ total_rows.min(max_rows.max(1))
+ };
+ let focus_row = visible_rank / columns;
+ let first_row = focus_row
+ .saturating_sub(rows / 2)
+ .min(total_rows.saturating_sub(rows));
+ let start = first_row * columns;
+ let shown = (member_count - start).min(rows * columns);
+ (start, shown, shown.div_ceil(columns))
+}
+
enum StackListRemoval {
NotRemoved,
DissolvedInPlace,
@@ -284,6 +351,8 @@ pub(crate) struct Tab {
stack_list_max_rows: usize,
/// Stacks the user has expanded past the cap by clicking "+N more".
expanded_stack_lists: HashSet<StackListId>,
+ /// Pack picker entries into a grid rather than one per row.
+ stack_list_grid: bool,
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
@@ -1101,6 +1170,7 @@ impl Tab {
responsive_layout_name: String::from("stacked"),
stack_list_max_rows: 0,
expanded_stack_lists: HashSet::new(),
+ stack_list_grid: false,
connected_clients_in_app,
web_server_ip,
web_server_port,
@@ -1173,21 +1243,22 @@ impl Tab {
if point.line() < geom.y as isize {
return pane_id;
}
- // Screen row, NOT the member's rank: the picker draws a window of the
- // members (see stack_list_window), so row 0 is members[window_start]
- // once the list has scrolled. Resolving this against the raw rank picks
- // the wrong pane for every capped, scrolled stack.
+ // A screen CELL, not the member's rank: the picker draws a window of the
+ // members and may pack several per row, so neither the row nor the
+ // column maps directly onto an index. Resolved through the same layout
+ // the renderer used, which is the only way these cannot drift apart.
let row = (point.line() - geom.y as isize) as usize;
- let visible_rank = list.rank_of(&list.visible).unwrap_or(0);
- let (window_start, shown_count) =
- self.stack_list_window_for(stack_list_id, list.members.len(), visible_rank);
- if row >= shown_count {
- // the "+N more" indicator row, which is not a member
+ let Some((list, entry_x, layout)) = self.stack_list_layout_of(stack_list_id) else {
return pane_id;
- }
- list.members
- .get(window_start + row)
- .copied()
+ };
+ let column = (point.column()).saturating_sub(entry_x);
+ layout
+ .cells
+ .iter()
+ .find(|cell| {
+ cell.row == row && column >= cell.x_offset && column < cell.x_offset + cell.width
+ })
+ .and_then(|cell| list.members.get(cell.member_index).copied())
.unwrap_or(pane_id)
}
pub fn suppressed_stack_list_members(&self) -> impl Iterator<Item = (&PaneId, &Box<dyn Pane>)> {
@@ -1288,35 +1359,69 @@ impl Tab {
/// Both the reserved-row arithmetic and the renderer go through this. If
/// they ever disagree the list draws into rows the geometry did not reserve,
/// i.e. over the pane below it.
- /// The window for a specific stack, honouring a user expansion.
+ /// Build the picker's cell layout for a stack.
///
- /// Every consumer of the row -> member mapping must go through this rather
- /// than the raw arithmetic below, or the renderer, the reserved-row budget
- /// and the mouse hit-test can disagree about which member a row shows.
- fn stack_list_window_for(
+ /// `available_cols` is the width the list may use, `widest_label` the widest
+ /// entry label including its index prefix. In rows mode every cell spans the
+ /// full width, exactly as before the grid existed.
+ fn stack_list_layout(
&self,
stack_list_id: StackListId,
member_count: usize,
visible_rank: usize,
- ) -> (usize, usize) {
- if self.expanded_stack_lists.contains(&stack_list_id) {
- return (0, member_count);
+ available_cols: usize,
+ widest_label: usize,
+ ) -> StackListLayout {
+ let expanded = self.expanded_stack_lists.contains(&stack_list_id);
+ let natural_cell = (widest_label + STACK_LIST_ENTRY_DECORATION_COLS)
+ .max(STACK_LIST_MIN_ENTRY_COLS);
+ let columns = if self.stack_list_grid {
+ // standard packing: n cells and n-1 gaps must fit
+ ((available_cols + STACK_LIST_CELL_GAP_COLS)
+ / (natural_cell + STACK_LIST_CELL_GAP_COLS).max(1))
+ .max(1)
+ } else {
+ 1
+ };
+ // one column keeps the historical full-width entry, which centres its own
+ // label; only a real grid needs fixed-width cells and a centred block
+ let (cell_width, cell_stride, left_margin) = if columns <= 1 {
+ (available_cols, available_cols, 0)
+ } else {
+ let stride = natural_cell + STACK_LIST_CELL_GAP_COLS;
+ // the block spans n cells and n-1 gaps; centre that, not n strides
+ let block = columns * natural_cell + (columns - 1) * STACK_LIST_CELL_GAP_COLS;
+ (
+ natural_cell,
+ stride,
+ available_cols.saturating_sub(block) / 2,
+ )
+ };
+ let max_rows = if expanded { 0 } else { self.stack_list_max_rows };
+ let (start, shown, rows) =
+ stack_list_window_core(member_count, visible_rank, columns, max_rows);
+ let cells = (0..shown)
+ .map(|i| StackListCell {
+ member_index: start + i,
+ x_offset: left_margin + (i % columns) * cell_stride,
+ row: i / columns,
+ width: cell_width,
+ })
+ .collect();
+ StackListLayout {
+ cells,
+ rows,
+ hidden: member_count - shown,
}
- self.stack_list_window(member_count, visible_rank)
}
+
/// Text for the picker's indicator row, if it has one.
///
/// Rendered in the padding row the list already reserves, so it never costs
/// a row or displaces a member. Also the click target that toggles the
/// expansion, which is why collapse gets a label too: an invisible way back
/// out is not an affordance.
- fn stack_list_indicator(
- &self,
- stack_list_id: StackListId,
- member_count: usize,
- shown: usize,
- ) -> Option<String> {
- let hidden = member_count.saturating_sub(shown);
+ fn stack_list_indicator(&self, stack_list_id: StackListId, hidden: usize) -> Option<String> {
if hidden > 0 {
Some(format!("+{} more", hidden))
} else if self.expanded_stack_lists.contains(&stack_list_id) {
@@ -1347,11 +1452,12 @@ impl Tab {
return false;
}
let row = (point.line() - geom.y as isize) as usize;
- let (_start, shown) =
- self.stack_list_window_for(stack_list_id, member_count, visible_rank);
- if row != shown
+ let Some((_list, _entry_x, layout)) = self.stack_list_layout_of(stack_list_id) else {
+ return false;
+ };
+ if row != layout.rows
|| self
- .stack_list_indicator(stack_list_id, member_count, shown)
+ .stack_list_indicator(stack_list_id, layout.hidden)
.is_none()
{
return false;
@@ -1364,15 +1470,53 @@ impl Tab {
self.set_should_clear_display_before_rendering();
true
}
- fn stack_list_window(&self, member_count: usize, visible_rank: usize) -> (usize, usize) {
- let cap = self.stack_list_max_rows;
- if cap == 0 || member_count <= cap {
- return (0, member_count);
- }
- let start = visible_rank
- .saturating_sub(cap / 2)
- .min(member_count.saturating_sub(cap));
- (start, cap)
+ /// Geometry and label metrics the picker needs, read from the stack's visible
+ /// pane. Shared so the renderer, the row budget and the hit-test all derive
+ /// the column count from identical inputs.
+ ///
+ /// Returns (list left edge, width available to the list, widest label).
+ fn stack_list_metrics(&self, list: &StackList) -> Option<(usize, usize, usize)> {
+ let pane = self.tiled_panes.get_pane(list.visible)?;
+ let rect = pane.position_and_size();
+ let content_offset = pane.get_content_offset();
+ let entry_x = rect.x + content_offset.left;
+ let available_cols = rect
+ .cols
+ .as_usize()
+ .saturating_sub(content_offset.left + content_offset.right);
+ // measured over ALL members, not just the shown ones, so the cell width
+ // does not jitter as the window scrolls
+ let widest_label = list
+ .members
+ .iter()
+ .enumerate()
+ .filter_map(|(rank, member)| {
+ let pane = if *member == list.visible {
+ self.tiled_panes.get_pane(*member).map(|p| &**p)
+ } else {
+ self.suppressed_panes.get(member).map(|(_, p)| &**p)
+ };
+ pane.map(|pane| {
+ stack_index_prefix(rank + 1).width() + pane.stack_list_entry_label().width()
+ })
+ })
+ .max()
+ .unwrap_or(0);
+ Some((entry_x, available_cols, widest_label))
+ }
+ /// The picker layout for a stack, or None if its pane has gone.
+ fn stack_list_layout_of(&self, stack_list_id: StackListId) -> Option<(StackList, usize, StackListLayout)> {
+ let list = self.stack_lists.get(&stack_list_id)?.clone();
+ let (entry_x, available_cols, widest_label) = self.stack_list_metrics(&list)?;
+ let visible_rank = list.rank_of(&list.visible).unwrap_or(0);
+ let layout = self.stack_list_layout(
+ stack_list_id,
+ list.members.len(),
+ visible_rank,
+ available_cols,
+ widest_label,
+ );
+ Some((list, entry_x, layout))
}
fn refresh_or_dissolve_stack_list(&mut self, stack_list_id: StackListId) {
let (member_count, visible, visible_rank) = match self.stack_lists.get(&stack_list_id) {
@@ -1386,11 +1530,11 @@ impl Tab {
if member_count <= 1 {
self.dissolve_stack_list(stack_list_id);
} else {
- let (_start, shown) =
- self.stack_list_window_for(stack_list_id, member_count, visible_rank);
- self.reserved_top_rows
- .borrow_mut()
- .insert(visible, shown + 1);
+ let rows = self
+ .stack_list_layout_of(stack_list_id)
+ .map(|(_list, _x, layout)| layout.rows)
+ .unwrap_or(member_count);
+ self.reserved_top_rows.borrow_mut().insert(visible, rows + 1);
self.tiled_panes.reapply_pane_frames();
self.resize_stack_list_hidden_members(stack_list_id);
}
@@ -1848,25 +1992,28 @@ impl Tab {
.unwrap_or(0);
let stack_is_focused = active_panes.values().any(|p_id| *p_id == list.visible);
let visible_rank = list.rank_of(&list.visible).unwrap_or(0);
- let (window_start, shown_count) =
- self.stack_list_window_for(stack_list_id, list.members.len(), visible_rank);
- let indicator =
- self.stack_list_indicator(stack_list_id, list.members.len(), shown_count);
- for (row, (rank, member)) in list
- .members
- .iter()
- .enumerate()
- .skip(window_start)
- .take(shown_count)
- .enumerate()
- {
+ let layout = self.stack_list_layout(
+ stack_list_id,
+ list.members.len(),
+ visible_rank,
+ entry_cols,
+ widest_member_title,
+ );
+ let indicator = self.stack_list_indicator(stack_list_id, layout.hidden);
+ let cells = layout.cells.clone();
+ for cell in cells {
+ let rank = cell.member_index;
+ let Some(member) = list.members.get(rank).copied() else {
+ continue;
+ };
+ let member = &member;
let mut header_geom = rect;
- header_geom.x = entry_x;
- header_geom.cols.set_inner(entry_cols);
- // `row` is the position on screen, `rank` stays the member's real
- // position in the stack, so the numbering keeps meaning something
- // once the window has scrolled away from the top
- header_geom.y = rect.y + row;
+ header_geom.x = entry_x + cell.x_offset;
+ header_geom.cols.set_inner(cell.width);
+ // cell.row is the position on screen, `rank` stays the member's
+ // real position in the stack, so the numbering keeps meaning
+ // something once the window has scrolled away from the top
+ header_geom.y = rect.y + cell.row;
header_geom.rows = Dimension::fixed(1);
header_geom.stacked = None;
let pane_box = if *member == list.visible {
@@ -1931,7 +2078,7 @@ impl Tab {
for _ in padding_row.len()..entry_cols {
padding_row.push(EMPTY_TERMINAL_CHARACTER);
}
- let padding_chunk = CharacterChunk::new(padding_row, entry_x, rect.y + shown_count);
+ let padding_chunk = CharacterChunk::new(padding_row, entry_x, rect.y + layout.rows);
output.add_character_chunks_to_multiple_clients(
vec![padding_chunk],
connected_clients.iter().copied(),
@@ -7840,6 +7987,9 @@ impl Tab {
pub fn update_stack_list_max_rows(&mut self, max_rows: usize) {
self.stack_list_max_rows = max_rows;
}
+ pub fn update_stack_list_grid(&mut self, grid: bool) {
+ self.stack_list_grid = grid;
+ }
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/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index c0034708..1b0cc1bc 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -16025,7 +16025,11 @@ fn sl0p_stack_list_caps_its_rows_and_windows_around_the_focused_entry() {
];
for ((member_count, visible_rank), expected) in cases {
assert_eq!(
- tab.stack_list_window(member_count, visible_rank),
+ {
+ let (start, shown, _rows) =
+ super::stack_list_window_core(member_count, visible_rank, 1, 3);
+ (start, shown)
+ },
expected,
"window for {} members focused on rank {}",
member_count,
@@ -16036,7 +16040,8 @@ fn sl0p_stack_list_caps_its_rows_and_windows_around_the_focused_entry() {
// a window must never exceed the cap, run past the end, or lose the focus
for member_count in 1..30usize {
for visible_rank in 0..member_count {
- let (start, shown) = tab.stack_list_window(member_count, visible_rank);
+ let (start, shown, _rows) =
+ super::stack_list_window_core(member_count, visible_rank, 1, 3);
assert!(shown <= 3, "{} members: shown {} exceeds cap", member_count, shown);
assert!(
start + shown <= member_count,
@@ -16068,7 +16073,11 @@ fn sl0p_stack_list_uncapped_by_default_shows_every_member() {
// 0 = uncapped, the historical behaviour
for member_count in [1usize, 5, 12, 40] {
assert_eq!(
- tab.stack_list_window(member_count, 0),
+ {
+ let (start, shown, _rows) =
+ super::stack_list_window_core(member_count, 0, 1, 0);
+ (start, shown)
+ },
(0, member_count),
"uncapped should show all {} members",
member_count
@@ -16121,7 +16130,7 @@ fn sl0p_clicking_a_scrolled_stack_entry_selects_the_pane_it_shows() {
tab.update_stack_list_max_rows(3);
// 8 members, cap 3, focused on rank 7 -> window clamps to ranks 5,6,7
- let (window_start, shown) = tab.stack_list_window(members.len(), 7);
+ let (window_start, shown, _rows) = super::stack_list_window_core(members.len(), 7, 1, 3);
assert_eq!((window_start, shown), (5, 3), "precondition: window position");
let geom = tab.tiled_panes.get_pane(visible).unwrap().current_geom();
@@ -16177,9 +16186,10 @@ fn sl0p_clicking_the_indicator_row_expands_and_collapses_the_stack_list() {
let row_of = |row: usize| Position::new((geom.y + row) as i32, 0);
// collapsed: 3 of 8 shown, indicator on the row after them
- assert_eq!(tab.stack_list_window_for(0, 8, 7), (5, 3));
+ let layout = tab.stack_list_layout_of(0).unwrap().2;
+ assert_eq!((layout.rows, layout.hidden), (3, 5));
assert_eq!(
- tab.stack_list_indicator(0, 8, 3),
+ tab.stack_list_indicator(0, layout.hidden),
Some(String::from("+5 more"))
);
@@ -16188,20 +16198,21 @@ fn sl0p_clicking_the_indicator_row_expands_and_collapses_the_stack_list() {
!tab.toggle_stack_list_expansion_at_point(visible, &row_of(1)),
"a click on a member row must not toggle the expansion"
);
- assert_eq!(tab.stack_list_window_for(0, 8, 7), (5, 3));
+ assert_eq!(tab.stack_list_layout_of(0).unwrap().2.rows, 3);
// clicking the indicator row expands to every member
assert!(
tab.toggle_stack_list_expansion_at_point(visible, &row_of(3)),
"a click on the indicator row should be consumed"
);
+ let layout = tab.stack_list_layout_of(0).unwrap().2;
assert_eq!(
- tab.stack_list_window_for(0, 8, 7),
- (0, 8),
+ (layout.rows, layout.hidden, layout.cells.len()),
+ (8, 0, 8),
"expanded should show every member"
);
assert_eq!(
- tab.stack_list_indicator(0, 8, 8),
+ tab.stack_list_indicator(0, layout.hidden),
Some(String::from("- fewer")),
"expanded needs a visible way back, or the toggle is one-way"
);
@@ -16215,10 +16226,166 @@ fn sl0p_clicking_the_indicator_row_expands_and_collapses_the_stack_list() {
// and clicking it again collapses (indicator is now one row lower)
assert!(tab.toggle_stack_list_expansion_at_point(visible, &row_of(8)));
- assert_eq!(tab.stack_list_window_for(0, 8, 7), (5, 3));
+ let layout = tab.stack_list_layout_of(0).unwrap().2;
+ assert_eq!((layout.rows, layout.hidden), (3, 5));
assert_eq!(
tab.reserved_top_rows.borrow().get(&visible).copied(),
Some(4),
"collapsed reserves the cap plus the indicator"
);
}
+
+#[test]
+fn sl0p_stack_list_grid_packs_entries_into_fewer_rows() {
+ let size = Size {
+ cols: 121,
+ rows: 40,
+ };
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ tab.update_stack_list_max_rows(3);
+
+ // rows mode: 8 members, 3 rows, 5 hidden
+ let rows_layout = tab.stack_list_layout(0, 8, 7, 120, 6);
+ assert_eq!((rows_layout.rows, rows_layout.hidden), (3, 5));
+ assert!(
+ rows_layout.cells.iter().all(|c| c.width == 120 && c.x_offset == 0),
+ "rows mode must keep full-width entries, got {:?}",
+ rows_layout.cells
+ );
+
+ // grid mode: label 6 + 6 decoration = 12 wide cells, so 120 cols -> 10
+ // columns and all 8 members fit on ONE row within the same budget
+ tab.update_stack_list_grid(true);
+ let grid_layout = tab.stack_list_layout(0, 8, 7, 120, 6);
+ assert_eq!(
+ (grid_layout.rows, grid_layout.hidden, grid_layout.cells.len()),
+ (1, 0, 8),
+ "the grid should fit every member in one row here"
+ );
+
+ // cells must tile: same row -> no overlap, and never past the edge
+ for cell in &grid_layout.cells {
+ assert!(
+ cell.x_offset + cell.width <= 120,
+ "cell {:?} runs past the available width",
+ cell
+ );
+ }
+ for a in &grid_layout.cells {
+ for b in &grid_layout.cells {
+ if a.member_index >= b.member_index || a.row != b.row {
+ continue;
+ }
+ assert!(
+ a.x_offset + a.width <= b.x_offset || b.x_offset + b.width <= a.x_offset,
+ "cells {:?} and {:?} overlap",
+ a,
+ b
+ );
+ }
+ }
+}
+
+#[test]
+fn sl0p_stack_list_grid_respects_the_row_budget_and_stays_addressable() {
+ let size = Size {
+ cols: 121,
+ rows: 40,
+ };
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ tab.update_stack_list_grid(true);
+
+ for max_rows in [1usize, 2, 3, 5] {
+ tab.update_stack_list_max_rows(max_rows);
+ for member_count in 1..40usize {
+ for visible_rank in 0..member_count {
+ let layout = tab.stack_list_layout(0, member_count, visible_rank, 120, 6);
+ assert!(
+ layout.rows <= max_rows,
+ "{} members cap {}: used {} rows",
+ member_count,
+ max_rows,
+ layout.rows
+ );
+ assert_eq!(
+ layout.cells.len() + layout.hidden,
+ member_count,
+ "every member must be either shown or counted as hidden"
+ );
+ // the focused member must always be reachable without scrolling
+ assert!(
+ layout
+ .cells
+ .iter()
+ .any(|c| c.member_index == visible_rank),
+ "{} members cap {} rank {}: focused member not in the layout",
+ member_count,
+ max_rows,
+ visible_rank
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn sl0p_clicking_a_grid_cell_selects_the_member_that_cell_shows() {
+ // The grid gives the row -> member mapping a COLUMN axis. That mapping
+ // already selected the wrong pane once when the renderer and the hit-test
+ // disagreed, so assert the hit-test against the very layout that was drawn.
+ let size = Size {
+ cols: 121,
+ rows: 40,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let mut members = vec![PaneId::Terminal(1)];
+ for pid in 2..=8 {
+ tab.new_pane(
+ PaneId::Terminal(pid),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ members.push(PaneId::Terminal(pid));
+ }
+ let visible = members[7];
+ tab.stack_lists.insert(
+ 0,
+ super::StackList {
+ members: members.clone(),
+ visible,
+ },
+ );
+ for member in &members {
+ tab.stack_list_of_member.insert(*member, 0);
+ }
+ tab.update_stack_list_max_rows(3);
+ tab.update_stack_list_grid(true);
+
+ let (_list, entry_x, layout) = tab.stack_list_layout_of(0).unwrap();
+ assert!(
+ layout.cells.iter().any(|c| c.x_offset > 0),
+ "precondition: the grid should have produced more than one column, got {:?}",
+ layout.cells
+ );
+
+ let geom = tab.tiled_panes.get_pane(visible).unwrap().position_and_size();
+ for cell in &layout.cells {
+ // click the middle of the cell, where the label actually is
+ let column = entry_x + cell.x_offset + cell.width / 2;
+ let point = Position::new((geom.y + cell.row) as i32, column as u16);
+ assert_eq!(
+ tab.stack_list_member_at_point(visible, &point),
+ members[cell.member_index],
+ "cell {:?} shows member {}, so clicking it must select that pane",
+ cell,
+ cell.member_index
+ );
+ }
+}
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index e70ea71c..a4004b4d 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -333,6 +333,7 @@ fn create_new_screen_with_kitty_graphics(
0,
String::from("stacked"),
0,
+ false,
);
screen
}
@@ -5544,6 +5545,7 @@ fn create_new_screen_with_message_capture(
0,
String::from("stacked"),
0,
+ false,
);
(screen, messages)
}
@@ -8645,6 +8647,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture
0,
String::from("stacked"),
0,
+ false,
);
(
screen,
@@ -9321,6 +9324,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) {
0,
String::from("stacked"),
0,
+ false,
);
(
screen,
@@ -9854,6 +9858,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen {
0,
String::from("stacked"),
0,
+ false,
)
}
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index d64e4598..4cb315ca 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -1497,6 +1497,7 @@ mod config_test {
responsive_min_cols: 0,
responsive_layout: String::from("stacked"),
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
};
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 7cd89d41..412aaae2 100644
--- a/zellij-utils/src/input/theme.rs
+++ b/zellij-utils/src/input/theme.rs
@@ -52,6 +52,9 @@ pub struct FrameConfig {
/// windows around the focused entry and reports the rest as "+N". 0 = show
/// every member, however many rows that takes.
pub stack_list_max_rows: usize,
+ /// Pack picker entries into a grid instead of one per row. The row cap then
+ /// becomes a space budget rather than a count.
+ pub stack_list_grid: bool,
}
/// Hand-written rather than derived: `gap_aspect` must default to 2 to match
@@ -72,6 +75,7 @@ impl Default for FrameConfig {
responsive_min_cols: 0,
responsive_layout: String::from("stacked"),
stack_list_max_rows: 0,
+ stack_list_grid: false,
}
}
}
@@ -103,6 +107,7 @@ impl FrameConfig {
merged.responsive_min_cols = other.responsive_min_cols;
merged.responsive_layout = other.responsive_layout.clone();
merged.stack_list_max_rows = other.stack_list_max_rows;
+ merged.stack_list_grid = other.stack_list_grid;
merged
}
}
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index 425e9157..dda62c87 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -5614,6 +5614,24 @@ impl UiConfig {
kdl_children_property_first_arg_as_i64!(pane_frames, "stack_list_max_rows")
.unwrap_or(0)
.max(0) as usize;
+ let stack_list_grid =
+ match kdl_children_property_first_arg_as_string!(pane_frames, "stack_list_layout") {
+ Some(raw) => match raw.trim().to_lowercase().as_str() {
+ "grid" => true,
+ "rows" => false,
+ _ => {
+ return Err(ConfigError::new_kdl_error(
+ format!(
+ "Unknown stack_list_layout: \"{}\". Valid values are: \"rows\", \"grid\"",
+ raw
+ ),
+ pane_frames.span().offset(),
+ pane_frames.span().len(),
+ ))
+ },
+ },
+ None => false,
+ };
let frame_config = FrameConfig {
rounded_corners,
hide_session_name,
@@ -5626,6 +5644,7 @@ impl UiConfig {
responsive_min_cols,
responsive_layout,
stack_list_max_rows,
+ stack_list_grid,
};
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 3ca65d74..c2c29be5 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
@@ -6244,6 +6244,7 @@ Config {
responsive_min_cols: 0,
responsive_layout: "stacked",
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
},
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 21e4e851..bc58ffff 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
@@ -6244,6 +6244,7 @@ Config {
responsive_min_cols: 0,
responsive_layout: "stacked",
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
},
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 67da72dc..75cbc50e 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
@@ -306,6 +306,7 @@ Config {
responsive_min_cols: 0,
responsive_layout: "stacked",
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
},
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 d04d9547..d04f6e93 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
@@ -8015,6 +8015,7 @@ Config {
responsive_min_cols: 0,
responsive_layout: "stacked",
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
},
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 a3b5b5ac..966fed4e 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
@@ -6244,6 +6244,7 @@ Config {
responsive_min_cols: 0,
responsive_layout: "stacked",
stack_list_max_rows: 0,
+ stack_list_grid: false,
},
},
env: {},