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/application.rs | 13 +- helix-term/src/commands.rs | 228 ++++++++++++----- helix-term/src/keymap/default.rs | 18 +- helix-term/src/ui/completion.rs | 6 +- helix-term/src/ui/document.rs | 475 ++++++++++++++++++++++++++++++++++++ helix-term/src/ui/editor.rs | 512 +++++++++++++++------------------------ helix-term/src/ui/lsp.rs | 5 +- helix-term/src/ui/mod.rs | 1 + helix-term/src/ui/picker.rs | 81 ++++--- 9 files changed, 913 insertions(+), 426 deletions(-) create mode 100644 helix-term/src/ui/document.rs (limited to 'helix-term') diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c0cbc245..05ceb874 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -172,7 +172,7 @@ impl Application { area, theme_loader.clone(), syn_loader.clone(), - Box::new(Map::new(Arc::clone(&config), |config: &Config| { + Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), ); @@ -309,8 +309,10 @@ impl Application { let surface = self.terminal.current_buffer_mut(); self.compositor.render(area, surface, &mut cx); - let (pos, kind) = self.compositor.cursor(area, &self.editor); + // reset cursor cache + self.editor.cursor_cache.set(None); + let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); self.terminal.draw(pos, kind).unwrap(); } @@ -395,6 +397,13 @@ impl Application { // Update all the relevant members in the editor after updating // the configuration. self.editor.refresh_config(); + + // reset view position in case softwrap was enabled/disabled + let scrolloff = self.editor.config().scrolloff; + for (view, _) in self.editor.tree.views_mut() { + let doc = &self.editor.documents[&view.doc]; + view.ensure_cursor_in_view(doc, scrolloff) + } } /// refresh language config after config change diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 47ef1ff1..1cbdd0fb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -9,21 +9,25 @@ use tui::widgets::Row; pub use typed::*; use helix_core::{ - comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, + char_idx_at_visual_offset, comment, + doc_formatter::TextFormat, + encoding, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, increment, indent, indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, - movement::{self, Direction}, - object, pos_at_coords, pos_at_visual_coords, + movement::{self, move_vertically_visual, Direction}, + object, pos_at_coords, regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, - selection, shellwords, surround, textobject, + selection, shellwords, surround, + text_annotations::TextAnnotations, + textobject, tree_sitter::Node, unicode::width::UnicodeWidthChar, - visual_coords_at_pos, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, - SmallVec, Tendril, Transaction, + visual_offset_from_block, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, + Selection, SmallVec, Tendril, Transaction, }; use helix_view::{ clipboard::ClipboardType, @@ -200,10 +204,14 @@ impl MappableCommand { move_char_right, "Move right", move_line_up, "Move up", move_line_down, "Move down", + move_visual_line_up, "Move up", + move_visual_line_down, "Move down", extend_char_left, "Extend left", extend_char_right, "Extend right", extend_line_up, "Extend up", extend_line_down, "Extend down", + extend_visual_line_up, "Extend up", + extend_visual_line_down, "Extend down", copy_selection_on_next_line, "Copy selection on next line", copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to start of next word", @@ -538,18 +546,27 @@ impl PartialEq for MappableCommand { fn no_op(_cx: &mut Context) {} -fn move_impl(cx: &mut Context, move_fn: F, dir: Direction, behaviour: Movement) -where - F: Fn(RopeSlice, Range, Direction, usize, Movement, usize) -> Range, -{ +type MoveFn = + fn(RopeSlice, Range, Direction, usize, Movement, &TextFormat, &mut TextAnnotations) -> Range; + +fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movement) { let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); + let text_fmt = doc.text_format(view.inner_area(doc).width, None); + let mut annotations = view.text_annotations(doc, None); - let selection = doc - .selection(view.id) - .clone() - .transform(|range| move_fn(text, range, dir, count, behaviour, doc.tab_width())); + let selection = doc.selection(view.id).clone().transform(|range| { + move_fn( + text, + range, + dir, + count, + behaviour, + &text_fmt, + &mut annotations, + ) + }); doc.set_selection(view.id, selection); } @@ -571,6 +588,24 @@ fn move_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Move) } +fn move_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Backward, + Movement::Move, + ) +} + +fn move_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Forward, + Movement::Move, + ) +} + fn extend_char_left(cx: &mut Context) { move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend) } @@ -587,6 +622,24 @@ fn extend_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Extend) } +fn extend_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Backward, + Movement::Extend, + ) +} + +fn extend_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Forward, + Movement::Extend, + ) +} + fn goto_line_end_impl(view: &mut View, doc: &mut Document, movement: Movement) { let text = doc.text().slice(..); @@ -814,7 +867,10 @@ fn trim_selections(cx: &mut Context) { } // align text in selection +#[allow(deprecated)] fn align_selections(cx: &mut Context) { + use helix_core::visual_coords_at_pos; + let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -891,17 +947,22 @@ fn goto_window(cx: &mut Context, align: Align) { // as we type let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); - let last_line = view.last_line(doc); + let last_visual_line = view.last_visual_line(doc); - let line = match align { - Align::Top => view.offset.row + scrolloff + count, - Align::Center => view.offset.row + ((last_line - view.offset.row) / 2), - Align::Bottom => last_line.saturating_sub(scrolloff + count), + let visual_line = match align { + Align::Top => view.offset.vertical_offset + scrolloff + count, + Align::Center => view.offset.vertical_offset + (last_visual_line / 2), + Align::Bottom => { + view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff + count) + } } - .max(view.offset.row + scrolloff) - .min(last_line.saturating_sub(scrolloff)); + .max(view.offset.vertical_offset + scrolloff) + .min(view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff)); + + let pos = view + .pos_at_visual_coords(doc, visual_line as u16, 0, false) + .expect("visual_line was constrained to the view area"); - let pos = doc.text().line_to_char(line); let text = doc.text().slice(..); let selection = doc .selection(view.id) @@ -1385,53 +1446,72 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let range = doc.selection(view.id).primary(); let text = doc.text().slice(..); - let cursor = visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); - let doc_last_line = doc.text().len_lines().saturating_sub(1); - - let last_line = view.last_line(doc); - - if direction == Backward && view.offset.row == 0 - || direction == Forward && last_line == doc_last_line - { - return; - } - + let cursor = range.cursor(text); let height = view.inner_height(); let scrolloff = config.scrolloff.min(height / 2); + let offset = match direction { + Forward => offset as isize, + Backward => -(offset as isize), + }; - view.offset.row = match direction { - Forward => view.offset.row + offset, - Backward => view.offset.row.saturating_sub(offset), - } - .min(doc_last_line); - - // recalculate last line - let last_line = view.last_line(doc); - - // clamp into viewport - let line = cursor - .row - .max(view.offset.row + scrolloff) - .min(last_line.saturating_sub(scrolloff)); + 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); + (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + view.offset.vertical_offset as isize + offset, + 0, + &text_fmt, + &annotations, + ); - // If cursor needs moving, replace primary selection - if line != cursor.row { - let head = pos_at_visual_coords(text, Position::new(line, cursor.col), doc.tab_width()); // this func will properly truncate to line end + let head; + match direction { + Forward => { + head = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + (view.offset.vertical_offset + scrolloff) as isize, + 0, + &text_fmt, + &annotations, + ) + .0; + if head <= cursor { + return; + } + } + Backward => { + head = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + (view.offset.vertical_offset + height - scrolloff) as isize, + 0, + &text_fmt, + &annotations, + ) + .0; + if head >= cursor { + return; + } + } + } - let anchor = if cx.editor.mode == Mode::Select { - range.anchor - } else { - head - }; + let anchor = if cx.editor.mode == Mode::Select { + range.anchor + } else { + head + }; - // replace primary selection with an empty selection at cursor pos - let prim_sel = Range::new(anchor, head); - let mut sel = doc.selection(view.id).clone(); - let idx = sel.primary_index(); - sel = sel.replace(idx, prim_sel); - doc.set_selection(view.id, sel); - } + // replace primary selection with an empty selection at cursor pos + let prim_sel = Range::new(anchor, head); + let mut sel = doc.selection(view.id).clone(); + let idx = sel.primary_index(); + sel = sel.replace(idx, prim_sel); + doc.set_selection(view.id, sel); } fn page_up(cx: &mut Context) { @@ -1458,7 +1538,15 @@ fn half_page_down(cx: &mut Context) { scroll(cx, offset, Direction::Forward); } +#[allow(deprecated)] +// currently uses the deprected `visual_coords_at_pos`/`pos_at_visual_coords` functions +// as this function ignores softwrapping (and virtual text) and instead only cares +// about "text visual position" +// +// TODO: implement a variant of that uses visual lines and respects virtual text fn copy_selection_on_line(cx: &mut Context, direction: Direction) { + use helix_core::{pos_at_visual_coords, visual_coords_at_pos}; + let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -4475,11 +4563,19 @@ fn align_view_bottom(cx: &mut Context) { fn align_view_middle(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let pos = doc.selection(view.id).primary().cursor(text); - let pos = coords_at_pos(text, pos); + let inner_width = view.inner_width(doc); + let text_fmt = doc.text_format(inner_width, None); + // there is no horizontal position when softwrap is enabled + if text_fmt.soft_wrap { + return; + } + let doc_text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); + let pos = doc.selection(view.id).primary().cursor(doc_text); + let pos = + visual_offset_from_block(doc_text, view.offset.anchor, pos, &text_fmt, &annotations).0; - view.offset.col = pos + view.offset.horizontal_offset = pos .col .saturating_sub((view.inner_area(doc).width as usize) / 2); } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index d48e6935..01184f80 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -7,8 +7,8 @@ use helix_core::hashmap; pub fn default() -> HashMap { let normal = keymap!({ "Normal mode" "h" | "left" => move_char_left, - "j" | "down" => move_line_down, - "k" | "up" => move_line_up, + "j" | "down" => move_visual_line_down, + "k" | "up" => move_visual_line_up, "l" | "right" => move_char_right, "t" => find_till_char, @@ -55,6 +55,8 @@ pub fn default() -> HashMap { "m" => goto_last_modified_file, "n" => goto_next_buffer, "p" => goto_previous_buffer, + "k" => move_line_up, + "j" => move_line_down, "." => goto_last_modification, }, ":" => command_mode, @@ -321,8 +323,8 @@ pub fn default() -> HashMap { let mut select = normal.clone(); select.merge_nodes(keymap!({ "Select mode" "h" | "left" => extend_char_left, - "j" | "down" => extend_line_down, - "k" | "up" => extend_line_up, + "j" | "down" => extend_visual_line_down, + "k" | "up" => extend_visual_line_up, "l" | "right" => extend_char_right, "w" => extend_next_word_start, @@ -345,6 +347,10 @@ pub fn default() -> HashMap { "esc" => exit_select_mode, "v" => normal_mode, + "g" => { "Goto" + "k" => extend_line_up, + "j" => extend_line_down, + }, })); let insert = keymap!({ "Insert mode" "esc" => normal_mode, @@ -362,8 +368,8 @@ pub fn default() -> HashMap { "C-j" | "ret" => insert_newline, "tab" => insert_tab, - "up" => move_line_up, - "down" => move_line_down, + "up" => move_visual_line_up, + "down" => move_visual_line_down, "left" => move_char_left, "right" => move_char_right, "pageup" => page_up, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 2eca709d..90e2fed0 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -391,8 +391,10 @@ impl Component for Completion { let language = doc.language_name().unwrap_or(""); let text = doc.text().slice(..); let cursor_pos = doc.selection(view.id).primary().cursor(text); - let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width()); - let cursor_pos = (coords.row - view.offset.row) as u16; + let coords = view + .screen_coords_at_pos(doc, text, cursor_pos) + .expect("cursor must be in view"); + let cursor_pos = coords.row as u16; let mut markdown_doc = match &option.documentation { Some(lsp::Documentation::String(contents)) 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); + } + } +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index a0518964..f297b44e 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -4,7 +4,10 @@ use crate::{ job::{self, Callback}, key, keymap::{KeymapResult, Keymaps}, - ui::{Completion, ProgressSpinners}, + ui::{ + document::{render_document, LinePos, TextRenderer, TranslatedPosition}, + Completion, ProgressSpinners, + }, }; use helix_core::{ @@ -13,8 +16,9 @@ use helix_core::{ }, movement::Direction, syntax::{self, HighlightEvent}, + text_annotations::TextAnnotations, unicode::width::UnicodeWidthStr, - visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, + visual_offset_from_block, Position, Range, Selection, Transaction, }; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, @@ -24,12 +28,12 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{borrow::Cow, cmp::min, num::NonZeroUsize, path::PathBuf}; +use std::{num::NonZeroUsize, path::PathBuf, rc::Rc}; use tui::buffer::Buffer as Surface; -use super::lsp::SignatureHelp; use super::statusline; +use super::{document::LineDecoration, lsp::SignatureHelp}; pub struct EditorView { pub keymaps: Keymaps, @@ -83,6 +87,10 @@ impl EditorView { let theme = &editor.theme; 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(); + // DAP: Highlight current stack frame position let stack_frame = editor.debugger.as_ref().and_then(|debugger| { if let (Some(frame), Some(thread_id)) = (debugger.active_frame, debugger.thread_id) { @@ -103,28 +111,40 @@ impl EditorView { == doc.path() { let line = frame.line - 1; // convert to 0-indexing - if line >= view.offset.row && line < view.offset.row + area.height as usize { - surface.set_style( - Rect::new( - area.x, - area.y + (line - view.offset.row) as u16, - area.width, - 1, - ), - theme.get("ui.highlight"), - ); - } + let style = theme.get("ui.highlight"); + let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + if pos.doc_line != line { + return; + } + renderer + .surface + .set_style(Rect::new(area.x, pos.visual_line, area.width, 1), style); + }; + + line_decorations.push(Box::new(line_decoration)); } } if is_focused && config.cursorline { - Self::highlight_cursorline(doc, view, surface, theme); + line_decorations.push(Self::cursorline_decorator(doc, view, theme)) } + if is_focused && config.cursorcolumn { - Self::highlight_cursorcolumn(doc, view, surface, theme); + Self::highlight_cursorcolumn(doc, view, surface, theme, inner, &text_annotations); + } + + let mut highlights = + Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme); + let overlay_highlights = Self::overlay_syntax_highlights( + doc, + view.offset.anchor, + inner.height, + &text_annotations, + ); + if !overlay_highlights.is_empty() { + highlights = Box::new(syntax::merge(highlights, overlay_highlights)); } - let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); for diagnostic in Self::doc_diagnostics_highlights(doc, theme) { // Most of the `diagnostic` Vecs are empty most of the time. Skipping // a merge for any empty Vec saves a significant amount of work. @@ -133,8 +153,9 @@ impl EditorView { } highlights = Box::new(syntax::merge(highlights, diagnostic)); } + let highlights: Box> = if is_focused { - Box::new(syntax::merge( + let highlights = syntax::merge( highlights, Self::doc_selection_highlights( editor.mode(), @@ -143,19 +164,52 @@ impl EditorView { theme, &config.cursor_shape, ), - )) + ); + let focused_view_elements = Self::highlight_focused_view_elements(view, doc, theme); + if focused_view_elements.is_empty() { + Box::new(highlights) + } else { + Box::new(syntax::merge(highlights, focused_view_elements)) + } } else { Box::new(highlights) }; - Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights, &config); - Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); - Self::render_rulers(editor, doc, view, inner, surface, theme); + Self::render_gutter( + editor, + doc, + view, + view.area, + theme, + is_focused, + &mut line_decorations, + ); if is_focused { - Self::render_focused_view_elements(view, doc, inner, theme, surface); + 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))); } + render_document( + surface, + inner, + doc, + view.offset, + &text_annotations, + highlights, + theme, + &mut line_decorations, + &mut *translated_positions, + ); + Self::render_rulers(editor, doc, view, inner, surface, theme); + // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { let x = area.right(); @@ -203,31 +257,53 @@ impl EditorView { .iter() // View might be horizontally scrolled, convert from absolute distance // from the 1st column to relative distance from left of viewport - .filter_map(|ruler| ruler.checked_sub(1 + view.offset.col as u16)) + .filter_map(|ruler| ruler.checked_sub(1 + view.offset.horizontal_offset as u16)) .filter(|ruler| ruler < &viewport.width) .map(|ruler| viewport.clip_left(ruler).with_width(1)) .for_each(|area| surface.set_style(area, ruler_theme)) } + pub fn overlay_syntax_highlights( + doc: &Document, + anchor: usize, + height: u16, + text_annotations: &TextAnnotations, + ) -> Vec<(usize, std::ops::Range)> { + let text = doc.text().slice(..); + let row = text.char_to_line(anchor.min(text.len_chars())); + + let range = { + // Calculate viewport byte ranges: + // Saturating subs to make it inclusive zero indexing. + let last_line = text.len_lines().saturating_sub(1); + let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); + let start = text.line_to_byte(row.min(last_line)); + let end = text.line_to_byte(last_visible_line + 1); + + start..end + }; + + text_annotations.collect_overlay_highlights(range) + } + /// Get syntax highlights for a document in a view represented by the first line /// and column (`offset`) and the last line. This is done instead of using a view /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) pub fn doc_syntax_highlights<'doc>( doc: &'doc Document, - offset: Position, + anchor: usize, height: u16, _theme: &Theme, ) -> Box + 'doc> { let text = doc.text().slice(..); + let row = text.char_to_line(anchor.min(text.len_chars())); let range = { // Calculate viewport byte ranges: // Saturating subs to make it inclusive zero indexing. - let last_line = doc.text().len_lines().saturating_sub(1); - let last_visible_line = (offset.row + height as usize) - .saturating_sub(1) - .min(last_line); - let start = text.line_to_byte(offset.row.min(last_line)); + let last_line = text.len_lines().saturating_sub(1); + let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); + let start = text.line_to_byte(row.min(last_line)); let end = text.line_to_byte(last_visible_line + 1); start..end @@ -272,11 +348,11 @@ impl EditorView { use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme - .find_scope_index(scope) + .find_scope_index_exact(scope) // get one of the themes below as fallback values - .or_else(|| theme.find_scope_index("diagnostic")) - .or_else(|| theme.find_scope_index("ui.cursor")) - .or_else(|| theme.find_scope_index("ui.selection")) + .or_else(|| theme.find_scope_index_exact("diagnostic")) + .or_else(|| theme.find_scope_index_exact("ui.cursor")) + .or_else(|| theme.find_scope_index_exact("ui.selection")) .expect( "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", ) @@ -339,29 +415,29 @@ impl EditorView { let cursor_is_block = cursorkind == CursorKind::Block; let selection_scope = theme - .find_scope_index("ui.selection") + .find_scope_index_exact("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); let primary_selection_scope = theme - .find_scope_index("ui.selection.primary") + .find_scope_index_exact("ui.selection.primary") .unwrap_or(selection_scope); let base_cursor_scope = theme - .find_scope_index("ui.cursor") + .find_scope_index_exact("ui.cursor") .unwrap_or(selection_scope); let base_primary_cursor_scope = theme .find_scope_index("ui.cursor.primary") .unwrap_or(base_cursor_scope); let cursor_scope = match mode { - Mode::Insert => theme.find_scope_index("ui.cursor.insert"), - Mode::Select => theme.find_scope_index("ui.cursor.select"), - Mode::Normal => theme.find_scope_index("ui.cursor.normal"), + Mode::Insert => theme.find_scope_index_exact("ui.cursor.insert"), + Mode::Select => theme.find_scope_index_exact("ui.cursor.select"), + Mode::Normal => theme.find_scope_index_exact("ui.cursor.normal"), } .unwrap_or(base_cursor_scope); let primary_cursor_scope = match mode { - Mode::Insert => theme.find_scope_index("ui.cursor.primary.insert"), - Mode::Select => theme.find_scope_index("ui.cursor.primary.select"), - Mode::Normal => theme.find_scope_index("ui.cursor.primary.normal"), + Mode::Insert => theme.find_scope_index_exact("ui.cursor.primary.insert"), + Mode::Select => theme.find_scope_index_exact("ui.cursor.primary.select"), + Mode::Normal => theme.find_scope_index_exact("ui.cursor.primary.normal"), } .unwrap_or(base_primary_cursor_scope); @@ -424,248 +500,26 @@ impl EditorView { spans } - pub fn render_text_highlights>( - doc: &Document, - offset: Position, - viewport: Rect, - surface: &mut Surface, - theme: &Theme, - highlights: H, - config: &helix_view::editor::Config, - ) { - let whitespace = &config.whitespace; - use helix_view::editor::WhitespaceRenderValue; - - // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch - // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). - let text = doc.text().slice(..); - - let characters = &whitespace.characters; - - let mut spans = Vec::new(); - let mut visual_x = 0usize; - let mut line = 0u16; - let tab_width = doc.tab_width(); - let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { - std::iter::once(characters.tab) - .chain(std::iter::repeat(characters.tabpad).take(tab_width - 1)) - .collect() - } else { - " ".repeat(tab_width) - }; - let space = characters.space.to_string(); - let nbsp = characters.nbsp.to_string(); - let newline = if whitespace.render.newline() == WhitespaceRenderValue::All { - characters.newline.to_string() - } else { - " ".to_string() - }; - let indent_guide_char = config.indent_guides.character.to_string(); - - let text_style = theme.get("ui.text"); - let whitespace_style = theme.get("ui.virtual.whitespace"); - - let mut is_in_indent_area = true; - let mut last_line_indent_level = 0; - - // use whitespace style as fallback for indent-guide - let indent_guide_style = text_style.patch( - theme - .try_get("ui.virtual.indent-guide") - .unwrap_or_else(|| theme.get("ui.virtual.whitespace")), - ); - - let draw_indent_guides = |indent_level, line, surface: &mut Surface| { - if !config.indent_guides.render { - return; - } - - let starting_indent = - (offset.col / tab_width) + config.indent_guides.skip_levels as usize; - - // 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 - offset.col + viewport.width as usize + (tab_width - 1), - ) / tab_width; - - for i in starting_indent..end_indent { - let x = (viewport.x as usize + (i * tab_width) - offset.col) as u16; - let y = viewport.y + line; - debug_assert!(surface.in_bounds(x, y)); - surface.set_string(x, y, &indent_guide_char, indent_guide_style); - } - }; - - 'outer: for event in highlights { - match event { - HighlightEvent::HighlightStart(span) => { - spans.push(span); - } - HighlightEvent::HighlightEnd => { - spans.pop(); - } - HighlightEvent::Source { start, end } => { - let is_trailing_cursor = text.len_chars() < end; - - // `unwrap_or_else` part is for off-the-end indices of - // the rope, to allow cursor highlighting at the end - // of the rope. - let text = text.get_slice(start..end).unwrap_or_else(|| " ".into()); - let style = spans - .iter() - .fold(text_style, |acc, span| acc.patch(theme.highlight(span.0))); - - let space = if whitespace.render.space() == WhitespaceRenderValue::All - && !is_trailing_cursor - { - &space - } else { - " " - }; - - let nbsp = if whitespace.render.nbsp() == WhitespaceRenderValue::All - && text.len_chars() < end - { -   - } else { - " " - }; - - use helix_core::graphemes::{grapheme_width, RopeGraphemes}; - - for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = offset.col > visual_x - || visual_x >= viewport.width as usize + offset.col; - - if LineEnding::from_rope_slice(&grapheme).is_some() { - if !out_of_bounds { - // we still want to render an empty cell with the style - surface.set_string( - (viewport.x as usize + visual_x - offset.col) as u16, - viewport.y + line, - &newline, - style.patch(whitespace_style), - ); - } - - draw_indent_guides(last_line_indent_level, line, surface); - - visual_x = 0; - line += 1; - is_in_indent_area = true; - - // TODO: with proper iter this shouldn't be necessary - if line >= viewport.height { - break 'outer; - } - } else { - let grapheme = Cow::from(grapheme); - let is_whitespace; - - let (display_grapheme, width) = if grapheme == "\t" { - is_whitespace = true; - // make sure we display tab as appropriate amount of spaces - let visual_tab_width = tab_width - (visual_x % tab_width); - let grapheme_tab_width = - helix_core::str_utils::char_to_byte_idx(&tab, visual_tab_width); - - (&tab[..grapheme_tab_width], visual_tab_width) - } else if grapheme == " " { - is_whitespace = true; - (space, 1) - } else if grapheme == "\u{00A0}" { - is_whitespace = true; - (nbsp, 1) - } else { - is_whitespace = false; - // Cow will prevent allocations if span contained in a single slice - // which should really be the majority case - let width = grapheme_width(&grapheme); - (grapheme.as_ref(), width) - }; - - let cut_off_start = offset.col.saturating_sub(visual_x); - - if !out_of_bounds { - // if we're offscreen just keep going until we hit a new line - surface.set_string( - (viewport.x as usize + visual_x - offset.col) as u16, - viewport.y + line, - display_grapheme, - if is_whitespace { - style.patch(whitespace_style) - } else { - style - }, - ); - } else if cut_off_start != 0 && cut_off_start < width { - // partially on screen - let rect = Rect::new( - viewport.x, - viewport.y + line, - (width - cut_off_start) as u16, - 1, - ); - surface.set_style( - rect, - if is_whitespace { - style.patch(whitespace_style) - } else { - style - }, - ); - } - - if is_in_indent_area && !(grapheme == " " || grapheme == "\t") { - draw_indent_guides(visual_x, line, surface); - is_in_indent_area = false; - last_line_indent_level = visual_x; - } - - visual_x = visual_x.saturating_add(width); - } - } - } - } - } - } - /// Render brace match, etc (meant for the focused view only) - pub fn render_focused_view_elements( + pub fn highlight_focused_view_elements( view: &View, doc: &Document, - viewport: Rect, theme: &Theme, - surface: &mut Surface, - ) { + ) -> Vec<(usize, std::ops::Range)> { // Highlight matching braces if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); use helix_core::match_brackets; let pos = doc.selection(view.id).primary().cursor(text); - let pos = match_brackets::find_matching_bracket(syntax, doc.text(), pos) - .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); - - if let Some(pos) = pos { + if let Some(pos) = match_brackets::find_matching_bracket(syntax, doc.text(), pos) { // ensure col is on screen - if (pos.col as u16) < viewport.width + view.offset.col as u16 - && pos.col >= view.offset.col - { - let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { - Style::default() - .add_modifier(Modifier::REVERSED) - .add_modifier(Modifier::DIM) - }); - - surface[(viewport.x + pos.col as u16, viewport.y + pos.row as u16)] - .set_style(style); + if let Some(highlight) = theme.find_scope_index_exact("ui.cursor.match") { + return vec![(highlight, pos..pos + 1)]; } } } + Vec::new() } /// Render bufferline at the top @@ -721,22 +575,17 @@ impl EditorView { } } - pub fn render_gutter( - editor: &Editor, - doc: &Document, + pub fn render_gutter<'d>( + editor: &'d Editor, + doc: &'d Document, view: &View, viewport: Rect, - surface: &mut Surface, theme: &Theme, is_focused: bool, + line_decorations: &mut Vec>, ) { let text = doc.text().slice(..); - let last_line = view.last_line(doc); - - // it's used inside an iterator so the collect isn't needless: - // https://github.com/rust-lang/rust-clippy/issues/6164 - #[allow(clippy::needless_collect)] - let cursors: Vec<_> = doc + let cursors: Rc<[_]> = doc .selection(view.id) .iter() .map(|range| range.cursor_line(text)) @@ -746,29 +595,36 @@ impl EditorView { let gutter_style = theme.get("ui.gutter"); let gutter_selected_style = theme.get("ui.gutter.selected"); - - // avoid lots of small allocations by reusing a text buffer for each line - let mut text = String::with_capacity(8); + let gutter_style_virtual = theme.get("ui.gutter.virtual"); + let gutter_selected_style_virtual = theme.get("ui.gutter.selected.virtual"); for gutter_type in view.gutters() { let mut gutter = gutter_type.style(editor, doc, view, theme, is_focused); let width = gutter_type.width(view, doc); - text.reserve(width); // ensure there's enough space for the gutter - for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { - let selected = cursors.contains(&line); + // avoid lots of small allocations by reusing a text buffer for each line + let mut text = String::with_capacity(width); + let cursors = cursors.clone(); + let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + // TODO handle softwrap in gutters + let selected = cursors.contains(&pos.doc_line); let x = viewport.x + offset; - let y = viewport.y + i as u16; + let y = viewport.y + pos.visual_line; - let gutter_style = if selected { - gutter_selected_style - } else { - gutter_style + let gutter_style = match (selected, pos.first_visual_line) { + (false, true) => gutter_style, + (true, true) => gutter_selected_style, + (false, false) => gutter_style_virtual, + (true, false) => gutter_selected_style_virtual, }; - if let Some(style) = gutter(line, selected, &mut text) { - surface.set_stringn(x, y, &text, width, gutter_style.patch(style)); + if let Some(style) = + gutter(pos.doc_line, selected, pos.first_visual_line, &mut text) + { + renderer + .surface + .set_stringn(x, y, &text, width, gutter_style.patch(style)); } else { - surface.set_style( + renderer.surface.set_style( Rect { x, y, @@ -779,7 +635,8 @@ impl EditorView { ); } text.clear(); - } + }; + line_decorations.push(Box::new(gutter_decoration)); offset += width as u16; } @@ -840,10 +697,13 @@ impl EditorView { } /// Apply the highlighting on the lines where a cursor is active - pub fn highlight_cursorline(doc: &Document, view: &View, surface: &mut Surface, theme: &Theme) { + pub fn cursorline_decorator( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Box { let text = doc.text().slice(..); - let last_line = view.last_line(doc); - + // 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); // The secondary_lines do contain the primary_line, it doesn't matter @@ -860,20 +720,23 @@ impl EditorView { let primary_style = theme.get("ui.cursorline.primary"); let secondary_style = theme.get("ui.cursorline.secondary"); + let viewport = view.area; - for line in view.offset.row..(last_line + 1) { + let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { let area = Rect::new( - view.area.x, - view.area.y + (line - view.offset.row) as u16, - view.area.width, + viewport.x, + viewport.y + pos.visual_line as u16, + viewport.width, 1, ); - if primary_line == line { - surface.set_style(area, primary_style); - } else if secondary_lines.binary_search(&line).is_ok() { - surface.set_style(area, secondary_style); + 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 @@ -882,6 +745,8 @@ impl EditorView { view: &View, surface: &mut Surface, theme: &Theme, + viewport: Rect, + text_annotations: &TextAnnotations, ) { let text = doc.text().slice(..); @@ -897,19 +762,23 @@ impl EditorView { .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); let inner_area = view.inner_area(doc); - let offset = view.offset.col; let selection = doc.selection(view.id); let primary = selection.primary(); + let text_format = doc.text_format(viewport.width, None); for range in selection.iter() { let is_primary = primary == *range; + let cursor = range.cursor(text); + + let Position { col, .. } = + visual_offset_from_block(text, cursor, cursor, &text_format, text_annotations).0; - let Position { row: _, col } = - visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); // if the cursor is horizontally in the view - if col >= offset && inner_area.width > (col - offset) as u16 { + if col >= view.offset.horizontal_offset + && inner_area.width > (col - view.offset.horizontal_offset) as u16 + { let area = Rect::new( - inner_area.x + (col - offset) as u16, + inner_area.x + (col - view.offset.horizontal_offset) as u16, view.area.y, 1, view.area.height, @@ -1149,7 +1018,7 @@ impl EditorView { let pos_and_view = |editor: &Editor, row, column| { editor.tree.views().find_map(|(view, _focus)| { - view.pos_at_screen_coords(&editor.documents[&view.doc], row, column) + view.pos_at_screen_coords(&editor.documents[&view.doc], row, column, true) .map(|pos| (pos, view.id)) }) }; @@ -1191,8 +1060,10 @@ impl EditorView { None => return EventResult::Ignored(None), }; - let line = coords.row + view.offset.row; - if line < doc.text().len_lines() { + if let Some(char_idx) = + view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) + { + let line = doc.text().char_to_line(char_idx); commands::dap_toggle_breakpoint_impl(cxt, path, line); return EventResult::Consumed(None); } @@ -1204,7 +1075,7 @@ impl EditorView { MouseEventKind::Drag(MouseButton::Left) => { let (view, doc) = current!(cxt.editor); - let pos = match view.pos_at_screen_coords(doc, row, column) { + let pos = match view.pos_at_screen_coords(doc, row, column, true) { Some(pos) => pos, None => return EventResult::Ignored(None), }; @@ -1268,8 +1139,9 @@ impl EditorView { cxt.editor.focus(view_id); let (view, doc) = current!(cxt.editor); - let line = coords.row + view.offset.row; - if let Ok(pos) = doc.text().try_line_to_char(line) { + if let Some(pos) = + view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) + { doc.set_selection(view_id, Selection::point(pos)); if modifiers == KeyModifiers::ALT { commands::MappableCommand::dap_edit_log.execute(cxt); diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index 393d24c4..44050aa1 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -53,7 +53,10 @@ impl Component for SignatureHelp { let active_param_span = self.active_param_range.map(|(start, end)| { vec![( - cx.editor.theme.find_scope_index("ui.selection").unwrap(), + cx.editor + .theme + .find_scope_index_exact("ui.selection") + .unwrap(), start..end, )] }); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index eb480758..5e7f8c36 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,4 +1,5 @@ mod completion; +mod document; pub(crate) mod editor; mod fuzzy_match; mod info; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 6bd64251..5fa75136 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -2,7 +2,12 @@ use crate::{ alt, compositor::{Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, - ui::{self, fuzzy_match::FuzzyQuery, EditorView}, + ui::{ + self, + document::{render_document, LineDecoration, LinePos, TextRenderer}, + fuzzy_match::FuzzyQuery, + EditorView, + }, }; use futures_util::future::BoxFuture; use tui::{ @@ -19,11 +24,15 @@ use std::cmp::{self, Ordering}; use std::{collections::HashMap, io::Read, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; -use helix_core::{movement::Direction, unicode::segmentation::UnicodeSegmentation, Position}; +use helix_core::{ + movement::Direction, text_annotations::TextAnnotations, + unicode::segmentation::UnicodeSegmentation, Position, +}; use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, theme::Style, + view::ViewPosition, Document, DocumentId, Editor, }; @@ -179,7 +188,7 @@ impl FilePicker { } _ => { // TODO: enable syntax highlighting; blocked by async rendering - Document::open(path, None, None) + Document::open(path, None, None, editor.config.clone()) .map(|doc| CachedPreview::Document(Box::new(doc))) .unwrap_or(CachedPreview::NotFound) } @@ -283,43 +292,57 @@ impl Component for FilePicker { }) .unwrap_or(0); - let offset = Position::new(first_line, 0); + let offset = ViewPosition { + anchor: doc.text().line_to_char(first_line), + horizontal_offset: 0, + vertical_offset: 0, + }; - let mut highlights = - EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); + let mut highlights = EditorView::doc_syntax_highlights( + doc, + offset.anchor, + area.height, + &cx.editor.theme, + ); for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) { if spans.is_empty() { continue; } highlights = Box::new(helix_core::syntax::merge(highlights, spans)); } - EditorView::render_text_highlights( + let mut decorations: Vec> = Vec::new(); + + if let Some((start, end)) = range { + let style = cx + .editor + .theme + .try_get("ui.highlight") + .unwrap_or_else(|| cx.editor.theme.get("ui.selection")); + let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| { + if (start..=end).contains(&pos.doc_line) { + let area = Rect::new( + renderer.viewport.x, + renderer.viewport.y + pos.visual_line, + renderer.viewport.width, + 1, + ); + renderer.surface.set_style(area, style) + } + }; + decorations.push(Box::new(draw_highlight)) + } + + render_document( + surface, + inner, doc, offset, - inner, - surface, - &cx.editor.theme, + &TextAnnotations::default(), highlights, - &cx.editor.config(), + &cx.editor.theme, + &mut decorations, + &mut [], ); - - // highlight the line - if let Some((start, end)) = range { - let offset = start.saturating_sub(first_line) as u16; - surface.set_style( - Rect::new( - inner.x, - inner.y + offset, - inner.width, - (end.saturating_sub(start) as u16 + 1) - .min(inner.height.saturating_sub(offset)), - ), - cx.editor - .theme - .try_get("ui.highlight") - .unwrap_or_else(|| cx.editor.theme.get("ui.selection")), - ); - } } } -- cgit v1.2.3-70-g09d2