Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhanced jump mode (based on #3791) #5340

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,23 @@ max-wrap = 25 # increase value to reduce forced mid-word wrapping
max-indent-retain = 0
wrap-indicator = "" # set wrap-indicator to "" to hide it
```

### `[editor.jump-mode]` Section

Options for jump mode. If you are already familiar with vim/nvim's [easymotion](https://github.com/easymotion/vim-easymotion), [hop](https://github.com/phaazon/hop.nvim), [leap](https://github.com/ggandor/leap.nvim) etc, you
can think of jump mode as the equivalent in helix.

| Key | Description | Default |
| --- | --- | --- |
| `dim-during-jump` | Whether to dim the view when in jump mode. | `true` |
| `num-chars-before-label` | How many characters the user should type before labelling the targets. | `1` |
| `jump-keys` | Keys used in labels. Should be ascii characters. | `"jwetovxqpdygfblzhckisuranm"` |

Example:

```toml
[editor.jump-mode]
dim-during-jump = true
num-chars-before-label = 2
jump-keys = "laskdjfhgpmoinqzubwxyvecrt"
```
2 changes: 2 additions & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ 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` | Word-wise jump mode | `jump_to_identifier_label` |
| `/` | Character or string search jump mode | `jump_to_str_label` |

#### Match mode

Expand Down
186 changes: 185 additions & 1 deletion helix-term/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub(crate) mod dap;
pub(crate) mod jump;
pub(crate) mod lsp;
pub(crate) mod typed;

Expand Down Expand Up @@ -48,6 +49,10 @@ use fuzzy_matcher::FuzzyMatcher;
use insert::*;
use movement::Movement;

use self::jump::{
cleanup, find_all_identifiers_in_view, find_all_str_occurrences_in_view, jump_keys,
show_key_annotations_with_callback, sort_jump_targets, JumpSequencer, TrieNode,
};
use crate::{
args,
compositor::{self, Component, Compositor},
Expand All @@ -62,7 +67,7 @@ use crate::{

use crate::job::{self, Jobs};
use futures_util::{stream::FuturesUnordered, StreamExt, TryStreamExt};
use std::{collections::HashMap, fmt, future::Future};
use std::{cmp::Ordering, collections::HashMap, fmt, future::Future};
use std::{collections::HashSet, num::NonZeroUsize};

use std::{
Expand Down Expand Up @@ -485,6 +490,10 @@ impl MappableCommand {
decrement, "Decrement item under cursor",
record_macro, "Record macro",
replay_macro, "Replay macro",
jump_to_identifier_label, "Jump mode: word-wise",
jump_to_str_label, "Jump mode: character search",
jump_to_identifier_label_and_extend_selection, "Jump mode: extend selection with word-wise jump",
jump_to_str_label_and_extend_selection, "Jump mode: extend selection with character search",
command_palette, "Open command palette",
);
}
Expand Down Expand Up @@ -5588,3 +5597,178 @@ fn replay_macro(cx: &mut Context) {
cx.editor.macro_replaying.pop();
}));
}

fn jump_to_identifier_label(cx: &mut Context) {
let jump_targets = find_all_identifiers_in_view(cx);
jump_with_targets(cx, jump_targets, false);
}

fn jump_to_identifier_label_and_extend_selection(cx: &mut Context) {
let jump_targets = find_all_identifiers_in_view(cx);
jump_with_targets(cx, jump_targets, true);
}

fn jump_to_str_label(cx: &mut Context) {
find_and_jump_to_str(cx, false);
}

fn jump_to_str_label_and_extend_selection(cx: &mut Context) {
find_and_jump_to_str(cx, true);
}

fn find_and_jump_to_str(cx: &mut Context, extend_selection: bool) {
jump::setup(cx);
let nchars = doc!(cx.editor)
.config
.load()
.jump_mode
.num_chars_before_label as usize;
cx.editor.set_status(format!("Press {} key(s)", nchars));
cx.on_next_key(move |cx, event| {
jump_with_str_input(
cx,
event,
extend_selection,
String::with_capacity(nchars),
nchars,
)
});
}

