repeat.rs

  1use std::{cell::RefCell, rc::Rc};
  2
  3use crate::{
  4    insert::NormalBefore,
  5    motion::Motion,
  6    normal::InsertBefore,
  7    state::{Mode, Operator, RecordedSelection, ReplayableAction, VimGlobals},
  8    Vim,
  9};
 10use editor::Editor;
 11use gpui::{actions, Action, App, Context, Window};
 12use workspace::Workspace;
 13
 14actions!(vim, [Repeat, EndRepeat, ToggleRecord, ReplayLastRecording]);
 15
 16fn should_replay(action: &dyn Action) -> bool {
 17    // skip so that we don't leave the character palette open
 18    if editor::actions::ShowCharacterPalette.partial_eq(action) {
 19        return false;
 20    }
 21    true
 22}
 23
 24fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
 25    match action {
 26        ReplayableAction::Action(action) => {
 27            if super::InsertBefore.partial_eq(&**action)
 28                || super::InsertAfter.partial_eq(&**action)
 29                || super::InsertFirstNonWhitespace.partial_eq(&**action)
 30                || super::InsertEndOfLine.partial_eq(&**action)
 31            {
 32                Some(super::InsertBefore.boxed_clone())
 33            } else if super::InsertLineAbove.partial_eq(&**action)
 34                || super::InsertLineBelow.partial_eq(&**action)
 35            {
 36                Some(super::InsertLineBelow.boxed_clone())
 37            } else if crate::replace::ToggleReplace.partial_eq(&**action) {
 38                Some(crate::replace::ToggleReplace.boxed_clone())
 39            } else {
 40                None
 41            }
 42        }
 43        ReplayableAction::Insertion { .. } => None,
 44    }
 45}
 46
 47pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 48    Vim::action(editor, cx, |vim, _: &EndRepeat, window, cx| {
 49        Vim::globals(cx).dot_replaying = false;
 50        vim.switch_mode(Mode::Normal, false, window, cx)
 51    });
 52
 53    Vim::action(editor, cx, |vim, _: &Repeat, window, cx| {
 54        vim.repeat(false, window, cx)
 55    });
 56
 57    Vim::action(editor, cx, |vim, _: &ToggleRecord, window, cx| {
 58        let globals = Vim::globals(cx);
 59        if let Some(char) = globals.recording_register.take() {
 60            globals.last_recorded_register = Some(char)
 61        } else {
 62            vim.push_operator(Operator::RecordRegister, window, cx);
 63        }
 64    });
 65
 66    Vim::action(editor, cx, |vim, _: &ReplayLastRecording, window, cx| {
 67        let Some(register) = Vim::globals(cx).last_recorded_register else {
 68            return;
 69        };
 70        vim.replay_register(register, window, cx)
 71    });
 72}
 73
 74pub struct ReplayerState {
 75    actions: Vec<ReplayableAction>,
 76    running: bool,
 77    ix: usize,
 78}
 79
 80#[derive(Clone)]
 81pub struct Replayer(Rc<RefCell<ReplayerState>>);
 82
 83impl Replayer {
 84    pub fn new() -> Self {
 85        Self(Rc::new(RefCell::new(ReplayerState {
 86            actions: vec![],
 87            running: false,
 88            ix: 0,
 89        })))
 90    }
 91
 92    pub fn replay(&mut self, actions: Vec<ReplayableAction>, window: &mut Window, cx: &mut App) {
 93        let mut lock = self.0.borrow_mut();
 94        let range = lock.ix..lock.ix;
 95        lock.actions.splice(range, actions);
 96        if lock.running {
 97            return;
 98        }
 99        lock.running = true;
100        let this = self.clone();
101        window.defer(cx, move |window, cx| this.next(window, cx))
102    }
103
104    pub fn stop(self) {
105        self.0.borrow_mut().actions.clear()
106    }
107
108    pub fn next(self, window: &mut Window, cx: &mut App) {
109        let mut lock = self.0.borrow_mut();
110        let action = if lock.ix < 10000 {
111            lock.actions.get(lock.ix).cloned()
112        } else {
113            log::error!("Aborting replay after 10000 actions");
114            None
115        };
116        lock.ix += 1;
117        drop(lock);
118        let Some(action) = action else {
119            Vim::globals(cx).replayer.take();
120            return;
121        };
122        match action {
123            ReplayableAction::Action(action) => {
124                if should_replay(&*action) {
125                    window.dispatch_action(action.boxed_clone(), cx);
126                    cx.defer(move |cx| Vim::globals(cx).observe_action(action.boxed_clone()));
127                }
128            }
129            ReplayableAction::Insertion {
130                text,
131                utf16_range_to_replace,
132            } => {
133                let Some(Some(workspace)) = window.root::<Workspace>() else {
134                    return;
135                };
136                let Some(editor) = workspace
137                    .read(cx)
138                    .active_item(cx)
139                    .and_then(|item| item.act_as::<Editor>(cx))
140                else {
141                    return;
142                };
143                editor.update(cx, |editor, cx| {
144                    editor.replay_insert_event(&text, utf16_range_to_replace.clone(), window, cx)
145                })
146            }
147        }
148        window.defer(cx, move |window, cx| self.next(window, cx));
149    }
150}
151
152impl Vim {
153    pub(crate) fn record_register(
154        &mut self,
155        register: char,
156        window: &mut Window,
157        cx: &mut Context<Self>,
158    ) {
159        let globals = Vim::globals(cx);
160        globals.recording_register = Some(register);
161        globals.recordings.remove(&register);
162        globals.ignore_current_insertion = true;
163        self.clear_operator(window, cx)
164    }
165
166    pub(crate) fn replay_register(
167        &mut self,
168        mut register: char,
169        window: &mut Window,
170        cx: &mut Context<Self>,
171    ) {
172        let mut count = Vim::take_count(cx).unwrap_or(1);
173        self.clear_operator(window, cx);
174
175        let globals = Vim::globals(cx);
176        if register == '@' {
177            let Some(last) = globals.last_replayed_register else {
178                return;
179            };
180            register = last;
181        }
182        let Some(actions) = globals.recordings.get(&register) else {
183            return;
184        };
185
186        let mut repeated_actions = vec![];
187        while count > 0 {
188            repeated_actions.extend(actions.iter().cloned());
189            count -= 1
190        }
191
192        globals.last_replayed_register = Some(register);
193        let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
194        replayer.replay(repeated_actions, window, cx);
195    }
196
197    pub(crate) fn repeat(
198        &mut self,
199        from_insert_mode: bool,
200        window: &mut Window,
201        cx: &mut Context<Self>,
202    ) {
203        let count = Vim::take_count(cx);
204
205        let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| {
206            let actions = globals.recorded_actions.clone();
207            if actions.is_empty() {
208                return None;
209            }
210            if globals.replayer.is_none() {
211                if let Some(recording_register) = globals.recording_register {
212                    globals
213                        .recordings
214                        .entry(recording_register)
215                        .or_default()
216                        .push(ReplayableAction::Action(Repeat.boxed_clone()));
217                }
218            }
219
220            let mut mode = None;
221            let selection = globals.recorded_selection.clone();
222            match selection {
223                RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
224                    globals.recorded_count = None;
225                    mode = Some(Mode::Visual);
226                }
227                RecordedSelection::VisualLine { .. } => {
228                    globals.recorded_count = None;
229                    mode = Some(Mode::VisualLine)
230                }
231                RecordedSelection::VisualBlock { .. } => {
232                    globals.recorded_count = None;
233                    mode = Some(Mode::VisualBlock)
234                }
235                RecordedSelection::None => {
236                    if let Some(count) = count {
237                        globals.recorded_count = Some(count);
238                    }
239                }
240            }
241
242            Some((actions, selection, mode))
243        }) else {
244            return;
245        };
246        if let Some(mode) = mode {
247            self.switch_mode(mode, false, window, cx)
248        }
249
250        match selection {
251            RecordedSelection::SingleLine { cols } => {
252                if cols > 1 {
253                    self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
254                }
255            }
256            RecordedSelection::Visual { rows, cols } => {
257                self.visual_motion(
258                    Motion::Down {
259                        display_lines: false,
260                    },
261                    Some(rows as usize),
262                    window,
263                    cx,
264                );
265                self.visual_motion(
266                    Motion::StartOfLine {
267                        display_lines: false,
268                    },
269                    None,
270                    window,
271                    cx,
272                );
273                if cols > 1 {
274                    self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
275                }
276            }
277            RecordedSelection::VisualBlock { rows, cols } => {
278                self.visual_motion(
279                    Motion::Down {
280                        display_lines: false,
281                    },
282                    Some(rows as usize),
283                    window,
284                    cx,
285                );
286                if cols > 1 {
287                    self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx);
288                }
289            }
290            RecordedSelection::VisualLine { rows } => {
291                self.visual_motion(
292                    Motion::Down {
293                        display_lines: false,
294                    },
295                    Some(rows as usize),
296                    window,
297                    cx,
298                );
299            }
300            RecordedSelection::None => {}
301        }
302
303        // insert internally uses repeat to handle counts
304        // vim doesn't treat 3a1 as though you literally repeated a1
305        // 3 times, instead it inserts the content thrice at the insert position.
306        if let Some(to_repeat) = repeatable_insert(&actions[0]) {
307            if let Some(ReplayableAction::Action(action)) = actions.last() {
308                if NormalBefore.partial_eq(&**action) {
309                    actions.pop();
310                }
311            }
312
313            let mut new_actions = actions.clone();
314            actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
315
316            let mut count = cx.global::<VimGlobals>().recorded_count.unwrap_or(1);
317
318            // if we came from insert mode we're just doing repetitions 2 onwards.
319            if from_insert_mode {
320                count -= 1;
321                new_actions[0] = actions[0].clone();
322            }
323
324            for _ in 1..count {
325                new_actions.append(actions.clone().as_mut());
326            }
327            new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
328            actions = new_actions;
329        }
330
331        actions.push(ReplayableAction::Action(EndRepeat.boxed_clone()));
332
333        if self.temp_mode {
334            self.temp_mode = false;
335            actions.push(ReplayableAction::Action(InsertBefore.boxed_clone()));
336        }
337
338        let globals = Vim::globals(cx);
339        globals.dot_replaying = true;
340        let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
341
342        replayer.replay(actions, window, cx);
343    }
344}
345
346#[cfg(test)]
347mod test {
348    use editor::test::editor_lsp_test_context::EditorLspTestContext;
349    use futures::StreamExt;
350    use indoc::indoc;
351
352    use gpui::EntityInputHandler;
353
354    use crate::{
355        state::Mode,
356        test::{NeovimBackedTestContext, VimTestContext},
357    };
358
359    #[gpui::test]
360    async fn test_dot_repeat(cx: &mut gpui::TestAppContext) {
361        let mut cx = NeovimBackedTestContext::new(cx).await;
362
363        // "o"
364        cx.set_shared_state("ˇhello").await;
365        cx.simulate_shared_keystrokes("o w o r l d escape").await;
366        cx.shared_state().await.assert_eq("hello\nworlˇd");
367        cx.simulate_shared_keystrokes(".").await;
368        cx.shared_state().await.assert_eq("hello\nworld\nworlˇd");
369
370        // "d"
371        cx.simulate_shared_keystrokes("^ d f o").await;
372        cx.simulate_shared_keystrokes("g g .").await;
373        cx.shared_state().await.assert_eq("ˇ\nworld\nrld");
374
375        // "p" (note that it pastes the current clipboard)
376        cx.simulate_shared_keystrokes("j y y p").await;
377        cx.simulate_shared_keystrokes("shift-g y y .").await;
378        cx.shared_state()
379            .await
380            .assert_eq("\nworld\nworld\nrld\nˇrld");
381
382        // "~" (note that counts apply to the action taken, not . itself)
383        cx.set_shared_state("ˇthe quick brown fox").await;
384        cx.simulate_shared_keystrokes("2 ~ .").await;
385        cx.set_shared_state("THE ˇquick brown fox").await;
386        cx.simulate_shared_keystrokes("3 .").await;
387        cx.set_shared_state("THE QUIˇck brown fox").await;
388        cx.run_until_parked();
389        cx.simulate_shared_keystrokes(".").await;
390        cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox");
391    }
392
393    #[gpui::test]
394    async fn test_repeat_ime(cx: &mut gpui::TestAppContext) {
395        let mut cx = VimTestContext::new(cx, true).await;
396
397        cx.set_state("hˇllo", Mode::Normal);
398        cx.simulate_keystrokes("i");
399
400        // simulate brazilian input for ä.
401        cx.update_editor(|editor, window, cx| {
402            editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), window, cx);
403            editor.replace_text_in_range(None, "ä", window, cx);
404        });
405        cx.simulate_keystrokes("escape");
406        cx.assert_state("hˇällo", Mode::Normal);
407        cx.simulate_keystrokes(".");
408        cx.assert_state("hˇäällo", Mode::Normal);
409    }
410
411    #[gpui::test]
412    async fn test_repeat_completion(cx: &mut gpui::TestAppContext) {
413        VimTestContext::init(cx);
414        let cx = EditorLspTestContext::new_rust(
415            lsp::ServerCapabilities {
416                completion_provider: Some(lsp::CompletionOptions {
417                    trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
418                    resolve_provider: Some(true),
419                    ..Default::default()
420                }),
421                ..Default::default()
422            },
423            cx,
424        )
425        .await;
426        let mut cx = VimTestContext::new_with_lsp(cx, true);
427
428        cx.set_state(
429            indoc! {"
430            onˇe
431            two
432            three
433        "},
434            Mode::Normal,
435        );
436
437        let mut request =
438            cx.handle_request::<lsp::request::Completion, _, _>(move |_, params, _| async move {
439                let position = params.text_document_position.position;
440                Ok(Some(lsp::CompletionResponse::Array(vec![
441                    lsp::CompletionItem {
442                        label: "first".to_string(),
443                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
444                            range: lsp::Range::new(position, position),
445                            new_text: "first".to_string(),
446                        })),
447                        ..Default::default()
448                    },
449                    lsp::CompletionItem {
450                        label: "second".to_string(),
451                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
452                            range: lsp::Range::new(position, position),
453                            new_text: "second".to_string(),
454                        })),
455                        ..Default::default()
456                    },
457                ])))
458            });
459        cx.simulate_keystrokes("a .");
460        request.next().await;
461        cx.condition(|editor, _| editor.context_menu_visible())
462            .await;
463        cx.simulate_keystrokes("down enter ! escape");
464
465        cx.assert_state(
466            indoc! {"
467                one.secondˇ!
468                two
469                three
470            "},
471            Mode::Normal,
472        );
473        cx.simulate_keystrokes("j .");
474        cx.assert_state(
475            indoc! {"
476                one.second!
477                two.secondˇ!
478                three
479            "},
480            Mode::Normal,
481        );
482    }
483
484    #[gpui::test]
485    async fn test_repeat_visual(cx: &mut gpui::TestAppContext) {
486        let mut cx = NeovimBackedTestContext::new(cx).await;
487
488        // single-line (3 columns)
489        cx.set_shared_state(indoc! {
490            "ˇthe quick brown
491            fox jumps over
492            the lazy dog"
493        })
494        .await;
495        cx.simulate_shared_keystrokes("v i w s o escape").await;
496        cx.shared_state().await.assert_eq(indoc! {
497            "ˇo quick brown
498            fox jumps over
499            the lazy dog"
500        });
501        cx.simulate_shared_keystrokes("j w .").await;
502        cx.shared_state().await.assert_eq(indoc! {
503            "o quick brown
504            fox ˇops over
505            the lazy dog"
506        });
507        cx.simulate_shared_keystrokes("f r .").await;
508        cx.shared_state().await.assert_eq(indoc! {
509            "o quick brown
510            fox ops oveˇothe lazy dog"
511        });
512
513        // visual
514        cx.set_shared_state(indoc! {
515            "the ˇquick brown
516            fox jumps over
517            fox jumps over
518            fox jumps over
519            the lazy dog"
520        })
521        .await;
522        cx.simulate_shared_keystrokes("v j x").await;
523        cx.shared_state().await.assert_eq(indoc! {
524            "the ˇumps over
525            fox jumps over
526            fox jumps over
527            the lazy dog"
528        });
529        cx.simulate_shared_keystrokes(".").await;
530        cx.shared_state().await.assert_eq(indoc! {
531            "the ˇumps over
532            fox jumps over
533            the lazy dog"
534        });
535        cx.simulate_shared_keystrokes("w .").await;
536        cx.shared_state().await.assert_eq(indoc! {
537            "the umps ˇumps over
538            the lazy dog"
539        });
540        cx.simulate_shared_keystrokes("j .").await;
541        cx.shared_state().await.assert_eq(indoc! {
542            "the umps umps over
543            the ˇog"
544        });
545
546        // block mode (3 rows)
547        cx.set_shared_state(indoc! {
548            "ˇthe quick brown
549            fox jumps over
550            the lazy dog"
551        })
552        .await;
553        cx.simulate_shared_keystrokes("ctrl-v j j shift-i o escape")
554            .await;
555        cx.shared_state().await.assert_eq(indoc! {
556            "ˇothe quick brown
557            ofox jumps over
558            othe lazy dog"
559        });
560        cx.simulate_shared_keystrokes("j 4 l .").await;
561        cx.shared_state().await.assert_eq(indoc! {
562            "othe quick brown
563            ofoxˇo jumps over
564            otheo lazy dog"
565        });
566
567        // line mode
568        cx.set_shared_state(indoc! {
569            "ˇthe quick brown
570            fox jumps over
571            the lazy dog"
572        })
573        .await;
574        cx.simulate_shared_keystrokes("shift-v shift-r o escape")
575            .await;
576        cx.shared_state().await.assert_eq(indoc! {
577            "ˇo
578            fox jumps over
579            the lazy dog"
580        });
581        cx.simulate_shared_keystrokes("j .").await;
582        cx.shared_state().await.assert_eq(indoc! {
583            "o
584            ˇo
585            the lazy dog"
586        });
587    }
588
589    #[gpui::test]
590    async fn test_repeat_motion_counts(cx: &mut gpui::TestAppContext) {
591        let mut cx = NeovimBackedTestContext::new(cx).await;
592
593        cx.set_shared_state(indoc! {
594            "ˇthe quick brown
595            fox jumps over
596            the lazy dog"
597        })
598        .await;
599        cx.simulate_shared_keystrokes("3 d 3 l").await;
600        cx.shared_state().await.assert_eq(indoc! {
601            "ˇ brown
602            fox jumps over
603            the lazy dog"
604        });
605        cx.simulate_shared_keystrokes("j .").await;
606        cx.shared_state().await.assert_eq(indoc! {
607            " brown
608            ˇ over
609            the lazy dog"
610        });
611        cx.simulate_shared_keystrokes("j 2 .").await;
612        cx.shared_state().await.assert_eq(indoc! {
613            " brown
614             over
615            ˇe lazy dog"
616        });
617    }
618
619    #[gpui::test]
620    async fn test_record_interrupted(cx: &mut gpui::TestAppContext) {
621        let mut cx = VimTestContext::new(cx, true).await;
622
623        cx.set_state("ˇhello\n", Mode::Normal);
624        cx.simulate_keystrokes("4 i j cmd-shift-p escape");
625        cx.simulate_keystrokes("escape");
626        cx.assert_state("ˇjhello\n", Mode::Normal);
627    }
628
629    #[gpui::test]
630    async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) {
631        let mut cx = NeovimBackedTestContext::new(cx).await;
632
633        cx.set_shared_state("ˇhello hello hello\n").await;
634        cx.simulate_shared_keystrokes("c f o x escape").await;
635        cx.shared_state().await.assert_eq("ˇx hello hello\n");
636        cx.simulate_shared_keystrokes(": escape").await;
637        cx.simulate_shared_keystrokes(".").await;
638        cx.shared_state().await.assert_eq("ˇx hello\n");
639    }
640
641    #[gpui::test]
642    async fn test_undo_repeated_insert(cx: &mut gpui::TestAppContext) {
643        let mut cx = NeovimBackedTestContext::new(cx).await;
644
645        cx.set_shared_state("hellˇo").await;
646        cx.simulate_shared_keystrokes("3 a . escape").await;
647        cx.shared_state().await.assert_eq("hello..ˇ.");
648        cx.simulate_shared_keystrokes("u").await;
649        cx.shared_state().await.assert_eq("hellˇo");
650    }
651
652    #[gpui::test]
653    async fn test_record_replay(cx: &mut gpui::TestAppContext) {
654        let mut cx = NeovimBackedTestContext::new(cx).await;
655
656        cx.set_shared_state("ˇhello world").await;
657        cx.simulate_shared_keystrokes("q w c w j escape q").await;
658        cx.shared_state().await.assert_eq("ˇj world");
659        cx.simulate_shared_keystrokes("2 l @ w").await;
660        cx.shared_state().await.assert_eq("j ˇj");
661    }
662
663    #[gpui::test]
664    async fn test_record_replay_count(cx: &mut gpui::TestAppContext) {
665        let mut cx = NeovimBackedTestContext::new(cx).await;
666
667        cx.set_shared_state("ˇhello world!!").await;
668        cx.simulate_shared_keystrokes("q a v 3 l s 0 escape l q")
669            .await;
670        cx.shared_state().await.assert_eq("0ˇo world!!");
671        cx.simulate_shared_keystrokes("2 @ a").await;
672        cx.shared_state().await.assert_eq("000ˇ!");
673    }
674
675    #[gpui::test]
676    async fn test_record_replay_dot(cx: &mut gpui::TestAppContext) {
677        let mut cx = NeovimBackedTestContext::new(cx).await;
678
679        cx.set_shared_state("ˇhello world").await;
680        cx.simulate_shared_keystrokes("q a r a l r b l q").await;
681        cx.shared_state().await.assert_eq("abˇllo world");
682        cx.simulate_shared_keystrokes(".").await;
683        cx.shared_state().await.assert_eq("abˇblo world");
684        cx.simulate_shared_keystrokes("shift-q").await;
685        cx.shared_state().await.assert_eq("ababˇo world");
686        cx.simulate_shared_keystrokes(".").await;
687        cx.shared_state().await.assert_eq("ababˇb world");
688    }
689
690    #[gpui::test]
691    async fn test_record_replay_of_dot(cx: &mut gpui::TestAppContext) {
692        let mut cx = NeovimBackedTestContext::new(cx).await;
693
694        cx.set_shared_state("ˇhello world").await;
695        cx.simulate_shared_keystrokes("r o q w . q").await;
696        cx.shared_state().await.assert_eq("ˇoello world");
697        cx.simulate_shared_keystrokes("d l").await;
698        cx.shared_state().await.assert_eq("ˇello world");
699        cx.simulate_shared_keystrokes("@ w").await;
700        cx.shared_state().await.assert_eq("ˇllo world");
701    }
702
703    #[gpui::test]
704    async fn test_record_replay_interleaved(cx: &mut gpui::TestAppContext) {
705        let mut cx = NeovimBackedTestContext::new(cx).await;
706
707        cx.set_shared_state("ˇhello world").await;
708        cx.simulate_shared_keystrokes("q z r a l q").await;
709        cx.shared_state().await.assert_eq("aˇello world");
710        cx.simulate_shared_keystrokes("q b @ z @ z q").await;
711        cx.shared_state().await.assert_eq("aaaˇlo world");
712        cx.simulate_shared_keystrokes("@ @").await;
713        cx.shared_state().await.assert_eq("aaaaˇo world");
714        cx.simulate_shared_keystrokes("@ b").await;
715        cx.shared_state().await.assert_eq("aaaaaaˇworld");
716        cx.simulate_shared_keystrokes("@ @").await;
717        cx.shared_state().await.assert_eq("aaaaaaaˇorld");
718        cx.simulate_shared_keystrokes("q z r b l q").await;
719        cx.shared_state().await.assert_eq("aaaaaaabˇrld");
720        cx.simulate_shared_keystrokes("@ b").await;
721        cx.shared_state().await.assert_eq("aaaaaaabbbˇd");
722    }
723}