feat: add opt-in trailing whitespace highlighting

This commit is contained in:
2026-10-13 22:42:30 +02:00
parent b58fb085a3
commit e2f33519d1
8 changed files with 179 additions and 4 deletions
+125
View File
@@ -1865,6 +1865,9 @@ fn draw_document(
area,
segment.start,
);
if editor.highlight_trailing_whitespace() {
paint_trailing_whitespace(frame, line, area, segment.start);
}
paint_editor_line(
frame,
editor,
@@ -2212,6 +2215,21 @@ fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: us
}
}
fn paint_trailing_whitespace(frame: &mut Frame, line: &str, area: Rect, left: usize) {
let prefix = line.trim_end_matches(char::is_whitespace);
if prefix.len() == line.len() {
return;
}
paint_cells(
frame,
area,
left,
display_column(prefix, usize::MAX),
display_column(line, usize::MAX),
Style::default().bg(Color::Red),
);
}
fn paint_grapheme_range(
frame: &mut Frame,
area: Rect,
@@ -2919,6 +2937,7 @@ fn draw_diff(
),
wrapped,
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
);
text_widths[0] = text_area.width;
if let Some(editor) =
@@ -2955,6 +2974,7 @@ fn draw_diff(
),
wrapped,
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
);
text_widths[1] = text_area.width;
if let Some(editor) =
@@ -3109,6 +3129,7 @@ fn draw_diff_pane(
viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
highlight_trailing_whitespace: bool,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
@@ -3281,6 +3302,9 @@ fn draw_diff_pane(
}
}
}
if highlight_trailing_whitespace {
paint_trailing_whitespace(frame, &text, text_area, left);
}
}
Rect::new(
content.x + gutter as u16,
@@ -4859,6 +4883,107 @@ mod tests {
}
}
#[test]
fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() {
let directory = Directory::new();
let source = "a b\t \u{3000}\n \t\n\nabcdefg ";
let path = directory.file("whitespace.txt", source);
let config = directory.file(
"config.rb",
"Vamp.configure { |c| c.highlight_trailing_whitespace = true; c.git_gutter = 'none' }",
);
let mut session = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
session.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
let buffer = terminal.backend().buffer();
for (y, range) in [(0, 5..9), (1, 2..6), (3, 9..10), (4, 2..3)] {
for x in 2..10 {
// The backend skips the covered cell of a wide grapheme.
if (x, y) == (8, 0) {
continue;
}
assert_eq!(
buffer[(x, y)].bg == Color::Red,
range.contains(&x),
"({x}, {y})"
);
}
}
assert_eq!(buffer[(2, 2)].bg, Color::Reset);
session.handle(Input::End);
session.handle(Input::Char('v'));
session.handle(Input::Left);
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue);
assert_eq!(session.editor().lines().join("\n"), source);
assert!(!session.editor().dirty());
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("x \t".into())])
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_ne!(terminal.backend().buffer()[(7, 0)].bg, Color::Red);
}
#[test]
fn trailing_whitespace_covers_both_diff_sides_and_unified_rows() {
for mode in [ViewMode::Split, ViewMode::Unified] {
for wrapped in [false, true] {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, crate::panes::SplitAxis::Vertical);
install_readonly_diff(
&mut editor,
syntax_diff_file("whitespace.txt", "old \t", "new \t"),
);
editor.set_highlight_trailing_whitespace(true);
if wrapped {
editor.execute("word_wrap.toggle");
}
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let buffer = terminal.backend().buffer();
for word in ["old", "new"] {
let start = buffer
.content
.iter()
.enumerate()
.find_map(|(index, cell)| {
(cell.symbol() == &word[..1]
&& buffer
.content
.get(index + 1)
.is_some_and(|cell| cell.symbol() == &word[1..2])
&& buffer
.content
.get(index + 2)
.is_some_and(|cell| cell.symbol() == &word[2..3]))
.then_some(index)
})
.unwrap();
for offset in 3..8 {
assert_eq!(
buffer.content[start + offset].bg,
Color::Red,
"{mode:?} {wrapped} {word}"
);
}
assert_ne!(buffer.content[start + 8].bg, Color::Red);
}
}
}
}
#[test]
fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() {
let directory = Directory::new();