diff --git a/book/src/configuration.md b/book/src/configuration.md index f067038a552a..89b1ad7017de 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -65,6 +65,7 @@ Its settings will be merged with the configuration directory `config.toml` and t | `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` | | `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` | +| `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 0f41b3247f3d..d6099c279a5a 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -223,6 +223,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 a62c07feee02..1d25cc2ce810 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -311,6 +311,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 c44685eeae84..4044134c18f5 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -703,6 +703,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 5bbf5149a92e..1a16c19ccb5a 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -10,7 +10,9 @@ 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_first_non_whitespace_char, find_workspace, graphemes, history::UndoKind, @@ -24,7 +26,7 @@ use helix_core::{ search::{self, CharMatcher}, selection, shellwords, surround, syntax::LanguageServerFeature, - text_annotations::TextAnnotations, + text_annotations::{Overlay, TextAnnotations}, textobject, tree_sitter::Node, unicode::width::UnicodeWidthChar, @@ -490,6 +492,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", ); } @@ -3463,7 +3467,6 @@ pub mod insert { let text = doc.text().slice(..); let cursor = doc.selection(view.id).primary().cursor(text); - use helix_core::chars::char_is_word; let mut iter = text.chars_at(cursor); iter.reverse(); for _ in 0..config.completion_trigger_len { @@ -5752,3 +5755,151 @@ 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() { + 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 = Range::new(anchor, range.head); + } + 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 cursor_fwd = movement::move_prev_word_start(text, Range::point(cursor), 1); + let mut cursor_fwd = if cursor_fwd.anchor == cursor { + Range::point(cursor_fwd.head) + } else { + Range::point(cursor) + }; + let mut cursor_rev = cursor_fwd; + loop { + let mut changed = false; + if cursor_fwd.head < end { + cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1); + let range = movement::move_prev_word_start(text, cursor_fwd, 1); + // The cursor is on a word longer than 2 characters. + if RopeGraphemes::new(text.slice(range.head..)) + .take(2) + .all(|g| g.chars().all(char_is_word)) + { + words.push(range.flip()); + } + changed = true; + if words.len() == jump_label_limit { + break; + } + } + if cursor_rev.head > start { + cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1); + // The cursor is on a word longer than 2 characters. + if RopeGraphemes::new(text.slice(cursor_rev.head..)) + .take(2) + .all(|g| g.chars().all(char_is_word)) + { + words.push(movement::move_next_word_end(text, cursor_rev, 1).flip()); + } + changed = true; + if words.len() == jump_label_limit { + 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 763ed4ae71ce..ac2ef4d532b4 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, @@ -357,6 +358,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 824825bf1eab..abb20b64ebd3 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -7,7 +7,7 @@ use helix_core::auto_pairs::AutoPairs; 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; @@ -135,6 +135,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, @@ -675,6 +676,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. @@ -1807,6 +1810,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 7af28ccc680a..af6adc7afc77 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -210,6 +210,14 @@ impl Default for FilePickerConfig { } } +fn string_as_chars<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let str = String::deserialize(deserializer)?; + Ok(str.chars().collect()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { @@ -291,6 +299,9 @@ pub struct Config { pub insert_final_newline: bool, /// Enables smart tab pub smart_tab: Option, + /// labels characters used in jumpmode + #[serde(skip_serializing, deserialize_with = "string_as_chars")] + pub jump_label_alphabet: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -846,6 +857,7 @@ impl Default for Config { default_line_ending: LineEndingConfig::default(), insert_final_newline: true, smart_tab: Some(SmartTabConfig::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 dd1a5d889ac1..40d68db2e505 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" }