diff options
| author | user <user@clank> | 2026-08-08 20:26:04 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-08 20:26:04 +0200 |
| commit | ccc77067e2ef230f3bc84155644b09476728b4ae (patch) | |
| tree | 4c0c664d4cbb187f2cfea3f6b08ba70daef59570 /zellij-server | |
| parent | sl0p: hot-reload title alignment, gap and padding on config change (diff) | |
| download | sl0ppij-ccc77067e2ef230f3bc84155644b09476728b4ae.tar.gz sl0ppij-ccc77067e2ef230f3bc84155644b09476728b4ae.tar.xz sl0ppij-ccc77067e2ef230f3bc84155644b09476728b4ae.zip | |
sl0p: fix ghosting/artifacts when resizing panes with a gap
With a gap configured, PaneFrame::render() emits CharacterChunks only for
the INSET frame. The gap ring - the cells between a pane's allocated
geometry and the smaller frame drawn inside it - was written by nobody.
On a fresh paint that looks correct, but only because the screen had just
been cleared. As soon as panes are moved or resized, whatever was
previously drawn in those cells survives, which is the ghosting/artifacts
seen while dragging pane borders.
gap_ring_chunks() emits explicit blanks for that ring, restoring the
renderer's existing invariant that every cell of a pane's allocation is
written by exactly one pane on every render. It is appended at all four
frame-render sites (tiled + plugin panes, cached and uncached paths), and
is a no-op when gap is 0 so the default path is untouched.
Tests assert the ring never overlaps itself, never escapes the allocation,
never overdraws the frame, has exactly (outer area - inner area) cells, and
that ring + frame together tile the allocation with no holes - a hole being
exactly what produces the artifacts.
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/panes/plugin_pane.rs | 15 | ||||
| -rw-r--r-- | zellij-server/src/panes/terminal_pane.rs | 15 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_boundaries_frame.rs | 106 |
3 files changed, 128 insertions, 8 deletions
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs index 163da48d..c461d9cb 100644 --- a/zellij-server/src/panes/plugin_pane.rs +++ b/zellij-server/src/panes/plugin_pane.rs @@ -14,7 +14,7 @@ use crate::pty::VteBytes; use crate::tab::{AdjustedInput, Pane}; use crate::ui::{ loading_indication::LoadingIndication, - pane_boundaries_frame::{FrameParams, PaneFrame}, + pane_boundaries_frame::{gap_ring_chunks, FrameParams, PaneFrame}, }; use crate::ClientId; use std::cell::RefCell; @@ -467,7 +467,8 @@ impl Pane for PluginPane { let is_pinned = frame_geom.is_pinned; // see the matching comment in terminal_pane.rs: the frame is drawn // inset from the pane's allocated geometry to produce the outer gap - let frame_viewport = Viewport::from(frame_geom).inset(self.style.pane_gap); + let outer_frame_viewport = Viewport::from(frame_geom); + let frame_viewport = outer_frame_viewport.inset(self.style.pane_gap); let mut frame = PaneFrame::new( frame_viewport, grid.scrollback_position_and_length(), @@ -484,7 +485,10 @@ impl Pane for PluginPane { Some(last_frame) => { if &frame != last_frame || is_pinned { if !self.borderless { - let frame_output = frame.render().with_context(err_context)?; + let mut frame_output = frame.render().with_context(err_context)?; + frame_output + .0 + .extend(gap_ring_chunks(outer_frame_viewport, frame_viewport)); self.frame.insert(client_id, frame); Some(frame_output) } else { @@ -496,7 +500,10 @@ impl Pane for PluginPane { }, None => { if !self.borderless { - let frame_output = frame.render().with_context(err_context)?; + let mut frame_output = frame.render().with_context(err_context)?; + frame_output + .0 + .extend(gap_ring_chunks(outer_frame_viewport, frame_viewport)); self.frame.insert(client_id, frame); Some(frame_output) } else { diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index a1258edf..c9a9c61e 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -37,7 +37,7 @@ use zellij_utils::{ shared::make_terminal_title, }; -use crate::ui::pane_boundaries_frame::{FrameParams, PaneFrame}; +use crate::ui::pane_boundaries_frame::{gap_ring_chunks, FrameParams, PaneFrame}; pub const SELECTION_SCROLL_INTERVAL_MS: u64 = 10; @@ -579,7 +579,8 @@ impl Pane for TerminalPane { // are untouched; only the drawn frame moves inward, leaving empty // cells between neighbouring panes. Content is inset to match in // set_content_offset(). - let frame_viewport = Viewport::from(frame_geom).inset(self.style.pane_gap); + let outer_frame_viewport = Viewport::from(frame_geom); + let frame_viewport = outer_frame_viewport.inset(self.style.pane_gap); let mut frame = PaneFrame::new( frame_viewport, self.grid.scrollback_position_and_length(), @@ -603,7 +604,10 @@ impl Pane for TerminalPane { Some(last_frame) => { if &frame != last_frame { if !self.borderless { - let frame_output = frame.render().with_context(err_context)?; + let mut frame_output = frame.render().with_context(err_context)?; + frame_output + .0 + .extend(gap_ring_chunks(outer_frame_viewport, frame_viewport)); self.frame.insert(client_id, frame); Some(frame_output) } else { @@ -615,7 +619,10 @@ impl Pane for TerminalPane { }, None => { if !self.borderless { - let frame_output = frame.render().with_context(err_context)?; + let mut frame_output = frame.render().with_context(err_context)?; + frame_output + .0 + .extend(gap_ring_chunks(outer_frame_viewport, frame_viewport)); self.frame.insert(client_id, frame); Some(frame_output) } else { diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index a5148883..51bb4b43 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -21,6 +21,46 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; // Set to "^" if your font renders the arrow double-width. const SCROLL_GLYPH: &str = "↑"; +/// Blank out the "gap ring": the cells between a pane's allocated geometry +/// (`outer`) and the smaller frame actually drawn inside it (`inner`) when a +/// pane gap is configured. +/// +/// Without this, nothing ever writes those cells. They look correct on a fresh +/// paint only because the screen had just been cleared - as soon as panes are +/// moved or resized, whatever was previously drawn there survives as ghosting. +/// Emitting explicit blanks makes every cell of the allocation owned by exactly +/// one pane again, which is the invariant the renderer already relies on. +pub fn gap_ring_chunks(outer: Viewport, inner: Viewport) -> Vec<CharacterChunk> { + let mut chunks = vec![]; + if outer == inner || outer.cols == 0 || outer.rows == 0 { + return chunks; + } + let blanks = |n: usize| vec![EMPTY_TERMINAL_CHARACTER; n]; + let outer_bottom = outer.y.saturating_add(outer.rows); + let outer_right = outer.x.saturating_add(outer.cols); + let inner_bottom = inner.y.saturating_add(inner.rows); + let inner_right = inner.x.saturating_add(inner.cols); + for y in outer.y..outer_bottom { + if y < inner.y || y >= inner_bottom { + // a full-width blank row above or below the drawn frame + chunks.push(CharacterChunk::new(blanks(outer.cols), outer.x, y)); + } else { + // the left and right margins beside the drawn frame + if inner.x > outer.x { + chunks.push(CharacterChunk::new(blanks(inner.x - outer.x), outer.x, y)); + } + if outer_right > inner_right { + chunks.push(CharacterChunk::new( + blanks(outer_right - inner_right), + inner_right, + y, + )); + } + } + } + chunks +} + fn styled_characters( characters: &str, style_character: impl Fn(&mut CharacterStyles), @@ -1576,6 +1616,72 @@ mod tests { rendered[..byte_idx].chars().count() } + fn ring_cells(outer: Viewport, inner: Viewport) -> Vec<(usize, usize)> { + let mut cells = vec![]; + for chunk in gap_ring_chunks(outer, inner) { + for i in 0..chunk.terminal_characters.len() { + cells.push((chunk.x + i, chunk.y)); + } + } + cells + } + + #[test] + fn gap_ring_is_empty_without_a_gap() { + let v = Viewport { x: 3, y: 4, cols: 20, rows: 10 }; + assert!(gap_ring_chunks(v, v).is_empty()); + } + + #[test] + fn gap_ring_covers_exactly_the_difference() { + // every cell of the allocation must be written by either the frame or + // the ring - a hole is precisely what shows up as ghosting on resize + for gap in 1..4usize { + let outer = Viewport { x: 5, y: 2, cols: 30, rows: 14 }; + let inner = outer.inset(gap); + let cells = ring_cells(outer, inner); + + let mut sorted = cells.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), cells.len(), "gap {}: ring overlaps itself", gap); + + let expected = outer.cols * outer.rows - inner.cols * inner.rows; + assert_eq!(cells.len(), expected, "gap {}: ring area wrong", gap); + + for &(x, y) in &cells { + assert!( + x >= outer.x && x < outer.x + outer.cols + && y >= outer.y && y < outer.y + outer.rows, + "gap {}: ring cell ({},{}) escaped the allocation", gap, x, y + ); + let inside_frame = x >= inner.x && x < inner.x + inner.cols + && y >= inner.y && y < inner.y + inner.rows; + assert!(!inside_frame, "gap {}: ring cell ({},{}) overdraws the frame", gap, x, y); + } + } + } + + #[test] + fn gap_ring_plus_frame_tile_the_allocation_completely() { + let outer = Viewport { x: 0, y: 0, cols: 12, rows: 8 }; + let inner = outer.inset(1); + let mut covered = std::collections::HashSet::new(); + for c in ring_cells(outer, inner) { + covered.insert(c); + } + for y in inner.y..inner.y + inner.rows { + for x in inner.x..inner.x + inner.cols { + covered.insert((x, y)); + } + } + assert_eq!( + covered.len(), + outer.cols * outer.rows, + "ring + frame must tile the whole allocation with no holes" + ); + } + #[test] fn title_alignment_orders_left_center_right() { let (title, cols) = ("mytitle", 80); |
