From 4c7cdb8feaf94e5f0ee993680fe9e26c0b9339eb Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Sun, 19 Nov 2023 22:34:06 +0100 Subject: [PATCH] Improve line annotation API The line annotation as implemented in #5420 had two shortcomings: * It required the height of virtual text lines to be known ahead time * It checked for line anchors at every grapheme The first problem made the API impractical to use in practice because almost all virtual text needs to be softwrapped. For example inline diagnostics should be softwrapped to avoid cutting off the diagnostic message (as no scrolling is possible). While more complex virtual text like side by side diffs must dynamically calculate the number of empty lines two align two documents (which requires taking account both softwrap and virtual text). To address this, the API has been refactored to use a trait. The second issue caused some performance overhead and unnecessarily complicated the `DocumentFormatter`. It was addressed by only calling the trait mentioned above at line breaks (instead of always). This allows offers additional flexibility to annotations as it offers the flexibility to align lines (needed for side by side diffs). --- helix-core/src/doc_formatter.rs | 48 ++++--- helix-core/src/text_annotations.rs | 221 ++++++++++++++++++++++++----- 2 files changed, 214 insertions(+), 55 deletions(-) diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index f934b38a1..0f9a52b5d 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; use std::fmt::Debug; -use std::mem::{replace, take}; +use std::mem::replace; #[cfg(test)] mod test; @@ -166,9 +166,6 @@ 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)>, // softwrap specific @@ -209,7 +206,6 @@ pub fn new_at_prev_checkpoint( 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), @@ -250,7 +246,6 @@ fn advance_grapheme(&mut self, col: usize, char_pos: usize) -> Option usize { 0 }; + let virtual_lines = + self.annotations + .virtual_lines_at(self.char_pos, self.visual_pos, self.line_pos); self.visual_pos.col = indent_carry_over as usize; - self.visual_pos.row += 1 + take(&mut self.virtual_lines); + 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) @@ -404,24 +402,32 @@ fn next(&mut self) -> Option { self.advance_grapheme(self.visual_pos.col, self.char_pos)? }; - let visual_pos = self.visual_pos; - let char_pos = 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, + }; + self.char_pos += grapheme.doc_chars(); - let line_idx = self.line_pos; - if grapheme.grapheme == Grapheme::Newline { - self.visual_pos.row += 1; - self.visual_pos.row += take(&mut self.virtual_lines); + if !grapheme.is_virtual() { + self.annotations.process_virtual_text_anchors(&grapheme); + } + if grapheme.raw == Grapheme::Newline { + // move to end of newline char + self.visual_pos.col += 1; + let virtual_lines = + self.annotations + .virtual_lines_at(self.char_pos, self.visual_pos, 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(FormattedGrapheme { - raw: grapheme.grapheme, - source: grapheme.source, - visual_pos, - line_idx, - char_idx: char_pos, - }) + Some(grapheme) } } diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 1576914e3..785e38fd7 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -1,8 +1,12 @@ use std::cell::Cell; +use std::cmp::Ordering; +use std::fmt::Debug; use std::ops::Range; +use std::ptr::NonNull; +use crate::doc_formatter::FormattedGrapheme; use crate::syntax::Highlight; -use crate::Tendril; +use crate::{Position, Tendril}; /// An inline annotation is continuous text shown /// on the screen before the grapheme that starts at @@ -75,19 +79,98 @@ pub fn new(char_idx: usize, grapheme: impl Into) -> Self { } } -/// 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`. +/// 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. /// -/// These lines can be filled with text in the rendering code -/// as their contents have no effect beyond visual appearance. +/// The height of virtual text is usually not known ahead of time as virtual +/// text often requires softwrapping. Furthermore the height of some virtual +/// text like side-by-side diffs depends on the height of the text (again +/// influenced by softwrap) and other virtual text. Therefore line annotations +/// are computed on the fly instead of ahead of time like other annotations. /// -/// 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 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 traversal of a document starts `reset_pos` is called. Afterwards +/// the other functions are called with indices 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 all return a +/// `char_idx` anchor. This anchor is stored when transversing the document and +/// when the grapheme at the anchor is traversed the `process_anchor` function +/// is called. +/// +/// # Note +/// +/// All functions only receive immutable references to `self`. +/// `LineAnnotation`s that want to store an internal position or +/// state of some kind should use `Cell`. Using interior mutability for +/// caches is preferable as otherwise a lot of lifetimes become invariant +/// which complicates APIs a lot. +pub trait LineAnnotation { + /// Resets the internal position to `char_idx`. This function is called + /// when a new traversal 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 `line_end_visual_pos` parameter indicates the visual vertical distance + /// from the start of block where the traversal starts. 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, + line_end_visual_pos: Position, + doc_line: usize, + ) -> Position; } #[derive(Debug)] @@ -143,13 +226,68 @@ 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` are covariant 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 +/// special 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. That +/// means any reference to the pointer can create a valid multable reference +/// that is 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)] +#[derive(Default)] pub struct TextAnnotations<'a> { inline_annotations: Vec>>, overlays: Vec>>, - line_annotations: 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<'a> TextAnnotations<'a> { @@ -157,9 +295,9 @@ impl<'a> TextAnnotations<'a> { 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( @@ -219,8 +357,9 @@ pub fn add_overlay(&mut self, layer: &'a [Overlay], highlight: Option /// /// 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: &'a [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 } @@ -250,21 +389,35 @@ pub(crate) fn overlay_at(&self, char_idx: usize) -> Option<(&Overlay, Option 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, + line_end_visual_pos: Position, + doc_line: usize, + ) -> usize { + let mut virt_off = Position::new(0, 0); + for (_, layer) in &self.line_annotations { + virt_off += unsafe { + layer + .get() + .insert_virtual_lines(char_idx, line_end_visual_pos + virt_off, doc_line) + }; + } + virt_off.row } }