diff --git a/book/src/configuration.md b/book/src/configuration.md index eb2cf473c..e0c4614bb 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -64,6 +64,7 @@ ### `[editor]` Section | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | | `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` | | `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` | +| `jump-label-alphabet` | The set of characters to use when creating jump labels. | `abcdefghijklmnopqrstuvwxyz` | ### `[editor.statusline]` Section diff --git a/book/src/themes.md b/book/src/themes.md index 96d7c0eca..23e06d877 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -307,6 +307,7 @@ #### 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-term/src/commands.rs b/helix-term/src/commands.rs index 141587937..5bafd9921 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -490,6 +490,7 @@ pub fn doc(&self) -> &str { record_macro, "Record macro", replay_macro, "Replay macro", command_palette, "Open command palette", + jump_to_label, "Jump to a two-character label", ); } @@ -5688,3 +5689,107 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.pop(); })); } + +fn jump_to_label(cx: &mut Context) { + use helix_core::text_annotations::Overlay; + + fn clear_overlays(editor: &mut Editor) { + let (view, doc) = current!(editor); + doc.jump_label_overlays.remove(&view.id); + } + + let alphabet = &cx.editor.config().jump_label_alphabet; + let alphabet_chars: Vec = alphabet.chars().collect(); + let alphabet_len = alphabet_chars.len(); + // Each jump uses a distinct two-character pair from the alphabet. + // For example for the default alphabet labels will be: + // aa, ab, ac, ..., ba, bb, bc, ..., zx, zy, zz + let jump_limit = alphabet_len * alphabet_len; + + // Calculate the jump candidates: ranges for any visible words with two or + // more characters. + let mut candidates = Vec::with_capacity(jump_limit); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let start = text.line_to_char(text.char_to_line(view.offset.anchor)); + // 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 end = text.line_to_char(view.estimate_last_doc_line(doc)); + let mut word = Range::point(start); + + loop { + word = movement::move_next_word_start(text, word, 1); + + // The word is on a word longer than 2 characters. + if text + .chars_at(word.anchor) + .take_while(|ch| ch.is_alphabetic()) + .take(2) + .count() + == 2 + { + candidates.push(word); + } + + if word.anchor >= end || candidates.len() == jump_limit { + break; + } + } + + // Add label for each jump candidate to the View as virtual text. + let overlays: Vec<_> = candidates + .iter() + .enumerate() + .flat_map(|(i, range)| { + [ + Overlay::new(range.anchor, String::from(alphabet_chars[i / alphabet_len])), + Overlay::new( + range.anchor + 1, + String::from(alphabet_chars[i % alphabet_len]), + ), + ] + }) + .collect(); + doc.jump_label_overlays.insert(view.id, overlays); + + // Accept two characters matching a visible label. Jump to the candidate + // for that label if it exists. + cx.on_next_key(move |cx, event| { + let alphabet = &cx.editor.config().jump_label_alphabet; + + let Some(first_index) = event.char().and_then(|ch| alphabet.find(ch)) else { + clear_overlays(cx.editor); + return; + }; + + // Bail if the given character cannot be a jump label. + if first_index * alphabet_len >= candidates.len() { + clear_overlays(cx.editor); + return; + } + + cx.on_next_key(move |cx, event| { + clear_overlays(cx.editor); + + let alphabet = &cx.editor.config().jump_label_alphabet; + + let Some(second_index) = event.char().and_then(|ch| alphabet.find(ch)) else { + clear_overlays(cx.editor); + return; + }; + + let index = first_index * alphabet_len + second_index; + if let Some(range) = candidates.get(index) { + let (view, doc) = current!(cx.editor); + // Trim any trailing whitespace left by 'move_next_word_start'. + let head = movement::backwards_skip_while(doc.text().slice(..), range.head, |ch| { + ch.is_whitespace() + }) + .unwrap_or(range.head); + + doc.set_selection(view.id, Selection::single(range.anchor, head)); + } + }); + }); +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 763ed4ae7..dfd93609b 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -277,6 +277,9 @@ pub fn default() -> HashMap { "r" => rename_symbol, "h" => select_references_to_symbol_under_cursor, "?" => command_palette, + + // TODO: find a home for this. + "l" => jump_to_label, }, "z" => { "View" "z" | "c" => align_view_center, diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 29371c3c4..73b616280 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -7,7 +7,7 @@ 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_vcs::{DiffHandle, DiffProviderRegistry}; use ::parking_lot::Mutex; @@ -140,6 +140,9 @@ pub struct Document { /// update from the LSP pub inlay_hints_oudated: bool, + /// Overlay labels for jump points in the document, by view. + pub jump_label_overlays: HashMap>, + path: Option, encoding: &'static encoding::Encoding, has_bom: bool, @@ -656,6 +659,7 @@ pub fn from( selections: HashMap::default(), inlay_hints: HashMap::default(), inlay_hints_oudated: false, + jump_label_overlays: HashMap::default(), indent_style: DEFAULT_INDENT, line_ending, restore_cursor: false, @@ -1231,6 +1235,16 @@ fn apply_impl( self.diagnostics .sort_unstable_by_key(|diagnostic| diagnostic.range); + // Update jump label positions. This should be a no-op most of the time unless + // a document is updated asynchronously while the jump labels are present. + for overlays in self.jump_label_overlays.values_mut() { + changes.update_positions( + overlays + .iter_mut() + .map(|overlay| (&mut overlay.char_idx, Assoc::After)), + ); + } + // 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) { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 86f35e0db..8852d2f19 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -289,6 +289,9 @@ pub struct Config { pub default_line_ending: LineEndingConfig, /// Enables smart tab pub smart_tab: Option, + /// Characters used in jump labels. + #[serde(deserialize_with = "deserialize_jump_label_alphabet")] + pub jump_label_alphabet: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -307,6 +310,30 @@ fn default() -> Self { } } +fn deserialize_jump_label_alphabet<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use std::collections::HashSet; + + let alphabet = String::deserialize(deserializer)?; + if alphabet.is_empty() { + return Err(serde::de::Error::custom( + "the jump list alphabet cannot be empty", + )); + } + let mut charset: HashSet = alphabet.chars().collect(); + for ch in alphabet.chars() { + if charset.remove(&ch) { + return Err(serde::de::Error::custom(format!( + "duplicate character '{ch}' found in jump label alphabet" + ))); + } + } + + Ok(alphabet) +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct TerminalConfig { @@ -843,6 +870,7 @@ fn default() -> Self { workspace_lsp_roots: Vec::new(), default_line_ending: LineEndingConfig::default(), smart_tab: Some(SmartTabConfig::default()), + jump_label_alphabet: "abcdefghijklmnopqrstuvwxyz".to_owned(), } } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index bbdc74a74..cdb05a5e2 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -415,6 +415,13 @@ pub fn text_annotations<'a>( ) -> TextAnnotations<'a> { let mut text_annotations = TextAnnotations::default(); + if let Some(overlays) = doc.jump_label_overlays.get(&self.id) { + let style = theme + .and_then(|t| t.find_scope_index("ui.virtual.jump-label")) + .map(Highlight); + text_annotations.add_overlay(overlays, style); + } + let DocumentInlayHints { id: _, type_inlay_hints,