diff --git a/Cargo.lock b/Cargo.lock index a1a9eae4dd273..57d508e5821a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,7 @@ dependencies = [ "hashbrown 0.13.2", "helix-loader", "imara-diff", + "indoc 1.0.9", "log", "once_cell", "quickcheck", @@ -1176,7 +1177,7 @@ dependencies = [ "helix-vcs", "helix-view", "ignore", - "indoc", + "indoc 2.0.0", "log", "once_cell", "pulldown-cmark", @@ -1212,6 +1213,7 @@ dependencies = [ name = "helix-vcs" version = "0.6.0" dependencies = [ + "arc-swap", "gix", "helix-core", "imara-diff", @@ -1347,6 +1349,12 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + [[package]] name = "indoc" version = "2.0.0" diff --git a/book/src/configuration.md b/book/src/configuration.md index aebf5ff0f9beb..ec692cab1225c 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -50,6 +50,7 @@ signal to the Helix process on Unix operating systems, such as by using the comm | `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | +| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | | `auto-info` | Whether to display info boxes | `true` | | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` | | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` | @@ -110,6 +111,7 @@ The following statusline elements can be configured: | `position-percentage` | The cursor position as a percentage of the total number of lines | | `separator` | The string defined in `editor.statusline.separator` (defaults to `"β”‚"`) | | `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) | +| `version-control` | The current branch name or detached commit hash of the opened workspace | ### `[editor.lsp]` Section @@ -118,9 +120,12 @@ The following statusline elements can be configured: | `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | | `display-messages` | Display LSP progress messages below statusline[^1] | `false` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | +| `display-inlay-hints` | Display inlay hints[^2] | `false` | | `display-signature-help-docs` | Display docs under signature help popup | `true` | [^1]: By default, a progress spinner is shown in the statusline beside the file path. +[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. + Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances, please report any bugs you see so we can fix them! ### `[editor.cursor-shape]` Section diff --git a/book/src/themes.md b/book/src/themes.md index af238e949b831..929f821e64cf9 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -215,6 +215,7 @@ We use a similar set of scopes as - `special` (preprocessor in C) - `tag` - Tags (e.g. `` in HTML) + - `builtin` - `namespace` @@ -261,58 +262,61 @@ These scopes are used for theming the editor interface: - `hover` - for hover popup UI -| Key | Notes | -| --- | --- | -| `ui.background` | | -| `ui.background.separator` | Picker separator below input line | -| `ui.cursor` | | -| `ui.cursor.normal` | | -| `ui.cursor.insert` | | -| `ui.cursor.select` | | -| `ui.cursor.match` | Matching bracket etc. | -| `ui.cursor.primary` | Cursor with primary selection | -| `ui.cursor.primary.normal` | | -| `ui.cursor.primary.insert` | | -| `ui.cursor.primary.select` | | -| `ui.gutter` | Gutter | -| `ui.gutter.selected` | Gutter for the line the cursor is on | -| `ui.linenr` | Line numbers | -| `ui.linenr.selected` | Line number for the line the cursor is on | -| `ui.statusline` | Statusline | -| `ui.statusline.inactive` | Statusline (unfocused document) | -| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.separator` | Separator character in statusline | -| `ui.popup` | Documentation popups (e.g. Space + k) | -| `ui.popup.info` | Prompt for multiple key options | -| `ui.window` | Borderlines separating splits | -| `ui.help` | Description box for commands | -| `ui.text` | Command prompts, popup text, etc. | -| `ui.text.focus` | | -| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | -| `ui.text.info` | The key: command text in `ui.popup.info` boxes | -| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | -| `ui.virtual.whitespace` | Visible whitespace characters | -| `ui.virtual.indent-guide` | Vertical indent width guides | -| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) | -| `ui.menu` | Code and command completion menus | -| `ui.menu.selected` | Selected autocomplete item | -| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | -| `ui.selection` | For selections in the editing area | -| `ui.selection.primary` | | -| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | -| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | -| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | -| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | -| `warning` | Diagnostics warning (gutter) | -| `error` | Diagnostics error (gutter) | -| `info` | Diagnostics info (gutter) | -| `hint` | Diagnostics hint (gutter) | -| `diagnostic` | Diagnostics fallback style (editing area) | -| `diagnostic.hint` | Diagnostics hint (editing area) | -| `diagnostic.info` | Diagnostics info (editing area) | -| `diagnostic.warning` | Diagnostics warning (editing area) | -| `diagnostic.error` | Diagnostics error (editing area) | +| Key | Notes | +| --- | --- | +| `ui.background` | | +| `ui.background.separator` | Picker separator below input line | +| `ui.cursor` | | +| `ui.cursor.normal` | | +| `ui.cursor.insert` | | +| `ui.cursor.select` | | +| `ui.cursor.match` | Matching bracket etc. | +| `ui.cursor.primary` | Cursor with primary selection | +| `ui.cursor.primary.normal` | | +| `ui.cursor.primary.insert` | | +| `ui.cursor.primary.select` | | +| `ui.gutter` | Gutter | +| `ui.gutter.selected` | Gutter for the line the cursor is on | +| `ui.linenr` | Line numbers | +| `ui.linenr.selected` | Line number for the line the cursor is on | +| `ui.statusline` | Statusline | +| `ui.statusline.inactive` | Statusline (unfocused document) | +| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.separator` | Separator character in statusline | +| `ui.popup` | Documentation popups (e.g. Space + k) | +| `ui.popup.info` | Prompt for multiple key options | +| `ui.window` | Borderlines separating splits | +| `ui.help` | Description box for commands | +| `ui.text` | Command prompts, popup text, etc. | +| `ui.text.focus` | | +| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | +| `ui.text.info` | The key: command text in `ui.popup.info` boxes | +| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | +| `ui.virtual.whitespace` | Visible whitespace characters | +| `ui.virtual.indent-guide` | Vertical indent width guides | +| `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds | +| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) | +| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) | +| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) | +| `ui.menu` | Code and command completion menus | +| `ui.menu.selected` | Selected autocomplete item | +| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | +| `ui.selection` | For selections in the editing area | +| `ui.selection.primary` | | +| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | +| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | +| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | +| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | +| `warning` | Diagnostics warning (gutter) | +| `error` | Diagnostics error (gutter) | +| `info` | Diagnostics info (gutter) | +| `hint` | Diagnostics hint (gutter) | +| `diagnostic` | Diagnostics fallback style (editing area) | +| `diagnostic.hint` | Diagnostics hint (editing area) | +| `diagnostic.info` | Diagnostics info (editing area) | +| `diagnostic.warning` | Diagnostics warning (editing area) | +| `diagnostic.error` | Diagnostics error (editing area) | [editor-section]: ./configuration.md#editor-section diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 62ec87b485ca7..8618f5863d50b 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -49,3 +49,4 @@ textwrap = "0.16.0" [dev-dependencies] quickcheck = { version = "1", default-features = false } +indoc = "1.0.6" diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 6b5da17ef56f5..58ddb0383a0ad 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -35,7 +35,7 @@ pub enum DiagnosticTag { Deprecated, } -/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html) +/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.94.0/lsp_types/struct.Diagnostic.html) #[derive(Debug, Clone)] pub struct Diagnostic { pub range: Range, diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index e68b31fd570fb..ac8918bb71ea0 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -119,16 +119,7 @@ fn overlay() { "foobar", 0, false, - &[ - Overlay { - char_idx: 0, - grapheme: "X".into(), - }, - Overlay { - char_idx: 2, - grapheme: "\t".into(), - }, - ] + &[Overlay::new(0, "X"), Overlay::new(2, "\t")], ), "Xo bar " ); @@ -138,18 +129,9 @@ fn overlay() { 0, true, &[ - Overlay { - char_idx: 2, - grapheme: "\t".into(), - }, - Overlay { - char_idx: 5, - grapheme: "\t".into(), - }, - Overlay { - char_idx: 16, - grapheme: "X".into(), - }, + Overlay::new(2, "\t"), + Overlay::new(5, "\t"), + Overlay::new(16, "X"), ] ), "fo f o foo \n.foo Xoo foo foo \n.foo foo foo " @@ -170,24 +152,14 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - #[test] fn annotation() { assert_eq!( - annotate_text( - "bar", - false, - &[InlineAnnotation { - char_idx: 0, - text: "foo".into(), - }] - ), + annotate_text("bar", false, &[InlineAnnotation::new(0, "foo")]), "foobar " ); assert_eq!( annotate_text( &"foo ".repeat(10), true, - &[InlineAnnotation { - char_idx: 0, - text: "foo ".into(), - }] + &[InlineAnnotation::new(0, "foo ")] ), "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " ); @@ -199,20 +171,8 @@ fn annotation_and_overlay() { "bbar".into(), &TextFormat::new_test(false), TextAnnotations::default() - .add_inline_annotations( - Rc::new([InlineAnnotation { - char_idx: 0, - text: "fooo".into(), - }]), - None - ) - .add_overlay( - Rc::new([Overlay { - char_idx: 0, - grapheme: "\t".into(), - }]), - None - ), + .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None) + .add_overlay(Rc::new([Overlay::new(0, "\t")]), None), 0, ) .0 diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 8e6b63066201c..60af47e5bf195 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -1474,7 +1474,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection.transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Move)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -1497,7 +1497,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection.transform(|r| move_prev_paragraph(text.slice(..), r, 2, Movement::Move)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -1520,7 +1520,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection .transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Extend)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -1540,7 +1540,7 @@ mod test { "a\nb\n\n#[goto\nthird\n\n|]#paragraph", ), ( - "a\nb#[\n|]#\ngoto\nsecond\n\nparagraph", + "a\nb#[\n|]#\n\ngoto\nsecond\n\nparagraph", "a\nb#[\n\n|]#goto\nsecond\n\nparagraph", ), ( @@ -1562,7 +1562,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection.transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Move)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -1585,7 +1585,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection.transform(|r| move_next_paragraph(text.slice(..), r, 2, Movement::Move)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -1608,7 +1608,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection .transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Extend)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 0eb2b755ec4f8..8e93c633e4ad6 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -661,6 +661,15 @@ impl<'a> IntoIterator for &'a Selection { } } +impl IntoIterator for Selection { + type Item = Range; + type IntoIter = smallvec::IntoIter<[Range; 1]>; + + fn into_iter(self) -> smallvec::IntoIter<[Range; 1]> { + self.ranges.into_iter() + } +} + // TODO: checkSelection -> check if valid for doc length && sorted pub fn keep_or_remove_matches( diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs index 17523ed761076..ab385a2a25358 100644 --- a/helix-core/src/test.rs +++ b/helix-core/src/test.rs @@ -1,7 +1,9 @@ //! Test helpers. use crate::{Range, Selection}; +use ropey::Rope; use smallvec::SmallVec; use std::cmp::Reverse; +use unicode_segmentation::UnicodeSegmentation; /// Convert annotated test string to test string and selection. /// @@ -10,6 +12,10 @@ use std::cmp::Reverse; /// `#[` for primary selection with head after anchor followed by `|]#`. /// `#(` for secondary selection with head after anchor followed by `|)#`. /// +/// If the selection contains any LF or CRLF sequences, which are immediately +/// followed by the same grapheme, then the subsequent one is removed. This is +/// to allow representing having the cursor over the end of the line. +/// /// # Examples /// /// ``` @@ -30,23 +36,23 @@ use std::cmp::Reverse; pub fn print(s: &str) -> (String, Selection) { let mut primary_idx = None; let mut ranges = SmallVec::new(); - let mut iter = s.chars().peekable(); + let mut iter = UnicodeSegmentation::graphemes(s, true).peekable(); let mut left = String::with_capacity(s.len()); 'outer: while let Some(c) = iter.next() { let start = left.chars().count(); - if c != '#' { - left.push(c); + if c != "#" { + left.push_str(c); continue; } let (is_primary, close_pair) = match iter.next() { - Some('[') => (true, ']'), - Some('(') => (false, ')'), + Some("[") => (true, "]"), + Some("(") => (false, ")"), Some(ch) => { left.push('#'); - left.push(ch); + left.push_str(ch); continue; } None => break, @@ -56,24 +62,45 @@ pub fn print(s: &str) -> (String, Selection) { panic!("primary `#[` already appeared {:?} {:?}", left, s); } - let head_at_beg = iter.next_if_eq(&'|').is_some(); + let head_at_beg = iter.next_if_eq(&"|").is_some(); + let last_grapheme = |s: &str| { + UnicodeSegmentation::graphemes(s, true) + .last() + .map(String::from) + }; while let Some(c) = iter.next() { - if !(c == close_pair && iter.peek() == Some(&'#')) { - left.push(c); + let next = iter.peek(); + let mut prev = last_grapheme(left.as_str()); + + if !(c == close_pair && next == Some(&"#")) { + left.push_str(c); continue; } if !head_at_beg { - let prev = left.pop().unwrap(); - if prev != '|' { - left.push(prev); - left.push(c); - continue; + match &prev { + Some(p) if p != "|" => { + left.push_str(c); + continue; + } + Some(p) if p == "|" => { + left.pop().unwrap(); // pop the | + prev = last_grapheme(left.as_str()); + } + _ => (), } } iter.next(); // skip "#" + let next = iter.peek(); + + // skip explicit line end inside selection + if (prev == Some(String::from("\r\n")) || prev == Some(String::from("\n"))) + && next.map(|n| String::from(*n)) == prev + { + iter.next(); + } if is_primary { primary_idx = Some(ranges.len()); @@ -118,14 +145,16 @@ pub fn print(s: &str) -> (String, Selection) { /// use smallvec::smallvec; /// /// assert_eq!( -/// plain("abc", Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)), +/// plain("abc", &Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)), /// "#[a|]#b#(|c)#".to_owned() /// ); /// ``` -pub fn plain(s: &str, selection: Selection) -> String { +pub fn plain>(s: R, selection: &Selection) -> String { + let s = s.into(); let primary = selection.primary_index(); - let mut out = String::with_capacity(s.len() + 5 * selection.len()); - out.push_str(s); + let mut out = String::with_capacity(s.len_bytes() + 5 * selection.len()); + out.push_str(&s.to_string()); + let mut insertion: Vec<_> = selection .iter() .enumerate() @@ -138,7 +167,9 @@ pub fn plain(s: &str, selection: Selection) -> String { (false, false) => [(range.anchor, ")#"), (range.head, "#(|")], } }) + .map(|(char_idx, marker)| (s.char_to_byte(char_idx), marker)) .collect(); + // insert in reverse order insertion.sort_unstable_by_key(|k| Reverse(k.0)); for (i, s) in insertion { @@ -147,6 +178,7 @@ pub fn plain(s: &str, selection: Selection) -> String { out } +#[allow(clippy::module_inception)] #[cfg(test)] #[allow(clippy::module_inception)] mod test { @@ -262,4 +294,94 @@ mod test { print("hello #[|πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦]# goodbye") ); } + + #[test] + fn plain_single() { + assert_eq!("#[|h]#ello", plain("hello", &Selection::single(1, 0))); + assert_eq!("#[h|]#ello", plain("hello", &Selection::single(0, 1))); + assert_eq!("#[|hell]#o", plain("hello", &Selection::single(4, 0))); + assert_eq!("#[hell|]#o", plain("hello", &Selection::single(0, 4))); + assert_eq!("#[|hello]#", plain("hello", &Selection::single(5, 0))); + assert_eq!("#[hello|]#", plain("hello", &Selection::single(0, 5))); + } + + #[test] + fn plain_multi() { + assert_eq!( + plain( + "hello", + &Selection::new( + SmallVec::from_slice(&[Range::new(1, 0), Range::new(5, 4)]), + 0 + ) + ), + String::from("#[|h]#ell#(|o)#") + ); + assert_eq!( + plain( + "hello", + &Selection::new( + SmallVec::from_slice(&[Range::new(0, 1), Range::new(4, 5)]), + 0 + ) + ), + String::from("#[h|]#ell#(o|)#") + ); + assert_eq!( + plain( + "hello", + &Selection::new( + SmallVec::from_slice(&[Range::new(2, 0), Range::new(5, 3)]), + 0 + ) + ), + String::from("#[|he]#l#(|lo)#") + ); + assert_eq!( + plain( + "hello\r\nhello\r\nhello\r\n", + &Selection::new( + SmallVec::from_slice(&[ + Range::new(7, 5), + Range::new(21, 19), + Range::new(14, 12) + ]), + 0 + ) + ), + String::from("hello#[|\r\n]#hello#(|\r\n)#hello#(|\r\n)#") + ); + } + + #[test] + fn plain_multi_byte_code_point() { + assert_eq!( + plain("β€žβ€œ", &Selection::single(1, 0)), + String::from("#[|β€ž]#β€œ") + ); + assert_eq!( + plain("β€žβ€œ", &Selection::single(2, 1)), + String::from("β€ž#[|β€œ]#") + ); + assert_eq!( + plain("β€žβ€œ", &Selection::single(0, 1)), + String::from("#[β€ž|]#β€œ") + ); + assert_eq!( + plain("β€žβ€œ", &Selection::single(1, 2)), + String::from("β€ž#[β€œ|]#") + ); + assert_eq!( + plain("they said β€žhelloβ€œ", &Selection::single(11, 10)), + String::from("they said #[|β€ž]#helloβ€œ") + ); + } + + #[test] + fn plain_multi_code_point_grapheme() { + assert_eq!( + plain("hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ goodbye", &Selection::single(13, 6)), + String::from("hello #[|πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦]# goodbye") + ); + } } diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 1956f6b5bf111..3e48de4d87ce5 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -15,6 +15,15 @@ pub struct InlineAnnotation { pub char_idx: usize, } +impl InlineAnnotation { + pub fn new(char_idx: usize, text: impl Into) -> Self { + Self { + char_idx, + text: text.into(), + } + } +} + /// Represents a **single Grapheme** that is part of the document /// that start at `char_idx` that will be replaced with /// a different `grapheme`. @@ -33,22 +42,13 @@ pub struct InlineAnnotation { /// use helix_core::text_annotations::Overlay; /// /// // replaces a -/// Overlay { -/// char_idx: 0, -/// grapheme: "X".into(), -/// }; +/// Overlay::new(0, "X"); /// /// // replaces X͎̊͑͒͜͝ -/// Overlay{ -/// char_idx: 1, -/// grapheme: "\t".into(), -/// }; +/// Overlay::new(1, "\t"); /// /// // replaces b -/// Overlay{ -/// char_idx: 6, -/// grapheme: "XΝŒΜ‹Μ‡ΝΝ‘ΜŸΜ’Μ’Ν–Μ²".into(), -/// }; +/// Overlay::new(6, "XΝŒΜ‹Μ‡ΝΝ‘ΜŸΜ’Μ’Ν–Μ²"); /// ``` /// /// The following examples are invalid uses @@ -57,16 +57,10 @@ pub struct InlineAnnotation { /// use helix_core::text_annotations::Overlay; /// /// // overlay is not aligned at grapheme boundary -/// Overlay{ -/// char_idx: 3, -/// grapheme: "x".into(), -/// }; +/// Overlay::new(3, "x"); /// /// // overlay contains multiple graphemes -/// Overlay{ -/// char_idx: 0, -/// grapheme: "xy".into(), -/// }; +/// Overlay::new(0, "xy"); /// ``` #[derive(Debug, Clone)] pub struct Overlay { @@ -74,6 +68,15 @@ pub struct Overlay { pub grapheme: Tendril, } +impl Overlay { + pub fn new(char_idx: usize, grapheme: impl Into) -> Self { + Self { + char_idx, + grapheme: grapheme.into(), + } + } +} + /// Line annotations allow for virtual text between normal /// text lines. They cause `height` empty lines to be inserted /// below the document line that contains `anchor_char_idx`. diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 972a80e78a605..bf00a4580491b 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -437,7 +437,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection .transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 1)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -460,7 +460,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection .transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 2)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } @@ -491,7 +491,7 @@ mod test { let text = Rope::from(s.as_str()); let selection = selection .transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Around, 1)); - let actual = crate::test::plain(&s, selection); + let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index d2f4de07dbe74..d8e581aae12fb 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -1,3 +1,5 @@ +use smallvec::SmallVec; + use crate::{Range, Rope, Selection, Tendril}; use std::borrow::Cow; @@ -466,6 +468,33 @@ impl Transaction { self } + /// Generate a transaction from a set of potentially overlapping changes. The `change_ranges` + /// iterator yield the range (of removed text) in the old document for each edit. If any change + /// overlaps with a range overlaps with a previous range then that range is ignored. + /// + /// The `process_change` callback is called for each edit that is not ignored (in the order + /// yielded by `changes`) and should return the new text that the associated range will be + /// replaced with. + /// + /// To make this function more flexible the iterator can yield additional data for each change + /// that is passed to `process_change` + pub fn change_ignore_overlapping( + doc: &Rope, + change_ranges: impl Iterator, + mut process_change: impl FnMut(usize, usize, T) -> Option, + ) -> Self { + let mut last = 0; + let changes = change_ranges.filter_map(|(from, to, data)| { + if from < last { + return None; + } + let tendril = process_change(from, to, data); + last = to; + Some((from, to, tendril)) + }); + Self::change(doc, changes) + } + /// Generate a transaction from a set of changes. pub fn change(doc: &Rope, changes: I) -> Self where @@ -513,6 +542,44 @@ impl Transaction { Self::change(doc, selection.iter().map(f)) } + pub fn change_by_selection_ignore_overlapping( + doc: &Rope, + selection: &Selection, + mut change_range: impl FnMut(&Range) -> (usize, usize), + mut create_tendril: impl FnMut(usize, usize) -> Option, + ) -> (Transaction, Selection) { + let mut last_selection_idx = None; + let mut new_primary_idx = None; + let mut ranges: SmallVec<[Range; 1]> = SmallVec::new(); + let process_change = |change_start, change_end, (idx, range): (usize, &Range)| { + // update the primary idx + if idx == selection.primary_index() { + new_primary_idx = Some(idx); + } else if new_primary_idx.is_none() { + if idx > selection.primary_index() { + new_primary_idx = last_selection_idx; + } else { + last_selection_idx = Some(idx); + } + } + ranges.push(*range); + create_tendril(change_start, change_end) + }; + let transaction = Self::change_ignore_overlapping( + doc, + selection.iter().enumerate().map(|range| { + let (change_start, change_end) = change_range(range.1); + (change_start, change_end, range) + }), + process_change, + ); + + ( + transaction, + Selection::new(ranges, new_primary_idx.unwrap_or(0)), + ) + } + /// Insert text at each selection head. pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self { Self::change_by_selection(doc, selection, |range| { diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 9fa118fbda22b..9cb7c14700498 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -315,6 +315,9 @@ impl Client { execute_command: Some(lsp::DynamicRegistrationClientCapabilities { dynamic_registration: Some(false), }), + inlay_hint: Some(lsp::InlayHintWorkspaceClientCapabilities { + refresh_support: Some(false), + }), ..Default::default() }), text_document: Some(lsp::TextDocumentClientCapabilities { @@ -386,6 +389,10 @@ impl Client { publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities { ..Default::default() }), + inlay_hint: Some(lsp::InlayHintClientCapabilities { + dynamic_registration: Some(false), + resolve_support: None, + }), ..Default::default() }), window: Some(lsp::WindowClientCapabilities { @@ -726,6 +733,31 @@ impl Client { Some(self.call::(params)) } + pub fn text_document_range_inlay_hints( + &self, + text_document: lsp::TextDocumentIdentifier, + range: lsp::Range, + work_done_token: Option, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + match capabilities.inlay_hint_provider { + Some( + lsp::OneOf::Left(true) + | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)), + ) => (), + _ => return None, + } + + let params = lsp::InlayHintParams { + text_document, + range, + work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, + }; + + Some(self.call::(params)) + } + pub fn text_document_hover( &self, text_document: lsp::TextDocumentIdentifier, diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 147b381c2a4e1..e31df59f4e2d9 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -59,8 +59,8 @@ pub enum OffsetEncoding { pub mod util { use super::*; use helix_core::line_ending::{line_end_byte_index, line_end_char_index}; + use helix_core::{chars, RopeSlice, SmallVec}; use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction}; - use helix_core::{smallvec, SmallVec}; /// Converts a diagnostic in the document to [`lsp::Diagnostic`]. /// @@ -247,13 +247,56 @@ pub mod util { Some(Range::new(start, end)) } + /// If the LS did not provide a range for the completion or the range of the + /// primary cursor can not be used for the secondary cursor, this function + /// can be used to find the completion range for a cursor + fn find_completion_range(text: RopeSlice, replace_mode: bool, cursor: usize) -> (usize, usize) { + let start = cursor + - text + .chars_at(cursor) + .reversed() + .take_while(|ch| chars::char_is_word(*ch)) + .count(); + let mut end = cursor; + if replace_mode { + end += text + .chars_at(cursor) + .skip(1) + .take_while(|ch| chars::char_is_word(*ch)) + .count(); + } + (start, end) + } + fn completion_range( + text: RopeSlice, + edit_offset: Option<(i128, i128)>, + replace_mode: bool, + cursor: usize, + ) -> Option<(usize, usize)> { + let res = match edit_offset { + Some((start_offset, end_offset)) => { + let start_offset = cursor as i128 + start_offset; + if start_offset < 0 { + return None; + } + let end_offset = cursor as i128 + end_offset; + if end_offset > text.len_chars() as i128 { + return None; + } + (start_offset as usize, end_offset as usize) + } + None => find_completion_range(text, replace_mode, cursor), + }; + Some(res) + } + /// Creates a [Transaction] from the [lsp::TextEdit] in a completion response. /// The transaction applies the edit to all cursors. pub fn generate_transaction_from_completion_edit( doc: &Rope, selection: &Selection, - start_offset: i128, - end_offset: i128, + edit_offset: Option<(i128, i128)>, + replace_mode: bool, new_text: String, ) -> Transaction { let replacement: Option = if new_text.is_empty() { @@ -263,83 +306,168 @@ pub mod util { }; let text = doc.slice(..); + let (removed_start, removed_end) = completion_range( + text, + edit_offset, + replace_mode, + selection.primary().cursor(text), + ) + .expect("transaction must be valid for primary selection"); + let removed_text = text.slice(removed_start..removed_end); - Transaction::change_by_selection(doc, selection, |range| { - let cursor = range.cursor(text); - ( - (cursor as i128 + start_offset) as usize, - (cursor as i128 + end_offset) as usize, - replacement.clone(), - ) - }) + let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping( + doc, + selection, + |range| { + let cursor = range.cursor(text); + completion_range(text, edit_offset, replace_mode, cursor) + .filter(|(start, end)| text.slice(start..end) == removed_text) + .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor)) + }, + |_, _| replacement.clone(), + ); + if transaction.changes().is_empty() { + return transaction; + } + selection = selection.map(transaction.changes()); + transaction.with_selection(selection) } /// Creates a [Transaction] from the [snippet::Snippet] in a completion response. /// The transaction applies the edit to all cursors. + #[allow(clippy::too_many_arguments)] pub fn generate_transaction_from_snippet( doc: &Rope, selection: &Selection, - start_offset: i128, - end_offset: i128, + edit_offset: Option<(i128, i128)>, + replace_mode: bool, snippet: snippet::Snippet, line_ending: &str, include_placeholder: bool, + tab_width: usize, + indent_width: usize, ) -> Transaction { let text = doc.slice(..); - // For each cursor store offsets for the first tabstop - let mut cursor_tabstop_offsets = Vec::>::new(); - let transaction = Transaction::change_by_selection(doc, selection, |range| { - let cursor = range.cursor(text); - let replacement_start = (cursor as i128 + start_offset) as usize; - let replacement_end = (cursor as i128 + end_offset) as usize; - let newline_with_offset = format!( - "{line_ending}{blank:width$}", - line_ending = line_ending, - width = replacement_start - doc.line_to_char(doc.char_to_line(replacement_start)), - blank = "" - ); - - let (replacement, tabstops) = - snippet::render(&snippet, newline_with_offset, include_placeholder); - - let replacement_len = replacement.chars().count(); - cursor_tabstop_offsets.push( - tabstops - .first() - .unwrap_or(&smallvec![(replacement_len, replacement_len)]) - .iter() - .map(|(from, to)| -> (i128, i128) { - ( - *from as i128 - replacement_len as i128, - *to as i128 - replacement_len as i128, - ) - }) - .collect(), - ); - - (replacement_start, replacement_end, Some(replacement.into())) - }); + let mut off = 0i128; + let mut mapped_doc = doc.clone(); + let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new(); + let (removed_start, removed_end) = completion_range( + text, + edit_offset, + replace_mode, + selection.primary().cursor(text), + ) + .expect("transaction must be valid for primary selection"); + let removed_text = text.slice(removed_start..removed_end); - // Create new selection based on the cursor tabstop from above - let mut cursor_tabstop_offsets_iter = cursor_tabstop_offsets.iter(); - let selection = selection - .clone() - .map(transaction.changes()) - .transform_iter(|range| { - cursor_tabstop_offsets_iter - .next() - .unwrap() - .iter() - .map(move |(from, to)| { - Range::new( - (range.anchor as i128 + *from) as usize, - (range.anchor as i128 + *to) as usize, - ) - }) - }); + let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping( + doc, + selection, + |range| { + let cursor = range.cursor(text); + completion_range(text, edit_offset, replace_mode, cursor) + .filter(|(start, end)| text.slice(start..end) == removed_text) + .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor)) + }, + |replacement_start, replacement_end| { + let mapped_replacement_start = (replacement_start as i128 + off) as usize; + let mapped_replacement_end = (replacement_end as i128 + off) as usize; + + let line_idx = mapped_doc.char_to_line(mapped_replacement_start); + let indent_level = helix_core::indent::indent_level_for_line( + mapped_doc.line(line_idx), + tab_width, + indent_width, + ) * indent_width; + + let newline_with_offset = format!( + "{line_ending}{blank:indent_level$}", + line_ending = line_ending, + blank = "" + ); + + let (replacement, tabstops) = + snippet::render(&snippet, &newline_with_offset, include_placeholder); + selection_tabstops.push((mapped_replacement_start, tabstops)); + mapped_doc.remove(mapped_replacement_start..mapped_replacement_end); + mapped_doc.insert(mapped_replacement_start, &replacement); + off += + replacement_start as i128 - replacement_end as i128 + replacement.len() as i128; + + Some(replacement) + }, + ); - transaction.with_selection(selection) + let changes = transaction.changes(); + if changes.is_empty() { + return transaction; + } + + let mut mapped_selection = SmallVec::with_capacity(selection.len()); + let mut mapped_primary_idx = 0; + let primary_range = selection.primary(); + for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) { + if range == primary_range { + mapped_primary_idx = mapped_selection.len() + } + + let range = range.map(changes); + let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty()); + let Some(tabstops) = tabstops else{ + // no tabstop normal mapping + mapped_selection.push(range); + continue; + }; + + // expand the selection to cover the tabstop to retain the helix selection semantic + // the tabstop closest to the range simply replaces `head` while anchor remains in place + // the remaining tabstops receive their own single-width cursor + if range.head < range.anchor { + let first_tabstop = tabstop_anchor + tabstops[0].1; + + // if selection is forward but was moved to the right it is + // contained entirely in the replacement text, just do a point + // selection (fallback below) + if range.anchor >= first_tabstop { + let range = Range::new(range.anchor, first_tabstop); + mapped_selection.push(range); + let rem_tabstops = tabstops[1..] + .iter() + .map(|tabstop| Range::point(tabstop_anchor + tabstop.1)); + mapped_selection.extend(rem_tabstops); + continue; + } + } else { + let last_idx = tabstops.len() - 1; + let last_tabstop = tabstop_anchor + tabstops[last_idx].1; + + // if selection is forward but was moved to the right it is + // contained entirely in the replacement text, just do a point + // selection (fallback below) + if range.anchor <= last_tabstop { + // we can't properly compute the the next grapheme + // here because the transaction hasn't been applied yet + // that is not a problem because the range gets grapheme aligned anyway + // tough so just adding one will always cause head to be grapheme + // aligned correctly when applied to the document + let range = Range::new(range.anchor, last_tabstop + 1); + mapped_selection.push(range); + let rem_tabstops = tabstops[..last_idx] + .iter() + .map(|tabstop| Range::point(tabstop_anchor + tabstop.0)); + mapped_selection.extend(rem_tabstops); + continue; + } + }; + + let tabstops = tabstops + .iter() + .map(|tabstop| Range::point(tabstop_anchor + tabstop.0)); + mapped_selection.extend(tabstops); + } + + transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx)) } pub fn generate_transaction_from_edits( diff --git a/helix-lsp/src/snippet.rs b/helix-lsp/src/snippet.rs index b27077e7068cf..4713ad8bbe80d 100644 --- a/helix-lsp/src/snippet.rs +++ b/helix-lsp/src/snippet.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use anyhow::{anyhow, Result}; -use helix_core::{smallvec, SmallVec}; +use helix_core::{smallvec, SmallVec, Tendril}; #[derive(Debug, PartialEq, Eq)] pub enum CaseChange { @@ -57,10 +57,10 @@ pub fn parse(s: &str) -> Result> { fn render_elements( snippet_elements: &[SnippetElement<'_>], - insert: &mut String, + insert: &mut Tendril, offset: &mut usize, tabstops: &mut Vec<(usize, (usize, usize))>, - newline_with_offset: &String, + newline_with_offset: &str, include_placeholer: bool, ) { use SnippetElement::*; @@ -121,10 +121,10 @@ fn render_elements( #[allow(clippy::type_complexity)] // only used one time pub fn render( snippet: &Snippet<'_>, - newline_with_offset: String, + newline_with_offset: &str, include_placeholer: bool, -) -> (String, Vec>) { - let mut insert = String::new(); +) -> (Tendril, Vec>) { + let mut insert = Tendril::new(); let mut tabstops = Vec::new(); let mut offset = 0; @@ -133,7 +133,7 @@ pub fn render( &mut insert, &mut offset, &mut tabstops, - &newline_with_offset, + newline_with_offset, include_placeholer, ); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 803f4051d0747..1c1edece1a313 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -114,17 +114,7 @@ impl<'a> Context<'a> { T: for<'de> serde::Deserialize<'de> + Send + 'static, F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, { - let callback = Box::pin(async move { - let json = call.await?; - let response = serde_json::from_value(json)?; - let call: job::Callback = Callback::EditorCompositor(Box::new( - move |editor: &mut Editor, compositor: &mut Compositor| { - callback(editor, compositor, response) - }, - )); - Ok(call) - }); - self.jobs.callback(callback); + self.jobs.callback(make_job_callback(call, callback)); } /// Returns 1 if no explicit count was provided @@ -134,6 +124,27 @@ impl<'a> Context<'a> { } } +#[inline] +fn make_job_callback( + call: impl Future> + 'static + Send, + callback: F, +) -> std::pin::Pin>>> +where + T: for<'de> serde::Deserialize<'de> + Send + 'static, + F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, +{ + Box::pin(async move { + let json = call.await?; + let response = serde_json::from_value(json)?; + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + callback(editor, compositor, response) + }, + )); + Ok(call) + }) +} + use helix_view::{align_view, Align}; /// A MappableCommand is either a static command like "jump_view_up" or a Typable command like diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 08519366b2579..f9d9856f58c75 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -15,8 +15,13 @@ use tui::{ use super::{align_view, push_jump, Align, Context, Editor, Open}; -use helix_core::{path, Selection}; -use helix_view::{document::Mode, editor::Action, theme::Style}; +use helix_core::{path, text_annotations::InlineAnnotation, Selection}; +use helix_view::{ + document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, + editor::Action, + theme::Style, + Document, View, +}; use crate::{ compositor::{self, Compositor}, @@ -27,7 +32,8 @@ use crate::{ }; use std::{ - borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc, + borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, future::Future, path::PathBuf, + sync::Arc, }; /// Gets the language server that is attached to a document, and @@ -1391,3 +1397,174 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { }, ); } + +pub fn compute_inlay_hints_for_all_views(editor: &mut Editor, jobs: &mut crate::job::Jobs) { + if !editor.config().lsp.display_inlay_hints { + return; + } + + for (view, _) in editor.tree.views() { + let doc = match editor.documents.get(&view.doc) { + Some(doc) => doc, + None => continue, + }; + if let Some(callback) = compute_inlay_hints_for_view(view, doc) { + jobs.callback(callback); + } + } +} + +fn compute_inlay_hints_for_view( + view: &View, + doc: &Document, +) -> Option>>>> { + let view_id = view.id; + let doc_id = view.doc; + + let language_server = doc.language_server()?; + + let capabilities = language_server.capabilities(); + + let (future, new_doc_inlay_hints_id) = match capabilities.inlay_hint_provider { + Some( + lsp::OneOf::Left(true) + | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)), + ) => { + let doc_text = doc.text(); + let len_lines = doc_text.len_lines(); + + // Compute ~3 times the current view height of inlay hints, that way some scrolling + // will not show half the view with hints and half without while still being faster + // than computing all the hints for the full file (which could be dozens of time + // longer than the view is). + let view_height = view.inner_height(); + let first_visible_line = doc_text.char_to_line(view.offset.anchor); + let first_line = first_visible_line.saturating_sub(view_height); + let last_line = first_visible_line + .saturating_add(view_height.saturating_mul(2)) + .min(len_lines); + + let new_doc_inlay_hint_id = DocumentInlayHintsId { + first_line, + last_line, + }; + // Don't recompute the annotations in case nothing has changed about the view + if !doc.inlay_hints_oudated + && doc + .inlay_hints(view_id) + .map_or(false, |dih| dih.id == new_doc_inlay_hint_id) + { + return None; + } + + let doc_slice = doc_text.slice(..); + let first_char_in_range = doc_slice.line_to_char(first_line); + let last_char_in_range = doc_slice.line_to_char(last_line); + + let range = helix_lsp::util::range_to_lsp_range( + doc_text, + helix_core::Range::new(first_char_in_range, last_char_in_range), + language_server.offset_encoding(), + ); + + ( + language_server.text_document_range_inlay_hints(doc.identifier(), range, None), + new_doc_inlay_hint_id, + ) + } + _ => return None, + }; + + let callback = super::make_job_callback( + future?, + move |editor, _compositor, response: Option>| { + // The config was modified or the window was closed while the request was in flight + if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() { + return; + } + + // Add annotations to relevant document, not the current one (it may have changed in between) + let doc = match editor.documents.get_mut(&doc_id) { + Some(doc) => doc, + None => return, + }; + + // If we have neither hints nor an LSP, empty the inlay hints since they're now oudated + let (mut hints, offset_encoding) = match (response, doc.language_server()) { + (Some(h), Some(ls)) if !h.is_empty() => (h, ls.offset_encoding()), + _ => { + doc.set_inlay_hints( + view_id, + DocumentInlayHints::empty_with_id(new_doc_inlay_hints_id), + ); + doc.inlay_hints_oudated = false; + return; + } + }; + + // Most language servers will already send them sorted but ensure this is the case to + // avoid errors on our end. + hints.sort_unstable_by_key(|inlay_hint| inlay_hint.position); + + let mut padding_before_inlay_hints = Vec::new(); + let mut type_inlay_hints = Vec::new(); + let mut parameter_inlay_hints = Vec::new(); + let mut other_inlay_hints = Vec::new(); + let mut padding_after_inlay_hints = Vec::new(); + + let doc_text = doc.text(); + + for hint in hints { + let char_idx = + match helix_lsp::util::lsp_pos_to_pos(doc_text, hint.position, offset_encoding) + { + Some(pos) => pos, + // Skip inlay hints that have no "real" position + None => continue, + }; + + let label = match hint.label { + lsp::InlayHintLabel::String(s) => s, + lsp::InlayHintLabel::LabelParts(parts) => parts + .into_iter() + .map(|p| p.value) + .collect::>() + .join(""), + }; + + let inlay_hints_vec = match hint.kind { + Some(lsp::InlayHintKind::TYPE) => &mut type_inlay_hints, + Some(lsp::InlayHintKind::PARAMETER) => &mut parameter_inlay_hints, + // We can't warn on unknown kind here since LSPs are free to set it or not, for + // example Rust Analyzer does not: every kind will be `None`. + _ => &mut other_inlay_hints, + }; + + if let Some(true) = hint.padding_left { + padding_before_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); + } + + inlay_hints_vec.push(InlineAnnotation::new(char_idx, label)); + + if let Some(true) = hint.padding_right { + padding_after_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); + } + } + + doc.set_inlay_hints( + view_id, + DocumentInlayHints { + id: new_doc_inlay_hints_id, + type_inlay_hints: type_inlay_hints.into(), + parameter_inlay_hints: parameter_inlay_hints.into(), + other_inlay_hints: other_inlay_hints.into(), + padding_before_inlay_hints: padding_before_inlay_hints.into(), + padding_after_inlay_hints: padding_after_inlay_hints.into(), + }, + ); + doc.inlay_hints_oudated = false; + }, + ); + + Some(callback) +} diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index ef88938fe33aa..da6b5ddcbc1fb 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -108,6 +108,7 @@ impl Completion { start_offset: usize, trigger_offset: usize, ) -> Self { + let replace_mode = editor.config().completion_replace; // Sort completion items according to their preselect status (given by the LSP server) items.sort_by_key(|item| !item.preselect.unwrap_or(false)); @@ -118,23 +119,27 @@ impl Completion { view_id: ViewId, item: &CompletionItem, offset_encoding: helix_lsp::OffsetEncoding, - start_offset: usize, trigger_offset: usize, include_placeholder: bool, + replace_mode: bool, ) -> Transaction { use helix_lsp::snippet; let selection = doc.selection(view_id); + let text = doc.text().slice(..); + let primary_cursor = selection.primary().cursor(text); - let (start_offset, end_offset, new_text) = if let Some(edit) = &item.text_edit { + let (edit_offset, new_text) = if let Some(edit) = &item.text_edit { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::InsertAndReplace(item) => { - // TODO: support using "insert" instead of "replace" via user config - lsp::TextEdit::new(item.replace, item.new_text.clone()) + let range = if replace_mode { + item.replace + } else { + item.insert + }; + lsp::TextEdit::new(range, item.new_text.clone()) } }; - let text = doc.text().slice(..); - let primary_cursor = selection.primary().cursor(text); let start_offset = match util::lsp_pos_to_pos(doc.text(), edit.range.start, offset_encoding) { @@ -147,26 +152,18 @@ impl Completion { None => return Transaction::new(doc.text()), }; - (start_offset, end_offset, edit.new_text) + (Some((start_offset, end_offset)), edit.new_text) } else { - let new_text = item.insert_text.as_ref().unwrap_or(&item.label); - // Some LSPs just give you an insertText with no offset Β―\_(ツ)_/Β― - // in these cases we need to check for a common prefix and remove it - let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset)); - let new_text = new_text.trim_start_matches::<&str>(&prefix); - - // TODO: this needs to be true for the numbers to work out correctly - // in the closure below. It's passed in to a callback as this same - // formula, but can the value change between the LSP request and - // response? If it does, can we recover? - debug_assert!( - doc.selection(view_id) - .primary() - .cursor(doc.text().slice(..)) - == trigger_offset - ); - - (0, 0, new_text.into()) + let new_text = item + .insert_text + .clone() + .unwrap_or_else(|| item.label.clone()); + // check that we are still at the correct savepoint + // we can still generate a transaction regardless but if the + // document changed (and not just the selection) then we will + // likely delete the wrong text (same if we applied an edit sent by the LS) + debug_assert!(primary_cursor == trigger_offset); + (None, new_text) }; if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET)) @@ -179,11 +176,13 @@ impl Completion { Ok(snippet) => util::generate_transaction_from_snippet( doc.text(), selection, - start_offset, - end_offset, + edit_offset, + replace_mode, snippet, doc.line_ending.as_str(), include_placeholder, + doc.tab_width(), + doc.indent_width(), ), Err(err) => { log::error!( @@ -198,8 +197,8 @@ impl Completion { util::generate_transaction_from_completion_edit( doc.text(), selection, - start_offset, - end_offset, + edit_offset, + replace_mode, new_text, ) } @@ -230,9 +229,9 @@ impl Completion { view.id, item, offset_encoding, - start_offset, trigger_offset, true, + replace_mode, ); // initialize a savepoint @@ -252,9 +251,9 @@ impl Completion { view.id, item, offset_encoding, - start_offset, trigger_offset, false, + replace_mode, ); doc.apply(&transaction, view.id); diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 4abbe01e70088..7c22df747642a 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -990,6 +990,8 @@ impl EditorView { } pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { + commands::compute_inlay_hints_for_all_views(cx.editor, cx.jobs); + if let Some(completion) = &mut self.completion { return if completion.ensure_item_resolved(cx) { EventResult::Consumed(None) @@ -1014,6 +1016,10 @@ impl EditorView { event: &MouseEvent, cxt: &mut commands::Context, ) -> EventResult { + if event.kind != MouseEventKind::Moved { + cxt.editor.reset_idle_timer(); + } + let config = cxt.editor.config(); let MouseEvent { kind, diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ec8b1c7f4ae6e..bc2f98ee6cace 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -225,6 +225,9 @@ impl FilePicker { let loader = cx.editor.syn_loader.clone(); doc.detect_language(loader); } + + // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now + // but it could be interesting in the future } EventResult::Consumed(None) @@ -339,6 +342,7 @@ impl Component for FilePicker { inner, doc, offset, + // TODO: compute text annotations asynchronously here (like inlay hints) &TextAnnotations::default(), highlights, &cx.editor.theme, diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 3e7065b8282cc..887863519319f 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -159,6 +159,7 @@ where helix_view::editor::StatusLineElement::TotalLineNumbers => render_total_line_numbers, helix_view::editor::StatusLineElement::Separator => render_separator, helix_view::editor::StatusLineElement::Spacer => render_spacer, + helix_view::editor::StatusLineElement::VersionControl => render_version_control, } } @@ -476,3 +477,16 @@ where { write(context, String::from(" "), None); } + +fn render_version_control(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option