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

feat: 2-character "find next / prev pair" commands similar to f and F #12888

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions book/src/generated/static-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
| `extend_parent_node_start` | Extend to beginning of the parent node | select: `` <A-b> `` |
| `find_till_char` | Move till next occurrence of char | normal: `` t `` |
| `find_next_char` | Move to next occurrence of char | normal: `` f `` |
| `find_next_pair` | Move to next occurrence of 2 chars | normal: `` L `` |
| `find_prev_pair` | Move to next occurrence of 2 chars | normal: `` H `` |
| `extend_next_pair` | Extend to next occurrence of 2 chars | select: `` L `` |
| `extend_prev_pair` | Extend to next occurrence of 2 chars | select: `` H `` |
| `extend_till_char` | Extend till next occurrence of char | select: `` t `` |
| `extend_next_char` | Extend to next occurrence of char | select: `` f `` |
| `till_prev_char` | Move till previous occurrence of char | normal: `` T `` |
Expand Down
2 changes: 2 additions & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ Normal mode is the default mode when you launch helix. You can return to it from
| `E` | Move next WORD end | `move_next_long_word_end` |
| `t` | Find 'till next char | `find_till_char` |
| `f` | Find next char | `find_next_char` |
| `L` | Find next 2 chars | `find_next_pair` |
| `T` | Find 'till previous char | `till_prev_char` |
| `F` | Find previous char | `find_prev_char` |
| `H` | Find prev 2 chars | `find_prev_pair` |
| `G` | Go to line number `<n>` | `goto_line` |
| `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` |
| `Home` | Move to the start of the line | `goto_line_start` |
Expand Down
178 changes: 177 additions & 1 deletion helix-core/src/search.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::RopeSlice;
use crate::{line_ending::line_end_char_index, movement::Direction, RopeSlice};

// TODO: switch to std::str::Pattern when it is stable.
pub trait CharMatcher {
Expand Down Expand Up @@ -65,3 +65,179 @@ pub fn find_nth_prev(text: RopeSlice, ch: char, mut pos: usize, n: usize) -> Opt

Some(pos)
}

#[derive(Copy, Clone)]
pub enum PairMatcher<'a> {
Char(char),
LineEnding(&'a str),
}

