diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6f86b06d284ef..ccb627d42698b 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,4 +1,5 @@ pub(crate) mod dap; +pub(crate) mod jump; pub(crate) mod lsp; pub(crate) mod typed; @@ -45,6 +46,10 @@ use fuzzy_matcher::FuzzyMatcher; use insert::*; use movement::Movement; +use self::jump::{ + apply_dimming, clear_dimming, find_all_char_occurrences, find_all_identifiers_in_view, + show_key_annotations_with_callback, sort_jump_targets, JumpSequencer, TrieNode, JUMP_KEYS, +}; use crate::{ args, compositor::{self, Component, Compositor}, @@ -55,7 +60,7 @@ use crate::{ use crate::job::{self, Jobs}; use futures_util::StreamExt; -use std::{collections::HashMap, fmt, future::Future}; +use std::{cmp::Ordering, collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; use std::{ @@ -444,9 +449,10 @@ 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", + jump_to_identifier_label, "Jump mode: word-wise", + jump_to_char_label, "Jump mode: character search", + jump_to_identifier_label_and_extend_selection, "Jump mode: extend selection with word-wise jump", + jump_to_char_label_and_extend_selection, "Jump mode: extend selection with character search", command_palette, "Open command palette", ); } @@ -5213,314 +5219,143 @@ fn replay_macro(cx: &mut Context) { })); } -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; - } - } +fn jump_to_identifier_label(cx: &mut Context) { + let jump_targets = find_all_identifiers_in_view(cx); + jump_with_targets(cx, jump_targets, false); +} - jump_mode_impl(cx, forward_jump_locations, backward_jump_locations); +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_mode_search(cx: &mut Context) { - jump_mode_search_impl(cx, false); +fn jump_to_char_label(cx: &mut Context) { + jump_with_char_input(cx, false); } -fn extend_jump_mode_search(cx: &mut Context) { - jump_mode_search_impl(cx, true); +fn jump_to_char_label_and_extend_selection(cx: &mut Context) { + jump_with_char_input(cx, true); } -fn jump_mode_search_impl(cx: &mut Context, extend: bool) { +fn jump_with_char_input(cx: &mut Context, extend_selection: bool) { + apply_dimming(cx); + cx.editor.set_status("Press a key:"); cx.on_next_key(move |cx, event| { - let c = match event.char() { - Some(c) => c, - _ => return, + let key = match event.char() { + Some(key) => key, + _ => return clear_dimming(cx), }; - - 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, - } + if !key.is_ascii() { + return clear_dimming(cx); } - 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); + let jump_targets = find_all_char_occurrences(cx, key as u8); + jump_with_targets(cx, jump_targets, extend_selection); }); } -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::>(); - - if jump_locations.is_empty() { - return; +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 => {} + }; } +} - // 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), +fn jump_to(ctx: &mut Context, mut range: Range, extend_selection: bool) { + let (view, doc) = current!(ctx.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)); +} - let mut jump_seqs = JUMP_KEYS[..sep_idx] - .iter() - .copied() - .map(|b| vec![b]) - .collect::>(); - loop { - if jump_seqs.len() >= jump_locations.len() { - break; +/// Handle user key press. +/// Returns whether we are finished and should move out of jump mode. +fn handle_key(mut node: TrieNode, ctx: &mut Context, key: u8, extend_selection: bool) -> bool { + clear_dimming(ctx); + match node.choose(key) { + Some(subnode) => { + node = *subnode; } - 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); + // char `c` is not a valid character. Finish jump mode + None => return true, } - - 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!(), - }; + match node.try_get_range() { + Some(range) => { + jump_to(ctx, range, extend_selection); + return true; } - 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 + '_> { - 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, - ) - }), - ), + None => { + show_key_annotations_with_callback(ctx, node.generate(), move |ctx, event| { + handle_key_event(node, ctx, event, extend_selection) + }); } } - fn annotations( - doc: &Document, - theme: &helix_view::Theme, - jumps: &HashMap, - ) -> impl Iterator { - 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::>() - .into_iter() - } - - let doc = doc_mut!(cx.editor); - - let annots = annotations(doc, &cx.editor.theme, &jumps); - doc.push_text_annotations("jump_mode", annots); + false +} - fn handle_key(mut jumps: HashMap, 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)); - } +fn handle_key_event(node: TrieNode, ctx: &mut Context, event: KeyEvent, extend_selection: bool) { + let finished = match event.char() { + Some(key) => { + if key.is_ascii() { + handle_key(node, ctx, key as u8, extend_selection) + } else { + // Only accept ascii characters. Finish jump mode. + true } } + // We didn't get a valid character. Finish jump mode. + None => true, + }; + if finished { + clear_dimming(ctx); } +} - cx.on_next_key(move |cx, event| handle_key(jumps, cx, event)); +fn extend_jump_selection(cx: &Context, jump_targets: Vec) -> Vec { + 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() +} + +fn jump_with_targets(ctx: &mut Context, mut jump_targets: Vec, extend_selection: bool) { + if jump_targets.is_empty() { + return clear_dimming(ctx); + } + // Jump targets are sorted based on their distance to the current cursor. + jump_targets = sort_jump_targets(ctx, jump_targets); + if extend_selection { + jump_targets = extend_jump_selection(ctx, jump_targets); + } + if jump_targets.len() == 1 { + jump_to(ctx, jump_targets[0], extend_selection); + return clear_dimming(ctx); + } + let root = TrieNode::build(JUMP_KEYS, jump_targets); + show_key_annotations_with_callback(ctx, root.generate(), move |ctx, event| { + handle_key_event(root, ctx, event, extend_selection) + }); } diff --git a/helix-term/src/commands/jump.rs b/helix-term/src/commands/jump.rs new file mode 100644 index 0000000000000..b6f96c6648f7a --- /dev/null +++ b/helix-term/src/commands/jump.rs @@ -0,0 +1,9 @@ +pub(crate) mod annotate; +pub(crate) mod locations; +pub(crate) mod score; +pub(crate) mod sequencer; + +pub use annotate::{apply_dimming, clear_dimming, show_key_annotations_with_callback, JUMP_KEYS}; +pub use locations::{find_all_char_occurrences, find_all_identifiers_in_view}; +pub use score::sort_jump_targets; +pub use sequencer::{JumpAnnotation, JumpSequence, JumpSequencer, TrieNode}; diff --git a/helix-term/src/commands/jump/annotate.rs b/helix-term/src/commands/jump/annotate.rs new file mode 100644 index 0000000000000..9ca7ef786b141 --- /dev/null +++ b/helix-term/src/commands/jump/annotate.rs @@ -0,0 +1,106 @@ +use super::JumpAnnotation; +use crate::commands::Context; +use helix_view::{ + decorations::{TextAnnotation, TextAnnotationKind}, + graphics::{Color, Modifier, Style}, + input::KeyEvent, + Document, Theme, +}; + +pub const JUMP_KEYS: &[u8] = b"etovxqpdygfblzhckisuran"; + +fn annotate(doc: &Document, theme: &Theme, jumps: Vec) -> Vec { + let text = doc.text().slice(..); + + let single_style = theme.try_get("ui.jump.single").unwrap_or_else(|| { + Style::default() + .fg(Color::Rgb(0xff, 0x00, 0x7c)) + .add_modifier(Modifier::BOLD) + }); + let multi_first_style = theme.try_get("ui.jump.multi-first").unwrap_or_else(|| { + Style::default() + .fg(Color::Rgb(0x00, 0xdf, 0xff)) + .add_modifier(Modifier::BOLD) + }); + let multi_rest_style = theme + .try_get("ui.jump.multi-rest") + .unwrap_or_else(|| Style::default().fg(Color::Rgb(0x2b, 0x8d, 0xb3))); + + let mut annotations: Vec<_> = jumps + .into_iter() + .flat_map(|jump| { + let line = text.char_to_line(jump.loc); + let column = jump.loc - text.line_to_char(line); + let style = match jump.keys.len() { + 2.. => multi_first_style, + _ => single_style, + }; + let (first, rest) = jump.keys.split_at(1); + let (first, rest) = (String::from(first), String::from(rest)); + let mut annotations = vec![TextAnnotation { + text: first.into(), + style, + line, + kind: TextAnnotationKind::Overlay(column), + }]; + if !rest.is_empty() { + annotations.push(TextAnnotation { + text: rest.into(), + style: multi_rest_style, + line, + kind: TextAnnotationKind::Overlay(column + 1), + }); + } + annotations.into_iter() + }) + .collect(); + annotations.sort_by(|a, b| { + if let (TextAnnotationKind::Overlay(col1), TextAnnotationKind::Overlay(col2)) = + (a.kind, b.kind) + { + return col1.cmp(&col2); + } + unreachable!(); + }); + annotations +} + +pub fn apply_dimming(ctx: &mut Context) { + let (view, doc) = current!(ctx.editor); + let first_line = view.offset.row; + let num_lines = view.last_line(doc) - first_line + 1; + + let lines: Vec<_> = doc + .text() + .lines_at(first_line) + .zip(first_line..) + .take(num_lines) + .map(|(line, idx)| TextAnnotation { + text: String::from(line).into(), + style: Style::default().fg(Color::Rgb(0x66, 0x66, 0x66)), + line: idx, + kind: TextAnnotationKind::Overlay(0), + }) + .collect(); + doc.push_text_annotations("jump_mode", lines.into_iter()); +} + +pub fn clear_dimming(ctx: &mut Context) { + doc_mut!(ctx.editor).clear_text_annotations("jump_mode"); +} + +pub fn show_key_annotations_with_callback( + ctx: &mut Context, + annotations: Vec, + on_key_press_callback: F, +) where + F: FnOnce(&mut Context, KeyEvent) + 'static, +{ + apply_dimming(ctx); + let doc = doc_mut!(ctx.editor); + doc.push_text_annotations( + "jump_mode", + annotate(doc, &ctx.editor.theme, annotations).into_iter(), + ); + ctx.on_next_key(on_key_press_callback); +} diff --git a/helix-term/src/commands/jump/locations.rs b/helix-term/src/commands/jump/locations.rs new file mode 100644 index 0000000000000..41eeffa18e2a0 --- /dev/null +++ b/helix-term/src/commands/jump/locations.rs @@ -0,0 +1,66 @@ +use crate::commands::Context; +use helix_core::{chars::char_is_word, graphemes, movement, visual_coords_at_pos, Position, Range}; + +fn view_boundary(cx: &Context) -> (usize, usize) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + + let start_idx = text.line_to_char(view.offset.row); + let end_idx = text.line_to_char(view.last_line(doc) + 1); + (start_idx, end_idx) +} + +pub fn cursor_at(cx: &Context) -> Position { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + let cursor_at = doc.selection(view.id).primary().head; + visual_coords_at_pos(text, cursor_at, doc.tab_width()) +} + +pub fn find_all_identifiers_in_view(cx: &mut Context) -> Vec { + let (start_idx, end_idx) = view_boundary(cx); + + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let mut jump_targets: Vec = Vec::new(); + let mut next = Range::new(start_idx, start_idx); + + // If the first line in view has a single character with no trailing whitespace, + // `move_next_word_start` will skip it. Thus we need to handle this edge case here. + if graphemes::is_grapheme_boundary(text, start_idx) { + // If there is an alphanumeric character on start_idx, consider it as a target. + let c = text.chars_at(start_idx).next().unwrap_or(' '); + if char_is_word(c) { + jump_targets.push(Range::point(start_idx)); + } + } + // Find other identifiers within this view. + loop { + next = movement::move_next_word_start(text, next, 1); + // next.anchor points to the start of the identifier, and next.head + // points to the end of the identifier. We want the cursor to be at + // the start of the identifier, so swap the head and anchor. + let (head, anchor) = (next.anchor, next.head); + if anchor >= end_idx { + break; + } + let c = text.chars_at(head).next().unwrap(); + if !char_is_word(c) || !view.is_cursor_in_view(head, doc, 0) { + continue; + } + jump_targets.push(Range::new(anchor, head)); + } + jump_targets +} + +pub fn find_all_char_occurrences(cx: &Context, key: u8) -> Vec { + let (start_idx, end_idx) = view_boundary(cx); + let doc = doc!(cx.editor); + let text = doc.text().slice(..); + + (start_idx..end_idx) + .filter(|&idx| key == text.char(idx) as u8) + .map(Range::point) + .collect() +} diff --git a/helix-term/src/commands/jump/score.rs b/helix-term/src/commands/jump/score.rs new file mode 100644 index 0000000000000..0438dbcbc0a8d --- /dev/null +++ b/helix-term/src/commands/jump/score.rs @@ -0,0 +1,36 @@ +use helix_core::{visual_coords_at_pos, Position, Range}; + +use crate::commands::Context; + +use super::locations::cursor_at; + +fn manhattan_distance(p1: &Position, p2: &Position) -> usize { + // Make it easier to travel along the y-axis + let x_weight = 10; + p1.row.abs_diff(p2.row) + p1.col.abs_diff(p2.col) * x_weight +} + +struct ScoredTarget { + range: Range, + distance: usize, +} + +pub fn sort_jump_targets(cx: &mut Context, jump_targets: Vec) -> Vec { + // Each jump target will be scored based on its distance to the cursor position. + let cursor = cursor_at(cx); + let doc = doc!(cx.editor); + let text = doc.text().slice(..); + let mut jump_targets: Vec<_> = jump_targets + .into_iter() + .map(|range| ScoredTarget { + range, + distance: manhattan_distance( + &cursor, + &visual_coords_at_pos(text, range.head, doc.tab_width()), + ), + }) + .collect(); + // Sort by the distance (shortest first) + jump_targets.sort_by(|a, b| a.distance.cmp(&b.distance)); + jump_targets.iter().map(|a| a.range).collect() +} diff --git a/helix-term/src/commands/jump/sequencer.rs b/helix-term/src/commands/jump/sequencer.rs new file mode 100644 index 0000000000000..1b7dad2188c00 --- /dev/null +++ b/helix-term/src/commands/jump/sequencer.rs @@ -0,0 +1,303 @@ +use std::collections::VecDeque; + +use helix_core::Range; + +/// Sequence of characters that need to be pressed to reach a destination. +#[derive(Debug, Eq, PartialEq)] +pub struct JumpSequence(Vec); + +impl JumpSequence { + /// Prefix the current sequence with the given character + pub fn prefix(&mut self, c: u8) { + assert!(c.is_ascii()); + // We are appending and not inserting to the 0th element, because the + // consumation order is LIFO. + self.0.push(c); + } +} + +impl From for JumpSequence { + fn from(key: u8) -> Self { + Self(vec![key]) + } +} + +impl From for String { + fn from(mut seq: JumpSequence) -> Self { + seq.0.reverse(); + String::from_utf8(seq.0).expect("Jump keys should be ascii letters") + } +} + +#[derive(Debug)] +pub struct JumpAnnotation { + // Starting location of a jump annotation. + pub loc: usize, + pub keys: String, +} + +/// Generator that generates a list of jump annotations +pub trait JumpSequencer { + /// Generates a list of JumpSequence. The order of the JumpSequence should + /// be highest priority first, lowest priority last + fn generate(&self) -> Vec; + // Advance the state machine + fn choose(self, key: u8) -> Option>; + // Returns Some if the sequencer is in a terminal state. None otherwise. + // The value represents the target position we should jump to. + fn try_get_range(&self) -> Option; +} + +#[derive(Debug)] +pub struct TrieNode { + key: u8, + children: Vec, + // Some if leaf node. None otherwise. + range_in_text: Option, +} + +impl From for TrieNode { + fn from(key: u8) -> Self { + TrieNode { + key, + children: vec![], + range_in_text: None, // Calculation happens after trie construction + } + } +} + +fn make_trie_children(keys: &[u8]) -> Vec { + keys.iter().map(|c| TrieNode::from(*c)).collect() +} + +fn attach_jump_targets_to_leaves( + node: &mut TrieNode, + jump_targets: &mut impl Iterator, +) { + if node.children.is_empty() { + node.range_in_text = jump_targets.next(); + return; + } + for child in node.children.iter_mut() { + attach_jump_targets_to_leaves(child, jump_targets); + } +} + +impl TrieNode { + pub fn build(keys: &[u8], jump_targets: Vec) -> Self { + assert!(!keys.is_empty()); + // Invalid key for the root node since it doesn't represent a key + let mut root = TrieNode::from(0); + let n = jump_targets.len(); + if n <= keys.len() { + root.children = make_trie_children(&keys[0..n]); + attach_jump_targets_to_leaves(&mut root, &mut jump_targets.into_iter()); + return root; + } + root.children = make_trie_children(keys); + + // Running BFS, expanding trie nodes along the way. + let mut queue = VecDeque::with_capacity(root.children.len()); + // Reverse-iterating the children such that the last key gets expanded first + queue.extend(root.children.iter_mut().rev()); + + let mut remaining = n - keys.len(); + loop { + let mut trie = queue.pop_front().unwrap(); + if remaining < keys.len() { + // We need to make remaining + 1 children because the current leaf + // node will no longer be a leaf node + trie.children = make_trie_children(&keys[0..remaining + 1]); + break; + } + trie.children = make_trie_children(keys); + // subtract 1 to account for the no-longer-leaf node + remaining -= keys.len() - 1; + queue.extend(trie.children.iter_mut().rev()); + } + attach_jump_targets_to_leaves(&mut root, &mut jump_targets.into_iter()); + root + } +} + +fn depth_first_search(node: &TrieNode) -> Vec<(Range, JumpSequence)> { + let key = node.key; + if node.children.is_empty() { + return vec![(node.range_in_text.unwrap(), JumpSequence::from(key))]; + } + node.children + .iter() + .flat_map(|child| { + depth_first_search(child).into_iter().map(|(pos, mut v)| { + v.prefix(key); + (pos, v) + }) + }) + .collect() +} + +impl JumpSequencer for TrieNode { + fn generate(&self) -> Vec { + if self.children.is_empty() { + return vec![]; + } + self.children + .iter() + .flat_map(|child| depth_first_search(child).into_iter()) + .map(|(range, sequence)| JumpAnnotation { + loc: range.head, + keys: String::from(sequence), + }) + .collect() + } + + fn choose(self, key: u8) -> Option> { + for child in self.children { + if child.key == key { + return Some(Box::new(child)); + } + } + None + } + + fn try_get_range(&self) -> Option { + self.range_in_text + } +} + +#[cfg(test)] +mod jump_tests { + use super::*; + + fn next(it: &mut std::vec::IntoIter) -> Option<(String, usize)> { + match it.next() { + Some(jump) => Some((jump.keys, jump.loc)), + None => None, + } + } + + fn iota(n: usize) -> Vec { + (0..n).map(|i| Range::point(i)).collect() + } + + #[test] + fn more_keys_than_jump_targets() { + let mut paths = TrieNode::build(b"abcdefg", iota(2)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("a"), 0))); + assert_eq!(next(&mut paths), Some((String::from("b"), 1))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn equal_number_of_keys_and_jump_targets() { + let mut paths = TrieNode::build(b"xyz", iota(3)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 0))); + assert_eq!(next(&mut paths), Some((String::from("y"), 1))); + assert_eq!(next(&mut paths), Some((String::from("z"), 2))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_1() { + let ranges = vec![9usize, 1, 5, 100] + .into_iter() + .map(|i| Range::point(i)) + .collect(); + let mut paths = TrieNode::build(b"xyz", ranges).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 9))); + assert_eq!(next(&mut paths), Some((String::from("y"), 1))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 5))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 100))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_2() { + let mut paths = TrieNode::build(b"xyz", iota(5)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 0))); + assert_eq!(next(&mut paths), Some((String::from("y"), 1))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 2))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 3))); + assert_eq!(next(&mut paths), Some((String::from("zz"), 4))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_3() { + let mut paths = TrieNode::build(b"xyz", iota(6)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 0))); + assert_eq!(next(&mut paths), Some((String::from("yx"), 1))); + assert_eq!(next(&mut paths), Some((String::from("yy"), 2))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 3))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 4))); + assert_eq!(next(&mut paths), Some((String::from("zz"), 5))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_4() { + let mut paths = TrieNode::build(b"xyz", iota(7)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 0))); + assert_eq!(next(&mut paths), Some((String::from("yx"), 1))); + assert_eq!(next(&mut paths), Some((String::from("yy"), 2))); + assert_eq!(next(&mut paths), Some((String::from("yz"), 3))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 4))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 5))); + assert_eq!(next(&mut paths), Some((String::from("zz"), 6))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_5() { + let mut paths = TrieNode::build(b"xyz", iota(8)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("xx"), 0))); + assert_eq!(next(&mut paths), Some((String::from("xy"), 1))); + assert_eq!(next(&mut paths), Some((String::from("yx"), 2))); + assert_eq!(next(&mut paths), Some((String::from("yy"), 3))); + assert_eq!(next(&mut paths), Some((String::from("yz"), 4))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 5))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 6))); + assert_eq!(next(&mut paths), Some((String::from("zz"), 7))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_6() { + let mut paths = TrieNode::build(b"xyz", iota(9)).generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("xx"), 0))); + assert_eq!(next(&mut paths), Some((String::from("xy"), 1))); + assert_eq!(next(&mut paths), Some((String::from("xz"), 2))); + assert_eq!(next(&mut paths), Some((String::from("yx"), 3))); + assert_eq!(next(&mut paths), Some((String::from("yy"), 4))); + assert_eq!(next(&mut paths), Some((String::from("yz"), 5))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 6))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 7))); + assert_eq!(next(&mut paths), Some((String::from("zz"), 8))); + assert_eq!(next(&mut paths), None); + } + + #[test] + fn more_jump_targets_than_keys_7() { + let root = TrieNode::build(b"xyz", iota(10)); + let mut paths = root.generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("xx"), 0))); + assert_eq!(next(&mut paths), Some((String::from("xy"), 1))); + assert_eq!(next(&mut paths), Some((String::from("xz"), 2))); + assert_eq!(next(&mut paths), Some((String::from("yx"), 3))); + assert_eq!(next(&mut paths), Some((String::from("yy"), 4))); + assert_eq!(next(&mut paths), Some((String::from("yz"), 5))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 6))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 7))); + assert_eq!(next(&mut paths), Some((String::from("zzx"), 8))); + assert_eq!(next(&mut paths), Some((String::from("zzy"), 9))); + assert_eq!(next(&mut paths), None); + + let node = root.choose(b'z').unwrap(); + let mut paths = node.generate().into_iter(); + assert_eq!(next(&mut paths), Some((String::from("x"), 6))); + assert_eq!(next(&mut paths), Some((String::from("y"), 7))); + assert_eq!(next(&mut paths), Some((String::from("zx"), 8))); + assert_eq!(next(&mut paths), Some((String::from("zy"), 9))); + assert_eq!(next(&mut paths), None); + } +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 6a37495eed798..2152b10369760 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -55,8 +55,8 @@ pub fn default() -> HashMap { "n" => goto_next_buffer, "p" => goto_previous_buffer, "." => goto_last_modification, - "j" => jump_mode_word, - "J" => jump_mode_search, + "j" => jump_to_identifier_label, + "J" => jump_to_char_label, }, ":" => command_mode, @@ -346,7 +346,8 @@ pub fn default() -> HashMap { "esc" => exit_select_mode, "g" => { "Goto" - "J" => extend_jump_mode_search, + "j" => jump_to_identifier_label_and_extend_selection, + "J" => jump_to_char_label_and_extend_selection, }, "v" => normal_mode,