This commit is contained in:
Nik Revenco 2025-03-31 21:55:19 -07:00 committed by GitHub
commit 6e3fc353fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 451 additions and 12 deletions

View file

@ -45,6 +45,10 @@
| `extend_parent_node_start` | Extend to beginning of the parent node | select: `` <A-b> `` | | `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_till_char` | Move till next occurrence of char | normal: `` t `` |
| `find_next_char` | Move to next occurrence of char | normal: `` f `` | | `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_till_char` | Extend till next occurrence of char | select: `` t `` |
| `extend_next_char` | Extend to next occurrence of char | select: `` f `` | | `extend_next_char` | Extend to next occurrence of char | select: `` f `` |
| `till_prev_char` | Move till previous occurrence of char | normal: `` T `` | | `till_prev_char` | Move till previous occurrence of char | normal: `` T `` |

View file

@ -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` | | `E` | Move next WORD end | `move_next_long_word_end` |
| `t` | Find 'till next char | `find_till_char` | | `t` | Find 'till next char | `find_till_char` |
| `f` | Find next char | `find_next_char` | | `f` | Find next char | `find_next_char` |
| `L` | Find next 2 chars | `find_next_pair` |
| `T` | Find 'till previous char | `till_prev_char` | | `T` | Find 'till previous char | `till_prev_char` |
| `F` | Find previous char | `find_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` | | `G` | Go to line number `<n>` | `goto_line` |
| `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` | | `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` |
| `Home` | Move to the start of the line | `goto_line_start` | | `Home` | Move to the start of the line | `goto_line_start` |

View file

@ -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. // TODO: switch to std::str::Pattern when it is stable.
pub trait CharMatcher { pub trait CharMatcher {
@ -65,3 +65,179 @@ pub fn find_nth_prev(text: RopeSlice, ch: char, mut pos: usize, n: usize) -> Opt
Some(pos) 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
}
}
}

View file

@ -329,6 +329,18 @@ impl Range {
//-------------------------------- //--------------------------------
// Block-cursor methods. // 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. /// Gets the left-side position of the block cursor.
#[must_use] #[must_use]
#[inline] #[inline]

View file

@ -343,6 +343,10 @@ impl MappableCommand {
extend_parent_node_start, "Extend to beginning of the parent node", extend_parent_node_start, "Extend to beginning of the parent node",
find_till_char, "Move till next occurrence of char", find_till_char, "Move till next occurrence of char",
find_next_char, "Move to 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_till_char, "Extend till next occurrence of char",
extend_next_char, "Extend to next occurrence of char", extend_next_char, "Extend to next occurrence of char",
till_prev_char, "Move till previous occurrence of char", till_prev_char, "Move till previous occurrence of char",
@ -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] #[inline]
fn find_char_impl<F, M: CharMatcher + Clone + Copy>( fn find_char_impl<F, M: CharMatcher + Clone + Copy>(
@ -1566,20 +1646,12 @@ fn find_char_impl<F, M: CharMatcher + Clone + Copy>(
) where ) where
F: Fn(RopeSlice, M, usize, usize, bool) -> Option<usize> + 'static, F: Fn(RopeSlice, M, usize, usize, bool) -> Option<usize> + 'static,
{ {
// TODO: make this grapheme-aware
let (view, doc) = current!(editor); let (view, doc) = current!(editor);
let text = doc.text().slice(..); let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone().transform(|range| { let selection = doc.selection(view.id).clone().transform(|range| {
// TODO: use `Range::cursor()` here instead. However, that works in terms of search_fn(text, char_matcher, range.char_cursor(), count, inclusive).map_or(range, |pos| {
// 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| {
if extend { if extend {
range.put_cursor(text, pos, true) range.put_cursor(text, pos, true)
} else { } else {
@ -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) { fn find_till_char(cx: &mut Context) {
find_char(cx, Direction::Forward, false, false); find_char(cx, Direction::Forward, false, false);
} }

View file

@ -11,6 +11,9 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"k" | "up" => move_visual_line_up, "k" | "up" => move_visual_line_up,
"l" | "right" => move_char_right, "l" | "right" => move_char_right,
"L" => find_next_pair,
"H" => find_prev_pair,
"t" => find_till_char, "t" => find_till_char,
"f" => find_next_char, "f" => find_next_char,
"T" => till_prev_char, "T" => till_prev_char,
@ -343,6 +346,9 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"k" | "up" => extend_visual_line_up, "k" | "up" => extend_visual_line_up,
"l" | "right" => extend_char_right, "l" | "right" => extend_char_right,
"L" => extend_next_pair,
"H" => extend_prev_pair,
"w" => extend_next_word_start, "w" => extend_next_word_start,
"b" => extend_prev_word_start, "b" => extend_prev_word_start,
"e" => extend_next_word_end, "e" => extend_next_word_end,

View file

@ -6,6 +6,157 @@ mod insert;
mod movement; mod movement;
mod write; mod write;
#[tokio::test(flavor = "multi_thread")]
async fn find_prev_pair() -> anyhow::Result<()> {
// finds prev pair of 2 letters
test((
indoc! {"\
hi
hi
#[hi|]#
hi"},
"Hhi",
indoc! {"\
hi
#[|hi]#
hi
hi"},
))
.await?;
// finds 3rd prev pair of 2 letters
test((
indoc! {"\
hi
hi
hi
#[hi|]#"},
"3Hhi",
indoc! {"\
#[|hi]#
hi
hi
hi"},
))
.await?;
// finds prev two newlines
test((
indoc! {"\
hi
hi
hi
#[hi|]#"},
"H<ret><ret>",
indoc! {"\
hi
hi
hi#[|
]#hi"},
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn find_next_pair() -> anyhow::Result<()> {
// finds next pair of 2 letters (non-extend)
test((
indoc! {"\
#[hi|]#
hi
hi
hi"},
"Lhi",
indoc! {"\
hi
#[hi|]#
hi
hi"},
))
.await?;
// finds next pair of 2 letters (extend)
test((
indoc! {"\
hi
#[hi|]#
hi
hi"},
"vLhi",
indoc! {"\
hi
#[hi
hi|]#
hi"},
))
.await?;
// finds 3rd next pair of 2 letters (non-extend)
test((
indoc! {"\
#[hi|]#
hi
hi
hi"},
"3Lhi",
indoc! {"\
hi
hi
hi
#[hi|]#"},
))
.await?;
// finds 3rd next pair of 2 letters (extend)
test((
indoc! {"\
#[hi|]#
hi
hi
hi"},
"v3Lhi",
indoc! {"\
#[hi
hi
hi
hi|]#"},
))
.await?;
// finds next char followed by newline
test((
indoc! {"\
#[hi|]#
hi
hi
hi"},
"L<ret>h",
indoc! {"\
hi#[
h|]#i
hi
hi"},
))
.await?;
// finds next char followed by newline
test((
indoc! {"\
#[hi|]#
hi
hi
hi"},
"L<ret><ret>",
indoc! {"\
hi
hi
hi#[
|]#hi"},
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn search_selection_detect_word_boundaries_at_eof() -> anyhow::Result<()> { async fn search_selection_detect_word_boundaries_at_eof() -> anyhow::Result<()> {
// <https://github.com/helix-editor/helix/issues/12609> // <https://github.com/helix-editor/helix/issues/12609>