aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 20:58:17 +0200
committeruser <user@clank>2026-08-08 20:58:17 +0200
commitc21f7324abdb6d83300a62b95e2af27bfcc75a1e (patch)
tree4bf95a6a71c5c0b6e36c96d2a2d58d654b13052b
parentsl0p: add fastdev build profile + SPEED.md (60x faster iteration) (diff)
downloadsl0ppij-c21f7324abdb6d83300a62b95e2af27bfcc75a1e.tar.gz
sl0ppij-c21f7324abdb6d83300a62b95e2af27bfcc75a1e.tar.xz
sl0ppij-c21f7324abdb6d83300a62b95e2af27bfcc75a1e.zip
sl0p: uniform pane gap + aspect-corrected horizontal gap
Two changes to how the gap is defined. 1. UNIFORM GAP. Previously every pane inset every side by `gap`, so two neighbours each gave up a cell and the space between them was 2*gap while the screen edge got only gap. Now a pane insets RIGHT and BOTTOM always, and LEFT/TOP only where that edge is the viewport boundary. Two neighbours contribute exactly one gap between them, and the outer screen edges get the same gap. `gap 1` now means 1 everywhere. The per-side offset is computed in tiled_panes, which is the layer that knows the viewport, and pushed to panes via Pane::set_gap_offset before the content-offset sweep. Floating panes keep a zero gap: they are positioned explicitly by the user, so an inter-pane gap does not apply to them (they still get padding). 2. ASPECT CORRECTION. Terminal cells are roughly twice as tall as they are wide, so an equal number of rows and columns does not look like an equal distance. `gap` and `padding` are now expressed in ROWS, and columns are multiplied by `gap_aspect` (default 2, minimum 1). Style carries the resolved pane_gap_x/y and pane_padding_x/y. FrameConfig::Default is now hand-written rather than derived: gap_aspect must default to 2 to match the KDL parser, otherwise a parsed config disagrees with a default-constructed one and it surfaces as config round-trip diffs (this broke can_define_ui_configuration_in_configfile and both bare_config_from_default_assets_to_string tests). Tests assert the actual invariants: stacked and side-by-side neighbours get exactly one gap between them, screen edges get the same gap as between panes, and inset_by refuses to collapse a pane too small to survive it. Baselines held: zellij-server 1421/30, zellij-utils 433/5.
-rw-r--r--zellij-client/src/web_client/server_listener.rs6
-rw-r--r--zellij-server/src/lib.rs18
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs14
-rw-r--r--zellij-server/src/panes/plugin_pane.rs37
-rw-r--r--zellij-server/src/panes/terminal_pane.rs37
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs47
-rw-r--r--zellij-server/src/screen.rs26
-rw-r--r--zellij-server/src/tab/mod.rs28
-rw-r--r--zellij-utils/src/data.rs10
-rw-r--r--zellij-utils/src/input/config.rs1
-rw-r--r--zellij-utils/src/input/theme.rs43
-rw-r--r--zellij-utils/src/kdl/mod.rs11
-rw-r--r--zellij-utils/src/pane_size.rs112
-rw-r--r--zellij-utils/src/plugin_api/event.rs6
-rw-r--r--zellij-utils/src/plugin_api/style.rs6
15 files changed, 341 insertions, 61 deletions
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index 71ef5376..8f80f4ac 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -84,8 +84,10 @@ pub fn zellij_server_listener(
rounded_corners: config.ui.pane_frames.rounded_corners,
hide_session_name: config.ui.pane_frames.hide_session_name,
pane_title_alignment: config.ui.pane_frames.title_alignment,
- pane_gap: config.ui.pane_frames.gap,
- pane_padding: config.ui.pane_frames.padding,
+ pane_gap_x: config.ui.pane_frames.resolved_gap().0,
+ pane_gap_y: config.ui.pane_frames.resolved_gap().1,
+ pane_padding_x: config.ui.pane_frames.resolved_padding().0,
+ pane_padding_y: config.ui.pane_frames.resolved_padding().1,
},
};
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 21238eab..a80bda2b 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -460,8 +460,10 @@ impl SessionMetaData {
rounded_corners: new_config.ui.pane_frames.rounded_corners,
hide_session_name: new_config.ui.pane_frames.hide_session_name,
pane_title_alignment: new_config.ui.pane_frames.title_alignment,
- pane_gap: new_config.ui.pane_frames.gap,
- pane_padding: new_config.ui.pane_frames.padding,
+ pane_gap_x: new_config.ui.pane_frames.resolved_gap().0,
+ pane_gap_y: new_config.ui.pane_frames.resolved_gap().1,
+ pane_padding_x: new_config.ui.pane_frames.resolved_padding().0,
+ pane_padding_y: new_config.ui.pane_frames.resolved_padding().1,
stacked_resize: new_config.options.stacked_resize.unwrap_or(true),
stacked_pane_list: new_config.options.stacked_pane_list.unwrap_or(true),
default_editor: new_config.options.scrollback_editor.clone(),
@@ -1004,8 +1006,10 @@ pub fn start_server_impl(
rounded_corners: config.ui.pane_frames.rounded_corners,
hide_session_name: config.ui.pane_frames.hide_session_name,
pane_title_alignment: config.ui.pane_frames.title_alignment,
- pane_gap: config.ui.pane_frames.gap,
- pane_padding: config.ui.pane_frames.padding,
+ pane_gap_x: config.ui.pane_frames.resolved_gap().0,
+ pane_gap_y: config.ui.pane_frames.resolved_gap().1,
+ pane_padding_x: config.ui.pane_frames.resolved_padding().0,
+ pane_padding_y: config.ui.pane_frames.resolved_padding().1,
},
};
@@ -1190,8 +1194,10 @@ pub fn start_server_impl(
rounded_corners: config.ui.pane_frames.rounded_corners,
hide_session_name: config.ui.pane_frames.hide_session_name,
pane_title_alignment: config.ui.pane_frames.title_alignment,
- pane_gap: config.ui.pane_frames.gap,
- pane_padding: config.ui.pane_frames.padding,
+ pane_gap_x: config.ui.pane_frames.resolved_gap().0,
+ pane_gap_y: config.ui.pane_frames.resolved_gap().1,
+ pane_padding_x: config.ui.pane_frames.resolved_padding().0,
+ pane_padding_y: config.ui.pane_frames.resolved_padding().1,
},
};
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index 157a912f..8252612c 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -1723,14 +1723,18 @@ impl FloatingPanes {
pub fn update_pane_frame_layout(
&mut self,
title_alignment: zellij_utils::data::PaneTitleAlignment,
- gap: usize,
- padding: usize,
+ gap_x: usize,
+ gap_y: usize,
+ padding_x: usize,
+ padding_y: usize,
) {
self.style.pane_title_alignment = title_alignment;
- self.style.pane_gap = gap;
- self.style.pane_padding = padding;
+ self.style.pane_gap_x = gap_x;
+ self.style.pane_gap_y = gap_y;
+ self.style.pane_padding_x = padding_x;
+ self.style.pane_padding_y = padding_y;
for pane in self.panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding_x, padding_y);
}
// Unlike the tiled panes, `set_pane_frame_style` only re-runs the
// content-offset sweep for a fullscreen floating pane, so gap/padding
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs
index c01db2ec..4f4e138d 100644
--- a/zellij-server/src/panes/plugin_pane.rs
+++ b/zellij-server/src/panes/plugin_pane.rs
@@ -82,6 +82,10 @@ pub(crate) struct PluginPane {
pub geom: PaneGeom,
pub geom_override: Option<PaneGeom>,
pub content_offset: Offset,
+ /// Per-side outer gap. Set by the pane container, which knows the viewport:
+ /// a side only gets a gap where one belongs, so two neighbours produce ONE
+ /// gap between them rather than two.
+ gap_offset: Offset,
pub send_plugin_instructions: SenderWithContext<PluginInstruction>,
pub active_at: Instant,
pub pane_title: String,
@@ -144,6 +148,7 @@ impl PluginPane {
active_at: Instant::now(),
frame: HashMap::new(),
content_offset: Offset::default(),
+ gap_offset: Offset::default(),
pane_title: title,
borderless: false,
pane_name: pane_name.clone(),
@@ -475,7 +480,7 @@ impl Pane for PluginPane {
// 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 outer_frame_viewport = Viewport::from(frame_geom);
- let frame_viewport = outer_frame_viewport.inset(self.style.pane_gap);
+ let frame_viewport = outer_frame_viewport.inset_by(self.gap_offset);
// Every cell of the pane's allocation must be written by exactly one
// pane each render, or stale glyphs survive as ghosting. The gap and
// padding insets carve out TWO regions that neither the frame nor the
@@ -754,16 +759,28 @@ impl Pane for PluginPane {
fn set_frame(&mut self, _frame: bool) {
self.frame.clear();
}
+ fn set_gap_offset(&mut self, gap_offset: Offset) {
+ self.gap_offset = gap_offset;
+ }
fn set_content_offset(&mut self, offset: Offset) {
// see terminal_pane.rs: borderless panes are chrome (this is the one
// that matters in practice - zjstatus/status-bar/tab-bar are plugins)
+ // borderless panes are UI chrome (status bar, tab bar): they are sized
+ // exactly to their content, so insetting them makes them vanish
+ // borderless panes are UI chrome (status bar, tab bar): they are sized
+ // exactly to their content, so insetting them at all makes them vanish
let inset = if self.borderless {
- 0
+ Offset::default()
} else {
- self.style.pane_gap + self.style.pane_padding
+ Offset {
+ top: self.gap_offset.top.saturating_add(self.style.pane_padding_y),
+ bottom: self.gap_offset.bottom.saturating_add(self.style.pane_padding_y),
+ left: self.gap_offset.left.saturating_add(self.style.pane_padding_x),
+ right: self.gap_offset.right.saturating_add(self.style.pane_padding_x),
+ }
};
self.content_offset =
- offset.expanded_by_within(inset, self.cols(), self.rows());
+ offset.expanded_by_offset_within(inset, self.cols(), self.rows());
self.resize_grids();
}
@@ -896,12 +913,16 @@ impl Pane for PluginPane {
fn update_frame_layout(
&mut self,
title_alignment: zellij_utils::data::PaneTitleAlignment,
- gap: usize,
- padding: usize,
+ gap_x: usize,
+ gap_y: usize,
+ padding_x: usize,
+ padding_y: usize,
) {
self.style.pane_title_alignment = title_alignment;
- self.style.pane_gap = gap;
- self.style.pane_padding = padding;
+ self.style.pane_gap_x = gap_x;
+ self.style.pane_gap_y = gap_y;
+ self.style.pane_padding_x = padding_x;
+ self.style.pane_padding_y = padding_y;
self.frame.clear();
}
fn set_should_be_suppressed(&mut self, should_be_suppressed: bool) {
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index 0ebdbe11..8c44d7b1 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -140,6 +140,10 @@ pub struct TerminalPane {
vte_parser: vte::Parser,
selection_scrolled_at: time::Instant,
content_offset: Offset,
+ /// Per-side outer gap. Set by the pane container, which knows the
+ /// viewport: a side only gets a gap where one belongs, so two
+ /// neighbours produce ONE gap between them rather than two.
+ gap_offset: Offset,
pane_title: String,
pane_name: String,
prev_pane_name: String,
@@ -580,7 +584,7 @@ impl Pane for TerminalPane {
// cells between neighbouring panes. Content is inset to match in
// set_content_offset().
let outer_frame_viewport = Viewport::from(frame_geom);
- let frame_viewport = outer_frame_viewport.inset(self.style.pane_gap);
+ let frame_viewport = outer_frame_viewport.inset_by(self.gap_offset);
// Every cell of the pane's allocation must be written by exactly one
// pane each render, or stale glyphs survive as ghosting. The gap and
// padding insets carve out TWO regions that neither the frame nor the
@@ -973,16 +977,28 @@ impl Pane for TerminalPane {
self.frame.clear();
}
+ fn set_gap_offset(&mut self, gap_offset: Offset) {
+ self.gap_offset = gap_offset;
+ }
fn set_content_offset(&mut self, offset: Offset) {
// borderless panes are UI chrome (status bar, tab bar): they are sized
// exactly to their content, so insetting them makes them vanish
+ // borderless panes are UI chrome (status bar, tab bar): they are sized
+ // exactly to their content, so insetting them makes them vanish
+ // borderless panes are UI chrome (status bar, tab bar): they are sized
+ // exactly to their content, so insetting them at all makes them vanish
let inset = if self.borderless {
- 0
+ Offset::default()
} else {
- self.style.pane_gap + self.style.pane_padding
+ Offset {
+ top: self.gap_offset.top.saturating_add(self.style.pane_padding_y),
+ bottom: self.gap_offset.bottom.saturating_add(self.style.pane_padding_y),
+ left: self.gap_offset.left.saturating_add(self.style.pane_padding_x),
+ right: self.gap_offset.right.saturating_add(self.style.pane_padding_x),
+ }
};
self.content_offset =
- offset.expanded_by_within(inset, self.get_columns(), self.get_rows());
+ offset.expanded_by_offset_within(inset, self.get_columns(), self.get_rows());
self.reflow_lines();
}
@@ -1251,12 +1267,16 @@ impl Pane for TerminalPane {
fn update_frame_layout(
&mut self,
title_alignment: zellij_utils::data::PaneTitleAlignment,
- gap: usize,
- padding: usize,
+ gap_x: usize,
+ gap_y: usize,
+ padding_x: usize,
+ padding_y: usize,
) {
self.style.pane_title_alignment = title_alignment;
- self.style.pane_gap = gap;
- self.style.pane_padding = padding;
+ self.style.pane_gap_x = gap_x;
+ self.style.pane_gap_y = gap_y;
+ self.style.pane_padding_x = padding_x;
+ self.style.pane_padding_y = padding_y;
self.frame.clear();
}
fn drain_fake_cursors(&mut self) -> Option<HashSet<(usize, usize)>> {
@@ -1432,6 +1452,7 @@ impl TerminalPane {
TerminalPane {
frame: HashMap::new(),
content_offset: Offset::default(),
+ gap_offset: Offset::default(),
pid,
grid,
selectable: true,
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index 1dbc7bb8..835c8e74 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -627,6 +627,39 @@ impl TiledPanes {
pane.set_frame(draws_full_frames);
}
+ // Per-side outer gap.
+ //
+ // A pane always gives up space on its RIGHT and BOTTOM, and gives
+ // up space on its LEFT/TOP only when that edge is the viewport
+ // boundary. Two neighbours therefore contribute exactly ONE gap
+ // between them (the left one's right side), while the outer edges
+ // of the screen still get a margin. Insetting every side on every
+ // pane would double the gap between neighbours.
+ //
+ // x and y are scaled differently on purpose: terminal cells are
+ // about twice as tall as they are wide, so an equal number of rows
+ // and columns does not look like an equal distance.
+ let gap_offset = if pane.borderless() {
+ Offset::default()
+ } else {
+ let geom = pane.current_geom();
+ Offset {
+ left: if geom.x <= viewport.x {
+ self.style.pane_gap_x
+ } else {
+ 0
+ },
+ top: if geom.y <= viewport.y {
+ self.style.pane_gap_y
+ } else {
+ 0
+ },
+ right: self.style.pane_gap_x,
+ bottom: self.style.pane_gap_y,
+ }
+ };
+ pane.set_gap_offset(gap_offset);
+
let pane_viewport = &viewport;
let reserved_rows = reserved_top_rows.get(&pane.pid()).copied().unwrap_or(0);
@@ -3121,14 +3154,18 @@ impl TiledPanes {
pub fn update_pane_frame_layout(
&mut self,
title_alignment: zellij_utils::data::PaneTitleAlignment,
- gap: usize,
- padding: usize,
+ gap_x: usize,
+ gap_y: usize,
+ padding_x: usize,
+ padding_y: usize,
) {
self.style.pane_title_alignment = title_alignment;
- self.style.pane_gap = gap;
- self.style.pane_padding = padding;
+ self.style.pane_gap_x = gap_x;
+ self.style.pane_gap_y = gap_y;
+ self.style.pane_padding_x = padding_x;
+ self.style.pane_padding_y = padding_y;
for pane in self.panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding_x, padding_y);
}
}
pub fn stack_panes(
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index b17cc61f..c3c06ca9 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -777,8 +777,10 @@ pub enum ScreenInstruction {
rounded_corners: bool,
hide_session_name: bool,
pane_title_alignment: PaneTitleAlignment,
- pane_gap: usize,
- pane_padding: usize,
+ pane_gap_x: usize,
+ pane_gap_y: usize,
+ pane_padding_x: usize,
+ pane_padding_y: usize,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6266,8 +6268,10 @@ impl Screen {
rounded_corners: bool,
hide_session_name: bool,
pane_title_alignment: PaneTitleAlignment,
- pane_gap: usize,
- pane_padding: usize,
+ pane_gap_x: usize,
+ pane_gap_y: usize,
+ pane_padding_x: usize,
+ pane_padding_y: usize,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6327,7 +6331,7 @@ impl Screen {
tab.update_copy_options(&self.copy_options);
// must precede set_pane_frames: gap/padding are baked into
// content_offset by the sweep that set_pane_frames triggers
- tab.update_frame_layout(pane_title_alignment, pane_gap, pane_padding);
+ tab.update_frame_layout(pane_title_alignment, pane_gap_x, pane_gap_y, pane_padding_x, pane_padding_y);
tab.set_pane_frames(pane_frame_style);
tab.update_arrow_fonts(should_support_arrow_fonts);
tab.update_advanced_mouse_actions(advanced_mouse_actions);
@@ -10844,8 +10848,10 @@ pub(crate) fn screen_thread_main(
rounded_corners,
hide_session_name,
pane_title_alignment,
- pane_gap,
- pane_padding,
+ pane_gap_x,
+ pane_gap_y,
+ pane_padding_x,
+ pane_padding_y,
stacked_resize,
stacked_pane_list,
default_editor,
@@ -10874,8 +10880,10 @@ pub(crate) fn screen_thread_main(
rounded_corners,
hide_session_name,
pane_title_alignment,
- pane_gap,
- pane_padding,
+ pane_gap_x,
+ pane_gap_y,
+ pane_padding_x,
+ pane_padding_y,
stacked_resize,
stacked_pane_list,
default_editor,
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index beec913b..c9047d68 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -737,6 +737,10 @@ pub trait Pane {
fn update_arrow_fonts(&mut self, _should_support_arrow_fonts: bool) {}
fn update_kitty_host_support(&mut self, _supported: KittyHostSupport) {}
fn update_rounded_corners(&mut self, _rounded_corners: bool) {}
+ /// Per-side outer gap, decided by the container (which knows the viewport).
+ /// MUST be followed by set_content_offset: the gap is baked into
+ /// content_offset, so it only takes effect on the next offset sweep.
+ fn set_gap_offset(&mut self, _gap_offset: zellij_utils::pane_size::Offset) {}
/// Live-update the sl0p frame layout options (title alignment, outer gap,
/// inner padding). Callers MUST follow this with the usual
/// `set_pane_frames` sweep: gap/padding are baked into `content_offset`
@@ -745,8 +749,10 @@ pub trait Pane {
fn update_frame_layout(
&mut self,
_title_alignment: zellij_utils::data::PaneTitleAlignment,
- _gap: usize,
- _padding: usize,
+ _gap_x: usize,
+ _gap_y: usize,
+ _padding_x: usize,
+ _padding_y: usize,
) {
}
fn set_should_be_suppressed(&mut self, _should_be_suppressed: bool) {}
@@ -7412,18 +7418,22 @@ impl Tab {
pub fn update_frame_layout(
&mut self,
title_alignment: zellij_utils::data::PaneTitleAlignment,
- gap: usize,
- padding: usize,
+ gap_x: usize,
+ gap_y: usize,
+ padding_x: usize,
+ padding_y: usize,
) {
self.style.pane_title_alignment = title_alignment;
- self.style.pane_gap = gap;
- self.style.pane_padding = padding;
+ self.style.pane_gap_x = gap_x;
+ self.style.pane_gap_y = gap_y;
+ self.style.pane_padding_x = padding_x;
+ self.style.pane_padding_y = padding_y;
self.floating_panes
- .update_pane_frame_layout(title_alignment, gap, padding);
+ .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding_x, padding_y);
self.tiled_panes
- .update_pane_frame_layout(title_alignment, gap, padding);
+ .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding_x, padding_y);
for (_, pane) in self.suppressed_panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding_x, padding_y);
}
}
pub fn update_arrow_fonts(&mut self, should_support_arrow_fonts: bool) {
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index c6603e76..aa455b35 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -1431,10 +1431,16 @@ pub struct Style {
/// unaffected and clicks inside a gap still resolve to the owning pane.
///
/// Like pane_title_alignment, not carried over the plugin protobuf boundary.
- pub pane_gap: usize,
+ /// Resolved per axis, because terminal cells are about twice as tall as
+ /// they are wide: an equal number of rows and columns does NOT look like
+ /// an equal distance. See FrameConfig::gap_aspect.
+ pub pane_gap_x: usize,
+ pub pane_gap_y: usize,
/// INNER padding: cells between a pane's frame and its content. Purely
/// cosmetic breathing room inside the pane; does not move the frame.
- pub pane_padding: usize,
+ /// Also aspect-corrected.
+ pub pane_padding_x: usize,
+ pub pane_padding_y: usize,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index 546d39d0..be08adb9 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -1410,6 +1410,7 @@ mod config_test {
title_alignment: Default::default(),
gap: Default::default(),
padding: Default::default(),
+ gap_aspect: 2, // parser default; see FrameConfig::default()
},
};
assert_eq!(config.ui, expected_ui_config, "Ui config defined in config");
diff --git a/zellij-utils/src/input/theme.rs b/zellij-utils/src/input/theme.rs
index 949a8752..f4f2ec80 100644
--- a/zellij-utils/src/input/theme.rs
+++ b/zellij-utils/src/input/theme.rs
@@ -22,15 +22,51 @@ impl UiConfig {
}
}
-#[derive(Debug, Default, Clone, Copy, PartialEq, Deserialize, Serialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
pub struct FrameConfig {
pub rounded_corners: bool,
pub hide_session_name: bool,
pub title_alignment: crate::data::PaneTitleAlignment,
- /// outer gap between panes, in cells (see Style::pane_gap)
+ /// outer gap between panes, expressed in ROWS (see Style::pane_gap_y).
+ /// Columns are scaled by `gap_aspect` so the gap looks square.
pub gap: usize,
- /// inner padding between a pane's frame and its content (see Style::pane_padding)
+ /// inner padding between a pane's frame and its content, in ROWS
pub padding: usize,
+ /// Columns per row. Terminal cells are roughly twice as tall as they are
+ /// wide, so 1 row of gap needs ~2 columns to look like the same distance.
+ /// 0 is treated as 1 (no correction).
+ pub gap_aspect: usize,
+}
+
+/// Hand-written rather than derived: `gap_aspect` must default to 2 to match
+/// the KDL parser's default. A derived Default would give 0, which makes a
+/// parsed config disagree with a default-constructed one and shows up as
+/// config round-trip diffs.
+impl Default for FrameConfig {
+ fn default() -> Self {
+ FrameConfig {
+ rounded_corners: false,
+ hide_session_name: false,
+ title_alignment: Default::default(),
+ gap: 0,
+ padding: 0,
+ gap_aspect: 2,
+ }
+ }
+}
+
+impl FrameConfig {
+ fn aspect(&self) -> usize {
+ self.gap_aspect.max(1)
+ }
+ /// (columns, rows) of outer gap
+ pub fn resolved_gap(&self) -> (usize, usize) {
+ (self.gap * self.aspect(), self.gap)
+ }
+ /// (columns, rows) of inner padding
+ pub fn resolved_padding(&self) -> (usize, usize) {
+ (self.padding * self.aspect(), self.padding)
+ }
}
impl FrameConfig {
@@ -41,6 +77,7 @@ impl FrameConfig {
merged.title_alignment = other.title_alignment;
merged.gap = other.gap;
merged.padding = other.padding;
+ merged.gap_aspect = other.gap_aspect;
merged
}
}
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index 6589d30f..c2500ebb 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -5554,12 +5554,17 @@ impl UiConfig {
let padding = kdl_children_property_first_arg_as_i64!(pane_frames, "padding")
.unwrap_or(0)
.max(0) as usize;
+ // default 2: terminal cells are ~2x taller than wide
+ let gap_aspect = kdl_children_property_first_arg_as_i64!(pane_frames, "gap_aspect")
+ .unwrap_or(2)
+ .max(1) as usize;
let frame_config = FrameConfig {
rounded_corners,
hide_session_name,
title_alignment,
gap,
padding,
+ gap_aspect,
};
ui_config.pane_frames = frame_config;
}
@@ -5608,6 +5613,12 @@ impl UiConfig {
padding.push(KdlValue::Base10(self.pane_frames.padding as i64));
frame_config_children.nodes_mut().push(padding);
}
+ if self.pane_frames.gap_aspect != 2 {
+ has_ui_config = true;
+ let mut gap_aspect = KdlNode::new("gap_aspect");
+ gap_aspect.push(KdlValue::Base10(self.pane_frames.gap_aspect as i64));
+ frame_config_children.nodes_mut().push(gap_aspect);
+ }
if has_ui_config {
frame_config.set_children(frame_config_children);
ui_config_children.nodes_mut().push(frame_config);
diff --git a/zellij-utils/src/pane_size.rs b/zellij-utils/src/pane_size.rs
index 5afedd7a..fd59cc7a 100644
--- a/zellij-utils/src/pane_size.rs
+++ b/zellij-utils/src/pane_size.rs
@@ -64,6 +64,33 @@ impl Viewport {
/// needs at least its two borders plus a cell of content in each axis, so
/// below that we return the viewport untouched rather than emit a
/// degenerate (or zero-sized) frame.
+ /// Shrink this viewport by a different amount on each side.
+ ///
+ /// Used for the pane gap: a pane only gives up space on a side where a gap
+ /// actually belongs, which is what makes the gap between two neighbours
+ /// equal to `gap` rather than `2 * gap`.
+ ///
+ /// Refuses (returns self) if the result would not leave a viable frame.
+ pub fn inset_by(&self, by: Offset) -> Viewport {
+ const MIN_FRAMED_EXTENT: usize = 3;
+ if by.top == 0 && by.bottom == 0 && by.left == 0 && by.right == 0 {
+ return *self;
+ }
+ let horizontal = by.left.saturating_add(by.right);
+ let vertical = by.top.saturating_add(by.bottom);
+ if self.cols < horizontal.saturating_add(MIN_FRAMED_EXTENT)
+ || self.rows < vertical.saturating_add(MIN_FRAMED_EXTENT)
+ {
+ return *self;
+ }
+ Viewport {
+ x: self.x.saturating_add(by.left),
+ y: self.y.saturating_add(by.top),
+ cols: self.cols.saturating_sub(horizontal),
+ rows: self.rows.saturating_sub(vertical),
+ }
+ }
+
pub fn inset(&self, by: usize) -> Viewport {
const MIN_FRAMED_EXTENT: usize = 3; // left border + 1 content cell + right border
if by == 0 {
@@ -480,6 +507,27 @@ impl Offset {
/// zero content rows, i.e. it silently disappears. Chrome panes are now
/// excluded by their callers, but any pane can be resized down to nothing,
/// so the geometry refuses to collapse itself regardless of caller.
+ /// As [`Offset::expanded_by_within`], but grows each side independently.
+ pub fn expanded_by_offset_within(&self, by: Offset, cols: usize, rows: usize) -> Self {
+ let expanded = Self {
+ top: self.top.saturating_add(by.top),
+ bottom: self.bottom.saturating_add(by.bottom),
+ left: self.left.saturating_add(by.left),
+ right: self.right.saturating_add(by.right),
+ };
+ let fits = cols
+ .checked_sub(expanded.left + expanded.right)
+ .is_some_and(|c| c >= 1)
+ && rows
+ .checked_sub(expanded.top + expanded.bottom)
+ .is_some_and(|r| r >= 1);
+ if fits {
+ expanded
+ } else {
+ *self
+ }
+ }
+
pub fn expanded_by_within(&self, by: usize, cols: usize, rows: usize) -> Self {
let expanded = self.expanded_by(by);
let fits = cols
@@ -636,6 +684,70 @@ mod gap_and_padding_tests {
assert_eq!(vp(0, 0, 7, 7).inset(2), vp(2, 2, 3, 3));
}
+ /// Model of the per-side rule in tiled_panes: right/bottom always, left/top
+ /// only at the viewport edge.
+ fn gap_for(geom: Viewport, vp: Viewport, gx: usize, gy: usize) -> Offset {
+ Offset {
+ left: if geom.x <= vp.x { gx } else { 0 },
+ top: if geom.y <= vp.y { gy } else { 0 },
+ right: gx,
+ bottom: gy,
+ }
+ }
+
+ #[test]
+ fn stacked_panes_get_exactly_one_gap_between_them() {
+ // THE regression this rule fixes: insetting every side on every pane
+ // made two neighbours produce 2*gap between them.
+ let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 };
+ let (gx, gy) = (2usize, 1usize);
+ let top_alloc = Viewport { x: 0, y: 0, cols: 80, rows: 12 };
+ let bottom_alloc = Viewport { x: 0, y: 12, cols: 80, rows: 12 };
+
+ let top = top_alloc.inset_by(gap_for(top_alloc, vp, gx, gy));
+ let bottom = bottom_alloc.inset_by(gap_for(bottom_alloc, vp, gx, gy));
+
+ let between = bottom.y - (top.y + top.rows);
+ assert_eq!(between, gy, "vertical gap between neighbours must be exactly gy");
+ }
+
+ #[test]
+ fn side_by_side_panes_get_exactly_one_gap_between_them() {
+ let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 };
+ let (gx, gy) = (2usize, 1usize);
+ let left_alloc = Viewport { x: 0, y: 0, cols: 40, rows: 24 };
+ let right_alloc = Viewport { x: 40, y: 0, cols: 40, rows: 24 };
+
+ let left = left_alloc.inset_by(gap_for(left_alloc, vp, gx, gy));
+ let right = right_alloc.inset_by(gap_for(right_alloc, vp, gx, gy));
+
+ let between = right.x - (left.x + left.cols);
+ assert_eq!(between, gx, "horizontal gap between neighbours must be exactly gx");
+ }
+
+ #[test]
+ fn screen_edges_get_the_same_gap_as_between_panes() {
+ let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 };
+ let (gx, gy) = (2usize, 1usize);
+ let a = Viewport { x: 0, y: 0, cols: 40, rows: 24 };
+ let b = Viewport { x: 40, y: 0, cols: 40, rows: 24 };
+ let left = a.inset_by(gap_for(a, vp, gx, gy));
+ let right = b.inset_by(gap_for(b, vp, gx, gy));
+
+ assert_eq!(left.x - vp.x, gx, "left screen margin");
+ assert_eq!((vp.x + vp.cols) - (right.x + right.cols), gx, "right screen margin");
+ assert_eq!(left.y - vp.y, gy, "top screen margin");
+ assert_eq!((vp.y + vp.rows) - (left.y + left.rows), gy, "bottom screen margin");
+ }
+
+ #[test]
+ fn inset_by_zero_is_identity_and_refuses_to_collapse() {
+ let v = Viewport { x: 2, y: 3, cols: 20, rows: 10 };
+ assert_eq!(v.inset_by(Offset::default()), v);
+ let tiny = Viewport { x: 0, y: 0, cols: 4, rows: 4 };
+ assert_eq!(tiny.inset_by(Offset::frame(1)), tiny, "must not collapse a tiny pane");
+ }
+
#[test]
fn expanded_by_grows_every_side() {
let o = Offset::frame(1).expanded_by(2);
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index f4bb3523..87af043e 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -2493,8 +2493,10 @@ fn serialize_mode_update_event_with_non_default_values() {
rounded_corners: true,
hide_session_name: false,
pane_title_alignment: Default::default(),
- pane_gap: Default::default(),
- pane_padding: Default::default(),
+ pane_gap_x: Default::default(),
+ pane_gap_y: Default::default(),
+ pane_padding_x: Default::default(),
+ pane_padding_y: Default::default(),
},
capabilities: PluginCapabilities { arrow_fonts: false },
session_name: Some("my awesome test session".to_owned()),
diff --git a/zellij-utils/src/plugin_api/style.rs b/zellij-utils/src/plugin_api/style.rs
index 46c3ce83..266ab7f8 100644
--- a/zellij-utils/src/plugin_api/style.rs
+++ b/zellij-utils/src/plugin_api/style.rs
@@ -26,8 +26,10 @@ impl TryFrom<ProtobufStyle> for Style {
// alignment, and adding a proto field would mean regenerating the
// committed prost assets.
pane_title_alignment: Default::default(),
- pane_gap: Default::default(),
- pane_padding: Default::default(),
+ pane_gap_x: Default::default(),
+ pane_gap_y: Default::default(),
+ pane_padding_x: Default::default(),
+ pane_padding_y: Default::default(),
})
}
}