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

Jump mode #3791

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8b4254e
Add initial text annotations support
sudormrfbin Jul 5, 2021
4d8be0a
Rework virtual text api
sudormrfbin Mar 11, 2022
6fa5c62
Initial implementation: char search -> jump
Omnikar Sep 20, 2022
d22ba2e
Allow multiple multikey jump prefix keys
Omnikar Sep 11, 2022
4a44615
Dynamically calculate keys to use for multikey jumps
Omnikar Sep 11, 2022
0a365ed
Add comments to multikey calculation
Omnikar Sep 11, 2022
6927693
Fix order of jump keys
Omnikar Sep 11, 2022
1788281
Add word-wise jumping
Omnikar Sep 20, 2022
384d926
Select jumped-to word
Omnikar Sep 20, 2022
3d5e7a8
Support extend mode with character search jump
Omnikar Sep 20, 2022
1d071ba
Make comment more descriptive
Omnikar Sep 11, 2022
ded521a
Consistent iteration var name
Omnikar Sep 11, 2022
ff80325
Implement bidirectional char search jumping
Omnikar Sep 11, 2022
a8b4bd5
Implement bidirectional word jumping
Omnikar Sep 11, 2022
3074ce9
Address clippy
Omnikar Sep 11, 2022
53cf891
Make jump labels themable
Omnikar Sep 23, 2022
1ecdf86
Fix jump label generation bug
Omnikar Sep 23, 2022
0fc294d
Fix clippy borrow error
Omnikar Sep 23, 2022
023a2dc
Add missing semicolon
Omnikar Sep 28, 2022
fbe6e9d
Add documentation
Omnikar Sep 28, 2022
3f42d3d
Merge branch 'master' into jump-mode
Omnikar Sep 29, 2022
399f283
Use `char_to_line` and `line_to_char`
Omnikar Sep 29, 2022
f7f5017
Fix offscreen detection
Omnikar Sep 29, 2022
b30e288
Cleanup
Omnikar Oct 1, 2022
370badd
Use `len_chars` instead of `len_bytes`
Omnikar Oct 1, 2022
d0d7942
Refactor
Omnikar Oct 19, 2022
d927f27
Fix typo
Omnikar Dec 27, 2022
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
2 changes: 2 additions & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ Jumps to various locations.
| `n` | Go to next buffer | `goto_next_buffer` |
| `p` | Go to previous buffer | `goto_previous_buffer` |
| `.` | Go to last modification in current file | `goto_last_modification` |
| `j` | Word-wise jump mode | `jump_mode_word` |
| `J` | Character search jump mode | `jump_mode_search` |

#### Match mode

Expand Down
321 changes: 320 additions & 1 deletion helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ impl MappableCommand {
decrement, "Decrement item under cursor",
record_macro, "Record macro",
replay_macro, "Replay macro",
jump_mode_word, "Jump mode: word-wise",
jump_mode_search, "Jump mode: character search",
extend_jump_mode_search, "Jump mode: extending character search",
command_palette, "Open command palette",
);
}
Expand Down Expand Up @@ -1629,7 +1632,11 @@ fn search_impl(

doc.set_selection(view.id, selection);
// TODO: is_cursor_in_view does the same calculation as ensure_cursor_in_view
if view.is_cursor_in_view(doc, 0) {
let cursor = doc
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
if view.is_cursor_in_view(cursor, doc, 0) {
view.ensure_cursor_in_view(doc, scrolloff);
} else {
align_view(doc, view, Align::Center)
Expand Down Expand Up @@ -4903,3 +4910,315 @@ fn replay_macro(cx: &mut Context) {
cx.editor.macro_replaying.pop();
}));
}

fn jump_mode_word(cx: &mut Context) {
let (view, doc) = current!(cx.editor);

let text = doc.text().slice(..);
let range = doc.selection(view.id).primary();

let mut forward_jump_locations = Vec::new();
for n in 1.. {
let next = movement::move_next_word_start(text, range, n);
// Check that the cursor is within the file before attempting further operations.
if next.cursor(text) >= text.len_chars() {
break;
}
// Use a `b` operation to position the cursor at the first character of words,
// rather than in between them.
let next = movement::move_prev_word_start(text, next, 1);
let cursor_pos = next.cursor(text);
let row = visual_coords_at_pos(doc.text().slice(..), cursor_pos, doc.tab_width()).row;
if row >= view.offset.row + view.inner_area().height as usize {
break;
}
if !view.is_cursor_in_view(cursor_pos, doc, 0) {
continue;
}
// Avoid adjacent jump locations
if forward_jump_locations
.last()
.map(|(pos, _)| cursor_pos - pos <= 1)
.unwrap_or(false)
{
continue;
}
forward_jump_locations.push((cursor_pos, next.anchor));
}

let mut backward_jump_locations = Vec::new();
for n in 1.. {
let next = movement::move_prev_word_start(text, range, n);
let cursor_pos = next.cursor(text);
let row = visual_coords_at_pos(doc.text().slice(..), cursor_pos, doc.tab_width()).row;
if row < view.offset.row {
break;
}
if !view.is_cursor_in_view(cursor_pos, doc, 0) {
if cursor_pos == 0 {
break;
}
continue;
}
if backward_jump_locations
.last()
.map(|(pos, _)| pos - cursor_pos <= 1)
.unwrap_or(false)
{
continue;
}
backward_jump_locations.push((cursor_pos, next.anchor));
if cursor_pos == 0 {
break;
}
}

jump_mode_impl(cx, forward_jump_locations, backward_jump_locations);
}

