You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helix/helix-term/src/ui/document.rs

479 lines
16 KiB
Rust

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 <d4hines@gmail.com> 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 <mcarsondavis@gmail.com> * improve documentation for positoning functions * simplify tests * fix documentation of Grapheme::width * Apply suggestions from code review Co-authored-by: Michael Davis <mcarsondavis@gmail.com> * 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 <mcarsondavis@gmail.com>
2 years ago
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<F: FnMut(&mut TextRenderer, LinePos)> 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<Item = HighlightEvent>> {
text_style: Style,
active_highlights: Vec<Highlight>,
highlight_iter: H,
theme: &'a Theme,
}
impl<H: Iterator<Item = HighlightEvent>> 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<dyn FnMut(&mut TextRenderer, Position) + 'a>);
#[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<Item = HighlightEvent>,
theme: &Theme,
line_decoration: &mut [Box<dyn LineDecoration + '_>],
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<Item = HighlightEvent>,
theme: &Theme,
line_decorations: &mut [Box<dyn LineDecoration + '_>],
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();
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,
);
}
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 <d4hines@gmail.com> 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 <mcarsondavis@gmail.com> * improve documentation for positoning functions * simplify tests * fix documentation of Grapheme::width * Apply suggestions from code review Co-authored-by: Michael Davis <mcarsondavis@gmail.com> * 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 <mcarsondavis@gmail.com>
2 years ago
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);
}
}
}