convert.rs

  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,
 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        around: bool,
 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, around, 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(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}