From 4dcf1fe66ba30a78edc054780d9b65c2f826530f Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Tue, 31 Jan 2023 18:03:19 +0100 Subject: rework positioning/rendering and enable softwrap/virtual text (#5420) * rework positioning/rendering, enables softwrap/virtual text This commit is a large rework of the core text positioning and rendering code in helix to remove the assumption that on-screen columns/lines correspond to text columns/lines. A generic `DocFormatter` is introduced that positions graphemes on and is used both for rendering and for movements/scrolling. Both virtual text support (inline, grapheme overlay and multi-line) and a capable softwrap implementation is included. fix picker highlight cleanup doc formatter, use word bondaries for wrapping make visual vertical movement a seperate commnad estimate line gutter width to improve performance cache cursor position cleanup and optimize doc formatter cleanup documentation fix typos Co-authored-by: Daniel Hines update documentation fix panic in last_visual_line funciton improve soft-wrap documentation add extend_visual_line_up/down commands fix non-visual vertical movement streamline virtual text highlighting, add softwrap indicator fix cursor position if softwrap is disabled improve documentation of text_annotations module avoid crashes if view anchor is out of bounds fix: consider horizontal offset when traslation char_idx -> vpos improve default configuration fix: mixed up horizontal and vertical offset reset view position after config reload apply suggestions from review disabled softwrap for very small screens to avoid endless spin fix wrap_indicator setting fix bar cursor disappearring on the EOF character add keybinding for linewise vertical movement fix: inconsistent gutter highlights improve virtual text API make scope idx lookup more ergonomic allow overlapping overlays correctly track char_pos for virtual text adjust configuration deprecate old position fucntions fix infinite loop in highlight lookup fix gutter style fix formatting document max-line-width interaction with softwrap change wrap-indicator example to use empty string fix: rare panic when view is in invalid state (bis) * Apply suggestions from code review Co-authored-by: Michael Davis * improve documentation for positoning functions * simplify tests * fix documentation of Grapheme::width * Apply suggestions from code review Co-authored-by: Michael Davis * add explicit drop invocation * Add explicit MoveFn type alias * add docuntation to Editor::cursor_cache * fix a few typos * explain use of allow(deprecated) * make gj and gk extend in select mode * remove unneded debug and TODO * mark tab_width_at #[inline] * add fast-path to move_vertically_visual in case softwrap is disabled * rename first_line to first_visual_line * simplify duplicate if/else --------- Co-authored-by: Michael Davis --- helix-term/src/ui/document.rs | 475 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 helix-term/src/ui/document.rs (limited to 'helix-term/src/ui/document.rs') diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs new file mode 100644 index 00000000..66332410 --- /dev/null +++ b/helix-term/src/ui/document.rs @@ -0,0 +1,475 @@ +use std::cmp::min; + +use helix_core::doc_formatter::{DocumentFormatter, GraphemeSource, TextFormat}; +use helix_core::graphemes::Grapheme; +use helix_core::str_utils::char_to_byte_idx; +use helix_core::syntax::Highlight; +use helix_core::syntax::HighlightEvent; +use helix_core::text_annotations::TextAnnotations; +use helix_core::{visual_offset_from_block, Position, RopeSlice}; +use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; +use helix_view::graphics::Rect; +use helix_view::theme::Style; +use helix_view::view::ViewPosition; +use helix_view::Document; +use helix_view::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) + } +} + +/// A wrapper around a HighlightIterator +/// that merges the layered highlights to create the final text style +/// and yields the active text style and the char_idx where the active +/// style will have to be recomputed. +struct StyleIter<'a, H: Iterator> { + text_style: Style, + active_highlights: Vec, + highlight_iter: H, + theme: &'a Theme, +} + +impl> Iterator for StyleIter<'_, H> { + type Item = (Style, usize); + fn next(&mut self) -> Option<(Style, usize)> { + while let Some(event) = self.highlight_iter.next() { + match event { + HighlightEvent::HighlightStart(highlights) => { + self.active_highlights.push(highlights) + } + HighlightEvent::HighlightEnd => { + self.active_highlights.pop(); + } + HighlightEvent::Source { start, end } => { + if start == end { + continue; + } + let style = self + .active_highlights + .iter() + .fold(self.text_style, |acc, span| { + acc.patch(self.theme.highlight(span.0)) + }); + return Some((style, end)); + } + } + } + None + } +} + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub struct LinePos { + /// Indicates whether the given visual line + /// is the first visual line of the given document line + pub first_visual_line: bool, + /// The line index of the document line that contains the given visual line + 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, + viewport: Rect, + doc: &Document, + offset: ViewPosition, + doc_annotations: &TextAnnotations, + highlight_iter: impl Iterator, + theme: &Theme, + line_decoration: &mut [Box], + translated_positions: &mut [TranslatedPosition], +) { + let mut renderer = TextRenderer::new(surface, doc, theme, offset.horizontal_offset, viewport); + render_text( + &mut renderer, + doc.text().slice(..), + offset, + &doc.text_format(viewport.width, Some(theme)), + doc_annotations, + highlight_iter, + theme, + line_decoration, + translated_positions, + ) +} + +fn translate_positions( + char_pos: usize, + first_visisble_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_visisble_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>( + renderer: &mut TextRenderer, + text: RopeSlice<'t>, + offset: ViewPosition, + text_fmt: &TextFormat, + text_annotations: &TextAnnotations, + highlight_iter: impl Iterator, + theme: &Theme, + line_decorations: &mut [Box], + translated_positions: &mut [TranslatedPosition], +) { + let ( + Position { + row: mut row_off, .. + }, + mut char_pos, + ) = visual_offset_from_block( + text, + offset.anchor, + offset.anchor, + text_fmt, + text_annotations, + ); + row_off += offset.vertical_offset; + assert_eq!(0, offset.vertical_offset); + + let (mut formatter, mut first_visible_char_idx) = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor); + let mut styles = StyleIter { + text_style: renderer.text_style, + active_highlights: Vec::with_capacity(64), + 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)); + + 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(); + // TODO refactor with let .. else once MSRV reaches 1.65 + let (grapheme, mut pos) = if let Some(it) = formatter.next() { + it + } else { + let mut last_pos = formatter.visual_pos(); + last_pos.col -= 1; + // 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; + }; + + // skip any graphemes on visual lines before the block start + if pos.row < row_off { + if char_pos >= style_span.1 { + // TODO refactor using let..else once MSRV reaches 1.65 + 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; + + // if the end of the viewport is reached stop rendering + if 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 { + 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); + } + } + 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, + }; + for line_decoration in &mut *line_decorations { + line_decoration.render_background(renderer, last_line_pos); + } + } + + // aquire the correct grapheme style + if char_pos >= style_span.1 { + // TODO refactor using let..else once MSRV reaches 1.65 + style_span = if let Some(style_span) = styles.next() { + style_span + } else { + (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; + if let Some(highlight) = highlight { + style.patch(theme.highlight(highlight.0)) + } else { + style + } + } else { + style_span.0 + }; + + renderer.draw_grapheme( + grapheme.grapheme, + grapheme_style, + &mut last_line_indent_level, + &mut is_in_indent_area, + 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); + } +} + +#[derive(Debug)] +pub struct TextRenderer<'a> { + pub surface: &'a mut Surface, + pub text_style: Style, + pub whitespace_style: Style, + pub indent_guide_char: String, + pub indent_guide_style: Style, + pub newline: String, + pub nbsp: String, + pub space: String, + pub tab: String, + pub tab_width: u16, + pub starting_indent: usize, + pub draw_indent_guides: bool, + pub col_offset: usize, + pub viewport: Rect, +} + +impl<'a> TextRenderer<'a> { + pub fn new( + surface: &'a mut Surface, + doc: &Document, + theme: &Theme, + col_offset: usize, + viewport: Rect, + ) -> TextRenderer<'a> { + let editor_config = doc.config.load(); + let WhitespaceConfig { + render: ws_render, + characters: ws_chars, + } = &editor_config.whitespace; + + let tab_width = doc.tab_width(); + let tab = if ws_render.tab() == WhitespaceRenderValue::All { + std::iter::once(ws_chars.tab) + .chain(std::iter::repeat(ws_chars.tabpad).take(tab_width - 1)) + .collect() + } else { + " ".repeat(tab_width) + }; + let newline = if ws_render.newline() == WhitespaceRenderValue::All { + ws_chars.newline.into() + } else { + " ".to_owned() + }; + + let space = if ws_render.space() == WhitespaceRenderValue::All { + ws_chars.space.into() + } else { + " ".to_owned() + }; + let nbsp = if ws_render.nbsp() == WhitespaceRenderValue::All { + ws_chars.nbsp.into() + } else { + " ".to_owned() + }; + + let text_style = theme.get("ui.text"); + + TextRenderer { + surface, + indent_guide_char: editor_config.indent_guides.character.into(), + newline, + nbsp, + space, + tab_width: tab_width as u16, + tab, + whitespace_style: theme.get("ui.virtual.whitespace"), + starting_indent: (col_offset / tab_width) + + editor_config.indent_guides.skip_levels as usize, + indent_guide_style: text_style.patch( + theme + .try_get("ui.virtual.indent-guide") + .unwrap_or_else(|| theme.get("ui.virtual.whitespace")), + ), + text_style, + draw_indent_guides: editor_config.indent_guides.render, + viewport, + col_offset, + } + } + + /// Draws a single `grapheme` at the current render position with a specified `style`. + pub fn draw_grapheme( + &mut self, + grapheme: Grapheme, + mut style: Style, + last_indent_level: &mut usize, + is_in_indent_area: &mut bool, + position: Position, + ) { + let cut_off_start = self.col_offset.saturating_sub(position.col as usize); + 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 width = grapheme.width(); + let grapheme = match grapheme { + Grapheme::Tab { width } => { + let grapheme_tab_width = char_to_byte_idx(&self.tab, width as usize); + &self.tab[..grapheme_tab_width] + } + // TODO special rendering for other whitespaces? + Grapheme::Other { ref g } if g == " " => &self.space, + Grapheme::Other { ref g } if g == "\u{00A0}" => &self.nbsp, + Grapheme::Other { ref g } => &*g, + Grapheme::Newline => &self.newline, + }; + + let in_bounds = self.col_offset <= (position.col as usize) + && (position.col as usize) < self.viewport.width as usize + self.col_offset; + + if in_bounds { + self.surface.set_string( + self.viewport.x + (position.col - self.col_offset) as u16, + self.viewport.y + position.row as u16, + grapheme, + style, + ); + } else if cut_off_start != 0 && cut_off_start < width as usize { + // partially on screen + let rect = Rect::new( + self.viewport.x as u16, + self.viewport.y + position.row as u16, + (width - cut_off_start) as u16, + 1, + ); + self.surface.set_style(rect, style); + } + + if *is_in_indent_area && !is_whitespace { + *last_indent_level = position.col; + *is_in_indent_area = false; + } + } + + /// Overlay indentation guides ontop of a rendered line + /// The indentation level is computed in `draw_lines`. + /// Therefore this function must always be called afterwards. + pub fn draw_indent_guides(&mut self, indent_level: usize, row: u16) { + if !self.draw_indent_guides { + return; + } + + // Don't draw indent guides outside of view + let end_indent = min( + indent_level, + // Add tab_width - 1 to round up, since the first visible + // indent might be a bit after offset.col + self.col_offset + self.viewport.width as usize + (self.tab_width - 1) as usize, + ) / self.tab_width as usize; + + for i in self.starting_indent..end_indent { + let x = + (self.viewport.x as usize + (i * self.tab_width as usize) - self.col_offset) as u16; + let y = self.viewport.y + row; + debug_assert!(self.surface.in_bounds(x, y)); + self.surface + .set_string(x, y, &self.indent_guide_char, self.indent_guide_style); + } + } +} -- cgit v1.2.3-70-g09d2