From 2357b5be46662e897ddf0a75343c4eb8ab3ee7a1 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 22:53:40 +0200 Subject: [PATCH] feat: add configurable whitespace markers --- TODO.md | 2 +- docs/configuration.md | 5 ++ examples/config.rb | 1 + src/editor.rs | 10 ++++ src/ruby.rs | 76 ++++++++++++++++++++++++++++-- src/ruby_config.rb | 15 ++++++ src/session.rs | 1 + src/tui.rs | 107 ++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 210 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index ef19c50..448c58e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ - [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. - [ ] github/gitlab/gitea integration (probably requries authentication with github) - github pr picker (floating window) diff --git a/docs/configuration.md b/docs/configuration.md index 5609afd..8fc9e4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ 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.file_explorer_side = "right" config.file_explorer_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.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_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 331c6bd..358bcdd 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -7,6 +7,7 @@ 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.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 f346aea..c3be93b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1764,6 +1764,7 @@ pub struct Editor { labels: BTreeMap, scroll_distance: Option, highlight_trailing_whitespace: bool, + whitespace_characters: BTreeMap, battery_charge: Option, battery_label: String, git_blame: bool, @@ -1867,6 +1868,7 @@ impl Editor { labels: BTreeMap::new(), scroll_distance: Some(0), highlight_trailing_whitespace: false, + whitespace_characters: BTreeMap::new(), battery_charge: None, battery_label: String::new(), git_blame: true, @@ -2621,6 +2623,14 @@ impl Editor { self.highlight_trailing_whitespace = enabled; } + pub(crate) fn whitespace_characters(&self) -> &BTreeMap { + &self.whitespace_characters + } + + pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap) { + self.whitespace_characters = characters; + } + 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 589d72d..c40d9b1 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -46,6 +46,7 @@ pub struct Config { pub labels: BTreeMap, pub scroll_distance: Option, pub highlight_trailing_whitespace: bool, + pub whitespace_characters: BTreeMap, pub diff_context_lines: usize, pub diff_expand_lines: usize, pub diff_view_mode: crate::diff::ViewMode, @@ -93,6 +94,7 @@ impl Default for Config { labels: BTreeMap::new(), scroll_distance: Some(0), highlight_trailing_whitespace: false, + whitespace_characters: BTreeMap::new(), diff_context_lines: 10, diff_expand_lines: 15, diff_view_mode: crate::diff::ViewMode::Split, @@ -127,6 +129,7 @@ pub fn load_config(path: &Path) -> Result { labels, scroll_distance, highlight_trailing_whitespace, + whitespace_characters, diff_context_lines, diff_expand_lines, diff_view_mode, @@ -162,6 +165,7 @@ pub fn load_config(path: &Path) -> Result { config.labels = labels; config.scroll_distance = scroll_distance; config.highlight_trailing_whitespace = highlight_trailing_whitespace; + config.whitespace_characters = whitespace_characters; config.diff_context_lines = diff_context_lines; config.diff_expand_lines = diff_expand_lines; config.diff_view_mode = diff_view_mode; @@ -496,6 +500,8 @@ struct ConfigFrame { #[serde(deserialize_with = "scroll_distance")] scroll_distance: Option, highlight_trailing_whitespace: bool, + #[serde(deserialize_with = "whitespace_characters")] + whitespace_characters: BTreeMap, #[serde(deserialize_with = "diff_context_lines")] diff_context_lines: usize, #[serde(deserialize_with = "diff_expand_lines")] @@ -505,6 +511,30 @@ struct ConfigFrame { diff_follow_working_copy: bool, } +fn whitespace_characters<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + use unicode_width::UnicodeWidthChar; + + let characters = BTreeMap::::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)] #[serde(tag = "type", deny_unknown_fields)] enum Response { @@ -1239,9 +1269,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,"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}"#, + 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}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } @@ -1319,6 +1349,7 @@ end "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", @@ -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] fn working_diff_tracking_rejects_non_boolean_configuration() { let fixture = Fixture::new(); @@ -1587,6 +1654,7 @@ end "top_bar": {}, "bottom_bar": {}, "labels": {}, "scroll_distance": 0, "highlight_trailing_whitespace": false, + "whitespace_characters": {}, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "diff_view_mode": "split", "diff_split_orientation": "vertical", @@ -1636,6 +1704,7 @@ end "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "highlight_trailing_whitespace": false, + "whitespace_characters": {}, "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "diff_context_lines": 10, "diff_expand_lines": 15, @@ -2065,6 +2134,7 @@ end"#, "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, + "whitespace_characters": {}, "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 87cf57b..5cbf661 100644 --- a/src/ruby_config.rb +++ b/src/ruby_config.rb @@ -143,6 +143,7 @@ module Vamp @labels = {} @scroll_distance = 0 @highlight_trailing_whitespace = false + @whitespace_characters = {} @diff_context_lines = 10 @diff_expand_lines = 15 @diff_view_mode = "split" @@ -230,6 +231,19 @@ module Vamp @highlight_trailing_whitespace = enabled 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 # @return [Integer] def diff_context_lines=(lines) @@ -303,6 +317,7 @@ module Vamp "git_gutter" => @git_gutter, "scroll_distance" => @scroll_distance, "highlight_trailing_whitespace" => @highlight_trailing_whitespace, + "whitespace_characters" => @whitespace_characters, "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 de5237f..5a9871c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -258,6 +258,7 @@ impl EditorSession { 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_whitespace_characters(config.whitespace_characters); 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 93a0dc9..473a7bc 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -995,6 +995,15 @@ 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) +} + +fn visible_text( + line: &str, + left: usize, + width: usize, + whitespace: Option<&BTreeMap>, +) -> String { let mut visible = String::new(); let mut column = 0; let right = left.saturating_add(width); @@ -1009,6 +1018,19 @@ fn visible_line(line: &str, left: usize, width: usize) -> String { break; } 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 { for _ in column.max(left)..end.min(right) { visible.push(' '); @@ -1834,10 +1856,11 @@ fn draw_document( .visible .iter() .map(|&(row, _, segment)| { - Line::from(visible_line( + Line::from(visible_text( &editor.lines()[row], segment.start, segment.end.saturating_sub(segment.start).min(width), + Some(editor.whitespace_characters()), )) }) .collect(); @@ -2938,6 +2961,7 @@ fn draw_diff( wrapped, panels.diff_colors, editor.is_some_and(Editor::highlight_trailing_whitespace), + editor.map(Editor::whitespace_characters), ); text_widths[0] = text_area.width; if let Some(editor) = @@ -2975,6 +2999,7 @@ fn draw_diff( wrapped, panels.diff_colors, editor.is_some_and(Editor::highlight_trailing_whitespace), + editor.map(Editor::whitespace_characters), ); text_widths[1] = text_area.width; if let Some(editor) = @@ -3130,6 +3155,7 @@ fn draw_diff_pane( wrapped: Option<&[WrappedDiffRow]>, colors: DiffColors, highlight_trailing_whitespace: bool, + whitespace_characters: Option<&BTreeMap>, ) -> Rect { if area.width == 0 || area.height == 0 { return area; @@ -3237,8 +3263,13 @@ fn draw_diff_pane( Cow::Borrowed(source_text.as_str()) }; frame.render_widget( - Paragraph::new(visible_line(&text, left, text_width.min(width))) - .style(Style::default().fg(Color::Reset).bg(background)), + Paragraph::new(visible_text( + &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), ); 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] fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { let directory = Directory::new(); @@ -4945,6 +5043,7 @@ mod tests { syntax_diff_file("whitespace.txt", "old \t", "new \t"), ); editor.set_highlight_trailing_whitespace(true); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')])); if wrapped { editor.execute("word_wrap.toggle"); } @@ -4979,6 +5078,8 @@ mod tests { ); } assert_ne!(buffer.content[start + 8].bg, Color::Red); + assert_eq!(buffer.content[start + 3].symbol(), "·"); + assert_eq!(buffer.content[start + 4].symbol(), "→"); } } }