fn jump_with_str_input(
cx: &mut Context,
event: KeyEvent,
extend_selection: bool,
mut s: String,
nchars: usize,
) {
let Some(c) = event.char().filter(|c| c.is_ascii()) else {
return cleanup(cx);
};
s.push(c);
if s.len() == nchars {
let jump_targets = find_all_str_occurrences_in_view(cx, s);
jump_with_targets(cx, jump_targets, extend_selection);
return;
}
cx.editor
.set_status(format!("Press {} more key(s)", nchars - s.len()));
cx.on_next_key(move |cx, event| jump_with_str_input(cx, event, extend_selection, s, nchars));
}

fn jump_with_targets(cx: &mut Context, mut jump_targets: Vec<Range>, extend_selection: bool) {
if jump_targets.is_empty() {
return cleanup(cx);
}
// Jump targets are sorted based on their distance to the current cursor.
jump_targets = sort_jump_targets(cx, jump_targets);
if extend_selection {
jump_targets = extend_jump_selection(cx, jump_targets);
}
if jump_targets.len() == 1 {
jump_to(cx, jump_targets[0], extend_selection);
return cleanup(cx);
}
let root = TrieNode::build(&jump_keys(cx), jump_targets);
show_key_annotations_with_callback(cx, root.generate(), move |cx, event| {
handle_key_event(root, cx, event, extend_selection)
});
}

fn fix_extend_mode_off_by_one(selection: &Range, target: &mut Range) {
// Non-zero width ranges are always inclusive on the left and exclusive on
// the right. But when we're extending the selection, this often creates
// off-by-one behavior, where the cursor doesn't quite reach the target.
// Thus we need to increment the upper bound when the target head is after
// the current anchor.
if selection.anchor < target.head {
match target.anchor.cmp(&target.head) {
Ordering::Less => target.head += 1,
Ordering::Greater => target.anchor += 1,
Ordering::Equal => {}
};
}
}

fn jump_to(cx: &mut Context, mut range: Range, extend_selection: bool) {
let (view, doc) = current!(cx.editor);
push_jump(view, doc);
let selection = doc.selection(view.id).primary();
if extend_selection {
fix_extend_mode_off_by_one(&selection, &mut range);
}
doc.set_selection(view.id, Selection::single(range.anchor, range.head));
}

/// Handle user key press.
/// Returns whether we are finished and should move out of jump mode.
fn handle_key(
mut sequencer: impl JumpSequencer + 'static,
cx: &mut Context,
key: u8,
extend_selection: bool,
) -> bool {
cleanup(cx);
match sequencer.choose(key) {
Some(subnode) => {
sequencer = *subnode;
}
// char `c` is not a valid character. Finish jump mode
None => return true,
}
match sequencer.try_get_range() {
Some(range) => {
jump_to(cx, range, extend_selection);
return true;
}
None => {
show_key_annotations_with_callback(cx, sequencer.generate(), move |cx, event| {
handle_key_event(sequencer, cx, event, extend_selection)
});
}
}
false
}

fn handle_key_event(
node: impl JumpSequencer + 'static,
cx: &mut Context,
event: KeyEvent,
extend_selection: bool,
) {
let finished = match event.char() {
Some(key) => {
if event.modifiers.is_empty() && key.is_ascii() {
handle_key(node, cx, key as u8, extend_selection)
} else {
// Only accept ascii characters with no modifiers. Otherwise, finish jump mode.
true
}
}
// We didn't get a valid character. Finish jump mode.
None => true,
};
if finished {
cleanup(cx);
}
}

