diff options
| author | user <user@clank> | 2026-08-11 16:33:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-11 16:33:46 +0200 |
| commit | 4e001669f5a9dcc883f58bf0c3710c7460ae93cc (patch) | |
| tree | 116fae3213fd781ea62b15c05256831f2ec9843e | |
| parent | sl0p: click the picker's indicator row to expand a capped stack (diff) | |
| download | sl0ppij-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.
14 files changed, 450 insertions, 78 deletions
@@ -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: {}, |
