feat: dim whitespace markers and configure scope and newlines

This commit is contained in:
2026-10-13 23:18:39 +02:00
parent adcc4f2656
commit 11b31d2621
10 changed files with 964 additions and 112 deletions
+726 -91
View File
@@ -40,6 +40,7 @@ use crate::{
},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
WhitespaceScope,
},
files::{DockSide, FilePicker, FilePresentation, FileView},
panes::{PaneRect, SplitAxis, split_rect},
@@ -995,23 +996,14 @@ 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, &[], false)
visible_text(line, left, width, false)
}
fn visible_text(
line: &str,
left: usize,
width: usize,
whitespace: Option<&BTreeMap<char, char>>,
strings: &[SyntaxSpan],
sanitize_controls: bool,
) -> String {
fn visible_text(line: &str, left: usize, width: usize, sanitize_controls: bool) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
let whitespace = whitespace.filter(|characters| !characters.is_empty());
let mut strings = strings.iter().peekable();
for (byte, grapheme) in line.grapheme_indices(true) {
for grapheme in line.graphemes(true) {
let control = sanitize_controls && grapheme.chars().any(|ch| ch.is_control() && ch != '\t');
let cells = if control {
grapheme.chars().count()
@@ -1025,29 +1017,6 @@ fn visible_text(
break;
}
if end > left || (cells == 0 && column >= left) {
let marker = whitespace.and_then(|characters| {
let ch = grapheme.chars().next()?;
let marker = (grapheme.len() == ch.len_utf8())
.then(|| characters.get(&ch))
.flatten()?;
while strings.peek().is_some_and(|span| span.end_byte <= byte) {
strings.next();
}
if strings
.peek()
.is_some_and(|span| span.start_byte < byte + grapheme.len())
{
return None;
}
Some(marker)
});
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(' ');
@@ -1881,8 +1850,6 @@ fn draw_document(
&editor.lines()[row],
segment.start,
segment.end.saturating_sub(segment.start).min(width),
Some(editor.whitespace_characters()),
syntax.string_spans(row),
false,
))
})
@@ -1911,6 +1878,17 @@ fn draw_document(
area,
segment.start,
);
paint_whitespace(
frame,
line,
area,
segment.start,
editor.whitespace_characters(),
editor.whitespace_scope(),
syntax.string_spans(row),
row + 1 < editor.lines().len(),
false,
);
if editor.highlight_trailing_whitespace() {
paint_trailing_whitespace(frame, line, area, segment.start);
}
@@ -2261,6 +2239,94 @@ fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: us
}
}
#[allow(clippy::too_many_arguments)]
fn paint_whitespace(
frame: &mut Frame,
line: &str,
area: Rect,
left: usize,
characters: &BTreeMap<char, char>,
scope: WhitespaceScope,
strings: &[SyntaxSpan],
newline: bool,
sanitize_controls: bool,
) {
if characters.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let leading_end = match scope {
WhitespaceScope::All => line.len(),
WhitespaceScope::Leading => line.len() - line.trim_start_matches(char::is_whitespace).len(),
};
let right = left.saturating_add(usize::from(area.width));
let style = Style::default()
.fg(Color::Reset)
.add_modifier(Modifier::DIM);
let mut strings = strings.iter().peekable();
let mut column = 0;
for (byte, grapheme) in line.grapheme_indices(true) {
if column >= right {
return;
}
let control = sanitize_controls && grapheme.chars().any(|ch| ch.is_control() && ch != '\t');
let cells = if control {
grapheme.chars().count()
} else if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
while strings.peek().is_some_and(|span| span.end_byte <= byte) {
strings.next();
}
let protected = strings
.peek()
.is_some_and(|span| span.start_byte < byte + grapheme.len());
if byte < leading_end && !protected && cells > 0 {
let ch = grapheme.chars().next().unwrap();
if grapheme.len() == ch.len_utf8()
&& let Some(&marker) = characters.get(&ch)
{
// Ratatui resets covered wide-glyph cells; restore their source style when exposed.
let wide_style = (cells > 1 && grapheme != "\t" && column >= left)
.then(|| frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].style());
for cell in column.max(left)..end.min(right) {
let cell = &mut frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)];
cell.set_symbol(" ");
if let Some(style) = wide_style {
cell.set_style(style);
}
}
if column >= left {
frame.buffer_mut()[(area.x + (column - left) as u16, area.y)]
.set_char(marker)
.set_style(style);
}
}
}
column = end;
}
while strings
.peek()
.is_some_and(|span| span.end_byte <= line.len())
{
strings.next();
}
if newline
&& column >= left
&& column < right
&& strings
.peek()
.is_none_or(|span| span.start_byte > line.len())
&& let Some(&marker) = characters.get(&'\n')
{
frame.buffer_mut()[(area.x + (column - left) as u16, area.y)]
.set_char(marker)
.set_style(style);
}
}
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() {
@@ -2923,6 +2989,7 @@ fn draw_diff(
id,
diff.file,
file,
diff.after_source_lines(file),
diff.live_revision,
Some(DiffSyntaxViewport {
diff,
@@ -2985,6 +3052,7 @@ fn draw_diff(
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
);
text_widths[0] = text_area.width;
if let Some(editor) =
@@ -3023,6 +3091,7 @@ fn draw_diff(
panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
);
text_widths[1] = text_area.width;
if let Some(editor) =
@@ -3179,6 +3248,7 @@ fn draw_diff_pane(
colors: DiffColors,
highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>,
whitespace_scope: WhitespaceScope,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
@@ -3270,15 +3340,8 @@ fn draw_diff_pane(
Color::Reset
};
frame.render_widget(
Paragraph::new(visible_text(
source_text,
left,
text_width.min(width),
whitespace_characters,
syntax[usize::from(!before)].string_spans(index),
true,
))
.style(Style::default().fg(Color::Reset).bg(background)),
Paragraph::new(visible_text(source_text, left, text_width.min(width), true))
.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) {
@@ -3333,6 +3396,24 @@ fn draw_diff_pane(
text_area,
left,
);
if let Some(characters) = whitespace_characters {
paint_whitespace(
frame,
source_text,
text_area,
left,
characters,
whitespace_scope,
syntax[usize::from(!before)].string_spans(index),
index + 1 < source.len()
|| if before {
file.before_eof
} else {
file.after_eof
},
true,
);
}
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(colors.character[usize::from(!before)]);
for change in &row.changes {
@@ -3734,6 +3815,7 @@ impl DiffSyntaxCache {
comparison_id: u64,
file_index: usize,
file: &ChangedFile,
after: &[String],
live_revision: Option<u64>,
visible: Option<DiffSyntaxViewport<'_>>,
) {
@@ -3760,26 +3842,30 @@ impl DiffSyntaxCache {
{
diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows, wrapped);
diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows, wrapped);
self.before.update_visible(
self.before.update_diff(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
&self.before_rows,
file.before_eof,
Some(&self.before_rows),
);
self.after.update_visible(
&file.after,
self.after.update_diff(
after,
Some(&file.path),
self.revision,
&self.after_rows,
file.after_eof,
Some(&self.after_rows),
);
} else {
self.before.update(
self.before.update_diff(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
file.before_eof,
None,
);
self.after
.update(&file.after, Some(&file.path), self.revision);
.update_diff(after, Some(&file.path), self.revision, file.after_eof, None);
}
}
}
@@ -4939,6 +5025,454 @@ mod tests {
}
}
#[allow(clippy::too_many_arguments)]
fn rendered_whitespace_line(
source: &str,
left: usize,
width: u16,
characters: &BTreeMap<char, char>,
scope: WhitespaceScope,
strings: &[SyntaxSpan],
newline: bool,
sanitize_controls: bool,
) -> ratatui::buffer::Buffer {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 1)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame.render_widget(
Paragraph::new(visible_text(
source,
left,
usize::from(width),
sanitize_controls,
))
.style(
Style::default()
.fg(Color::Green)
.bg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED),
),
area,
);
paint_whitespace(
frame,
source,
area,
left,
characters,
scope,
strings,
newline,
sanitize_controls,
);
})
.unwrap();
terminal.backend().buffer().clone()
}
#[test]
fn whitespace_scope_uses_the_source_prefix_and_dims_only_marker_cells() {
let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{3000}', '_'), ('\n', '↵')]);
for (scope, source, left, width, expected) in [
(WhitespaceScope::All, "\t x \t ", 0, 10, "→ ·x·→·↵"),
(WhitespaceScope::Leading, "\t x \t ", 0, 10, "→ ·x ↵"),
(WhitespaceScope::Leading, "\t \u{3000}", 0, 8, "→ ·_ ↵"),
(WhitespaceScope::Leading, " x ", 5, 4, "·x ↵"),
(WhitespaceScope::Leading, "x ", 1, 3, " ↵"),
(WhitespaceScope::Leading, "", 0, 1, "↵"),
] {
let buffer =
rendered_whitespace_line(source, left, width, &characters, scope, &[], true, false);
for (x, symbol) in expected.chars().enumerate() {
let cell = &buffer[(x as u16, 0)];
assert_eq!(
cell.symbol(),
symbol.to_string(),
"{scope:?}, {source:?}, left={left}"
);
let marker = ['·', '→', '_', '↵'].contains(&symbol);
assert_eq!(cell.modifier.contains(Modifier::DIM), marker);
assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green });
assert_eq!(cell.bg, Color::Yellow);
assert!(cell.modifier.contains(Modifier::UNDERLINED));
}
}
for (source, left, newline, expected) in [
("x", 0, false, "x "),
("x", 1, true, "↵ "),
("x", 2, true, " "),
] {
let buffer = rendered_whitespace_line(
source,
left,
2,
&characters,
WhitespaceScope::All,
&[],
newline,
false,
);
assert_eq!(
buffer
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>(),
expected,
);
}
}
#[test]
fn buffer_newline_markers_use_final_wrap_caret_cells_not_soft_wrap_or_eof() {
let directory = Directory::new();
for tail in ["last", ""] {
for scope in [WhitespaceScope::All, WhitespaceScope::Leading] {
let source = format!("abcd\n\n x \n{tail}");
let path = directory.file("newlines.txt", &source);
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')]));
editor.set_whitespace_scope(scope);
editor.set_highlight_trailing_whitespace(true);
editor.execute("word_wrap.toggle");
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(6, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let area = views.document.geometry.unwrap().1;
let area = Rect::new(
area.right() - views.document.text_width as u16,
area.y,
views.document.text_width as u16,
area.height,
);
assert_eq!(area.width, 4);
let buffer = terminal.backend().buffer();
for row in [1, 2, 4] {
let cell = &buffer[(area.x, area.y + row)];
assert_eq!(cell.symbol(), "↵");
assert!(cell.modifier.contains(Modifier::DIM));
assert_eq!(cell.fg, Color::Reset);
assert_ne!(cell.bg, Color::Red);
}
assert_eq!(
buffer
.content
.iter()
.filter(|cell| cell.symbol() == "↵")
.count(),
3,
);
assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "d");
assert!(
!buffer[(area.x + 3, area.y)]
.modifier
.contains(Modifier::DIM)
);
assert_eq!(
buffer[(area.x, area.y + 5)].symbol(),
if tail.is_empty() { " " } else { "l" }
);
for offset in [0, 1] {
assert_eq!(buffer[(area.x + offset, area.y + 3)].symbol(), "·");
}
let trailing = &buffer[(area.x + 3, area.y + 3)];
assert_eq!(
trailing.symbol(),
if scope == WhitespaceScope::All {
"·"
} else {
" "
}
);
assert_eq!(trailing.bg, Color::Red);
assert_eq!(
trailing.modifier.contains(Modifier::DIM),
scope == WhitespaceScope::All
);
editor.handle(Input::Char('V'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let newline = &terminal.backend().buffer()[(area.x, area.y + 1)];
assert_eq!(newline.symbol(), "↵");
assert_eq!(newline.bg, Color::Blue);
assert!(newline.modifier.contains(Modifier::DIM));
assert_eq!(editor.lines().join("\n"), source);
assert!(!editor.dirty());
assert_eq!(fs::read_to_string(path).unwrap(), source);
}
}
}
#[test]
fn historical_buffer_markers_retain_real_final_newlines_and_scope() {
let mut file = syntax_diff_file("history.txt", "old\n tail ", "new\n tail ");
file.before_eof = true;
file.after_eof = true;
file.hunks = crate::diff::line_hunks(&file.before, true, &file.after, true);
let mut editor = readonly_diff_editor(file);
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')]));
editor.set_whitespace_scope(WhitespaceScope::Leading);
editor.set_highlight_trailing_whitespace(true);
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 path = editor
.interaction_documents()
.find(|document| document.id == editor.buffer_id())
.unwrap()
.path
.unwrap()
.to_owned();
editor
.navigate_lsp_to(path, definition_point(1, 0), None)
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(editor.diff().is_none());
assert!(editor.mutation_blocked());
let area = views.document.geometry.unwrap().1;
let area = Rect::new(
area.right() - views.document.text_width as u16,
area.y,
views.document.text_width as u16,
area.height,
);
let buffer = terminal.backend().buffer();
for (x, y, symbol) in [(3, 0, "↵"), (0, 1, "·"), (1, 1, "·"), (7, 1, "↵")] {
let cell = &buffer[(area.x + x, area.y + y)];
assert_eq!(cell.symbol(), symbol);
assert!(cell.modifier.contains(Modifier::DIM));
}
assert_eq!(buffer[(area.x + 6, area.y + 1)].symbol(), " ");
assert_eq!(buffer[(area.x + 6, area.y + 1)].bg, Color::Red);
assert!(
!buffer[(area.x + 6, area.y + 1)]
.modifier
.contains(Modifier::DIM)
);
assert_eq!(buffer[(area.x, area.y + 2)].symbol(), " ");
assert_eq!(editor.lines().join("\n"), "old\n tail \n");
assert!(!editor.dirty());
}
#[test]
fn diff_newline_markers_cover_both_sides_without_hatch_or_soft_wrap_markers() {
for mode in [ViewMode::Split, ViewMode::Unified] {
for wrapped in [false, true] {
for (before_eof, after_eof) in [(false, true), (true, false)] {
for scope in [WhitespaceScope::All, WhitespaceScope::Leading] {
let length = if wrapped { 220 } else { 60 };
let before = format!(" old{} \t", "x".repeat(length));
let after = format!("\0 new{} \t\n \nlast", "y".repeat(length));
let mut file = syntax_diff_file("newlines.txt", &before, &after);
file.before_eof = before_eof;
file.after_eof = after_eof;
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, SplitAxis::Vertical);
install_readonly_diff(&mut editor, file);
editor.set_whitespace_characters(BTreeMap::from([
(' ', '␣'),
('\t', '→'),
('\n', '↵'),
]));
editor.set_whitespace_scope(scope);
editor.set_highlight_trailing_whitespace(true);
if wrapped {
editor.execute("word_wrap.toggle");
}
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(240, 40)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let buffer = terminal.backend().buffer();
let geometry = views.panels.diff.as_ref().unwrap();
let mut text_cells = Vec::new();
for (region, &width) in
geometry.regions[1..].iter().zip(&geometry.text_widths)
{
let inner = diff_inner(region.0);
for y in inner.y..inner.bottom() {
for x in inner.right() - width..inner.right() {
text_cells.push(&buffer[(x, y)]);
}
}
}
// The unwrapped pane must expose each line end too.
if !wrapped {
assert!(
views
.panels
.diff
.as_ref()
.unwrap()
.text_widths
.iter()
.filter(|&&width| width > 0)
.all(|&width| width >= 72)
);
}
assert_eq!(
text_cells
.iter()
.filter(|cell| cell.symbol() == "↵")
.count(),
2 + usize::from(before_eof) + usize::from(after_eof),
"{mode:?}, wrap={wrapped}, before={before_eof}, after={after_eof}",
);
for cell in text_cells
.iter()
.filter(|cell| ["␣", "→", "↵"].contains(&cell.symbol()))
{
assert!(cell.modifier.contains(Modifier::DIM));
assert_eq!(cell.fg, Color::Reset);
}
for cell in text_cells.iter().filter(|cell| cell.symbol() == "↵") {
assert_ne!(cell.bg, Color::Red);
}
assert!(text_cells.iter().any(|cell| cell.symbol() == "�"));
assert_eq!(
text_cells
.iter()
.filter(|cell| cell.symbol() == "→")
.count(),
if scope == WhitespaceScope::All { 2 } else { 0 },
);
assert_eq!(
text_cells
.iter()
.filter(|cell| cell.symbol() == "␣")
.count(),
if scope == WhitespaceScope::All { 8 } else { 4 },
);
if mode == ViewMode::Split {
assert!(buffer.content.iter().any(|cell| cell.symbol() == "╱"));
}
}
}
}
}
}
#[test]
fn exact_width_diff_newlines_use_the_existing_extra_caret_segment() {
for mode in [ViewMode::Split, ViewMode::Unified] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
let mut reference = Editor::open(None, vec![]).unwrap();
reference.set_diff_display_options(mode, SplitAxis::Vertical);
install_readonly_diff(&mut reference, syntax_diff_file("exact.txt", "old", "new"));
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut reference);
let mut widths = views.panels.diff.as_ref().unwrap().text_widths;
if mode == ViewMode::Unified {
let width = widths[0].max(widths[1]);
widths.fill(width);
}
let mut file = syntax_diff_file(
"exact.txt",
&"o".repeat(usize::from(widths[0])),
&"n".repeat(usize::from(widths[1])),
);
file.before_eof = true;
file.after_eof = true;
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, SplitAxis::Vertical);
install_readonly_diff(&mut editor, file);
editor.set_whitespace_characters(BTreeMap::from([('\n', '↵')]));
editor.execute("word_wrap.toggle");
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let buffer = terminal.backend().buffer();
let newlines: Vec<_> = buffer
.content
.iter()
.enumerate()
.filter_map(|(index, cell)| (cell.symbol() == "↵").then_some(index))
.collect();
assert_eq!(newlines.len(), 2, "{mode:?}: {}", screen_text(&terminal));
for index in newlines {
let x = (index % 80) as u16;
let y = (index / 80) as u16;
let symbol = buffer[(x, y - 1)].symbol();
assert!(["o", "n"].contains(&symbol));
let width = widths[usize::from(symbol == "n")];
for offset in 0..width {
assert_eq!(buffer[(x + offset, y - 1)].symbol(), symbol);
}
assert!(buffer.content[index].modifier.contains(Modifier::DIM));
}
}
}
#[test]
fn literal_newlines_stay_unmarked_in_buffers_and_each_diff_source() {
let directory = Directory::new();
let closed = "text = \"\"\"a b\nc d\"\"\"";
let second = "text = \"\"\"e f\ng h\"\"\"";
for source in [closed, second] {
let mut editor = Editor::open(
Some(directory.file("literal.py", &format!("{source}\n"))),
vec![],
)
.unwrap();
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')]));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
terminal
.backend()
.buffer()
.content
.iter()
.filter(|cell| cell.symbol() == "↵")
.count(),
1,
);
assert!(screen_text(&terminal).contains(if source == closed {
"c d\"\"\"↵"
} else {
"g h\"\"\"↵"
}));
}
for mode in [ViewMode::Split, ViewMode::Unified] {
let mut file = syntax_diff_file("literal.py", closed, second);
file.before_eof = true;
file.after_eof = true;
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, SplitAxis::Vertical);
install_readonly_diff(&mut editor, file);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')]));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal
.backend()
.buffer()
.content
.iter()
.filter(|cell| cell.symbol() == "↵")
.count(),
2,
"{mode:?}: {}",
screen_text(&terminal),
);
for expected in ["a b", "c d\"\"\"↵", "e f", "g h\"\"\"↵"] {
assert!(screen_text(&terminal).contains(expected));
}
}
}
#[test]
fn whitespace_markers_preserve_multiline_raw_and_injected_strings() {
let directory = Directory::new();
@@ -4995,34 +5529,68 @@ mod tests {
}
let characters =
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]);
for (source, left, width, expected) in [
("let text = \"α β\"; ", 12, 3, "α β"),
for (source, left, width, cells) in [
(
"let text = \"α β\"; ",
12,
3,
vec![(0, "α"), (1, " "), (2, "β")],
),
(
"let s = \"\t \u{3000}\u{a0} \"; ",
0,
80,
"let·s·=·\" \u{3000}\u{a0} \";·",
20,
vec![
(3, "·"),
(5, "·"),
(7, "·"),
(9, " "),
(10, " "),
(11, " "),
(12, " "),
(13, "\u{3000}"),
(15, "\u{a0}"),
(16, " "),
(19, "·"),
],
),
(
"let s = \"\t \u{3000}\u{a0} \"; ",
10,
7,
" \u{3000}\u{a0} ",
vec![
(0, " "),
(1, " "),
(2, " "),
(3, "\u{3000}"),
(5, "\u{a0}"),
(6, " "),
],
),
] {
let mut syntax = SyntaxCache::new();
syntax.update_text(source, std::path::Path::new("strings.rs"));
assert_eq!(
visible_text(
source,
left,
width,
Some(&characters),
syntax.string_spans(0),
false
),
expected,
let buffer = rendered_whitespace_line(
source,
left,
width,
&characters,
WhitespaceScope::All,
syntax.string_spans(0),
false,
false,
);
for (x, symbol) in cells {
assert_eq!(
buffer[(x, 0)].symbol(),
symbol,
"{source:?}, left={left}, x={x}"
);
assert_eq!(
buffer[(x, 0)].modifier.contains(Modifier::DIM),
symbol == "·"
);
}
}
}
@@ -5046,6 +5614,21 @@ mod tests {
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("let·text·=·\"a b\";·"), "{rendered}");
let area = views.document.geometry.unwrap().1;
let area = Rect::new(
area.right() - views.document.text_width as u16,
area.y,
views.document.text_width as u16,
area.height,
);
for column in 11..16 {
let cell = &terminal.backend().buffer()[(area.x + column, area.y)];
assert_eq!(cell.fg, Color::Green);
assert!(!cell.modifier.contains(Modifier::DIM));
}
let marker = &terminal.backend().buffer()[(area.x + 3, area.y)];
assert_eq!(marker.fg, Color::Reset);
assert!(marker.modifier.contains(Modifier::DIM));
editor
.apply(vec![crate::editor::Effect::Replace(
"let text = a + b; ".into(),
@@ -5083,6 +5666,16 @@ mod tests {
for expected in ["let·text·=·\"a b\";·", "�let·text·=·\"c d\";·"] {
assert!(rendered.contains(expected), "{mode:?}: {rendered}");
}
for (index, cell) in terminal.backend().buffer().content.iter().enumerate() {
if cell.symbol() == "\"" {
let literal_space = &terminal.backend().buffer().content[index + 2];
// Opening quotes are followed by a letter and its literal space.
if literal_space.symbol() == " " {
assert_eq!(literal_space.fg, Color::Green);
assert!(!literal_space.modifier.contains(Modifier::DIM));
}
}
}
}
}
@@ -5091,27 +5684,39 @@ mod tests {
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), &[], false),
"a·→ b⍽_ ·c"
);
assert_eq!(
visible_text(source, 3, 6, Some(&characters), &[], false),
" b⍽_ ·"
);
assert_eq!(
visible_text(source, 7, 2, Some(&characters), &[], false),
" ·"
);
assert_eq!(
visible_text(source, 6, 1, Some(&characters), &[], false),
"_"
);
assert_eq!(
visible_text(" \u{301}", 0, 4, Some(&characters), &[], false),
" \u{301}"
);
assert_eq!(visible_text("", 0, 4, Some(&characters), &[], false), "");
for (source, left, width, expected) in [
(
source,
0,
10,
vec!["a", "·", "→", " ", "b", "⍽", "_", " ", "·", "c"],
),
(source, 3, 6, vec![" ", "b", "⍽", "_", " ", "·"]),
(source, 7, 2, vec![" ", "·"]),
(source, 6, 1, vec!["_"]),
(" \u{301}", 0, 1, vec![" \u{301}"]),
("", 0, 1, vec![" "]),
] {
let buffer = rendered_whitespace_line(
source,
left,
width,
&characters,
WhitespaceScope::All,
&[],
false,
false,
);
for (x, symbol) in expected.into_iter().enumerate() {
let cell = &buffer[(x as u16, 0)];
assert_eq!(cell.symbol(), symbol, "{source:?}, left={left}, x={x}");
let marker = ["·", "→", "⍽", "_"].contains(&symbol);
assert_eq!(cell.modifier.contains(Modifier::DIM), marker);
assert!(cell.modifier.contains(Modifier::UNDERLINED));
assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green });
assert_eq!(cell.bg, Color::Yellow);
}
}
let directory = Directory::new();
let source = "a b\t \u{3000}\n \t\n\nabcdefg ";
@@ -5122,6 +5727,21 @@ mod tests {
);
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
session.execute("word_wrap.toggle");
let token = semantic_token(
0,
0,
6,
SemanticTokenKind::Type,
SemanticTokenModifier::Deprecated as u16,
);
let editor = session.editor_mut();
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([token])),
)
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap();
let mut views = BufferViewports::default();
terminal
@@ -5139,8 +5759,18 @@ mod tests {
(2, 4, "·"),
] {
assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})");
assert!(buffer[(x, y)].modifier.contains(Modifier::DIM));
if y == 0 {
assert_eq!(buffer[(x, y)].fg, Color::Reset);
assert!(buffer[(x, y)].modifier.contains(Modifier::CROSSED_OUT));
}
}
assert_eq!(buffer[(8, 0)].symbol(), " ");
assert!(!buffer[(8, 0)].modifier.contains(Modifier::DIM));
assert!(!buffer[(4, 0)].modifier.contains(Modifier::DIM));
assert_eq!(buffer[(4, 0)].fg, semantic_style(&token).fg.unwrap());
assert!(buffer[(4, 0)].modifier.contains(Modifier::CROSSED_OUT));
assert!(!buffer[(2, 2)].modifier.contains(Modifier::DIM));
assert_eq!(buffer[(7, 0)].bg, Color::Red);
assert_eq!(buffer[(2, 2)].symbol(), " ");
session.handle(Input::End);
@@ -5151,6 +5781,11 @@ mod tests {
.unwrap();
assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "·");
assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue);
assert!(
terminal.backend().buffer()[(6, 0)]
.modifier
.contains(Modifier::DIM)
);
assert_eq!(session.editor().lines().join("\n"), source);
assert!(!session.editor().dirty());
assert_eq!(fs::read_to_string(path).unwrap(), source);
@@ -5465,13 +6100,13 @@ mod tests {
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
file.old_path = Some("old.py".into());
let mut syntax = DiffSyntaxCache::default();
syntax.update(1, 0, &file, None, None);
syntax.update(1, 0, &file, &file.after, None, None);
assert_eq!(syntax.before.language(), Some("Python"));
assert_eq!(syntax.after.language(), Some("Rust"));
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
file.path = "unknown.extension".into();
syntax.update(1, 0, &file, None, None);
syntax.update(1, 0, &file, &file.after, None, None);
assert_eq!(syntax.after.language(), None);
assert!(syntax.after.line(0).is_empty());