aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 19:31:55 +0200
committeruser <user@clank>2026-08-08 19:31:55 +0200
commit3f767ee91b08eebf10b693c08c173b8329ba2c14 (patch)
tree6f2b7feed0f454f8a6036fbef54b2d647f35462d
parentsl0p: rebuilt default plugin wasm assets (diff)
downloadsl0ppij-3f767ee91b08eebf10b693c08c173b8329ba2c14.tar.gz
sl0ppij-3f767ee91b08eebf10b693c08c173b8329ba2c14.tar.xz
sl0ppij-3f767ee91b08eebf10b693c08c173b8329ba2c14.zip
sl0p: configurable pane title alignment
Adds `ui { pane_frames { title_alignment "left"|"center"|"right" } }`. Full-frame panes previously hardcoded the title flush left, while frameless panes (pane_frame_style "titles") already centered it via compose_bracketed_title. This brings full frames up to parity and makes the position configurable. Implementation notes: - The value rides on `Style`, which is already threaded to FrameParams, so no new plumbing through Tab/PaneContentsAndUi was needed. - It is deliberately NOT round-tripped over the plugin protobuf boundary: plugins have no use for frame title alignment and adding a proto field would force regenerating the committed prost assets in assets/prost/. - Left keeps the original code path untouched, so default configs render exactly as before. - The geometry loop clamps the title start against the right-side indicators and breaks on `>=` rather than `==`, so a mis-clamped segment cannot spin. Tests: alignment ordering, frame width preservation across widths, and narrow-pane safety (cols 1..24 must not panic or corrupt width).
-rw-r--r--zellij-client/src/web_client/server_listener.rs1
-rw-r--r--zellij-server/src/lib.rs2
-rw-r--r--zellij-server/src/ui/pane_boundaries_frame.rs162
-rw-r--r--zellij-utils/src/data.rs34
-rw-r--r--zellij-utils/src/input/config.rs1
-rw-r--r--zellij-utils/src/input/theme.rs2
-rw-r--r--zellij-utils/src/kdl/mod.rs25
-rw-r--r--zellij-utils/src/plugin_api/event.rs1
-rw-r--r--zellij-utils/src/plugin_api/style.rs5
9 files changed, 232 insertions, 1 deletions
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index c66a1286..5d753435 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -83,6 +83,7 @@ pub fn zellij_server_listener(
colors: palette,
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,
},
};
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index daaa938d..38b83adb 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -1000,6 +1000,7 @@ pub fn start_server_impl(
.unwrap_or_else(|| default_palette().into()),
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,
},
};
@@ -1183,6 +1184,7 @@ pub fn start_server_impl(
.unwrap_or_else(|| default_palette().into()),
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,
},
};
diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs
index 955f161e..a5148883 100644
--- a/zellij-server/src/ui/pane_boundaries_frame.rs
+++ b/zellij-server/src/ui/pane_boundaries_frame.rs
@@ -9,7 +9,7 @@ use crate::ui::hint_text::{
HintSegment, HintTier,
};
use crate::ClientId;
-use zellij_utils::data::{client_id_to_colors, PaletteColor, Style};
+use zellij_utils::data::{client_id_to_colors, PaletteColor, PaneTitleAlignment, Style};
use zellij_utils::errors::prelude::*;
use zellij_utils::pane_size::{Offset, PaneGeom, Viewport};
use zellij_utils::position::Position;
@@ -733,7 +733,78 @@ impl PaneFrame {
_ => self.middle_only_title_line(middle, middle_len),
}
}
+ /// Compose a title line with the pane title placed at `alignment` rather than
+ /// hard against the left corner. The geometry loop mirrors
+ /// `three_part_title_line`; only the start position of the title differs.
+ ///
+ /// The right side (scroll / pin indicators) is anchored and laid out first,
+ /// so the title only ever gets the space that is genuinely left over.
+ fn aligned_title_line(&self, alignment: PaneTitleAlignment) -> Vec<TerminalCharacter> {
+ let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the corners
+ let right_side = self.render_title_right_side(total_title_length);
+ let right_side_len = right_side.as_ref().map(|(_, len)| *len).unwrap_or(0);
+ // 1 cell of breathing room between title and the right-side indicators
+ let separator = if right_side_len > 0 { 1 } else { 0 };
+ let space_for_title = total_title_length.saturating_sub(right_side_len + separator);
+ let title = self.render_title_left_side(space_for_title);
+ let title_len = title.as_ref().map(|(_, len)| *len).unwrap_or(0);
+
+ let last_col = self.geom.x + self.geom.cols.saturating_sub(1);
+ let right_side_start = last_col.saturating_sub(right_side_len);
+ let min_title_start = self.geom.x + 1;
+ // never let the title run into the right-side indicators or the corner
+ let max_title_start = right_side_start
+ .saturating_sub(title_len + separator)
+ .max(min_title_start);
+ let title_start = match alignment {
+ PaneTitleAlignment::Center => {
+ min_title_start + total_title_length.saturating_sub(title_len) / 2
+ },
+ PaneTitleAlignment::Right => max_title_start,
+ PaneTitleAlignment::Left => min_title_start,
+ }
+ .clamp(min_title_start, max_title_start);
+
+ let mut title_chars = title.map(|(chars, _)| chars).unwrap_or_default();
+ let mut right_chars = right_side.map(|(chars, _)| chars).unwrap_or_default();
+ let mut title_line = vec![];
+ let mut col = self.geom.x;
+ loop {
+ if col == self.geom.x {
+ title_line.append(&mut foreground_color(
+ self.get_corner(boundary_type::TOP_LEFT),
+ self.color,
+ ));
+ } else if col >= last_col {
+ title_line.append(&mut foreground_color(
+ self.get_corner(boundary_type::TOP_RIGHT),
+ self.color,
+ ));
+ } else if title_len > 0 && col == title_start {
+ title_line.append(&mut title_chars);
+ col += title_len;
+ continue;
+ } else if right_side_len > 0 && col == right_side_start {
+ title_line.append(&mut right_chars);
+ col += right_side_len;
+ continue;
+ } else {
+ title_line.append(&mut foreground_color(boundary_type::HORIZONTAL, self.color));
+ }
+ // `>=` rather than `==`: a mis-clamped segment must not spin forever
+ if col >= last_col {
+ break;
+ }
+ col += 1;
+ }
+ title_line
+ }
fn title_line_without_middle(&self) -> Vec<TerminalCharacter> {
+ // Left keeps the original code path untouched, so the default config
+ // renders byte-for-byte as before.
+ if self.style.pane_title_alignment != PaneTitleAlignment::Left {
+ return self.aligned_title_line(self.style.pane_title_alignment);
+ }
let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners
let left_side = self.render_title_left_side(total_title_length);
let right_side = left_side.as_ref().and_then(|(_left_side, left_side_len)| {
@@ -1486,6 +1557,95 @@ mod tests {
chars.iter().map(|c| c.character).collect()
}
+ fn titled_frame(title: &str, alignment: PaneTitleAlignment, cols: usize) -> PaneFrame {
+ let mut frame = pane_frame_with(false, false, cols);
+ frame.title = title.to_string();
+ frame.style.pane_title_alignment = alignment;
+ frame
+ }
+
+ /// Column (in CHARACTERS, not bytes) at which the title text starts within
+ /// the rendered title line. The frame is drawn with multi-byte box-drawing
+ /// glyphs, so a byte offset here would be meaningless.
+ fn title_offset(title: &str, alignment: PaneTitleAlignment, cols: usize) -> usize {
+ let frame = titled_frame(title, alignment, cols);
+ let rendered = characters_to_string(&frame.render_title().unwrap());
+ let byte_idx = rendered
+ .find(title)
+ .unwrap_or_else(|| panic!("title {:?} not found in {:?}", title, rendered));
+ rendered[..byte_idx].chars().count()
+ }
+
+ #[test]
+ fn title_alignment_orders_left_center_right() {
+ let (title, cols) = ("mytitle", 80);
+ let left = title_offset(title, PaneTitleAlignment::Left, cols);
+ let center = title_offset(title, PaneTitleAlignment::Center, cols);
+ let right = title_offset(title, PaneTitleAlignment::Right, cols);
+ assert!(
+ left < center && center < right,
+ "expected left({}) < center({}) < right({})",
+ left,
+ center,
+ right
+ );
+ // left stays flush against the corner, as it always has:
+ // one corner glyph plus the leading space of " {title} "
+ assert_eq!(left, 2, "left-aligned title should sit just after the corner");
+ // center is within a cell of true centre
+ let ideal = (cols - title.len()) / 2;
+ assert!(
+ center.abs_diff(ideal) <= 1,
+ "center({}) should be within 1 of ideal({})",
+ center,
+ ideal
+ );
+ // right ends flush against the closing corner
+ assert!(
+ right + title.len() >= cols - 3,
+ "right({}) + len should reach the right corner (cols {})",
+ right,
+ cols
+ );
+ }
+
+ #[test]
+ fn title_alignment_preserves_frame_width() {
+ // the whole point of the geometry loop: every alignment must emit
+ // exactly `cols` characters, or the frame corrupts the grid.
+ for cols in [20, 41, 80, 120] {
+ for alignment in [
+ PaneTitleAlignment::Left,
+ PaneTitleAlignment::Center,
+ PaneTitleAlignment::Right,
+ ] {
+ let frame = titled_frame("mytitle", alignment, cols);
+ let rendered = characters_to_string(&frame.render_title().unwrap());
+ assert_eq!(
+ rendered.chars().count(),
+ cols,
+ "alignment {:?} at cols {} produced {:?}",
+ alignment,
+ cols,
+ rendered
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn title_alignment_survives_narrow_panes() {
+ // must not panic or spin: the geometry loop clamps rather than trusting
+ // that the title fits.
+ for cols in 1..24 {
+ for alignment in [PaneTitleAlignment::Center, PaneTitleAlignment::Right] {
+ let frame = titled_frame("a-rather-long-pane-title", alignment, cols);
+ let rendered = characters_to_string(&frame.render_title().unwrap());
+ assert_eq!(rendered.chars().count(), cols, "cols {}", cols);
+ }
+ }
+ }
+
#[test]
fn help_text_full_includes_ctrl_scroll_when_enabled_for_tiled_pane() {
let frame = pane_frame_with(true, false, 80);
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 12e66002..f4ce534f 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -1384,11 +1384,45 @@ pub struct Palette {
pub brown: PaletteColor,
}
+/// Where the pane title sits inside a pane's frame.
+///
+/// NOTE: this currently only affects panes drawn with FULL frames. In frameless
+/// (`pane_frame_style "titles"`) mode the title goes through
+/// `compose_bracketed_title`, which already centers it unconditionally.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PaneTitleAlignment {
+ #[default]
+ Left,
+ Center,
+ Right,
+}
+
+impl std::str::FromStr for PaneTitleAlignment {
+ type Err = String;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.trim().to_lowercase().as_str() {
+ "left" => Ok(PaneTitleAlignment::Left),
+ "center" | "centre" => Ok(PaneTitleAlignment::Center),
+ "right" => Ok(PaneTitleAlignment::Right),
+ e => Err(format!(
+ "Unknown pane title alignment: '{}' (expected 'left', 'center' or 'right')",
+ e
+ )),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub struct Style {
pub colors: Styling,
pub rounded_corners: bool,
pub hide_session_name: bool,
+ /// NOT round-tripped through the plugin protobuf API on purpose: plugins
+ /// have no use for it and adding a proto field would force regenerating
+ /// the committed prost assets. Defaults to Left when coming back from a
+ /// ProtobufStyle.
+ pub pane_title_alignment: PaneTitleAlignment,
}
#[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 1b917864..9387aef3 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -1407,6 +1407,7 @@ mod config_test {
pane_frames: FrameConfig {
rounded_corners: true,
hide_session_name: true,
+ title_alignment: Default::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 b4fbc630..0930bc15 100644
--- a/zellij-utils/src/input/theme.rs
+++ b/zellij-utils/src/input/theme.rs
@@ -26,6 +26,7 @@ impl UiConfig {
pub struct FrameConfig {
pub rounded_corners: bool,
pub hide_session_name: bool,
+ pub title_alignment: crate::data::PaneTitleAlignment,
}
impl FrameConfig {
@@ -33,6 +34,7 @@ impl FrameConfig {
let mut merged = self.clone();
merged.rounded_corners = other.rounded_corners;
merged.hide_session_name = other.hide_session_name;
+ merged.title_alignment = other.title_alignment;
merged
}
}
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index 8e86445f..bc188aed 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -5524,9 +5524,21 @@ impl UiConfig {
.unwrap_or(false);
let hide_session_name =
kdl_get_child_entry_bool_value!(pane_frames, "hide_session_name").unwrap_or(false);
+ let title_alignment = match kdl_children_property_first_arg_as_string!(
+ pane_frames,
+ "title_alignment"
+ ) {
+ Some(string) => {
+ crate::data::PaneTitleAlignment::from_str(string).map_err(|e| {
+ ConfigError::new_kdl_error(e, kdl_ui_config.span().offset(), kdl_ui_config.span().len())
+ })?
+ },
+ None => crate::data::PaneTitleAlignment::default(),
+ };
let frame_config = FrameConfig {
rounded_corners,
hide_session_name,
+ title_alignment,
};
ui_config.pane_frames = frame_config;
}
@@ -5550,6 +5562,19 @@ impl UiConfig {
hide_session_name.push(KdlValue::Bool(true));
frame_config_children.nodes_mut().push(hide_session_name);
}
+ if self.pane_frames.title_alignment != crate::data::PaneTitleAlignment::default() {
+ has_ui_config = true;
+ let mut title_alignment = KdlNode::new("title_alignment");
+ title_alignment.push(KdlValue::String(
+ match self.pane_frames.title_alignment {
+ crate::data::PaneTitleAlignment::Left => "left",
+ crate::data::PaneTitleAlignment::Center => "center",
+ crate::data::PaneTitleAlignment::Right => "right",
+ }
+ .to_string(),
+ ));
+ frame_config_children.nodes_mut().push(title_alignment);
+ }
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/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index 970f5e2c..0189d11b 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -2492,6 +2492,7 @@ fn serialize_mode_update_event_with_non_default_values() {
// TODO: replace default
rounded_corners: true,
hide_session_name: false,
+ pane_title_alignment: 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 eb9eebcf..2f3ff298 100644
--- a/zellij-utils/src/plugin_api/style.rs
+++ b/zellij-utils/src/plugin_api/style.rs
@@ -21,6 +21,11 @@ impl TryFrom<ProtobufStyle> for Style {
colors: s,
rounded_corners: protobuf_style.rounded_corners,
hide_session_name: protobuf_style.hide_session_name,
+ // deliberately not carried over the plugin protobuf boundary; see
+ // the field docs on Style. Plugins never need the frame's title
+ // alignment, and adding a proto field would mean regenerating the
+ // committed prost assets.
+ pane_title_alignment: Default::default(),
})
}
}