fn extend_jump_selection(cx: &Context, jump_targets: Vec<Range>) -> Vec<Range> {
let (view, doc) = current_ref!(cx.editor);
// We only care about the primary selection
let mut cur = doc.selection(view.id).primary();
jump_targets
.into_iter()
.map(|mut range| {
// We want to grow the selection, so if the new head crosses the
// old anchor, swap the old head and old anchor
let cross_fwd = cur.head < cur.anchor && cur.anchor < range.head;
let cross_bwd = range.head < cur.anchor && cur.anchor < cur.head;
if cross_fwd || cross_bwd {
std::mem::swap(&mut cur.head, &mut cur.anchor);
}
range.anchor = cur.anchor;
range
})
.collect()
}
9 changes: 9 additions & 0 deletions helix-term/src/commands/jump.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
pub(crate) mod annotate;
pub(crate) mod locations;
pub(crate) mod score;
pub(crate) mod sequencer;

pub use annotate::{cleanup, jump_keys, setup, show_key_annotations_with_callback};
pub use locations::{find_all_identifiers_in_view, find_all_str_occurrences_in_view};
pub use score::sort_jump_targets;
pub use sequencer::{JumpAnnotation, JumpSequence, JumpSequencer, TrieNode};
86 changes: 86 additions & 0 deletions helix-term/src/commands/jump/annotate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use super::JumpAnnotation;
use crate::commands::Context;
use helix_core::chars::char_is_line_ending;
use helix_core::text_annotations::Overlay;
use helix_view::{input::KeyEvent, View};
use std::rc::Rc;

pub fn jump_keys(ctx: &mut Context) -> Vec<u8> {
doc!(ctx.editor)
.config
.load()
.jump_mode
.jump_keys
.clone()
.into_bytes()
}

#[inline]
pub fn setup(ctx: &mut Context) {
let (view, doc) = current!(ctx.editor);
if doc.config.load().jump_mode.dim_during_jump {
view.dimmed = true;
}
view.in_visual_jump_mode = true;
}

#[inline]
fn clear_dimming(view: &mut View) {
view.dimmed = false;
view.in_visual_jump_mode = false;
}

#[inline]
pub fn cleanup(ctx: &mut Context) {
let mut view = view_mut!(ctx.editor);
clear_dimming(view);
view.visual_jump_labels[0] = Rc::new([]);
view.visual_jump_labels[1] = Rc::new([]);
view.visual_jump_labels[2] = Rc::new([]);
}

/// `annotations` should already be sorted by the `loc` attribute (= char_idx)
pub fn show_key_annotations_with_callback<F>(
ctx: &mut Context,
annotations: Vec<JumpAnnotation>,
on_key_press_callback: F,
) where
F: FnOnce(&mut Context, KeyEvent) + 'static,
{
setup(ctx);
let (view, doc) = current!(ctx.editor);
let text = doc.text().slice(..);
let mut overlays_single: Vec<Overlay> = Vec::new();
let mut overlays_multi_first: Vec<Overlay> = Vec::new();
let mut overlays_multi_rest: Vec<Overlay> = Vec::new();
for jump in annotations.into_iter() {
if jump.keys.len() == 1 {
overlays_single.push(Overlay {
char_idx: jump.loc,
grapheme: jump.keys.into(),
});
continue;
}
overlays_multi_first.push(Overlay {
char_idx: jump.loc,
grapheme: jump.keys.chars().next().unwrap().to_string().into(),
});
for (i, c) in (1..jump.keys.len()).zip(jump.keys.chars().skip(1)) {
let char_idx = jump.loc + i;
let char = text.chars_at(char_idx).next().unwrap();
// We shouldn't overlay anything on top of a line break. If we do, the next line will
// crawl up and concatenate with the current line.
if char_is_line_ending(char) {
break;
}
overlays_multi_rest.push(Overlay {
char_idx,
grapheme: c.to_string().into(),
});
}
}
view.visual_jump_labels[0] = overlays_single.into();
view.visual_jump_labels[1] = overlays_multi_first.into();
view.visual_jump_labels[2] = overlays_multi_rest.into();
ctx.on_next_key(on_key_press_callback);
}
Loading