diff options
| author | user <user@clank> | 2026-08-11 14:14:53 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-11 14:14:53 +0200 |
| commit | 9441f1b177c1e08f4ceae76ef70d444103d15efa (patch) | |
| tree | bf59f9655fc32e46cd3f6c15e599f5b6dbc10281 /zellij-server | |
| parent | sl0p: add `arrangement` to build a tree for N panes (diff) | |
| download | sl0ppij-9441f1b177c1e08f4ceae76ef70d444103d15efa.tar.gz sl0ppij-9441f1b177c1e08f4ceae76ef70d444103d15efa.tar.xz sl0ppij-9441f1b177c1e08f4ceae76ef70d444103d15efa.zip | |
sl0p: cap how many rows the stacked-pane picker takes
A stack reserves one row per member plus a padding row, so a deep stack spends
most of the screen listing itself. `stack_list_max_rows` caps that; the list
windows around the focused member and reports the rest as "+N more".
The indicator goes in the padding row the list already reserves, so it costs
no extra row and never displaces a real entry. Entries keep their real rank
rather than renumbering from 1, otherwise the numbering stops meaning anything
once the window has scrolled. The window slides on ordinary focus movement,
which is also why this needs no new keybinding -- Action is a protobuf enum
and the prost assets cannot be regenerated here (no protoc).
A fixed cap rather than hover-to-expand on purpose: reserved_top_rows feeds
pane geometry, so growing the list on hover would resize the pane's PTY and
reflow whatever is running in it on every mouse move. A real expand/collapse
has to be an overlay that reserves nothing, which is a much larger change to
the render path.
reserved_top_rows and the renderer both go through stack_list_window(); if
they disagreed the list would draw into rows the geometry never reserved, i.e.
over the pane beneath it.
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/lib.rs | 1 | ||||
| -rw-r--r-- | zellij-server/src/screen.rs | 12 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 82 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 79 | ||||
| -rw-r--r-- | zellij-server/src/unit/screen_tests.rs | 5 |
5 files changed, 170 insertions, 9 deletions
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index c96c3b48..55af19c5 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -466,6 +466,7 @@ impl SessionMetaData { 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, + stack_list_max_rows: new_config.ui.pane_frames.stack_list_max_rows, 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 20ddff41..d054c057 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -784,6 +784,7 @@ pub enum ScreenInstruction { hide_default_pane_names: bool, responsive_min_cols: usize, responsive_layout_name: String, + stack_list_max_rows: usize, stacked_resize: bool, stacked_pane_list: bool, default_editor: Option<PathBuf>, @@ -1560,6 +1561,7 @@ pub(crate) struct Screen { mouse_click_through: bool, responsive_min_cols: usize, responsive_layout_name: String, + stack_list_max_rows: usize, 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 @@ -1682,6 +1684,7 @@ impl Screen { nested_session_handling: NestedSessionHandling, responsive_min_cols: usize, responsive_layout_name: String, + stack_list_max_rows: usize, ) -> Self { let session_name = mode_info.session_name.clone().unwrap_or_default(); let session_info = SessionInfo::new(session_name.clone()); @@ -1748,6 +1751,7 @@ impl Screen { mouse_click_through, responsive_min_cols, responsive_layout_name, + stack_list_max_rows, web_server_ip, web_server_port, render_blocker: RenderBlocker::new(100), @@ -4510,6 +4514,7 @@ impl Screen { self.responsive_min_cols, self.responsive_layout_name.clone(), ); + tab.update_stack_list_max_rows(self.stack_list_max_rows); for (client_id, mode_info) in &self.mode_info { tab.change_mode_info(mode_info.clone(), *client_id); } @@ -6312,6 +6317,7 @@ impl Screen { hide_default_pane_names: bool, responsive_min_cols: usize, responsive_layout_name: String, + stack_list_max_rows: usize, stacked_resize: bool, stacked_pane_list: bool, default_editor: Option<PathBuf>, @@ -6349,6 +6355,7 @@ impl Screen { self.focus_follows_mouse = focus_follows_mouse; self.mouse_click_through = mouse_click_through; self.responsive_min_cols = responsive_min_cols; + self.stack_list_max_rows = stack_list_max_rows; self.responsive_layout_name = responsive_layout_name.clone(); self.nested_session_handling = nested_session_handling; self.default_mode_info @@ -6374,6 +6381,7 @@ impl Screen { // 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_stack_list_max_rows(stack_list_max_rows); tab.update_frame_layout( pane_title_alignment, pane_gap_x, @@ -7479,6 +7487,7 @@ pub(crate) fn screen_thread_main( // 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 stack_list_max_rows = config.ui.pane_frames.stack_list_max_rows; let responsive_layout_name = config.ui.pane_frames.responsive_layout.clone(); let config_options = config.options; @@ -7591,6 +7600,7 @@ pub(crate) fn screen_thread_main( nested_session_handling, responsive_min_cols, responsive_layout_name, + stack_list_max_rows, ); screen.host_theme_dark_styling = host_theme_dark_styling; screen.host_theme_light_styling = host_theme_light_styling; @@ -10914,6 +10924,7 @@ pub(crate) fn screen_thread_main( hide_default_pane_names, responsive_min_cols, responsive_layout_name, + stack_list_max_rows, stacked_resize, stacked_pane_list, default_editor, @@ -10949,6 +10960,7 @@ pub(crate) fn screen_thread_main( hide_default_pane_names, responsive_min_cols, responsive_layout_name, + stack_list_max_rows, stacked_resize, stacked_pane_list, default_editor, diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index cf413e63..2c75c425 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -53,7 +53,10 @@ use crate::{ panes::nested_session_modal::GuestModalShortcuts, panes::sixel::SixelImageStore, panes::{FloatingPanes, TiledPanes}, - panes::{LinkHandler, PaneId, PluginPane, TerminalPane, EMPTY_TERMINAL_CHARACTER}, + panes::{ + LinkHandler, PaneId, PluginPane, TerminalCharacter, TerminalPane, + EMPTY_TERMINAL_CHARACTER, + }, plugins::PluginInstruction, pty::{ClientTabIndexOrPaneId, PtyInstruction, VteBytes}, thread_bus::ThreadSenders, @@ -277,6 +280,8 @@ pub(crate) struct Tab { responsive_restore_position: Option<usize>, responsive_min_cols: usize, responsive_layout_name: String, + /// Cap on picker rows; see stack_list_window. 0 = uncapped. + stack_list_max_rows: usize, 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 @@ -1092,6 +1097,7 @@ impl Tab { responsive_restore_position: None, responsive_min_cols: 0, responsive_layout_name: String::from("stacked"), + stack_list_max_rows: 0, connected_clients_in_app, web_server_ip, web_server_port, @@ -1256,17 +1262,41 @@ impl Tab { } } } + /// Where the picker's visible window starts, and how many entries it shows. + /// + /// `stack_list_max_rows` caps the height; the window slides to keep the + /// currently visible member on screen, so ordinary focus movement scrolls + /// the list instead of needing its own binding. + /// + /// 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. + 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) + } fn refresh_or_dissolve_stack_list(&mut self, stack_list_id: StackListId) { - let (member_count, visible) = match self.stack_lists.get(&stack_list_id) { - Some(list) => (list.members.len(), list.visible), + let (member_count, visible, visible_rank) = match self.stack_lists.get(&stack_list_id) { + Some(list) => ( + list.members.len(), + list.visible, + list.rank_of(&list.visible).unwrap_or(0), + ), None => return, }; if member_count <= 1 { self.dissolve_stack_list(stack_list_id); } else { + let (_start, shown) = self.stack_list_window(member_count, visible_rank); self.reserved_top_rows .borrow_mut() - .insert(visible, member_count + 1); + .insert(visible, shown + 1); self.tiled_panes.reapply_pane_frames(); self.resize_stack_list_hidden_members(stack_list_id); } @@ -1718,11 +1748,25 @@ impl Tab { .max() .unwrap_or(0); let stack_is_focused = active_panes.values().any(|p_id| *p_id == list.visible); - for (rank, member) in list.members.iter().enumerate() { + let visible_rank = list.rank_of(&list.visible).unwrap_or(0); + let (window_start, shown_count) = + self.stack_list_window(list.members.len(), visible_rank); + let hidden_count = list.members.len().saturating_sub(shown_count); + for (row, (rank, member)) in list + .members + .iter() + .enumerate() + .skip(window_start) + .take(shown_count) + .enumerate() + { let mut header_geom = rect; header_geom.x = entry_x; header_geom.cols.set_inner(entry_cols); - header_geom.y = rect.y + rank; + // `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.rows = Dimension::fixed(1); header_geom.stacked = None; let pane_box = if *member == list.visible { @@ -1768,12 +1812,27 @@ impl Tab { )?; } } + // The row after the entries is already part of the reserved budget, + // so the "+N hidden" indicator costs nothing extra and never steals a + // row from a real entry. let mut padding_row = Vec::with_capacity(entry_cols); - for _ in 0..entry_cols { + if hidden_count > 0 { + let indicator = format!("+{} more", hidden_count); + let indicator_start = entry_cols.saturating_sub(indicator.width()) / 2; + for _ in 0..indicator_start { + padding_row.push(EMPTY_TERMINAL_CHARACTER); + } + for character in indicator.chars() { + padding_row.push(TerminalCharacter::new_styled( + character, + EMPTY_TERMINAL_CHARACTER.styles, + )); + } + } + for _ in padding_row.len()..entry_cols { padding_row.push(EMPTY_TERMINAL_CHARACTER); } - let padding_chunk = - CharacterChunk::new(padding_row, entry_x, rect.y + list.members.len()); + let padding_chunk = CharacterChunk::new(padding_row, entry_x, rect.y + shown_count); output.add_character_chunks_to_multiple_clients( vec![padding_chunk], connected_clients.iter().copied(), @@ -7677,6 +7736,11 @@ impl Tab { self.responsive_min_cols = min_cols; self.responsive_layout_name = layout_name; } + /// Cap on stacked-pane picker rows. Takes effect on the next list refresh, + /// which is what recomputes the reserved rows. + pub fn update_stack_list_max_rows(&mut self, max_rows: usize) { + self.stack_list_max_rows = max_rows; + } 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 2449cac9..a036bf6a 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -15996,3 +15996,82 @@ fn sl0p_stack_list_entry_keeps_a_label_when_default_pane_names_are_hidden() { ); } } + +#[test] +fn sl0p_stack_list_caps_its_rows_and_windows_around_the_focused_entry() { + // The picker reserves one row per member plus a padding row, so a deep + // stack eats the screen. `stack_list_max_rows` caps it and slides the + // window to keep the focused member visible. + let size = Size { + cols: 121, + rows: 40, + }; + let mut tab = create_new_tab(size, ModeInfo::default()); + tab.update_stack_list_max_rows(3); + + // (member_count, visible_rank) -> (window_start, shown_count) + let cases = vec![ + // fewer members than the cap: show everything, no window + ((2, 0), (0, 2)), + ((3, 2), (0, 3)), + // capped: window centred on the focused entry where it can be + ((10, 0), (0, 3)), + ((10, 1), (0, 3)), + ((10, 2), (1, 3)), + ((10, 5), (4, 3)), + // clamped at the end, so the window never runs off the bottom + ((10, 9), (7, 3)), + ((10, 8), (7, 3)), + ]; + for ((member_count, visible_rank), expected) in cases { + assert_eq!( + tab.stack_list_window(member_count, visible_rank), + expected, + "window for {} members focused on rank {}", + member_count, + visible_rank + ); + } + + // 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); + assert!(shown <= 3, "{} members: shown {} exceeds cap", member_count, shown); + assert!( + start + shown <= member_count, + "{} members rank {}: window {}..{} runs past the end", + member_count, + visible_rank, + start, + start + shown + ); + assert!( + visible_rank >= start && visible_rank < start + shown, + "{} members rank {}: focused entry outside window {}..{}", + member_count, + visible_rank, + start, + start + shown + ); + } + } +} + +#[test] +fn sl0p_stack_list_uncapped_by_default_shows_every_member() { + let size = Size { + cols: 121, + rows: 40, + }; + let tab = create_new_tab(size, ModeInfo::default()); + // 0 = uncapped, the historical behaviour + for member_count in [1usize, 5, 12, 40] { + assert_eq!( + tab.stack_list_window(member_count, 0), + (0, member_count), + "uncapped should show all {} members", + member_count + ); + } +} diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 04700cf4..e70ea71c 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -332,6 +332,7 @@ fn create_new_screen_with_kitty_graphics( NestedSessionHandling::default(), 0, String::from("stacked"), + 0, ); screen } @@ -5542,6 +5543,7 @@ fn create_new_screen_with_message_capture( NestedSessionHandling::default(), 0, String::from("stacked"), + 0, ); (screen, messages) } @@ -8642,6 +8644,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture NestedSessionHandling::default(), 0, String::from("stacked"), + 0, ); ( screen, @@ -9317,6 +9320,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) { NestedSessionHandling::default(), 0, String::from("stacked"), + 0, ); ( screen, @@ -9849,6 +9853,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen { NestedSessionHandling::default(), 0, String::from("stacked"), + 0, ) } |
