mirror of
https://github.com/helix-editor/helix.git
synced 2024-11-21 17:06:18 +04:00
Add an Amp-like jump command
This commit is contained in:
parent
553f8706ab
commit
1b6ef04857
@ -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
|
||||
|
||||
|
@ -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 |
|
||||
|
@ -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<char> = 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -277,6 +277,9 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
|
||||
"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,
|
||||
|
@ -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<ViewId, Vec<Overlay>>,
|
||||
|
||||
path: Option<PathBuf>,
|
||||
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) {
|
||||
|
@ -289,6 +289,9 @@ pub struct Config {
|
||||
pub default_line_ending: LineEndingConfig,
|
||||
/// Enables smart tab
|
||||
pub smart_tab: Option<SmartTabConfig>,
|
||||
/// 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<String, D::Error>
|
||||
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<char> = 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user