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