From e2f33519d129971120bd94b5d8f4550c07763400 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 22:42:30 +0200 Subject: [PATCH] feat: add opt-in trailing whitespace highlighting --- TODO.md | 2 +- docs/configuration.md | 3 + examples/config.rb | 1 + src/editor.rs | 10 ++++ src/ruby.rs | 31 ++++++++++- src/ruby_config.rb | 10 ++++ src/session.rs | 1 + src/tui.rs | 125 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 179 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 6a7470b..ef19c50 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] 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) - whitespace kind -> character map should be configurable. - [ ] github/gitlab/gitea integration (probably requries authentication with github) diff --git a/docs/configuration.md b/docs/configuration.md index 3bdc055..5609afd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,6 +16,7 @@ Vamp.configure do |config| config.git_blame = true # false disables current-line attribution 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.file_explorer_side = "right" config.file_explorer_view = "tree" config.file_picker_view = "tree" @@ -52,6 +53,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) 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.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. diff --git a/examples/config.rb b/examples/config.rb index c7f1281..331c6bd 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -6,6 +6,7 @@ Vamp.configure do |config| config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal 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.file_explorer_side = "right" config.file_explorer_view = "tree" config.file_picker_view = "tree" diff --git a/src/editor.rs b/src/editor.rs index a3bd9f2..f346aea 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1763,6 +1763,7 @@ pub struct Editor { popup: PopupConfig, labels: BTreeMap, scroll_distance: Option, + highlight_trailing_whitespace: bool, battery_charge: Option, battery_label: String, git_blame: bool, @@ -1865,6 +1866,7 @@ impl Editor { popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), + highlight_trailing_whitespace: false, battery_charge: None, battery_label: String::new(), git_blame: true, @@ -2611,6 +2613,14 @@ impl Editor { self.scroll_distance = distance; } + pub(crate) fn highlight_trailing_whitespace(&self) -> bool { + self.highlight_trailing_whitespace + } + + pub(crate) fn set_highlight_trailing_whitespace(&mut self, enabled: bool) { + self.highlight_trailing_whitespace = enabled; + } + 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 { diff --git a/src/ruby.rs b/src/ruby.rs index 5601f89..589d72d 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -45,6 +45,7 @@ pub struct Config { pub popup: PopupConfig, pub labels: BTreeMap, pub scroll_distance: Option, + pub highlight_trailing_whitespace: bool, pub diff_context_lines: usize, pub diff_expand_lines: usize, pub diff_view_mode: crate::diff::ViewMode, @@ -91,6 +92,7 @@ impl Default for Config { popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), + highlight_trailing_whitespace: false, diff_context_lines: 10, diff_expand_lines: 15, diff_view_mode: crate::diff::ViewMode::Split, @@ -124,6 +126,7 @@ pub fn load_config(path: &Path) -> Result { popup, labels, scroll_distance, + highlight_trailing_whitespace, diff_context_lines, diff_expand_lines, diff_view_mode, @@ -158,6 +161,7 @@ pub fn load_config(path: &Path) -> Result { config.popup = popup; config.labels = labels; config.scroll_distance = scroll_distance; + config.highlight_trailing_whitespace = highlight_trailing_whitespace; config.diff_context_lines = diff_context_lines; config.diff_expand_lines = diff_expand_lines; config.diff_view_mode = diff_view_mode; @@ -491,6 +495,7 @@ struct ConfigFrame { labels: BTreeMap, #[serde(deserialize_with = "scroll_distance")] scroll_distance: Option, + highlight_trailing_whitespace: bool, #[serde(deserialize_with = "diff_context_lines")] diff_context_lines: usize, #[serde(deserialize_with = "diff_expand_lines")] @@ -1234,9 +1239,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,"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,"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,"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,"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":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}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } @@ -1313,6 +1318,7 @@ end "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", @@ -1331,6 +1337,22 @@ end assert!(serde_json::from_value::(frame).is_err()); } + #[test] + fn trailing_whitespace_configuration_rejects_non_booleans() { + let fixture = Fixture::new(); + for value in ["nil", "0", "\"true\"", "[]", "{}"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"), + ); + assert!( + load_config(&path) + .unwrap_err() + .contains("highlight_trailing_whitespace must be true or false") + ); + } + } + #[test] fn working_diff_tracking_rejects_non_boolean_configuration() { let fixture = Fixture::new(); @@ -1564,6 +1586,7 @@ end "file_picker_view": "tree", "file_explorer_view": "tree", "top_bar": {}, "bottom_bar": {}, "labels": {}, "scroll_distance": 0, + "highlight_trailing_whitespace": false, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "diff_view_mode": "split", "diff_split_orientation": "vertical", @@ -1612,6 +1635,7 @@ end "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", + "highlight_trailing_whitespace": false, "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "diff_context_lines": 10, "diff_expand_lines": 15, @@ -2040,6 +2064,7 @@ end"#, "git_blame": true, "git_gutter": "bar", "scroll_distance": 0, + "highlight_trailing_whitespace": false, "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, diff --git a/src/ruby_config.rb b/src/ruby_config.rb index 05bd702..87cf57b 100644 --- a/src/ruby_config.rb +++ b/src/ruby_config.rb @@ -142,6 +142,7 @@ module Vamp @popup = PopupConfig.new @labels = {} @scroll_distance = 0 + @highlight_trailing_whitespace = false @diff_context_lines = 10 @diff_expand_lines = 15 @diff_view_mode = "split" @@ -221,6 +222,14 @@ module Vamp @scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0) end + # @param enabled [Boolean] + # @return [Boolean] + def highlight_trailing_whitespace=(enabled) + raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled) + + @highlight_trailing_whitespace = enabled + end + # @param lines [Integer] nonnegative context lines around each diff change # @return [Integer] def diff_context_lines=(lines) @@ -293,6 +302,7 @@ module Vamp "git_blame" => @git_blame, "git_gutter" => @git_gutter, "scroll_distance" => @scroll_distance, + "highlight_trailing_whitespace" => @highlight_trailing_whitespace, "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, diff --git a/src/session.rs b/src/session.rs index 32ec379..de5237f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -257,6 +257,7 @@ impl EditorSession { editor.set_git_blame(config.git_blame); editor.set_gutter_style(config.git_gutter); editor.set_scroll_distance(config.scroll_distance); + editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace); 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) = diff --git a/src/tui.rs b/src/tui.rs index 925a0ef..93a0dc9 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1865,6 +1865,9 @@ fn draw_document( area, segment.start, ); + if editor.highlight_trailing_whitespace() { + paint_trailing_whitespace(frame, line, area, segment.start); + } paint_editor_line( frame, editor, @@ -2212,6 +2215,21 @@ fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: us } } +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() { + return; + } + paint_cells( + frame, + area, + left, + display_column(prefix, usize::MAX), + display_column(line, usize::MAX), + Style::default().bg(Color::Red), + ); +} + fn paint_grapheme_range( frame: &mut Frame, area: Rect, @@ -2919,6 +2937,7 @@ fn draw_diff( ), wrapped, panels.diff_colors, + editor.is_some_and(Editor::highlight_trailing_whitespace), ); text_widths[0] = text_area.width; if let Some(editor) = @@ -2955,6 +2974,7 @@ fn draw_diff( ), wrapped, panels.diff_colors, + editor.is_some_and(Editor::highlight_trailing_whitespace), ); text_widths[1] = text_area.width; if let Some(editor) = @@ -3109,6 +3129,7 @@ fn draw_diff_pane( viewport: (usize, &mut usize, Option), wrapped: Option<&[WrappedDiffRow]>, colors: DiffColors, + highlight_trailing_whitespace: bool, ) -> Rect { if area.width == 0 || area.height == 0 { return area; @@ -3281,6 +3302,9 @@ fn draw_diff_pane( } } } + if highlight_trailing_whitespace { + paint_trailing_whitespace(frame, &text, text_area, left); + } } Rect::new( content.x + gutter as u16, @@ -4859,6 +4883,107 @@ mod tests { } } + #[test] + fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { + let directory = Directory::new(); + let source = "a b\t \u{3000}\n \t\n\nabcdefg "; + let path = directory.file("whitespace.txt", source); + let config = directory.file( + "config.rb", + "Vamp.configure { |c| c.highlight_trailing_whitespace = true; c.git_gutter = 'none' }", + ); + let mut session = EditorSession::open(Some(path), 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 (y, range) in [(0, 5..9), (1, 2..6), (3, 9..10), (4, 2..3)] { + for x in 2..10 { + // The backend skips the covered cell of a wide grapheme. + if (x, y) == (8, 0) { + continue; + } + assert_eq!( + buffer[(x, y)].bg == Color::Red, + range.contains(&x), + "({x}, {y})" + ); + } + } + assert_eq!(buffer[(2, 2)].bg, Color::Reset); + 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)].bg, Color::Blue); + assert_eq!(session.editor().lines().join("\n"), source); + assert!(!session.editor().dirty()); + + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("x \t".into())]) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_ne!(terminal.backend().buffer()[(7, 0)].bg, Color::Red); + } + + #[test] + fn trailing_whitespace_covers_both_diff_sides_and_unified_rows() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for wrapped in [false, true] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, crate::panes::SplitAxis::Vertical); + install_readonly_diff( + &mut editor, + syntax_diff_file("whitespace.txt", "old \t", "new \t"), + ); + editor.set_highlight_trailing_whitespace(true); + if wrapped { + editor.execute("word_wrap.toggle"); + } + 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 buffer = terminal.backend().buffer(); + for word in ["old", "new"] { + let start = buffer + .content + .iter() + .enumerate() + .find_map(|(index, cell)| { + (cell.symbol() == &word[..1] + && buffer + .content + .get(index + 1) + .is_some_and(|cell| cell.symbol() == &word[1..2]) + && buffer + .content + .get(index + 2) + .is_some_and(|cell| cell.symbol() == &word[2..3])) + .then_some(index) + }) + .unwrap(); + for offset in 3..8 { + assert_eq!( + buffer.content[start + offset].bg, + Color::Red, + "{mode:?} {wrapped} {word}" + ); + } + assert_ne!(buffer.content[start + 8].bg, Color::Red); + } + } + } + } + #[test] fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() { let directory = Directory::new();