pub fn find_nth_pair(
text: RopeSlice,
pair_matcher_left: PairMatcher,
pair_matcher_right: PairMatcher,
pos: usize,
n: usize,
direction: Direction,
) -> Option<usize> {
if pos >= text.len_chars() || n == 0 {
return None;
}

let is_forward = direction == Direction::Forward;
let direction_multiplier = if is_forward { 1 } else { -1 };

match (pair_matcher_left, pair_matcher_right) {
(PairMatcher::Char(ch_left), PairMatcher::Char(ch_right)) => {
let chars = text.chars_at(pos);

let mut chars = if is_forward {
chars.peekable()
} else {
chars.reversed().peekable()
};

let mut offset = 0;

for _ in 0..n {
loop {
let ch_next = chars.next()?;
let ch_peek = chars.peek()?;

offset += 1;

let matches_char = if is_forward {
ch_left == ch_next && ch_right == *ch_peek
} else {
ch_right == ch_next && ch_left == *ch_peek
};

if matches_char {
break;
}
}
}

let offs = offset * direction_multiplier;
let new_pos: usize = (pos as isize + offs)
.try_into()
.expect("Character offset cannot exceed character count");

Some(new_pos - 1)
}
(PairMatcher::Char(ch_left), PairMatcher::LineEnding(eol)) => {
let start_line = text.char_to_line(pos);
let start_line = if pos >= line_end_char_index(&text, start_line) {
// if our cursor is currently on a character just before the eol, or on the eol
// we start searching from the next line, instead of from the current line.
start_line + eol.len()
} else {
start_line
};

let mut lines = if is_forward {
text.lines_at(start_line).enumerate()
} else {
text.lines_at(start_line).reversed().enumerate()
};

if !is_forward {
// skip the line we are currently on when going backward
lines.next();
}

let mut matched_count = 0;
for (traversed_lines, _line) in lines {
let current_line = (start_line as isize
+ (traversed_lines as isize * direction_multiplier))
as usize;

let ch_opposite_eol_i = if is_forward {
line_end_char_index(&text, current_line).saturating_sub(eol.len())
} else {
text.line_to_char(current_line)
};

let ch_opposite_eol = text.char(ch_opposite_eol_i);

if ch_opposite_eol == ch_left {
matched_count += 1;
if matched_count == n {
return Some(ch_opposite_eol_i - if is_forward { 0 } else { 1 });
}
}
}

None
}
(PairMatcher::LineEnding(eol), PairMatcher::Char(ch_right)) => {
// Search starting from the beginning of the next or previous line
let start_line = text.char_to_line(pos) + (is_forward as usize);

let lines = if is_forward {
text.lines_at(start_line).enumerate()
} else {
text.lines_at(start_line).reversed().enumerate()
};

let mut matched_count = 0;
for (traversed_lines, _line) in lines {
let current_line = (start_line as isize
+ (traversed_lines as isize * direction_multiplier))
as usize;

let ch_opposite_eol_i = if is_forward {
// eol, THEN character at the beginning of the current line
text.line_to_char(current_line)
} else {
// character at the end of the previous line, THEN eol
line_end_char_index(&text, current_line - 1) - eol.len()
};

let ch_opposite_eol = text.get_char(ch_opposite_eol_i)?;

if ch_opposite_eol == ch_right {
matched_count += 1;
if matched_count == n {
return Some(ch_opposite_eol_i - (is_forward as usize));
}
}
}

None
}
(PairMatcher::LineEnding(eol), PairMatcher::LineEnding(_)) => {
// Search starting from the beginning of the
// line after the current one
let start_line = text.char_to_line(pos) + 1;

let mut lines = if is_forward {
text.lines_at(start_line).enumerate()
} else {
text.lines_at(start_line).reversed().enumerate()
};

if !is_forward {
// skip the line we are currently on when going backward
lines.next();
}

let mut matched_count = 0;
for (traversed_lines, _line) in lines {
let current_line = (start_line as isize
+ (traversed_lines as isize * direction_multiplier))
as usize;
let current_line = text.line_to_char(current_line);
let current_line_end = current_line + eol.len();
if text.slice(current_line..current_line_end).as_str()? == eol {
matched_count += 1;
if matched_count == n {
return Some(current_line - eol.len());
}
}
}

None
}
}
}
12 changes: 12 additions & 0 deletions helix-core/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,18 @@ impl Range {
//--------------------------------
// Block-cursor methods.

/// Gets the left-side position of the block cursor.
/// Not grapheme-aware. For the grapheme-aware version, use [`Range::cursor`]
#[must_use]
#[inline]
pub fn char_cursor(self) -> usize {
if self.head > self.anchor {
self.head - 1
} else {
self.head
}
}

/// Gets the left-side position of the block cursor.
#[must_use]
#[inline]
Expand Down
110 changes: 99 additions & 11 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ impl MappableCommand {
extend_parent_node_start, "Extend to beginning of the parent node",
find_till_char, "Move till next occurrence of char",
find_next_char, "Move to next occurrence of char",
find_next_pair, "Move to next occurrence of 2 chars",
find_prev_pair, "Move to next occurrence of 2 chars",
extend_next_pair, "Extend to next occurrence of 2 chars",
extend_prev_pair, "Extend to next occurrence of 2 chars",
extend_till_char, "Extend till next occurrence of char",
extend_next_char, "Extend to next occurrence of char",
till_prev_char, "Move till previous occurrence of char",
Expand Down Expand Up @@ -1553,7 +1557,83 @@ fn find_char(cx: &mut Context, direction: Direction, inclusive: bool, extend: bo
})
}

