diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index 8423ae8e4..7a9c98ffe 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -8,13 +8,15 @@ use helix_core::syntax::HighlightEvent; use helix_core::text_annotations::TextAnnotations; use helix_core::{visual_offset_from_block, Position, RopeSlice}; use helix_stdx::rope::RopeSliceExt; -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; use helix_view::{Document, Theme}; use tui::buffer::Buffer as Surface; +use super::trailing_whitespace::{TrailingWhitespaceTracker, WhitespaceKind}; + use crate::ui::text_decorations::DecorationManager; #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -256,6 +258,7 @@ pub struct TextRenderer<'a> { surface: &'a mut Surface, pub text_style: Style, pub whitespace_style: Style, + pub trailing_whitespace_style: Style, pub indent_guide_char: String, pub indent_guide_style: Style, pub newline: String, @@ -269,6 +272,7 @@ pub struct TextRenderer<'a> { pub draw_indent_guides: bool, pub viewport: Rect, pub offset: Position, + pub trailing_whitespace_tracker: TrailingWhitespaceTracker, } pub struct GraphemeStyle { @@ -285,56 +289,27 @@ 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 nnbsp = if ws_render.nnbsp() == WhitespaceRenderValue::All { - ws_chars.nnbsp.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, - nnbsp, - space, - tab, - virtual_tab, + newline: regular_ws.newline, + nbsp: regular_ws.nbsp, + nnbsp: regular_ws.nnbsp, + space: regular_ws.space, + tab: regular_ws.tab, + virtual_tab: regular_ws.virtual_tab, whitespace_style: theme.get("ui.virtual.whitespace"), + trailing_whitespace_style: theme.get("ui.virtual.trailing_whitespace"), indent_width, starting_indent: offset.col / indent_width as usize + (offset.col % indent_width as usize != 0) as usize @@ -348,6 +323,7 @@ impl<'a> TextRenderer<'a> { draw_indent_guides: editor_config.indent_guides.render, viewport, offset, + trailing_whitespace_tracker, } } /// Draws a single `grapheme` at the current render position with a specified `style`. @@ -422,28 +398,61 @@ impl<'a> TextRenderer<'a> { } else { &self.tab }; + let mut whitespace_kind = WhitespaceKind::None; let grapheme = match grapheme { Grapheme::Tab { width } => { + whitespace_kind = WhitespaceKind::Tab; 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 == "\u{202F}" => nnbsp, + 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 } if g == "\u{202F}" => { + whitespace_kind = WhitespaceKind::NarrowNonBreakingSpace; + nnbsp + } Grapheme::Other { ref g } => g, - Grapheme::Newline => &self.newline, + Grapheme::Newline => { + whitespace_kind = WhitespaceKind::Newline; + &self.newline + } }; + let viewport_right_edge = self.viewport.width as usize + self.offset.col - 1; let in_bounds = self.column_in_bounds(position.col, width); if in_bounds { + let in_bounds_col = position.col - self.offset.col; self.surface.set_string( - self.viewport.x + (position.col - self.offset.col) as u16, + self.viewport.x + in_bounds_col as u16, self.viewport.y + position.row as u16, grapheme, style, ); + + if self + .trailing_whitespace_tracker + .track(in_bounds_col, whitespace_kind) + || position.col == viewport_right_edge + { + self.trailing_whitespace_tracker.render( + &mut |trailing_whitespace: &str, from: usize| { + self.surface.set_string( + self.viewport.x + from as u16, + self.viewport.y + position.row as u16, + trailing_whitespace, + style.patch(self.trailing_whitespace_style), + ); + }, + ); + } } else if cut_off_start != 0 && cut_off_start < width { // partially on screen let rect = Rect::new( diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index a76adbe21..be1285009 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -13,6 +13,7 @@ mod spinner; mod statusline; mod text; mod text_decorations; +mod trailing_whitespace; use crate::compositor::Compositor; use crate::filter_picker_entry; diff --git a/helix-term/src/ui/trailing_whitespace.rs b/helix-term/src/ui/trailing_whitespace.rs new file mode 100644 index 000000000..6f847138c --- /dev/null +++ b/helix-term/src/ui/trailing_whitespace.rs @@ -0,0 +1,173 @@ +use helix_core::str_utils::char_to_byte_idx; +use helix_view::editor::{WhitespacePalette, WhitespaceRender, WhitespaceRenderValue}; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum WhitespaceKind { + None, + Space, + NonBreakingSpace, + NarrowNonBreakingSpace, + Tab, + Newline, +} + +impl WhitespaceKind { + pub fn to_str(self, palette: &WhitespacePalette) -> &str { + match self { + WhitespaceKind::Space => &palette.space, + WhitespaceKind::NonBreakingSpace => &palette.nbsp, + WhitespaceKind::NarrowNonBreakingSpace => &palette.nnbsp, + WhitespaceKind::Tab => { + let grapheme_tab_width = char_to_byte_idx(&palette.tab, palette.tab.len()); + &palette.tab[..grapheme_tab_width] + } + WhitespaceKind::Newline | WhitespaceKind::None => "", + } + } +} + +#[derive(Debug)] +pub struct TrailingWhitespaceTracker { + enabled: bool, + palette: WhitespacePalette, + tracking_from: usize, + tracking_content: Vec<(WhitespaceKind, usize)>, +} + +impl TrailingWhitespaceTracker { + pub fn new(render: WhitespaceRender, palette: WhitespacePalette) -> Self { + Self { + palette, + enabled: render.any(WhitespaceRenderValue::Trailing), + tracking_from: 0, + tracking_content: vec![], + } + } + + // Tracks the whitespace and returns wether [`render`] should be called right after + // to display the trailing whitespace. + pub fn track(&mut self, from: usize, kind: WhitespaceKind) -> bool { + if !self.enabled || kind == WhitespaceKind::None { + self.tracking_content.clear(); + return false; + } + if kind == WhitespaceKind::Newline { + return true; + } + if self.tracking_content.is_empty() { + self.tracking_from = from; + } + self.compress(kind); + false + } + + pub fn render(&mut self, callback: &mut impl FnMut(&str, usize)) { + if self.tracking_content.is_empty() { + return; + } + let mut offset = self.tracking_from; + self.tracking_content.iter().for_each(|(kind, n)| { + let ws = kind.to_str(&self.palette).repeat(*n); + callback(&ws, offset); + offset += n; + }); + self.tracking_content.clear(); + } + + fn compress(&mut self, kind: WhitespaceKind) { + if let Some((last_kind, n)) = self.tracking_content.last_mut() { + if *last_kind == kind { + *n += 1; + return; + } + } + self.tracking_content.push((kind, 1)); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + use helix_view::editor::WhitespaceRender; + + fn palette() -> WhitespacePalette { + WhitespacePalette { + space: "S".into(), + nbsp: "N".into(), + nnbsp: "M".into(), + tab: "".into(), + virtual_tab: "V".into(), + newline: "L".into(), + } + } + + fn capture(sut: &mut TrailingWhitespaceTracker) -> (String, usize, usize) { + let mut captured_content = String::new(); + let mut from: usize = 0; + let mut to: usize = 0; + + sut.render(&mut |content: &str, pos: usize| { + captured_content.push_str(content); + if from == 0 { + from = pos; + } + to = pos; + }); + + (captured_content, from, to) + } + + #[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::NarrowNonBreakingSpace); + sut.track(8, WhitespaceKind::Tab); + + let (content, from, to) = capture(&mut sut); + + assert_eq!(5, from); + assert_eq!(8, to); + assert_eq!("SNM", content); + + // Now we break the sequence + sut.track(6, WhitespaceKind::None); + + let (content, from, to) = capture(&mut sut); + assert_eq!(0, from); + assert_eq!(0, to); + assert_eq!("", content); + + sut.track(10, WhitespaceKind::Tab); + sut.track(11, WhitespaceKind::NonBreakingSpace); + sut.track(12, WhitespaceKind::NarrowNonBreakingSpace); + sut.track(13, WhitespaceKind::Space); + + let (content, from, to) = capture(&mut sut); + assert_eq!(10, from); + assert_eq!(13, to); + assert_eq!("NMS", content); + + // Verify compression works + sut.track(20, WhitespaceKind::Space); + sut.track(21, WhitespaceKind::Space); + sut.track(22, WhitespaceKind::NonBreakingSpace); + sut.track(23, WhitespaceKind::NonBreakingSpace); + sut.track(24, WhitespaceKind::NarrowNonBreakingSpace); + sut.track(25, WhitespaceKind::NarrowNonBreakingSpace); + sut.track(26, WhitespaceKind::Tab); + sut.track(27, WhitespaceKind::Tab); + sut.track(28, WhitespaceKind::Tab); + + let (content, from, to) = capture(&mut sut); + assert_eq!(20, from); + assert_eq!(26, to); // Compression means last tracked token is on 26 instead of 28 + assert_eq!("SSNNMM", content); + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index be2218997..a1f353da4 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -759,13 +759,88 @@ pub enum WhitespaceRender { }, } +impl WhitespaceRender { + pub fn any(&self, value: WhitespaceRenderValue) -> bool { + self.space() == value + || self.nbsp() == value + || self.nnbsp() == 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 nnbsp: 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() + }, + nnbsp: if feature.is_enabled(cfg.render.nnbsp()) { + cfg.characters.nnbsp.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 { None, + All, + Trailing, // TODO // Selection, - All, } impl WhitespaceRender { @@ -890,6 +965,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 { @@ -2302,3 +2385,95 @@ impl CursorCache { self.0.set(None) } } + +#[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.nnbsp); + 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.nnbsp); + 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.nnbsp); + 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)); + } +}