From 675d6ddc415359ed5ec5df6cdb2cfb3fcd78262d Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Sun, 19 Nov 2023 22:34:03 +0100 Subject: [PATCH 1/4] use slices instead of Rc for virtual text --- helix-core/src/doc_formatter.rs | 2 +- helix-core/src/doc_formatter/test.rs | 19 +++++++++---- helix-core/src/text_annotations.rs | 31 ++++++++++---------- helix-term/src/commands.rs | 4 ++- helix-term/src/commands/lsp.rs | 10 +++---- helix-view/src/document.rs | 42 +++++++++++----------------- helix-view/src/view.rs | 28 ++++++++----------- 7 files changed, 66 insertions(+), 70 deletions(-) diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index c7dc9081f5cf..cbe2da3b697a 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -116,7 +116,7 @@ impl Default for TextFormat { #[derive(Debug)] pub struct DocumentFormatter<'t> { text_fmt: &'t TextFormat, - annotations: &'t TextAnnotations, + annotations: &'t TextAnnotations<'t>, /// The visual position at the end of the last yielded word boundary visual_pos: Position, diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index ac8918bb71ea..d2b6ddc74a91 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -1,5 +1,3 @@ -use std::rc::Rc; - use crate::doc_formatter::{DocumentFormatter, TextFormat}; use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations}; @@ -105,7 +103,7 @@ fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_overlay(overlays.into(), None), + TextAnnotations::default().add_overlay(overlays, None), char_pos, ) .0 @@ -142,7 +140,7 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_inline_annotations(annotations.into(), None), + TextAnnotations::default().add_inline_annotations(annotations, None), 0, ) .0 @@ -164,15 +162,24 @@ fn annotation() { "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " ); } + #[test] fn annotation_and_overlay() { + let annotations = [InlineAnnotation { + char_idx: 0, + text: "fooo".into(), + }]; + let overlay = [Overlay { + char_idx: 0, + grapheme: "\t".into(), + }]; assert_eq!( DocumentFormatter::new_at_prev_checkpoint( "bbar".into(), &TextFormat::new_test(false), TextAnnotations::default() - .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None) - .add_overlay(Rc::new([Overlay::new(0, "\t")]), None), + .add_inline_annotations(annotations.as_slice(), None) + .add_overlay(overlay.as_slice(), None), 0, ) .0 diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 11d19d485642..1576914e3653 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -1,6 +1,5 @@ use std::cell::Cell; use std::ops::Range; -use std::rc::Rc; use crate::syntax::Highlight; use crate::Tendril; @@ -92,23 +91,23 @@ pub struct LineAnnotation { } #[derive(Debug)] -struct Layer { - annotations: Rc<[A]>, +struct Layer<'a, A, M> { + annotations: &'a [A], current_index: Cell, metadata: M, } -impl Clone for Layer { +impl Clone for Layer<'_, A, M> { fn clone(&self) -> Self { Layer { - annotations: self.annotations.clone(), + annotations: self.annotations, current_index: self.current_index.clone(), metadata: self.metadata.clone(), } } } -impl Layer { +impl Layer<'_, A, M> { pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) { let new_index = self .annotations @@ -128,8 +127,8 @@ impl Layer { } } -impl From<(Rc<[A]>, M)> for Layer { - fn from((annotations, metadata): (Rc<[A]>, M)) -> Layer { +impl<'a, A, M> From<(&'a [A], M)> for Layer<'a, A, M> { + fn from((annotations, metadata): (&'a [A], M)) -> Layer { Layer { annotations, current_index: Cell::new(0), @@ -147,13 +146,13 @@ fn reset_pos(layers: &[Layer], pos: usize, get_pos: impl Fn(&A) -> u /// Annotations that change that is displayed when the document is render. /// Also commonly called virtual text. #[derive(Default, Debug, Clone)] -pub struct TextAnnotations { - inline_annotations: Vec>>, - overlays: Vec>>, - line_annotations: Vec>, +pub struct TextAnnotations<'a> { + inline_annotations: Vec>>, + overlays: Vec>>, + line_annotations: Vec>, } -impl TextAnnotations { +impl<'a> TextAnnotations<'a> { /// Prepare the TextAnnotations for iteration starting at char_idx pub fn reset_pos(&self, char_idx: usize) { reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); @@ -194,7 +193,7 @@ impl TextAnnotations { /// the annotations that belong to the layers added first will be shown first. pub fn add_inline_annotations( &mut self, - layer: Rc<[InlineAnnotation]>, + layer: &'a [InlineAnnotation], highlight: Option, ) -> &mut Self { self.inline_annotations.push((layer, highlight).into()); @@ -211,7 +210,7 @@ impl TextAnnotations { /// /// If multiple layers contain overlay at the same position /// the overlay from the layer added last will be show. - pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option) -> &mut Self { + pub fn add_overlay(&mut self, layer: &'a [Overlay], highlight: Option) -> &mut Self { self.overlays.push((layer, highlight).into()); self } @@ -220,7 +219,7 @@ impl TextAnnotations { /// /// The line annotations **must be sorted** by their `char_idx`. /// Multiple line annotations with the same `char_idx` **are not allowed**. - pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self { + pub fn add_line_annotation(&mut self, layer: &'a [LineAnnotation]) -> &mut Self { self.line_annotations.push((layer, ()).into()); self } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a5e79a53992f..0385deaaac0c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -618,6 +618,7 @@ fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movem &mut annotations, ) }); + drop(annotations); doc.set_selection(view.id, selection); } @@ -1638,7 +1639,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor let doc_text = doc.text().slice(..); let viewport = view.inner_area(doc); let text_fmt = doc.text_format(viewport.width, None); - let mut annotations = view.text_annotations(doc, None); + let mut annotations = view.text_annotations(&*doc, None); (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( doc_text, view.offset.anchor, @@ -1716,6 +1717,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor let mut sel = doc.selection(view.id).clone(); let idx = sel.primary_index(); sel = sel.replace(idx, prim_sel); + drop(annotations); doc.set_selection(view.id, sel); } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index a3168dc2d585..63d1608f928c 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1315,11 +1315,11 @@ fn compute_inlay_hints_for_view( 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(), + type_inlay_hints, + parameter_inlay_hints, + other_inlay_hints, + padding_before_inlay_hints, + padding_after_inlay_hints, }, ); doc.inlay_hints_oudated = false; diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index f813c7424a83..090e4dd5ccc9 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -8,7 +8,7 @@ use helix_core::chars::char_is_word; use helix_core::doc_formatter::TextFormat; use helix_core::encoding::Encoding; use helix_core::syntax::{Highlight, LanguageServerFeature}; -use helix_core::text_annotations::{InlineAnnotation, TextAnnotations}; +use helix_core::text_annotations::InlineAnnotation; use helix_lsp::util::lsp_pos_to_pos; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -21,7 +21,6 @@ use std::collections::HashMap; use std::fmt::Display; use std::future::Future; use std::path::{Path, PathBuf}; -use std::rc::Rc; use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::SystemTime; @@ -200,22 +199,22 @@ pub struct DocumentInlayHints { pub id: DocumentInlayHintsId, /// Inlay hints of `TYPE` kind, if any. - pub type_inlay_hints: Rc<[InlineAnnotation]>, + pub type_inlay_hints: Vec, /// Inlay hints of `PARAMETER` kind, if any. - pub parameter_inlay_hints: Rc<[InlineAnnotation]>, + pub parameter_inlay_hints: Vec, /// Inlay hints that are neither `TYPE` nor `PARAMETER`. /// /// LSPs are not required to associate a kind to their inlay hints, for example Rust-Analyzer /// currently never does (February 2023) and the LSP spec may add new kinds in the future that /// we want to display even if we don't have some special highlighting for them. - pub other_inlay_hints: Rc<[InlineAnnotation]>, + pub other_inlay_hints: Vec, /// Inlay hint padding. When creating the final `TextAnnotations`, the `before` padding must be /// added first, then the regular inlay hints, then the `after` padding. - pub padding_before_inlay_hints: Rc<[InlineAnnotation]>, - pub padding_after_inlay_hints: Rc<[InlineAnnotation]>, + pub padding_before_inlay_hints: Vec, + pub padding_after_inlay_hints: Vec, } impl DocumentInlayHints { @@ -223,11 +222,11 @@ impl DocumentInlayHints { pub fn empty_with_id(id: DocumentInlayHintsId) -> Self { Self { id, - type_inlay_hints: Rc::new([]), - parameter_inlay_hints: Rc::new([]), - other_inlay_hints: Rc::new([]), - padding_before_inlay_hints: Rc::new([]), - padding_after_inlay_hints: Rc::new([]), + type_inlay_hints: Vec::new(), + parameter_inlay_hints: Vec::new(), + other_inlay_hints: Vec::new(), + padding_before_inlay_hints: Vec::new(), + padding_after_inlay_hints: Vec::new(), } } } @@ -1266,13 +1265,12 @@ impl Document { }); // Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place - let apply_inlay_hint_changes = |annotations: &mut Rc<[InlineAnnotation]>| { - if let Some(data) = Rc::get_mut(annotations) { - changes.update_positions( - data.iter_mut() - .map(|annotation| (&mut annotation.char_idx, Assoc::After)), - ); - } + let apply_inlay_hint_changes = |annotations: &mut Vec| { + changes.update_positions( + annotations + .iter_mut() + .map(|annotation| (&mut annotation.char_idx, Assoc::After)), + ); }; self.inlay_hints_oudated = true; @@ -1940,12 +1938,6 @@ impl Document { } } - /// Get the text annotations that apply to the whole document, those that do not apply to any - /// specific view. - pub fn text_annotations(&self, _theme: Option<&Theme>) -> TextAnnotations { - TextAnnotations::default() - } - /// Set the inlay hints for this document and `view_id`. pub fn set_inlay_hints(&mut self, view_id: ViewId, inlay_hints: DocumentInlayHints) { self.inlay_hints.insert(view_id, inlay_hints); diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index e5e2641a8c5c..bbdc74a74449 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -19,7 +19,6 @@ use helix_core::{ use std::{ collections::{HashMap, VecDeque}, fmt, - rc::Rc, }; const JUMP_LIST_CAPACITY: usize = 30; @@ -409,10 +408,12 @@ impl View { } /// Get the text annotations to display in the current view for the given document and theme. - pub fn text_annotations(&self, doc: &Document, theme: Option<&Theme>) -> TextAnnotations { - // TODO custom annotations for custom views like side by side diffs - - let mut text_annotations = doc.text_annotations(theme); + pub fn text_annotations<'a>( + &self, + doc: &'a Document, + theme: Option<&Theme>, + ) -> TextAnnotations<'a> { + let mut text_annotations = TextAnnotations::default(); let DocumentInlayHints { id: _, @@ -436,20 +437,15 @@ impl View { .and_then(|t| t.find_scope_index("ui.virtual.inlay-hint")) .map(Highlight); - let mut add_annotations = |annotations: &Rc<[_]>, style| { - if !annotations.is_empty() { - text_annotations.add_inline_annotations(Rc::clone(annotations), style); - } - }; - // Overlapping annotations are ignored apart from the first so the order here is not random: // types -> parameters -> others should hopefully be the "correct" order for most use cases, // with the padding coming before and after as expected. - add_annotations(padding_before_inlay_hints, None); - add_annotations(type_inlay_hints, type_style); - add_annotations(parameter_inlay_hints, parameter_style); - add_annotations(other_inlay_hints, other_style); - add_annotations(padding_after_inlay_hints, None); + text_annotations + .add_inline_annotations(padding_before_inlay_hints, None) + .add_inline_annotations(type_inlay_hints, type_style) + .add_inline_annotations(parameter_inlay_hints, parameter_style) + .add_inline_annotations(other_inlay_hints, other_style) + .add_inline_annotations(padding_after_inlay_hints, None); text_annotations } From 52b4483bd647587fc06061373f49bf1d821f81ae Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Fri, 1 Dec 2023 00:00:16 +0100 Subject: [PATCH 2/4] dismiss pending keys properly for mouse/paste --- helix-term/src/ui/editor.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index c1e36bbddc4e..ad7aa5c5a89a 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1048,13 +1048,33 @@ impl EditorView { } impl EditorView { + /// must be called whenever the editor processed input that + /// is not a `KeyEvent`. In these cases any pending keys/on next + /// key callbacks must be canceled. + fn handle_non_key_input(&mut self, cxt: &mut commands::Context) { + cxt.editor.status_msg = None; + cxt.editor.reset_idle_timer(); + // HACKS: create a fake key event that will never trigger any actual map + // and therefore simply acts as "dismiss" + let null_key_event = KeyEvent { + code: KeyCode::Null, + modifiers: KeyModifiers::empty(), + }; + // dismiss any pending keys + if let Some(on_next_key) = self.on_next_key.take() { + on_next_key(cxt, null_key_event); + } + self.handle_keymap_event(cxt.editor.mode, cxt, null_key_event); + self.pseudo_pending.clear(); + } + fn handle_mouse_event( &mut self, event: &MouseEvent, cxt: &mut commands::Context, ) -> EventResult { if event.kind != MouseEventKind::Moved { - cxt.editor.reset_idle_timer(); + self.handle_non_key_input(cxt) } let config = cxt.editor.config(); @@ -1279,6 +1299,7 @@ impl Component for EditorView { match event { Event::Paste(contents) => { + self.handle_non_key_input(&mut cx); cx.count = cx.editor.count; commands::paste_bracketed_value(&mut cx, contents.clone()); cx.editor.count = None; From 2d473c4748d81a6bd6940afc2b61b686c7488088 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Fri, 8 Mar 2024 15:45:08 +0100 Subject: [PATCH 3/4] add reverse rope grapheme iterator --- helix-core/src/graphemes.rs | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index d9e5e0224732..7cb5cd0625d5 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -425,6 +425,85 @@ impl<'a> Iterator for RopeGraphemes<'a> { } } +/// An iterator over the graphemes of a `RopeSlice` in reverse. +#[derive(Clone)] +pub struct RevRopeGraphemes<'a> { + text: RopeSlice<'a>, + chunks: Chunks<'a>, + cur_chunk: &'a str, + cur_chunk_start: usize, + cursor: GraphemeCursor, +} + +impl<'a> fmt::Debug for RevRopeGraphemes<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RevRopeGraphemes") + .field("text", &self.text) + .field("chunks", &self.chunks) + .field("cur_chunk", &self.cur_chunk) + .field("cur_chunk_start", &self.cur_chunk_start) + // .field("cursor", &self.cursor) + .finish() + } +} + +impl<'a> RevRopeGraphemes<'a> { + #[must_use] + pub fn new(slice: RopeSlice) -> RevRopeGraphemes { + let (mut chunks, mut cur_chunk_start, _, _) = slice.chunks_at_byte(slice.len_bytes()); + chunks.reverse(); + let first_chunk = chunks.next().unwrap_or(""); + cur_chunk_start -= first_chunk.len(); + RevRopeGraphemes { + text: slice, + chunks, + cur_chunk: first_chunk, + cur_chunk_start, + cursor: GraphemeCursor::new(slice.len_bytes(), slice.len_bytes(), true), + } + } +} + +impl<'a> Iterator for RevRopeGraphemes<'a> { + type Item = RopeSlice<'a>; + + fn next(&mut self) -> Option> { + let a = self.cursor.cur_cursor(); + let b; + loop { + match self + .cursor + .prev_boundary(self.cur_chunk, self.cur_chunk_start) + { + Ok(None) => { + return None; + } + Ok(Some(n)) => { + b = n; + break; + } + Err(GraphemeIncomplete::PrevChunk) => { + self.cur_chunk = self.chunks.next().unwrap_or(""); + self.cur_chunk_start -= self.cur_chunk.len(); + } + Err(GraphemeIncomplete::PreContext(idx)) => { + let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1)); + self.cursor.provide_context(chunk, byte_idx); + } + _ => unreachable!(), + } + } + + if a >= self.cur_chunk_start + self.cur_chunk.len() { + Some(self.text.byte_slice(b..a)) + } else { + let a2 = a - self.cur_chunk_start; + let b2 = b - self.cur_chunk_start; + Some((&self.cur_chunk[b2..a2]).into()) + } + } +} + /// A highly compressed Cow<'a, str> that holds /// atmost u31::MAX bytes and is readonly pub struct GraphemeStr<'a> { From 4a171dd237e53b7978d52d2c1679bc974cdc9310 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Tue, 21 Nov 2023 01:46:12 +0100 Subject: [PATCH 4/4] Add an Amp-like jump command Co-authored-by: Michael Davis --- book/src/configuration.md | 1 + book/src/keymap.md | 1 + book/src/themes.md | 1 + helix-core/src/selection.rs | 9 ++ helix-term/src/commands.rs | 190 +++++++++++++++++++++++++++- helix-term/src/keymap/default.rs | 2 + helix-view/src/document.rs | 13 +- helix-view/src/editor.rs | 23 +++- helix-view/src/view.rs | 7 + runtime/themes/everforest_dark.toml | 1 + theme.toml | 2 +- 11 files changed, 244 insertions(+), 6 deletions(-) diff --git a/book/src/configuration.md b/book/src/configuration.md index 8857af82a98e..c55426c013d1 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -68,6 +68,7 @@ Its settings will be merged with the configuration directory `config.toml` and t | `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` | | `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` | | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` +| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | "abcdefghijklmnopqrstuvwxyz" ### `[editor.statusline]` Section diff --git a/book/src/keymap.md b/book/src/keymap.md index bb09b03197b8..f57b1e63606c 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -224,6 +224,7 @@ Jumps to various locations. | `.` | Go to last modification in current file | `goto_last_modification` | | `j` | Move down textual (instead of visual) line | `move_line_down` | | `k` | Move up textual (instead of visual) line | `move_line_up` | +| `w` | Show labels at each word and select the word that belongs to the entered labels | `goto_word` | #### Match mode diff --git a/book/src/themes.md b/book/src/themes.md index 04d6a69b3914..29a8c4ba82e8 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -314,6 +314,7 @@ These scopes are used for theming the editor interface: | `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.virtual.jump-label` | Style for virtual jump labels | | `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 | diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index bd252deb9d2c..579499de5e4a 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -705,6 +705,15 @@ impl IntoIterator for Selection { } } +impl From for Selection { + fn from(range: Range) -> Self { + Self { + ranges: smallvec![range], + primary_index: 0, + } + } +} + // TODO: checkSelection -> check if valid for doc length && sorted pub fn keep_or_remove_matches( diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 0385deaaac0c..80eeb94edd1e 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -10,9 +10,12 @@ use tui::widgets::Row; pub use typed::*; use helix_core::{ - char_idx_at_visual_offset, comment, + char_idx_at_visual_offset, + chars::char_is_word, + comment, doc_formatter::TextFormat, - encoding, find_workspace, graphemes, + encoding, find_workspace, + graphemes::{self, next_grapheme_boundary, RevRopeGraphemes}, history::UndoKind, increment, indent, indent::IndentStyle, @@ -24,7 +27,7 @@ use helix_core::{ search::{self, CharMatcher}, selection, shellwords, surround, syntax::{BlockCommentToken, LanguageServerFeature}, - text_annotations::TextAnnotations, + text_annotations::{Overlay, TextAnnotations}, textobject, tree_sitter::Node, unicode::width::UnicodeWidthChar, @@ -503,6 +506,8 @@ impl MappableCommand { record_macro, "Record macro", replay_macro, "Replay macro", command_palette, "Open command palette", + goto_word, "Jump to a two-character label", + extend_to_word, "Extend to a two-character label", ); } @@ -5809,3 +5814,182 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.pop(); })); } + +fn goto_word(cx: &mut Context) { + jump_to_word(cx, Movement::Move) +} + +fn extend_to_word(cx: &mut Context) { + jump_to_word(cx, Movement::Extend) +} + +fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { + let doc = doc!(cx.editor); + let alphabet = &cx.editor.config().jump_label_alphabet; + if labels.is_empty() { + return; + } + let alphabet_char = |i| { + let mut res = Tendril::new(); + res.push(alphabet[i]); + res + }; + + // Add label for each jump candidate to the View as virtual text. + let text = doc.text().slice(..); + let mut overlays: Vec<_> = labels + .iter() + .enumerate() + .flat_map(|(i, range)| { + [ + Overlay::new(range.from(), alphabet_char(i / alphabet.len())), + Overlay::new( + graphemes::next_grapheme_boundary(text, range.from()), + alphabet_char(i % alphabet.len()), + ), + ] + }) + .collect(); + overlays.sort_unstable_by_key(|overlay| overlay.char_idx); + let (view, doc) = current!(cx.editor); + doc.set_jump_labels(view.id, overlays); + + // Accept two characters matching a visible label. Jump to the candidate + // for that label if it exists. + let primary_selection = doc.selection(view.id).primary(); + let view = view.id; + let doc = doc.id(); + cx.on_next_key(move |cx, event| { + let alphabet = &cx.editor.config().jump_label_alphabet; + let Some(i ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + return; + }; + let outer = i * alphabet.len(); + // Bail if the given character cannot be a jump label. + if outer > labels.len() { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + return; + } + cx.on_next_key(move |cx, event| { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + let alphabet = &cx.editor.config().jump_label_alphabet; + let Some(inner ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else { + return; + }; + if let Some(mut range) = labels.get(outer + inner).copied() { + range = if behaviour == Movement::Extend { + let anchor = if range.anchor < range.head { + let from = primary_selection.from(); + if range.anchor < from { + range.anchor + } else { + from + } + } else { + let to = primary_selection.to(); + if range.anchor > to { + range.anchor + } else { + to + } + }; + Range::new(anchor, range.head) + }else{ + range.with_direction(Direction::Forward) + }; + doc_mut!(cx.editor, &doc).set_selection(view, range.into()); + } + }); + }); +} + +fn jump_to_word(cx: &mut Context, behaviour: Movement) { + // Calculate the jump candidates: ranges for any visible words with two or + // more characters. + let alphabet = &cx.editor.config().jump_label_alphabet; + let jump_label_limit = alphabet.len() * alphabet.len(); + let mut words = Vec::with_capacity(jump_label_limit); + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + + // This is not necessarily exact if there is virtual text like soft wrap. + // It's ok though because the extra jump labels will not be rendered. + let start = text.line_to_char(text.char_to_line(view.offset.anchor)); + let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1); + + let primary_selection = doc.selection(view.id).primary(); + let cursor = primary_selection.cursor(text); + let mut cursor_fwd = Range::point(cursor); + let mut cursor_rev = Range::point(cursor); + if text.get_char(cursor).is_some_and(|c| !c.is_whitespace()) { + let cursor_word_end = movement::move_next_word_end(text, cursor_fwd, 1); + // single grapheme words need a specical case + if cursor_word_end.anchor == cursor { + cursor_fwd = cursor_word_end; + } + let cursor_word_start = movement::move_prev_word_start(text, cursor_rev, 1); + if cursor_word_start.anchor == next_grapheme_boundary(text, cursor) { + cursor_rev = cursor_word_start; + } + } + 'outer: loop { + let mut changed = false; + while cursor_fwd.head < end { + cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1); + // The cursor is on a word that is atleast two graphemes long and + // madeup of word characters. The latter condition is needed because + // move_next_word_end simply treats a sequence of characters from + // the same char class as a word so `=<` would also count as a word. + let add_label = RevRopeGraphemes::new(text.slice(..cursor_fwd.head)) + .take(2) + .take_while(|g| g.chars().all(char_is_word)) + .count() + == 2; + if !add_label { + continue; + } + changed = true; + // skip any leading whitespace + cursor_fwd.anchor += text + .chars_at(cursor_fwd.anchor) + .take_while(|&c| !char_is_word(c)) + .count(); + words.push(cursor_fwd); + if words.len() == jump_label_limit { + break 'outer; + } + break; + } + while cursor_rev.head > start { + cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1); + // The cursor is on a word that is atleast two graphemes long and + // madeup of word characters. The latter condition is needed because + // move_prev_word_start simply treats a sequence of characters from + // the same char class as a word so `=<` would also count as a word. + let add_label = RopeGraphemes::new(text.slice(cursor_rev.head..)) + .take(2) + .take_while(|g| g.chars().all(char_is_word)) + .count() + == 2; + if !add_label { + continue; + } + changed = true; + cursor_rev.anchor -= text + .chars_at(cursor_rev.anchor) + .reversed() + .take_while(|&c| !char_is_word(c)) + .count(); + words.push(cursor_rev); + if words.len() == jump_label_limit { + break 'outer; + } + break; + } + if !changed { + break; + } + } + jump_to_label(cx, words, behaviour) +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index bab662b04dd3..ca5a21d26a16 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -58,6 +58,7 @@ pub fn default() -> HashMap { "k" => move_line_up, "j" => move_line_down, "." => goto_last_modification, + "w" => goto_word, }, ":" => command_mode, @@ -360,6 +361,7 @@ pub fn default() -> HashMap { "g" => { "Goto" "k" => extend_line_up, "j" => extend_line_down, + "w" => extend_to_word, }, })); let insert = keymap!({ "Insert mode" diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 090e4dd5ccc9..da68a67fb4e6 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -8,7 +8,7 @@ use helix_core::chars::char_is_word; use helix_core::doc_formatter::TextFormat; use helix_core::encoding::Encoding; use helix_core::syntax::{Highlight, LanguageServerFeature}; -use helix_core::text_annotations::InlineAnnotation; +use helix_core::text_annotations::{InlineAnnotation, Overlay}; use helix_lsp::util::lsp_pos_to_pos; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -125,6 +125,7 @@ pub struct Document { /// /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`. pub(crate) inlay_hints: HashMap, + pub(crate) jump_labels: HashMap>, /// Set to `true` when the document is updated, reset to `false` on the next inlay hints /// update from the LSP pub inlay_hints_oudated: bool, @@ -665,6 +666,7 @@ impl Document { version_control_head: None, focused_at: std::time::Instant::now(), readonly: false, + jump_labels: HashMap::new(), } } @@ -1138,6 +1140,7 @@ impl Document { pub fn remove_view(&mut self, view_id: ViewId) { self.selections.remove(&view_id); self.inlay_hints.remove(&view_id); + self.jump_labels.remove(&view_id); } /// Apply a [`Transaction`] to the [`Document`] to change its text. @@ -1943,6 +1946,14 @@ impl Document { self.inlay_hints.insert(view_id, inlay_hints); } + pub fn set_jump_labels(&mut self, view_id: ViewId, labels: Vec) { + self.jump_labels.insert(view_id, labels); + } + + pub fn remove_jump_labels(&mut self, view_id: ViewId) { + self.jump_labels.remove(&view_id); + } + /// Get the inlay hints for this document and `view_id`. pub fn inlay_hints(&self, view_id: ViewId) -> Option<&DocumentInlayHints> { self.inlay_hints.get(&view_id) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index bad61052e827..3c530c4e89bf 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -22,7 +22,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ borrow::Cow, cell::Cell, - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, fs, io::{self, stdin}, num::NonZeroUsize, @@ -212,6 +212,23 @@ impl Default for FilePickerConfig { } } +fn deserialize_alphabet<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + let str = String::deserialize(deserializer)?; + let chars: Vec<_> = str.chars().collect(); + let unique_chars: HashSet<_> = chars.iter().copied().collect(); + if unique_chars.len() != chars.len() { + return Err(::custom( + "jump-label-alphabet must contain unique characters", + )); + } + Ok(chars) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { @@ -305,6 +322,9 @@ pub struct Config { /// Which indent heuristic to use when a new line is inserted #[serde(default)] pub indent_heuristic: IndentationHeuristic, + /// labels characters used in jumpmode + #[serde(skip_serializing, deserialize_with = "deserialize_alphabet")] + pub jump_label_alphabet: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -870,6 +890,7 @@ impl Default for Config { smart_tab: Some(SmartTabConfig::default()), popup_border: PopupBorderConfig::None, indent_heuristic: IndentationHeuristic::default(), + jump_label_alphabet: ('a'..='z').collect(), } } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index bbdc74a74449..b5dc0615a5af 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -415,6 +415,13 @@ impl View { ) -> TextAnnotations<'a> { let mut text_annotations = TextAnnotations::default(); + if let Some(labels) = doc.jump_labels.get(&self.id) { + let style = theme + .and_then(|t| t.find_scope_index("ui.virtual.jump-label")) + .map(Highlight); + text_annotations.add_overlay(labels, style); + } + let DocumentInlayHints { id: _, type_inlay_hints, diff --git a/runtime/themes/everforest_dark.toml b/runtime/themes/everforest_dark.toml index 25a4913458d1..a3083d539076 100644 --- a/runtime/themes/everforest_dark.toml +++ b/runtime/themes/everforest_dark.toml @@ -40,6 +40,7 @@ "constructor" = "green" "module" = "yellow" "special" = "blue" +"ui.virtual.jump-label" = { fg = "#00dfff", modifiers = ["bold"] } "markup.heading.marker" = "grey1" "markup.heading.1" = { fg = "red", modifiers = ["bold"] } diff --git a/theme.toml b/theme.toml index 8a5bfd72d299..a853ce6fe044 100644 --- a/theme.toml +++ b/theme.toml @@ -52,11 +52,11 @@ label = "honey" "ui.popup" = { bg = "revolver" } "ui.window" = { fg = "bossanova" } "ui.help" = { bg = "#7958DC", fg = "#171452" } - "ui.text" = { fg = "lavender" } "ui.text.focus" = { fg = "white" } "ui.text.inactive" = "sirocco" "ui.virtual" = { fg = "comet" } +"ui.virtual.jump-label" = { fg = "apricot", modifiers = ["bold"] } "ui.virtual.indent-guide" = { fg = "comet" }