on master help sign in
Add object selection (textobjects) (#385)
c68fe1fGokul Soumya
book/src/keymap.md+4−1
@@ -150,7 +150,8 @@ Jumps to various locations.
150## Match mode
150## Match mode
151
151
152Enter this mode using `m` from normal mode. See the relavant section
152Enter this mode using `m` from normal mode. See the relavant section
153−in [Usage](./usage.md#surround) for an explanation about surround usage.
153+in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround)
154+and [textobject](./usage.md#textobject) usage.
154
155
155| Key | Description |
156| Key | Description |
156| ----- | ----------- |
157| ----- | ----------- |
@@ -158,6 +159,8 @@ in [Usage](./usage.md#surround) for an explanation about surround usage.
158| `s` `<char>` | Surround current selection with `<char>` |
159| `s` `<char>` | Surround current selection with `<char>` |
159| `r` `<from><to>` | Replace surround character `<from>` with `<to>` |
160| `r` `<from><to>` | Replace surround character `<from>` with `<to>` |
160| `d` `<char>` | Delete surround character `<char>` |
161| `d` `<char>` | Delete surround character `<char>` |
162+| `a` `<object>` | Select around textobject |
163+| `i` `<object>` | Select inside textobject |
161
164
162## Object mode
165## Object mode
163
166
book/src/usage.md+16
@@ -24,3 +24,19 @@ It can also act on multiple seletions (yay!). For example, to change every occur
24- `mr([` to replace the parens with square brackets
24- `mr([` to replace the parens with square brackets
25
25
26Multiple characters are currently not supported, but planned.
26Multiple characters are currently not supported, but planned.
27+
28+## Textobjects
29+
30+Currently supported: `word`, `surround`.
31+
32+![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
33+
34+- `ma` - Select around the object (`va` in vim, `<alt-a>` in kakoune)
35+- `mi` - Select inside the object (`vi` in vim, `<alt-i>` in kakoune)
36+
37+| Key after `mi` or `ma` | Textobject selected |
38+| --- | --- |
39+| `w` | Word |
40+| `(`, `[`, `'`, etc | Specified surround pairs |
41+
42+Textobjects based on treesitter, like `function`, `class`, etc are planned.
helix-core/src/lib.rs+1
@@ -18,6 +18,7 @@ pub mod selection;
18mod state;
18mod state;
19pub mod surround;
19pub mod surround;
20pub mod syntax;
20pub mod syntax;
21+pub mod textobject;
21mod transaction;
22mod transaction;
22
23
23pub mod unicode {
24pub mod unicode {
helix-core/src/movement.rs+94−5
@@ -113,6 +113,10 @@ pub fn move_prev_long_word_start(slice: RopeSlice, range: Range, count: usize) -
113 word_move(slice, range, count, WordMotionTarget::PrevLongWordStart)
113 word_move(slice, range, count, WordMotionTarget::PrevLongWordStart)
114}
114}
115
115
116+pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
117+ word_move(slice, range, count, WordMotionTarget::PrevWordEnd)
118+}
119+
116fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
120fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
117 (0..count).fold(range, |range, _| {
121 (0..count).fold(range, |range, _| {
118 slice.chars_at(range.head).range_to_target(target, range)
122 slice.chars_at(range.head).range_to_target(target, range)
@@ -159,6 +163,7 @@ pub enum WordMotionTarget {
159 NextWordStart,
163 NextWordStart,
160 NextWordEnd,
164 NextWordEnd,
161 PrevWordStart,
165 PrevWordStart,
166+ PrevWordEnd,
162 // A "Long word" (also known as a WORD in vim/kakoune) is strictly
167 // A "Long word" (also known as a WORD in vim/kakoune) is strictly
163 // delimited by whitespace, and can consist of punctuation as well
168 // delimited by whitespace, and can consist of punctuation as well
164 // as alphanumerics.
169 // as alphanumerics.
@@ -181,7 +186,9 @@ impl CharHelpers for Chars<'_> {
181 fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
186 fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
182 // Characters are iterated forward or backwards depending on the motion direction.
187 // Characters are iterated forward or backwards depending on the motion direction.
183 let characters: Box<dyn Iterator<Item = char>> = match target {
188 let characters: Box<dyn Iterator<Item = char>> = match target {
189+ WordMotionTarget::PrevWordStart
184− WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
190+ | WordMotionTarget::PrevLongWordStart
191+ | WordMotionTarget::PrevWordEnd => {
185 self.next();
192 self.next();
186 Box::new(from_fn(|| self.prev()))
193 Box::new(from_fn(|| self.prev()))
187 }
194 }
@@ -190,9 +197,9 @@ impl CharHelpers for Chars<'_> {
190
197
191 // Index advancement also depends on the direction.
198 // Index advancement also depends on the direction.
192 let advance: &dyn Fn(&mut usize) = match target {
199 let advance: &dyn Fn(&mut usize) = match target {
200+ WordMotionTarget::PrevWordStart
193− WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
201+ | WordMotionTarget::PrevLongWordStart
194− &|u| *u = u.saturating_sub(1)
202+ | WordMotionTarget::PrevWordEnd => &|u| *u = u.saturating_sub(1),
195− }
196 _ => &|u| *u += 1,
203 _ => &|u| *u += 1,
197 };
204 };
198
205
@@ -265,7 +272,7 @@ fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>
265 };
272 };
266
273
267 match target {
274 match target {
268− WordMotionTarget::NextWordStart => {
275+ WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
269 is_word_boundary(peek, *next_peek)
276 is_word_boundary(peek, *next_peek)
270 && (char_is_line_ending(*next_peek) || !next_peek.is_whitespace())
277 && (char_is_line_ending(*next_peek) || !next_peek.is_whitespace())
271 }
278 }
@@ -913,6 +920,88 @@ mod test {
913 }
920 }
914 }
921 }
915
922
923+ #[test]
924+ fn test_behaviour_when_moving_to_end_of_previous_words() {
925+ let tests = array::IntoIter::new([
926+ ("Basic backward motion from the middle of a word",
927+ vec![(1, Range::new(9, 9), Range::new(9, 5))]),
928+ ("Starting from after boundary retreats the anchor",
929+ vec![(1, Range::new(0, 13), Range::new(12, 8))]),
930+ ("Jump to end of a word succeeded by whitespace",
931+ vec![(1, Range::new(10, 10), Range::new(10, 4))]),
932+ (" Jump to start of line from end of word preceded by whitespace",
933+ vec![(1, Range::new(7, 7), Range::new(7, 0))]),
934+ ("Previous anchor is irrelevant for backward motions",
935+ vec![(1, Range::new(26, 12), Range::new(12, 8))]),
936+ (" Starting from whitespace moves to first space in sequence",
937+ vec![(1, Range::new(0, 3), Range::new(3, 0))]),
938+ ("Test identifiers_with_underscores are considered a single word",
939+ vec![(1, Range::new(0, 25), Range::new(25, 4))]),
940+ ("Jumping\n \nback through a newline selects whitespace",
941+ vec![(1, Range::new(0, 13), Range::new(11, 8))]),
942+ ("Jumping to start of word from the end selects the whole word",
943+ vec![(1, Range::new(15, 15), Range::new(15, 10))]),
944+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
945+ vec![
946+ (1, Range::new(30, 30), Range::new(30, 21)),
947+ (1, Range::new(30, 21), Range::new(20, 18)),
948+ (1, Range::new(20, 18), Range::new(17, 15))
949+ ]),
950+
951+ ("... ... punctuation and spaces behave as expected",
952+ vec![
953+ (1, Range::new(0, 10), Range::new(9, 9)),
954+ (1, Range::new(9, 6), Range::new(5, 3)),
955+ ]),
956+ (".._.._ punctuation is not joined by underscores into a single block",
957+ vec![(1, Range::new(0, 5), Range::new(4, 3))]),
958+ ("Newlines\n\nare bridged seamlessly.",
959+ vec![
960+ (1, Range::new(0, 10), Range::new(7, 0)),
961+ ]),
962+ ("Jumping \n\n\n\n\nback from within a newline group selects previous block",
963+ vec![
964+ (1, Range::new(0, 13), Range::new(10, 7)),
965+ ]),
966+ ("Failed motions do not modify the range",
967+ vec![
968+ (0, Range::new(3, 0), Range::new(3, 0)),
969+ ]),
970+ ("Multiple motions at once resolve correctly",
971+ vec![
972+ (3, Range::new(23, 23), Range::new(15, 8)),
973+ ]),
974+ ("Excessive motions are performed partially",
975+ vec![
976+ (999, Range::new(40, 40), Range::new(8, 0)),
977+ ]),
978+ ("", // Edge case of moving backwards in empty string
979+ vec![
980+ (1, Range::new(0, 0), Range::new(0, 0)),
981+ ]),
982+ ("\n\n\n\n\n", // Edge case of moving backwards in all newlines
983+ vec![
984+ (1, Range::new(0, 0), Range::new(0, 0)),
985+ ]),
986+ (" \n \nJumping back through alternated space blocks and newlines selects the space blocks",
987+ vec![
988+ (1, Range::new(0, 7), Range::new(6, 4)),
989+ (1, Range::new(6, 4), Range::new(2, 0)),
990+ ]),
991+ ("Test ヒーリクス multibyte characters behave as normal characters",
992+ vec![
993+ (1, Range::new(0, 9), Range::new(9, 4)),
994+ ]),
995+ ]);
996+
997+ for (sample, scenario) in tests {
998+ for (count, begin, expected_end) in scenario.into_iter() {
999+ let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count);
1000+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
1001+ }
1002+ }
1003+ }
1004+
916 #[test]
1005 #[test]
917 fn test_behaviour_when_moving_to_end_of_next_long_words() {
1006 fn test_behaviour_when_moving_to_end_of_next_long_words() {
918 let tests = array::IntoIter::new([
1007 let tests = array::IntoIter::new([
helix-core/src/selection.rs+10
@@ -130,6 +130,16 @@ impl Range {
130 }
130 }
131}
131}
132
132
133+impl From<(usize, usize)> for Range {
134+ fn from(tuple: (usize, usize)) -> Self {
135+ Self {
136+ anchor: tuple.0,
137+ head: tuple.1,
138+ horiz: None,
139+ }
140+ }
141+}
142+
133/// A selection consists of one or more selection ranges.
143/// A selection consists of one or more selection ranges.
134/// invariant: A selection can never be empty (always contains at least primary range).
144/// invariant: A selection can never be empty (always contains at least primary range).
135#[derive(Debug, Clone, PartialEq, Eq)]
145#[derive(Debug, Clone, PartialEq, Eq)]
helix-core/src/textobject.rsnew file+319
@@ -0,0 +1,319 @@
1+use ropey::RopeSlice;
2+
3+use crate::chars::{categorize_char, char_is_line_ending, char_is_whitespace, CharCategory};
4+use crate::movement::{self, Direction};
5+use crate::surround;
6+use crate::Range;
7+
8+fn this_word_end_pos(slice: RopeSlice, pos: usize) -> usize {
9+ this_word_bound_pos(slice, pos, Direction::Forward)
10+}
11+
12+fn this_word_start_pos(slice: RopeSlice, pos: usize) -> usize {
13+ this_word_bound_pos(slice, pos, Direction::Backward)
14+}
15+
16+fn this_word_bound_pos(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize {
17+ let iter = match direction {
18+ Direction::Forward => slice.chars_at(pos + 1),
19+ Direction::Backward => {
20+ let mut iter = slice.chars_at(pos);
21+ iter.reverse();
22+ iter
23+ }
24+ };
25+
26+ match categorize_char(slice.char(pos)) {
27+ CharCategory::Eol | CharCategory::Whitespace => pos,
28+ category => {
29+ for peek in iter {
30+ let curr_category = categorize_char(peek);
31+ if curr_category != category
32+ || curr_category == CharCategory::Eol
33+ || curr_category == CharCategory::Whitespace
34+ {
35+ return pos;
36+ }
37+ pos = match direction {
38+ Direction::Forward => pos + 1,
39+ Direction::Backward => pos.saturating_sub(1),
40+ }
41+ }
42+ pos
43+ }
44+ }
45+}
46+
47+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
48+pub enum TextObject {
49+ Around,
50+ Inside,
51+}
52+
53+// count doesn't do anything yet
54+pub fn textobject_word(
55+ slice: RopeSlice,
56+ range: Range,
57+ textobject: TextObject,
58+ count: usize,
59+) -> Range {
60+ let this_word_start = this_word_start_pos(slice, range.head);
61+ let this_word_end = this_word_end_pos(slice, range.head);
62+
63+ let (anchor, head);
64+ match textobject {
65+ TextObject::Inside => {
66+ anchor = this_word_start;
67+ head = this_word_end;
68+ }
69+ TextObject::Around => {
70+ if slice
71+ .get_char(this_word_end + 1)
72+ .map_or(true, char_is_line_ending)
73+ {
74+ head = this_word_end;
75+ if slice
76+ .get_char(this_word_start.saturating_sub(1))
77+ .map_or(true, char_is_line_ending)
78+ {
79+ // single word on a line
80+ anchor = this_word_start;
81+ } else {
82+ // last word on a line, select the whitespace before it too
83+ anchor = movement::move_prev_word_end(slice, range, count).head;
84+ }
85+ } else if char_is_whitespace(slice.char(range.head)) {
86+ // select whole whitespace and next word
87+ head = movement::move_next_word_end(slice, range, count).head;
88+ anchor = movement::backwards_skip_while(slice, range.head, |c| c.is_whitespace())
89+ .map(|p| p + 1) // p is first *non* whitespace char, so +1 to get whitespace pos
90+ .unwrap_or(0);
91+ } else {
92+ head = movement::move_next_word_start(slice, range, count).head;
93+ anchor = this_word_start;
94+ }
95+ }
96+ };
97+ Range::new(anchor, head)
98+}
99+
100+pub fn textobject_surround(
101+ slice: RopeSlice,
102+ range: Range,
103+ textobject: TextObject,
104+ ch: char,
105+ count: usize,
106+) -> Range {
107+ surround::find_nth_pairs_pos(slice, ch, range.head, count)
108+ .map(|(anchor, head)| match textobject {
109+ TextObject::Inside => Range::new(anchor + 1, head.saturating_sub(1)),
110+ TextObject::Around => Range::new(anchor, head),
111+ })
112+ .unwrap_or(range)
113+}
114+
115+#[cfg(test)]
116+mod test {
117+ use super::TextObject::*;
118+ use super::*;
119+
120+ use crate::Range;
121+ use ropey::Rope;
122+
123+ #[test]
124+ fn test_textobject_word() {
125+ // (text, [(cursor position, textobject, final range), ...])
126+ let tests = &[
127+ (
128+ "cursor at beginning of doc",
129+ vec![(0, Inside, (0, 5)), (0, Around, (0, 6))],
130+ ),
131+ (
132+ "cursor at middle of word",
133+ vec![
134+ (13, Inside, (10, 15)),
135+ (10, Inside, (10, 15)),
136+ (15, Inside, (10, 15)),
137+ (13, Around, (10, 16)),
138+ (10, Around, (10, 16)),
139+ (15, Around, (10, 16)),
140+ ],
141+ ),
142+ (
143+ "cursor between word whitespace",
144+ vec![(6, Inside, (6, 6)), (6, Around, (6, 13))],
145+ ),
146+ (
147+ "cursor on word before newline\n",
148+ vec![
149+ (22, Inside, (22, 28)),
150+ (28, Inside, (22, 28)),
151+ (25, Inside, (22, 28)),
152+ (22, Around, (21, 28)),
153+ (28, Around, (21, 28)),
154+ (25, Around, (21, 28)),
155+ ],
156+ ),
157+ (
158+ "cursor on newline\nnext line",
159+ vec![(17, Inside, (17, 17)), (17, Around, (17, 22))],
160+ ),
161+ (
162+ "cursor on word after newline\nnext line",
163+ vec![
164+ (29, Inside, (29, 32)),
165+ (30, Inside, (29, 32)),
166+ (32, Inside, (29, 32)),
167+ (29, Around, (29, 33)),
168+ (30, Around, (29, 33)),
169+ (32, Around, (29, 33)),
170+ ],
171+ ),
172+ (
173+ "cursor on #$%:;* punctuation",
174+ vec![
175+ (13, Inside, (10, 15)),
176+ (10, Inside, (10, 15)),
177+ (15, Inside, (10, 15)),
178+ (13, Around, (10, 16)),
179+ (10, Around, (10, 16)),
180+ (15, Around, (10, 16)),
181+ ],
182+ ),
183+ (
184+ "cursor on punc%^#$:;.tuation",
185+ vec![
186+ (14, Inside, (14, 20)),
187+ (20, Inside, (14, 20)),
188+ (17, Inside, (14, 20)),
189+ (14, Around, (14, 20)),
190+ // FIXME: edge case
191+ // (20, Around, (14, 20)),
192+ (17, Around, (14, 20)),
193+ ],
194+ ),
195+ (
196+ "cursor in extra whitespace",
197+ vec![
198+ (9, Inside, (9, 9)),
199+ (10, Inside, (10, 10)),
200+ (11, Inside, (11, 11)),
201+ (9, Around, (9, 16)),
202+ (10, Around, (9, 16)),
203+ (11, Around, (9, 16)),
204+ ],
205+ ),
206+ (
207+ "cursor at end of doc",
208+ vec![(19, Inside, (17, 19)), (19, Around, (16, 19))],
209+ ),
210+ ];
211+
212+ for (sample, scenario) in tests {
213+ let doc = Rope::from(*sample);
214+ let slice = doc.slice(..);
215+ for &case in scenario {
216+ let (pos, objtype, expected_range) = case;
217+ let result = textobject_word(slice, Range::point(pos), objtype, 1);
218+ assert_eq!(
219+ result,
220+ expected_range.into(),
221+ "\nCase failed: {:?} - {:?}",
222+ sample,
223+ case
224+ );
225+ }
226+ }
227+ }
228+
229+ #[test]
230+ fn test_textobject_surround() {
231+ // (text, [(cursor position, textobject, final range, count), ...])
232+ let tests = &[
233+ (
234+ "simple (single) surround pairs",
235+ vec![
236+ (3, Inside, (3, 3), '(', 1),
237+ (7, Inside, (8, 13), ')', 1),
238+ (10, Inside, (8, 13), '(', 1),
239+ (14, Inside, (8, 13), ')', 1),
240+ (3, Around, (3, 3), '(', 1),
241+ (7, Around, (7, 14), ')', 1),
242+ (10, Around, (7, 14), '(', 1),
243+ (14, Around, (7, 14), ')', 1),
244+ ],
245+ ),
246+ (
247+ "samexx 'single' surround pairs",
248+ vec![
249+ (3, Inside, (3, 3), '\'', 1),
250+ // FIXME: surround doesn't work when *on* same chars pair
251+ // (7, Inner, (8, 13), '\'', 1),
252+ (10, Inside, (8, 13), '\'', 1),
253+ // (14, Inner, (8, 13), '\'', 1),
254+ (3, Around, (3, 3), '\'', 1),
255+ // (7, Around, (7, 14), '\'', 1),
256+ (10, Around, (7, 14), '\'', 1),
257+ // (14, Around, (7, 14), '\'', 1),
258+ ],
259+ ),
260+ (
261+ "(nested (surround (pairs)) 3 levels)",
262+ vec![
263+ (0, Inside, (1, 34), '(', 1),
264+ (6, Inside, (1, 34), ')', 1),
265+ (8, Inside, (9, 24), '(', 1),
266+ (8, Inside, (9, 34), ')', 2),
267+ (20, Inside, (9, 24), '(', 2),
268+ (20, Inside, (1, 34), ')', 3),
269+ (0, Around, (0, 35), '(', 1),
270+ (6, Around, (0, 35), ')', 1),
271+ (8, Around, (8, 25), '(', 1),
272+ (8, Around, (8, 35), ')', 2),
273+ (20, Around, (8, 25), '(', 2),
274+ (20, Around, (0, 35), ')', 3),
275+ ],
276+ ),
277+ (
278+ "(mixed {surround [pair] same} line)",
279+ vec![
280+ (2, Inside, (1, 33), '(', 1),
281+ (9, Inside, (8, 27), '{', 1),
282+ (18, Inside, (18, 21), '[', 1),
283+ (2, Around, (0, 34), '(', 1),
284+ (9, Around, (7, 28), '{', 1),
285+ (18, Around, (17, 22), '[', 1),
286+ ],
287+ ),
288+ (
289+ "(stepped (surround) pairs (should) skip)",
290+ vec![(22, Inside, (1, 38), '(', 1), (22, Around, (0, 39), '(', 1)],
291+ ),
292+ (
293+ "[surround pairs{\non different]\nlines}",
294+ vec![
295+ (7, Inside, (1, 28), '[', 1),
296+ (15, Inside, (16, 35), '{', 1),
297+ (7, Around, (0, 29), '[', 1),
298+ (15, Around, (15, 36), '{', 1),
299+ ],
300+ ),
301+ ];
302+
303+ for (sample, scenario) in tests {
304+ let doc = Rope::from(*sample);
305+ let slice = doc.slice(..);
306+ for &case in scenario {
307+ let (pos, objtype, expected_range, ch, count) = case;
308+ let result = textobject_surround(slice, Range::point(pos), objtype, ch, count);
309+ assert_eq!(
310+ result,
311+ expected_range.into(),
312+ "\nCase failed: {:?} - {:?}",
313+ sample,
314+ case
315+ );
316+ }
317+ }
318+ }
319+}
helix-term/src/commands.rs+31−1
@@ -3502,6 +3502,9 @@ fn right_bracket_mode(cx: &mut Context) {
3502 })
3502 })
3503}
3503}
3504
3504
3505+use helix_core::surround;
3506+use helix_core::textobject;
3507+
3505fn match_mode(cx: &mut Context) {
3508fn match_mode(cx: &mut Context) {
3506 let count = cx.count;
3509 let count = cx.count;
3507 cx.on_next_key(move |cx, event| {
3510 cx.on_next_key(move |cx, event| {
@@ -3517,13 +3520,40 @@ fn match_mode(cx: &mut Context) {
3517 's' => surround_add(cx),
3520 's' => surround_add(cx),
3518 'r' => surround_replace(cx),
3521 'r' => surround_replace(cx),
3519 'd' => surround_delete(cx),
3522 'd' => surround_delete(cx),
3523+ 'a' => select_textobject(cx, textobject::TextObject::Around),
3524+ 'i' => select_textobject(cx, textobject::TextObject::Inside),
3520 _ => (),
3525 _ => (),
3521 }
3526 }
3522 }
3527 }
3523 })
3528 })
3524}
3529}
3525
3530
3526−use helix_core::surround;
3531+fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
3532+ let count = cx.count();
3533+ cx.on_next_key(move |cx, event| {
3534+ if let KeyEvent {
3535+ code: KeyCode::Char(ch),
3536+ ..
3537+ } = event
3538+ {
3539+ let (view, doc) = current!(cx.editor);
3540+ let text = doc.text().slice(..);
3541+
3542+ let selection = doc.selection(view.id).transform(|range| {
3543+ match ch {
3544+ 'w' => textobject::textobject_word(text, range, objtype, count),
3545+ // TODO: cancel new ranges if inconsistent surround matches across lines
3546+ ch if !ch.is_ascii_alphanumeric() => {
3547+ textobject::textobject_surround(text, range, objtype, ch, count)
3548+ }
3549+ _ => range,
3550+ }
3551+ });
3552+
3553+ doc.set_selection(view.id, selection);
3554+ }
3555+ })
3556+}
3527
3557
3528fn surround_add(cx: &mut Context) {
3558fn surround_add(cx: &mut Context) {
3529 cx.on_next_key(move |cx, event| {
3559 cx.on_next_key(move |cx, event| {