fn jump_mode_search(cx: &mut Context) {
jump_mode_search_impl(cx, false);
}

fn extend_jump_mode_search(cx: &mut Context) {
jump_mode_search_impl(cx, true);
}

fn jump_mode_search_impl(cx: &mut Context, extend: bool) {
cx.on_next_key(move |cx, event| {
let c = match event.char() {
Some(c) => c,
_ => return,
};

let (view, doc) = current!(cx.editor);

let text = doc.text().slice(..);
let (cursor, anchor) = {
let range = doc.selection(view.id).primary();
(range.cursor(text), range.anchor)
};

let mut forward_jump_locations = Vec::new();
for n in 1.. {
let next = search::find_nth_next(text, c, cursor + 1, n);
match next {
Some(pos) => {
let row = visual_coords_at_pos(doc.text().slice(..), pos, doc.tab_width()).row;
if row >= view.offset.row + view.inner_area().height as usize {
break;
}
if !view.is_cursor_in_view(pos, doc, 0) {
continue;
}
forward_jump_locations.push((pos, if extend { anchor } else { pos }));
}
_ => break,
}
}
let mut backward_jump_locations = Vec::new();
for n in 1.. {
let next = search::find_nth_prev(text, c, cursor, n);
match next {
Some(pos) => {
let row = visual_coords_at_pos(doc.text().slice(..), pos, doc.tab_width()).row;
if row < view.offset.row {
break;
}
if !view.is_cursor_in_view(pos, doc, 0) {
continue;
}
backward_jump_locations.push((pos, if extend { anchor } else { pos }));
}
_ => break,
}
}

jump_mode_impl(cx, forward_jump_locations, backward_jump_locations);
});
}

