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