diff --git a/helix-core/src/doc_cursor.rs b/helix-core/src/doc_cursor.rs new file mode 100644 index 000000000000..fe11434c6ccb --- /dev/null +++ b/helix-core/src/doc_cursor.rs @@ -0,0 +1,478 @@ +//! The `DocumentCursor` forms the bridge between the raw document text +//! and onscreen rendering. It behaves similar to an iterator +//! and transverses (part) of the document text. During that transversal it +//! handles grapheme detection, softwrapping and annotation. +//! The result are [`Word`]s which are chunks of graphemes placed at **visual** +//! coordinates. +//! +//! The document cursor very flexible and be used to efficently map char positions in the document +//! to visual coordinates (and back). + +use std::borrow::Cow; +use std::mem::take; +use std::vec; + +#[cfg(test)] +mod test; + +use crate::graphemes::Grapheme; +use crate::{LineEnding, Position, RopeGraphemes, RopeSlice}; + +/// A preprossed Grapheme that is ready for rendering +/// with attachted styling data +#[derive(Debug, Clone)] +pub struct StyledGraphemes<'a, S> { + pub grapheme: Grapheme<'a>, + pub style: S, + // the number of chars in the document required by this grapheme + pub doc_chars: u16, +} + +impl<'a, S: Default> StyledGraphemes<'a, S> { + pub fn placeholder() -> Self { + StyledGraphemes { + grapheme: Grapheme::Space, + style: S::default(), + doc_chars: 0, + } + } + + pub fn new( + raw: Cow<'a, str>, + style: S, + visual_x: usize, + tab_width: u16, + chars: u16, + ) -> StyledGraphemes<'a, S> { + StyledGraphemes { + grapheme: Grapheme::new(raw, visual_x, tab_width), + style, + doc_chars: chars, + } + } + + pub fn is_whitespace(&self) -> bool { + self.grapheme.is_whitespace() + } + + pub fn is_breaking_space(&self) -> bool { + self.grapheme.is_breaking_space() + } + + /// Returns the approximate visual width of this grapheme, + pub fn width(&self) -> u16 { + self.grapheme.width() + } +} + +/// An annotation source allows inserting virtual text during rendering +/// that is correctly considered by the positioning and rendering code +/// The AnnotiationSource essentially fonctions as a cursor over annotations. +/// To facilitate efficent implementation it is garunteed that all +/// functions except `set_pos` are only called with increasing `char_pos`. +/// +/// Further only `char_pos` correspoding to grapehme boundries are passed to `AnnotationSource` +pub trait AnnotationSource<'t, S>: Clone { + /// Yield a grapeheme to insert at the current `char_pos`. + /// `char_pos` will not increase as long as this funciton yields `Some` grapheme. + fn next_annotation_grapheme(&mut self, char_pos: usize) -> Option<(Cow<'t, str>, S)>; + /// This function is usuully only called when a [`DocumentCursor`] is created. + /// It moves the annotation source to a random `char_pos` that might be before + /// other char_pos previously passed to this annotation source + fn set_pos(&mut self, char_pos: usize); +} + +impl<'a, 't, S: Default, A> AnnotationSource<'t, S> for &'a mut A +where + A: AnnotationSource<'t, S>, +{ + fn next_annotation_grapheme(&mut self, char_pos: usize) -> Option<(Cow<'t, str>, S)> { + A::next_annotation_grapheme(self, char_pos) + } + + fn set_pos(&mut self, char_pos: usize) { + A::set_pos(self, char_pos); + } +} + +impl<'t, S: Default> AnnotationSource<'t, S> for () { + fn next_annotation_grapheme(&mut self, _char_pos: usize) -> Option<(Cow<'t, str>, S)> { + None + } + + fn set_pos(&mut self, _char_pos: usize) {} +} + +#[derive(Debug, Clone, Copy)] +pub struct CursorConfig { + pub soft_wrap: bool, + pub tab_width: u16, + pub max_wrap: u16, + pub max_indent_retain: u16, + pub wrap_indent: u16, + pub viewport_width: u16, +} + +#[derive(Debug, Clone, Copy)] +pub struct LineBreak { + /// wether this linebreak corresponds to a softwrap + pub is_softwrap: bool, + /// Amount of additional indentation to insert before this line + pub indent: u16, +} + +enum WordBoundary { + /// Any line break + LineBreak, + /// a breaking space (' ' or \t) + Space, +} + +#[derive(Debug, Clone)] +pub struct Word { + pub visual_width: usize, + pub doc_char_width: usize, + pub terminating_linebreak: Option, + /// The graphemes in this words + graphemes: Option, +} + +impl Word { + pub fn consume_graphemes<'d, 't, S: Default, A: AnnotationSource<'t, S>>( + &mut self, + cursor: &'d mut DocumentCursor<'t, S, A>, + ) -> vec::Drain<'d, StyledGraphemes<'t, S>> { + let num_graphemes = self + .graphemes + .take() + .expect("finish_word can only be called once for a word"); + cursor.word_buf.drain(..num_graphemes) + } +} + +impl Drop for Word { + fn drop(&mut self) { + if self.graphemes.is_some() { + unreachable!("A words graphemes must be consumed with `Word::consume_graphemes`") + } + } +} + +#[derive(Debug, Clone)] +pub struct DocumentCursor<'t, S: Default, A: AnnotationSource<'t, S>> { + pub config: CursorConfig, + + /// The indentation of the current line + /// Is set to `None` if the indentation level is not yet know + /// because no non-whitespace grahemes has been encountered yet + indent_level: Option, + /// The char index of the last yielded word boundary + doc_char_idx: usize, + /// The current line index inside the documnet + doc_line_idx: usize, + /// The visual position at the end of the last yielded word boundary + visual_pos: Position, + + graphemes: RopeGraphemes<'t>, + annotation_source: A, + + /// The visual width of the word + word_width: usize, + /// The number of codepoints (chars) in the current word + word_doc_chars: usize, + word_buf: Vec>, +} + +impl<'t, S: Default + Copy, A: AnnotationSource<'t, S>> DocumentCursor<'t, S, A> { + /// Create a new `DocumentFormatter` that transveres `text`. + /// The char/document idx is offset by `doc_char_offset` + /// and `doc_line_offset`. + /// This has no effect on the cursor itself and only affects the char + /// indecies passed to the `annotation_source` and used to determine when the end of the `highlight_scope` + /// is reached + fn new( + text: RopeSlice<'t>, + config: CursorConfig, + doc_char_offset: usize, + doc_line_offset: usize, + annotation_source: A, + ) -> Self { + annotation_source.set_pos(doc_char_offset); + DocumentCursor { + config, + indent_level: None, + doc_char_idx: doc_char_offset, + doc_line_idx: doc_line_offset, + visual_pos: Position { row: 0, col: 0 }, + graphemes: RopeGraphemes::new(text), + annotation_source, + word_width: 0, + word_doc_chars: 0, + word_buf: Vec::with_capacity(64), + } + } + + /// Returns the last checkpoint as (char_idx, line_idx) from which the `DocumentCursor` must be started + /// to find the first visual line. + /// + /// Right now only document lines are used as checkpoints + /// which leads to inefficent rendering for extremly large wrapped lines + /// In the future we want to mimic led and implement blocks to chunk extermly long lines + fn prev_checkpoint(text: RopeSlice, doc_char_idx: usize) -> (usize, usize) { + let line = text.char_to_line(doc_char_idx); + let line_start = text.line_to_char(line); + (line_start, line) + } + + /// Creates a new formatter at the last block before `char_idx`. + /// A block is a chunk which always ends with a linebreak. + /// This is usally just a normal line break. + /// However very long lines are always wrapped at constant intervals that can be cheaply calculated + /// to avoid pathological behaviour. + pub fn new_at_prev_block( + text: RopeSlice<'t>, + config: CursorConfig, + char_idx: usize, + mut annotation_source: A, + ) -> Self { + // TODO support for blocks + let block_line_idx = text.char_to_line(char_idx); + let block_char_idx = text.line_to_char(block_line_idx); + DocumentCursor::new( + text.slice(block_char_idx..), + config, + block_char_idx, + block_line_idx, + annotation_source, + ) + } + + /// Creates a new cursor at the start of the visual line that contains `char_idx` + /// Note that this + pub fn new_at_prev_line( + text: RopeSlice<'t>, + config: CursorConfig, + char_idx: usize, + mut annotation_source: A, + ) -> Self { + let mut cursor = Self::new_at_prev_block(text, config, char_idx, annotation_source); + let mut line_off = 0; + let mut indent_level = None; + + if config.soft_wrap { + annotation_source.set_pos(cursor.doc_char_idx); + let mut last_line_start = cursor.doc_char_idx(); + let doc_line_idx = cursor.doc_line_idx; + + while let Some(mut word) = cursor.advance() { + word.consume_graphemes(&mut cursor); + if cursor.doc_char_idx > char_idx { + break; + } + + if let Some(line_break) = word.terminating_linebreak { + line_off = line_break.indent; + last_line_start = cursor.doc_char_idx; + indent_level = Some((line_off - config.wrap_indent) as usize); + } + } + cursor = DocumentCursor::new( + text.slice(last_line_start..), + config, + last_line_start, + doc_line_idx, + annotation_source, + ); + + cursor.indent_level = indent_level; + cursor.visual_pos.col = line_off as usize; + } + + cursor + } + + pub fn doc_line_idx(&self) -> usize { + self.doc_line_idx + } + + pub fn doc_char_idx(&self) -> usize { + self.doc_char_idx + } + + pub fn visual_pos(&self) -> Position { + self.visual_pos + } + + pub fn advance(&mut self) -> Option { + self.advance_with_highlight((usize::MAX, S::default())) + } + + pub fn advance_with_highlight(&mut self, highlight_scope: (usize, S)) -> Option { + loop { + if self.doc_char_idx + self.word_doc_chars >= highlight_scope.0 { + debug_assert_eq!( + self.doc_char_idx + self.word_doc_chars, + highlight_scope.0, + "Highlight scope must be aligned to grapheme boundary" + ); + return None; + } + + if self.word_width + self.visual_pos.col >= self.config.viewport_width as usize { + break; + } + + let (grapheme, style, doc_chars) = if let Some(annotation) = self + .annotation_source + .next_annotation_grapheme(self.doc_char_idx + self.word_doc_chars) + { + (annotation.0, annotation.1, 0) + } else if let Some(grapheme) = self.graphemes.next() { + let codepoints = grapheme.len_chars(); + self.word_doc_chars += codepoints; + (Cow::from(grapheme), highlight_scope.1, codepoints as u16) + } else { + return None; + }; + + match self.push_grapheme(grapheme, style, doc_chars) { + Some(WordBoundary::LineBreak) => { + self.indent_level = None; + let word = self.take_word(Some(LineBreak { + is_softwrap: false, + indent: 0, + })); + return Some(word); + } + Some(WordBoundary::Space) => { + return Some(self.take_word(None)); + } + _ => (), + } + } + + if self.config.soft_wrap { + let indent_carry_over = if let Some(indent) = self.indent_level { + if indent as u16 <= self.config.max_indent_retain { + indent as u16 + } else { + 0 + } + } else { + 0 + }; + let line_indent = indent_carry_over + self.config.wrap_indent; + + let mut num_graphemes = 0; + let mut visual_width = 0; + let mut doc_chars = 0; + if self.word_width > self.config.max_wrap as usize { + num_graphemes = self.word_buf.len(); + visual_width = take(&mut self.word_width); + doc_chars = take(&mut self.word_doc_chars); + + // Usually we stop accomulating graphemes as soon as softwrapping becomes necessary. + // However if the last grapheme is multiple columns wide it might extend beyond the EOL. + // The condition below ensures that this grapheme is not yielded yet and instead wrapped to the next line + if self.word_buf.last().map_or(false, |last| last.width() != 1) { + num_graphemes -= 1; + let wrapped_grapheme = self.word_buf.last_mut().unwrap(); + + wrapped_grapheme + .grapheme + .change_position(line_indent as usize, self.config.tab_width); + let wrapped_grapheme_width = wrapped_grapheme.width() as usize; + visual_width -= wrapped_grapheme_width; + self.word_width = wrapped_grapheme_width as usize; + let wrapped_grapheme_chars = wrapped_grapheme.doc_chars as usize; + self.word_doc_chars = wrapped_grapheme_chars; + doc_chars -= wrapped_grapheme_chars; + } + } + + let word = Word { + visual_width, + graphemes: Some(num_graphemes), + terminating_linebreak: Some(LineBreak { + is_softwrap: true, + indent: line_indent, + }), + doc_char_width: doc_chars, + }; + self.visual_pos.row += 1; + self.visual_pos.col = line_indent as usize; + self.doc_char_idx += doc_chars; + + Some(word) + } else { + Some(self.take_word(None)) + } + } + + pub fn finish(&mut self) -> Word { + self.take_word(None) + } + + fn push_grapheme( + &mut self, + grapheme: Cow<'t, str>, + style: S, + doc_chars: u16, + ) -> Option { + 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 + self.word_buf.push(StyledGraphemes { + grapheme: Grapheme::Newline, + style, + doc_chars, + }); + self.word_width += 1; + return Some(WordBoundary::LineBreak); + } + + let grapheme = StyledGraphemes::new( + grapheme, + style, + self.visual_pos.col + self.word_width, + self.config.tab_width, + doc_chars, + ); + + if self.indent_level.is_none() && !grapheme.is_whitespace() { + self.indent_level = Some(self.visual_pos.col); + } + + self.word_width += grapheme.width() as usize; + let word_end = if grapheme.is_breaking_space() { + Some(WordBoundary::Space) + } else { + None + }; + + self.word_buf.push(grapheme); + word_end + } + + fn take_word(&mut self, terminating_linebreak: Option) -> Word { + if let Some(line_break) = terminating_linebreak { + debug_assert!( + !line_break.is_softwrap, + "Softwrapped words are handeled seperatly" + ); + self.doc_line_idx += 1; + self.visual_pos.row += 1; + self.visual_pos.col = 0; + } else { + self.visual_pos.col += self.word_width; + } + let doc_char_width = take(&mut self.word_doc_chars); + self.doc_char_idx += doc_char_width; + Word { + visual_width: take(&mut self.word_width), + graphemes: Some(self.word_buf.len()), + terminating_linebreak, + doc_char_width, + } + } +} diff --git a/helix-core/src/doc_cursor/test.rs b/helix-core/src/doc_cursor/test.rs new file mode 100644 index 000000000000..59d9d898fb65 --- /dev/null +++ b/helix-core/src/doc_cursor/test.rs @@ -0,0 +1,144 @@ +use crate::doc_cursor::{CursorConfig, DocumentCursor}; + +const WRAP_INDENT: u16 = 1; +impl CursorConfig { + fn new_test(softwrap: bool) -> CursorConfig { + CursorConfig { + soft_wrap: softwrap, + tab_width: 2, + max_wrap: 3, + max_indent_retain: 4, + wrap_indent: WRAP_INDENT, + // use a prime number to allow linging up too often with repear + viewport_width: 17, + } + } +} + +impl<'t> DocumentCursor<'t, (), ()> { + fn new_test(text: &'t str, char_pos: usize, softwrap: bool) -> Self { + Self::new_at_prev_line(text.into(), CursorConfig::new_test(softwrap), char_pos, ()) + } + + fn collect_to_str(&mut self, res: &mut String) { + use std::fmt::Write; + let wrap_indent = self.config.wrap_indent; + let viewport_width = self.config.viewport_width; + let mut line_width = 0; + + while let Some(mut word) = self.advance() { + let mut word_width_check = 0; + let word_width = word.visual_width; + for grapheme in word.consume_graphemes(self) { + word_width_check += grapheme.width() as usize; + write!(res, "{}", grapheme.grapheme).unwrap(); + } + assert_eq!(word_width, word_width_check); + line_width += word.visual_width; + + if let Some(line_break) = word.terminating_linebreak { + assert!( + line_width <= viewport_width as usize, + "softwrapped failed {line_width}<={viewport_width}" + ); + res.push('\n'); + if line_break.is_softwrap { + for i in 0..line_break.indent { + if i < wrap_indent { + res.push('.'); + } else { + res.push(' ') + } + } + } else { + assert_eq!(line_break.indent, 0); + } + line_width = line_break.indent as usize; + } + } + + for grapheme in self.finish().consume_graphemes(self) { + write!(res, "{}", grapheme.grapheme).unwrap(); + } + assert!( + line_width <= viewport_width as usize, + "softwrapped failed {line_width}<={viewport_width}" + ); + } +} + +fn softwrap_text(text: &str, char_pos: usize) -> String { + let mut cursor = DocumentCursor::new_test(text, char_pos, true); + let mut res = String::new(); + for i in 0..cursor.visual_pos().col { + if i < WRAP_INDENT as usize { + res.push('.'); + } else { + res.push(' ') + } + } + cursor.collect_to_str(&mut res); + res +} + +#[test] +fn basic_softwrap() { + assert_eq!( + softwrap_text(&"foo ".repeat(10), 0), + "foo foo foo foo \n.foo foo foo foo \n.foo foo " + ); + assert_eq!( + softwrap_text(&"fooo ".repeat(10), 0), + "fooo fooo fooo \n.fooo fooo fooo \n.fooo fooo fooo \n.fooo " + ); + + // check that we don't wrap unecessarly + assert_eq!( + softwrap_text("\t\txxxx1xxxx2xx\n", 0), + " xxxx1xxxx2xx \n" + ); +} + +#[test] +fn softwrap_indentation() { + assert_eq!( + softwrap_text("\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n", 0), + " foo1 foo2 \n. foo3 foo4 \n. foo5 foo6 \n" + ); + assert_eq!( + softwrap_text("\t\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n", 0), + " foo1 foo2 \n.foo3 foo4 foo5 \n.foo6 \n" + ); +} + +#[test] +fn long_word_softwrap() { + assert_eq!( + softwrap_text("\t\txxxx1xxxx2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n", 0), + " xxxx1xxxx2xxx\n. x3xxxx4xxxx5\n. xxxx6xxxx7xx\n. xx8xxxx9xxx \n" + ); + assert_eq!( + softwrap_text("xxxxxxxx1xxxx2xxx\n", 0), + "xxxxxxxx1xxxx2xxx\n. \n" + ); + assert_eq!( + softwrap_text("\t\txxxx1xxxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n", 0), + " xxxx1xxxx \n. 2xxxx3xxxx4x\n. xxx5xxxx6xxx\n. x7xxxx8xxxx9\n. xxx \n" + ); + assert_eq!( + softwrap_text("\t\txxxx1xxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n", 0), + " xxxx1xxx 2xxx\n. x3xxxx4xxxx5\n. xxxx6xxxx7xx\n. xx8xxxx9xxx \n" + ); +} + +#[test] +fn softwrap_checkpoint() { + assert_eq!( + softwrap_text(&"foo ".repeat(10), 4), + "foo foo foo foo \n.foo foo foo foo \n.foo foo " + ); + let text = "foo ".repeat(10); + assert_eq!(softwrap_text(&text, 18), ".foo foo foo foo \n.foo foo "); + println!("{}", &text[32..]); + assert_eq!(softwrap_text(&"foo ".repeat(10), 32), ".foo foo "); +} diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 675f57505c09..dc08b0e6fab0 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -5,7 +5,76 @@ use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice}; use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete}; use unicode_width::UnicodeWidthStr; -use std::fmt; +use std::borrow::Cow; +use std::fmt::{self, Display}; + +#[derive(Debug, Clone)] +/// A preprossed Grapheme that is ready for rendering +pub enum Grapheme<'a> { + Space, + Newline, + Nbsp, + Tab { width: u16 }, + Other { raw: Cow<'a, str>, width: u16 }, +} + +impl<'a> Grapheme<'a> { + pub fn new(raw: Cow<'a, str>, visual_x: usize, tab_width: u16) -> Grapheme<'a> { + match &*raw { + "\t" => { + let width = tab_width - (visual_x % tab_width as usize) as u16; + Grapheme::Tab { width } + } + " " => Grapheme::Space, + "\u{00A0}" => Grapheme::Nbsp, + _ => Grapheme::Other { + width: grapheme_width(&*raw) as u16, + raw, + }, + } + } + + pub fn change_position(&mut self, visual_x: usize, tab_width: u16) { + if let Grapheme::Tab { width } = self { + *width = tab_width - (visual_x % tab_width as usize) as u16 + } + } + + /// 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 width(&self) -> u16 { + match *self { + Grapheme::Other { width, .. } | Grapheme::Tab { width } => width, + _ => 1, + } + } + + pub fn is_whitespace(&self) -> bool { + !matches!(&self, Grapheme::Other { .. }) + } + + pub fn is_breaking_space(&self) -> bool { + !matches!(&self, Grapheme::Other { .. } | Grapheme::Nbsp) + } +} + +impl Display for Grapheme<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Grapheme::Space | Grapheme::Newline | Grapheme::Nbsp => write!(f, " "), + Grapheme::Tab { width } => { + for _ in 0..width { + write!(f, " ")?; + } + Ok(()) + } + Grapheme::Other { ref raw, .. } => { + write!(f, "{raw}") + } + } + } +} #[must_use] pub fn grapheme_width(g: &str) -> usize { @@ -300,6 +369,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-core/src/lib.rs b/helix-core/src/lib.rs index 0e76ebbbefca..f24bf2f0410b 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -6,6 +6,8 @@ pub mod comment; pub mod config; pub mod diagnostic; pub mod diff; +pub mod doc_cursor; +pub mod doc_formatter; pub mod graphemes; pub mod history; pub mod increment; @@ -95,7 +97,8 @@ pub use {regex, tree_sitter}; pub use graphemes::RopeGraphemes; pub use position::{ - coords_at_pos, pos_at_coords, pos_at_visual_coords, visual_coords_at_pos, Position, + coords_at_pos, pos_at_coords, pos_at_visual_coords, pos_at_visual_coords_2, + visual_coords_at_pos, visual_coords_at_pos_2, Position, }; pub use selection::{Range, Selection}; pub use smallvec::{smallvec, SmallVec}; diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index f456eb988186..3bf21ed62e37 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use crate::{ chars::char_is_line_ending, + doc_cursor::{CursorConfig, DocumentCursor}, graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes}, line_ending::line_end_char_index, RopeSlice, @@ -93,6 +94,46 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po Position::new(line, col) } +/// Convert a character index to (line, column) coordinates visually. +/// +/// Takes \t, double-width characters (CJK) into account as well as text +/// not in the document in the future. +/// See [`coords_at_pos`] for an "objective" one. +pub fn visual_coords_at_pos_2( + text: RopeSlice, + anchor: usize, + pos: usize, + config: CursorConfig, +) -> Position { + // TODO consider inline annotations + let mut cursor: DocumentCursor<(), _> = + DocumentCursor::new_at_prev_line(text, config, anchor, ()); + let mut visual_pos = cursor.visual_pos(); + let mut doc_char_idx = cursor.doc_char_idx(); + let mut word = loop { + if let Some(mut word) = cursor.advance() { + if cursor.doc_char_idx() >= pos { + break word; + } else { + word.consume_graphemes(&mut cursor); + visual_pos = cursor.visual_pos(); + doc_char_idx = cursor.doc_char_idx(); + } + } else { + break cursor.finish(); + } + }; + + for grapheme in word.consume_graphemes(&mut cursor) { + if doc_char_idx + grapheme.doc_chars as usize > pos { + break; + } + doc_char_idx += grapheme.doc_chars as usize; + visual_pos.col += grapheme.width() as usize; + } + visual_pos +} + /// Convert (line, column) coordinates to a character index. /// /// If the `line` coordinate is beyond the end of the file, the EOF @@ -169,6 +210,50 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize) line_start + col_char_offset } +/// Convert a character index to (line, column) coordinates visually. +/// +/// Takes \t, double-width characters (CJK) into account as well as text +/// not in the document in the future. +/// See [`coords_at_pos`] for an "objective" one. +pub fn pos_at_visual_coords_2( + text: RopeSlice, + anchor: usize, + cords: Position, + config: CursorConfig, +) -> usize { + // TODO consider inline annotations + let mut cursor: DocumentCursor<(), _> = + DocumentCursor::new_at_prev_line(text, config, anchor, ()); + let mut visual_pos = cursor.visual_pos(); + let mut doc_char_idx = cursor.doc_char_idx(); + let mut word = loop { + if let Some(mut word) = cursor.advance() { + if visual_pos.row == cords.row { + if visual_pos.col + word.visual_width > cords.col { + break word; + } else if word.terminating_linebreak.is_some() { + word.consume_graphemes(&mut cursor); + return cursor.doc_char_idx(); + } + } + word.consume_graphemes(&mut cursor); + visual_pos = cursor.visual_pos(); + doc_char_idx = cursor.doc_char_idx(); + } else { + break cursor.finish(); + } + }; + + for grapheme in word.consume_graphemes(&mut cursor) { + if visual_pos.col + grapheme.width() as usize > cords.col { + break; + } + doc_char_idx += grapheme.doc_chars as usize; + visual_pos.col += grapheme.width() as usize; + } + doc_char_idx +} + #[cfg(test)] mod test { use super::*; diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs new file mode 100644 index 000000000000..67225fbb36f9 --- /dev/null +++ b/helix-term/src/ui/document.rs @@ -0,0 +1,358 @@ +use std::borrow::Cow; +use std::cmp::min; + +use helix_core::doc_cursor::{AnnotationSource, DocumentCursor}; +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::{Position, RopeSlice}; +use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; +use helix_view::graphics::Rect; +use helix_view::theme::Style; +use helix_view::Theme; +use helix_view::{editor, Document}; +use tui::buffer::Buffer as Surface; + +pub struct DocumentRender<'a, A, H> +where + H: Iterator, + A: AnnotationSource<'a, Style>, +{ + pub config: &'a editor::Config, + pub theme: &'a Theme, + + text: RopeSlice<'a>, + pub cursor: DocumentCursor<'a, Style, A>, + + highlights: H, + spans: Vec, + highlight_scope: (usize, Style), + + is_finished: bool, +} + +impl<'a, H, A> DocumentRender<'a, A, H> +where + H: Iterator, + A: AnnotationSource<'a, Style>, +{ + pub fn new( + config: &'a editor::Config, + theme: &'a Theme, + text: RopeSlice<'a>, + cursor: DocumentCursor<'a, Style, A>, + highlights: H, + text_render: &mut TextRender, + ) -> Self { + let mut render = DocumentRender { + config, + theme, + highlights, + cursor, + // render: TextRender::new(surface, render_config, offset.col, viewport), + spans: Vec::with_capacity(64), + is_finished: false, + highlight_scope: (0, Style::default()), + text, + }; + + // advance to first highlight scope + render.advance_highlight_scope(text_render); + render + } + + /// Advance to the next treesitter highlight range + /// if the last one is exhaused + 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.highlight_scope = (end, style); + return; + } + } + } + self.is_finished = true; + } + + /// Returns whether this document renderer finished rendering + /// either because the viewport end or EOF was reached + pub fn is_finished(&self) -> bool { + self.is_finished + } + + /// Renders the next line of the document. + /// If softwrapping is enabled this may only correspond to rendering a part of the line + pub fn render_line(&mut self, text_render: &mut TextRender) { + if self.is_finished { + return; + } + + loop { + while let Some(mut word) = self.cursor.advance_with_highlight(self.highlight_scope) { + for grapheme in word.consume_graphemes(&mut self.cursor) { + text_render.draw_grapheme(grapheme); + } + + // TODO refactor to use let..else once MSRV reaches 1.65 + let line_break = if let Some(line_break) = word.terminating_linebreak { + line_break + } else { + continue; + }; + + if self.config.indent_guides.render { + text_render.draw_indent_guides(); + } + + text_render.posititon.row += 1; + text_render.posititon.col = line_break.indent as usize; + self.is_finished = text_render.reached_viewport_end(); + 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.is_finished { + // the last word is garunteed to fit on the last line + // and to not wrap (otherwise it would have been yielded before) + // render it + for grapheme in self.cursor.finish().consume_graphemes(&mut self.cursor) { + text_render.draw_grapheme(grapheme); + } + + if self.highlight_scope.0 > self.text.len_chars() { + // trailing cursor is rendered as a whitespace + text_render.draw_grapheme(StyledGrapheme { + grapheme: Grapheme::Space, + style: self.highlight_scope.1, + doc_chars: 0, + }); + } + + return; + } + + // we reached the viewport end but the line was only partially rendered + if text_render.reached_viewport_end() { + if self.config.indent_guides.render { + text_render.draw_indent_guides() + } + self.is_finished = true; + return; + } + } + } +} + +pub type StyledGrapheme<'a> = helix_core::doc_cursor::StyledGraphemes<'a, Style>; + +/// A TextRender Basic grapheme rendering and visual position tracking +#[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, + indent_known: bool, + indent_level: usize, + pub posititon: Position, +} + +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, + col_offset, + posititon: Position { row: 0, col: 0 }, + indent_level: 0, + indent_known: false, + } + } + + /// 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.posititon.row as usize); + let is_whitespace = styled_grapheme.is_whitespace(); + + let style = if is_whitespace { + styled_grapheme.style.patch(self.config.whitespace_style) + } else { + styled_grapheme.style + }; + let (width, grapheme) = match styled_grapheme.grapheme { + Grapheme::Tab { width } => { + let grapheme_tab_width = char_to_byte_idx(&self.config.tab, width as usize); + (width, Cow::from(&self.config.tab[..grapheme_tab_width])) + } + + Grapheme::Space => (1, Cow::from(&self.config.space)), + Grapheme::Nbsp => (1, Cow::from(&self.config.nbsp)), + Grapheme::Other { width, raw: str } => (width, str), + Grapheme::Newline => (1, Cow::from(&self.config.newline)), + }; + + if self.in_bounds() { + self.surface.set_string( + self.viewport.x + (self.posititon.col - self.col_offset) as u16, + self.viewport.y + self.posititon.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 + self.posititon.row as u16, + width - cut_off_start as u16, + 1, + ); + self.surface.set_style(rect, style); + } + + if !is_whitespace && !self.indent_known { + self.indent_known = true; + self.indent_level = self.posititon.col; + } + self.posititon.col += width as usize; + } + + /// Returns whether the current column is in bounds + fn in_bounds(&self) -> bool { + self.col_offset <= (self.posititon.col as usize) + && (self.posititon.col as usize) < self.viewport.width as usize + self.col_offset + } + + /// 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) { + // Don't draw indent guides outside of view + let end_indent = min( + self.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.posititon.row as u16; + debug_assert!(self.surface.in_bounds(x, y)); + self.surface.set_string( + x, + y, + &self.config.indent_guide_char, + self.config.indent_guide_style, + ); + } + + // reset indentation level for next line + self.indent_known = false; + } + + pub fn reached_viewport_end(&mut self) -> bool { + self.posititon.row as u16 >= self.viewport.height + } +} + +#[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, + cutoff: usize, + ) -> 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: (cutoff / 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, + } + } +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index fc201853f7dc..e49e8c063b9d 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -4,17 +4,21 @@ use crate::{ job::{self, Callback}, key, keymap::{KeymapResult, Keymaps}, - ui::{Completion, ProgressSpinners}, + ui::{ + document::{DocumentRender, TextRender, TextRenderConfig}, + Completion, ProgressSpinners, + }, }; use helix_core::{ + doc_cursor::DocumentCursor, graphemes::{ ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, }, movement::Direction, syntax::{self, HighlightEvent}, unicode::width::UnicodeWidthStr, - visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, + visual_coords_at_pos_2, Position, Range, Selection, Transaction, }; use helix_view::{ apply_transaction, @@ -25,7 +29,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; @@ -119,10 +123,10 @@ impl EditorView { } if is_focused && config.cursorline { - Self::highlight_cursorline(doc, view, surface, theme); + Self::highlight_cursorline(doc, view, surface, theme, inner); } if is_focused && config.cursorcolumn { - Self::highlight_cursorcolumn(doc, view, surface, theme); + Self::highlight_cursorcolumn(doc, &config, view, surface, theme, inner); } let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); @@ -149,8 +153,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 +432,41 @@ impl EditorView { theme: &Theme, highlights: H, config: &helix_view::editor::Config, - ) { - let whitespace = &config.whitespace; - use helix_view::editor::WhitespaceRenderValue; + ) -> Vec<(u16, usize)> { + let text = doc.text(); + let anchor = text.line_to_char(offset.row) + offset.col; + // TODO switch fully to char_based offset - // 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(..); + // TODO add annotation like inline virtual text + let cursor = + DocumentCursor::new_at_prev_line(text, doc.cursor_config(viewport.width), anchor, ()); - 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() + let cutoff = if config.soft_wrap.enable { + 0 } else { - " ".repeat(tab_width) + offset.col }; - 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); - } - } - } + let render_config = TextRenderConfig::new(doc, config, theme, cutoff); + let mut text_render = TextRender::new(surface, &render_config, cutoff, viewport); + text_render.posititon = cursor.visual_pos(); + + let mut render = + DocumentRender::new(config, theme, text, cursor, highlights, &mut text_render); + let mut last_doc_line = usize::MAX; + let mut line_starts = Vec::new(); + while !render.is_finished() { + if render.cursor.doc_line_idx() != last_doc_line { + last_doc_line = render.cursor.doc_line_idx(); + line_starts.push(( + text_render.posititon.row as u16, + render.cursor.doc_line_idx(), + )) } + render.render_line(&mut text_render); } + line_starts } /// Render brace match, etc (meant for the focused view only) @@ -700,6 +557,7 @@ impl EditorView { } } + #[allow(clippy::too_many_arguments)] pub fn render_gutter( editor: &Editor, doc: &Document, @@ -708,9 +566,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 +591,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 @@ -819,7 +677,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 highlight_cursorline( + doc: &Document, + view: &View, + surface: &mut Surface, + theme: &Theme, + viewport: Rect, + ) { let text = doc.text().slice(..); let last_line = view.last_line(doc); @@ -858,9 +722,11 @@ impl EditorView { /// Apply the highlighting on the columns where a cursor is active pub fn highlight_cursorcolumn( doc: &Document, + config: &helix_view::editor::Config, view: &View, surface: &mut Surface, theme: &Theme, + viewport: Rect, ) { let text = doc.text().slice(..); @@ -876,15 +742,24 @@ impl EditorView { .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); let inner_area = view.inner_area(doc); - let offset = view.offset.col; + let offset = if config.soft_wrap.enable { + 0 + } else { + view.offset.col + }; + let anchor = text.line_to_char(view.offset.row) + offset; let selection = doc.selection(view.id); let primary = selection.primary(); for range in selection.iter() { let is_primary = primary == *range; - let Position { row: _, col } = - visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + let Position { row: _, col } = visual_coords_at_pos_2( + text, + range.cursor(text), + anchor, + doc.cursor_config(viewport.width), + ); // if the cursor is horizontally in the view if col >= offset && inner_area.width > (col - offset) as u16 { let area = Rect::new( 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/document.rs b/helix-view/src/document.rs index 856e5628ab42..ffec14f5593b 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, bail, Context, Error}; use futures_util::future::BoxFuture; use futures_util::FutureExt; use helix_core::auto_pairs::AutoPairs; +use helix_core::doc_cursor::CursorConfig; use helix_core::Range; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -26,7 +27,7 @@ use helix_core::{ DEFAULT_LINE_ENDING, }; -use crate::editor::RedrawHandle; +use crate::editor::{RedrawHandle, SoftWrap}; use crate::{apply_transaction, DocumentId, Editor, View, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. @@ -1195,6 +1196,20 @@ impl Document { None => global_config, } } + + pub fn cursor_config(&self, viewport_width: u16) -> CursorConfig { + // TODO find a way to share a config (probably with an Arc) that avoids polluting every callsite + // with an additional config: &editor::Config argument which would cause problems for async + let soft_wrap = SoftWrap::default(); + CursorConfig { + soft_wrap: soft_wrap.enable, + tab_width: self.tab_width() as u16, + max_wrap: soft_wrap.max_wrap.min(viewport_width / 4), + max_indent_retain: soft_wrap.max_indent_retain.min(viewport_width / 4), + wrap_indent: soft_wrap.wrap_indent, + viewport_width, + } + } } impl Default for Document { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 973cf82ea109..4528b940ac42 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: u16, + /// 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: u16, + /// Extra spaces inserted before rendeirng softwrapped lines. + /// + /// Default to 2 + pub wrap_indent: u16, +} + +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 c09d502dcaca..1f546b5073af 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -1,6 +1,7 @@ use crate::{align_view, editor::GutterType, graphics::Rect, Align, Document, DocumentId, ViewId}; use helix_core::{ - pos_at_visual_coords, visual_coords_at_pos, Position, RopeSlice, Selection, Transaction, + pos_at_visual_coords_2, visual_coords_at_pos, visual_coords_at_pos_2, Position, RopeSlice, + Selection, Transaction, }; use std::{ @@ -282,15 +283,17 @@ impl View { return None; } - let tab_width = doc.tab_width(); - // TODO: visual_coords_at_pos also does char_to_line which we ignore, can we reuse the call? - let Position { col, .. } = visual_coords_at_pos(text, pos, tab_width); + let anchor = text.line_to_char(self.offset.row); + let viewport_width = self.inner_area(doc).width; + let config = doc.cursor_config(viewport_width); - // It is possible for underflow to occur if the buffer length is larger than the terminal width. - let row = line.saturating_sub(self.offset.row); - let col = col.saturating_sub(self.offset.col); + let mut pos = visual_coords_at_pos_2(text, anchor, pos, config); - Some(Position::new(row, col)) + if !config.soft_wrap { + pos.col = pos.col.saturating_sub(self.offset.col); + } + + Some(pos) } pub fn text_pos_at_screen_coords( @@ -311,20 +314,24 @@ impl View { return None; } - let text_row = (row - inner.y) as usize + self.offset.row; - if text_row > text.len_lines() - 1 { - return Some(text.len_chars()); - } + let text_row = (row - inner.y) as usize; + // if text_row > text.len_lines() - 1 { + // return Some(text.len_chars()); + // } - let text_col = (column - inner.x) as usize + self.offset.col; + let text_col = (column - inner.x) as usize; + let anchor = text.line_to_char(self.offset.row); + let mut config = doc.cursor_config(inner.width); + config.tab_width = tab_width as u16; - Some(pos_at_visual_coords( + Some(pos_at_visual_coords_2( text, + anchor, Position { row: text_row, col: text_col, }, - tab_width, + config, )) }