1use gpui::{
2 Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
3 KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
4};
5use ui::{
6 ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
7 ParentElement as _, Render, Styled as _, Tooltip, Window, prelude::*,
8};
9
10actions!(
11 keystroke_input,
12 [
13 /// Starts recording keystrokes
14 StartRecording,
15 /// Stops recording keystrokes
16 StopRecording,
17 /// Clears the recorded keystrokes
18 ClearKeystrokes,
19 ]
20);
21
22const KEY_CONTEXT_VALUE: &str = "KeystrokeInput";
23
24const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration =
25 std::time::Duration::from_millis(300);
26
27enum CloseKeystrokeResult {
28 Partial,
29 Close,
30 None,
31}
32
33impl PartialEq for CloseKeystrokeResult {
34 fn eq(&self, other: &Self) -> bool {
35 matches!(
36 (self, other),
37 (CloseKeystrokeResult::Partial, CloseKeystrokeResult::Partial)
38 | (CloseKeystrokeResult::Close, CloseKeystrokeResult::Close)
39 | (CloseKeystrokeResult::None, CloseKeystrokeResult::None)
40 )
41 }
42}
43
44pub struct KeystrokeInput {
45 keystrokes: Vec<KeybindingKeystroke>,
46 placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
47 outer_focus_handle: FocusHandle,
48 inner_focus_handle: FocusHandle,
49 intercept_subscription: Option<Subscription>,
50 _focus_subscriptions: [Subscription; 2],
51 search: bool,
52 /// The sequence of close keystrokes being typed
53 close_keystrokes: Option<Vec<Keystroke>>,
54 close_keystrokes_start: Option<usize>,
55 previous_modifiers: Modifiers,
56 /// In order to support inputting keystrokes that end with a prefix of the
57 /// close keybind keystrokes, we clear the close keystroke capture info
58 /// on a timeout after a close keystroke is pressed
59 ///
60 /// e.g. if close binding is `esc esc esc` and user wants to search for
61 /// `ctrl-g esc`, after entering the `ctrl-g esc`, hitting `esc` twice would
62 /// stop recording because of the sequence of three escapes making it
63 /// impossible to search for anything ending in `esc`
64 clear_close_keystrokes_timer: Option<Task<()>>,
65 #[cfg(test)]
66 recording: bool,
67}
68
69impl KeystrokeInput {
70 const KEYSTROKE_COUNT_MAX: usize = 3;
71
72 pub fn new(
73 placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
74 window: &mut Window,
75 cx: &mut Context<Self>,
76 ) -> Self {
77 let outer_focus_handle = cx.focus_handle();
78 let inner_focus_handle = cx.focus_handle();
79 let _focus_subscriptions = [
80 cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in),
81 cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out),
82 ];
83 Self {
84 keystrokes: Vec::new(),
85 placeholder_keystrokes,
86 inner_focus_handle,
87 outer_focus_handle,
88 intercept_subscription: None,
89 _focus_subscriptions,
90 search: false,
91 close_keystrokes: None,
92 close_keystrokes_start: None,
93 previous_modifiers: Modifiers::default(),
94 clear_close_keystrokes_timer: None,
95 #[cfg(test)]
96 recording: false,
97 }
98 }
99
100 pub fn set_keystrokes(&mut self, keystrokes: Vec<KeybindingKeystroke>, cx: &mut Context<Self>) {
101 self.keystrokes = keystrokes;
102 self.keystrokes_changed(cx);
103 }
104
105 pub fn set_search(&mut self, search: bool) {
106 self.search = search;
107 }
108
109 pub fn keystrokes(&self) -> &[KeybindingKeystroke] {
110 if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
111 && self.keystrokes.is_empty()
112 {
113 return placeholders;
114 }
115 if !self.search
116 && self
117 .keystrokes
118 .last()
119 .is_some_and(|last| last.display_key.is_empty())
120 {
121 return &self.keystrokes[..self.keystrokes.len() - 1];
122 }
123 &self.keystrokes
124 }
125
126 fn dummy(modifiers: Modifiers) -> KeybindingKeystroke {
127 KeybindingKeystroke {
128 inner: Keystroke {
129 modifiers,
130 key: "".to_string(),
131 key_char: None,
132 },
133 display_modifiers: modifiers,
134 display_key: "".to_string(),
135 }
136 }
137
138 fn keystrokes_changed(&self, cx: &mut Context<Self>) {
139 cx.emit(());
140 cx.notify();
141 }
142
143 fn key_context() -> KeyContext {
144 let mut key_context = KeyContext::default();
145 key_context.add(KEY_CONTEXT_VALUE);
146 key_context
147 }
148
149 fn determine_stop_recording_binding(window: &mut Window) -> Option<gpui::KeyBinding> {
150 if cfg!(test) {
151 Some(gpui::KeyBinding::new(
152 "escape escape escape",
153 StopRecording,
154 Some(KEY_CONTEXT_VALUE),
155 ))
156 } else {
157 window.highest_precedence_binding_for_action_in_context(
158 &StopRecording,
159 Self::key_context(),
160 )
161 }
162 }
163
164 fn upsert_close_keystrokes_start(&mut self, start: usize, cx: &mut Context<Self>) {
165 if self.close_keystrokes_start.is_some() {
166 return;
167 }
168 self.close_keystrokes_start = Some(start);
169 self.update_clear_close_keystrokes_timer(cx);
170 }
171
172 fn update_clear_close_keystrokes_timer(&mut self, cx: &mut Context<Self>) {
173 self.clear_close_keystrokes_timer = Some(cx.spawn(async |this, cx| {
174 cx.background_executor()
175 .timer(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT)
176 .await;
177 this.update(cx, |this, _cx| {
178 this.end_close_keystrokes_capture();
179 })
180 .ok();
181 }));
182 }
183
184 /// Interrupt the capture of close keystrokes, but do not clear the close keystrokes
185 /// from the input
186 fn end_close_keystrokes_capture(&mut self) -> Option<usize> {
187 self.close_keystrokes.take();
188 self.clear_close_keystrokes_timer.take();
189 self.close_keystrokes_start.take()
190 }
191
192 fn handle_possible_close_keystroke(
193 &mut self,
194 keystroke: &Keystroke,
195 window: &mut Window,
196 cx: &mut Context<Self>,
197 ) -> CloseKeystrokeResult {
198 let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else {
199 log::trace!("No keybinding to stop recording keystrokes in keystroke input");
200 self.end_close_keystrokes_capture();
201 return CloseKeystrokeResult::None;
202 };
203 let action_keystrokes = keybind_for_close_action.keystrokes();
204
205 if let Some(mut close_keystrokes) = self.close_keystrokes.take() {
206 let mut index = 0;
207
208 while index < action_keystrokes.len() && index < close_keystrokes.len() {
209 if !close_keystrokes[index].should_match(&action_keystrokes[index]) {
210 break;
211 }
212 index += 1;
213 }
214 if index == close_keystrokes.len() {
215 if index >= action_keystrokes.len() {
216 self.end_close_keystrokes_capture();
217 return CloseKeystrokeResult::None;
218 }
219 if keystroke.should_match(&action_keystrokes[index]) {
220 close_keystrokes.push(keystroke.clone());
221 if close_keystrokes.len() == action_keystrokes.len() {
222 return CloseKeystrokeResult::Close;
223 } else {
224 self.close_keystrokes = Some(close_keystrokes);
225 self.update_clear_close_keystrokes_timer(cx);
226 return CloseKeystrokeResult::Partial;
227 }
228 } else {
229 self.end_close_keystrokes_capture();
230 return CloseKeystrokeResult::None;
231 }
232 }
233 } else if let Some(first_action_keystroke) = action_keystrokes.first()
234 && keystroke.should_match(first_action_keystroke)
235 {
236 self.close_keystrokes = Some(vec![keystroke.clone()]);
237 return CloseKeystrokeResult::Partial;
238 }
239 self.end_close_keystrokes_capture();
240 CloseKeystrokeResult::None
241 }
242
243 fn on_modifiers_changed(
244 &mut self,
245 event: &ModifiersChangedEvent,
246 window: &mut Window,
247 cx: &mut Context<Self>,
248 ) {
249 cx.stop_propagation();
250 let keystrokes_len = self.keystrokes.len();
251
252 if self.previous_modifiers.modified()
253 && event.modifiers.is_subset_of(&self.previous_modifiers)
254 {
255 self.previous_modifiers &= event.modifiers;
256 return;
257 }
258 self.keystrokes_changed(cx);
259
260 if let Some(last) = self.keystrokes.last_mut()
261 && last.display_key.is_empty()
262 && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
263 {
264 if !self.search && !event.modifiers.modified() {
265 self.keystrokes.pop();
266 return;
267 }
268 if self.search {
269 if self.previous_modifiers.modified() {
270 last.display_modifiers |= event.modifiers;
271 last.inner.modifiers |= event.modifiers;
272 } else {
273 self.keystrokes.push(Self::dummy(event.modifiers));
274 }
275 self.previous_modifiers |= event.modifiers;
276 } else {
277 last.display_modifiers = event.modifiers;
278 last.inner.modifiers = event.modifiers;
279 return;
280 }
281 } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
282 self.keystrokes.push(Self::dummy(event.modifiers));
283 if self.search {
284 self.previous_modifiers |= event.modifiers;
285 }
286 }
287 if keystrokes_len >= Self::KEYSTROKE_COUNT_MAX {
288 self.clear_keystrokes(&ClearKeystrokes, window, cx);
289 }
290 }
291
292 fn handle_keystroke(
293 &mut self,
294 keystroke: &Keystroke,
295 window: &mut Window,
296 cx: &mut Context<Self>,
297 ) {
298 cx.stop_propagation();
299
300 let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
301 if close_keystroke_result == CloseKeystrokeResult::Close {
302 self.stop_recording(&StopRecording, window, cx);
303 return;
304 }
305
306 let mut keystroke =
307 KeybindingKeystroke::new(keystroke.clone(), false, cx.keyboard_mapper().as_ref());
308 if let Some(last) = self.keystrokes.last()
309 && last.display_key.is_empty()
310 && (!self.search || self.previous_modifiers.modified())
311 {
312 let display_key = keystroke.display_key.clone();
313 let inner_key = keystroke.inner.key.clone();
314 keystroke = last.clone();
315 keystroke.display_key = display_key;
316 keystroke.inner.key = inner_key;
317 self.keystrokes.pop();
318 }
319
320 if close_keystroke_result == CloseKeystrokeResult::Partial {
321 self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
322 if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
323 return;
324 }
325 }
326
327 if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
328 self.clear_keystrokes(&ClearKeystrokes, window, cx);
329 return;
330 }
331
332 self.keystrokes.push(keystroke.clone());
333 self.keystrokes_changed(cx);
334
335 if self.search {
336 self.previous_modifiers = keystroke.display_modifiers;
337 return;
338 }
339 if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
340 && keystroke.display_modifiers.modified()
341 {
342 self.keystrokes
343 .push(Self::dummy(keystroke.display_modifiers));
344 }
345 }
346
347 fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
348 if self.intercept_subscription.is_none() {
349 let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
350 this.handle_keystroke(&event.keystroke, window, cx);
351 });
352 self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
353 }
354 }
355
356 fn on_inner_focus_out(
357 &mut self,
358 _event: gpui::FocusOutEvent,
359 _window: &mut Window,
360 cx: &mut Context<Self>,
361 ) {
362 self.intercept_subscription.take();
363 cx.notify();
364 }
365
366 fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> {
367 let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
368 && self.keystrokes.is_empty()
369 {
370 if is_recording {
371 &[]
372 } else {
373 placeholders.as_slice()
374 }
375 } else {
376 &self.keystrokes
377 };
378 keystrokes.iter().map(move |keystroke| {
379 h_flex().children(ui::render_keybinding_keystroke(
380 keystroke,
381 Some(Color::Default),
382 Some(rems(0.875).into()),
383 ui::PlatformStyle::platform(),
384 false,
385 ))
386 })
387 }
388
389 pub fn start_recording(
390 &mut self,
391 _: &StartRecording,
392 window: &mut Window,
393 cx: &mut Context<Self>,
394 ) {
395 window.focus(&self.inner_focus_handle);
396 self.clear_keystrokes(&ClearKeystrokes, window, cx);
397 self.previous_modifiers = window.modifiers();
398 #[cfg(test)]
399 {
400 self.recording = true;
401 }
402 cx.stop_propagation();
403 }
404
405 pub fn stop_recording(
406 &mut self,
407 _: &StopRecording,
408 window: &mut Window,
409 cx: &mut Context<Self>,
410 ) {
411 if !self.is_recording(window) {
412 return;
413 }
414 window.focus(&self.outer_focus_handle);
415 if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
416 && close_keystrokes_start < self.keystrokes.len()
417 {
418 self.keystrokes.drain(close_keystrokes_start..);
419 self.keystrokes_changed(cx);
420 }
421 self.end_close_keystrokes_capture();
422 #[cfg(test)]
423 {
424 self.recording = false;
425 }
426 cx.notify();
427 }
428
429 pub fn clear_keystrokes(
430 &mut self,
431 _: &ClearKeystrokes,
432 _window: &mut Window,
433 cx: &mut Context<Self>,
434 ) {
435 self.keystrokes.clear();
436 self.keystrokes_changed(cx);
437 self.end_close_keystrokes_capture();
438 }
439
440 fn is_recording(&self, window: &Window) -> bool {
441 #[cfg(test)]
442 {
443 if true {
444 // in tests, we just need a simple bool that is toggled on start and stop recording
445 return self.recording;
446 }
447 }
448 // however, in the real world, checking if the inner focus handle is focused
449 // is a much more reliable check, as the intercept keystroke handlers are installed
450 // on focus of the inner focus handle, thereby ensuring our recording state does
451 // not get de-synced
452 self.inner_focus_handle.is_focused(window)
453 }
454}
455
456impl EventEmitter<()> for KeystrokeInput {}
457
458impl Focusable for KeystrokeInput {
459 fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
460 self.outer_focus_handle.clone()
461 }
462}
463
464impl Render for KeystrokeInput {
465 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
466 let colors = cx.theme().colors();
467 let is_focused = self.outer_focus_handle.contains_focused(window, cx);
468 let is_recording = self.is_recording(window);
469
470 let horizontal_padding = rems_from_px(64.);
471
472 let recording_bg_color = colors
473 .editor_background
474 .blend(colors.text_accent.opacity(0.1));
475
476 let recording_pulse = |color: Color| {
477 Icon::new(IconName::Circle)
478 .size(IconSize::Small)
479 .color(Color::Error)
480 .with_animation(
481 "recording-pulse",
482 Animation::new(std::time::Duration::from_secs(2))
483 .repeat()
484 .with_easing(gpui::pulsating_between(0.4, 0.8)),
485 {
486 let color = color.color(cx);
487 move |this, delta| this.color(Color::Custom(color.opacity(delta)))
488 },
489 )
490 };
491
492 let recording_indicator = h_flex()
493 .h_4()
494 .pr_1()
495 .gap_0p5()
496 .border_1()
497 .border_color(colors.border)
498 .bg(colors
499 .editor_background
500 .blend(colors.text_accent.opacity(0.1)))
501 .rounded_sm()
502 .child(recording_pulse(Color::Error))
503 .child(
504 Label::new("REC")
505 .size(LabelSize::XSmall)
506 .weight(FontWeight::SEMIBOLD)
507 .color(Color::Error),
508 );
509
510 let search_indicator = h_flex()
511 .h_4()
512 .pr_1()
513 .gap_0p5()
514 .border_1()
515 .border_color(colors.border)
516 .bg(colors
517 .editor_background
518 .blend(colors.text_accent.opacity(0.1)))
519 .rounded_sm()
520 .child(recording_pulse(Color::Accent))
521 .child(
522 Label::new("SEARCH")
523 .size(LabelSize::XSmall)
524 .weight(FontWeight::SEMIBOLD)
525 .color(Color::Accent),
526 );
527
528 let record_icon = if self.search {
529 IconName::MagnifyingGlass
530 } else {
531 IconName::PlayFilled
532 };
533
534 h_flex()
535 .id("keystroke-input")
536 .track_focus(&self.outer_focus_handle)
537 .py_2()
538 .px_3()
539 .gap_2()
540 .min_h_10()
541 .w_full()
542 .flex_1()
543 .justify_between()
544 .rounded_sm()
545 .overflow_hidden()
546 .map(|this| {
547 if is_recording {
548 this.bg(recording_bg_color)
549 } else {
550 this.bg(colors.editor_background)
551 }
552 })
553 .border_1()
554 .border_color(colors.border_variant)
555 .when(is_focused, |parent| {
556 parent.border_color(colors.border_focused)
557 })
558 .key_context(Self::key_context())
559 .on_action(cx.listener(Self::start_recording))
560 .on_action(cx.listener(Self::clear_keystrokes))
561 .child(
562 h_flex()
563 .w(horizontal_padding)
564 .gap_0p5()
565 .justify_start()
566 .flex_none()
567 .when(is_recording, |this| {
568 this.map(|this| {
569 if self.search {
570 this.child(search_indicator)
571 } else {
572 this.child(recording_indicator)
573 }
574 })
575 }),
576 )
577 .child(
578 h_flex()
579 .id("keystroke-input-inner")
580 .track_focus(&self.inner_focus_handle)
581 .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
582 .size_full()
583 .when(!self.search, |this| {
584 this.focus(|mut style| {
585 style.border_color = Some(colors.border_focused);
586 style
587 })
588 })
589 .w_full()
590 .min_w_0()
591 .justify_center()
592 .flex_wrap()
593 .gap(ui::DynamicSpacing::Base04.rems(cx))
594 .children(self.render_keystrokes(is_recording)),
595 )
596 .child(
597 h_flex()
598 .w(horizontal_padding)
599 .gap_0p5()
600 .justify_end()
601 .flex_none()
602 .map(|this| {
603 if is_recording {
604 this.child(
605 IconButton::new("stop-record-btn", IconName::Stop)
606 .shape(IconButtonShape::Square)
607 .map(|this| {
608 this.tooltip(Tooltip::for_action_title(
609 if self.search {
610 "Stop Searching"
611 } else {
612 "Stop Recording"
613 },
614 &StopRecording,
615 ))
616 })
617 .icon_color(Color::Error)
618 .on_click(cx.listener(|this, _event, window, cx| {
619 this.stop_recording(&StopRecording, window, cx);
620 })),
621 )
622 } else {
623 this.child(
624 IconButton::new("record-btn", record_icon)
625 .shape(IconButtonShape::Square)
626 .map(|this| {
627 this.tooltip(Tooltip::for_action_title(
628 if self.search {
629 "Start Searching"
630 } else {
631 "Start Recording"
632 },
633 &StartRecording,
634 ))
635 })
636 .when(!is_focused, |this| this.icon_color(Color::Muted))
637 .on_click(cx.listener(|this, _event, window, cx| {
638 this.start_recording(&StartRecording, window, cx);
639 })),
640 )
641 }
642 })
643 .child(
644 IconButton::new("clear-btn", IconName::Backspace)
645 .shape(IconButtonShape::Square)
646 .tooltip(Tooltip::for_action_title(
647 "Clear Keystrokes",
648 &ClearKeystrokes,
649 ))
650 .when(!is_recording || !is_focused, |this| {
651 this.icon_color(Color::Muted)
652 })
653 .on_click(cx.listener(|this, _event, window, cx| {
654 this.clear_keystrokes(&ClearKeystrokes, window, cx);
655 })),
656 ),
657 )
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use fs::FakeFs;
665 use gpui::{Entity, TestAppContext, VisualTestContext};
666 use itertools::Itertools as _;
667 use project::Project;
668 use settings::SettingsStore;
669 use workspace::Workspace;
670
671 pub struct KeystrokeInputTestHelper {
672 input: Entity<KeystrokeInput>,
673 current_modifiers: Modifiers,
674 cx: VisualTestContext,
675 }
676
677 impl KeystrokeInputTestHelper {
678 /// Creates a new test helper with default settings
679 pub fn new(mut cx: VisualTestContext) -> Self {
680 let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx));
681
682 let mut helper = Self {
683 input,
684 current_modifiers: Modifiers::default(),
685 cx,
686 };
687
688 helper.start_recording();
689 helper
690 }
691
692 /// Sets search mode on the input
693 pub fn with_search_mode(&mut self, search: bool) -> &mut Self {
694 self.input.update(&mut self.cx, |input, _| {
695 input.set_search(search);
696 });
697 self
698 }
699
700 /// Sends a keystroke event based on string description
701 /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
702 #[track_caller]
703 pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
704 self.expect_is_recording(true);
705 let keystroke_str = if keystroke_input.ends_with('-') {
706 format!("{}_", keystroke_input)
707 } else {
708 keystroke_input.to_string()
709 };
710
711 let mut keystroke = Keystroke::parse(&keystroke_str)
712 .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input));
713
714 // Remove the dummy key if we added it for modifier-only keystrokes
715 if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") {
716 keystroke.key = "".to_string();
717 }
718
719 // Combine current modifiers with keystroke modifiers
720 keystroke.modifiers |= self.current_modifiers;
721
722 self.update_input(|input, window, cx| {
723 input.handle_keystroke(&keystroke, window, cx);
724 });
725
726 // Don't update current_modifiers for keystrokes with actual keys
727 if keystroke.key.is_empty() {
728 self.current_modifiers = keystroke.modifiers;
729 }
730 self
731 }
732
733 /// Sends a modifier change event based on string description
734 /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
735 #[track_caller]
736 pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
737 self.expect_is_recording(true);
738 let new_modifiers = if modifiers == "-all" {
739 Modifiers::default()
740 } else {
741 self.parse_modifier_change(modifiers)
742 };
743
744 let event = ModifiersChangedEvent {
745 modifiers: new_modifiers,
746 capslock: gpui::Capslock::default(),
747 };
748
749 self.update_input(|input, window, cx| {
750 input.on_modifiers_changed(&event, window, cx);
751 });
752
753 self.current_modifiers = new_modifiers;
754 self
755 }
756
757 /// Sends multiple events in sequence
758 /// Each event string is either a keystroke or modifier change
759 #[track_caller]
760 pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
761 self.expect_is_recording(true);
762 for event in events {
763 if event.starts_with('+') || event.starts_with('-') {
764 self.send_modifiers(event);
765 } else {
766 self.send_keystroke(event);
767 }
768 }
769 self
770 }
771
772 #[track_caller]
773 fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) {
774 let expected_keystrokes: Result<Vec<Keystroke>, _> = expected
775 .iter()
776 .map(|s| {
777 let keystroke_str = if s.ends_with('-') {
778 format!("{}_", s)
779 } else {
780 s.to_string()
781 };
782
783 let mut keystroke = Keystroke::parse(&keystroke_str)?;
784
785 // Remove the dummy key if we added it for modifier-only keystrokes
786 if s.ends_with('-') && keystroke_str.ends_with("_") {
787 keystroke.key = "".to_string();
788 }
789
790 Ok(keystroke)
791 })
792 .collect();
793
794 let expected_keystrokes = expected_keystrokes
795 .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e));
796
797 assert_eq!(
798 actual.len(),
799 expected_keystrokes.len(),
800 "Keystroke count mismatch. Expected: {:?}, Actual: {:?}",
801 expected_keystrokes
802 .iter()
803 .map(|k| k.unparse())
804 .collect::<Vec<_>>(),
805 actual.iter().map(|k| k.unparse()).collect::<Vec<_>>()
806 );
807
808 for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate()
809 {
810 assert_eq!(
811 actual.unparse(),
812 expected.unparse(),
813 "Keystroke {} mismatch. Expected: '{}', Actual: '{}'",
814 i,
815 expected.unparse(),
816 actual.unparse()
817 );
818 }
819 }
820
821 /// Verifies that the keystrokes match the expected strings
822 #[track_caller]
823 pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
824 let actual: Vec<Keystroke> = self.input.read_with(&self.cx, |input, _| {
825 input
826 .keystrokes
827 .iter()
828 .map(|keystroke| keystroke.inner.clone())
829 .collect()
830 });
831 Self::expect_keystrokes_equal(&actual, expected);
832 self
833 }
834
835 #[track_caller]
836 pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
837 let actual = self
838 .input
839 .read_with(&self.cx, |input, _| input.close_keystrokes.clone())
840 .unwrap_or_default();
841 Self::expect_keystrokes_equal(&actual, expected);
842 self
843 }
844
845 /// Verifies that there are no keystrokes
846 #[track_caller]
847 pub fn expect_empty(&mut self) -> &mut Self {
848 self.expect_keystrokes(&[])
849 }
850
851 /// Starts recording keystrokes
852 #[track_caller]
853 pub fn start_recording(&mut self) -> &mut Self {
854 self.expect_is_recording(false);
855 self.input.update_in(&mut self.cx, |input, window, cx| {
856 input.start_recording(&StartRecording, window, cx);
857 });
858 self
859 }
860
861 /// Stops recording keystrokes
862 pub fn stop_recording(&mut self) -> &mut Self {
863 self.expect_is_recording(true);
864 self.input.update_in(&mut self.cx, |input, window, cx| {
865 input.stop_recording(&StopRecording, window, cx);
866 });
867 self
868 }
869
870 /// Clears all keystrokes
871 #[track_caller]
872 pub fn clear_keystrokes(&mut self) -> &mut Self {
873 let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
874 self.input.update_in(&mut self.cx, |input, window, cx| {
875 input.clear_keystrokes(&ClearKeystrokes, window, cx);
876 });
877 KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
878 self.current_modifiers = Default::default();
879 self
880 }
881
882 /// Verifies the recording state
883 #[track_caller]
884 pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self {
885 let actual = self
886 .input
887 .update_in(&mut self.cx, |input, window, _| input.is_recording(window));
888 assert_eq!(
889 actual, expected,
890 "Recording state mismatch. Expected: {}, Actual: {}",
891 expected, actual
892 );
893 self
894 }
895
896 pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self {
897 let task = self.input.update_in(&mut self.cx, |input, _, _| {
898 input.clear_close_keystrokes_timer.take()
899 });
900 let task = task.expect("No close keystroke capture end timer task");
901 self.cx
902 .executor()
903 .advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT);
904 task.await;
905 self
906 }
907
908 /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
909 #[track_caller]
910 fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
911 let mut modifiers = self.current_modifiers;
912
913 assert!(!modifiers_str.is_empty(), "Empty modifier string");
914
915 let value;
916 let split_char;
917 let remaining;
918 if let Some(to_add) = modifiers_str.strip_prefix('+') {
919 value = true;
920 split_char = '+';
921 remaining = to_add;
922 } else {
923 let to_remove = modifiers_str
924 .strip_prefix('-')
925 .expect("Modifier string must start with '+' or '-'");
926 value = false;
927 split_char = '-';
928 remaining = to_remove;
929 }
930
931 for modifier in remaining.split(split_char) {
932 match modifier {
933 "ctrl" | "control" => modifiers.control = value,
934 "alt" | "option" => modifiers.alt = value,
935 "shift" => modifiers.shift = value,
936 "cmd" | "command" | "platform" => modifiers.platform = value,
937 "fn" | "function" => modifiers.function = value,
938 _ => panic!("Unknown modifier: {}", modifier),
939 }
940 }
941
942 modifiers
943 }
944
945 #[track_caller]
946 fn update_input<R>(
947 &mut self,
948 cb: impl FnOnce(&mut KeystrokeInput, &mut Window, &mut Context<KeystrokeInput>) -> R,
949 ) -> R {
950 let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
951 let result = self.input.update_in(&mut self.cx, cb);
952 KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
953 result
954 }
955 }
956
957 struct KeystrokeUpdateTracker {
958 initial_keystrokes: Vec<KeybindingKeystroke>,
959 _subscription: Subscription,
960 input: Entity<KeystrokeInput>,
961 received_keystrokes_updated: bool,
962 }
963
964 impl KeystrokeUpdateTracker {
965 fn new(input: Entity<KeystrokeInput>, cx: &mut VisualTestContext) -> Entity<Self> {
966 cx.new(|cx| Self {
967 initial_keystrokes: input.read_with(cx, |input, _| input.keystrokes.clone()),
968 _subscription: cx.subscribe(&input, |this: &mut Self, _, _, _| {
969 this.received_keystrokes_updated = true;
970 }),
971 input,
972 received_keystrokes_updated: false,
973 })
974 }
975 #[track_caller]
976 fn finish(this: Entity<Self>, cx: &VisualTestContext) {
977 let (received_keystrokes_updated, initial_keystrokes_str, updated_keystrokes_str) =
978 this.read_with(cx, |this, cx| {
979 let updated_keystrokes = this
980 .input
981 .read_with(cx, |input, _| input.keystrokes.clone());
982 let initial_keystrokes_str = keystrokes_str(&this.initial_keystrokes);
983 let updated_keystrokes_str = keystrokes_str(&updated_keystrokes);
984 (
985 this.received_keystrokes_updated,
986 initial_keystrokes_str,
987 updated_keystrokes_str,
988 )
989 });
990 if received_keystrokes_updated {
991 assert_ne!(
992 initial_keystrokes_str, updated_keystrokes_str,
993 "Received keystrokes_updated event, expected different keystrokes"
994 );
995 } else {
996 assert_eq!(
997 initial_keystrokes_str, updated_keystrokes_str,
998 "Received no keystrokes_updated event, expected same keystrokes"
999 );
1000 }
1001
1002 fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String {
1003 ks.iter().map(|ks| ks.inner.unparse()).join(" ")
1004 }
1005 }
1006 }
1007
1008 async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper {
1009 cx.update(|cx| {
1010 let settings_store = SettingsStore::test(cx);
1011 cx.set_global(settings_store);
1012 theme::init(theme::LoadThemes::JustBase, cx);
1013 language::init(cx);
1014 project::Project::init_settings(cx);
1015 workspace::init_settings(cx);
1016 });
1017
1018 let fs = FakeFs::new(cx.executor());
1019 let project = Project::test(fs, [], cx).await;
1020 let workspace =
1021 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1022 let cx = VisualTestContext::from_window(*workspace, cx);
1023 KeystrokeInputTestHelper::new(cx)
1024 }
1025
1026 #[gpui::test]
1027 async fn test_basic_keystroke_input(cx: &mut TestAppContext) {
1028 init_test(cx)
1029 .await
1030 .send_keystroke("a")
1031 .clear_keystrokes()
1032 .expect_empty();
1033 }
1034
1035 #[gpui::test]
1036 async fn test_modifier_handling(cx: &mut TestAppContext) {
1037 init_test(cx)
1038 .await
1039 .with_search_mode(true)
1040 .send_events(&["+ctrl", "a", "-ctrl"])
1041 .expect_keystrokes(&["ctrl-a"]);
1042 }
1043
1044 #[gpui::test]
1045 async fn test_multiple_modifiers(cx: &mut TestAppContext) {
1046 init_test(cx)
1047 .await
1048 .send_keystroke("cmd-shift-z")
1049 .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]);
1050 }
1051
1052 #[gpui::test]
1053 async fn test_search_mode_behavior(cx: &mut TestAppContext) {
1054 init_test(cx)
1055 .await
1056 .with_search_mode(true)
1057 .send_events(&["+cmd", "shift-f", "-cmd"])
1058 // In search mode, when completing a modifier-only keystroke with a key,
1059 // only the original modifiers are preserved, not the keystroke's modifiers
1060 .expect_keystrokes(&["cmd-f"]);
1061 }
1062
1063 #[gpui::test]
1064 async fn test_keystroke_limit(cx: &mut TestAppContext) {
1065 init_test(cx)
1066 .await
1067 .send_keystroke("a")
1068 .send_keystroke("b")
1069 .send_keystroke("c")
1070 .expect_keystrokes(&["a", "b", "c"]) // At max limit
1071 .send_keystroke("d")
1072 .expect_empty(); // Should clear when exceeding limit
1073 }
1074
1075 #[gpui::test]
1076 async fn test_modifier_release_all(cx: &mut TestAppContext) {
1077 init_test(cx)
1078 .await
1079 .with_search_mode(true)
1080 .send_events(&["+ctrl+shift", "a", "-all"])
1081 .expect_keystrokes(&["ctrl-shift-a"]);
1082 }
1083
1084 #[gpui::test]
1085 async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) {
1086 init_test(cx)
1087 .await
1088 .with_search_mode(true)
1089 .send_events(&["+ctrl+shift", "a", "-ctrl"])
1090 .expect_keystrokes(&["ctrl-shift-a"])
1091 .send_events(&["+ctrl"])
1092 .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]);
1093 }
1094
1095 #[gpui::test]
1096 async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) {
1097 init_test(cx)
1098 .await
1099 .with_search_mode(false)
1100 .send_events(&["+ctrl+shift", "a", "-all"])
1101 .expect_keystrokes(&["ctrl-shift-a"]);
1102 }
1103
1104 #[gpui::test]
1105 async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) {
1106 init_test(cx)
1107 .await
1108 .with_search_mode(false)
1109 .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3
1110 .expect_empty(); // Should clear when exceeding limit
1111 }
1112
1113 #[gpui::test]
1114 async fn test_complex_modifier_sequences(cx: &mut TestAppContext) {
1115 init_test(cx)
1116 .await
1117 .with_search_mode(true)
1118 .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"])
1119 .expect_keystrokes(&["ctrl-shift-alt-a"]);
1120 }
1121
1122 #[gpui::test]
1123 async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) {
1124 init_test(cx)
1125 .await
1126 .with_search_mode(true)
1127 .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1128 .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes
1129 }
1130
1131 #[gpui::test]
1132 async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) {
1133 init_test(cx)
1134 .await
1135 .with_search_mode(false)
1136 .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1137 .expect_empty(); // Modifier-only sequences get filtered in non-search mode
1138 }
1139
1140 #[gpui::test]
1141 async fn test_rapid_modifier_changes(cx: &mut TestAppContext) {
1142 init_test(cx)
1143 .await
1144 .with_search_mode(true)
1145 .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"])
1146 .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]);
1147 }
1148
1149 #[gpui::test]
1150 async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) {
1151 init_test(cx)
1152 .await
1153 .with_search_mode(true)
1154 .send_events(&["+ctrl", "a", "-ctrl", "b"])
1155 .expect_keystrokes(&["ctrl-a", "b"])
1156 .clear_keystrokes()
1157 .expect_empty();
1158 }
1159
1160 #[gpui::test]
1161 async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) {
1162 init_test(cx)
1163 .await
1164 .with_search_mode(false)
1165 .send_events(&["+ctrl", "a"])
1166 .expect_keystrokes(&["ctrl-a", "ctrl-"])
1167 .send_events(&["-ctrl"])
1168 .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes
1169 }
1170
1171 #[gpui::test]
1172 async fn test_all_modifiers_at_once(cx: &mut TestAppContext) {
1173 init_test(cx)
1174 .await
1175 .with_search_mode(true)
1176 .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"])
1177 .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]);
1178 }
1179
1180 #[gpui::test]
1181 async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) {
1182 init_test(cx)
1183 .await
1184 .with_search_mode(true)
1185 .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit)
1186 .expect_keystrokes(&["a", "b", "c"])
1187 .send_events(&["d"]) // should clear when exceeding
1188 .expect_empty();
1189 }
1190
1191 #[gpui::test]
1192 async fn test_function_modifier_key(cx: &mut TestAppContext) {
1193 init_test(cx)
1194 .await
1195 .with_search_mode(true)
1196 .send_events(&["+fn", "f1", "-fn"])
1197 .expect_keystrokes(&["fn-f1"]);
1198 }
1199
1200 #[gpui::test]
1201 async fn test_start_stop_recording(cx: &mut TestAppContext) {
1202 init_test(cx)
1203 .await
1204 .send_events(&["a", "b"])
1205 .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes
1206 .stop_recording()
1207 .expect_is_recording(false)
1208 .start_recording()
1209 .send_events(&["c"])
1210 .expect_keystrokes(&["c"]);
1211 }
1212
1213 #[gpui::test]
1214 async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) {
1215 init_test(cx)
1216 .await
1217 .with_search_mode(true)
1218 .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"])
1219 .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]);
1220 }
1221
1222 #[gpui::test]
1223 async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) {
1224 init_test(cx)
1225 .await
1226 .with_search_mode(true)
1227 .send_events(&[]) // No events at all
1228 .expect_empty();
1229 }
1230
1231 #[gpui::test]
1232 async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) {
1233 init_test(cx)
1234 .await
1235 .with_search_mode(true)
1236 .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"])
1237 .expect_keystrokes(&["ctrl-shift-a"]);
1238 }
1239
1240 #[gpui::test]
1241 async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) {
1242 init_test(cx)
1243 .await
1244 .with_search_mode(true)
1245 .send_events(&["a", "escape", "escape", "escape"])
1246 .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording
1247 .expect_is_recording(false);
1248 }
1249
1250 #[gpui::test]
1251 async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) {
1252 init_test(cx)
1253 .await
1254 .with_search_mode(false)
1255 .send_events(&["a", "escape", "escape", "escape"])
1256 .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape
1257 }
1258
1259 #[gpui::test]
1260 async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) {
1261 init_test(cx)
1262 .await
1263 .with_search_mode(true)
1264 .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit
1265 .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes
1266 }
1267
1268 #[gpui::test]
1269 async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) {
1270 init_test(cx)
1271 .await
1272 .with_search_mode(true)
1273 .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a'
1274 .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered
1275 }
1276
1277 #[gpui::test]
1278 async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) {
1279 init_test(cx)
1280 .await
1281 .with_search_mode(true)
1282 .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit)
1283 .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection
1284 }
1285
1286 #[gpui::test]
1287 async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) {
1288 init_test(cx)
1289 .await
1290 .with_search_mode(true)
1291 .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close
1292 .expect_keystrokes(&["escape", "escape"])
1293 .expect_is_recording(true); // Should remain in keystrokes, no close triggered
1294 }
1295
1296 #[gpui::test]
1297 async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) {
1298 init_test(cx)
1299 .await
1300 .with_search_mode(true)
1301 .send_events(&["a", "escape", "escape", "escape"])
1302 .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape
1303 .expect_is_recording(false);
1304 }
1305
1306 #[gpui::test]
1307 async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) {
1308 init_test(cx)
1309 .await
1310 .with_search_mode(true)
1311 .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close
1312 .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit
1313 }
1314
1315 #[gpui::test]
1316 async fn test_triple_escape_only(cx: &mut TestAppContext) {
1317 init_test(cx)
1318 .await
1319 .with_search_mode(true)
1320 .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
1321 .expect_empty();
1322 }
1323
1324 #[gpui::test]
1325 async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) {
1326 init_test(cx)
1327 .await
1328 .send_events(&["+ctrl", "g", "-ctrl", "escape"])
1329 .expect_keystrokes(&["ctrl-g", "escape"])
1330 .wait_for_close_keystroke_capture_end()
1331 .await
1332 .send_events(&["escape", "escape"])
1333 .expect_keystrokes(&["ctrl-g", "escape", "escape"])
1334 .expect_close_keystrokes(&["escape", "escape"])
1335 .send_keystroke("escape")
1336 .expect_keystrokes(&["ctrl-g", "escape"]);
1337 }
1338
1339 #[gpui::test]
1340 async fn test_search_previous_modifiers_are_sticky(cx: &mut TestAppContext) {
1341 init_test(cx)
1342 .await
1343 .with_search_mode(true)
1344 .send_events(&["+ctrl+alt", "-ctrl", "j"])
1345 .expect_keystrokes(&["ctrl-alt-j"]);
1346 }
1347
1348 #[gpui::test]
1349 async fn test_previous_modifiers_can_be_entered_separately(cx: &mut TestAppContext) {
1350 init_test(cx)
1351 .await
1352 .with_search_mode(true)
1353 .send_events(&["+ctrl", "-ctrl"])
1354 .expect_keystrokes(&["ctrl-"])
1355 .send_events(&["+alt", "-alt"])
1356 .expect_keystrokes(&["ctrl-", "alt-"]);
1357 }
1358
1359 #[gpui::test]
1360 async fn test_previous_modifiers_reset_on_key(cx: &mut TestAppContext) {
1361 init_test(cx)
1362 .await
1363 .with_search_mode(true)
1364 .send_events(&["+ctrl+alt", "-ctrl", "+shift"])
1365 .expect_keystrokes(&["ctrl-shift-alt-"])
1366 .send_keystroke("j")
1367 .expect_keystrokes(&["ctrl-shift-alt-j"])
1368 .send_keystroke("i")
1369 .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"])
1370 .send_events(&["-shift-alt", "+cmd"])
1371 .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]);
1372 }
1373
1374 #[gpui::test]
1375 async fn test_previous_modifiers_reset_on_release_all(cx: &mut TestAppContext) {
1376 init_test(cx)
1377 .await
1378 .with_search_mode(true)
1379 .send_events(&["+ctrl+alt", "-ctrl", "+shift"])
1380 .expect_keystrokes(&["ctrl-shift-alt-"])
1381 .send_events(&["-all", "j"])
1382 .expect_keystrokes(&["ctrl-shift-alt-", "j"]);
1383 }
1384
1385 #[gpui::test]
1386 async fn test_search_repeat_modifiers(cx: &mut TestAppContext) {
1387 init_test(cx)
1388 .await
1389 .with_search_mode(true)
1390 .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
1391 .expect_keystrokes(&["ctrl-", "alt-", "shift-"])
1392 .send_events(&["+cmd"])
1393 .expect_empty();
1394 }
1395
1396 #[gpui::test]
1397 async fn test_not_search_repeat_modifiers(cx: &mut TestAppContext) {
1398 init_test(cx)
1399 .await
1400 .with_search_mode(false)
1401 .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
1402 .expect_empty();
1403 }
1404}