feat: add configurable whitespace markers

This commit is contained in:
2026-10-13 22:53:40 +02:00
parent e2f33519d1
commit 2357b5be46
8 changed files with 210 additions and 7 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
- [x] add option to highlight trailing whitespace (off by default) - [x] add option to highlight trailing whitespace (off by default)
- [ ] add option to put characters at certain whitespace characters (off by default) - [x] add option to put characters at certain whitespace characters (off by default)
- whitespace kind -> character map should be configurable. - whitespace kind -> character map should be configurable.
- [ ] github/gitlab/gitea integration (probably requries authentication with github) - [ ] github/gitlab/gitea integration (probably requries authentication with github)
- github pr picker (floating window) - github pr picker (floating window)
+5
View File
@@ -17,6 +17,7 @@ Vamp.configure do |config|
config.git_gutter = "bar" # or "symbols", "line_numbers", "none" config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\u00a0" => "⍽" }
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
@@ -55,6 +56,10 @@ 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.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.
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.
`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. `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.
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration. `config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
+1
View File
@@ -7,6 +7,7 @@ Vamp.configure do |config|
config.git_blame = true # false disables current-line inline blame config.git_blame = true # false disables current-line inline blame
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\u00a0" => "⍽" }
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
+10
View File
@@ -1764,6 +1764,7 @@ pub struct Editor {
labels: BTreeMap<String, String>, labels: BTreeMap<String, String>,
scroll_distance: Option<usize>, scroll_distance: Option<usize>,
highlight_trailing_whitespace: bool, highlight_trailing_whitespace: bool,
whitespace_characters: BTreeMap<char, char>,
battery_charge: Option<u8>, battery_charge: Option<u8>,
battery_label: String, battery_label: String,
git_blame: bool, git_blame: bool,
@@ -1867,6 +1868,7 @@ impl Editor {
labels: BTreeMap::new(), labels: BTreeMap::new(),
scroll_distance: Some(0), scroll_distance: Some(0),
highlight_trailing_whitespace: false, highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
battery_charge: None, battery_charge: None,
battery_label: String::new(), battery_label: String::new(),
git_blame: true, git_blame: true,
@@ -2621,6 +2623,14 @@ impl Editor {
self.highlight_trailing_whitespace = enabled; self.highlight_trailing_whitespace = enabled;
} }
pub(crate) fn whitespace_characters(&self) -> &BTreeMap<char, char> {
&self.whitespace_characters
}
pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap<char, char>) {
self.whitespace_characters = characters;
}
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) { pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines); self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff { if let Some(diff) = &mut self.diff {
+73 -3
View File
@@ -46,6 +46,7 @@ pub struct Config {
pub labels: BTreeMap<String, String>, pub labels: BTreeMap<String, String>,
pub scroll_distance: Option<usize>, pub scroll_distance: Option<usize>,
pub highlight_trailing_whitespace: bool, pub highlight_trailing_whitespace: bool,
pub whitespace_characters: BTreeMap<char, char>,
pub diff_context_lines: usize, pub diff_context_lines: usize,
pub diff_expand_lines: usize, pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode, pub diff_view_mode: crate::diff::ViewMode,
@@ -93,6 +94,7 @@ impl Default for Config {
labels: BTreeMap::new(), labels: BTreeMap::new(),
scroll_distance: Some(0), scroll_distance: Some(0),
highlight_trailing_whitespace: false, highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
diff_context_lines: 10, diff_context_lines: 10,
diff_expand_lines: 15, diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split, diff_view_mode: crate::diff::ViewMode::Split,
@@ -127,6 +129,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
labels, labels,
scroll_distance, scroll_distance,
highlight_trailing_whitespace, highlight_trailing_whitespace,
whitespace_characters,
diff_context_lines, diff_context_lines,
diff_expand_lines, diff_expand_lines,
diff_view_mode, diff_view_mode,
@@ -162,6 +165,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.labels = labels; config.labels = labels;
config.scroll_distance = scroll_distance; config.scroll_distance = scroll_distance;
config.highlight_trailing_whitespace = highlight_trailing_whitespace; config.highlight_trailing_whitespace = highlight_trailing_whitespace;
config.whitespace_characters = whitespace_characters;
config.diff_context_lines = diff_context_lines; config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines; config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode; config.diff_view_mode = diff_view_mode;
@@ -496,6 +500,8 @@ struct ConfigFrame {
#[serde(deserialize_with = "scroll_distance")] #[serde(deserialize_with = "scroll_distance")]
scroll_distance: Option<usize>, scroll_distance: Option<usize>,
highlight_trailing_whitespace: bool, highlight_trailing_whitespace: bool,
#[serde(deserialize_with = "whitespace_characters")]
whitespace_characters: BTreeMap<char, char>,
#[serde(deserialize_with = "diff_context_lines")] #[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize, diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")] #[serde(deserialize_with = "diff_expand_lines")]
@@ -505,6 +511,30 @@ struct ConfigFrame {
diff_follow_working_copy: bool, diff_follow_working_copy: bool,
} }
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> {
use unicode_width::UnicodeWidthChar;
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
for (&whitespace, &marker) in &characters {
if !whitespace.is_whitespace()
|| (whitespace != '\t'
&& (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",
));
}
if marker.is_control() || marker.width() != Some(1) {
return Err(serde::de::Error::custom(
"whitespace_characters values must be printable single-cell characters",
));
}
}
Ok(characters)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)] #[serde(tag = "type", deny_unknown_fields)]
enum Response { enum Response {
@@ -1239,9 +1269,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ 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,"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":{},"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,"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,"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}"#,
] { ] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
@@ -1319,6 +1349,7 @@ end
"git_gutter": "bar", "git_gutter": "bar",
"scroll_distance": 0, "scroll_distance": 0,
"highlight_trailing_whitespace": false, "highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1353,6 +1384,42 @@ end
} }
} }
#[test]
fn whitespace_characters_validate_real_ruby_configuration() {
let fixture = Fixture::new();
for value in [
"nil",
"false",
"[]",
r#"{ space: "·" }"#,
r#"{ " " => 1 }"#,
r#"{ "" => "." }"#,
r#"{ " " => "." }"#,
r#"{ " " => ".." }"#,
r#"{ "x" => "." }"#,
r#"{ "\n" => "." }"#,
r#"{ "\r" => "." }"#,
r#"{ "\u200b" => "." }"#,
r#"{ " " => "\t" }"#,
r#"{ " " => "\u0301" }"#,
r#"{ " " => "界" }"#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
);
assert_eq!(
load_config(&path).unwrap().whitespace_characters,
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]),
);
}
#[test] #[test]
fn working_diff_tracking_rejects_non_boolean_configuration() { fn working_diff_tracking_rejects_non_boolean_configuration() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -1587,6 +1654,7 @@ end
"top_bar": {}, "bottom_bar": {}, "labels": {}, "top_bar": {}, "bottom_bar": {}, "labels": {},
"scroll_distance": 0, "scroll_distance": 0,
"highlight_trailing_whitespace": false, "highlight_trailing_whitespace": false,
"whitespace_characters": {},
"popup": {"command": {}, "search": {}}, "popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1636,6 +1704,7 @@ end
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"highlight_trailing_whitespace": false, "highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "labels": {}, "top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}}, "popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15, "diff_context_lines": 10, "diff_expand_lines": 15,
@@ -2065,6 +2134,7 @@ end"#,
"git_gutter": "bar", "git_gutter": "bar",
"scroll_distance": 0, "scroll_distance": 0,
"highlight_trailing_whitespace": false, "highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "labels": {}, "top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}}, "popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+15
View File
@@ -143,6 +143,7 @@ module Vamp
@labels = {} @labels = {}
@scroll_distance = 0 @scroll_distance = 0
@highlight_trailing_whitespace = false @highlight_trailing_whitespace = false
@whitespace_characters = {}
@diff_context_lines = 10 @diff_context_lines = 10
@diff_expand_lines = 15 @diff_expand_lines = 15
@diff_view_mode = "split" @diff_view_mode = "split"
@@ -230,6 +231,19 @@ module Vamp
@highlight_trailing_whitespace = enabled @highlight_trailing_whitespace = enabled
end end
# @param characters [Hash{String => String}] whitespace character to single-cell marker
# @return [Hash{String => String}]
def whitespace_characters=(characters)
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
characters.each do |whitespace, marker|
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
end
end
@whitespace_characters = characters.dup
end
# @param lines [Integer] nonnegative context lines around each diff change # @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer] # @return [Integer]
def diff_context_lines=(lines) def diff_context_lines=(lines)
@@ -303,6 +317,7 @@ module Vamp
"git_gutter" => @git_gutter, "git_gutter" => @git_gutter,
"scroll_distance" => @scroll_distance, "scroll_distance" => @scroll_distance,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace, "highlight_trailing_whitespace" => @highlight_trailing_whitespace,
"whitespace_characters" => @whitespace_characters,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels, "top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search }, "popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
+1
View File
@@ -258,6 +258,7 @@ impl EditorSession {
editor.set_gutter_style(config.git_gutter); editor.set_gutter_style(config.git_gutter);
editor.set_scroll_distance(config.scroll_distance); editor.set_scroll_distance(config.scroll_distance);
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace); editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
editor.set_whitespace_characters(config.whitespace_characters);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); 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); editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) = if let Err(error) =
+104 -3
View File
@@ -995,6 +995,15 @@ fn display_column(line: &str, grapheme_column: usize) -> usize {
} }
fn visible_line(line: &str, left: usize, width: usize) -> String { fn visible_line(line: &str, left: usize, width: usize) -> String {
visible_text(line, left, width, None)
}
fn visible_text(
line: &str,
left: usize,
width: usize,
whitespace: Option<&BTreeMap<char, char>>,
) -> String {
let mut visible = String::new(); let mut visible = String::new();
let mut column = 0; let mut column = 0;
let right = left.saturating_add(width); let right = left.saturating_add(width);
@@ -1009,6 +1018,19 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
break; break;
} }
if end > left || (cells == 0 && column >= left) { if end > left || (cells == 0 && column >= left) {
let marker = whitespace.and_then(|characters| {
let ch = grapheme.chars().next()?;
(grapheme.len() == ch.len_utf8())
.then(|| characters.get(&ch))
.flatten()
});
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 { if grapheme == "\t" || column < left || end > right {
for _ in column.max(left)..end.min(right) { for _ in column.max(left)..end.min(right) {
visible.push(' '); visible.push(' ');
@@ -1834,10 +1856,11 @@ fn draw_document(
.visible .visible
.iter() .iter()
.map(|&(row, _, segment)| { .map(|&(row, _, segment)| {
Line::from(visible_line( Line::from(visible_text(
&editor.lines()[row], &editor.lines()[row],
segment.start, segment.start,
segment.end.saturating_sub(segment.start).min(width), segment.end.saturating_sub(segment.start).min(width),
Some(editor.whitespace_characters()),
)) ))
}) })
.collect(); .collect();
@@ -2938,6 +2961,7 @@ fn draw_diff(
wrapped, wrapped,
panels.diff_colors, panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace), editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
); );
text_widths[0] = text_area.width; text_widths[0] = text_area.width;
if let Some(editor) = if let Some(editor) =
@@ -2975,6 +2999,7 @@ fn draw_diff(
wrapped, wrapped,
panels.diff_colors, panels.diff_colors,
editor.is_some_and(Editor::highlight_trailing_whitespace), editor.is_some_and(Editor::highlight_trailing_whitespace),
editor.map(Editor::whitespace_characters),
); );
text_widths[1] = text_area.width; text_widths[1] = text_area.width;
if let Some(editor) = if let Some(editor) =
@@ -3130,6 +3155,7 @@ fn draw_diff_pane(
wrapped: Option<&[WrappedDiffRow]>, wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors, colors: DiffColors,
highlight_trailing_whitespace: bool, highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>,
) -> Rect { ) -> Rect {
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return area; return area;
@@ -3237,8 +3263,13 @@ fn draw_diff_pane(
Cow::Borrowed(source_text.as_str()) Cow::Borrowed(source_text.as_str())
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(visible_line(&text, left, text_width.min(width))) Paragraph::new(visible_text(
.style(Style::default().fg(Color::Reset).bg(background)), &text,
left,
text_width.min(width),
whitespace_characters,
))
.style(Style::default().fg(Color::Reset).bg(background)),
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), 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) { if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) {
@@ -4883,6 +4914,73 @@ mod tests {
} }
} }
#[test]
fn whitespace_characters_preserve_source_geometry_and_overlay_styles() {
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)), "a·→ b⍽_ ·c");
assert_eq!(visible_text(source, 3, 6, Some(&characters)), " b⍽_ ·");
assert_eq!(visible_text(source, 7, 2, Some(&characters)), " ·");
assert_eq!(visible_text(source, 6, 1, Some(&characters)), "_");
assert_eq!(
visible_text(" \u{301}", 0, 4, Some(&characters)),
" \u{301}"
);
assert_eq!(visible_text("", 0, 4, Some(&characters)), "");
let directory = Directory::new();
let source = "a b\t \u{3000}\n \t\n\nabcdefg ";
let path = directory.file("markers.txt", source);
let config = directory.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u3000" => "_" }; c.highlight_trailing_whitespace = true; c.git_gutter = "none" }"#,
);
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
session.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
let buffer = terminal.backend().buffer();
for (x, y, marker) in [
(3, 0, "·"),
(5, 0, "→"),
(6, 0, "·"),
(7, 0, "_"),
(2, 1, "·"),
(3, 1, "→"),
(9, 3, "·"),
(2, 4, "·"),
] {
assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})");
}
assert_eq!(buffer[(8, 0)].symbol(), " ");
assert_eq!(buffer[(7, 0)].bg, Color::Red);
assert_eq!(buffer[(2, 2)].symbol(), " ");
session.handle(Input::End);
session.handle(Input::Char('v'));
session.handle(Input::Left);
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "·");
assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue);
assert_eq!(session.editor().lines().join("\n"), source);
assert!(!session.editor().dirty());
assert_eq!(fs::read_to_string(path).unwrap(), source);
session
.editor_mut()
.set_whitespace_characters(BTreeMap::new());
terminal
.draw(|frame| views.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), " ");
assert_eq!(terminal.backend().buffer()[(7, 0)].symbol(), "\u{3000}");
}
#[test] #[test]
fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() {
let directory = Directory::new(); let directory = Directory::new();
@@ -4945,6 +5043,7 @@ mod tests {
syntax_diff_file("whitespace.txt", "old \t", "new \t"), syntax_diff_file("whitespace.txt", "old \t", "new \t"),
); );
editor.set_highlight_trailing_whitespace(true); editor.set_highlight_trailing_whitespace(true);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')]));
if wrapped { if wrapped {
editor.execute("word_wrap.toggle"); editor.execute("word_wrap.toggle");
} }
@@ -4979,6 +5078,8 @@ mod tests {
); );
} }
assert_ne!(buffer.content[start + 8].bg, Color::Red); assert_ne!(buffer.content[start + 8].bg, Color::Red);
assert_eq!(buffer.content[start + 3].symbol(), "·");
assert_eq!(buffer.content[start + 4].symbol(), "→");
} }
} }
} }