feat: dim whitespace markers and configure scope and newlines
This commit is contained in:
+726
-91
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user