diff --git a/book/src/configuration.md b/book/src/configuration.md index f89ef5aed..ab229f772 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -256,3 +256,55 @@ ### `[editor.indent-guides]` Section character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" skip-levels = 1 ``` + +### `[editor.gutters]` Section + +For simplicity, `editor.gutters` accepts an array of gutter types, which will +use default settings for all gutter components. + +```toml +[editor] +gutters = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +To customize the behavior of gutters, the `[editor.gutters]` section must +be used. This section contains top level settings, as well as settings for +specific gutter components as sub-sections. + +| Key | Description | Default | +| --- | --- | --- | +| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | + +Example: + +```toml +[editor.gutters] +layout = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +#### `[editor.gutters.line-numbers]` Section + +Options for the line number gutter + +| Key | Description | Default | +| --- | --- | --- | +| `min-width` | The minimum number of characters to use | `3` | + +Example: + +```toml +[editor.gutters.line-numbers] +min-width = 1 +``` + +#### `[editor.gutters.diagnotics]` Section + +Currently unused + +#### `[editor.gutters.diff]` Section + +Currently unused + +#### `[editor.gutters.spacer]` Section + +Currently unused \ No newline at end of file diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 3ee0325d5..9af8e4c34 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -71,6 +71,96 @@ fn serialize_duration_millis(duration: &Duration, serializer: S) -> Result, + /// Options specific to the "line-numbers" gutter + pub line_numbers: GutterLineNumbersConfig, +} + +impl Default for GutterConfig { + fn default() -> Self { + Self { + layout: vec![ + GutterType::Diagnostics, + GutterType::Spacer, + GutterType::LineNumbers, + GutterType::Spacer, + GutterType::Diff, + ], + line_numbers: GutterLineNumbersConfig::default(), + } + } +} + +impl From> for GutterConfig { + fn from(x: Vec) -> Self { + GutterConfig { + layout: x, + ..Default::default() + } + } +} + +fn deserialize_gutter_seq_or_struct<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct GutterVisitor; + + impl<'de> serde::de::Visitor<'de> for GutterVisitor { + type Value = GutterConfig; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "an array of gutter names or a detailed gutter configuration" + ) + } + + fn visit_seq(self, mut seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + let mut gutters = Vec::new(); + while let Some(gutter) = seq.next_element::<&str>()? { + gutters.push( + gutter + .parse::() + .map_err(serde::de::Error::custom)?, + ) + } + + Ok(gutters.into()) + } + + fn visit_map(self, map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let deserializer = serde::de::value::MapAccessDeserializer::new(map); + Deserialize::deserialize(deserializer) + } + } + + deserializer.deserialize_any(GutterVisitor) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct GutterLineNumbersConfig { + /// Minimum number of characters to use for line number gutter. Defaults to 3. + pub min_width: usize, +} + +impl Default for GutterLineNumbersConfig { + fn default() -> Self { + Self { min_width: 3 } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct FilePickerConfig { @@ -132,8 +222,8 @@ pub struct Config { pub cursorline: bool, /// Highlight the columns cursors are currently on. Defaults to false. pub cursorcolumn: bool, - /// Gutters. Default ["diagnostics", "line-numbers"] - pub gutters: Vec, + #[serde(deserialize_with = "deserialize_gutter_seq_or_struct")] + pub gutters: GutterConfig, /// Middle click paste support. Defaults to true. pub middle_click_paste: bool, /// Automatic insertion of pairs to parentheses, brackets, @@ -606,13 +696,7 @@ fn default() -> Self { line_number: LineNumber::Absolute, cursorline: false, cursorcolumn: false, - gutters: vec![ - GutterType::Diagnostics, - GutterType::Spacer, - GutterType::LineNumbers, - GutterType::Spacer, - GutterType::Diff, - ], + gutters: GutterConfig::default(), middle_click_paste: true, auto_pairs: AutoPairConfig::default(), auto_completion: true, @@ -844,6 +928,7 @@ pub fn refresh_config(&mut self) { let config = self.config(); self.auto_pairs = (&config.auto_pairs).into(); self.reset_idle_timer(); + self._refresh(); } pub fn clear_idle_timer(&mut self) { @@ -984,6 +1069,7 @@ fn _refresh(&mut self) { for (view, _) in self.tree.views_mut() { let doc = doc_mut!(self, &view.doc); view.sync_changes(doc); + view.gutters = config.gutters.clone(); view.ensure_cursor_in_view(doc, config.scrolloff) } } diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index 377518fb5..c1b5e2b16 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -35,10 +35,10 @@ pub fn style<'doc>( } } - pub fn width(self, _view: &View, doc: &Document) -> usize { + pub fn width(self, view: &View, doc: &Document) -> usize { match self { GutterType::Diagnostics => 1, - GutterType::LineNumbers => line_numbers_width(_view, doc), + GutterType::LineNumbers => line_numbers_width(view, doc), GutterType::Spacer => 1, GutterType::Diff => 1, } @@ -140,12 +140,13 @@ pub fn line_numbers<'doc>( is_focused: bool, ) -> GutterFn<'doc> { let text = doc.text().slice(..); - let last_line = view.last_line(doc); - let width = GutterType::LineNumbers.width(view, doc); + let width = line_numbers_width(view, doc); + + let last_line_in_view = view.last_line(doc); // Whether to draw the line number for the last line of the // document or not. We only draw it if it's not an empty line. - let draw_last = text.line_to_byte(last_line) < text.len_bytes(); + let draw_last = text.line_to_byte(last_line_in_view) < text.len_bytes(); let linenr = theme.get("ui.linenr"); let linenr_select = theme.get("ui.linenr.selected"); @@ -158,7 +159,7 @@ pub fn line_numbers<'doc>( let mode = editor.mode; Box::new(move |line: usize, selected: bool, out: &mut String| { - if line == last_line && !draw_last { + if line == last_line_in_view && !draw_last { write!(out, "{:>1$}", '~', width).unwrap(); Some(linenr) } else { @@ -187,14 +188,19 @@ pub fn line_numbers<'doc>( }) } -pub fn line_numbers_width(_view: &View, doc: &Document) -> usize { +/// The width of a "line-numbers" gutter +/// +/// The width of the gutter depends on the number of lines in the document, +/// whether there is content on the last line (the `~` line), and the +/// `editor.gutters.line-numbers.min-width` settings. +fn line_numbers_width(view: &View, doc: &Document) -> usize { let text = doc.text(); let last_line = text.len_lines().saturating_sub(1); let draw_last = text.line_to_byte(last_line) < text.len_bytes(); let last_drawn = if draw_last { last_line + 1 } else { last_line }; - - // set a lower bound to 2-chars to minimize ambiguous relative line numbers - std::cmp::max(count_digits(last_drawn), 2) + let digits = count_digits(last_drawn); + let n_min = view.gutters.line_numbers.min_width; + digits.max(n_min) } pub fn padding<'doc>( @@ -282,3 +288,82 @@ pub fn diagnostics_or_breakpoints<'doc>( breakpoints(line, selected, out).or_else(|| diagnostics(line, selected, out)) }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + use crate::editor::{GutterConfig, GutterLineNumbersConfig}; + use crate::graphics::Rect; + use crate::DocumentId; + use helix_core::Rope; + + #[test] + fn test_default_gutter_widths() { + let mut view = View::new(DocumentId::default(), GutterConfig::default()); + view.area = Rect::new(40, 40, 40, 40); + + let rope = Rope::from_str("abc\n\tdef"); + let doc = Document::from(rope, None); + + assert_eq!(view.gutters.layout.len(), 5); + assert_eq!(view.gutters.layout[0].width(&view, &doc), 1); + assert_eq!(view.gutters.layout[1].width(&view, &doc), 1); + assert_eq!(view.gutters.layout[2].width(&view, &doc), 3); + assert_eq!(view.gutters.layout[3].width(&view, &doc), 1); + assert_eq!(view.gutters.layout[4].width(&view, &doc), 1); + } + + #[test] + fn test_configured_gutter_widths() { + let gutters = GutterConfig { + layout: vec![GutterType::Diagnostics], + ..Default::default() + }; + + let mut view = View::new(DocumentId::default(), gutters); + view.area = Rect::new(40, 40, 40, 40); + + let rope = Rope::from_str("abc\n\tdef"); + let doc = Document::from(rope, None); + + assert_eq!(view.gutters.layout.len(), 1); + assert_eq!(view.gutters.layout[0].width(&view, &doc), 1); + + let gutters = GutterConfig { + layout: vec![GutterType::Diagnostics, GutterType::LineNumbers], + line_numbers: GutterLineNumbersConfig { min_width: 10 }, + }; + + let mut view = View::new(DocumentId::default(), gutters); + view.area = Rect::new(40, 40, 40, 40); + + let rope = Rope::from_str("abc\n\tdef"); + let doc = Document::from(rope, None); + + assert_eq!(view.gutters.layout.len(), 2); + assert_eq!(view.gutters.layout[0].width(&view, &doc), 1); + assert_eq!(view.gutters.layout[1].width(&view, &doc), 10); + } + + #[test] + fn test_line_numbers_gutter_width_resizes() { + let gutters = GutterConfig { + layout: vec![GutterType::Diagnostics, GutterType::LineNumbers], + line_numbers: GutterLineNumbersConfig { min_width: 1 }, + }; + + let mut view = View::new(DocumentId::default(), gutters); + view.area = Rect::new(40, 40, 40, 40); + + let rope = Rope::from_str("a\nb"); + let doc_short = Document::from(rope, None); + + let rope = Rope::from_str("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np"); + let doc_long = Document::from(rope, None); + + assert_eq!(view.gutters.layout.len(), 2); + assert_eq!(view.gutters.layout[1].width(&view, &doc_short), 1); + assert_eq!(view.gutters.layout[1].width(&view, &doc_long), 2); + } +} diff --git a/helix-view/src/tree.rs b/helix-view/src/tree.rs index 469e913de..5ec2773d9 100644 --- a/helix-view/src/tree.rs +++ b/helix-view/src/tree.rs @@ -701,7 +701,7 @@ fn next_back(&mut self) -> Option { #[cfg(test)] mod test { use super::*; - use crate::editor::GutterType; + use crate::editor::GutterConfig; use crate::DocumentId; #[test] @@ -712,34 +712,22 @@ fn find_split_in_direction() { width: 180, height: 80, }); - let mut view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let mut view = View::new(DocumentId::default(), GutterConfig::default()); view.area = Rect::new(0, 0, 180, 80); tree.insert(view); let l0 = tree.focus; - let view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(DocumentId::default(), GutterConfig::default()); tree.split(view, Layout::Vertical); let r0 = tree.focus; tree.focus = l0; - let view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(DocumentId::default(), GutterConfig::default()); tree.split(view, Layout::Horizontal); let l1 = tree.focus; tree.focus = l0; - let view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(DocumentId::default(), GutterConfig::default()); tree.split(view, Layout::Vertical); let l2 = tree.focus; @@ -781,40 +769,28 @@ fn swap_split_in_direction() { }); let doc_l0 = DocumentId::default(); - let mut view = View::new( - doc_l0, - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let mut view = View::new(doc_l0, GutterConfig::default()); 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, - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(doc_r0, GutterConfig::default()); tree.split(view, Layout::Vertical); let r0 = tree.focus; tree.focus = l0; let doc_l1 = DocumentId::default(); - let view = View::new( - doc_l1, - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(doc_l1, GutterConfig::default()); tree.split(view, Layout::Horizontal); let l1 = tree.focus; tree.focus = l0; let doc_l2 = DocumentId::default(); - let view = View::new( - doc_l2, - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let view = View::new(doc_l2, GutterConfig::default()); tree.split(view, Layout::Vertical); let l2 = tree.focus; diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 23fb85c96..abcf9a169 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -1,4 +1,10 @@ -use crate::{align_view, editor::GutterType, graphics::Rect, Align, Document, DocumentId, ViewId}; +use crate::{ + align_view, + editor::{GutterConfig, GutterType}, + graphics::Rect, + Align, Document, DocumentId, ViewId, +}; + use helix_core::{ pos_at_visual_coords, visual_coords_at_pos, Position, RopeSlice, Selection, Transaction, }; @@ -103,8 +109,8 @@ pub struct View { pub last_modified_docs: [Option; 2], /// used to store previous selections of tree-sitter objects pub object_selections: Vec, - /// GutterTypes used to fetch Gutter (constructor) and width for rendering - gutters: Vec, + /// all gutter-related configuration settings, used primarily for gutter rendering + pub gutters: 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 @@ -123,7 +129,7 @@ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { } impl View { - pub fn new(doc: DocumentId, gutter_types: Vec) -> Self { + pub fn new(doc: DocumentId, gutters: GutterConfig) -> Self { Self { id: ViewId::default(), doc, @@ -133,7 +139,7 @@ pub fn new(doc: DocumentId, gutter_types: Vec) -> Sel docs_access_history: Vec::new(), last_modified_docs: [None, None], object_selections: Vec::new(), - gutters: gutter_types, + gutters, doc_revisions: HashMap::new(), } } @@ -154,11 +160,12 @@ pub fn inner_height(&self) -> usize { } pub fn gutters(&self) -> &[GutterType] { - &self.gutters + &self.gutters.layout } pub fn gutter_offset(&self, doc: &Document) -> u16 { self.gutters + .layout .iter() .map(|gutter| gutter.width(self, doc) as u16) .sum() @@ -414,18 +421,19 @@ pub fn sync_changes(&mut self, doc: &mut Document) { mod tests { use super::*; use helix_core::Rope; - const OFFSET: u16 = 3; // 1 diagnostic + 2 linenr (< 100 lines) - const OFFSET_WITHOUT_LINE_NUMBERS: u16 = 1; // 1 diagnostic - // const OFFSET: u16 = GUTTERS.iter().map(|(_, width)| *width as u16).sum(); + + // 1 diagnostic + 1 spacer + 3 linenr (< 1000 lines) + 1 spacer + 1 diff + const DEFAULT_GUTTER_OFFSET: u16 = 7; + + // 1 diagnostics + 1 spacer + 1 gutter + const DEFAULT_GUTTER_OFFSET_ONLY_DIAGNOSTICS: u16 = 3; + use crate::document::Document; - use crate::editor::GutterType; + use crate::editor::{GutterConfig, GutterLineNumbersConfig, GutterType}; #[test] fn test_text_pos_at_screen_coords() { - let mut view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let mut view = View::new(DocumentId::default(), GutterConfig::default()); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); let doc = Document::from(rope, None); @@ -445,24 +453,24 @@ fn test_text_pos_at_screen_coords() { assert_eq!(view.text_pos_at_screen_coords(&doc, 78, 41, 4), None); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 3, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 3, 4), Some(3) ); assert_eq!(view.text_pos_at_screen_coords(&doc, 40, 80, 4), Some(3)); assert_eq!( - view.text_pos_at_screen_coords(&doc, 41, 40 + OFFSET + 1, 4), + view.text_pos_at_screen_coords(&doc, 41, 40 + DEFAULT_GUTTER_OFFSET + 1, 4), Some(4) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 41, 40 + OFFSET + 4, 4), + view.text_pos_at_screen_coords(&doc, 41, 40 + DEFAULT_GUTTER_OFFSET + 4, 4), Some(5) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 41, 40 + OFFSET + 7, 4), + view.text_pos_at_screen_coords(&doc, 41, 40 + DEFAULT_GUTTER_OFFSET + 7, 4), Some(8) ); @@ -471,19 +479,36 @@ fn test_text_pos_at_screen_coords() { #[test] fn test_text_pos_at_screen_coords_without_line_numbers_gutter() { - let mut view = View::new(DocumentId::default(), vec![GutterType::Diagnostics]); + let mut view = View::new( + DocumentId::default(), + GutterConfig { + layout: vec![GutterType::Diagnostics], + line_numbers: GutterLineNumbersConfig::default(), + }, + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); let doc = Document::from(rope, None); assert_eq!( - view.text_pos_at_screen_coords(&doc, 41, 40 + OFFSET_WITHOUT_LINE_NUMBERS + 1, 4), + view.text_pos_at_screen_coords( + &doc, + 41, + 40 + DEFAULT_GUTTER_OFFSET_ONLY_DIAGNOSTICS + 1, + 4 + ), Some(4) ); } #[test] fn test_text_pos_at_screen_coords_without_any_gutters() { - let mut view = View::new(DocumentId::default(), vec![]); + let mut view = View::new( + DocumentId::default(), + GutterConfig { + layout: vec![], + line_numbers: GutterLineNumbersConfig::default(), + }, + ); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("abc\n\tdef"); let doc = Document::from(rope, None); @@ -492,76 +517,70 @@ fn test_text_pos_at_screen_coords_without_any_gutters() { #[test] fn test_text_pos_at_screen_coords_cjk() { - let mut view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let mut view = View::new(DocumentId::default(), GutterConfig::default()); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("Hi! こんにちは皆さん"); let doc = Document::from(rope, None); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET, 4), Some(0) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 4, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 4, 4), Some(4) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 5, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 5, 4), Some(4) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 6, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 6, 4), Some(5) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 7, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 7, 4), Some(5) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 8, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 8, 4), Some(6) ); } #[test] fn test_text_pos_at_screen_coords_graphemes() { - let mut view = View::new( - DocumentId::default(), - vec![GutterType::Diagnostics, GutterType::LineNumbers], - ); + let mut view = View::new(DocumentId::default(), GutterConfig::default()); view.area = Rect::new(40, 40, 40, 40); let rope = Rope::from_str("Hèl̀l̀ò world!"); let doc = Document::from(rope, None); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET, 4), Some(0) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 1, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 1, 4), Some(1) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 2, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 2, 4), Some(3) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 3, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 3, 4), Some(5) ); assert_eq!( - view.text_pos_at_screen_coords(&doc, 40, 40 + OFFSET + 4, 4), + view.text_pos_at_screen_coords(&doc, 40, 40 + DEFAULT_GUTTER_OFFSET + 4, 4), Some(7) ); }