aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 20:26:04 +0200
committeruser <user@clank>2026-08-08 20:26:04 +0200
commitccc77067e2ef230f3bc84155644b09476728b4ae (patch)
tree4c0c664d4cbb187f2cfea3f6b08ba70daef59570 /zellij-server
parentsl0p: hot-reload title alignment, gap and padding on config change (diff)
downloadsl0ppij-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.rs15
-rw-r--r--zellij-server/src/panes/terminal_pane.rs15
-rw-r--r--zellij-server/src/ui/pane_boundaries_frame.rs106
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);