diff --git a/book/src/configuration.md b/book/src/configuration.md index 38f9f9eb2383e..1ff0fe810f581 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -128,6 +128,7 @@ The following statusline elements can be configured: | `display-inlay-hints` | Display inlay hints[^2] | `false` | | `display-signature-help-docs` | Display docs under signature help popup | `true` | | `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` | +| `display-inline-diagnostics` | Display diagnostics under their starting line | `true` | [^1]: By default, a progress spinner is shown in the statusline beside the file path. [^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. diff --git a/book/src/themes.md b/book/src/themes.md index 56d0372ca8888..4972ddbe5355f 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -300,6 +300,7 @@ These scopes are used for theming the editor interface: | `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | | `ui.text.info` | The key: command text in `ui.popup.info` boxes | | `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | +| `ui.virtual.diagnostics` | Default style for inline diagnostics lines (notably control the background) | | `ui.virtual.whitespace` | Visible whitespace characters | | `ui.virtual.indent-guide` | Vertical indent width guides | | `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds | diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 58ddb0383a0ad..57284a50112f8 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -1,8 +1,9 @@ //! LSP diagnostic utility types. -use serde::{Deserialize, Serialize}; +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Describes the severity level of a [`Diagnostic`]. -#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] pub enum Severity { Hint, Info, @@ -10,6 +11,39 @@ pub enum Severity { Error, } +impl Serialize for Severity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(match *self { + Severity::Hint => "hint", + Severity::Info => "info", + Severity::Warning => "warning", + Severity::Error => "error", + }) + } +} + +impl<'de> Deserialize<'de> for Severity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let res = match String::deserialize(deserializer)?.as_str() { + "hint" => Severity::Hint, + "info" => Severity::Info, + "warning" => Severity::Warning, + "error" => Severity::Error, + _ => { + return Err(D::Error::custom( + "expected \"hint\", \"info\", \"warning\" or \"error\"", + )) + } + }; + Ok(res) + } +} impl Default for Severity { fn default() -> Self { Self::Hint @@ -23,6 +57,12 @@ pub struct Range { pub end: usize, } +impl Range { + pub fn contains(self, pos: usize) -> bool { + (self.start..self.end).contains(&pos) + } +} + #[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)] pub enum NumberOrString { Number(i32), @@ -47,3 +87,9 @@ pub struct Diagnostic { pub source: Option, pub data: Option, } + +impl Diagnostic { + pub fn severity(&self) -> Severity { + self.severity.unwrap_or(Severity::Warning) + } +} diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index c7dc9081f5cfd..c29e40d22748d 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -10,8 +10,9 @@ //! called a "block" and the caller must advance it as needed. use std::borrow::Cow; +use std::cmp::Ordering; use std::fmt::Debug; -use std::mem::{replace, take}; +use std::mem::replace; #[cfg(test)] mod test; @@ -37,52 +38,104 @@ pub enum GraphemeSource { }, } +impl GraphemeSource { + /// Returns whether this grapheme is virtual inline text + pub fn is_virtual(self) -> bool { + matches!(self, GraphemeSource::VirtualText { .. }) + } + + pub fn is_eof(self) -> bool { + // all doc chars except the EOF char have non-zero codepoints + matches!(self, GraphemeSource::Document { codepoints: 0 }) + } + + pub fn doc_chars(self) -> usize { + match self { + GraphemeSource::Document { codepoints } => codepoints as usize, + GraphemeSource::VirtualText { .. } => 0, + } + } +} + #[derive(Debug, Clone)] pub struct FormattedGrapheme<'a> { - pub grapheme: Grapheme<'a>, + pub raw: Grapheme<'a>, pub source: GraphemeSource, + pub visual_pos: Position, + /// Document line at the start of the grapheme + pub line_idx: usize, + /// Document char position at the start of the grapheme + pub char_idx: usize, } -impl<'a> FormattedGrapheme<'a> { - pub fn new( +impl FormattedGrapheme<'_> { + pub fn is_virtual(&self) -> bool { + self.source.is_virtual() + } + + pub fn doc_chars(&self) -> usize { + self.source.doc_chars() + } + + pub fn is_whitespace(&self) -> bool { + self.raw.is_whitespace() + } + + pub fn width(&self) -> usize { + self.raw.width() + } + + pub fn is_word_boundary(&self) -> bool { + self.raw.is_word_boundary() + } +} + +#[derive(Debug, Clone)] +struct GraphemeWithSource<'a> { + grapheme: Grapheme<'a>, + source: GraphemeSource, +} + +impl<'a> GraphemeWithSource<'a> { + fn new( g: GraphemeStr<'a>, visual_x: usize, tab_width: u16, source: GraphemeSource, - ) -> FormattedGrapheme<'a> { - FormattedGrapheme { + ) -> GraphemeWithSource<'a> { + GraphemeWithSource { grapheme: Grapheme::new(g, visual_x, tab_width), source, } } - /// Returns whether this grapheme is virtual inline text - pub fn is_virtual(&self) -> bool { - matches!(self.source, GraphemeSource::VirtualText { .. }) - } - - pub fn placeholder() -> Self { - FormattedGrapheme { + fn placeholder() -> Self { + GraphemeWithSource { grapheme: Grapheme::Other { g: " ".into() }, source: GraphemeSource::Document { codepoints: 0 }, } } - pub fn doc_chars(&self) -> usize { - match self.source { - GraphemeSource::Document { codepoints } => codepoints as usize, - GraphemeSource::VirtualText { .. } => 0, - } + fn doc_chars(&self) -> usize { + self.source.doc_chars() } - pub fn is_whitespace(&self) -> bool { + fn is_whitespace(&self) -> bool { self.grapheme.is_whitespace() } - pub fn width(&self) -> usize { + fn is_newline(&self) -> bool { + matches!(self.grapheme, Grapheme::Newline) + } + + fn is_eof(&self) -> bool { + self.source.is_eof() + } + + fn width(&self) -> usize { self.grapheme.width() } - pub fn is_word_boundary(&self) -> bool { + fn is_word_boundary(&self) -> bool { self.grapheme.is_word_boundary() } } @@ -96,6 +149,7 @@ pub struct TextFormat { pub wrap_indicator: Box, pub wrap_indicator_highlight: Option, pub viewport_width: u16, + pub soft_wrap_at_text_width: bool, } // test implementation is basically only used for testing or when softwrap is always disabled @@ -109,6 +163,7 @@ impl Default for TextFormat { wrap_indicator: Box::from(" "), viewport_width: 17, wrap_indicator_highlight: None, + soft_wrap_at_text_width: false, } } } @@ -116,7 +171,7 @@ impl Default for TextFormat { #[derive(Debug)] pub struct DocumentFormatter<'t> { text_fmt: &'t TextFormat, - annotations: &'t TextAnnotations, + annotations: &'t TextAnnotations<'t>, /// The visual position at the end of the last yielded word boundary visual_pos: Position, @@ -127,10 +182,7 @@ pub struct DocumentFormatter<'t> { line_pos: usize, exhausted: bool, - /// Line breaks to be reserved for virtual text - /// at the next line break - virtual_lines: usize, - inline_anntoation_graphemes: Option<(Graphemes<'t>, Option)>, + inline_annotation_graphemes: Option<(Graphemes<'t>, Option)>, // softwrap specific /// The indentation of the current line @@ -139,9 +191,9 @@ pub struct DocumentFormatter<'t> { indent_level: Option, /// In case a long word needs to be split a single grapheme might need to be wrapped /// while the rest of the word stays on the same line - peeked_grapheme: Option<(FormattedGrapheme<'t>, usize)>, + peeked_grapheme: Option>, /// A first-in first-out (fifo) buffer for the Graphemes of any given word - word_buf: Vec>, + word_buf: Vec>, /// The index of the next grapheme that will be yielded from the `word_buf` word_i: usize, } @@ -157,35 +209,35 @@ impl<'t> DocumentFormatter<'t> { text_fmt: &'t TextFormat, annotations: &'t TextAnnotations, char_idx: usize, - ) -> (Self, usize) { + ) -> Self { // TODO divide long lines into blocks to avoid bad performance for long lines let block_line_idx = text.char_to_line(char_idx.min(text.len_chars())); let block_char_idx = text.line_to_char(block_line_idx); annotations.reset_pos(block_char_idx); - ( - DocumentFormatter { - text_fmt, - annotations, - visual_pos: Position { row: 0, col: 0 }, - graphemes: RopeGraphemes::new(text.slice(block_char_idx..)), - char_pos: block_char_idx, - exhausted: false, - virtual_lines: 0, - indent_level: None, - peeked_grapheme: None, - word_buf: Vec::with_capacity(64), - word_i: 0, - line_pos: block_line_idx, - inline_anntoation_graphemes: None, - }, - block_char_idx, - ) + + DocumentFormatter { + text_fmt, + annotations, + visual_pos: Position { row: 0, col: 0 }, + graphemes: RopeGraphemes::new(text.slice(block_char_idx..)), + char_pos: block_char_idx, + exhausted: false, + indent_level: None, + peeked_grapheme: None, + word_buf: Vec::with_capacity(64), + word_i: 0, + line_pos: block_line_idx, + inline_annotation_graphemes: None, + } } - fn next_inline_annotation_grapheme(&mut self) -> Option<(&'t str, Option)> { + fn next_inline_annotation_grapheme( + &mut self, + char_pos: usize, + ) -> Option<(&'t str, Option)> { loop { if let Some(&mut (ref mut annotation, highlight)) = - self.inline_anntoation_graphemes.as_mut() + self.inline_annotation_graphemes.as_mut() { if let Some(grapheme) = annotation.next() { return Some((grapheme, highlight)); @@ -193,9 +245,9 @@ impl<'t> DocumentFormatter<'t> { } if let Some((annotation, highlight)) = - self.annotations.next_inline_annotation_at(self.char_pos) + self.annotations.next_inline_annotation_at(char_pos) { - self.inline_anntoation_graphemes = Some(( + self.inline_annotation_graphemes = Some(( UnicodeSegmentation::graphemes(&*annotation.text, true), highlight, )) @@ -205,21 +257,19 @@ impl<'t> DocumentFormatter<'t> { } } - fn advance_grapheme(&mut self, col: usize) -> Option> { + fn advance_grapheme(&mut self, col: usize, char_pos: usize) -> Option> { let (grapheme, source) = - if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme() { + if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme(char_pos) { (grapheme.into(), GraphemeSource::VirtualText { highlight }) } else if let Some(grapheme) = self.graphemes.next() { - self.virtual_lines += self.annotations.annotation_lines_at(self.char_pos); let codepoints = grapheme.len_chars() as u32; - let overlay = self.annotations.overlay_at(self.char_pos); + let overlay = self.annotations.overlay_at(char_pos); let grapheme = match overlay { Some((overlay, _)) => overlay.grapheme.as_str().into(), None => Cow::from(grapheme).into(), }; - self.char_pos += codepoints as usize; (grapheme, GraphemeSource::Document { codepoints }) } else { if self.exhausted { @@ -228,19 +278,19 @@ impl<'t> DocumentFormatter<'t> { self.exhausted = true; // EOF grapheme is required for rendering // and correct position computations - return Some(FormattedGrapheme { + return Some(GraphemeWithSource { grapheme: Grapheme::Other { g: " ".into() }, source: GraphemeSource::Document { codepoints: 0 }, }); }; - let grapheme = FormattedGrapheme::new(grapheme, col, self.text_fmt.tab_width, source); + let grapheme = GraphemeWithSource::new(grapheme, col, self.text_fmt.tab_width, source); Some(grapheme) } /// Move a word to the next visual line - fn wrap_word(&mut self, virtual_lines_before_word: usize) -> usize { + fn wrap_word(&mut self) -> usize { // softwrap this word to the next line let indent_carry_over = if let Some(indent) = self.indent_level { if indent as u16 <= self.text_fmt.max_indent_retain { @@ -255,14 +305,16 @@ impl<'t> DocumentFormatter<'t> { }; self.visual_pos.col = indent_carry_over as usize; - self.virtual_lines -= virtual_lines_before_word; - self.visual_pos.row += 1 + virtual_lines_before_word; + let virtual_lines = + self.annotations + .virtual_lines_at(self.char_pos, self.visual_pos.row, self.line_pos); + self.visual_pos.row += 1 + virtual_lines; let mut i = 0; let mut word_width = 0; let wrap_indicator = UnicodeSegmentation::graphemes(&*self.text_fmt.wrap_indicator, true) .map(|g| { i += 1; - let grapheme = FormattedGrapheme::new( + let grapheme = GraphemeWithSource::new( g.into(), self.visual_pos.col + word_width, self.text_fmt.tab_width, @@ -282,46 +334,80 @@ impl<'t> DocumentFormatter<'t> { .change_position(visual_x, self.text_fmt.tab_width); word_width += grapheme.width(); } + if let Some(grapheme) = &mut self.peeked_grapheme { + let visual_x = self.visual_pos.col + word_width; + grapheme + .grapheme + .change_position(visual_x, self.text_fmt.tab_width); + } word_width } + fn peek_grapheme(&mut self, col: usize, char_pos: usize) -> Option<&GraphemeWithSource<'t>> { + if self.peeked_grapheme.is_none() { + self.peeked_grapheme = self.advance_grapheme(col, char_pos); + } + self.peeked_grapheme.as_ref() + } + + fn next_grapheme(&mut self, col: usize, char_pos: usize) -> Option> { + self.peek_grapheme(col, char_pos); + self.peeked_grapheme.take() + } + fn advance_to_next_word(&mut self) { self.word_buf.clear(); let mut word_width = 0; - let virtual_lines_before_word = self.virtual_lines; - let mut virtual_lines_before_grapheme = self.virtual_lines; + let mut word_chars = 0; + + if self.exhausted { + return; + } loop { - // softwrap word if necessary - if word_width + self.visual_pos.col >= self.text_fmt.viewport_width as usize { - // wrapping this word would move too much text to the next line - // split the word at the line end instead - if word_width > self.text_fmt.max_wrap as usize { - // Usually we stop accomulating graphemes as soon as softwrapping becomes necessary. - // However if the last grapheme is multiple columns wide it might extend beyond the EOL. - // The condition below ensures that this grapheme is not cutoff and instead wrapped to the next line - if word_width + self.visual_pos.col > self.text_fmt.viewport_width as usize { - self.peeked_grapheme = self.word_buf.pop().map(|grapheme| { - (grapheme, self.virtual_lines - virtual_lines_before_grapheme) - }); - self.virtual_lines = virtual_lines_before_grapheme; - } + let mut col = self.visual_pos.col + word_width; + let char_pos = self.char_pos + word_chars; + match col.cmp(&(self.text_fmt.viewport_width as usize)) { + Ordering::Equal + if self.text_fmt.soft_wrap_at_text_width + && self.peek_grapheme(col, char_pos).map_or(false, |grapheme| { + grapheme.is_newline() || grapheme.is_eof() + }) => + { + // The EOF char and newline chars are always selectable in helix. + // That means that wrapping happens "too-early" if a word fits a line + // perfectly. This is intentional so that all selectable graphemes are always + // visisble (and therefore the cursor never dissapears). However if the user + // manually set a lower softwrap width then this is underisable. + // Just increasing the viewport-width by one doesn't work because if a line + // is wrapped multiple times then some words may extend past the specified width. + // + // Instead for newline chars/EOF we simply only + + // if the word fits the screen width perfectly then we don't need to do anything + // because trailing whitespaces are attechted to a word this can + // * The word ends the EOF + // * + // if we allow line breaks to extend one past the end of the screen + // (because we are using a manually configured text_width) or we are at the end of the file + // then the word fits perfectly to the width and we don't need to wrap (or do anything) + } + Ordering::Equal if word_width > self.text_fmt.max_wrap as usize => return, + Ordering::Greater if word_width > self.text_fmt.max_wrap as usize => { + self.peeked_grapheme = self.word_buf.pop(); return; } - - word_width = self.wrap_word(virtual_lines_before_word); + Ordering::Equal | Ordering::Greater => { + word_width = self.wrap_word(); + col = self.visual_pos.col + word_width; + } + Ordering::Less => (), } - virtual_lines_before_grapheme = self.virtual_lines; - - let grapheme = if let Some((grapheme, virtual_lines)) = self.peeked_grapheme.take() { - self.virtual_lines += virtual_lines; - grapheme - } else if let Some(grapheme) = self.advance_grapheme(self.visual_pos.col + word_width) { - grapheme - } else { - return; + let Some(grapheme) = self.next_grapheme(col, char_pos) else{ + return }; + word_chars += grapheme.doc_chars(); // Track indentation if !grapheme.is_whitespace() && self.indent_level.is_none() { @@ -340,19 +426,18 @@ impl<'t> DocumentFormatter<'t> { } } - /// returns the document line pos of the **next** grapheme that will be yielded - pub fn line_pos(&self) -> usize { - self.line_pos + /// returns the char index at the end of the last yielded grapheme + pub fn next_char_pos(&self) -> usize { + self.char_pos } - - /// returns the visual pos of the **next** grapheme that will be yielded - pub fn visual_pos(&self) -> Position { + /// returns the visual position at the end of the last yielded grapheme + pub fn next_visual_pos(&self) -> Position { self.visual_pos } } impl<'t> Iterator for DocumentFormatter<'t> { - type Item = (FormattedGrapheme<'t>, Position); + type Item = FormattedGrapheme<'t>; fn next(&mut self) -> Option { let grapheme = if self.text_fmt.soft_wrap { @@ -362,23 +447,40 @@ impl<'t> Iterator for DocumentFormatter<'t> { } let grapheme = replace( self.word_buf.get_mut(self.word_i)?, - FormattedGrapheme::placeholder(), + GraphemeWithSource::placeholder(), ); self.word_i += 1; grapheme } else { - self.advance_grapheme(self.visual_pos.col)? + self.advance_grapheme(self.visual_pos.col, self.char_pos)? + }; + + let grapheme = FormattedGrapheme { + raw: grapheme.grapheme, + source: grapheme.source, + visual_pos: self.visual_pos, + line_idx: self.line_pos, + char_idx: self.char_pos, }; - let pos = self.visual_pos; - if grapheme.grapheme == Grapheme::Newline { - self.visual_pos.row += 1; - self.visual_pos.row += take(&mut self.virtual_lines); + self.char_pos += grapheme.doc_chars(); + if !grapheme.is_virtual() { + self.annotations.process_virtual_text_anchors(&grapheme); + } + if grapheme.raw == Grapheme::Newline { + let virtual_lines = self.annotations.virtual_lines_at( + self.char_pos, + self.visual_pos.row, + self.line_pos, + ); + self.visual_pos.row += 1 + virtual_lines; self.visual_pos.col = 0; - self.line_pos += 1; + if !grapheme.is_virtual() { + self.line_pos += 1; + } } else { self.visual_pos.col += grapheme.width(); } - Some((grapheme, pos)) + Some(grapheme) } } diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index ac8918bb71ea0..415ce8f6a131b 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -1,5 +1,3 @@ -use std::rc::Rc; - use crate::doc_formatter::{DocumentFormatter, TextFormat}; use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations}; @@ -14,6 +12,7 @@ impl TextFormat { wrap_indicator_highlight: None, // use a prime number to allow lining up too often with repeat viewport_width: 17, + soft_wrap_at_text_width: false, } } } @@ -23,20 +22,23 @@ impl<'t> DocumentFormatter<'t> { use std::fmt::Write; let mut res = String::new(); let viewport_width = self.text_fmt.viewport_width; + let soft_wrap_at_text_width = self.text_fmt.soft_wrap_at_text_width; let mut line = 0; - for (grapheme, pos) in self { - if pos.row != line { + for grapheme in self { + if grapheme.visual_pos.row != line { line += 1; - assert_eq!(pos.row, line); - write!(res, "\n{}", ".".repeat(pos.col)).unwrap(); + assert_eq!(grapheme.visual_pos.row, line); + write!(res, "\n{}", ".".repeat(grapheme.visual_pos.col)).unwrap(); + } + if !soft_wrap_at_text_width { assert!( - pos.col <= viewport_width as usize, + grapheme.visual_pos.col <= viewport_width as usize, "softwrapped failed {}<={viewport_width}", - pos.col + grapheme.visual_pos.col ); } - write!(res, "{}", grapheme.grapheme).unwrap(); + write!(res, "{}", grapheme.raw).unwrap(); } res @@ -50,7 +52,6 @@ fn softwrap_text(text: &str) -> String { &TextAnnotations::default(), 0, ) - .0 .collect_to_str() } @@ -101,14 +102,29 @@ fn long_word_softwrap() { ); } +fn softwrap_text_at_text_width(text: &str) -> String { + let mut text_fmt = TextFormat::new_test(true); + text_fmt.soft_wrap_at_text_width = true; + let annotations = TextAnnotations::default(); + let mut formatter = + DocumentFormatter::new_at_prev_checkpoint(text.into(), &text_fmt, &annotations, 0); + formatter.collect_to_str() +} +#[test] +fn long_word_softwrap_text_width() { + assert_eq!( + softwrap_text_at_text_width("xxxxxxxx1xxxx2xxx\nxxxxxxxx1xxxx2xxx"), + "xxxxxxxx1xxxx2xxx \nxxxxxxxx1xxxx2xxx " + ); +} + fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay]) -> String { DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_overlay(overlays.into(), None), + TextAnnotations::default().add_overlay(overlays, None), char_pos, ) - .0 .collect_to_str() } @@ -142,10 +158,9 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_inline_annotations(annotations.into(), None), + TextAnnotations::default().add_inline_annotations(annotations, None), 0, ) - .0 .collect_to_str() } @@ -164,18 +179,26 @@ fn annotation() { "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " ); } + #[test] fn annotation_and_overlay() { + let annotations = [InlineAnnotation { + char_idx: 0, + text: "fooo".into(), + }]; + let overlay = [Overlay { + char_idx: 0, + grapheme: "\t".into(), + }]; assert_eq!( DocumentFormatter::new_at_prev_checkpoint( "bbar".into(), &TextFormat::new_test(false), TextAnnotations::default() - .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None) - .add_overlay(Rc::new([Overlay::new(0, "\t")]), None), + .add_inline_annotations(annotations.as_slice(), None) + .add_overlay(overlay.as_slice(), None), 0, ) - .0 .collect_to_str(), "fooo bar " ); diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 15ef3eb043e81..fca19ca2cf1ab 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -28,6 +28,11 @@ pub enum Grapheme<'a> { } impl<'a> Grapheme<'a> { + pub fn new_decoration(g: &'static str) -> Grapheme<'a> { + assert_ne!(g, "\t"); + Grapheme::new(g.into(), 0, 0) + } + pub fn new(g: GraphemeStr<'a>, visual_x: usize, tab_width: u16) -> Grapheme<'a> { match g { g if g == "\t" => Grapheme::Tab { diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index b67e2c8a38e23..528e38a690e55 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -54,8 +54,8 @@ pub use {regex, tree_sitter}; pub use graphemes::RopeGraphemes; pub use position::{ - char_idx_at_visual_offset, coords_at_pos, pos_at_coords, visual_offset_from_anchor, - visual_offset_from_block, Position, VisualOffsetError, + char_idx_at_visual_offset, coords_at_pos, pos_at_coords, softwrapped_dimensions, + visual_offset_from_anchor, visual_offset_from_block, Position, VisualOffsetError, }; #[allow(deprecated)] pub use position::{pos_at_visual_coords, visual_coords_at_pos}; diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index ee764bc64aa73..96b934e57a6d3 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -121,16 +121,14 @@ pub fn visual_offset_from_block( annotations: &TextAnnotations, ) -> (Position, usize) { let mut last_pos = Position::default(); - let (formatter, block_start) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut char_pos = block_start; + let block_start = formatter.next_char_pos(); - for (grapheme, vpos) in formatter { - last_pos = vpos; - char_pos += grapheme.doc_chars(); - - if char_pos > pos { - return (last_pos, block_start); + while let Some(grapheme) = formatter.next() { + last_pos = grapheme.visual_pos; + if formatter.next_char_pos() > pos { + return (grapheme.visual_pos, block_start); } } @@ -143,6 +141,17 @@ pub enum VisualOffsetError { PosAfterMaxRow, } +/// Returns the height of the given text when softwrapping +pub fn softwrapped_dimensions(text: RopeSlice, text_fmt: &TextFormat) -> (usize, u16) { + let last_pos = + visual_offset_from_block(text, 0, usize::MAX, text_fmt, &TextAnnotations::default()).0; + if last_pos.row == 0 { + (1, last_pos.col as u16) + } else { + (last_pos.row + 1, text_fmt.viewport_width) + } +} + /// Returns the visual offset from the start of the visual line /// that contains anchor. pub fn visual_offset_from_anchor( @@ -153,22 +162,21 @@ pub fn visual_offset_from_anchor( annotations: &TextAnnotations, max_rows: usize, ) -> Result<(Position, usize), VisualOffsetError> { - let (formatter, block_start) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut char_pos = block_start; let mut anchor_line = None; let mut found_pos = None; let mut last_pos = Position::default(); + let block_start = formatter.next_char_pos(); if pos < block_start { return Err(VisualOffsetError::PosBeforeAnchorRow); } - for (grapheme, vpos) in formatter { - last_pos = vpos; - char_pos += grapheme.doc_chars(); + while let Some(grapheme) = formatter.next() { + last_pos = grapheme.visual_pos; - if char_pos > pos { + if formatter.next_char_pos() > pos { if let Some(anchor_line) = anchor_line { last_pos.row -= anchor_line; return Ok((last_pos, block_start)); @@ -176,7 +184,7 @@ pub fn visual_offset_from_anchor( found_pos = Some(last_pos); } } - if char_pos > anchor && anchor_line.is_none() { + if formatter.next_char_pos() > anchor && anchor_line.is_none() { if let Some(mut found_pos) = found_pos { return if found_pos.row == last_pos.row { found_pos.row = 0; @@ -190,7 +198,7 @@ pub fn visual_offset_from_anchor( } if let Some(anchor_line) = anchor_line { - if vpos.row >= anchor_line + max_rows { + if grapheme.visual_pos.row >= anchor_line + max_rows { return Err(VisualOffsetError::PosAfterMaxRow); } } @@ -368,34 +376,33 @@ pub fn char_idx_at_visual_block_offset( text_fmt: &TextFormat, annotations: &TextAnnotations, ) -> (usize, usize) { - let (formatter, mut char_idx) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut last_char_idx = char_idx; + let mut last_char_idx = formatter.next_char_pos(); let mut last_char_idx_on_line = None; let mut last_row = 0; - for (grapheme, grapheme_pos) in formatter { - match grapheme_pos.row.cmp(&row) { + for grapheme in &mut formatter { + match grapheme.visual_pos.row.cmp(&row) { Ordering::Equal => { - if grapheme_pos.col + grapheme.width() > column { + if grapheme.visual_pos.col + grapheme.width() > column { if !grapheme.is_virtual() { - return (char_idx, 0); + return (grapheme.char_idx, 0); } else if let Some(char_idx) = last_char_idx_on_line { return (char_idx, 0); } } else if !grapheme.is_virtual() { - last_char_idx_on_line = Some(char_idx) + last_char_idx_on_line = Some(grapheme.char_idx) } } Ordering::Greater => return (last_char_idx, row - last_row), _ => (), } - last_char_idx = char_idx; - last_row = grapheme_pos.row; - char_idx += grapheme.doc_chars(); + last_char_idx = grapheme.char_idx; + last_row = grapheme.visual_pos.row; } - (char_idx, 0) + (formatter.next_char_pos(), 0) } #[cfg(test)] diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 11d19d4856425..0c24687da0938 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -1,7 +1,10 @@ use std::cell::Cell; +use std::cmp::Ordering; +use std::fmt::Debug; use std::ops::Range; -use std::rc::Rc; +use std::ptr::NonNull; +use crate::doc_formatter::FormattedGrapheme; use crate::syntax::Highlight; use crate::Tendril; @@ -76,39 +79,118 @@ impl Overlay { } } -/// Line annotations allow for virtual text between normal -/// text lines. They cause `height` empty lines to be inserted -/// below the document line that contains `anchor_char_idx`. -/// -/// These lines can be filled with text in the rendering code +/// Line annotations allow inserting virtual text lines between normal +/// text lines. These lines can be filled with text in the rendering code /// as their contents have no effect beyond visual appearance. /// -/// To insert a line after a document line simply set -/// `anchor_char_idx` to `doc.line_to_char(line_idx)` -#[derive(Debug, Clone)] -pub struct LineAnnotation { - pub anchor_char_idx: usize, - pub height: usize, +/// The height of virtual text is usually not know ahead of time +/// as virtual text often requires softwrapping. Furthermore the +/// height of some virtual text like some virtual text like side-by-side +/// diffs depends on the height of the text (again influcend by softwrap) +/// and other virtual text Therefore line annotations are computed +/// on the fly instead of ahead of time like other annotations. +/// +/// The core of this trait `insert_virtual_lines` function. It is called at +/// the end of every visual line and allows the `LineAnnotation` to insert +/// empty virtual lines. Apart from that the `LineAnnotation` trait has +/// multiple methods that allow it to track anchors in the document. +/// +/// When a new transerversal of a document starts `reset_pos` is called. +/// Afterwards the other functions are called with indecies that are larger +/// then the one passed to `reset_pos`. This allows performing a binary search (use `partition_point`) +/// in `reset_pos` once and then to only look at the next anchor during each +/// method call. +/// +/// The `reset_pos`, `skip_conceal` and `process_anchor` functions return a `char_idx` +/// anchor. This anchor is stored when transvering the document and when the grapheme at +/// the anchor is transvered the `process_anchor` function is called. +/// +/// # Note +/// +/// All functions only recive immutable reference to `self`. +/// LineAnnotations that want to store an internal position or +/// state of somekind should use `Cell`. Using interior mutability for +/// caches is actually preffered as otherwise a lot of lieftimes would +/// become invariant which can cause weird lifetime issues and unwieldy API. +pub trait LineAnnotation { + /// Resets the internal position to `char_idx`. This functions is called + //// when a new transveral of a document starts. + /// + /// All `char_idx` passed to `insert_virtual_lines` are strictly monotonically increasing + /// with the first `char_idx` greater or equal to the `char_idx` + /// passed to this function. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn reset_pos(&mut self, _char_idx: usize) -> usize { + usize::MAX + } + + /// Called when a text is concealed that contains an anchor registered by this `LineAnnotation` + ///. In this case the line decorations **must** ensure that virtual text anchored within that + /// char range is skipped. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// **after the end of conceal_end_char_idx** + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn skip_concealed_anchors(&mut self, conceal_end_char_idx: usize) -> usize { + self.reset_pos(conceal_end_char_idx) + } + + /// Process an anchor (horizontal position is provided) and returns the next anchor. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn process_anchor(&mut self, _grapheme: &FormattedGrapheme) -> usize { + usize::MAX + } + + /// This function is called at the end of a visual line to insert virtual text + /// + /// # Returns + /// + /// The number of additional virtual lines to reserve + /// + /// # Note + /// + /// The `vertical_off` paramater indiciates the visual vertical distance + /// from the block where the transversal start. This includes the offset + /// from other `LineAnnotations`. This allows inline annotations to consider + /// the height of the text and "align" two different documents (like for side + /// by side diffs). These annotations that want to "align" two documents should + /// therefore be added last so that other virtual text is also considered while aligning + fn insert_virtual_lines( + &mut self, + line_end_char_idx: usize, + vertical_off: usize, + doc_line: usize, + ) -> usize; } #[derive(Debug)] -struct Layer { - annotations: Rc<[A]>, +struct Layer<'a, A, M> { + annotations: &'a [A], current_index: Cell, metadata: M, } -impl Clone for Layer { +impl Clone for Layer<'_, A, M> { fn clone(&self) -> Self { Layer { - annotations: self.annotations.clone(), + annotations: self.annotations, current_index: self.current_index.clone(), metadata: self.metadata.clone(), } } } -impl Layer { +impl Layer<'_, A, M> { pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) { let new_index = self .annotations @@ -128,8 +210,8 @@ impl Layer { } } -impl From<(Rc<[A]>, M)> for Layer { - fn from((annotations, metadata): (Rc<[A]>, M)) -> Layer { +impl<'a, A, M> From<(&'a [A], M)> for Layer<'a, A, M> { + fn from((annotations, metadata): (&'a [A], M)) -> Layer { Layer { annotations, current_index: Cell::new(0), @@ -144,23 +226,77 @@ fn reset_pos(layers: &[Layer], pos: usize, get_pos: impl Fn(&A) -> u } } +/// Safety: We store LineAnnotation in a NonNull pointer. This is necessary to work +/// around an unfortunate inconsistency in rusts variance system that unnnecesarily +/// makes the lifetime invariant if implemented with safe code. This makes the +/// DocFormatter API very cumbersome/basically impossible to work with. +/// +/// Normally object types dyn Foo + 'a so if we used Box below +/// everything would be alright. However we want to use `Cell>` +/// to be able to call the mutable function on `LineAnnotation`. The problem is that +/// some types like `Cell` make all their arguments invariant. This is important for soundness +/// normally for the same reasons that &'a mut T is invariant over T +/// (see ). However for &'a mut (dyn Foo + 'b) +/// there is a specical rule in the language to make 'b covariant (otherwise trait objects would be +/// super annoying to use). See for +/// why this is sound. Sadly that rule doesn't apply to Cell +/// (or other invariant types like `UnsafeCell` or `*mut (dyn Foo + 'a)`). +/// +/// We sidestep the problem by using `NonNull` which is covariant. In the specical case +/// of trait objects this is sound (easily checked by adding a `PhantomData<&'a mut Foo + 'a)>` field). +/// We don't need an explicit Cell type here because we never hand out any refereces +/// to the trait objects so even without guarding mutable access to the pointer data behind a `` +/// are covariant over 'a (or in other words it's a raw pointer, as long as we don't hand out +/// references we are free to do whatever we want). +struct RawBox(NonNull); + +impl RawBox { + /// Safety: Only a single mutable reference + /// created by this function may exist at a given time. + #[allow(clippy::mut_from_ref)] + unsafe fn get(&self) -> &mut T { + &mut *self.0.as_ptr() + } +} +impl From> for RawBox { + fn from(box_: Box) -> Self { + // obviously safe because Box::into_raw never returns null + unsafe { Self(NonNull::new_unchecked(Box::into_raw(box_))) } + } +} + +impl Drop for RawBox { + fn drop(&mut self) { + unsafe { drop(Box::from_raw(self.0.as_ptr())) } + } +} + /// Annotations that change that is displayed when the document is render. /// Also commonly called virtual text. -#[derive(Default, Debug, Clone)] -pub struct TextAnnotations { - inline_annotations: Vec>>, - overlays: Vec>>, - line_annotations: Vec>, +#[derive(Default)] +pub struct TextAnnotations<'a> { + inline_annotations: Vec>>, + overlays: Vec>>, + line_annotations: Vec<(Cell, RawBox)>, +} + +impl Debug for TextAnnotations<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TextAnnotations") + .field("inline_annotations", &self.inline_annotations) + .field("overlays", &self.overlays) + .finish_non_exhaustive() + } } -impl TextAnnotations { +impl<'a> TextAnnotations<'a> { /// Prepare the TextAnnotations for iteration starting at char_idx pub fn reset_pos(&self, char_idx: usize) { reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); reset_pos(&self.overlays, char_idx, |annot| annot.char_idx); - reset_pos(&self.line_annotations, char_idx, |annot| { - annot.anchor_char_idx - }); + for (next_anchor, layer) in &self.line_annotations { + next_anchor.set(unsafe { layer.get().reset_pos(char_idx) }); + } } pub fn collect_overlay_highlights( @@ -194,7 +330,7 @@ impl TextAnnotations { /// the annotations that belong to the layers added first will be shown first. pub fn add_inline_annotations( &mut self, - layer: Rc<[InlineAnnotation]>, + layer: &'a [InlineAnnotation], highlight: Option, ) -> &mut Self { self.inline_annotations.push((layer, highlight).into()); @@ -211,7 +347,7 @@ impl TextAnnotations { /// /// If multiple layers contain overlay at the same position /// the overlay from the layer added last will be show. - pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option) -> &mut Self { + pub fn add_overlay(&mut self, layer: &'a [Overlay], highlight: Option) -> &mut Self { self.overlays.push((layer, highlight).into()); self } @@ -220,8 +356,9 @@ impl TextAnnotations { /// /// The line annotations **must be sorted** by their `char_idx`. /// Multiple line annotations with the same `char_idx` **are not allowed**. - pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self { - self.line_annotations.push((layer, ()).into()); + pub fn add_line_annotation(&mut self, layer: Box) -> &mut Self { + self.line_annotations + .push((Cell::new(usize::MAX), layer.into())); self } @@ -251,21 +388,35 @@ impl TextAnnotations { overlay } - pub(crate) fn annotation_lines_at(&self, char_idx: usize) -> usize { - self.line_annotations - .iter() - .map(|layer| { - let mut lines = 0; - while let Some(annot) = layer.annotations.get(layer.current_index.get()) { - if annot.anchor_char_idx == char_idx { - layer.current_index.set(layer.current_index.get() + 1); - lines += annot.height - } else { - break; + pub(crate) fn process_virtual_text_anchors(&self, grapheme: &FormattedGrapheme) { + for (next_anchor, layer) in &self.line_annotations { + loop { + match next_anchor.get().cmp(&grapheme.char_idx) { + Ordering::Less => next_anchor + .set(unsafe { layer.get().skip_concealed_anchors(grapheme.char_idx) }), + Ordering::Equal => { + next_anchor.set(unsafe { layer.get().process_anchor(grapheme) }) } - } - lines - }) - .sum() + Ordering::Greater => break, + }; + } + } + } + + pub(crate) fn virtual_lines_at( + &self, + char_idx: usize, + vertical_off: usize, + doc_line: usize, + ) -> usize { + let mut virtual_lines = 0; + for (_, layer) in &self.line_annotations { + virtual_lines += unsafe { + layer + .get() + .insert_virtual_lines(char_idx, vertical_off + virtual_lines, doc_line) + }; + } + virtual_lines } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 95310c1fec718..ee7bc79ddf447 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -587,6 +587,7 @@ fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movem &mut annotations, ) }); + drop(annotations); doc.set_selection(view.id, selection); } @@ -1479,7 +1480,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let doc_text = doc.text().slice(..); let viewport = view.inner_area(doc); let text_fmt = doc.text_format(viewport.width, None); - let annotations = view.text_annotations(doc, None); + let annotations = view.text_annotations(&*doc, None); (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( doc_text, view.offset.anchor, @@ -1533,6 +1534,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let mut sel = doc.selection(view.id).clone(); let idx = sel.primary_index(); sel = sel.replace(idx, prim_sel); + drop(annotations); doc.set_selection(view.id, sel); } diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index 80da1c5427b74..0875f3cc301e5 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -11,26 +11,10 @@ use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; use helix_view::graphics::Rect; use helix_view::theme::Style; use helix_view::view::ViewPosition; -use helix_view::Document; -use helix_view::Theme; +use helix_view::{Document, Theme}; use tui::buffer::Buffer as Surface; -pub trait LineDecoration { - fn render_background(&mut self, _renderer: &mut TextRenderer, _pos: LinePos) {} - fn render_foreground( - &mut self, - _renderer: &mut TextRenderer, - _pos: LinePos, - _end_char_idx: usize, - ) { - } -} - -impl LineDecoration for F { - fn render_background(&mut self, renderer: &mut TextRenderer, pos: LinePos) { - self(renderer, pos) - } -} +use crate::ui::text_decorations::DecorationManager; /// A wrapper around a HighlightIterator /// that merges the layered highlights to create the final text style @@ -54,10 +38,7 @@ impl> Iterator for StyleIter<'_, H> { HighlightEvent::HighlightEnd => { self.active_highlights.pop(); } - HighlightEvent::Source { start, end } => { - if start == end { - continue; - } + HighlightEvent::Source { end, .. } => { let style = self .active_highlights .iter() @@ -81,15 +62,8 @@ pub struct LinePos { pub doc_line: usize, /// Vertical offset from the top of the inner view area pub visual_line: u16, - /// The first char index of this visual line. - /// Note that if the visual line is entirely filled by - /// a very long inline virtual text then this index will point - /// at the next (non-virtual) char after this visual line - pub start_char_idx: usize, } -pub type TranslatedPosition<'a> = (usize, Box); - #[allow(clippy::too_many_arguments)] pub fn render_document( surface: &mut Surface, @@ -99,8 +73,7 @@ pub fn render_document( doc_annotations: &TextAnnotations, highlight_iter: impl Iterator, theme: &Theme, - line_decoration: &mut [Box], - translated_positions: &mut [TranslatedPosition], + decorations: DecorationManager, ) { let mut renderer = TextRenderer::new(surface, doc, theme, offset.horizontal_offset, viewport); render_text( @@ -111,44 +84,43 @@ pub fn render_document( doc_annotations, highlight_iter, theme, - line_decoration, - translated_positions, + decorations, ) } -fn translate_positions( - char_pos: usize, - first_visible_char_idx: usize, - translated_positions: &mut [TranslatedPosition], - text_fmt: &TextFormat, - renderer: &mut TextRenderer, - pos: Position, -) { - // check if any positions translated on the fly (like cursor) has been reached - for (char_idx, callback) in &mut *translated_positions { - if *char_idx < char_pos && *char_idx >= first_visible_char_idx { - // by replacing the char_index with usize::MAX large number we ensure - // that the same position is only translated once - // text will never reach usize::MAX as rust memory allocations are limited - // to isize::MAX - *char_idx = usize::MAX; - - if text_fmt.soft_wrap { - callback(renderer, pos) - } else if pos.col >= renderer.col_offset - && pos.col - renderer.col_offset < renderer.viewport.width as usize - { - callback( - renderer, - Position { - row: pos.row, - col: pos.col - renderer.col_offset, - }, - ) - } - } - } -} +// fn translate_positions( +// char_pos: usize, +// first_visible_char_idx: usize, +// translated_positions: &mut [TranslatedPosition], +// text_fmt: &TextFormat, +// renderer: &mut TextRenderer, +// pos: Position, +// ) { +// // check if any positions translated on the fly (like cursor) has been reached +// for (char_idx, callback) in &mut *translated_positions { +// if *char_idx < char_pos && *char_idx >= first_visible_char_idx { +// // by replacing the char_index with usize::MAX large number we ensure +// // that the same position is only translated once +// // text will never reach usize::MAX as rust memory allocations are limited +// // to isize::MAX +// *char_idx = usize::MAX; + +// if text_fmt.soft_wrap { +// callback(renderer, pos) +// } else if pos.col >= renderer.col_offset +// && pos.col - renderer.col_offset < renderer.viewport.width as usize +// { +// callback( +// renderer, +// Position { +// row: pos.row, +// col: pos.col - renderer.col_offset, +// }, +// ) +// } +// } +// } +// } #[allow(clippy::too_many_arguments)] pub fn render_text<'t>( @@ -159,24 +131,20 @@ pub fn render_text<'t>( text_annotations: &TextAnnotations, highlight_iter: impl Iterator, theme: &Theme, - line_decorations: &mut [Box], - translated_positions: &mut [TranslatedPosition], + mut decorations: DecorationManager, ) { - let ( - Position { - row: mut row_off, .. - }, - mut char_pos, - ) = visual_offset_from_block( + let mut row_off = visual_offset_from_block( text, offset.anchor, offset.anchor, text_fmt, text_annotations, - ); + ) + .0 + .row; row_off += offset.vertical_offset; - let (mut formatter, mut first_visible_char_idx) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor); let mut styles = StyleIter { text_style: renderer.text_style, @@ -184,96 +152,67 @@ pub fn render_text<'t>( highlight_iter, theme, }; - let mut last_line_pos = LinePos { first_visual_line: false, doc_line: usize::MAX, visual_line: u16::MAX, - start_char_idx: usize::MAX, }; let mut is_in_indent_area = true; let mut last_line_indent_level = 0; let mut style_span = styles .next() .unwrap_or_else(|| (Style::default(), usize::MAX)); + let mut reached_view_top = false; loop { - // formattter.line_pos returns to line index of the next grapheme - // so it must be called before formatter.next - let doc_line = formatter.line_pos(); - let Some((grapheme, mut pos)) = formatter.next() else { - let mut last_pos = formatter.visual_pos(); - if last_pos.row >= row_off { - last_pos.col -= 1; - last_pos.row -= row_off; - // check if any positions translated on the fly (like cursor) are at the EOF - translate_positions( - char_pos + 1, - first_visible_char_idx, - translated_positions, - text_fmt, - renderer, - last_pos, - ); - } - break; - }; + let Some(mut grapheme) = formatter.next() else { break }; // skip any graphemes on visual lines before the block start - if pos.row < row_off { - if char_pos >= style_span.1 { + if grapheme.visual_pos.row < row_off { + if grapheme.char_idx >= style_span.1 { style_span = if let Some(style_span) = styles.next() { style_span } else { break; } } - char_pos += grapheme.doc_chars(); - first_visible_char_idx = char_pos + 1; continue; } - pos.row -= row_off; + grapheme.visual_pos.row -= row_off; + if !reached_view_top { + decorations.prepare_for_rendering(grapheme.char_idx); + reached_view_top = true; + } // if the end of the viewport is reached stop rendering - if pos.row as u16 >= renderer.viewport.height { + if grapheme.visual_pos.row as u16 >= renderer.viewport.height { break; } // apply decorations before rendering a new line - if pos.row as u16 != last_line_pos.visual_line { - if pos.row > 0 { + if grapheme.visual_pos.row as u16 != last_line_pos.visual_line { + // we initiate doc_line with usize::MAX because no file + // can reach that size (memory allocations are limited to isize::MAX) + // initially there is no "previous" line (so doc_line is set to usize::MAX) + // in that case we don't need to draw indent guides/virtual text + if last_line_pos.doc_line != usize::MAX { + // draw indent guides for the last line renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); is_in_indent_area = true; - for line_decoration in &mut *line_decorations { - line_decoration.render_foreground(renderer, last_line_pos, char_pos); - } + decorations.render_virtual_lines(renderer, last_line_pos) } last_line_pos = LinePos { - first_visual_line: doc_line != last_line_pos.doc_line, - doc_line, - visual_line: pos.row as u16, - start_char_idx: char_pos, + first_visual_line: grapheme.line_idx != last_line_pos.doc_line, + doc_line: grapheme.line_idx, + visual_line: grapheme.visual_pos.row as u16, }; - for line_decoration in &mut *line_decorations { - line_decoration.render_background(renderer, last_line_pos); - } + decorations.decorate_line(renderer, last_line_pos); } - // acquire the correct grapheme style - if char_pos >= style_span.1 { + // aquire the correct grapheme style + while grapheme.char_idx >= style_span.1 { style_span = styles.next().unwrap_or((Style::default(), usize::MAX)); } - char_pos += grapheme.doc_chars(); - - // check if any positions translated on the fly (like cursor) has been reached - translate_positions( - char_pos, - first_visible_char_idx, - translated_positions, - text_fmt, - renderer, - pos, - ); let grapheme_style = if let GraphemeSource::VirtualText { highlight } = grapheme.source { let style = renderer.text_style; @@ -285,22 +224,21 @@ pub fn render_text<'t>( } else { style_span.0 }; + decorations.decorate_grapheme(renderer, &grapheme); let virt = grapheme.is_virtual(); renderer.draw_grapheme( - grapheme.grapheme, + grapheme.raw, grapheme_style, virt, &mut last_line_indent_level, &mut is_in_indent_area, - pos, + grapheme.visual_pos, ); } renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); - for line_decoration in &mut *line_decorations { - line_decoration.render_foreground(renderer, last_line_pos, char_pos); - } + decorations.render_virtual_lines(renderer, last_line_pos) } #[derive(Debug)] @@ -390,6 +328,42 @@ impl<'a> TextRenderer<'a> { col_offset, } } + /// Draws a single `grapheme` at the current render position with a specified `style`. + pub fn draw_decoration_grapheme( + &mut self, + grapheme: Grapheme, + mut style: Style, + row: u16, + col: u16, + ) -> bool { + if row >= self.viewport.height || col >= self.viewport.width { + return false; + } + let is_whitespace = grapheme.is_whitespace(); + + // TODO is it correct to apply the whitspace style to all unicode white spaces? + if is_whitespace { + style = style.patch(self.whitespace_style); + } + + let grapheme = match grapheme { + Grapheme::Tab { width } => { + let grapheme_tab_width = char_to_byte_idx(&self.virtual_tab, width); + &self.virtual_tab[..grapheme_tab_width] + } + Grapheme::Other { ref g } if g == "\u{00A0}" => " ", + Grapheme::Other { ref g } => g, + Grapheme::Newline => " ", + }; + + self.surface.set_string( + self.viewport.x + col, + self.viewport.y + row as u16, + grapheme, + style, + ); + true + } /// Draws a single `grapheme` at the current render position with a specified `style`. pub fn draw_grapheme( @@ -429,8 +403,7 @@ impl<'a> TextRenderer<'a> { Grapheme::Newline => &self.newline, }; - let in_bounds = self.col_offset <= position.col - && position.col < self.viewport.width as usize + self.col_offset; + let in_bounds = self.column_in_bounds(position.col); if in_bounds { self.surface.set_string( @@ -456,6 +429,10 @@ impl<'a> TextRenderer<'a> { } } + pub fn column_in_bounds(&self, colum: usize) -> bool { + self.col_offset <= colum && colum < self.viewport.width as usize + self.col_offset + } + /// Overlay indentation guides ontop of a rendered line /// The indentation level is computed in `draw_lines`. /// Therefore this function must always be called afterwards. diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index fd8e8fb21b471..3494da07d67f0 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -5,7 +5,8 @@ use crate::{ key, keymap::{KeymapResult, Keymaps}, ui::{ - document::{render_document, LinePos, TextRenderer, TranslatedPosition}, + document::{render_document, LinePos, TextRenderer}, + text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics}, Completion, ProgressSpinners, }, }; @@ -33,8 +34,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; use tui::{buffer::Buffer as Surface, text::Span}; +use super::lsp::SignatureHelp; use super::statusline; -use super::{document::LineDecoration, lsp::SignatureHelp}; pub struct EditorView { pub keymaps: Keymaps, @@ -90,11 +91,10 @@ impl EditorView { let config = editor.config(); let text_annotations = view.text_annotations(doc, Some(theme)); - let mut line_decorations: Vec> = Vec::new(); - let mut translated_positions: Vec = Vec::new(); + let mut decorations = DecorationManager::default(); if is_focused && config.cursorline { - line_decorations.push(Self::cursorline_decorator(doc, view, theme)) + decorations.add_decoration(Self::cursorline(doc, view, theme)); } if is_focused && config.cursorcolumn { @@ -115,7 +115,7 @@ impl EditorView { ); }; - line_decorations.push(Box::new(line_decoration)); + decorations.add_decoration(line_decoration); } let mut highlights = @@ -167,21 +167,26 @@ impl EditorView { view.area, theme, is_focused, - &mut line_decorations, + &mut decorations, ); - + let primary_cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); if is_focused { - let cursor = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - // set the cursor_cache to out of view in case the position is not found - editor.cursor_cache.set(Some(None)); - let update_cursor_cache = - |_: &mut TextRenderer, pos| editor.cursor_cache.set(Some(Some(pos))); - translated_positions.push((cursor, Box::new(update_cursor_cache))); + decorations.add_decoration(text_decorations::Cursor { + cache: &editor.cursor_cache, + primary_cursor, + }); + } + if config.lsp.inline_diagnostics.enable(inner.width) { + decorations.add_decoration(InlineDiagnostics::new( + doc.diagnostics(), + theme, + primary_cursor, + config.lsp.inline_diagnostics.clone(), + )); } - render_document( surface, inner, @@ -190,8 +195,7 @@ impl EditorView { &text_annotations, highlights, theme, - &mut line_decorations, - &mut translated_positions, + decorations, ); Self::render_rulers(editor, doc, view, inner, surface, theme); @@ -207,7 +211,9 @@ impl EditorView { } } - Self::render_diagnostics(doc, view, inner, surface, theme); + if config.lsp.display_diagnostic_message { + Self::render_diagnostics(doc, view, inner, surface, theme); + } let statusline_area = view .area @@ -568,7 +574,7 @@ impl EditorView { viewport: Rect, theme: &Theme, is_focused: bool, - line_decorations: &mut Vec>, + decoration_manager: &mut DecorationManager<'d>, ) { let text = doc.text().slice(..); let cursors: Rc<[_]> = doc @@ -622,7 +628,7 @@ impl EditorView { } text.clear(); }; - line_decorations.push(Box::new(gutter_decoration)); + decoration_manager.add_decoration(gutter_decoration); offset += width as u16; } @@ -691,11 +697,7 @@ impl EditorView { } /// Apply the highlighting on the lines where a cursor is active - pub fn cursorline_decorator( - doc: &Document, - view: &View, - theme: &Theme, - ) -> Box { + pub fn cursorline(doc: &Document, view: &View, theme: &Theme) -> impl Decoration { let text = doc.text().slice(..); // TODO only highlight the visual line that contains the cursor instead of the full visual line let primary_line = doc.selection(view.id).primary().cursor_line(text); @@ -716,16 +718,14 @@ impl EditorView { let secondary_style = theme.get("ui.cursorline.secondary"); let viewport = view.area; - let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + move |renderer: &mut TextRenderer, pos: LinePos| { let area = Rect::new(viewport.x, viewport.y + pos.visual_line, viewport.width, 1); if primary_line == pos.doc_line { renderer.surface.set_style(area, primary_style); } else if secondary_lines.binary_search(&pos.doc_line).is_ok() { renderer.surface.set_style(area, secondary_style); } - }; - - Box::new(line_decoration) + } } /// Apply the highlighting on the columns where a cursor is active diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 3e9a14b063071..3d375ca56ed54 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -13,6 +13,7 @@ mod prompt; mod spinner; mod statusline; mod text; +mod text_decorations; use crate::compositor::{Component, Compositor}; use crate::filter_picker_entry; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index e7a7de9095db4..1b738b189175a 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -4,8 +4,9 @@ use crate::{ ctrl, key, shift, ui::{ self, - document::{render_document, LineDecoration, LinePos, TextRenderer}, + document::{render_document, LinePos, TextRenderer}, fuzzy_match::FuzzyQuery, + text_decorations::DecorationManager, EditorView, }, }; @@ -315,7 +316,7 @@ impl Component for FilePicker { } highlights = Box::new(helix_core::syntax::merge(highlights, spans)); } - let mut decorations: Vec> = Vec::new(); + let mut decorations = DecorationManager::default(); if let Some((start, end)) = range { let style = cx @@ -334,7 +335,7 @@ impl Component for FilePicker { renderer.surface.set_style(area, style) } }; - decorations.push(Box::new(draw_highlight)) + decorations.add_decoration(draw_highlight); } render_document( @@ -346,8 +347,7 @@ impl Component for FilePicker { &TextAnnotations::default(), highlights, &cx.editor.theme, - &mut decorations, - &mut [], + decorations, ); } } diff --git a/helix-term/src/ui/text_decorations.rs b/helix-term/src/ui/text_decorations.rs new file mode 100644 index 0000000000000..06682b8bbbfdb --- /dev/null +++ b/helix-term/src/ui/text_decorations.rs @@ -0,0 +1,169 @@ +use std::cmp::Ordering; + +use helix_core::doc_formatter::FormattedGrapheme; +use helix_view::editor::CursorCache; + +use crate::ui::document::{LinePos, TextRenderer}; + +pub use diagnostics::InlineDiagnostics; + +mod diagnostics; + +/// Decorations are the primary mechanisim for extending the text rendering. +/// +/// Any on-screen element which is anchored to the rendered text in some form should +/// be implemented using this trait. Translating char positions to +/// on-screen positions can be expensive and should not be done during rendering. +/// Instead such translations are performed on the fly while the text is being rendered. +/// The results are provided to this trait +/// +/// To reserve space for virtual text lines (which is then filled by this trait) emit appropriate +/// [`LineAnnotation`](helix_core::text_annotations::LineAnnotation) in [`helix_view::View::text_annotations`] +pub trait Decoration { + /// Called **before** a **visual** line is rendered. A visual line does not + /// necessairly correspond to a single line in a document as soft wrapping can + /// spread a single document line across multiple visual lines. + /// + /// This function is called before text is rendered as any decorations should + /// never overlap the document text. That means that setting the forground color + /// here is (essentially) useless as the text color is overwritten by the + /// rendered text. This -ofcourse- doesn't apply when rendering inside virtual lines + /// below the line reserved by `LineAnnotation`s. e as no text will be rendered here. + fn decorate_line(&mut self, _renderer: &mut TextRenderer, _pos: LinePos) {} + + /// Called **after** a **visual** line is rendered. A visual line does not + /// necessairly correspond to a single line in a document as soft wrapping can + /// spread a single document line across multiple visual lines. + /// + /// This function is called after text is rendered so that decorations can collect + /// horizontal positions on the line (see [`Decoration::decorate_grapheme`]) first and + /// use those positions` while rendering + /// virtual text. + /// That means that setting the forground color + /// here is (essentially) useless as the text color is overwritten by the + /// rendered text. This -ofcourse- doesn't apply when rendering inside virtual lines + /// below the line reserved by `LineAnnotation`s. e as no text will be rendered here. + /// **Note**: To avoid overlapping decorations in the virtual lines, each decoration + /// must return the number of virtual text lines it has taken up. Each `Decoration` recieves + /// an offset `virt_off` based on these return values where it can render virtual text: + /// + /// That means that a `render_line` implementation that returns `X` can render virtual text + /// in the following area: + /// ``` no-compile + /// let start = inner.y + pos.virtual_line + virt_off; + /// start .. start + X + /// ```` + fn render_virt_lines( + &mut self, + _renderer: &mut TextRenderer, + _pos: LinePos, + _virt_off: u16, + ) -> u16 { + 0 + } + + fn reset_pos(&mut self, _pos: usize) -> usize { + usize::MAX + } + + fn skip_concealed_anchor(&mut self, conceal_end_char_idx: usize) -> usize { + self.reset_pos(conceal_end_char_idx) + } + + /// This function is called **before** the grapheme at `char_idx` is rendered. + /// + /// # Returns + /// The next + fn decorate_grapheme( + &mut self, + _renderer: &mut TextRenderer, + _grapheme: &FormattedGrapheme, + ) -> usize { + usize::MAX + } +} + +impl Decoration for F { + fn decorate_line(&mut self, renderer: &mut TextRenderer, pos: LinePos) { + self(renderer, pos); + } +} + +#[derive(Default)] +pub struct DecorationManager<'a> { + decorations: Vec<(Box, usize)>, +} + +impl<'a> DecorationManager<'a> { + pub fn add_decoration(&mut self, decoration: impl Decoration + 'a) { + self.decorations.push((Box::new(decoration), 0)); + } + + pub fn prepare_for_rendering(&mut self, first_visible_char: usize) { + for (decoration, next_position) in &mut self.decorations { + *next_position = decoration.reset_pos(first_visible_char) + } + } + + pub fn decorate_grapheme(&mut self, renderer: &mut TextRenderer, grapheme: &FormattedGrapheme) { + for (decoration, hook_char_idx) in &mut self.decorations { + loop { + match (*hook_char_idx).cmp(&grapheme.char_idx) { + // this grapheme has been concealed or we are at the first grapheme + Ordering::Less => { + *hook_char_idx = decoration.skip_concealed_anchor(grapheme.char_idx) + } + Ordering::Equal => { + *hook_char_idx = decoration.decorate_grapheme(renderer, grapheme) + } + Ordering::Greater => break, + } + } + } + } + + pub fn decorate_line(&mut self, renderer: &mut TextRenderer, pos: LinePos) { + for (decoration, _) in &mut self.decorations { + decoration.decorate_line(renderer, pos); + } + } + + pub fn render_virtual_lines(&mut self, renderer: &mut TextRenderer, pos: LinePos) { + let mut virt_off = 1; // start at 1 so the line is never overwritten + for (decoration, _) in &mut self.decorations { + if pos.visual_line + virt_off >= renderer.viewport.height { + break; + } + virt_off += decoration.render_virt_lines(renderer, pos, virt_off); + } + } +} + +/// Cursor rendering is done externally so all the cursor decoration +/// does is save the position of primary cursor +pub struct Cursor<'a> { + pub cache: &'a CursorCache, + pub primary_cursor: usize, +} +impl Decoration for Cursor<'_> { + fn reset_pos(&mut self, pos: usize) -> usize { + if pos <= self.primary_cursor { + self.primary_cursor + } else { + usize::MAX + } + } + + fn decorate_grapheme( + &mut self, + renderer: &mut TextRenderer, + grapheme: &FormattedGrapheme, + ) -> usize { + if renderer.column_in_bounds(grapheme.visual_pos.col) { + let mut position = grapheme.visual_pos; + position.col -= renderer.col_offset; + self.cache.set(Some(position)); + } + usize::MAX + } +} diff --git a/helix-term/src/ui/text_decorations/diagnostics.rs b/helix-term/src/ui/text_decorations/diagnostics.rs new file mode 100644 index 0000000000000..49d6fdd7f38a7 --- /dev/null +++ b/helix-term/src/ui/text_decorations/diagnostics.rs @@ -0,0 +1,247 @@ +use std::cmp::Ordering; + +use helix_core::diagnostic::Severity; +use helix_core::doc_formatter::{DocumentFormatter, FormattedGrapheme}; +use helix_core::graphemes::Grapheme; +use helix_core::text_annotations::TextAnnotations; +use helix_core::Diagnostic; +use helix_view::annotations::diagnostics::{InlineDiagnosticAccumulator, InlineDiagnosticsConfig}; + +use helix_view::theme::Style; +use helix_view::Theme; + +use crate::ui::document::{LinePos, TextRenderer}; +use crate::ui::text_decorations::Decoration; + +#[derive(Debug)] +struct Styles { + hint: Style, + info: Style, + warning: Style, + error: Style, +} + +impl Styles { + fn new(theme: &Theme) -> Styles { + Styles { + hint: theme.get("hint"), + info: theme.get("info"), + warning: theme.get("warning"), + error: theme.get("error"), + } + } + + fn severity_style(&self, severity: Severity) -> Style { + match severity { + Severity::Hint => self.hint, + Severity::Info => self.info, + Severity::Warning => self.warning, + Severity::Error => self.error, + } + } +} + +pub struct InlineDiagnostics<'a> { + state: InlineDiagnosticAccumulator<'a>, + styles: Styles, +} + +impl<'a> InlineDiagnostics<'a> { + pub fn new( + diagnostics: &'a [Diagnostic], + theme: &Theme, + cursor: usize, + config: InlineDiagnosticsConfig, + ) -> Self { + InlineDiagnostics { + state: InlineDiagnosticAccumulator::new(cursor, diagnostics, config), + styles: Styles::new(theme), + } + } +} + +const BL_CORNER: &str = "┘"; +const TR_CORNER: &str = "┌"; +const BR_CORNER: &str = "└"; +const STACK: &str = "├"; +const MULTI: &str = "┴"; +const HOR_BAR: &str = "─"; +const VER_BAR: &str = "│"; + +struct Renderer<'a, 'b> { + renderer: &'a mut TextRenderer<'b>, + first_row: u16, + row: u16, + config: &'a InlineDiagnosticsConfig, + styles: &'a Styles, +} + +impl Renderer<'_, '_> { + fn draw_decoration(&mut self, g: &'static str, severity: Severity, col: u16) { + self.draw_decoration_at(g, severity, col, self.row) + } + + fn draw_decoration_at(&mut self, g: &'static str, severity: Severity, col: u16, row: u16) { + self.renderer.draw_decoration_grapheme( + Grapheme::new_decoration(g), + self.styles.severity_style(severity), + row, + col, + ); + } + + fn draw_diagnostic(&mut self, diag: &Diagnostic, col: u16, next_severity: Option) { + let severity = diag.severity(); + let (sym, sym_severity) = if let Some(next_severity) = next_severity { + (STACK, next_severity.max(severity)) + } else { + (BR_CORNER, severity) + }; + self.draw_decoration(sym, sym_severity, col); + for i in 0..self.config.prefix_len { + self.draw_decoration(HOR_BAR, severity, col + i + 1); + } + + let text_col = col + self.config.prefix_len + 1; + let text_fmt = self.config.text_fmt(text_col, self.renderer.viewport.width); + let annotations = TextAnnotations::default(); + let formatter = DocumentFormatter::new_at_prev_checkpoint( + diag.message.as_str().trim().into(), + &text_fmt, + &annotations, + 0, + ); + let mut last_row = 0; + let style = self.styles.severity_style(severity); + for grapheme in formatter { + last_row = grapheme.visual_pos.row; + self.renderer.draw_decoration_grapheme( + grapheme.raw, + style, + self.row + grapheme.visual_pos.row as u16, + text_col + grapheme.visual_pos.col as u16, + ); + } + self.row += 1; + // height is last_row + 1 and extra_rows is height - 1 + let extra_lines = last_row; + if let Some(next_severity) = next_severity { + for _ in 0..extra_lines { + self.draw_decoration(VER_BAR, next_severity, col); + self.row += 1; + } + } else { + self.row += extra_lines as u16; + } + } + + fn draw_multi_diagnostics(&mut self, stack: &mut Vec<(&Diagnostic, u16)>) { + let Some(&(last_diag, last_anchor)) = stack.last() else { return }; + let start = self + .config + .max_diagnostic_start(self.renderer.viewport.width); + + if last_anchor <= start { + return; + } + let mut severity = last_diag.severity(); + let mut last_anchor = last_anchor; + self.draw_decoration(BL_CORNER, severity, last_anchor); + let mut stacked_diagnostics = 1; + for &(diag, anchor) in stack.iter().rev().skip(1) { + let sym = match anchor.cmp(&start) { + Ordering::Less => break, + Ordering::Equal => STACK, + Ordering::Greater => MULTI, + }; + stacked_diagnostics += 1; + severity = severity.max(diag.severity()); + let old_severity = severity; + if anchor == last_anchor && severity == old_severity { + continue; + } + for col in (anchor + 1)..last_anchor { + self.draw_decoration(HOR_BAR, old_severity, col) + } + self.draw_decoration(sym, severity, anchor); + last_anchor = anchor; + } + + // if no diagnostic anchor was found exactly at the start of the + // diagnostic text draw an upwards corner and ensure the last piece + // of the line is not missing + if last_anchor != start { + for col in (start + 1)..last_anchor { + self.draw_decoration(HOR_BAR, severity, col) + } + self.draw_decoration(TR_CORNER, severity, start) + } + self.row += 1; + let stacked_diagnostics = &stack[stack.len() - stacked_diagnostics..]; + + for (i, (diag, _)) in stacked_diagnostics.iter().rev().enumerate() { + let next_severity = stacked_diagnostics[..stacked_diagnostics.len() - i - 1] + .iter() + .map(|(diag, _)| diag.severity()) + .max(); + self.draw_diagnostic(diag, start, next_severity); + } + + stack.truncate(stack.len() - stacked_diagnostics.len()); + } + + fn draw_diagnostics(&mut self, stack: &mut Vec<(&Diagnostic, u16)>) { + let mut stack = stack.drain(..).rev().peekable(); + let mut last_anchor = self.renderer.viewport.width; + while let Some((diag, anchor)) = stack.next() { + if anchor != last_anchor { + for row in self.first_row..self.row { + self.draw_decoration_at(VER_BAR, diag.severity(), anchor, row); + } + } + let next_severity = stack.peek().and_then(|&(diag, next_anchor)| { + (next_anchor == anchor).then_some(diag.severity()) + }); + self.draw_diagnostic(diag, anchor, next_severity); + last_anchor = anchor; + } + } +} + +impl Decoration for InlineDiagnostics<'_> { + fn render_virt_lines( + &mut self, + renderer: &mut TextRenderer, + pos: LinePos, + virt_off: u16, + ) -> u16 { + self.state.compute_line_diagnostics(); + let mut renderer = Renderer { + renderer, + first_row: pos.visual_line + virt_off, + row: pos.visual_line + virt_off, + config: &self.state.config, + styles: &self.styles, + }; + renderer.draw_multi_diagnostics(&mut self.state.stack); + renderer.draw_diagnostics(&mut self.state.stack); + renderer.row - renderer.first_row + } + + fn reset_pos(&mut self, pos: usize) -> usize { + self.state.reset_pos(pos) + } + + fn skip_concealed_anchor(&mut self, conceal_end_char_idx: usize) -> usize { + self.state.skip_concealed(conceal_end_char_idx) + } + + fn decorate_grapheme( + &mut self, + renderer: &mut TextRenderer, + grapheme: &FormattedGrapheme, + ) -> usize { + self.state + .proccess_anchor(grapheme, renderer.viewport.width, renderer.col_offset) + } +} diff --git a/helix-view/src/annotations.rs b/helix-view/src/annotations.rs new file mode 100644 index 0000000000000..4c630487f1cb5 --- /dev/null +++ b/helix-view/src/annotations.rs @@ -0,0 +1 @@ +pub mod diagnostics; diff --git a/helix-view/src/annotations/diagnostics.rs b/helix-view/src/annotations/diagnostics.rs new file mode 100644 index 0000000000000..05203decc1f0c --- /dev/null +++ b/helix-view/src/annotations/diagnostics.rs @@ -0,0 +1,267 @@ +use helix_core::diagnostic::Severity; +use helix_core::doc_formatter::{FormattedGrapheme, TextFormat}; +use helix_core::text_annotations::LineAnnotation; +use helix_core::{softwrapped_dimensions, Diagnostic}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] +pub enum SeverityFilter { + AtLeast(Severity), + OneOf(Box<[Severity]>), +} + +impl SeverityFilter { + pub fn matches(&self, severity: Severity) -> bool { + match self { + SeverityFilter::AtLeast(min) => severity >= *min, + SeverityFilter::OneOf(list) => list.contains(&severity), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct InlineDiagnosticsConfig { + pub cursor_line: SeverityFilter, + pub other_lines: SeverityFilter, + pub min_diagnostic_width: u16, + pub prefix_len: u16, + pub max_warp: u16, + pub max_diagnostics: usize, +} + +impl InlineDiagnosticsConfig { + // last column where to start diagnostics + // every diagnostics that start afterwards will be displayed with a "backwards + // line" to ensure they are still rendered with `min_diagnostic_widht`. If `width` + // it too small to display diagnostics with atleast `min_diagnostic_width` space + // (or inline diagnostics are displed) `None` is returned. In that case inline + // diagnostics should not be shown + pub fn enable(&self, width: u16) -> bool { + let disabled = matches!( + self, + Self { + cursor_line: SeverityFilter::OneOf(cursor_line), + other_lines: SeverityFilter::OneOf(other_lines), + .. + } if cursor_line.is_empty() && other_lines.is_empty() + ); + !disabled && width >= self.min_diagnostic_width + self.prefix_len + } + + pub fn max_diagnostic_start(&self, width: u16) -> u16 { + width - self.min_diagnostic_width - self.prefix_len + } + + pub fn text_fmt(&self, anchor_col: u16, width: u16) -> TextFormat { + let width = if anchor_col > self.max_diagnostic_start(width) { + self.min_diagnostic_width + } else { + width - anchor_col - self.prefix_len + }; + + TextFormat { + soft_wrap: true, + tab_width: 4, + max_wrap: self.max_warp.min(width / 4), + max_indent_retain: 0, + wrap_indicator: "".into(), + wrap_indicator_highlight: None, + viewport_width: width, + soft_wrap_at_text_width: true, + } + } +} + +impl Default for InlineDiagnosticsConfig { + fn default() -> Self { + InlineDiagnosticsConfig { + cursor_line: SeverityFilter::AtLeast(Severity::Hint), + other_lines: SeverityFilter::AtLeast(Severity::Warning), + min_diagnostic_width: 40, + prefix_len: 1, + max_warp: 20, + max_diagnostics: 20, + } + } +} + +#[derive(Default)] +pub struct InlineDiagnosticAccumulator<'a> { + idx: usize, + pub stack: Vec<(&'a Diagnostic, u16)>, + diagnostics: &'a [Diagnostic], + pub config: InlineDiagnosticsConfig, + cursor: usize, + cursor_line: bool, +} + +impl<'a> InlineDiagnosticAccumulator<'a> { + pub fn new( + cursor: usize, + diagnostics: &'a [Diagnostic], + config: InlineDiagnosticsConfig, + ) -> Self { + InlineDiagnosticAccumulator { + idx: 0, + stack: Vec::new(), + diagnostics, + config, + cursor, + cursor_line: false, + } + } + + pub fn reset_pos(&mut self, char_idx: usize) -> usize { + self.idx = 0; + self.skip_concealed(char_idx) + } + + pub fn skip_concealed(&mut self, conceal_end_char_idx: usize) -> usize { + let diagnostics = &self.diagnostics[self.idx..]; + let idx = diagnostics.partition_point(|diag| diag.range.start < conceal_end_char_idx); + self.idx += idx; + self.next_anchor(conceal_end_char_idx) + } + + pub fn next_anchor(&self, current_char_idx: usize) -> usize { + let next_diag_start = self + .diagnostics + .get(self.idx) + .map_or(usize::MAX, |diag| diag.range.start); + if (current_char_idx..next_diag_start).contains(&self.cursor) { + self.cursor + } else { + next_diag_start + } + } + + fn process_anchor_impl( + &mut self, + grapheme: &FormattedGrapheme, + width: u16, + horizontal_off: usize, + ) -> bool { + // TODO: doing the cursor tracking here works well but is somewhat + // duplicate effort/tedious maybe centrilize this somehwere? + // In the DocFormatter? + if grapheme.char_idx == self.cursor { + self.cursor_line = true; + if self + .diagnostics + .get(self.idx) + .map_or(true, |diag| diag.range.start != grapheme.char_idx) + { + return false; + } + } + + let Some(anchor_col) = grapheme.visual_pos.col.checked_sub(horizontal_off) else { + return true + }; + if anchor_col >= width as usize { + return true; + } + + for diag in &self.diagnostics[self.idx..] { + if diag.range.start != grapheme.char_idx { + break; + } + self.stack.push((diag, anchor_col as u16)); + self.idx += 1; + } + false + } + + pub fn proccess_anchor( + &mut self, + grapheme: &FormattedGrapheme, + width: u16, + horizontal_off: usize, + ) -> usize { + if self.process_anchor_impl(grapheme, width, horizontal_off) { + self.idx += self.diagnostics[self.idx..] + .iter() + .take_while(|diag| diag.range.start == grapheme.char_idx) + .count(); + } + self.next_anchor(grapheme.char_idx + 1) + } + + pub fn compute_line_diagnostics(&mut self) { + let filter = if self.cursor_line { + self.cursor_line = false; + &self.config.cursor_line + } else { + &self.config.other_lines + }; + self.stack + .retain(|(diag, _)| filter.matches(diag.severity())); + self.stack.truncate(self.config.max_diagnostics) + } + + pub fn has_multi(&self, width: u16) -> bool { + self.stack.last().map_or(false, |&(_, anchor)| { + anchor > self.config.max_diagnostic_start(width) + }) + } +} + +pub(crate) struct InlineDiagnostics<'a> { + state: InlineDiagnosticAccumulator<'a>, + width: u16, + horizontal_off: usize, +} + +impl<'a> InlineDiagnostics<'a> { + #[allow(clippy::new_ret_no_self)] + pub(crate) fn new( + diagnostics: &'a [Diagnostic], + cursor: usize, + width: u16, + horizontal_off: usize, + config: InlineDiagnosticsConfig, + ) -> Box { + Box::new(InlineDiagnostics { + state: InlineDiagnosticAccumulator::new(cursor, diagnostics, config), + width, + horizontal_off, + }) + } +} + +impl LineAnnotation for InlineDiagnostics<'_> { + fn reset_pos(&mut self, char_idx: usize) -> usize { + self.state.reset_pos(char_idx) + } + + fn skip_concealed_anchors(&mut self, conceal_end_char_idx: usize) -> usize { + self.state.skip_concealed(conceal_end_char_idx) + } + + fn process_anchor(&mut self, grapheme: &FormattedGrapheme) -> usize { + self.state + .proccess_anchor(grapheme, self.width, self.horizontal_off) + } + + fn insert_virtual_lines( + &mut self, + _line_end_char_idx: usize, + _vertical_off: usize, + _doc_line: usize, + ) -> usize { + self.state.compute_line_diagnostics(); + let multi = self.state.has_multi(self.width); + let diagostic_height: usize = self + .state + .stack + .drain(..) + .map(|(diag, anchor)| { + let text_fmt = self.state.config.text_fmt(anchor, self.width); + softwrapped_dimensions(diag.message.as_str().trim().into(), &text_fmt).0 + }) + .sum(); + multi as usize + diagostic_height + } +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index eca6002653f54..4afb79d4bdca2 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -6,7 +6,7 @@ use futures_util::FutureExt; use helix_core::auto_pairs::AutoPairs; use helix_core::doc_formatter::TextFormat; use helix_core::syntax::Highlight; -use helix_core::text_annotations::{InlineAnnotation, TextAnnotations}; +use helix_core::text_annotations::InlineAnnotation; use helix_core::Range; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -95,7 +95,6 @@ impl Serialize for Mode { serializer.collect_str(self) } } - /// A snapshot of the text of a document that we want to write out to disk #[derive(Debug, Clone)] pub struct DocumentSavedEvent { @@ -982,7 +981,7 @@ impl Document { diagnostic.line = self.text.char_to_line(diagnostic.range.start); } self.diagnostics - .sort_unstable_by_key(|diagnostic| diagnostic.range); + .sort_by_key(|diagnostic| (diagnostic.range.start, diagnostic.severity)); // Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place let apply_inlay_hint_changes = |annotations: &mut Rc<[InlineAnnotation]>| { @@ -1388,7 +1387,7 @@ impl Document { pub fn set_diagnostics(&mut self, diagnostics: Vec) { self.diagnostics = diagnostics; self.diagnostics - .sort_unstable_by_key(|diagnostic| diagnostic.range); + .sort_by_key(|diagnostic| (diagnostic.range.start, diagnostic.severity)); } /// Get the document's auto pairs. If the document has a recognized @@ -1428,14 +1427,10 @@ impl Document { .and_then(|soft_wrap| soft_wrap.wrap_at_text_width) }) .or(config.soft_wrap.wrap_at_text_width) - .unwrap_or(false); + .unwrap_or(false) + && text_width as u16 > viewport_width; if soft_wrap_at_text_width { - // We increase max_line_len by 1 because softwrap considers the newline character - // as part of the line length while the "typical" expectation is that this is not the case. - // In particular other commands like :reflow do not count the line terminator. - // This is technically inconsistent for the last line as that line never has a line terminator - // but having the last visual line exceed the width by 1 seems like a rare edge case. - viewport_width = viewport_width.min(text_width as u16 + 1) + viewport_width = text_width as u16; } let config = self.config.load(); let editor_soft_wrap = &config.soft_wrap; @@ -1472,15 +1467,10 @@ impl Document { wrap_indicator_highlight: theme .and_then(|theme| theme.find_scope_index("ui.virtual.wrap")) .map(Highlight), + soft_wrap_at_text_width, } } - /// Get the text annotations that apply to the whole document, those that do not apply to any - /// specific view. - pub fn text_annotations(&self, _theme: Option<&Theme>) -> TextAnnotations { - TextAnnotations::default() - } - /// Set the inlay hints for this document and `view_id`. pub fn set_inlay_hints(&mut self, view_id: ViewId, inlay_hints: DocumentInlayHints) { self.inlay_hints.insert(view_id, inlay_hints); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 52f86f2da4679..0541b2403a6c6 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,5 +1,6 @@ use crate::{ align_view, + annotations::diagnostics::InlineDiagnosticsConfig, clipboard::{get_clipboard_provider, ClipboardProvider}, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode}, graphics::{CursorKind, Rect}, @@ -354,6 +355,10 @@ pub struct LspConfig { pub display_inlay_hints: bool, /// Whether to enable snippet support pub snippets: bool, + /// Display diagnostic on the same line they occur automatically. + /// Also called "error lens"-style diagnostics, in reference to the popular VSCode extension. + pub inline_diagnostics: InlineDiagnosticsConfig, + pub display_diagnostic_message: bool, } impl Default for LspConfig { @@ -365,6 +370,8 @@ impl Default for LspConfig { display_signature_help_docs: true, display_inlay_hints: false, snippets: true, + inline_diagnostics: InlineDiagnosticsConfig::default(), + display_diagnostic_message: false, } } } @@ -869,7 +876,7 @@ pub struct Editor { /// This cache is only a performance optimization to /// avoid calculating the cursor position multiple /// times during rendering and should not be set by other functions. - pub cursor_cache: Cell>>, + pub cursor_cache: CursorCache, /// When a new completion request is sent to the server old /// unifinished request must be dropped. Each completion /// request is associated with a channel that cancels @@ -975,7 +982,7 @@ impl Editor { config_events: unbounded_channel(), redraw_handle: Default::default(), needs_redraw: false, - cursor_cache: Cell::new(None), + cursor_cache: CursorCache::default(), completion_request_handle: None, } } @@ -1539,15 +1546,7 @@ impl Editor { pub fn cursor(&self) -> (Option, CursorKind) { let config = self.config(); let (view, doc) = current_ref!(self); - let cursor = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let pos = self - .cursor_cache - .get() - .unwrap_or_else(|| view.screen_coords_at_pos(doc, doc.text().slice(..), cursor)); - if let Some(mut pos) = pos { + if let Some(mut pos) = self.cursor_cache.get(view, doc) { let inner = view.inner_area(doc); pos.col += inner.x as usize; pos.row += inner.y as usize; @@ -1704,3 +1703,20 @@ fn try_restore_indent(doc: &mut Document, view: &mut View) { doc.apply(&transaction, view.id); } } + +#[derive(Default)] +pub struct CursorCache(Cell>>); + +impl CursorCache { + pub fn get(&self, view: &View, doc: &Document) -> Option { + self.0.get().unwrap_or_else(|| { + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + view.screen_coords_at_pos(doc, text, cursor) + }) + } + + pub fn set(&self, cursor_pos: Option) { + self.0.set(Some(cursor_pos)) + } +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index c3f67345b3618..33686ceec4cb3 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] pub mod macros; +pub mod annotations; pub mod clipboard; pub mod document; pub mod editor; diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index ee6fc1275debb..763fd8d3944d0 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -1,5 +1,6 @@ use crate::{ align_view, + annotations::diagnostics::InlineDiagnostics, document::DocumentInlayHints, editor::{GutterConfig, GutterType}, graphics::Rect, @@ -19,7 +20,6 @@ use helix_core::{ use std::{ collections::{HashMap, VecDeque}, fmt, - rc::Rc, }; const JUMP_LIST_CAPACITY: usize = 30; @@ -403,47 +403,58 @@ impl View { } /// Get the text annotations to display in the current view for the given document and theme. - pub fn text_annotations(&self, doc: &Document, theme: Option<&Theme>) -> TextAnnotations { - // TODO custom annotations for custom views like side by side diffs - - let mut text_annotations = doc.text_annotations(theme); + pub fn text_annotations<'a>( + &self, + doc: &'a Document, + theme: Option<&Theme>, + ) -> TextAnnotations<'a> { + let mut text_annotations = TextAnnotations::default(); - let DocumentInlayHints { + if let Some(DocumentInlayHints { id: _, type_inlay_hints, parameter_inlay_hints, other_inlay_hints, padding_before_inlay_hints, padding_after_inlay_hints, - } = match doc.inlay_hints.get(&self.id) { - Some(doc_inlay_hints) => doc_inlay_hints, - None => return text_annotations, + }) = doc.inlay_hints.get(&self.id) + { + let type_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.type")) + .map(Highlight); + let parameter_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.parameter")) + .map(Highlight); + let other_style = theme + .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint")) + .map(Highlight); + + // Overlapping annotations are ignored apart from the first so the order here is not random: + // types -> parameters -> others should hopefully be the "correct" order for most use cases, + // with the padding coming before and after as expected. + text_annotations + .add_inline_annotations(padding_before_inlay_hints, None) + .add_inline_annotations(type_inlay_hints, type_style) + .add_inline_annotations(parameter_inlay_hints, parameter_style) + .add_inline_annotations(other_inlay_hints, other_style) + .add_inline_annotations(padding_after_inlay_hints, None); }; - - let type_style = theme - .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.type")) - .map(Highlight); - let parameter_style = theme - .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint.parameter")) - .map(Highlight); - let other_style = theme - .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint")) - .map(Highlight); - - let mut add_annotations = |annotations: &Rc<[_]>, style| { - if !annotations.is_empty() { - text_annotations.add_inline_annotations(Rc::clone(annotations), style); - } - }; - - // Overlapping annotations are ignored apart from the first so the order here is not random: - // types -> parameters -> others should hopefully be the "correct" order for most use cases, - // with the padding coming before and after as expected. - add_annotations(padding_before_inlay_hints, None); - add_annotations(type_inlay_hints, type_style); - add_annotations(parameter_inlay_hints, parameter_style); - add_annotations(other_inlay_hints, other_style); - add_annotations(padding_after_inlay_hints, None); + let width = self.inner_width(doc); + let config = doc.config.load(); + if config.lsp.inline_diagnostics.enable(width) { + let config = config.lsp.inline_diagnostics.clone(); + let cursor = doc + .selection(self.id) + .primary() + .cursor(doc.text().slice(..)); + text_annotations.add_line_annotation(InlineDiagnostics::new( + doc.diagnostics(), + cursor, + width, + self.offset.horizontal_offset, + config, + )); + } text_annotations }