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