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

Add object selection (textobjects) #385

Merged
merged 13 commits into from
Jul 3, 2021
1 change: 1 addition & 0 deletions helix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod selection;
mod state;
pub mod surround;
pub mod syntax;
pub mod textobject;
mod transaction;

pub mod unicode {
Expand Down
51 changes: 47 additions & 4 deletions helix-core/src/movement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Ran
word_move(slice, range, count, WordMotionTarget::PrevWordStart)
}

pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::PrevWordEnd)
}

pub fn move_this_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::ThisWordStart)
sudormrfbin marked this conversation as resolved.
Show resolved Hide resolved
}

pub fn move_this_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::ThisWordEnd)
}

pub fn move_this_word_prev_bound(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::ThisWordPrevBound)
}


fn word_move(slice: RopeSlice, mut range: Range, count: usize, target: WordMotionTarget) -> Range {
(0..count).fold(range, |range, _| {
slice.chars_at(range.head).range_to_target(target, range)
Expand Down Expand Up @@ -147,9 +164,14 @@ where
/// Possible targets of a word motion
#[derive(Copy, Clone, Debug)]
pub enum WordMotionTarget {
ThisWordStart,
sudormrfbin marked this conversation as resolved.
Show resolved Hide resolved
ThisWordEnd,
NextWordStart,
NextWordEnd,
PrevWordStart,
PrevWordEnd,
// like PrevWordEnd but doesn't move if already on word end
ThisWordPrevBound,
}

pub trait CharHelpers {
Expand All @@ -167,7 +189,10 @@ impl CharHelpers for Chars<'_> {
let range = origin;
// Characters are iterated forward or backwards depending on the motion direction.
let characters: Box<dyn Iterator<Item = char>> = match target {
WordMotionTarget::PrevWordStart => {
WordMotionTarget::PrevWordStart
| WordMotionTarget::ThisWordStart
| WordMotionTarget::PrevWordEnd
| WordMotionTarget::ThisWordPrevBound => {
self.next();
Box::new(from_fn(|| self.prev()))
}
Expand All @@ -176,12 +201,22 @@ impl CharHelpers for Chars<'_> {

// Index advancement also depends on the direction.
let advance: &dyn Fn(&mut usize) = match target {
WordMotionTarget::PrevWordStart => &|u| *u = u.saturating_sub(1),
WordMotionTarget::PrevWordStart
| WordMotionTarget::ThisWordStart
| WordMotionTarget::PrevWordEnd
| WordMotionTarget::ThisWordPrevBound => &|u| *u = u.saturating_sub(1),
_ => &|u| *u += 1,
};

let mut characters = characters.peekable();
let mut phase = WordMotionPhase::Start;
let mut phase = match target {
// curosr may already be at word end or word beginning, i.e. we may
// have already reached our target so check that first
WordMotionTarget::ThisWordEnd
| WordMotionTarget::ThisWordStart
| WordMotionTarget::ThisWordPrevBound => WordMotionPhase::ReachTarget,
_ => WordMotionPhase::Start,
};
let mut head = origin.head;
let mut anchor: Option<usize> = None;
let is_boundary =
Expand Down Expand Up @@ -236,14 +271,22 @@ fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>
};

match target {
WordMotionTarget::NextWordStart => {
WordMotionTarget::NextWordStart
| WordMotionTarget::PrevWordEnd
| WordMotionTarget::ThisWordPrevBound => {
((categorize_char(peek) != categorize_char(*next_peek))
&& (char_is_line_ending(*next_peek) || !next_peek.is_whitespace()))
}
WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
((categorize_char(peek) != categorize_char(*next_peek))
&& (!peek.is_whitespace() || char_is_line_ending(*next_peek)))
}
WordMotionTarget::ThisWordStart | WordMotionTarget::ThisWordEnd => {
categorize_char(peek) != categorize_char(*next_peek)
|| peek.is_whitespace()
|| char_is_line_ending(peek)
|| char_is_line_ending(*next_peek)
}
}
}

Expand Down
126 changes: 126 additions & 0 deletions helix-core/src/textobject.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
use ropey::{Rope, RopeSlice};

use crate::chars::{char_is_line_ending, char_is_whitespace};
use crate::movement;
use crate::Range;

#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum TextObject {
Around,
Inner,
}

// count doesn't do anything yet
pub fn textobject_word(
slice: RopeSlice,
range: Range,
textobject: TextObject,
count: usize,
) -> Range {
let this_word_start = movement::move_this_word_start(slice, range, count).head;
let this_word_end = movement::move_this_word_end(slice, range, count).head;

let (anchor, head);
match textobject {
TextObject::Inner => {
anchor = this_word_start;
head = this_word_end;
}
TextObject::Around => {
if slice
.get_char(this_word_end + 1)
.map_or(true, |c| char_is_line_ending(c))
{
head = this_word_end;
if slice
.get_char(this_word_start - 1)
.map_or(true, |c| char_is_line_ending(c))
{
// single word on a line
anchor = this_word_start;
} else {
// last word on a line, select the whitespace before it too
anchor = movement::move_prev_word_end(slice, range, count).head;
}
} else if char_is_whitespace(slice.char(range.head)) {
// select whole whitespace and next word
head = movement::move_next_word_end(slice, range, count).head;
anchor = movement::move_this_word_prev_bound(slice, range, count).head;
} else {
head = movement::move_next_word_start(slice, range, count).head;
anchor = movement::move_this_word_start(slice, range, count).head;
}
}
};
Range::new(anchor, head)
}

pub fn textobject_paragraph(
slice: RopeSlice,
range: Range,
textobject: TextObject,
count: usize,
) -> Range {
Range::point(0)
}

pub fn textobject_surround(
slice: RopeSlice,
range: Range,
textobject: TextObject,
ch: char,
count: usize,
) -> Range {
Range::point(0)
}

#[cfg(test)]
mod test {
use super::TextObject::*;
use super::*;

use crate::Range;
use ropey::Rope;

#[test]
fn test_textobject_word() {
let doc = Rope::from("text with chars\nmore lines\n$!@%word next ");
let slice = doc.slice(..);

// initial, textobject, final
let cases = &[
// cursor at w[i]th
((6, 6), Inner, (5, 8)), // [with]
((6, 6), Around, (5, 9)), // [with ]
// cursor at text[ ]with
((4, 4), Inner, (4, 4)), // no change
((4, 4), Around, (4, 8)), // [ with]
// cursor at [c]hars
((10, 10), Inner, (10, 14)), // [chars]
((10, 10), Around, (9, 14)), // [ chars]
// cursor at char[s]
((14, 14), Inner, (10, 14)), // [chars]
((14, 14), Around, (9, 14)), // [ chars]
// cursor at chars[\n]more
((15, 15), Inner, (15, 15)), // no change
((15, 15), Around, (15, 20)), // [\nmore ]
// cursor at [m]ore
((16, 16), Inner, (16, 19)), // [more]
((16, 16), Around, (16, 20)), // [more ]
// cursor at $!@[%]
((30, 30), Inner, (27, 30)), // [$!@%]
// ((30, 30), Around, (27, 30)), // [$!@%]
// cursor at word [ ] next
((36, 36), Inner, (36, 36)), // no change
((36, 36), Around, (35, 41)), // word[ next]
];

for &case in cases {
let (before, textobject, after) = case;
let before = Range::new(before.0, before.1);
let expected = Range::new(after.0, after.1);
let result = textobject_word(slice, before, textobject, 1);
assert_eq!(expected, result, "\n{:?}", case);
}
}
}
34 changes: 29 additions & 5 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3386,6 +3386,9 @@ fn right_bracket_mode(cx: &mut Context) {
})
}

use helix_core::surround;
use helix_core::textobject;

fn match_mode(cx: &mut Context) {
let count = cx.count;
cx.on_next_key(move |cx, event| {
Expand All @@ -3400,17 +3403,38 @@ fn match_mode(cx: &mut Context) {
'm' => match_brackets(cx),
's' => surround_add(cx),
'r' => surround_replace(cx),
'd' => {
surround_delete(cx);
let (view, doc) = current!(cx.editor);
}
'd' => surround_delete(cx),
'a' => select_textobject(cx, textobject::TextObject::Around),
'i' => select_textobject(cx, textobject::TextObject::Inner),
_ => (),
}
}
})
}

use helix_core::surround;
fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
sudormrfbin marked this conversation as resolved.
Show resolved Hide resolved
let count = cx.count();
cx.on_next_key(move |cx, event| {
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = event
{
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);

let selection = doc.selection(view.id).transform(|mut range| {
match ch {
'w' => textobject::textobject_word(text, range, objtype, count),
_ => range,
}
});

doc.set_selection(view.id, selection);
}
})

}

fn surround_add(cx: &mut Context) {
cx.on_next_key(move |cx, event| {
Expand Down