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 surround keybinds #320

Merged
merged 8 commits into from
Jun 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 14 additions & 2 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
| `F` | Find previous char |
| `Home` | Move to the start of the line |
| `End` | Move to the end of the line |
| `m` | Jump to matching bracket |
| `PageUp` | Move page up |
| `PageDown` | Move page down |
| `Ctrl-u` | Move half page up |
Expand All @@ -30,6 +29,7 @@
| `Ctrl-o` | Jump backward on the jumplist |
| `v` | Enter [select (extend) mode](#select--extend-mode) |
| `g` | Enter [goto mode](#goto-mode) |
| `m` | Enter [match mode](#match-mode)
| `:` | Enter command mode |
| `z` | Enter [view mode](#view-mode) |
| `Ctrl-w` | Enter [window mode](#window-mode) (maybe will be remove for spc w w later) |
Expand Down Expand Up @@ -70,7 +70,7 @@
| `Alt-;` | Flip selection cursor and anchor |
| `%` | Select entire file |
| `x` | Select current line, if already selected, extend to next line |
| `` | Expand selection to parent syntax node TODO: pick a key |
| | Expand selection to parent syntax node TODO: pick a key |
| `J` | join lines inside selection |
| `K` | keep selections matching the regex TODO: overlapped by hover help |
| `Space` | keep only the primary selection TODO: overlapped by space mode |
Expand Down Expand Up @@ -144,6 +144,18 @@ Jumps to various locations.
| `i` | Go to implementation |
| `a` | Go to the last accessed/alternate file |

## Match mode

Enter this mode using `m` from normal mode. See the relavant section
in [Usage](./usage.md#surround) for an explanation about surround usage.

| Key | Description |
| ----- | ----------- |
| `m` | Goto matching bracket |
| `s` `<char>` | Surround current selection with `<char>` |
| `r` `<from><to>` | Replace surround character `<from>` with `<to>` |
| `d` `<char>` | Delete surround character `<char>` |

## Object mode

TODO: Mappings for selecting syntax nodes (a superset of `[`).
Expand Down
25 changes: 25 additions & 0 deletions book/src/usage.md
Original file line number Diff line number Diff line change
@@ -1 +1,26 @@
# Usage

(Currently not fully documented, see the [keymappings](./keymap.md) list for more.)

## Surround

Functionality similar to [vim-surround](https://github.com/tpope/vim-surround) is built into
helix. The keymappings have been inspired from [vim-sandwich](https://github.com/machakann/vim-sandwich):

![surround demo](https://user-images.githubusercontent.com/23398472/122865801-97073180-d344-11eb-8142-8f43809982c6.gif)

- `ms` - Add surround characters
- `mr` - Replace surround characters
- `md` - Delete surround characters

`ms` acts on a selection, so select the text first and use `ms<char>`. `mr` and `md` work
on the closest pairs found and selections are not required; use counts to act in outer pairs.

It can also act on multiple seletions (yay!). For example, to change every occurance of `(use)` to `[use]`:

- `%` to select the whole file
- `s` to split the selections on a search term
- Input `use` and hit Enter
- `mr([` to replace the parens with square brackets

Multiple characters are currently not supported, but planned.
1 change: 1 addition & 0 deletions helix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod register;
pub mod search;
pub mod selection;
mod state;
pub mod surround;
pub mod syntax;
mod transaction;

Expand Down
157 changes: 157 additions & 0 deletions helix-core/src/surround.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
use crate::{search, Selection};
use ropey::RopeSlice;

pub const PAIRS: &[(char, char)] = &[
('(', ')'),
('[', ']'),
('{', '}'),
('<', '>'),
('«', '»'),
('「', '」'),
('(', ')'),
];

/// Given any char in [PAIRS], return the open and closing chars. If not found in
/// [PAIRS] return (ch, ch).
///
/// ```
/// use helix_core::surround::get_pair;
///
/// assert_eq!(get_pair('['), ('[', ']'));
/// assert_eq!(get_pair('}'), ('{', '}'));
/// assert_eq!(get_pair('"'), ('"', '"'));
/// ```
pub fn get_pair(ch: char) -> (char, char) {
PAIRS
.iter()
.find(|(open, close)| *open == ch || *close == ch)
.copied()
.unwrap_or((ch, ch))
}

/// Find the position of surround pairs of `ch` which can be either a closing
/// or opening pair. `n` will skip n - 1 pairs (eg. n=2 will discard (only)
/// the first pair found and keep looking)
pub fn find_nth_pairs_pos(
text: RopeSlice,
ch: char,
pos: usize,
n: usize,
) -> Option<(usize, usize)> {
let (open, close) = get_pair(ch);
// find_nth* do not consider current character; +1/-1 to include them
let open_pos = search::find_nth_prev(text, open, pos + 1, n, true)?;
let close_pos = search::find_nth_next(text, close, pos - 1, n, true)?;

Some((open_pos, close_pos))
}

/// Find position of surround characters around every cursor. Returns None
/// if any positions overlap. Note that the positions are in a flat Vec.
/// Use get_surround_pos().chunks(2) to get matching pairs of surround positions.
/// `ch` can be either closing or opening pair.
pub fn get_surround_pos(
text: RopeSlice,
selection: &Selection,
ch: char,
skip: usize,
) -> Option<Vec<usize>> {
let mut change_pos = Vec::new();

for range in selection {
let (open_pos, close_pos) = find_nth_pairs_pos(text, ch, range.head, skip)?;
if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) {
return None;
}
change_pos.extend_from_slice(&[open_pos, close_pos]);
}
Some(change_pos)
}

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

use ropey::Rope;
use smallvec::SmallVec;

#[test]
fn test_find_nth_pairs_pos() {
let doc = Rope::from("some (text) here");
let slice = doc.slice(..);

// cursor on [t]ext
assert_eq!(find_nth_pairs_pos(slice, '(', 6, 1), Some((5, 10)));
assert_eq!(find_nth_pairs_pos(slice, ')', 6, 1), Some((5, 10)));
// cursor on so[m]e
assert_eq!(find_nth_pairs_pos(slice, '(', 2, 1), None);
// cursor on bracket itself
assert_eq!(find_nth_pairs_pos(slice, '(', 5, 1), Some((5, 10)));
}

#[test]
fn test_find_nth_pairs_pos_skip() {
let doc = Rope::from("(so (many (good) text) here)");
let slice = doc.slice(..);

// cursor on go[o]d
assert_eq!(find_nth_pairs_pos(slice, '(', 13, 1), Some((10, 15)));
assert_eq!(find_nth_pairs_pos(slice, '(', 13, 2), Some((4, 21)));
assert_eq!(find_nth_pairs_pos(slice, '(', 13, 3), Some((0, 27)));
}

#[test]
fn test_find_nth_pairs_pos_mixed() {
let doc = Rope::from("(so [many {good} text] here)");
let slice = doc.slice(..);

// cursor on go[o]d
assert_eq!(find_nth_pairs_pos(slice, '{', 13, 1), Some((10, 15)));
assert_eq!(find_nth_pairs_pos(slice, '[', 13, 1), Some((4, 21)));
assert_eq!(find_nth_pairs_pos(slice, '(', 13, 1), Some((0, 27)));
}

#[test]
fn test_get_surround_pos() {
let doc = Rope::from("(some) (chars)\n(newline)");
let slice = doc.slice(..);
let selection = Selection::new(
SmallVec::from_slice(&[Range::point(2), Range::point(9), Range::point(20)]),
0,
);

// cursor on s[o]me, c[h]ars, newl[i]ne
assert_eq!(
get_surround_pos(slice, &selection, '(', 1)
.unwrap()
.as_slice(),
&[0, 5, 7, 13, 15, 23]
);
}

#[test]
fn test_get_surround_pos_bail() {
let doc = Rope::from("[some]\n(chars)xx\n(newline)");
let slice = doc.slice(..);

let selection =
Selection::new(SmallVec::from_slice(&[Range::point(2), Range::point(9)]), 0);

// cursor on s[o]me, c[h]ars
assert_eq!(
get_surround_pos(slice, &selection, '(', 1),
None // different surround chars
);

let selection = Selection::new(
SmallVec::from_slice(&[Range::point(14), Range::point(24)]),
0,
);
// cursor on [x]x, newli[n]e
assert_eq!(
get_surround_pos(slice, &selection, '(', 1),
None // overlapping surround chars
);
}
}
128 changes: 127 additions & 1 deletion helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ impl Command {
space_mode,
view_mode,
left_bracket_mode,
right_bracket_mode
right_bracket_mode,
match_mode
);
}

Expand Down Expand Up @@ -3311,6 +3312,131 @@ fn right_bracket_mode(cx: &mut Context) {
})
}

fn match_mode(cx: &mut Context) {
let count = cx.count;
cx.on_next_key(move |cx, event| {
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = event
{
// FIXME: count gets reset because of cx.on_next_key()
cx.count = count;
match ch {
'm' => match_brackets(cx),
's' => surround_add(cx),
'r' => surround_replace(cx),
'd' => {
surround_delete(cx);
let (view, doc) = current!(cx.editor);
}
_ => (),
}
}
})
}

use helix_core::surround;

fn surround_add(cx: &mut Context) {
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);
let (open, close) = surround::get_pair(ch);

let mut changes = Vec::new();
for (i, range) in selection.iter().enumerate() {
let from = range.from();
let line = text.char_to_line(range.to());
let max_to = doc.text().len_chars().saturating_sub(
get_line_ending(&text.line(line))
.map(|le| le.len_chars())
.unwrap_or(0),
);
let to = std::cmp::min(range.to() + 1, max_to);

changes.push((from, from, Some(Tendril::from_char(open))));
changes.push((to, to, Some(Tendril::from_char(close))));
}

let transaction = Transaction::change(doc.text(), changes.into_iter());
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view.id);
}
})
}

