aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 16:45:20 +0200
committeruser <user@clank>2026-08-11 16:45:20 +0200
commita433a998139ad36c9725c8dd3598cd88306ad89a (patch)
tree0b20366e242930056d16f991c4196e19fc668cd2 /zellij-server
parentsl0p: optionally render the stacked-pane picker as a grid (diff)
downloadsl0ppij-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.rs88
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs69
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
+ );
+ }
+ }
+ }
+ }
+}