//
fn find_char_pair(cx: &mut Context, direction: Direction, extend: bool) {
// TODO: count is reset to 1 before next key so we move it into the closure here.
// Would be nice to carry over.
let count = cx.count();
let eof = doc!(cx.editor).line_ending.as_str();

// need to wait for next key
// TODO: should this be done by grapheme rather than char? For example,
// we can't properly handle the line-ending CRLF case here in terms of char.
cx.on_next_key(move |cx, event| {
let ch = match event {
KeyEvent {
code: KeyCode::Enter,
..
} => search::PairMatcher::LineEnding(eof),

KeyEvent {
code: KeyCode::Tab, ..
} => search::PairMatcher::Char('\t'),

KeyEvent {
code: KeyCode::Char(ch),
..
} => search::PairMatcher::Char(ch),
_ => return,
};

cx.on_next_key(move |cx, event| {
let ch_2 = match event {
KeyEvent {
code: KeyCode::Enter,
..
} => search::PairMatcher::LineEnding(eof),

KeyEvent {
code: KeyCode::Tab, ..
} => search::PairMatcher::Char('\t'),

KeyEvent {
code: KeyCode::Char(ch),
..
} => search::PairMatcher::Char(ch),
_ => return,
};
let motion = move |editor: &mut Editor| {
let (view, doc) = current!(editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone();
let selection = match direction {
Direction::Forward => selection.transform(|range| {
search::find_nth_pair(text, ch, ch_2, range.char_cursor(), count, direction)
.map_or(range, |pos| {
if extend {
Range::new(range.from(), pos + 2)
} else {
Range::new(pos, pos + 2)
}
})
}),
Direction::Backward => selection.transform(|range| {
search::find_nth_pair(text, ch, ch_2, range.char_cursor(), count, direction)
.map_or(range, |pos| {
if extend {
Range::new(pos + 2, range.to())
} else {
Range::new(pos + 2, pos)
}
})
}),
};
doc.set_selection(view.id, selection);
};

cx.editor.apply_motion(motion);
})
})
}

#[inline]
fn find_char_impl<F, M: CharMatcher + Clone + Copy>(
Expand All @@ -1566,20 +1646,12 @@ fn find_char_impl<F, M: CharMatcher + Clone + Copy>(
) where
F: Fn(RopeSlice, M, usize, usize, bool) -> Option<usize> + 'static,
{
// TODO: make this grapheme-aware
let (view, doc) = current!(editor);
let text = doc.text().slice(..);

let selection = doc.selection(view.id).clone().transform(|range| {
// TODO: use `Range::cursor()` here instead. However, that works in terms of
// graphemes, whereas this function doesn't yet. So we're doing the same logic
// here, but just in terms of chars instead.
let search_start_pos = if range.anchor < range.head {
range.head - 1
} else {
range.head
};

search_fn(text, char_matcher, search_start_pos, count, inclusive).map_or(range, |pos| {
search_fn(text, char_matcher, range.char_cursor(), count, inclusive).map_or(range, |pos| {
if extend {
range.put_cursor(text, pos, true)
} else {
Expand Down Expand Up @@ -1627,6 +1699,22 @@ fn find_prev_char_impl(
}
}

fn find_next_pair(cx: &mut Context) {
find_char_pair(cx, Direction::Forward, false)
}

fn find_prev_pair(cx: &mut Context) {
find_char_pair(cx, Direction::Backward, false)
}

fn extend_next_pair(cx: &mut Context) {
find_char_pair(cx, Direction::Forward, true)
}

fn extend_prev_pair(cx: &mut Context) {
find_char_pair(cx, Direction::Backward, true)
}

fn find_till_char(cx: &mut Context) {
find_char(cx, Direction::Forward, false, false);
}
Expand Down
6 changes: 6 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"k" | "up" => move_visual_line_up,
"l" | "right" => move_char_right,

"L" => find_next_pair,
"H" => find_prev_pair,

"t" => find_till_char,
"f" => find_next_char,
"T" => till_prev_char,
Expand Down Expand Up @@ -343,6 +346,9 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"k" | "up" => extend_visual_line_up,
"l" | "right" => extend_char_right,

"L" => extend_next_pair,
"H" => extend_prev_pair,

"w" => extend_next_word_start,
"b" => extend_prev_word_start,
"e" => extend_next_word_end,
Expand Down
Loading
Loading