diff options
| author | user <user@clank> | 2026-08-08 19:31:55 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-08 19:31:55 +0200 |
| commit | 3f767ee91b08eebf10b693c08c173b8329ba2c14 (patch) | |
| tree | 6f2b7feed0f454f8a6036fbef54b2d647f35462d | |
| parent | sl0p: rebuilt default plugin wasm assets (diff) | |
| download | sl0ppij-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.rs | 1 | ||||
| -rw-r--r-- | zellij-server/src/lib.rs | 2 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_boundaries_frame.rs | 162 | ||||
| -rw-r--r-- | zellij-utils/src/data.rs | 34 | ||||
| -rw-r--r-- | zellij-utils/src/input/config.rs | 1 | ||||
| -rw-r--r-- | zellij-utils/src/input/theme.rs | 2 | ||||
| -rw-r--r-- | zellij-utils/src/kdl/mod.rs | 25 | ||||
| -rw-r--r-- | zellij-utils/src/plugin_api/event.rs | 1 | ||||
| -rw-r--r-- | zellij-utils/src/plugin_api/style.rs | 5 |
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(), }) } } |
