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