From 04d1f32d1e036eeaf7fd9892476c303460e30ec0 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Sun, 4 Dec 2022 00:44:44 +0100 Subject: [PATCH] rework text rendering and integrate softwrap rendering --- helix-core/src/graphemes.rs | 12 + helix-term/src/ui/document.rs | 207 ++++++++++++ helix-term/src/ui/document/cursor.rs | 194 +++++++++++ helix-term/src/ui/document/text_render.rs | 375 ++++++++++++++++++++++ helix-term/src/ui/editor.rs | 261 ++++----------- helix-term/src/ui/mod.rs | 1 + helix-view/src/editor.rs | 39 +++ helix-view/src/view.rs | 4 +- 8 files changed, 888 insertions(+), 205 deletions(-) create mode 100644 helix-term/src/ui/document.rs create mode 100644 helix-term/src/ui/document/cursor.rs create mode 100644 helix-term/src/ui/document/text_render.rs diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 675f57505c09..2b73c01b6988 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -300,6 +300,18 @@ impl<'a> RopeGraphemes<'a> { cursor: GraphemeCursor::new(0, slice.len_bytes(), true), } } + + /// Advances to `byte_pos` if it is at a grapheme boundrary + /// otherwise advances to the next grapheme boundrary after byte + pub fn advance_to(&mut self, byte_pos: usize) { + while byte_pos > self.byte_pos() { + self.next(); + } + } + + pub fn byte_pos(&self) -> usize { + self.cursor.cur_cursor() + } } impl<'a> Iterator for RopeGraphemes<'a> { diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs new file mode 100644 index 000000000000..daad78026cc9 --- /dev/null +++ b/helix-term/src/ui/document.rs @@ -0,0 +1,207 @@ +use helix_core::syntax::Highlight; +use helix_core::RopeSlice; +use helix_core::{syntax::HighlightEvent, Position}; +use helix_view::editor; +use helix_view::{graphics::Rect, Theme}; + +use crate::ui::document::cursor::WordBoundary; +use crate::ui::document::text_render::{Grapheme, IndentLevel, StyledGrapheme}; +pub use cursor::DocumentCursor; +pub use text_render::{TextRender, TextRenderConfig}; + +mod cursor; +mod text_render; + +pub struct DocumentRender<'a, H: Iterator> { + pub config: &'a editor::Config, + pub theme: &'a Theme, + + pub text: RopeSlice<'a>, + highlights: H, + + pub cursor: DocumentCursor<'a>, + spans: Vec, + + finished: bool, +} + +impl<'a, H: Iterator> DocumentRender<'a, H> { + #[allow(clippy::too_many_arguments)] + pub fn new( + config: &'a editor::Config, + theme: &'a Theme, + text: RopeSlice<'a>, + highlights: H, + viewport: Rect, + offset: Position, + char_offset: usize, + text_render: &mut TextRender, + ) -> Self { + let mut render = DocumentRender { + config, + theme, + text, + highlights, + cursor: DocumentCursor::new(text, offset.row, char_offset, config, &viewport), + // render: TextRender::new(surface, render_config, offset.col, viewport), + spans: Vec::with_capacity(64), + finished: false, + }; + + // advance to first highlight scope + render.advance_highlight_scope(text_render); + render + } + + /// Returns the line in the doucment that will be rendered next + pub fn doc_line(&self) -> usize { + self.cursor.doc_position().row + } + + fn advance_highlight_scope(&mut self, text_render: &mut TextRender) { + while let Some(event) = self.highlights.next() { + match event { + HighlightEvent::HighlightStart(span) => self.spans.push(span), + HighlightEvent::HighlightEnd => { + self.spans.pop(); + } + HighlightEvent::Source { start, end } => { + if start == end { + continue; + } + // TODO cursor end + let style = self + .spans + .iter() + .fold(text_render.config.text_style, |acc, span| { + acc.patch(self.theme.highlight(span.0)) + }); + self.cursor.set_highlight_scope(start, end, style); + return; + } + } + } + self.finished = true; + } + + /// Perform a softwrap + fn wrap_line(&mut self, text_render: &mut TextRender) { + // Fully wrapping this word would wrap too much wrap + // wrap inside the word instead + if self.cursor.word_width() > self.cursor.config.max_wrap { + self.cursor.take_word_buf_until(|grapheme| { + if text_render.space_left() < grapheme.min_width() as usize { + // leave this grapheme in the word_buf + Some(grapheme) + } else { + text_render.draw_grapheme(grapheme); + None + } + }); + } + + let indent_level = match text_render.indent_level() { + IndentLevel::Known(indent_level) + if indent_level <= self.cursor.config.max_indent_retain => + { + IndentLevel::Known(indent_level) + } + _ => IndentLevel::None, + }; + self.advance_line(indent_level, text_render); + text_render.skip(self.config.soft_wrap.wrap_indent); + } + + /// Returns whether this document renderer finished rendering + /// either because the viewport end or EOF was reached + pub fn finished(&self) -> bool { + self.finished + } + + /// Renders the next line of the document. + /// If softwrapping is enabled this may only correspond to rendering a part of the line + /// + /// # Returns + /// + /// Whether the rendered line was only partially rendered because the viewport end was reached + pub fn render_line(&mut self, text_render: &mut TextRender) { + if self.finished { + return; + } + + loop { + while let Some(word_boundry) = self.cursor.advance(if self.config.soft_wrap.enable { + text_render.space_left() + } else { + // limit word size to maintain fast performance for large lines + 64 + }) { + if self.config.soft_wrap.enable && word_boundry == WordBoundary::Wrap { + self.wrap_line(text_render); + return; + } + + self.render_word(text_render); + + if word_boundry == WordBoundary::Newline { + // render EOL space + text_render.draw_grapheme(StyledGrapheme { + grapheme: Grapheme::Space, + style: self.cursor.get_highlight_scope().1, + }); + self.advance_line(IndentLevel::Unkown, text_render); + self.finished = text_render.reached_viewport_end(); + return; + } + + if text_render.reached_viewport_end() { + self.finished = true; + return; + } + } + + self.advance_highlight_scope(text_render); + + // we properly reached the text end, this is the end of the last line + // render remaining text + if self.finished { + self.render_word(text_render); + + if self.cursor.get_highlight_scope().0 > self.text.len_chars() { + // trailing cursor is rendered as a whitespace + text_render.draw_grapheme(StyledGrapheme { + grapheme: Grapheme::Space, + style: self.cursor.get_highlight_scope().1, + }); + } + + self.finish_line(text_render); + return; + } + + // we reached the viewport end but the line was only partially rendered + if text_render.reached_viewport_end() { + self.finish_line(text_render); + self.finished = true; + return; + } + } + } + + fn render_word(&mut self, text_render: &mut TextRender) { + for grapheme in self.cursor.take_word_buf() { + text_render.draw_grapheme(grapheme); + } + } + + fn finish_line(&mut self, text_render: &mut TextRender) { + if self.config.indent_guides.render { + text_render.draw_indent_guides() + } + } + + fn advance_line(&mut self, next_indent_level: IndentLevel, text_render: &mut TextRender) { + self.finish_line(text_render); + text_render.advance_to_next_line(next_indent_level); + } +} diff --git a/helix-term/src/ui/document/cursor.rs b/helix-term/src/ui/document/cursor.rs new file mode 100644 index 000000000000..ab1e129402fe --- /dev/null +++ b/helix-term/src/ui/document/cursor.rs @@ -0,0 +1,194 @@ +use std::borrow::Cow; +use std::mem::replace; + +use helix_core::{LineEnding, Position, RopeGraphemes, RopeSlice}; +use helix_view::editor; +use helix_view::graphics::Rect; +use helix_view::theme::Style; + +use crate::ui::document::text_render::StyledGrapheme; + +#[derive(Debug)] +pub struct DocumentCursorConfig { + pub max_wrap: usize, + pub max_indent_retain: usize, +} + +impl DocumentCursorConfig { + pub fn new(editor_config: &editor::Config, viewport: &Rect) -> DocumentCursorConfig { + DocumentCursorConfig { + // provide a lower limit to ensure wrapping works well for tiny screens (like the picker) + max_wrap: editor_config + .soft_wrap + .max_wrap + .min(viewport.width as usize / 4), + max_indent_retain: editor_config + .soft_wrap + .max_indent_retain + .min(viewport.width as usize / 4), + } + } +} + +// fn str_is_whitespace(text: &str) -> bool { +// text.chars().next().map_or(false, |c| c.is_whitespace()) +// } + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub enum WordBoundary { + Space, + Wrap, + Newline, +} + +#[derive(Debug)] +pub struct DocumentCursor<'a> { + pub config: DocumentCursorConfig, + char_pos: usize, + word_buf: Vec>, + word_width: usize, + graphemes: RopeGraphemes<'a>, + /// postition within the document + pos: Position, + highlight_scope: (usize, Style), +} + +impl<'a> DocumentCursor<'a> { + pub fn new( + text: RopeSlice<'a>, + start_line: usize, + start_char: usize, + editor_config: &editor::Config, + viewport: &Rect, + ) -> DocumentCursor<'a> { + DocumentCursor { + char_pos: start_char, + word_buf: Vec::with_capacity(if editor_config.soft_wrap.enable { + viewport.width as usize + } else { + 64 + }), + word_width: 0, + graphemes: RopeGraphemes::new(text), + pos: Position { + row: start_line, + col: 0, + }, + config: DocumentCursorConfig::new(editor_config, viewport), + highlight_scope: (0, Style::default()), + } + } + + // pub fn byte_pos(&self) -> usize { + // self.graphemes.byte_pos() + // } + + // pub fn char_pos(&self) -> usize { + // self.char_pos + // } + + pub fn doc_position(&self) -> Position { + self.pos + } + + pub fn set_highlight_scope( + &mut self, + scope_char_start: usize, + scope_char_end: usize, + style: Style, + ) { + debug_assert_eq!(self.char_pos, scope_char_start); + self.highlight_scope = (scope_char_end, style) + } + + pub fn get_highlight_scope(&self) -> (usize, Style) { + self.highlight_scope + } + + pub fn advance(&mut self, space_left: usize) -> Option { + loop { + if self.char_pos >= self.highlight_scope.0 { + debug_assert_eq!( + self.char_pos, self.highlight_scope.0, + "Highlight scope must be aligned to grapheme boundary" + ); + return None; + } + if let Some(grapheme) = self.graphemes.next() { + let codepoints = grapheme.len_chars(); + self.pos.col += codepoints; + self.char_pos += codepoints; + if let Some(word_end) = + self.push_grapheme::(grapheme, self.highlight_scope.1) + { + return Some(word_end); + } + + // we reached a point where we need to wrap + // yield to check if a force wrap is necessary + if self.word_width >= space_left { + return Some(WordBoundary::Wrap); + } + } else { + break; + } + } + None + } + + pub fn word_width(&self) -> usize { + self.word_width + } + + pub fn take_word_buf(&mut self) -> impl Iterator> + '_ { + self.word_width = 0; + self.word_buf.drain(..) + } + + pub fn take_word_buf_until( + &mut self, + mut f: impl FnMut(StyledGrapheme<'a>) -> Option>, + ) { + let mut taken_graphemes = 0; + for grapheme in &mut self.word_buf { + if let Some(old_val) = f(replace(grapheme, StyledGrapheme::placeholder())) { + *grapheme = old_val; + break; + } + self.word_width -= grapheme.min_width() as usize; + taken_graphemes += 1; + } + self.word_buf.drain(..taken_graphemes); + } + + /// inserts and additional grapheme into the current word + /// should + pub fn push_grapheme( + &mut self, + grapheme: RopeSlice<'a>, + style: Style, + ) -> Option { + let grapheme = Cow::from(grapheme); + + if LineEnding::from_str(&grapheme).is_some() { + // we reached EOL reset column and advance the row + // do not push a grapheme for the line end, instead let the caller handle decide that + debug_assert!(!VIRTUAL, "inline virtual text must not contain newlines"); + self.pos.row += 1; + self.pos.col = 0; + + return Some(WordBoundary::Newline); + } + + let grapheme = StyledGrapheme::new(grapheme, style); + self.word_width += grapheme.min_width() as usize; + let word_end = if grapheme.is_breaking_space() { + Some(WordBoundary::Space) + } else { + None + }; + + self.word_buf.push(grapheme); + word_end + } +} diff --git a/helix-term/src/ui/document/text_render.rs b/helix-term/src/ui/document/text_render.rs new file mode 100644 index 000000000000..51492de66513 --- /dev/null +++ b/helix-term/src/ui/document/text_render.rs @@ -0,0 +1,375 @@ +use std::borrow::Cow; +use std::cmp::min; + +use helix_core::graphemes::grapheme_width; +use helix_core::str_utils::char_to_byte_idx; +use helix_core::Position; +use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; +use helix_view::graphics::Rect; +use helix_view::theme::Style; +use helix_view::{editor, Document, Theme}; +use tui::buffer::Buffer as Surface; + +#[derive(Debug)] +/// Various constants required for text rendering. +pub struct TextRenderConfig { + 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, +} + +impl TextRenderConfig { + pub fn new( + doc: &Document, + editor_config: &editor::Config, + theme: &Theme, + offset: &Position, + ) -> TextRenderConfig { + 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"); + + TextRenderConfig { + 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: (offset.col / 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, + } + } +} + +#[derive(Debug)] +pub enum Grapheme<'a> { + Space, + Nbsp, + Tab, + Other { raw: Cow<'a, str>, width: u16 }, +} + +impl<'a> From> for Grapheme<'a> { + fn from(raw: Cow<'a, str>) -> Grapheme<'a> { + match &*raw { + "\t" => Grapheme::Tab, + " " => Grapheme::Space, + "\u{00A0}" => Grapheme::Nbsp, + _ => Grapheme::Other { + width: grapheme_width(&*raw) as u16, + raw, + }, + } + } +} + +impl<'a> Grapheme<'a> { + /// Returns the approximate visual width of this grapheme, + /// This serves as a lower bound for the width for use during soft wrapping. + /// The actual displayed witdth might be position dependent and larger (primarly tabs) + pub fn min_width(&self) -> u16 { + match *self { + Grapheme::Other { width, .. } => width, + _ => 1, + } + } + + pub fn into_display(self, visual_x: usize, config: &'a TextRenderConfig) -> (u16, Cow) { + match self { + Grapheme::Tab => { + // make sure we display tab as appropriate amount of spaces + let visual_tab_width = + config.tab_width - (visual_x % config.tab_width as usize) as u16; + let grapheme_tab_width = char_to_byte_idx(&config.tab, visual_tab_width as usize); + ( + visual_tab_width, + Cow::from(&config.tab[..grapheme_tab_width]), + ) + } + + Grapheme::Space => (1, Cow::from(&config.space)), + Grapheme::Nbsp => (1, Cow::from(&config.nbsp)), + Grapheme::Other { width, raw: str } => (width, str), + } + } + + pub fn is_whitespace(&self) -> bool { + !matches!(&self, Grapheme::Other { .. }) + } + + pub fn is_breaking_space(&self) -> bool { + !matches!(&self, Grapheme::Other { .. } | Grapheme::Nbsp) + } +} + +/// A preprossed Grapheme that is ready for rendering +#[derive(Debug)] +pub struct StyledGrapheme<'a> { + pub grapheme: Grapheme<'a>, + pub style: Style, +} + +impl<'a> StyledGrapheme<'a> { + pub fn placeholder() -> Self { + StyledGrapheme { + grapheme: Grapheme::Space, + style: Style::default(), + } + } + + pub fn new(raw: Cow<'a, str>, style: Style) -> StyledGrapheme<'a> { + StyledGrapheme { + grapheme: raw.into(), + style, + } + } + + pub fn is_whitespace(&self) -> bool { + self.grapheme.is_whitespace() + } + pub fn is_breaking_space(&self) -> bool { + self.grapheme.is_breaking_space() + } + + pub fn style(&self, config: &TextRenderConfig) -> Style { + if self.is_whitespace() { + self.style.patch(config.whitespace_style) + } else { + self.style + } + } + + /// Returns the approximate visual width of this grapheme, + /// This serves as a lower bound for the width for use during soft wrapping. + /// The actual displayed witdth might be position dependent and larger (primarly tabs) + pub fn min_width(&self) -> u16 { + self.grapheme.min_width() + } + + pub fn into_display( + self, + visual_x: usize, + config: &'a TextRenderConfig, + ) -> (u16, Cow, Style) { + let style = self.style(config); + let (width, raw) = self.grapheme.into_display(visual_x, config); + (width, raw, style) + } +} + +#[derive(Debug, Copy, Clone)] +pub enum IndentLevel { + /// Indentation is disabled for this line because it wrapped for too long + None, + /// Indentation level is not yet known for this line because no non-whitespace char has been reached + /// The previous indentation level is kept so that indentation guides are not interrupted by empty lines + Unkown, + /// Identation level is known for this line + Known(usize), +} + +/// A generic render that can draw lines of text to a surfe +#[derive(Debug)] +pub struct TextRender<'a> { + /// Surface to render to + surface: &'a mut Surface, + /// Various constants required for rendering + pub config: &'a TextRenderConfig, + viewport: Rect, + col_offset: usize, + visual_line: u16, + visual_x: usize, + indent_level: IndentLevel, + prev_indent_level: usize, +} + +impl<'a> TextRender<'a> { + pub fn new( + surface: &'a mut Surface, + config: &'a TextRenderConfig, + col_offset: usize, + viewport: Rect, + ) -> TextRender<'a> { + TextRender { + surface, + config, + viewport, + visual_line: 0, + visual_x: 0, + col_offset, + indent_level: IndentLevel::Unkown, + prev_indent_level: 0, + } + } + + /// Returns the indentation of the current line. + /// If no non-whitespace character has ben encountered yet returns `usize::MAX` + pub fn indent_level(&self) -> IndentLevel { + self.indent_level + } + + // pub fn visual_x(&self) -> usize { + // self.visual_x + // } + + /// Returns the line in the viewport (starting at 0) that will be filled next + pub fn visual_line(&self) -> u16 { + self.visual_line + } + + /// Draws a single `grapheme` at `visual_x` with a specified `style`. + /// + /// # Note + /// + /// This function assumes that `visual_x` is in-bounds for the viewport. + fn draw_raw_grapheme_at(&mut self, visual_x: usize, grapheme: &str, style: Style) { + self.surface.set_string( + self.viewport.x + (visual_x - self.col_offset) as u16, + self.viewport.y + self.visual_line, + grapheme, + style, + ); + } + + // /// Draws a single `grapheme` at `visual_x` with a specified `style`. + // /// + // /// # Note + // /// + // /// This function assumes that `visual_x` is in-bounds for the viewport. + // pub fn draw_grapheme_at(&mut self, visual_x: usize, styled_grapheme: StyledGrapheme) { + // let (_, grapheme, style) = styled_grapheme.into_display(visual_x, self.config); + // self.draw_raw_grapheme_at(visual_x, &grapheme, style); + // } + + /// Draws a single `grapheme` at the current render position with a specified `style`. + pub fn draw_grapheme(&mut self, styled_grapheme: StyledGrapheme) { + let cut_off_start = self.col_offset.saturating_sub(self.visual_x as usize); + let is_whitespace = styled_grapheme.is_whitespace(); + + let (width, grapheme, style) = styled_grapheme.into_display(self.visual_x, self.config); + if self.in_bounds(self.visual_x) { + self.draw_raw_grapheme_at(self.visual_x, &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 + self.visual_line, + width - cut_off_start as u16, + 1, + ); + self.surface.set_style(rect, style); + } + + if !is_whitespace && matches!(self.indent_level, IndentLevel::Unkown { .. }) { + self.indent_level = IndentLevel::Known(self.visual_x) + } + self.visual_x += width as usize; + } + + /// Returns whether `visual_x` is inside of the viewport + fn in_bounds(&self, visual_x: usize) -> bool { + self.col_offset <= (visual_x as usize) + && (visual_x as usize) < self.viewport.width as usize + self.col_offset + } + + pub fn space_left(&self) -> usize { + (self.col_offset + self.viewport.width as usize).saturating_sub(self.visual_x) + } + + /// 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) { + let indent_level = match self.indent_level { + IndentLevel::None => return, // no identation after wrap + IndentLevel::Unkown => self.prev_indent_level, //line only contains whitespaces + IndentLevel::Known(ident_level) => ident_level, + }; + // 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.config.tab_width - 1) as usize, + ) / self.config.tab_width as usize; + + for i in self.config.starting_indent..end_indent { + let x = (self.viewport.x as usize + (i * self.config.tab_width as usize) + - self.col_offset) as u16; + let y = self.viewport.y + self.visual_line; + debug_assert!(self.surface.in_bounds(x, y)); + self.surface.set_string( + x, + y, + &self.config.indent_guide_char, + self.config.indent_guide_style, + ); + } + } + + /// Advances this `TextRender` to the next visual line + /// This function does not check whether the next line is in bounds. + pub fn advance_to_next_line(&mut self, new_ident: IndentLevel) { + self.visual_line += 1; + + if let IndentLevel::Known(indent_level) = self.indent_level { + self.prev_indent_level = indent_level; + } + self.visual_x = match new_ident { + IndentLevel::None | IndentLevel::Unkown { .. } => 0, + IndentLevel::Known(ident) => ident, + }; + self.indent_level = new_ident; + } + + pub fn skip(&mut self, width: usize) { + self.visual_x += width + } + + pub fn reached_viewport_end(&mut self) -> bool { + self.visual_line >= self.viewport.height + } +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index fc201853f7dc..bbdefbe25dff 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::{DocumentRender, TextRender, TextRenderConfig}, + Completion, ProgressSpinners, + }, }; use helix_core::{ @@ -14,7 +17,7 @@ use helix_core::{ movement::Direction, syntax::{self, HighlightEvent}, unicode::width::UnicodeWidthStr, - visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, + visual_coords_at_pos, Position, Range, Selection, Transaction, }; use helix_view::{ apply_transaction, @@ -25,7 +28,7 @@ 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}; use tui::buffer::Buffer as Surface; @@ -149,8 +152,25 @@ impl EditorView { 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); + let line_starts = Self::render_text_highlights( + doc, + view.offset, + inner, + surface, + theme, + highlights, + &config, + ); + Self::render_gutter( + editor, + doc, + view, + view.area, + surface, + theme, + is_focused, + line_starts.iter().copied(), + ); Self::render_rulers(editor, doc, view, inner, surface, theme); if is_focused { @@ -411,205 +431,39 @@ impl EditorView { 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; + ) -> Vec<(u16, usize)> { + // log::error!("{doc_offset:?}"); + let render_config = TextRenderConfig::new(doc, config, theme, &offset); + let mut text_render = TextRender::new(surface, &render_config, offset.col, viewport); + let text = doc.text(); + + if config.soft_wrap.enable { + debug_assert_eq!(offset.col, 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")), + // TODO differentiate between view pos and doc pos + let doc_pos = text.line_to_char(offset.row); + let text = doc.text().slice(doc_pos..); + let mut render = DocumentRender::new( + config, + theme, + text, + highlights, + viewport, + offset, + doc_pos, + &mut text_render, ); - - 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); - } - } - } + let mut last_doc_line = usize::MAX; + let mut line_starts = Vec::new(); + while !render.finished() { + if render.doc_line() != last_doc_line { + last_doc_line = render.doc_line(); + line_starts.push((text_render.visual_line(), render.doc_line())) } + render.render_line(&mut text_render); } + line_starts } /// Render brace match, etc (meant for the focused view only) @@ -700,6 +554,7 @@ impl EditorView { } } + #[allow(clippy::too_many_arguments)] pub fn render_gutter( editor: &Editor, doc: &Document, @@ -708,9 +563,9 @@ impl EditorView { surface: &mut Surface, theme: &Theme, is_focused: bool, + line_starts: impl Iterator + Clone, ) { 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 @@ -733,10 +588,10 @@ impl EditorView { 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() { + for (i, line) in line_starts.clone() { let selected = cursors.contains(&line); let x = viewport.x + offset; - let y = viewport.y + i as u16; + let y = viewport.y + i; let gutter_style = if selected { gutter_selected_style diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index f61c4c450c7d..867dbb96db0d 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-view/src/editor.rs b/helix-view/src/editor.rs index 973cf82ea109..0c164c81b176 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -178,6 +178,44 @@ pub struct Config { pub indent_guides: IndentGuidesConfig, /// Whether to color modes with different colors. Defaults to `false`. pub color_modes: bool, + pub soft_wrap: SoftWrap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct SoftWrap { + /// Soft wrap lines that exceed viewport width. Default to off + pub enable: bool, + /// Maximum space that softwrapping may leave free at the end of the line when perfomring softwrapping + /// This space is used to wrap text at word boundries. If that is not possible within this limit + /// the word is simply split at the end of the line. + /// + /// This is automatically hardlimited to a quarter of the viewport to ensure correct display on small views. + /// + /// Default to 5 + pub max_wrap: usize, + /// Maximum number of indentation that can be carried over from the previous line when softwrapping. + /// If a line is indenten further then this limit it is rendered at the start of the viewport instead. + /// + /// This is automatically hardlimited to a quarter of the viewport to ensure correct display on small views. + /// + /// Default to 40 + pub max_indent_retain: usize, + /// Extra spaces inserted before rendeirng softwrapped lines. + /// + /// Default to 2 + pub wrap_indent: usize, +} + +impl Default for SoftWrap { + fn default() -> Self { + SoftWrap { + enable: true, + max_wrap: 5, + max_indent_retain: 80, + wrap_indent: 2, + } + } } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -630,6 +668,7 @@ impl Default for Config { bufferline: BufferLine::default(), indent_guides: IndentGuidesConfig::default(), color_modes: false, + soft_wrap: SoftWrap::default(), } } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index ecc8e8beaa7b..0008d0b50b46 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -215,9 +215,9 @@ impl View { } pub fn ensure_cursor_in_view(&mut self, doc: &Document, scrolloff: usize) { - if let Some((row, col)) = self.offset_coords_to_in_view(doc, scrolloff) { + if let Some((row, _col)) = self.offset_coords_to_in_view(doc, scrolloff) { self.offset.row = row; - self.offset.col = col; + self.offset.col = 0; } }