feat: add configurable whitespace markers

This commit is contained in:
2026-10-13 22:53:40 +02:00
parent e2f33519d1
commit 2357b5be46
8 changed files with 210 additions and 7 deletions
+104 -3
View File
@@ -995,6 +995,15 @@ fn display_column(line: &str, grapheme_column: usize) -> usize {
}
fn visible_line(line: &str, left: usize, width: usize) -> String {
visible_text(line, left, width, None)
}
fn visible_text(
line: &str,
left: usize,
width: usize,
whitespace: Option<&BTreeMap<char, char>>,
) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
@@ -1009,6 +1018,19 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
break;
}
if end > left || (cells == 0 && column >= left) {
let marker = whitespace.and_then(|characters| {
let ch = grapheme.chars().next()?;
(grapheme.len() == ch.len_utf8())
.then(|| characters.get(&ch))
.flatten()
});
if let Some(&marker) = marker {
for cell in column.max(left)..end.min(right) {
visible.push(if cell == column { marker } else { ' ' });
}
column = end;
continue;
}
if grapheme == "\t" || column < left || end > right {
for _ in column.max(left)..end.min(right) {
visible.push(' ');
@@ -1834,10 +1856,11 @@ fn draw_document(
.visible
.iter()
.map(|&(row, _, segment)| {
Line::from(visible_line(
Line::from(visible_text(
&editor.lines()[row],
segment.start,
segment.end.saturating_sub(segment.start).min(width),
Some(editor.whitespace_characters()),
))
})
.collect();
@@ -2938,6 +2961,7 @@ fn draw_diff(
wrapped,
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
);
text_widths[0] = text_area.width;
if let Some(editor) =
@@ -2975,6 +2999,7 @@ fn draw_diff(
wrapped,
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
);
text_widths[1] = text_area.width;
if let Some(editor) =
@@ -3130,6 +3155,7 @@ fn draw_diff_pane(
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
@@ -3237,8 +3263,13 @@ fn draw_diff_pane(
Cow::Borrowed(source_text.as_str())
};
frame.render_widget(
Paragraph::new(visible_line(&text, left, text_width.min(width)))
.style(Style::default().fg(Color::Reset).bg(background)),
Paragraph::new(visible_text(
&text,
left,
text_width.min(width),
whitespace_characters,
))
.style(Style::default().fg(Color::Reset).bg(background)),
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
);
if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) {
@@ -4883,6 +4914,73 @@ mod tests {
}
}
#[test]
fn whitespace_characters_preserve_source_geometry_and_overlay_styles() {
let characters =
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]);
let source = "a \tb\u{a0}\u{3000} c";
assert_eq!(visible_text(source, 0, 20, Some(&characters)), "a·→ b⍽_ ·c");
assert_eq!(visible_text(source, 3, 6, Some(&characters)), " b⍽_ ·");
assert_eq!(visible_text(source, 7, 2, Some(&characters)), " ·");
assert_eq!(visible_text(source, 6, 1, Some(&characters)), "_");
assert_eq!(
visible_text(" \u{301}", 0, 4, Some(&characters)),
" \u{301}"
);
assert_eq!(visible_text("", 0, 4, Some(&characters)), "");
let directory = Directory::new();
let source = "a b\t \u{3000}\n \t\n\nabcdefg ";
let path = directory.file("markers.txt", source);
let config = directory.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u3000" => "_" }; c.highlight_trailing_whitespace = true; c.git_gutter = "none" }"#,
);
let mut session = EditorSession::open(Some(path.clone()), 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 (x, y, marker) in [
(3, 0, "·"),
(5, 0, "→"),
(6, 0, "·"),
(7, 0, "_"),
(2, 1, "·"),
(3, 1, "→"),
(9, 3, "·"),
(2, 4, "·"),
] {
assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})");
}
assert_eq!(buffer[(8, 0)].symbol(), " ");
assert_eq!(buffer[(7, 0)].bg, Color::Red);
assert_eq!(buffer[(2, 2)].symbol(), " ");
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)].symbol(), "·");
assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue);
assert_eq!(session.editor().lines().join("\n"), source);
assert!(!session.editor().dirty());
assert_eq!(fs::read_to_string(path).unwrap(), source);
session
.editor_mut()
.set_whitespace_characters(BTreeMap::new());
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), " ");
assert_eq!(terminal.backend().buffer()[(7, 0)].symbol(), "\u{3000}");
}
#[test]
fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() {
let directory = Directory::new();
@@ -4945,6 +5043,7 @@ mod tests {
syntax_diff_file("whitespace.txt", "old \t", "new \t"),
);
editor.set_highlight_trailing_whitespace(true);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')]));
if wrapped {
editor.execute("word_wrap.toggle");
}
@@ -4979,6 +5078,8 @@ mod tests {
);
}
assert_ne!(buffer.content[start + 8].bg, Color::Red);
assert_eq!(buffer.content[start + 3].symbol(), "·");
assert_eq!(buffer.content[start + 4].symbol(), "→");
}
}
}