feat(editor): add support to highlight trailing whitespace

Adds a new render configuration value `Trailing`, which can be used
to selectively enable trailing whitespace of certain whitespace characters.
pull/7215/head
Alexandre Vinyals Valdepeñas 2 years ago
parent d511122279
commit 60c06076b2

@ -7,7 +7,7 @@ use helix_core::syntax::Highlight;
use helix_core::syntax::HighlightEvent;
use helix_core::text_annotations::TextAnnotations;
use helix_core::{visual_offset_from_block, Position, RopeSlice};
use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue};
use helix_view::editor::WhitespaceFeature;
use helix_view::graphics::Rect;
use helix_view::theme::Style;
use helix_view::view::ViewPosition;
@ -15,6 +15,8 @@ use helix_view::Document;
use helix_view::Theme;
use tui::buffer::Buffer as Surface;
use super::trailing_whitespace::{TrailingWhitespaceTracker, WhitespaceKind};
pub trait LineDecoration {
fn render_background(&mut self, _renderer: &mut TextRenderer, _pos: LinePos) {}
fn render_foreground(
@ -320,6 +322,7 @@ pub struct TextRenderer<'a> {
pub draw_indent_guides: bool,
pub col_offset: usize,
pub viewport: Rect,
pub trailing_whitespace_tracker: TrailingWhitespaceTracker,
}
impl<'a> TextRenderer<'a> {
@ -331,49 +334,24 @@ impl<'a> TextRenderer<'a> {
viewport: Rect,
) -> TextRenderer<'a> {
let editor_config = doc.config.load();
let WhitespaceConfig {
render: ws_render,
characters: ws_chars,
} = &editor_config.whitespace;
let tab_width = doc.tab_width();
let tab = if ws_render.tab() == WhitespaceRenderValue::All {
std::iter::once(ws_chars.tab)
.chain(std::iter::repeat(ws_chars.tabpad).take(tab_width - 1))
.collect()
} else {
" ".repeat(tab_width)
};
let virtual_tab = " ".repeat(tab_width);
let newline = if ws_render.newline() == WhitespaceRenderValue::All {
ws_chars.newline.into()
} else {
" ".to_owned()
};
let space = if ws_render.space() == WhitespaceRenderValue::All {
ws_chars.space.into()
} else {
" ".to_owned()
};
let nbsp = if ws_render.nbsp() == WhitespaceRenderValue::All {
ws_chars.nbsp.into()
} else {
" ".to_owned()
};
let text_style = theme.get("ui.text");
let indent_width = doc.indent_style.indent_width(tab_width) as u16;
let ws = &editor_config.whitespace;
let regular_ws = WhitespaceFeature::Regular.palette(ws, tab_width);
let trailing_ws = WhitespaceFeature::Trailing.palette(ws, tab_width);
let trailing_whitespace_tracker = TrailingWhitespaceTracker::new(&ws.render, trailing_ws);
TextRenderer {
surface,
indent_guide_char: editor_config.indent_guides.character.into(),
newline,
nbsp,
space,
tab,
virtual_tab,
newline: regular_ws.newline,
nbsp: regular_ws.nbsp,
space: regular_ws.space,
tab: regular_ws.tab,
virtual_tab: regular_ws.virtual_tab,
whitespace_style: theme.get("ui.virtual.whitespace"),
indent_width,
starting_indent: col_offset / indent_width as usize
@ -388,6 +366,7 @@ impl<'a> TextRenderer<'a> {
draw_indent_guides: editor_config.indent_guides.render,
viewport,
col_offset,
trailing_whitespace_tracker,
}
}
@ -417,28 +396,65 @@ impl<'a> TextRenderer<'a> {
} else {
&self.tab
};
let grapheme = match grapheme {
let mut whitespace_kind = WhitespaceKind::None;
let grapheme_value = match grapheme {
Grapheme::Tab { width } => {
whitespace_kind = WhitespaceKind::Tab(width);
let grapheme_tab_width = char_to_byte_idx(tab, width);
&tab[..grapheme_tab_width]
}
// TODO special rendering for other whitespaces?
Grapheme::Other { ref g } if g == " " => space,
Grapheme::Other { ref g } if g == "\u{00A0}" => nbsp,
Grapheme::Other { ref g } if g == " " => {
whitespace_kind = WhitespaceKind::Space;
space
}
Grapheme::Other { ref g } if g == "\u{00A0}" => {
whitespace_kind = WhitespaceKind::NonBreakingSpace;
nbsp
}
Grapheme::Other { ref g } => g,
Grapheme::Newline => &self.newline,
Grapheme::Newline => {
whitespace_kind = WhitespaceKind::Newline;
&self.newline
}
};
let in_bounds = self.col_offset <= position.col
&& position.col < self.viewport.width as usize + self.col_offset;
self.trailing_whitespace_tracker
.track(position.col, whitespace_kind);
let viewport_right_edge = self.viewport.width as usize + self.col_offset - 1;
let in_bounds = self.col_offset <= position.col && position.col <= viewport_right_edge;
if in_bounds {
self.surface.set_string(
self.viewport.x + (position.col - self.col_offset) as u16,
self.viewport.y + position.row as u16,
grapheme,
style,
);
if self.trailing_whitespace_tracker.is_enabled()
&& (grapheme == Grapheme::Newline || position.col == viewport_right_edge)
{
if let Some((from, trailing_whitespace)) = self.trailing_whitespace_tracker.get() {
let offset = if from < self.col_offset {
0
} else {
from - self.col_offset
};
let begin_at = if from < self.col_offset {
self.col_offset - from
} else {
0
};
self.surface.set_string(
self.viewport.x + offset as u16,
self.viewport.y + position.row as u16,
&trailing_whitespace[char_to_byte_idx(&trailing_whitespace, begin_at)..],
style,
);
}
} else {
self.surface.set_string(
self.viewport.x + (position.col - self.col_offset) as u16,
self.viewport.y + position.row as u16,
grapheme_value,
style,
);
}
} else if cut_off_start != 0 && cut_off_start < width {
// partially on screen
let rect = Rect::new(

@ -13,6 +13,7 @@ mod prompt;
mod spinner;
mod statusline;
mod text;
mod trailing_whitespace;
use crate::compositor::{Component, Compositor};
use crate::filter_picker_entry;

@ -0,0 +1,129 @@
use helix_view::editor::{WhitespacePalette, WhitespaceRender, WhitespaceRenderValue};
use helix_core::str_utils::char_to_byte_idx;
#[derive(Debug, Eq, PartialEq)]
pub enum WhitespaceKind {
None,
Space,
NonBreakingSpace,
Tab(usize),
Newline,
}
#[derive(Debug)]
pub struct TrailingWhitespaceTracker {
enabled: bool,
palette: WhitespacePalette,
tracking: bool,
tracking_from: usize,
tracking_content: Vec<WhitespaceKind>,
}
impl TrailingWhitespaceTracker {
pub fn new(render: &WhitespaceRender, palette: WhitespacePalette) -> Self {
Self {
palette,
enabled: render.any(WhitespaceRenderValue::Trailing),
tracking: false,
tracking_from: 0,
tracking_content: vec![],
}
}
pub fn track(&mut self, from: usize, kind: WhitespaceKind) {
if kind == WhitespaceKind::None {
self.tracking = false;
return;
}
if !self.tracking {
self.tracking = true;
self.tracking_from = from;
self.tracking_content.clear();
}
self.tracking_content.push(kind);
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub fn get(&mut self) -> Option<(usize, String)> {
if !self.enabled || !self.tracking {
return None;
}
self.tracking = false;
let trailing_whitespace = self
.tracking_content
.iter()
.map(|kind| match kind {
WhitespaceKind::Space => &self.palette.space,
WhitespaceKind::NonBreakingSpace => &self.palette.nbsp,
WhitespaceKind::Tab(width) => {
let grapheme_tab_width = char_to_byte_idx(&self.palette.tab, *width);
&self.palette.tab[..grapheme_tab_width]
}
WhitespaceKind::Newline => &self.palette.newline,
WhitespaceKind::None => "",
})
.collect::<Vec<&str>>()
.join("");
Some((self.tracking_from, trailing_whitespace))
}
}
#[cfg(test)]
mod tests {
use super::*;
use helix_view::editor::WhitespaceRender;
fn palette() -> WhitespacePalette {
WhitespacePalette {
space: "S".into(),
nbsp: "N".into(),
tab: "T".into(),
virtual_tab: "V".into(),
newline: "L".into(),
}
}
#[test]
fn test_trailing_whitespace_tracker_correctly_tracks_sequences() {
let ws_render = WhitespaceRender::Basic(WhitespaceRenderValue::Trailing);
let mut sut = TrailingWhitespaceTracker::new(&ws_render, palette());
sut.track(5, WhitespaceKind::Space);
sut.track(6, WhitespaceKind::NonBreakingSpace);
sut.track(7, WhitespaceKind::Tab(1));
sut.track(8, WhitespaceKind::Newline);
let trailing = sut.get();
assert!(trailing.is_some());
let (from, display) = trailing.unwrap();
assert_eq!(5, from);
assert_eq!("SNTL", display);
// Now we break the sequence
sut.track(6, WhitespaceKind::None);
let trailing = sut.get();
assert!(trailing.is_none());
// Now we track again
sut.track(10, WhitespaceKind::Tab(1));
sut.track(11, WhitespaceKind::NonBreakingSpace);
sut.track(12, WhitespaceKind::Space);
sut.track(13, WhitespaceKind::Newline);
let trailing = sut.get();
assert!(trailing.is_some());
let (from, display) = trailing.unwrap();
assert_eq!(10, from);
assert_eq!("TNSL", display);
}
}

@ -632,6 +632,73 @@ pub enum WhitespaceRender {
},
}
impl WhitespaceRender {
pub fn any(&self, value: WhitespaceRenderValue) -> bool {
self.space() == value
|| self.nbsp() == value
|| self.tab() == value
|| self.newline() == value
}
}
pub enum WhitespaceFeature {
Regular,
Trailing,
}
impl WhitespaceFeature {
pub fn is_enabled(&self, render: WhitespaceRenderValue) -> bool {
match self {
WhitespaceFeature::Regular => matches!(render, WhitespaceRenderValue::All),
WhitespaceFeature::Trailing => matches!(
render,
WhitespaceRenderValue::All | WhitespaceRenderValue::Trailing
),
}
}
pub fn palette(self, cfg: &WhitespaceConfig, tab_width: usize) -> WhitespacePalette {
WhitespacePalette::from(self, cfg, tab_width)
}
}
#[derive(Debug)]
pub struct WhitespacePalette {
pub space: String,
pub nbsp: String,
pub tab: String,
pub virtual_tab: String,
pub newline: String,
}
impl WhitespacePalette {
fn from(feature: WhitespaceFeature, cfg: &WhitespaceConfig, tab_width: usize) -> Self {
Self {
space: if feature.is_enabled(cfg.render.space()) {
cfg.characters.space.to_string()
} else {
" ".to_string()
},
nbsp: if feature.is_enabled(cfg.render.nbsp()) {
cfg.characters.nbsp.to_string()
} else {
" ".to_string()
},
tab: if feature.is_enabled(cfg.render.tab()) {
cfg.characters.generate_tab(tab_width)
} else {
" ".repeat(tab_width)
},
newline: if feature.is_enabled(cfg.render.newline()) {
cfg.characters.newline.to_string()
} else {
" ".to_string()
},
virtual_tab: " ".repeat(tab_width),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WhitespaceRenderValue {
@ -639,6 +706,7 @@ pub enum WhitespaceRenderValue {
// TODO
// Selection,
All,
Trailing,
}
impl WhitespaceRender {
@ -698,6 +766,14 @@ impl Default for WhitespaceCharacters {
}
}
impl WhitespaceCharacters {
pub fn generate_tab(&self, width: usize) -> String {
std::iter::once(self.tab)
.chain(std::iter::repeat(self.tabpad).take(width - 1))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct IndentGuidesConfig {
@ -1736,3 +1812,92 @@ fn try_restore_indent(doc: &mut Document, view: &mut View) {
doc.apply(&transaction, view.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_whitespace_render_any() {
let sut = WhitespaceRender::Basic(WhitespaceRenderValue::Trailing);
assert!(!sut.any(WhitespaceRenderValue::None));
assert!(!sut.any(WhitespaceRenderValue::All));
assert!(sut.any(WhitespaceRenderValue::Trailing));
}
#[test]
fn test_whitespace_feature_is_enabled_regular() {
let sut = WhitespaceFeature::Regular;
assert!(!sut.is_enabled(WhitespaceRenderValue::None));
assert!(!sut.is_enabled(WhitespaceRenderValue::Trailing));
assert!(sut.is_enabled(WhitespaceRenderValue::All));
}
#[test]
fn test_whitespace_feature_is_enabled_trailing() {
let sut = WhitespaceFeature::Trailing;
assert!(!sut.is_enabled(WhitespaceRenderValue::None));
assert!(sut.is_enabled(WhitespaceRenderValue::Trailing));
assert!(sut.is_enabled(WhitespaceRenderValue::All));
}
#[test]
fn test_whitespace_palette_regular_all() {
let cfg = WhitespaceConfig {
render: WhitespaceRender::Basic(WhitespaceRenderValue::All),
..Default::default()
};
let sut = WhitespacePalette::from(WhitespaceFeature::Regular, &cfg, 2);
assert_eq!("·", sut.space);
assert_eq!("⍽", sut.nbsp);
assert_eq!("→ ", sut.tab);
assert_eq!(" ", sut.virtual_tab);
assert_eq!("⏎", sut.newline);
}
#[test]
fn test_whitespace_palette_regular_trailing() {
let cfg = WhitespaceConfig {
render: WhitespaceRender::Basic(WhitespaceRenderValue::Trailing),
..Default::default()
};
let sut = WhitespacePalette::from(WhitespaceFeature::Regular, &cfg, 2);
assert_eq!(" ", sut.space);
assert_eq!(" ", sut.nbsp);
assert_eq!(" ", sut.tab);
assert_eq!(" ", sut.virtual_tab);
assert_eq!(" ", sut.newline);
}
#[test]
fn test_whitespace_palette_trailing_all() {
let cfg = WhitespaceConfig {
render: WhitespaceRender::Basic(WhitespaceRenderValue::All),
..Default::default()
};
let sut = WhitespacePalette::from(WhitespaceFeature::Trailing, &cfg, 2);
assert_eq!("·", sut.space);
assert_eq!("⍽", sut.nbsp);
assert_eq!("→ ", sut.tab);
assert_eq!(" ", sut.virtual_tab);
assert_eq!("⏎", sut.newline);
}
#[test]
fn test_whitespace_characters_render_tab() {
let sut = WhitespaceCharacters::default();
assert_eq!("→", sut.generate_tab(1));
assert_eq!("→ ", sut.generate_tab(2));
assert_eq!("→ ", sut.generate_tab(3));
assert_eq!("→ ", sut.generate_tab(4));
}
}

Loading…
Cancel
Save