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-view/src/document.rs | 74 +++++- helix-view/src/editor.rs | 85 +++++- helix-view/src/gutter.rs | 293 +++++++++++--------- helix-view/src/lib.rs | 23 +- helix-view/src/theme.rs | 15 +- helix-view/src/view.rs | 647 ++++++++++++++++++++++++++++++++++++--------- 6 files changed, 855 insertions(+), 282 deletions(-) (limited to 'helix-view/src') diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 6b33ea6a..798b5400 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1,7 +1,11 @@ use anyhow::{anyhow, bail, Context, Error}; +use arc_swap::access::DynAccess; use futures_util::future::BoxFuture; use futures_util::FutureExt; use helix_core::auto_pairs::AutoPairs; +use helix_core::doc_formatter::TextFormat; +use helix_core::syntax::Highlight; +use helix_core::text_annotations::TextAnnotations; use helix_core::Range; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -26,8 +30,8 @@ use helix_core::{ DEFAULT_LINE_ENDING, }; -use crate::editor::RedrawHandle; -use crate::{DocumentId, Editor, View, ViewId}; +use crate::editor::{Config, RedrawHandle}; +use crate::{DocumentId, Editor, Theme, View, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -127,6 +131,7 @@ pub struct Document { // it back as it separated from the edits. We could split out the parts manually but that will // be more troublesome. pub history: Cell, + pub config: Arc>, pub savepoint: Option, @@ -351,7 +356,11 @@ use helix_lsp::lsp; use url::Url; impl Document { - pub fn from(text: Rope, encoding: Option<&'static encoding::Encoding>) -> Self { + pub fn from( + text: Rope, + encoding: Option<&'static encoding::Encoding>, + config: Arc>, + ) -> Self { let encoding = encoding.unwrap_or(encoding::UTF_8); let changes = ChangeSet::new(&text); let old_state = None; @@ -377,9 +386,13 @@ impl Document { modified_since_accessed: false, language_server: None, diff_handle: None, + config, } } - + pub fn default(config: Arc>) -> Self { + let text = Rope::from(DEFAULT_LINE_ENDING.as_str()); + Self::from(text, None, config) + } // TODO: async fn? /// Create a new document from `path`. Encoding is auto-detected, but it can be manually /// overwritten with the `encoding` parameter. @@ -387,6 +400,7 @@ impl Document { path: &Path, encoding: Option<&'static encoding::Encoding>, config_loader: Option>, + config: Arc>, ) -> Result { // Open the file if it exists, otherwise assume it is a new file (and thus empty). let (rope, encoding) = if path.exists() { @@ -398,7 +412,7 @@ impl Document { (Rope::from(DEFAULT_LINE_ENDING.as_str()), encoding) }; - let mut doc = Self::from(rope, Some(encoding)); + let mut doc = Self::from(rope, Some(encoding), config); // set the path and try detecting the language doc.set_path(Some(path))?; @@ -1192,12 +1206,34 @@ impl Document { None => global_config, } } -} -impl Default for Document { - fn default() -> Self { - let text = Rope::from(DEFAULT_LINE_ENDING.as_str()); - Self::from(text, None) + pub fn text_format(&self, mut viewport_width: u16, theme: Option<&Theme>) -> TextFormat { + if let Some(max_line_len) = self + .language_config() + .and_then(|config| config.max_line_length) + { + viewport_width = viewport_width.min(max_line_len as u16) + } + let config = self.config.load(); + let soft_wrap = &config.soft_wrap; + let tab_width = self.tab_width() as u16; + TextFormat { + soft_wrap: soft_wrap.enable && viewport_width > 10, + tab_width, + max_wrap: soft_wrap.max_wrap.min(viewport_width / 4), + max_indent_retain: soft_wrap.max_indent_retain.min(viewport_width * 2 / 5), + // avoid spinning forever when the window manager + // sets the size to something tiny + viewport_width, + wrap_indicator: soft_wrap.wrap_indicator.clone().into_boxed_str(), + wrap_indicator_highlight: theme + .and_then(|theme| theme.find_scope_index("ui.virtual.wrap")) + .map(Highlight), + } + } + + pub fn text_annotations(&self, _theme: Option<&Theme>) -> TextAnnotations { + TextAnnotations::default() } } @@ -1236,13 +1272,19 @@ impl Display for FormatterError { #[cfg(test)] mod test { + use arc_swap::ArcSwap; + use super::*; #[test] fn changeset_to_changes_ignore_line_endings() { use helix_lsp::{lsp, Client, OffsetEncoding}; let text = Rope::from("hello\r\nworld"); - let mut doc = Document::from(text, None); + let mut doc = Document::from( + text, + None, + Arc::new(ArcSwap::new(Arc::new(Config::default()))), + ); let view = ViewId::default(); doc.set_selection(view, Selection::single(0, 0)); @@ -1276,7 +1318,11 @@ mod test { fn changeset_to_changes() { use helix_lsp::{lsp, Client, OffsetEncoding}; let text = Rope::from("hello"); - let mut doc = Document::from(text, None); + let mut doc = Document::from( + text, + None, + Arc::new(ArcSwap::new(Arc::new(Config::default()))), + ); let view = ViewId::default(); doc.set_selection(view, Selection::single(5, 5)); @@ -1389,7 +1435,9 @@ mod test { #[test] fn test_line_ending() { assert_eq!( - Document::default().text().to_string(), + Document::default(Arc::new(ArcSwap::new(Arc::new(Config::default())))) + .text() + .to_string(), DEFAULT_LINE_ENDING.as_str() ); } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 1029c14f..46511c62 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -7,6 +7,7 @@ use crate::{ input::KeyEvent, theme::{self, Theme}, tree::{self, Tree}, + view::ViewPosition, Align, Document, DocumentId, View, ViewId, }; use helix_vcs::DiffProviderRegistry; @@ -18,6 +19,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ borrow::Cow, + cell::Cell, collections::{BTreeMap, HashMap}, io::stdin, num::NonZeroUsize, @@ -268,6 +270,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 left free at the end of the line. + /// This space is used to wrap text at word boundaries. If that is not possible within this limit + /// the word is simply split at the end of the line. + /// + /// This is automatically hard-limited to a quarter of the viewport to ensure correct display on small views. + /// + /// Default to 20 + pub max_wrap: u16, + /// Maximum number of indentation that can be carried over from the previous line when softwrapping. + /// If a line is indented further then this limit it is rendered at the start of the viewport instead. + /// + /// This is automatically hard-limited to a quarter of the viewport to ensure correct display on small views. + /// + /// Default to 40 + pub max_indent_retain: u16, + /// Indicator placed at the beginning of softwrapped lines + /// + /// Defaults to ↪ + pub wrap_indicator: String, +} + +impl Default for SoftWrap { + fn default() -> Self { + SoftWrap { + enable: false, + max_wrap: 20, + max_indent_retain: 40, + wrap_indicator: "↪ ".into(), + } + } } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -717,6 +757,7 @@ impl Default for Config { bufferline: BufferLine::default(), indent_guides: IndentGuidesConfig::default(), color_modes: false, + soft_wrap: SoftWrap::default(), } } } @@ -797,7 +838,7 @@ pub struct Editor { pub status_msg: Option<(Cow<'static, str>, Severity)>, pub autoinfo: Option, - pub config: Box>, + pub config: Arc>, pub auto_pairs: Option, pub idle_timer: Pin>, @@ -813,6 +854,19 @@ pub struct Editor { /// The `RwLock` blocks the editor from performing the render until an exclusive lock can be aquired pub redraw_handle: RedrawHandle, pub needs_redraw: bool, + /// Cached position of the cursor calculated during rendering. + /// The content of `cursor_cache` is returned by `Editor::cursor` if + /// set to `Some(_)`. The value will be cleared after it's used. + /// If `cursor_cache` is `None` then the `Editor::cursor` function will + /// calculate the cursor position. + /// + /// `Some(None)` represents a cursor position outside of the visible area. + /// This will just cause `Editor::cursor` to return `None`. + /// + /// This cache is only a performance optimization to + /// avoid calculating the cursor position multiple + /// times during rendering and should not be set by other functions. + pub cursor_cache: Cell>>, } pub type RedrawHandle = (Arc, Arc>); @@ -866,7 +920,7 @@ impl Editor { mut area: Rect, theme_loader: Arc, syn_loader: Arc, - config: Box>, + config: Arc>, ) -> Self { let conf = config.load(); let auto_pairs = (&conf.auto_pairs).into(); @@ -910,6 +964,7 @@ impl Editor { config_events: unbounded_channel(), redraw_handle: Default::default(), needs_redraw: false, + cursor_cache: Cell::new(None), } } @@ -994,7 +1049,7 @@ impl Editor { fn set_theme_impl(&mut self, theme: Theme, preview: ThemeAction) { // `ui.selection` is the only scope required to be able to render a theme. - if theme.find_scope_index("ui.selection").is_none() { + if theme.find_scope_index_exact("ui.selection").is_none() { self.set_error("Invalid theme: `ui.selection` required"); return; } @@ -1077,7 +1132,7 @@ impl Editor { fn replace_document_in_view(&mut self, current_view: ViewId, doc_id: DocumentId) { let view = self.tree.get_mut(current_view); view.doc = doc_id; - view.offset = Position::default(); + view.offset = ViewPosition::default(); let doc = doc_mut!(self, &doc_id); doc.ensure_view_init(view.id); @@ -1204,12 +1259,15 @@ impl Editor { } pub fn new_file(&mut self, action: Action) -> DocumentId { - self.new_file_from_document(action, Document::default()) + self.new_file_from_document(action, Document::default(self.config.clone())) } pub fn new_file_from_stdin(&mut self, action: Action) -> Result { let (rope, encoding) = crate::document::from_reader(&mut stdin(), None)?; - Ok(self.new_file_from_document(action, Document::from(rope, Some(encoding)))) + Ok(self.new_file_from_document( + action, + Document::from(rope, Some(encoding), self.config.clone()), + )) } // ??? possible use for integration tests @@ -1220,7 +1278,12 @@ impl Editor { let id = if let Some(id) = id { id } else { - let mut doc = Document::open(&path, None, Some(self.syn_loader.clone()))?; + let mut doc = Document::open( + &path, + None, + Some(self.syn_loader.clone()), + self.config.clone(), + )?; let _ = Self::launch_language_server(&mut self.language_servers, &mut doc); if let Some(diff_base) = self.diff_providers.get_diff_base(&path) { @@ -1306,7 +1369,7 @@ impl Editor { .iter() .map(|(&doc_id, _)| doc_id) .next() - .unwrap_or_else(|| self.new_document(Document::default())); + .unwrap_or_else(|| self.new_document(Document::default(self.config.clone()))); let view = View::new(doc_id, self.config().gutters.clone()); let view_id = self.tree.insert(view); let doc = doc_mut!(self, &doc_id); @@ -1440,7 +1503,11 @@ impl Editor { .selection(view.id) .primary() .cursor(doc.text().slice(..)); - if let Some(mut pos) = view.screen_coords_at_pos(doc, doc.text().slice(..), cursor) { + let pos = self + .cursor_cache + .get() + .unwrap_or_else(|| view.screen_coords_at_pos(doc, doc.text().slice(..), cursor)); + if let Some(mut pos) = pos { let inner = view.inner_area(doc); pos.col += inner.x as usize; pos.row += inner.y as usize; diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index c1b5e2b1..90c94d55 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -12,7 +12,7 @@ fn count_digits(n: usize) -> usize { std::iter::successors(Some(n), |&n| (n >= 10).then(|| n / 10)).count() } -pub type GutterFn<'doc> = Box Option