1use collections::HashMap;
2use editor::{SelectionEffects, display_map::ToDisplayPoint};
3use gpui::{Context, Window};
4use language::{Bias, Point, SelectionGoal};
5use multi_buffer::MultiBufferRow;
6
7use crate::{
8 Vim,
9 motion::Motion,
10 normal::{ChangeCase, ConvertToLowerCase, ConvertToRot13, ConvertToRot47, ConvertToUpperCase},
11 object::Object,
12 state::{Mode, ObjectScope},
13};
14
15pub enum ConvertTarget {
16 LowerCase,
17 UpperCase,
18 OppositeCase,
19 Rot13,
20 Rot47,
21}
22
23impl Vim {
24 pub fn convert_motion(
25 &mut self,
26 motion: Motion,
27 times: Option<usize>,
28 forced_motion: bool,
29 mode: ConvertTarget,
30 window: &mut Window,
31 cx: &mut Context<Self>,
32 ) {
33 self.stop_recording(cx);
34 self.update_editor(cx, |_, editor, cx| {
35 editor.set_clip_at_line_ends(false, cx);
36 let text_layout_details = editor.text_layout_details(window);
37 editor.transact(window, cx, |editor, window, cx| {
38 let mut selection_starts: HashMap<_, _> = Default::default();
39 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
40 s.move_with(|map, selection| {
41 let anchor = map.display_point_to_anchor(selection.head(), Bias::Left);
42 selection_starts.insert(selection.id, anchor);
43 motion.expand_selection(
44 map,
45 selection,
46 times,
47 &text_layout_details,
48 forced_motion,
49 );
50 });
51 });
52 match mode {
53 ConvertTarget::LowerCase => {
54 editor.convert_to_lower_case(&Default::default(), window, cx)
55 }
56 ConvertTarget::UpperCase => {
57 editor.convert_to_upper_case(&Default::default(), window, cx)
58 }
59 ConvertTarget::OppositeCase => {
60 editor.convert_to_opposite_case(&Default::default(), window, cx)
61 }
62 ConvertTarget::Rot13 => {
63 editor.convert_to_rot13(&Default::default(), window, cx)
64 }
65 ConvertTarget::Rot47 => {
66 editor.convert_to_rot47(&Default::default(), window, cx)
67 }
68 }
69 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
70 s.move_with(|map, selection| {
71 let anchor = selection_starts.remove(&selection.id).unwrap();
72 selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
73 });
74 });
75 });
76 editor.set_clip_at_line_ends(true, cx);
77 });
78 }
79
80 pub fn convert_object(
81 &mut self,
82 object: Object,
83 scope: ObjectScope,
84 mode: ConvertTarget,
85 times: Option<usize>,
86 window: &mut Window,
87 cx: &mut Context<Self>,
88 ) {
89 self.stop_recording(cx);
90 self.update_editor(cx, |_, editor, cx| {
91 editor.transact(window, cx, |editor, window, cx| {
92 editor.set_clip_at_line_ends(false, cx);
93 let mut original_positions: HashMap<_, _> = Default::default();
94 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
95 s.move_with(|map, selection| {
96 object.expand_selection(map, selection, &scope, times);
97 original_positions.insert(
98 selection.id,
99 map.display_point_to_anchor(selection.start, Bias::Left),
100 );
101 });
102 });
103 match mode {
104 ConvertTarget::LowerCase => {
105 editor.convert_to_lower_case(&Default::default(), window, cx)
106 }
107 ConvertTarget::UpperCase => {
108 editor.convert_to_upper_case(&Default::default(), window, cx)
109 }
110 ConvertTarget::OppositeCase => {
111 editor.convert_to_opposite_case(&Default::default(), window, cx)
112 }
113 ConvertTarget::Rot13 => {
114 editor.convert_to_rot13(&Default::default(), window, cx)
115 }
116 ConvertTarget::Rot47 => {
117 editor.convert_to_rot47(&Default::default(), window, cx)
118 }
119 }
120 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
121 s.move_with(|map, selection| {
122 let anchor = original_positions.remove(&selection.id).unwrap();
123 selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
124 });
125 });
126 editor.set_clip_at_line_ends(true, cx);
127 });
128 });
129 }
130
131 pub fn change_case(&mut self, _: &ChangeCase, window: &mut Window, cx: &mut Context<Self>) {
132 self.manipulate_text(window, cx, |c| {
133 if c.is_lowercase() {
134 c.to_uppercase().collect::<Vec<char>>()
135 } else {
136 c.to_lowercase().collect::<Vec<char>>()
137 }
138 })
139 }
140
141 pub fn convert_to_upper_case(
142 &mut self,
143 _: &ConvertToUpperCase,
144 window: &mut Window,
145 cx: &mut Context<Self>,
146 ) {
147 self.manipulate_text(window, cx, |c| c.to_uppercase().collect::<Vec<char>>())
148 }
149
150 pub fn convert_to_lower_case(
151 &mut self,
152 _: &ConvertToLowerCase,
153 window: &mut Window,
154 cx: &mut Context<Self>,
155 ) {
156 self.manipulate_text(window, cx, |c| c.to_lowercase().collect::<Vec<char>>())
157 }
158
159 pub fn convert_to_rot13(
160 &mut self,
161 _: &ConvertToRot13,
162 window: &mut Window,
163 cx: &mut Context<Self>,
164 ) {
165 self.manipulate_text(window, cx, |c| {
166 vec![match c {
167 'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char,
168 'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char,
169 _ => c,
170 }]
171 })
172 }
173
174 pub fn convert_to_rot47(
175 &mut self,
176 _: &ConvertToRot47,
177 window: &mut Window,
178 cx: &mut Context<Self>,
179 ) {
180 self.manipulate_text(window, cx, |c| {
181 let code_point = c as u32;
182 if code_point >= 33 && code_point <= 126 {
183 return vec![char::from_u32(33 + ((code_point + 14) % 94)).unwrap()];
184 }
185 vec![c]
186 })
187 }
188
189 fn manipulate_text<F>(&mut self, window: &mut Window, cx: &mut Context<Self>, transform: F)
190 where
191 F: Fn(char) -> Vec<char> + Copy,
192 {
193 self.record_current_action(cx);
194 self.store_visual_marks(window, cx);
195 let count = Vim::take_count(cx).unwrap_or(1) as u32;
196 Vim::take_forced_motion(cx);
197
198 self.update_editor(cx, |vim, editor, cx| {
199 let mut ranges = Vec::new();
200 let mut cursor_positions = Vec::new();
201 let snapshot = editor.buffer().read(cx).snapshot(cx);
202 for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) {
203 match vim.mode {
204 Mode::Visual | Mode::VisualLine => {
205 ranges.push(selection.start..selection.end);
206 cursor_positions.push(selection.start..selection.start);
207 }
208 Mode::VisualBlock => {
209 ranges.push(selection.start..selection.end);
210 if cursor_positions.is_empty() {
211 cursor_positions.push(selection.start..selection.start);
212 }
213 }
214
215 Mode::HelixNormal | Mode::HelixSelect => {
216 if selection.is_empty() {
217 // Handle empty selection by operating on single character
218 let start = selection.start;
219 let end = snapshot.clip_point(start + Point::new(0, 1), Bias::Right);
220 ranges.push(start..end);
221 cursor_positions.push(selection.start..selection.start);
222 } else {
223 ranges.push(selection.start..selection.end);
224 cursor_positions.push(selection.start..selection.end);
225 }
226 }
227 Mode::Insert | Mode::Normal | Mode::Replace => {
228 let start = selection.start;
229 let mut end = start;
230 for _ in 0..count {
231 end = snapshot.clip_point(end + Point::new(0, 1), Bias::Right);
232 }
233 ranges.push(start..end);
234
235 if end.column == snapshot.line_len(MultiBufferRow(end.row))
236 && end.column > 0
237 {
238 end = snapshot.clip_point(end - Point::new(0, 1), Bias::Left);
239 }
240 cursor_positions.push(end..end)
241 }
242 }
243 }
244 editor.transact(window, cx, |editor, window, cx| {
245 for range in ranges.into_iter().rev() {
246 let snapshot = editor.buffer().read(cx).snapshot(cx);
247 let text = snapshot
248 .text_for_range(range.start..range.end)
249 .flat_map(|s| s.chars())
250 .flat_map(transform)
251 .collect::<String>();
252 editor.edit([(range, text)], cx)
253 }
254 editor.change_selections(Default::default(), window, cx, |s| {
255 s.select_ranges(cursor_positions)
256 })
257 });
258 });
259 if self.mode != Mode::HelixNormal {
260 self.switch_mode(Mode::Normal, true, window, cx)
261 }
262 }
263}
264
265#[cfg(test)]
266mod test {
267 use crate::test::VimTestContext;
268
269 use crate::{state::Mode, test::NeovimBackedTestContext};
270
271 #[gpui::test]
272 async fn test_change_case(cx: &mut gpui::TestAppContext) {
273 let mut cx = NeovimBackedTestContext::new(cx).await;
274 cx.set_shared_state("ˇabC\n").await;
275 cx.simulate_shared_keystrokes("~").await;
276 cx.shared_state().await.assert_eq("AˇbC\n");
277 cx.simulate_shared_keystrokes("2 ~").await;
278 cx.shared_state().await.assert_eq("ABˇc\n");
279
280 // works in visual mode
281 cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
282 cx.simulate_shared_keystrokes("~").await;
283 cx.shared_state().await.assert_eq("a😀CˇDé1*F\n");
284
285 // works with multibyte characters
286 cx.simulate_shared_keystrokes("~").await;
287 cx.set_shared_state("aˇC😀é1*F\n").await;
288 cx.simulate_shared_keystrokes("4 ~").await;
289 cx.shared_state().await.assert_eq("ac😀É1ˇ*F\n");
290
291 // works with line selections
292 cx.set_shared_state("abˇC\n").await;
293 cx.simulate_shared_keystrokes("shift-v ~").await;
294 cx.shared_state().await.assert_eq("ˇABc\n");
295
296 // works in visual block mode
297 cx.set_shared_state("ˇaa\nbb\ncc").await;
298 cx.simulate_shared_keystrokes("ctrl-v j ~").await;
299 cx.shared_state().await.assert_eq("ˇAa\nBb\ncc");
300
301 // works with multiple cursors (zed only)
302 cx.set_state("aˇßcdˇe\n", Mode::Normal);
303 cx.simulate_keystrokes("~");
304 cx.assert_state("aSSˇcdˇE\n", Mode::Normal);
305 }
306
307 #[gpui::test]
308 async fn test_convert_to_upper_case(cx: &mut gpui::TestAppContext) {
309 let mut cx = NeovimBackedTestContext::new(cx).await;
310 // works in visual mode
311 cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
312 cx.simulate_shared_keystrokes("shift-u").await;
313 cx.shared_state().await.assert_eq("a😀CˇDÉ1*F\n");
314
315 // works with line selections
316 cx.set_shared_state("abˇC\n").await;
317 cx.simulate_shared_keystrokes("shift-v shift-u").await;
318 cx.shared_state().await.assert_eq("ˇABC\n");
319
320 // works in visual block mode
321 cx.set_shared_state("ˇaa\nbb\ncc").await;
322 cx.simulate_shared_keystrokes("ctrl-v j shift-u").await;
323 cx.shared_state().await.assert_eq("ˇAa\nBb\ncc");
324 }
325
326 #[gpui::test]
327 async fn test_convert_to_lower_case(cx: &mut gpui::TestAppContext) {
328 let mut cx = NeovimBackedTestContext::new(cx).await;
329 // works in visual mode
330 cx.set_shared_state("A😀c«DÉ1*fˇ»\n").await;
331 cx.simulate_shared_keystrokes("u").await;
332 cx.shared_state().await.assert_eq("A😀cˇdé1*f\n");
333
334 // works with line selections
335 cx.set_shared_state("ABˇc\n").await;
336 cx.simulate_shared_keystrokes("shift-v u").await;
337 cx.shared_state().await.assert_eq("ˇabc\n");
338
339 // works in visual block mode
340 cx.set_shared_state("ˇAa\nBb\nCc").await;
341 cx.simulate_shared_keystrokes("ctrl-v j u").await;
342 cx.shared_state().await.assert_eq("ˇaa\nbb\nCc");
343 }
344
345 #[gpui::test]
346 async fn test_change_case_motion(cx: &mut gpui::TestAppContext) {
347 let mut cx = NeovimBackedTestContext::new(cx).await;
348
349 cx.set_shared_state("ˇabc def").await;
350 cx.simulate_shared_keystrokes("g shift-u w").await;
351 cx.shared_state().await.assert_eq("ˇABC def");
352
353 cx.simulate_shared_keystrokes("g u w").await;
354 cx.shared_state().await.assert_eq("ˇabc def");
355
356 cx.simulate_shared_keystrokes("g ~ w").await;
357 cx.shared_state().await.assert_eq("ˇABC def");
358
359 cx.simulate_shared_keystrokes(".").await;
360 cx.shared_state().await.assert_eq("ˇabc def");
361
362 cx.set_shared_state("abˇc def").await;
363 cx.simulate_shared_keystrokes("g ~ i w").await;
364 cx.shared_state().await.assert_eq("ˇABC def");
365
366 cx.simulate_shared_keystrokes(".").await;
367 cx.shared_state().await.assert_eq("ˇabc def");
368
369 cx.simulate_shared_keystrokes("g shift-u $").await;
370 cx.shared_state().await.assert_eq("ˇABC DEF");
371 }
372
373 #[gpui::test]
374 async fn test_change_case_motion_object(cx: &mut gpui::TestAppContext) {
375 let mut cx = NeovimBackedTestContext::new(cx).await;
376
377 cx.set_shared_state("abc dˇef\n").await;
378 cx.simulate_shared_keystrokes("g shift-u i w").await;
379 cx.shared_state().await.assert_eq("abc ˇDEF\n");
380 }
381
382 #[gpui::test]
383 async fn test_convert_to_rot13(cx: &mut gpui::TestAppContext) {
384 let mut cx = NeovimBackedTestContext::new(cx).await;
385 // works in visual mode
386 cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
387 cx.simulate_shared_keystrokes("g ?").await;
388 cx.shared_state().await.assert_eq("a😀CˇqÉ1*s\n");
389
390 // works with line selections
391 cx.set_shared_state("abˇC\n").await;
392 cx.simulate_shared_keystrokes("shift-v g ?").await;
393 cx.shared_state().await.assert_eq("ˇnoP\n");
394
395 // works in visual block mode
396 cx.set_shared_state("ˇaa\nbb\ncc").await;
397 cx.simulate_shared_keystrokes("ctrl-v j g ?").await;
398 cx.shared_state().await.assert_eq("ˇna\nob\ncc");
399 }
400
401 #[gpui::test]
402 async fn test_change_rot13_motion(cx: &mut gpui::TestAppContext) {
403 let mut cx = NeovimBackedTestContext::new(cx).await;
404
405 cx.set_shared_state("ˇabc def").await;
406 cx.simulate_shared_keystrokes("g ? w").await;
407 cx.shared_state().await.assert_eq("ˇnop def");
408
409 cx.simulate_shared_keystrokes("g ? w").await;
410 cx.shared_state().await.assert_eq("ˇabc def");
411
412 cx.simulate_shared_keystrokes(".").await;
413 cx.shared_state().await.assert_eq("ˇnop def");
414
415 cx.set_shared_state("abˇc def").await;
416 cx.simulate_shared_keystrokes("g ? i w").await;
417 cx.shared_state().await.assert_eq("ˇnop def");
418
419 cx.simulate_shared_keystrokes(".").await;
420 cx.shared_state().await.assert_eq("ˇabc def");
421
422 cx.simulate_shared_keystrokes("g ? $").await;
423 cx.shared_state().await.assert_eq("ˇnop qrs");
424 }
425
426 #[gpui::test]
427 async fn test_change_rot13_object(cx: &mut gpui::TestAppContext) {
428 let mut cx = NeovimBackedTestContext::new(cx).await;
429
430 cx.set_shared_state("ˇabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
431 .await;
432 cx.simulate_shared_keystrokes("g ? i w").await;
433 cx.shared_state()
434 .await
435 .assert_eq("ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM");
436 }
437
438 #[gpui::test]
439 async fn test_change_case_helix_mode(cx: &mut gpui::TestAppContext) {
440 let mut cx = VimTestContext::new(cx, true).await;
441
442 // Explicit selection
443 cx.set_state("«hello worldˇ»", Mode::HelixNormal);
444 cx.simulate_keystrokes("~");
445 cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal);
446
447 // Cursor-only (empty) selection - switch case
448 cx.set_state("The ˇquick brown", Mode::HelixNormal);
449 cx.simulate_keystrokes("~");
450 cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
451 cx.simulate_keystrokes("~");
452 cx.assert_state("The ˇquick brown", Mode::HelixNormal);
453
454 // Cursor-only (empty) selection - switch to uppercase and lowercase explicitly
455 cx.set_state("The ˇquick brown", Mode::HelixNormal);
456 cx.simulate_keystrokes("alt-`");
457 cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
458 cx.simulate_keystrokes("`");
459 cx.assert_state("The ˇquick brown", Mode::HelixNormal);
460
461 // With `e` motion (which extends selection to end of word in Helix)
462 cx.set_state("The ˇquick brown fox", Mode::HelixNormal);
463 cx.simulate_keystrokes("e");
464 cx.simulate_keystrokes("~");
465 cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal);
466
467 // Cursor-only
468 }
469}