fix: centralize chrome segment spacing

This commit is contained in:
2026-10-18 01:52:43 +02:00
parent d8cfd27ee6
commit a28444a9f2
5 changed files with 87 additions and 75 deletions
+6 -4
View File
@@ -117,6 +117,8 @@ config.bind "leader s", config.core.diff.orientation.toggle
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group. `config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
The renderer inserts one unstyled blank column between visible items in each group, including tab titles. Hidden items contribute no separator; there is no separator before the first or after the last item. Separator widths count toward anchoring and collision handling. Plain-text segments default to no padding or margins, so adjacent labels do not double their spacing.
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported. Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
Defaults put `left: [:tabs]` and `right: [:shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: Defaults put `left: [:tabs]` and `right: [:shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
@@ -127,7 +129,7 @@ Vamp.configure do |config|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
center: [], right: [:language, :lsp, :position], center: [], right: [:language, :lsp, :position],
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" }, mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:shell, :battery], left: [:tabs], center: [], right: [:shell, :battery],
@@ -150,9 +152,9 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active. `:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column, and `:shell`, which has one trailing column separating it from the battery. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits. `margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. `padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, and shell status. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar. Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
@@ -160,7 +162,7 @@ Any other segment name is a custom named value, such as `:build_status`. Set ini
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks. Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
`:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and a separating margin; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators. `:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators.
### Command and search popup anchors ### Command and search popup anchors
+4 -23
View File
@@ -102,7 +102,7 @@ pub struct ChromeLine {
pub segments: BTreeMap<String, SegmentConfig>, pub segments: BTreeMap<String, SegmentConfig>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SegmentConfig { pub struct SegmentConfig {
pub title: String, pub title: String,
pub padding: usize, pub padding: usize,
@@ -112,19 +112,6 @@ pub struct SegmentConfig {
pub first_leading_edge: Option<String>, pub first_leading_edge: Option<String>,
} }
impl Default for SegmentConfig {
fn default() -> Self {
Self {
title: String::new(),
padding: 1,
margin: [0; 2],
leading_edge: String::new(),
trailing_edge: String::new(),
first_leading_edge: None,
}
}
}
impl ChromeLine { impl ChromeLine {
pub fn top() -> Self { pub fn top() -> Self {
Self { Self {
@@ -149,13 +136,7 @@ impl ChromeLine {
right: vec!["language".into(), "lsp".into(), "position".into()], right: vec!["language".into(), "lsp".into(), "position".into()],
segments: DEFAULT_BOTTOM_SEGMENTS segments: DEFAULT_BOTTOM_SEGMENTS
.iter() .iter()
.map(|name| { .map(|name| ((*name).into(), default_segment(name)))
let mut segment = default_segment(name);
if *name == "position" {
segment.margin[0] = 1;
}
((*name).into(), segment)
})
.collect(), .collect(),
} }
} }
@@ -219,8 +200,8 @@ fn default_segment(name: &str) -> SegmentConfig {
if matches!(name, "tabs" | "mode" | "shell") { if matches!(name, "tabs" | "mode" | "shell") {
segment.trailing_edge = "▓░".into(); segment.trailing_edge = "▓░".into();
} }
if name == "shell" { if matches!(name, "tabs" | "mode" | "position" | "battery" | "shell") {
segment.margin[1] = 1; segment.padding = 1;
} }
if name == "tabs" { if name == "tabs" {
segment.title = "{index} {view}{name}{modified}".into(); segment.title = "{index} {view}{name}{modified}".into();
+5 -3
View File
@@ -1833,7 +1833,7 @@ fn chrome_items<'a>(
} }
fn chrome_group_width(items: &[ChromeItem<'_>]) -> usize { fn chrome_group_width(items: &[ChromeItem<'_>]) -> usize {
items.iter().map(|item| item.width).sum() items.iter().map(|item| item.width).sum::<usize>() + items.len().saturating_sub(1)
} }
fn chrome_fits([left, center, right]: [usize; 3], width: usize) -> bool { fn chrome_fits([left, center, right]: [usize; 3], width: usize) -> bool {
@@ -1904,13 +1904,15 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
} }
break; break;
}; };
widths[group] -= groups[group].remove(index).width; groups[group].remove(index);
widths[group] = chrome_group_width(&groups[group]);
} }
let widths = widths.map(|cells| cells.min(width)); let widths = widths.map(|cells| cells.min(width));
let starts = [0, (width - widths[1]) / 2, width - widths[2]]; let starts = [0, (width - widths[1]) / 2, width - widths[2]];
for (items, start) in groups.iter().zip(starts) { for (items, start) in groups.iter().zip(starts) {
let mut x = start; let mut x = start;
for item in items { for (index, item) in items.iter().enumerate() {
x += usize::from(index > 0);
if x >= width { if x >= width {
break; break;
} }
-1
View File
@@ -931,7 +931,6 @@ end
let loaded = load_config(&path).unwrap(); let loaded = load_config(&path).unwrap();
assert_eq!(loaded.top_bar.left, ["mode", "language"]); assert_eq!(loaded.top_bar.left, ["mode", "language"]);
assert_eq!(loaded.top_bar.segment("mode").padding, 0); assert_eq!(loaded.top_bar.segment("mode").padding, 0);
assert_eq!(loaded.top_bar.segment("language").padding, 1);
assert_eq!(loaded.bottom_bar.left, ["tabs"]); assert_eq!(loaded.bottom_bar.left, ["tabs"]);
assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}"); assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}");
+63 -35
View File
@@ -4933,41 +4933,69 @@ fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() {
} }
#[test] #[test]
fn bottom_position_has_only_a_leading_margin_and_lsp_has_none() { fn chrome_separates_visible_segments_once_in_every_anchor() {
let mut editor = Editor::open(None, vec![]).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_lsp_names(vec!["server".into()]); for anchor in 0..3 {
configure_chrome(&mut editor, |top, bottom| { configure_chrome(&mut editor, |top, bottom| {
top.left.clear(); for line in [top, bottom] {
top.center.clear(); line.left.clear();
top.right = vec!["position".into()]; line.center.clear();
bottom.left.clear(); line.right.clear();
bottom.center.clear(); let names = vec![
bottom.right = vec!["lsp".into(), "position".into()]; "before".into(),
"lsp".into(),
"after".into(),
"position".into(),
];
match anchor {
0 => line.left = names,
1 => line.center = names,
_ => line.right = names,
}
}
}); });
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); editor
.set_label("before".into(), "Markdown".into())
.unwrap();
editor.set_label("after".into(), "tail".into()).unwrap();
for servers in [vec![], vec!["server".into()]] {
editor.set_lsp_names(servers);
let expected = if editor.lsp_label().is_empty() {
"Markdown tail ░▓ 1:1 "
} else {
"Markdown server tail ░▓ 1:1 "
};
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 2)).unwrap();
terminal terminal
.draw(|frame| { .draw(|frame| {
for (line, y) in [ for (line, y) in [
(editor.top_bar_config(), 0), (editor.top_bar_config(), 0),
(editor.bottom_bar_config(), 1), (editor.bottom_bar_config(), 1),
] { ] {
let groups = chrome_groups(&editor, line, &SyntaxCache::new()); draw_chrome(
let start = 40 - chrome_group_width(&groups[2]) as u16; frame,
if y == 1 { chrome_groups(&editor, line, &SyntaxCache::new()),
let gap = start + groups[2][0].width as u16; Rect::new(0, y, 50, 1),
draw_chrome(frame, groups, Rect::new(0, y, 40, 1)); );
assert_eq!(frame.buffer_mut()[(start, y)].bg, Color::Cyan);
assert_eq!(frame.buffer_mut()[(gap, y)].symbol(), " ");
assert_eq!(frame.buffer_mut()[(gap, y)].bg, Color::Reset);
assert_eq!(frame.buffer_mut()[(gap + 1, y)].symbol(), "░");
} else {
draw_chrome(frame, groups, Rect::new(0, y, 40, 1));
assert_eq!(frame.buffer_mut()[(start, y)].symbol(), "░");
}
assert_eq!(frame.buffer_mut()[(39, y)].bg, Color::Cyan);
} }
}) })
.unwrap(); .unwrap();
let start = match anchor {
0 => 0,
1 => (50 - expected.width()) / 2,
_ => 50 - expected.width(),
};
for y in 0..2 {
assert_eq!(
pane_screen(
&terminal,
Rect::new(start as u16, y, expected.width() as u16, 1)
),
expected
);
}
}
}
} }
#[test] #[test]
@@ -5855,8 +5883,8 @@ fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() {
assert_eq!(buffer[(25, y)].symbol(), "I"); assert_eq!(buffer[(25, y)].symbol(), "I");
assert_eq!(buffer[(26, y)].symbol(), "D"); assert_eq!(buffer[(26, y)].symbol(), "D");
} }
assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("ABC")); assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("A BC"));
assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("ABC")); assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("A BC"));
editor.set_label("left".into(), "L".repeat(30)).unwrap(); editor.set_label("left".into(), "L".repeat(30)).unwrap();
terminal terminal
@@ -5876,7 +5904,7 @@ fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() {
for (y, x, width) in [(0, 0, 51), (2, 3, 45)] { for (y, x, width) in [(0, 0, 51), (2, 3, 45)] {
let row = pane_screen(&terminal, Rect::new(x, y, width, 1)); let row = pane_screen(&terminal, Rect::new(x, y, width, 1));
assert!(!row.contains("MID"), "{row}"); assert!(!row.contains("MID"), "{row}");
assert!(row.ends_with("ABC"), "{row}"); assert!(row.ends_with("A BC"), "{row}");
} }
} }
@@ -6024,11 +6052,11 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
for (x, text) in [ for (x, text) in [
(2, "1"), (2, "1"),
(5, "界"), (6, "界"),
(7, "▌"), (8, "▌"),
(10, "2"), (11, "2"),
(13, "▐"), (14, "▐"),
(14, "界"), (15, "界"),
(29, "界"), (29, "界"),
(31, "("), (31, "("),
(34, "e\u{301}"), (34, "e\u{301}"),
@@ -6051,9 +6079,9 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
assert_eq!(buffer[(x, 2)].bg, Color::Cyan); assert_eq!(buffer[(x, 2)].bg, Color::Cyan);
} }
assert_eq!(buffer[(2, 0)].bg, Color::Reset); assert_eq!(buffer[(2, 0)].bg, Color::Reset);
assert_eq!(buffer[(10, 0)].bg, Color::Cyan); assert_eq!(buffer[(11, 0)].bg, Color::Cyan);
assert_eq!(buffer[(5, 0)].bg, Color::Reset); assert_eq!(buffer[(5, 0)].bg, Color::Reset);
for x in [8, 9, 11, 12, 32, 33, 35, 36] { for x in [9, 10, 12, 13, 32, 33, 35, 36] {
assert_eq!(buffer[(x, 0)].symbol(), " "); assert_eq!(buffer[(x, 0)].symbol(), " ");
assert_eq!(buffer[(x, 0)].bg, Color::Cyan); assert_eq!(buffer[(x, 0)].bg, Color::Cyan);
} }
@@ -6071,8 +6099,8 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
assert_eq!(buffer[(0, 0)].symbol(), "<"); assert_eq!(buffer[(0, 0)].symbol(), "<");
assert_eq!(buffer[(3, 0)].symbol(), "1"); assert_eq!(buffer[(3, 0)].symbol(), "1");
assert_eq!(buffer[(3, 0)].bg, Color::Cyan); assert_eq!(buffer[(3, 0)].bg, Color::Cyan);
assert_eq!(buffer[(11, 0)].symbol(), "2"); assert_eq!(buffer[(12, 0)].symbol(), "2");
assert_eq!(buffer[(11, 0)].bg, Color::Reset); assert_eq!(buffer[(12, 0)].bg, Color::Reset);
} }
#[test] #[test]