fn jump_mode_impl(
cx: &mut Context,
forward_jumps: Vec<(usize, usize)>,
backward_jumps: Vec<(usize, usize)>,
) {
const JUMP_KEYS: &[u8] = b"asdghklqwertyuiopzxcvbnmfj;";

let jump_locations = forward_jumps
.into_iter()
.map(Some)
.chain(std::iter::repeat(None))
.zip(
backward_jumps
.into_iter()
.map(Some)
.chain(std::iter::repeat(None)),
)
.take_while(|tup| *tup != (None, None))
.flat_map(|(fwd, bck)| [fwd, bck])
.flatten()
.collect::<Vec<_>>();

if jump_locations.is_empty() {
return;
}

// Optimize the quantity of keys to use for multikey jumps to maximize the
// number of jumps accessible within one keystroke without compromising on
// making enough jumps accessible within two keystrokes.
let sep_idx = JUMP_KEYS.len() - {
let k = JUMP_KEYS.len() as f32;
// Clamp input to the domain (0, (k^2 + 2k + 1) / 4].
let n = (jump_locations.len() as f32).min((k.powi(2) + 2.0 * k + 1.0) / 4.0);
// Within the domain (0, (k^2 + 2k + 1) / 4], this function returns values
// in the range (-1, k/2]. As such, when `.ceil()` is called on the output,
// the result is in the range [0, k/2].
((k - 1.0 - (k.powi(2) + 2.0 * k - 4.0 * n + 1.0).sqrt()) / 2.0).ceil() as usize
};

enum Jump {
Final(usize, usize),
Multi(HashMap<u8, Jump>),
}

let mut jump_seqs = JUMP_KEYS[..sep_idx]
.iter()
.copied()
.map(|b| vec![b])
.collect::<Vec<_>>();
loop {
if jump_seqs.len() >= jump_locations.len() {
break;
}
let last_len = jump_seqs.last().map(|seq| seq.len()).unwrap_or(1);
let mut seq_iter = jump_seqs
.iter()
.zip((1..=jump_seqs.len()).rev())
.skip_while(|(seq, _)| seq.len() < last_len)
.map(|(seq, len)| (seq.clone(), len))
.peekable();
let subset_len = seq_iter.peek().map(|(_, len)| *len).unwrap_or(1);
let mut new_seqs = std::iter::repeat(seq_iter.map(|(seq, _)| seq))
.take(
// Add 1 less than the divisor to essentially ceil the integer division.
(jump_locations.len().saturating_sub(jump_seqs.len()) + subset_len - 1)
/ subset_len,
)
.zip(JUMP_KEYS[sep_idx..].iter().copied())
.flat_map(|(iter, k)| {
iter.map(move |mut seq| {
seq.insert(0, k);
seq
})
})
.collect();
jump_seqs.append(&mut new_seqs);
}

let mut jumps = HashMap::new();
for (seq, pos) in jump_seqs.into_iter().zip(jump_locations) {
let mut current = &mut jumps;
for &k in &seq[..seq.len() - 1] {
current = match current
.entry(k)
.or_insert_with(|| Jump::Multi(HashMap::new()))
{
Jump::Multi(map) => map,
_ => unreachable!(),
};
}
current.insert(*seq.last().unwrap(), Jump::Final(pos.0, pos.1));
}

use helix_view::decorations::{TextAnnotation, TextAnnotationKind};
use helix_view::graphics::{Color, Modifier, Style};

fn annotations_impl(label: u8, jump: &Jump) -> Box<dyn Iterator<Item = (String, usize)> + '_> {
match jump {
Jump::Final(pos, _) => Box::new(std::iter::once(((label as char).into(), *pos))),
Jump::Multi(map) => Box::new(
map.iter()
.flat_map(|(&label, jump)| annotations_impl(label, jump))
.map(move |(mut label_, jump)| {
(
{
label_.insert(0, label as char);
label_
},
jump,
)
}),
),
}
}
fn annotations(
doc: &Document,
theme: &helix_view::Theme,
jumps: &HashMap<u8, Jump>,
) -> impl Iterator<Item = TextAnnotation> {
let single_style = theme
.try_get("ui.jump.single")
.unwrap_or_else(|| Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
let multi_style = theme.try_get("ui.jump.multi").unwrap_or_else(|| {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
});
jumps
.iter()
.flat_map(|(&label, jump)| annotations_impl(label, jump))
.map(|(annot, pos)| {
let text = doc.text();
let line = text.char_to_line(pos);
let offset = pos - text.line_to_char(line);
let style = match annot.len() {
2.. => multi_style,
_ => single_style,
};
TextAnnotation {
text: annot.into(),
style,
line,
kind: TextAnnotationKind::Overlay(offset),
}
})
// Collect to satisfy 'static lifetime.
.collect::<Vec<_>>()
.into_iter()
}

let doc = doc_mut!(cx.editor);

let annots = annotations(doc, &cx.editor.theme, &jumps);
doc.push_text_annotations("jump_mode", annots);

fn handle_key(mut jumps: HashMap<u8, Jump>, cx: &mut Context, event: KeyEvent) {
let doc = doc_mut!(cx.editor);
doc.clear_text_annotations("jump_mode");
if let Some(jump) = event
.char()
.and_then(|c| c.try_into().ok())
.and_then(|c| jumps.remove(&c))
{
match jump {
Jump::Multi(jumps) => {
let annots = annotations(doc, &cx.editor.theme, &jumps);
doc.push_text_annotations("jump_mode", annots);
cx.on_next_key(move |cx, event| handle_key(jumps, cx, event));
}
Jump::Final(mut cursor, anchor) => {
let (view, doc) = current!(cx.editor);
push_jump(view, doc);
// Fixes off-by-one errors when extending with jump mode
if cursor >= anchor {
cursor += 1
}
doc.set_selection(view.id, Selection::single(anchor, cursor));
}
}
}
}

cx.on_next_key(move |cx, event| handle_key(jumps, cx, event));
}
6 changes: 6 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub fn default() -> HashMap<Mode, Keymap> {
"n" => goto_next_buffer,
"p" => goto_previous_buffer,
"." => goto_last_modification,
"j" => jump_mode_word,
"J" => jump_mode_search,
},
":" => command_mode,

Expand Down Expand Up @@ -337,6 +339,10 @@ pub fn default() -> HashMap<Mode, Keymap> {
"end" => extend_to_line_end,
"esc" => exit_select_mode,

"g" => { "Goto"
"J" => extend_jump_mode_search,
},

"v" => normal_mode,
}));
let insert = keymap!({ "Insert mode"
Expand Down
Loading