fn surround_replace(cx: &mut Context) {
let count = cx.count();
cx.on_next_key(move |cx, event| {
if let KeyEvent {
code: KeyCode::Char(from),
..
} = event
{
cx.on_next_key(move |cx, event| {
if let KeyEvent {
code: KeyCode::Char(to),
..
} = event
{
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id);

let change_pos = match surround::get_surround_pos(text, selection, from, count)
{
Some(c) => c,
None => return,
};

let (open, close) = surround::get_pair(to);
let transaction = Transaction::change(
doc.text(),
change_pos.iter().enumerate().map(|(i, &pos)| {
let ch = if i % 2 == 0 { open } else { close };
(pos, pos + 1, Some(Tendril::from_char(ch)))
}),
);
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view.id);
}
});
}
})
}

fn surround_delete(cx: &mut Context) {
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);

let change_pos = match surround::get_surround_pos(text, selection, ch, count) {
Some(c) => c,
None => return,
};

let transaction =
Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None)));
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view.id);
}
})
}

impl fmt::Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Command(name, _) = self;
Expand Down
12 changes: 1 addition & 11 deletions helix-term/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,17 +200,7 @@ impl Default for Keymaps {
// extend_to_whole_line, crop_to_whole_line


key!('m') => Command::match_brackets,
// TODO: refactor into
// key!('m') => commands::select_to_matching,
// key!('M') => commands::back_select_to_matching,
// select mode extend equivalents

// key!('.') => commands::repeat_insert,
// repeat_select

// TODO: figure out what key to use
// key!('[') => Command::expand_selection, ??
key!('m') => Command::match_mode,
key!('[') => Command::left_bracket_mode,
key!(']') => Command::right_bracket_mode,

Expand Down
Loading