diff --git a/book/src/editor.md b/book/src/editor.md index b79792058..fd7338cf2 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -16,6 +16,7 @@ - [`[editor.gutters.diagnostics]` Section](#editorguttersdiagnostics-section) - [`[editor.gutters.diff]` Section](#editorguttersdiff-section) - [`[editor.gutters.spacer]` Section](#editorguttersspacer-section) +- [`[editor.gutters-right]` Section](#editorgutters-right-section) - [`[editor.soft-wrap]` Section](#editorsoft-wrap-section) - [`[editor.smart-tab]` Section](#editorsmart-tab-section) - [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section) @@ -34,7 +35,8 @@ | `cursorline` | Highlight all lines with a cursor | `false` | | `cursorcolumn` | Highlight all columns with a cursor | `false` | | `continue-comments` | if helix should automatically add a line comment token if you create a new line inside a comment. | `true` | -| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | +| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer` and `scrollbar`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | +| `gutters-right` | Gutters to display on the right-hand side of the window. Configuration is identical to the `gutters` key above, but specified gutters will be rendered right-to-left. | `[]` | | `auto-completion` | Enable automatic pop up of auto-completion | `true` | | `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` | | `auto-format` | Enable automatic formatting on save | `true` | @@ -387,6 +389,19 @@ There are currently no options for this section. Currently unused +#### `[editor.gutters.scrollbar]` Section + +### `[editor.gutters-right]` Section + +Configuration options are identical to `[editor.gutters]` Section. + +```toml +[editor] +gutters-right = ["scrollbar"] +``` + +Currently unused + ### `[editor.soft-wrap]` Section Options for soft wrapping lines that exceed the view width: diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index 8423ae8e4..5e7c47397 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -247,6 +247,14 @@ pub fn render_text( last_line_end = grapheme.visual_pos.col + grapheme_width; } + let remaining_viewport_lines = + last_line_pos.visual_line..renderer.viewport.height.saturating_sub(1); + for _ in remaining_viewport_lines { + last_line_pos.doc_line += 1; + last_line_pos.visual_line += 1; + decorations.decorate_line(renderer, last_line_pos); + } + renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); decorations.render_virtual_lines(renderer, last_line_pos, last_line_end) } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 6be565747..60688d0df 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -25,7 +25,7 @@ use helix_core::{ use helix_view::{ annotations::diagnostics::DiagnosticFilter, document::{Mode, SCRATCH_BUFFER_NAME}, - editor::{CompleteAction, CursorShapeConfig}, + editor::{CompleteAction, CursorShapeConfig, GutterType}, graphics::{Color, CursorKind, Modifier, Rect, Style}, input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, @@ -163,9 +163,9 @@ impl EditorView { } } - let gutter_overflow = view.gutter_offset(doc) == 0; + let gutter_overflow = view.gutter_offset(doc) + view.gutter_offset_right(doc) == 0; if !gutter_overflow { - Self::render_gutter( + Self::render_gutters( editor, doc, view, @@ -661,7 +661,7 @@ impl EditorView { } } - pub fn render_gutter<'d>( + pub fn render_gutters<'d>( editor: &'d Editor, doc: &'d Document, view: &View, @@ -677,20 +677,22 @@ impl EditorView { .map(|range| range.cursor_line(text)) .collect(); - let mut offset = 0; + let mut left_offset = 0; + let mut right_offset = 0; let gutter_style = theme.get("ui.gutter"); let gutter_selected_style = theme.get("ui.gutter.selected"); let gutter_style_virtual = theme.get("ui.gutter.virtual"); let gutter_selected_style_virtual = theme.get("ui.gutter.selected.virtual"); - for gutter_type in view.gutters() { + let render_gutter_item = move |viewport: Rect, offset: u16, gutter_type: &GutterType| { let mut gutter = gutter_type.style(editor, doc, view, theme, is_focused); let width = gutter_type.width(view, doc); // avoid lots of small allocations by reusing a text buffer for each line let mut text = String::with_capacity(width); let cursors = cursors.clone(); - let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + + move |renderer: &mut TextRenderer, pos: LinePos| { // TODO handle softwrap in gutters let selected = cursors.contains(&pos.doc_line); let x = viewport.x + offset; @@ -719,10 +721,27 @@ impl EditorView { ); } text.clear(); - }; - decoration_manager.add_decoration(gutter_decoration); + } + }; - offset += width as u16; + for gutter_type in view.gutters() { + let decoration = render_gutter_item(viewport, left_offset, gutter_type); + decoration_manager.add_decoration(decoration); + + left_offset += gutter_type.width(view, doc) as u16; + } + + for gutter_type in view.gutters_right() { + right_offset += gutter_type.width(view, doc) as u16; + + // Offset is moved prior to rendering right-hand gutter items + // since string rendering happens from left to right + let decoration = render_gutter_item( + viewport, + viewport.width.saturating_sub(right_offset), + gutter_type, + ); + decoration_manager.add_decoration(decoration); } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index eab291e44..10aeb682f 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -263,6 +263,8 @@ pub struct Config { pub cursorcolumn: bool, #[serde(deserialize_with = "deserialize_gutter_seq_or_struct")] pub gutters: GutterConfig, + #[serde(deserialize_with = "deserialize_gutter_seq_or_struct")] + pub gutters_right: GutterConfig, /// Middle click paste support. Defaults to true. pub middle_click_paste: bool, /// Automatic insertion of pairs to parentheses, brackets, @@ -715,6 +717,8 @@ pub enum GutterType { Spacer, /// Highlight local changes Diff, + /// Show scrollbar + Scrollbar, } impl std::str::FromStr for GutterType { @@ -726,8 +730,9 @@ impl std::str::FromStr for GutterType { "spacer" => Ok(Self::Spacer), "line-numbers" => Ok(Self::LineNumbers), "diff" => Ok(Self::Diff), + "scrollbar" => Ok(Self::Scrollbar), _ => anyhow::bail!( - "Gutter type can only be `diagnostics`, `spacer`, `line-numbers` or `diff`." + "Gutter type can only be `diagnostics`, `spacer`, `line-numbers`, `diff`, or `scrollbar`." ), } } @@ -976,6 +981,7 @@ impl Default for Config { cursorline: false, cursorcolumn: false, gutters: GutterConfig::default(), + gutters_right: GutterConfig::from(Vec::new()), middle_click_paste: true, auto_pairs: AutoPairConfig::default(), auto_completion: true, @@ -1686,7 +1692,13 @@ impl Editor { .try_get(self.tree.focus) .filter(|v| id == v.doc) // Different Document .cloned() - .unwrap_or_else(|| View::new(id, self.config().gutters.clone())); + .unwrap_or_else(|| { + View::new( + id, + self.config().gutters.clone(), + self.config().gutters_right.clone(), + ) + }); let view_id = self.tree.split( view, match action { @@ -1872,7 +1884,11 @@ impl Editor { .map(|(&doc_id, _)| doc_id) .next() .unwrap_or_else(|| self.new_document(Document::default(self.config.clone()))); - let view = View::new(doc_id, self.config().gutters.clone()); + let view = View::new( + doc_id, + self.config().gutters.clone(), + self.config().gutters_right.clone(), + ); let view_id = self.tree.insert(view); let doc = doc_mut!(self, &doc_id); doc.ensure_view_init(view_id); diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index 665a78bcc..156b918cc 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -32,6 +32,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers(editor, doc, view, theme, is_focused), GutterType::Spacer => padding(editor, doc, view, theme, is_focused), GutterType::Diff => diff(editor, doc, view, theme, is_focused), + GutterType::Scrollbar => scrollbar(editor, doc, view, theme, is_focused), } } @@ -41,6 +42,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers_width(view, doc), GutterType::Spacer => 1, GutterType::Diff => 1, + GutterType::Scrollbar => 1, } } } @@ -139,6 +141,44 @@ pub fn diff<'doc>( } } +pub fn scrollbar<'doc>( + _editor: &'doc Editor, + doc: &'doc Document, + view: &View, + theme: &Theme, + is_focused: bool, +) -> GutterFn<'doc> { + let total_lines = doc.text().len_lines(); + let view_height = view.inner_height(); + let fits = view_height > total_lines; + + if !is_focused || fits { + return Box::new(move |_, _, _, _| None); + } + + let scroll_height = view_height.pow(2).div_ceil(total_lines).min(view_height); + let visual_pos = view.pos_at_visual_coords(doc, 0, 0, false).unwrap(); + let view_vertical_offset = doc.text().char_to_line(visual_pos); + + let scroll_line = ((view_height - scroll_height) * view_vertical_offset + / 1.max(total_lines.saturating_sub(view_height))) + + view_vertical_offset; + + let style = theme.get("ui.menu.scroll"); + Box::new( + move |line: usize, _selected: bool, _first_visual_line: bool, out: &mut String| { + let icon = if line >= scroll_line && line <= scroll_line + scroll_height { + "▐" + } else { + "" + }; + + write!(out, "{}", icon).unwrap(); + Some(style) + }, + ) +} + pub fn line_numbers<'doc>( editor: &'doc Editor, doc: &'doc Document, @@ -167,7 +207,9 @@ pub fn line_numbers<'doc>( Box::new( move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| { - if line == last_line_in_view && !draw_last { + if line > last_line_in_view { + None + } else if line == last_line_in_view && !draw_last { write!(out, "{:>1$}", '~', width).unwrap(); Some(linenr) } else { @@ -338,7 +380,11 @@ mod tests { #[test] fn test_default_gutter_widths() { - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); @@ -363,7 +409,11 @@ mod tests { ..Default::default() }; - let mut view = View::new(DocumentId::default(), gutters); + let mut view = View::new( + DocumentId::default(), + gutters, + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); @@ -381,7 +431,11 @@ mod tests { line_numbers: GutterLineNumbersConfig { min_width: 10 }, }; - let mut view = View::new(DocumentId::default(), gutters); + let mut view = View::new( + DocumentId::default(), + gutters, + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); @@ -403,7 +457,11 @@ mod tests { line_numbers: GutterLineNumbersConfig { min_width: 1 }, }; - let mut view = View::new(DocumentId::default(), gutters); + let mut view = View::new( + DocumentId::default(), + gutters, + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("a\nb"); diff --git a/helix-view/src/tree.rs b/helix-view/src/tree.rs index aba947a21..d2488204a 100644 --- a/helix-view/src/tree.rs +++ b/helix-view/src/tree.rs @@ -736,22 +736,38 @@ mod test { width: 180, height: 80, }); - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(0, 0, 180, 80); tree.insert(view); let l0 = tree.focus; - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); let r0 = tree.focus; tree.focus = l0; - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Horizontal); let l1 = tree.focus; tree.focus = l0; - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); // Tree in test @@ -792,28 +808,44 @@ mod test { }); let doc_l0 = DocumentId::default(); - let mut view = View::new(doc_l0, GutterConfig::default()); + let mut view = View::new( + doc_l0, + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(0, 0, 180, 80); tree.insert(view); let l0 = tree.focus; let doc_r0 = DocumentId::default(); - let view = View::new(doc_r0, GutterConfig::default()); + let view = View::new( + doc_r0, + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); let r0 = tree.focus; tree.focus = l0; let doc_l1 = DocumentId::default(); - let view = View::new(doc_l1, GutterConfig::default()); + let view = View::new( + doc_l1, + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Horizontal); let l1 = tree.focus; tree.focus = l0; let doc_l2 = DocumentId::default(); - let view = View::new(doc_l2, GutterConfig::default()); + let view = View::new( + doc_l2, + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); let l2 = tree.focus; @@ -908,19 +940,35 @@ mod test { width: tree_area_width, height: 80, }); - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(0, 0, 180, 80); tree.insert(view); - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Horizontal); tree.remove(tree.focus); - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); // Make sure that we only have one level in the tree. @@ -946,12 +994,20 @@ mod test { width: tree_area_width, height: tree_area_height, }); - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(0, 0, tree_area_width, tree_area_height); tree.insert(view); for _ in 0..9 { - let view = View::new(DocumentId::default(), GutterConfig::default()); + let view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); tree.split(view, Layout::Vertical); } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index d6f10753a..d7fd7be37 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -142,6 +142,8 @@ pub struct View { pub object_selections: Vec, /// all gutter-related configuration settings, used primarily for gutter rendering pub gutters: GutterConfig, + /// all configuration settings related to the right-hand gutter + pub gutters_right: GutterConfig, /// A mapping between documents and the last history revision the view was updated at. /// Changes between documents and views are synced lazily when switching windows. This /// mapping keeps track of the last applied history revision so that only new changes @@ -168,7 +170,7 @@ impl fmt::Debug for View { } impl View { - pub fn new(doc: DocumentId, gutters: GutterConfig) -> Self { + pub fn new(doc: DocumentId, gutters: GutterConfig, gutters_right: GutterConfig) -> Self { Self { id: ViewId::default(), doc, @@ -178,6 +180,7 @@ impl View { last_modified_docs: [None, None], object_selections: Vec::new(), gutters, + gutters_right, doc_revisions: HashMap::new(), diagnostics_handler: DiagnosticsHandler::new(), } @@ -191,7 +194,10 @@ impl View { } pub fn inner_area(&self, doc: &Document) -> Rect { - self.area.clip_left(self.gutter_offset(doc)).clip_bottom(1) // -1 for statusline + self.area + .clip_left(self.gutter_offset(doc)) + .clip_right(self.gutter_offset_right(doc)) + .clip_bottom(1) // -1 for statusline } pub fn inner_height(&self) -> usize { @@ -199,13 +205,20 @@ impl View { } pub fn inner_width(&self, doc: &Document) -> u16 { - self.area.clip_left(self.gutter_offset(doc)).width + self.area + .clip_left(self.gutter_offset(doc)) + .clip_right(self.gutter_offset_right(doc)) + .width } pub fn gutters(&self) -> &[GutterType] { &self.gutters.layout } + pub fn gutters_right(&self) -> &[GutterType] { + &self.gutters_right.layout + } + pub fn gutter_offset(&self, doc: &Document) -> u16 { let total_width = self .gutters @@ -220,6 +233,20 @@ impl View { } } + pub fn gutter_offset_right(&self, doc: &Document) -> u16 { + let total_width = self + .gutters_right + .layout + .iter() + .map(|gutter| gutter.width(self, doc) as u16) + .sum(); + if total_width < self.area.width { + total_width + } else { + 0 + } + } + // pub fn offset_coords_to_in_view( &self, @@ -712,7 +739,11 @@ mod tests { #[test] fn test_text_pos_at_screen_coords() { - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); let mut doc = Document::from( @@ -887,6 +918,7 @@ mod tests { layout: vec![GutterType::Diagnostics], line_numbers: GutterLineNumbersConfig::default(), }, + GutterConfig::from(Vec::new()), ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); @@ -917,6 +949,7 @@ mod tests { layout: vec![], line_numbers: GutterLineNumbersConfig::default(), }, + GutterConfig::from(Vec::new()), ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); @@ -941,7 +974,11 @@ mod tests { #[test] fn test_text_pos_at_screen_coords_cjk() { - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("Hi! こんにちは皆さん"); let mut doc = Document::from( @@ -1025,7 +1062,11 @@ mod tests { #[test] fn test_text_pos_at_screen_coords_graphemes() { - let mut view = View::new(DocumentId::default(), GutterConfig::default()); + let mut view = View::new( + DocumentId::default(), + GutterConfig::default(), + GutterConfig::from(Vec::new()), + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("Hèl̀l̀ò world!"); let mut doc = Document::from(