feat: support configurable tabline status label

This commit is contained in:
Valdemar Lorenzen
2026-10-12 10:56:30 +02:00
parent 05d62aa4a5
commit d55b7b65d2
10 changed files with 526 additions and 32 deletions
+191 -4
View File
@@ -1002,7 +1002,7 @@ fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
}
fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
if editor.tab_count() <= 1 || area.height == 0 {
if (editor.tab_count() <= 1 && editor.tabline_label().is_empty()) || area.height == 0 {
return area;
}
let bar = Rect::new(area.x, area.y, area.width, 1);
@@ -1044,7 +1044,16 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
let fade_width = if active == 0 { 3 } else { 6 };
let fade = usize::from(bar.width) >= labels[active].width() + fade_width;
let mut width = labels[active].width() + if fade { fade_width } else { 0 };
while first > 0 && width + labels[first - 1].width() <= usize::from(bar.width) {
let status = editor.tabline_label();
let status_width = status.width() + 5; // Left fade and one space on each side.
let show_status = !status.is_empty() && width + 1 + status_width <= usize::from(bar.width);
let tabs_right = bar.right()
- if show_status {
status_width as u16 + 1
} else {
0
};
while first > 0 && width + labels[first - 1].width() <= usize::from(tabs_right - bar.x) {
first -= 1;
width += labels[first].width();
}
@@ -1053,7 +1062,7 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
for (index, label) in labels.iter().enumerate().skip(first) {
let decorated = index == active && fade;
let width = (label.width() + if decorated { fade_width } else { 0 })
.min(usize::from(bar.right() - x)) as u16;
.min(usize::from(tabs_right - x)) as u16;
let highlight = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
@@ -1073,10 +1082,28 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
};
frame.render_widget(Paragraph::new(line), Rect::new(x, bar.y, width, 1));
x += width;
if x == bar.right() {
if x == tabs_right {
break;
}
}
if show_status {
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
let edge = Style::default().fg(Color::Cyan);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("░▒▓", edge),
Span::styled(" ", highlight),
Span::styled(status, highlight),
Span::styled(" ", highlight),
])),
Rect::new(
bar.right() - status_width as u16,
bar.y,
status_width as u16,
1,
),
);
}
workspace
}
@@ -5249,6 +5276,7 @@ mod tests {
"edit {}",
directory.file("active.txt", "ACTIVE").display()
));
editor.set_tabline_label("status".into());
let mut views = BufferViewports::default();
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
let mut terminal =
@@ -5259,6 +5287,7 @@ mod tests {
if width > 0 && height > 0 {
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
assert!(title.trim_start().starts_with('3'), "{title}");
assert!(!title.contains("status"), "{title}");
}
}
@@ -5273,6 +5302,164 @@ mod tests {
assert!(titles.contains("2 [No Name]"), "{titles}");
}
#[test]
fn singleton_tabline_label_sanitizes_unicode_and_redraws_through_clear() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let original = screen_text(&terminal);
assert_eq!(views.document.geometry.unwrap().1.y, 0);
editor.set_tabline_label("電池 e\u{301}\t90%".into());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(title.contains("1 source.txt"), "{title}");
assert!(title.contains("電") && title.contains("池"), "{title}");
assert!(title.contains("e\u{301}�90%"), "{title}");
let start = 80 - (editor.tabline_label().width() + 5) as u16;
let buffer = terminal.backend().buffer();
for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] {
assert_eq!(buffer[(start + offset, 0)].symbol(), glyph);
}
assert_eq!(buffer[(78, 0)].symbol(), "%");
assert_eq!(buffer[(79, 0)].symbol(), " ");
assert_eq!(views.document.geometry.unwrap().1.y, 1);
assert!(pane_screen(&terminal, views.rects[0].1).contains("DOCUMENT"));
assert!(terminal.get_cursor_position().unwrap().y >= 1);
editor.set_tabline_label("live".into());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live "));
assert!(!screen_text(&terminal).contains("90%"));
editor.set_tabline_label(String::new());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.document.geometry.unwrap().1.y, 0);
assert_eq!(screen_text(&terminal), original);
}
#[test]
fn tabline_label_keeps_a_gap_and_yields_to_the_full_active_title() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("a-very-long-inactive-title.txt", "first")),
vec![],
)
.unwrap();
editor.execute("tab.new");
editor.execute("tab.new");
editor.execute(&format!(
"edit {}",
directory.file("active.txt", "ACTIVE").display()
));
editor.set_tabline_label("90%".into());
for width in [28, 29, 80] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
assert_eq!(draw_tab_bar(frame, &editor, area).y, 1);
})
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
assert!(title.contains("3 active.txt"), "{title}");
if width == 28 {
assert!(!title.contains("90%"), "{title}");
} else {
assert!(title.ends_with(" ░▒▓ 90% "), "{title}");
}
}
editor.set_tabline_label("界".repeat(1024));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 2)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
draw_tab_bar(frame, &editor, area);
})
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(title.contains("3 active.txt"), "{title}");
assert!(!title.contains('界'), "{title}");
}
#[test]
fn tabline_label_stays_above_split_overlays_and_diff_geometry() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
editor.execute("pane.split_vertical");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let original = views.rects.clone();
editor.set_tabline_label("live".into());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
for ((id, before), (after_id, after)) in original.iter().zip(&views.rects) {
assert_eq!(id, after_id);
assert_eq!(
*after,
Rect::new(before.x, before.y + 1, before.width, before.height - 1)
);
}
let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Commands"));
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
let mut editor = readonly_diff_editor(deletion_file());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let regions = views.panels.diff.as_ref().unwrap().regions;
editor.set_tabline_label("live".into());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
for ((before, focus), (after, after_focus)) in regions
.into_iter()
.zip(views.panels.diff.as_ref().unwrap().regions)
{
assert_eq!(focus, after_focus);
if before == Rect::default() {
assert_eq!(after, before);
} else {
assert_eq!(
after,
Rect::new(before.x, before.y + 1, before.width, before.height - 1)
);
}
}
assert_eq!(views.panels.files.unwrap().0.y, 2);
let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
views.panels.files.unwrap().0,
floating_panel(Rect::new(0, 1, 120, 29))
);
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
}
#[test]
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
let directory = Directory::new();