diff options
| author | user <user@clank> | 2026-08-11 16:45:20 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-11 16:45:20 +0200 |
| commit | a433a998139ad36c9725c8dd3598cd88306ad89a (patch) | |
| tree | 0b20366e242930056d16f991c4196e19fc668cd2 /zellij-server | |
| parent | sl0p: optionally render the stacked-pane picker as a grid (diff) | |
| download | sl0ppij-a433a998139ad36c9725c8dd3598cd88306ad89a.tar.gz sl0ppij-a433a998139ad36c9725c8dd3598cd88306ad89a.tar.xz sl0ppij-a433a998139ad36c9725c8dd3598cd88306ad89a.zip | |
sl0p: clear the stale picker region when the layout re-flows
Adding a pane left old borders on screen until a resize forced a full redraw.
Two causes. The picker draws each entry as its own chunk, so the space between
and around the entries was never painted and kept the previous frame's
contents; and adding a pane really does re-flow the whole grid, because a
freshly created pane has no title yet and its "Pane #N" fallback is far wider
than a named one, which widens every cell and drops the column count.
Separately, refresh_or_dissolve_stack_list changed the reserved row count
without forcing a clear, so when the list got shorter the visible pane moved up
and the rows it vacated kept their old contents.
Blanks only the spans no cell covers, never a cell's own columns: chunks are
NOT composited last-wins, so blanking the whole region erased the entries
instead of the stale contents under them -- the first attempt at this fix drew
exactly one entry.
stack_list_row_gaps is a free function so the covering invariant is testable:
gaps and cells must tile each row exactly, with no overlap, across a sweep of
row caps, label widths and member counts.
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 88 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 69 |
2 files changed, 142 insertions, 15 deletions
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 5ad0fa20..8ff62e07 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -243,6 +243,41 @@ fn stack_list_window_core( (start, shown, shown.div_ceil(columns)) } +/// Spans of a picker row that no cell covers, as (start, end) column offsets. +/// +/// The picker draws each entry as its own chunk, so the space between and around +/// them is never painted and keeps whatever was there before -- which matters +/// because the grid genuinely re-flows when a label's width changes. +/// +/// Returns only the uncovered spans, never the cells: chunks are not composited +/// last-wins, so blanking a region a cell also draws to erases the entry instead +/// of the stale contents beneath it. +fn stack_list_row_gaps( + layout: &StackListLayout, + row: usize, + available_cols: usize, +) -> Vec<(usize, usize)> { + let mut spans: Vec<(usize, usize)> = layout + .cells + .iter() + .filter(|cell| cell.row == row) + .map(|cell| (cell.x_offset, cell.x_offset + cell.width)) + .collect(); + spans.sort_unstable(); + let mut gaps = vec![]; + let mut cursor = 0; + for (start, end) in spans { + if start > cursor { + gaps.push((cursor, start)); + } + cursor = cursor.max(end); + } + if cursor < available_cols { + gaps.push((cursor, available_cols)); + } + gaps +} + enum StackListRemoval { NotRemoved, DissolvedInPlace, @@ -1233,13 +1268,11 @@ impl Tab { let Some(stack_list_id) = self.stack_list_id_of_member(&pane_id) else { return pane_id; }; - let Some(list) = self.stack_lists.get(&stack_list_id) else { - return pane_id; - }; let Some(pane) = self.tiled_panes.get_pane(pane_id) else { return pane_id; }; - let geom = pane.current_geom(); + // the same geometry the renderer laid the list out against + let geom = pane.position_and_size(); if point.line() < geom.y as isize { return pane_id; } @@ -1439,15 +1472,13 @@ impl Tab { let Some(stack_list_id) = self.stack_list_id_of_member(&pane_id) else { return false; }; - let Some(list) = self.stack_lists.get(&stack_list_id) else { + let Some(visible) = self.stack_lists.get(&stack_list_id).map(|list| list.visible) else { return false; }; - let (member_count, visible) = (list.members.len(), list.visible); - let visible_rank = list.rank_of(&visible).unwrap_or(0); let Some(pane) = self.tiled_panes.get_pane(visible) else { return false; }; - let geom = pane.current_geom(); + let geom = pane.position_and_size(); if point.line() < geom.y as isize { return false; } @@ -1519,12 +1550,8 @@ impl Tab { 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) { - Some(list) => ( - list.members.len(), - list.visible, - list.rank_of(&list.visible).unwrap_or(0), - ), + let (member_count, visible) = match self.stack_lists.get(&stack_list_id) { + Some(list) => (list.members.len(), list.visible), None => return, }; if member_count <= 1 { @@ -1534,9 +1561,19 @@ impl Tab { .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); + let previous = 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); + if previous != Some(rows + 1) { + // the list changed height, so the visible pane moved: rows the + // list used to own are now nobody's and keep their old contents + // until something repaints them + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + } } } fn select_stack_list_member( @@ -2000,6 +2037,27 @@ impl Tab { widest_member_title, ); let indicator = self.stack_list_indicator(stack_list_id, layout.hidden); + // Blank the parts of the list region no cell covers. + // + // Entries are drawn as individual cells, so the gaps between them + // keep whatever was drawn there before -- and the layout genuinely + // changes shape when a pane is added, because an unnamed pane's + // "Pane #N" label is far wider than a named one and re-flows the + // whole grid. + // + // Only the gaps, never the cells: chunks are not composited + // last-wins, so blanking a region a cell also draws to can erase the + // cell instead of the stale pixels underneath it. + for row in 0..layout.rows { + for (start, end) in stack_list_row_gaps(&layout, row, entry_cols) { + let blanks = vec![EMPTY_TERMINAL_CHARACTER; end - start]; + output.add_character_chunks_to_multiple_clients( + vec![CharacterChunk::new(blanks, entry_x + start, rect.y + row)], + connected_clients.iter().copied(), + None, + )?; + } + } let cells = layout.cells.clone(); for cell in cells { let rank = cell.member_index; diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index 1b0cc1bc..3868f8fb 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -16389,3 +16389,72 @@ fn sl0p_clicking_a_grid_cell_selects_the_member_that_cell_shows() { ); } } + +#[test] +fn sl0p_stack_list_gaps_cover_every_column_no_cell_draws_to() { + // REGRESSION: the picker draws each entry as its own chunk, so the space + // between and around them was never painted and kept the previous frame's + // contents. Adding a pane re-flows the grid (an unnamed pane's "Pane #N" + // label is far wider than a named one), which left old borders on screen. + // + // The fix must cover EVERY column no cell draws to, and must not overlap a + // cell -- blanking a cell's own columns erases the entry instead. + let size = Size { + cols: 121, + rows: 40, + }; + let mut tab = create_new_tab(size, ModeInfo::default()); + tab.update_stack_list_grid(true); + let available_cols = 120; + + for max_rows in [1usize, 3] { + tab.update_stack_list_max_rows(max_rows); + for widest in [4usize, 10, 30] { + for member_count in 1..20usize { + let layout = tab.stack_list_layout(0, member_count, 0, available_cols, widest); + for row in 0..layout.rows { + let gaps = super::stack_list_row_gaps(&layout, row, available_cols); + let cells: Vec<(usize, usize)> = layout + .cells + .iter() + .filter(|c| c.row == row) + .map(|c| (c.x_offset, c.x_offset + c.width)) + .collect(); + + // no gap may overlap a cell + for (gs, ge) in &gaps { + for (cs, ce) in &cells { + assert!( + ge <= cs || ce <= gs, + "gap {:?} overlaps cell {:?} (widest {}, {} members)", + (gs, ge), + (cs, ce), + widest, + member_count + ); + } + } + + // gaps + cells must tile the row exactly + let mut covered: Vec<(usize, usize)> = + gaps.iter().copied().chain(cells.iter().copied()).collect(); + covered.sort_unstable(); + let mut cursor = 0; + for (start, end) in covered { + assert_eq!( + start, cursor, + "column {} uncovered on row {} (widest {}, {} members)", + cursor, row, widest, member_count + ); + cursor = end; + } + assert_eq!( + cursor, available_cols, + "row {} stops short at {} (widest {}, {} members)", + row, cursor, widest, member_count + ); + } + } + } + } +} |
