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
+3
View File
@@ -1,6 +1,9 @@
- [x] add option to highlight trailing whitespace (off by default)
- [x] add option to put characters at certain whitespace characters (off by default)
- whitespace kind -> character map should be configurable.
- [ ] diff view - design question:
- is it possible to angle the hatchings for empty lines more?
- if a character like the current one exists with a 45degree angle instead (and a top and bottom), we could use that across two columns. that would give us a perfectly 45 degree hatching.
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
- github pr picker (floating window)
- three tabs: open, closed, all
+8 -3
View File
@@ -17,7 +17,8 @@ Vamp.configure do |config|
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\u00a0" => "⍽" }
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
@@ -56,11 +57,15 @@ The setting applies to ordinary and historical buffers, split/unified diff panes
`config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration.
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽" }` for spaces, tabs, and nonbreaking spaces. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, or tabs, and values must be printable one-cell characters. Line breaks and other control characters, zero-width markers, and wide markers reject the configuration.
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact.
`config.whitespace_scope` defaults to `"all"`. Use `"leading"` to mark only the initial whitespace prefix of each source line; a whitespace-only line is entirely leading, but wrapping and horizontal scrolling do not create new indentation. Only the strings `"all"` and `"leading"` are accepted; other values reject the configuration.
A `"\n"` map entry marks actual line breaks independently of `whitespace_scope`, including blank lines and a file's final newline when present. Soft wraps, missing final newlines, and the virtual empty EOF row do not gain markers. The glyph occupies the existing end-of-line caret cell on the final wrapped segment; a full-width line uses the existing extra caret segment. In unwrapped text, an offscreen end-of-line marker is clipped like other source cells. Newline markers inside syntax-recognized strings are suppressed along with horizontal whitespace markers.
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; empty lines and padding do not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
+2 -1
View File
@@ -7,7 +7,8 @@ Vamp.configure do |config|
config.git_blame = true # false disables current-line inline blame
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\u00a0" => "⍽" }
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
+9
View File
@@ -1140,6 +1140,15 @@ impl DiffState {
.is_some_and(|file| file.loaded)
}
/// Source rows without the editor's virtual terminal row, preserving real empty lines.
pub(crate) fn after_source_lines<'a>(&self, file: &'a ChangedFile) -> &'a [String] {
if self.live_files.contains(&self.file) {
live_content(&file.after, file.after_eof)
} else {
&file.after
}
}
fn prepare_selected_file(&mut self) {
if self.refresh_pending() {
self.align_file();
+18
View File
@@ -25,6 +25,14 @@ use unicode_width::UnicodeWidthStr;
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WhitespaceScope {
#[default]
All,
Leading,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Input {
Char(char),
@@ -1765,6 +1773,7 @@ pub struct Editor {
scroll_distance: Option<usize>,
highlight_trailing_whitespace: bool,
whitespace_characters: BTreeMap<char, char>,
whitespace_scope: WhitespaceScope,
battery_charge: Option<u8>,
battery_label: String,
git_blame: bool,
@@ -1869,6 +1878,7 @@ impl Editor {
scroll_distance: Some(0),
highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
whitespace_scope: WhitespaceScope::All,
battery_charge: None,
battery_label: String::new(),
git_blame: true,
@@ -2631,6 +2641,14 @@ impl Editor {
self.whitespace_characters = characters;
}
pub(crate) fn whitespace_scope(&self) -> WhitespaceScope {
self.whitespace_scope
}
pub(crate) fn set_whitespace_scope(&mut self, scope: WhitespaceScope) {
self.whitespace_scope = scope;
}
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff {
+110 -8
View File
@@ -47,6 +47,7 @@ pub struct Config {
pub scroll_distance: Option<usize>,
pub highlight_trailing_whitespace: bool,
pub whitespace_characters: BTreeMap<char, char>,
pub whitespace_scope: crate::editor::WhitespaceScope,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode,
@@ -95,6 +96,7 @@ impl Default for Config {
scroll_distance: Some(0),
highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
whitespace_scope: crate::editor::WhitespaceScope::All,
diff_context_lines: 10,
diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split,
@@ -130,6 +132,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
scroll_distance,
highlight_trailing_whitespace,
whitespace_characters,
whitespace_scope,
diff_context_lines,
diff_expand_lines,
diff_view_mode,
@@ -166,6 +169,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.scroll_distance = scroll_distance;
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
config.whitespace_characters = whitespace_characters;
config.whitespace_scope = whitespace_scope;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode;
@@ -502,6 +506,7 @@ struct ConfigFrame {
highlight_trailing_whitespace: bool,
#[serde(deserialize_with = "whitespace_characters")]
whitespace_characters: BTreeMap<char, char>,
whitespace_scope: crate::editor::WhitespaceScope,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
@@ -519,11 +524,11 @@ fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
for (&whitespace, &marker) in &characters {
if !whitespace.is_whitespace()
|| (whitespace != '\t'
|| (!matches!(whitespace, '\t' | '\n')
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
{
return Err(serde::de::Error::custom(
"whitespace_characters keys must be visible-width whitespace characters or tabs",
"whitespace_characters keys must be visible-width whitespace characters, tabs, or newlines",
));
}
if marker.is_control() || marker.width() != Some(1) {
@@ -1269,9 +1274,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1350,6 +1355,7 @@ end
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1397,10 +1403,13 @@ end
r#"{ " " => "." }"#,
r#"{ " " => ".." }"#,
r#"{ "x" => "." }"#,
r#"{ "\n" => "." }"#,
r#"{ "\u000b" => "." }"#,
r#"{ "\r" => "." }"#,
r#"{ "\u000c" => "." }"#,
r#"{ "\u0085" => "." }"#,
r#"{ "\u200b" => "." }"#,
r#"{ " " => "\t" }"#,
r#"{ " " => "\n" }"#,
r#"{ " " => "\u0301" }"#,
r#"{ " " => "界" }"#,
] {
@@ -1412,14 +1421,104 @@ end
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
);
assert_eq!(
load_config(&path).unwrap().whitespace_characters,
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]),
BTreeMap::from([
(' ', '·'),
('\t', '→'),
('\n', '↵'),
('\u{a0}', '⍽'),
('\u{3000}', '_'),
]),
);
}
#[test]
fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() {
use crate::editor::WhitespaceScope;
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure {}");
assert_eq!(
load_config(&path).unwrap().whitespace_scope,
WhitespaceScope::All
);
for (value, expected) in [
("all", WhitespaceScope::All),
("leading", WhitespaceScope::Leading),
] {
let path = fixture.file(
"config.rb",
&format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#),
);
assert_eq!(load_config(&path).unwrap().whitespace_scope, expected);
}
for value in [
"nil",
"false",
"0",
"[]",
"{}",
":all",
r#""""#,
r#""unknown""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
let path = fixture.file(
"config.rb",
&format!(
r#"Vamp.configure do |c|
c.whitespace_scope = "leading"
begin
c.whitespace_scope = {value}
raise "invalid scope accepted"
rescue TypeError, ArgumentError
end
end"#,
),
);
assert_eq!(
load_config(&path).unwrap().whitespace_scope,
WhitespaceScope::Leading
);
}
let mut frame = serde_json::json!({
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
"whitespace_characters": {}, "whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true
});
for value in ["all", "leading"] {
frame["whitespace_scope"] = serde_json::json!(value);
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
}
for value in [
serde_json::Value::Null,
serde_json::json!(false),
serde_json::json!(0),
serde_json::json!([]),
serde_json::json!({}),
serde_json::json!(""),
serde_json::json!("unknown"),
] {
frame["whitespace_scope"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove("whitespace_scope");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn working_diff_tracking_rejects_non_boolean_configuration() {
let fixture = Fixture::new();
@@ -1655,6 +1754,7 @@ end
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1705,6 +1805,7 @@ end
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15,
@@ -2135,6 +2236,7 @@ end"#,
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+11
View File
@@ -144,6 +144,7 @@ module Vamp
@scroll_distance = 0
@highlight_trailing_whitespace = false
@whitespace_characters = {}
@whitespace_scope = "all"
@diff_context_lines = 10
@diff_expand_lines = 15
@diff_view_mode = "split"
@@ -244,6 +245,15 @@ module Vamp
@whitespace_characters = characters.dup
end
# @param scope [String] "all" or "leading"
# @return [String]
def whitespace_scope=(scope)
scope = Vamp.string!(scope, "whitespace_scope")
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
@whitespace_scope = scope
end
# @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer]
def diff_context_lines=(lines)
@@ -318,6 +328,7 @@ module Vamp
"scroll_distance" => @scroll_distance,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
"whitespace_characters" => @whitespace_characters,
"whitespace_scope" => @whitespace_scope,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
+1
View File
@@ -259,6 +259,7 @@ impl EditorSession {
editor.set_scroll_distance(config.scroll_distance);
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
editor.set_whitespace_characters(config.whitespace_characters);
editor.set_whitespace_scope(config.whitespace_scope);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
+75 -8
View File
@@ -24,7 +24,7 @@ pub enum SyntaxKind {
Markup,
}
/// A syntax foreground range in the original line's bytes, excluding its newline.
/// A syntax range in original line bytes; string coverage may include its newline.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SyntaxSpan {
pub start_byte: usize,
@@ -37,6 +37,7 @@ pub struct SyntaxSpan {
pub struct SyntaxCache {
revision: Option<u64>,
path: Option<PathBuf>,
final_newline: bool,
syntax: Option<&'static LanguageSpec>,
source: String,
next_source: String,
@@ -89,6 +90,7 @@ impl SyntaxCache {
path,
revision,
std::slice::from_ref(&(0..lines.len())),
false,
);
}
@@ -101,7 +103,26 @@ impl SyntaxCache {
revision: u64,
visible: &[std::ops::Range<usize>],
) {
self.update_rows(lines, path, revision, visible);
self.update_rows(lines, path, revision, visible, false);
}
/// Diff sources store a final line break separately instead of a trailing empty row.
pub(crate) fn update_diff(
&mut self,
lines: &[String],
path: Option<&Path>,
revision: u64,
final_newline: bool,
visible: Option<&[std::ops::Range<usize>]>,
) {
let all_rows = 0..lines.len();
self.update_rows(
lines,
path,
revision,
visible.unwrap_or(std::slice::from_ref(&all_rows)),
final_newline && !lines.is_empty(),
);
}
fn update_rows(
@@ -110,9 +131,11 @@ impl SyntaxCache {
path: Option<&Path>,
revision: u64,
visible: &[std::ops::Range<usize>],
final_newline: bool,
) {
let path_changed = self.path.as_deref() != path;
let revision_changed = self.revision != Some(revision);
let revision_changed =
self.revision != Some(revision) || self.final_newline != final_newline;
let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str))
} else {
@@ -120,6 +143,7 @@ impl SyntaxCache {
};
let language_changed = !same_language(self.syntax, syntax);
self.revision = Some(revision);
self.final_newline = final_newline;
if path_changed {
self.path = path.map(Path::to_path_buf);
}
@@ -128,8 +152,9 @@ impl SyntaxCache {
let mut edit = None;
if revision_changed {
self.next_source.clear();
let length =
lines.iter().map(String::len).sum::<usize>() + lines.len().saturating_sub(1);
let length = lines.iter().map(String::len).sum::<usize>()
+ lines.len().saturating_sub(1)
+ usize::from(final_newline);
self.next_source.reserve(length);
for (row, line) in lines.iter().enumerate() {
if row > 0 {
@@ -137,6 +162,9 @@ impl SyntaxCache {
}
self.next_source.push_str(line);
}
if final_newline {
self.next_source.push('\n');
}
if self.source != self.next_source {
edit = Some(minimal_edit(&self.source, &self.next_source));
std::mem::swap(&mut self.source, &mut self.next_source);
@@ -147,6 +175,9 @@ impl SyntaxCache {
self.line_starts.push(start);
start += line.len() + 1;
}
if final_newline {
self.line_starts.push(self.source.len());
}
self.lines.resize_with(lines.len(), Vec::new);
self.string_spans.resize_with(lines.len(), Vec::new);
self.highlighted.resize(lines.len(), false);
@@ -185,6 +216,8 @@ impl SyntaxCache {
start_point: Point::new(start, 0),
end_point: if row < lines.len() {
Point::new(row, 0)
} else if final_newline {
Point::new(lines.len(), 0)
} else {
Point::new(
lines.len().saturating_sub(1),
@@ -228,6 +261,7 @@ impl SyntaxCache {
}
/// String coverage before foreground overrides, including embedded-language highlighting.
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
self.string_spans.get(row).map_or(&[], Vec::as_slice)
}
@@ -1135,10 +1169,10 @@ fn render_spans(
let low = previous.max(range.start_byte);
let high = byte.min(range.end_byte);
if let Some(kind) = kind {
append_global(source, starts, lines, low, high, kind);
append_global(source, starts, lines, low, high, kind, false);
}
if active_strings > 0 {
append_global(source, starts, strings, low, high, SyntaxKind::String);
append_global(source, starts, strings, low, high, SyntaxKind::String, true);
}
}
}
@@ -1166,6 +1200,7 @@ fn append_global(
mut start: usize,
end: usize,
kind: SyntaxKind,
include_newline: bool,
) {
if starts.is_empty() {
return;
@@ -1175,7 +1210,7 @@ fn append_global(
.saturating_sub(1);
while start < end && row < lines.len() {
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
let high = end.min(line_end);
let high = end.min(line_end + usize::from(include_newline && row + 1 < starts.len()));
if start < high {
let spans = &mut lines[row];
let low = start - starts[row];
@@ -1215,6 +1250,38 @@ mod tests {
}
}
#[test]
fn string_newlines_remain_literal_across_visible_row_caching_and_edits() {
let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n";
let input = lines(source);
let path = Path::new("strings.py");
let mut cache = SyntaxCache::new();
let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| {
cache
.string_spans(row)
.iter()
.any(|span| span.start_byte <= length && span.end_byte > length)
};
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1)));
assert!(includes_newline(&cache, 0, input[0].len()));
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4)));
assert!(includes_newline(&cache, 0, input[0].len()));
assert!(includes_newline(&cache, 1, input[1].len()));
assert!(!includes_newline(&cache, 2, input[2].len()));
assert!(!includes_newline(&cache, 3, input[3].len()));
for (row, line) in input.iter().enumerate() {
assert!(
cache
.line(row)
.iter()
.all(|span| span.end_byte <= line.len())
);
}
cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1);
assert!(!includes_newline(&cache, 0, 3));
assert!(!includes_newline(&cache, 1, 0));
}
#[test]
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
let cases = [
+720 -85
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,14 +3340,7 @@ 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,
))
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),
);
@@ -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(
let buffer = rendered_whitespace_line(
source,
left,
width,
Some(&characters),
&characters,
WhitespaceScope::All,
syntax.string_spans(0),
false
),
expected,
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"
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,
);
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 (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());