1use gpui::{
2 Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
3 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: &'static 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<Keystroke>,
46 placeholder_keystrokes: Option<Vec<Keystroke>>,
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<Keystroke>>,
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<Keystroke>, 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) -> &[Keystroke] {
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 .map_or(false, |last| last.key.is_empty())
120 {
121 return &self.keystrokes[..self.keystrokes.len() - 1];
122 }
123 return &self.keystrokes;
124 }
125
126 fn dummy(modifiers: Modifiers) -> Keystroke {
127 return 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 return 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 return 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 let keystrokes_len = self.keystrokes.len();
246
247 if self.previous_modifiers.modified()
248 && event.modifiers.is_subset_of(&self.previous_modifiers)
249 {
250 self.previous_modifiers &= event.modifiers;
251 cx.stop_propagation();
252 return;
253 }
254
255 if let Some(last) = self.keystrokes.last_mut()
256 && last.key.is_empty()
257 && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
258 {
259 if self.search {
260 if self.previous_modifiers.modified() {
261 last.modifiers |= event.modifiers;
262 self.previous_modifiers |= event.modifiers;
263 } else {
264 self.keystrokes.push(Self::dummy(event.modifiers));
265 self.previous_modifiers |= event.modifiers;
266 }
267 } else if !event.modifiers.modified() {
268 self.keystrokes.pop();
269 } else {
270 last.modifiers = event.modifiers;
271 }
272
273 self.keystrokes_changed(cx);
274 } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
275 self.keystrokes.push(Self::dummy(event.modifiers));
276 if self.search {
277 self.previous_modifiers |= event.modifiers;
278 }
279 self.keystrokes_changed(cx);
280 }
281 cx.stop_propagation();
282 }
283
284 fn handle_keystroke(
285 &mut self,
286 keystroke: &Keystroke,
287 window: &mut Window,
288 cx: &mut Context<Self>,
289 ) {
290 let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
291 if close_keystroke_result == CloseKeystrokeResult::Close {
292 self.stop_recording(&StopRecording, window, cx);
293 return;
294 }
295 let key_len = self.keystrokes.len();
296 if let Some(last) = self.keystrokes.last_mut()
297 && last.key.is_empty()
298 && key_len <= Self::KEYSTROKE_COUNT_MAX
299 {
300 if self.search {
301 last.key = keystroke.key.clone();
302 if close_keystroke_result == CloseKeystrokeResult::Partial
303 && self.close_keystrokes_start.is_none()
304 {
305 self.upsert_close_keystrokes_start(self.keystrokes.len() - 1, cx);
306 }
307 if self.search {
308 self.previous_modifiers = keystroke.modifiers;
309 } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
310 && keystroke.modifiers.modified()
311 {
312 self.keystrokes.push(Self::dummy(keystroke.modifiers));
313 }
314 self.keystrokes_changed(cx);
315 cx.stop_propagation();
316 return;
317 } else {
318 self.keystrokes.pop();
319 }
320 }
321 if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
322 if close_keystroke_result == CloseKeystrokeResult::Partial
323 && self.close_keystrokes_start.is_none()
324 {
325 self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
326 }
327 self.keystrokes.push(keystroke.clone());
328 if self.search {
329 self.previous_modifiers = keystroke.modifiers;
330 } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
331 && keystroke.modifiers.modified()
332 {
333 self.keystrokes.push(Self::dummy(keystroke.modifiers));
334 }
335 } else if close_keystroke_result != CloseKeystrokeResult::Partial {
336 self.clear_keystrokes(&ClearKeystrokes, window, cx);
337 }
338 self.keystrokes_changed(cx);
339 cx.stop_propagation();
340 }
341
342 fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
343 if self.intercept_subscription.is_none() {
344 let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
345 this.handle_keystroke(&event.keystroke, window, cx);
346 });
347 self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
348 }
349 }
350
351 fn on_inner_focus_out(
352 &mut self,
353 _event: gpui::FocusOutEvent,
354 _window: &mut Window,
355 cx: &mut Context<Self>,
356 ) {
357 self.intercept_subscription.take();
358 cx.notify();
359 }
360
361 fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> {
362 let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
363 && self.keystrokes.is_empty()
364 {
365 if is_recording {
366 &[]
367 } else {
368 placeholders.as_slice()
369 }
370 } else {
371 &self.keystrokes
372 };
373 keystrokes.iter().map(move |keystroke| {
374 h_flex().children(ui::render_keystroke(
375 keystroke,
376 Some(Color::Default),
377 Some(rems(0.875).into()),
378 ui::PlatformStyle::platform(),
379 false,
380 ))
381 })
382 }
383
384 pub fn start_recording(
385 &mut self,
386 _: &StartRecording,
387 window: &mut Window,
388 cx: &mut Context<Self>,
389 ) {
390 window.focus(&self.inner_focus_handle);
391 self.clear_keystrokes(&ClearKeystrokes, window, cx);
392 self.previous_modifiers = window.modifiers();
393 #[cfg(test)]
394 {
395 self.recording = true;
396 }
397 cx.stop_propagation();
398 }
399
400 pub fn stop_recording(
401 &mut self,
402 _: &StopRecording,
403 window: &mut Window,
404 cx: &mut Context<Self>,
405 ) {
406 if !self.is_recording(window) {
407 return;
408 }
409 window.focus(&self.outer_focus_handle);
410 if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
411 && close_keystrokes_start < self.keystrokes.len()
412 {
413 self.keystrokes.drain(close_keystrokes_start..);
414 self.keystrokes_changed(cx);
415 }
416 self.end_close_keystrokes_capture();
417 #[cfg(test)]
418 {
419 self.recording = false;
420 }
421 cx.notify();
422 }
423
424 pub fn clear_keystrokes(
425 &mut self,
426 _: &ClearKeystrokes,
427 _window: &mut Window,
428 cx: &mut Context<Self>,
429 ) {
430 self.keystrokes.clear();
431 self.keystrokes_changed(cx);
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 return 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 horizontal_padding = 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 .py_2()
532 .px_3()
533 .gap_2()
534 .min_h_10()
535 .w_full()
536 .flex_1()
537 .justify_between()
538 .rounded_lg()
539 .overflow_hidden()
540 .map(|this| {
541 if is_recording {
542 this.bg(recording_bg_color)
543 } else {
544 this.bg(colors.editor_background)
545 }
546 })
547 .border_1()
548 .border_color(colors.border_variant)
549 .when(is_focused, |parent| {
550 parent.border_color(colors.border_focused)
551 })
552 .key_context(Self::key_context())
553 .on_action(cx.listener(Self::start_recording))
554 .on_action(cx.listener(Self::clear_keystrokes))
555 .child(
556 h_flex()
557 .w(horizontal_padding)
558 .gap_0p5()
559 .justify_start()
560 .flex_none()
561 .when(is_recording, |this| {
562 this.map(|this| {
563 if self.search {
564 this.child(search_indicator)
565 } else {
566 this.child(recording_indicator)
567 }
568 })
569 }),
570 )
571 .child(
572 h_flex()
573 .id("keystroke-input-inner")
574 .track_focus(&self.inner_focus_handle)
575 .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
576 .size_full()
577 .when(!self.search, |this| {
578 this.focus(|mut style| {
579 style.border_color = Some(colors.border_focused);
580 style
581 })
582 })
583 .w_full()
584 .min_w_0()
585 .justify_center()
586 .flex_wrap()
587 .gap(ui::DynamicSpacing::Base04.rems(cx))
588 .children(self.render_keystrokes(is_recording)),
589 )
590 .child(
591 h_flex()
592 .w(horizontal_padding)
593 .gap_0p5()
594 .justify_end()
595 .flex_none()
596 .map(|this| {
597 if is_recording {
598 this.child(
599 IconButton::new("stop-record-btn", IconName::StopFilled)
600 .shape(IconButtonShape::Square)
601 .map(|this| {
602 this.tooltip(Tooltip::for_action_title(
603 if self.search {
604 "Stop Searching"
605 } else {
606 "Stop Recording"
607 },
608 &StopRecording,
609 ))
610 })
611 .icon_color(Color::Error)
612 .on_click(cx.listener(|this, _event, window, cx| {
613 this.stop_recording(&StopRecording, window, cx);
614 })),
615 )
616 } else {
617 this.child(
618 IconButton::new("record-btn", record_icon)
619 .shape(IconButtonShape::Square)
620 .map(|this| {
621 this.tooltip(Tooltip::for_action_title(
622 if self.search {
623 "Start Searching"
624 } else {
625 "Start Recording"
626 },
627 &StartRecording,
628 ))
629 })
630 .when(!is_focused, |this| this.icon_color(Color::Muted))
631 .on_click(cx.listener(|this, _event, window, cx| {
632 this.start_recording(&StartRecording, window, cx);
633 })),
634 )
635 }
636 })
637 .child(
638 IconButton::new("clear-btn", IconName::Delete)
639 .shape(IconButtonShape::Square)
640 .tooltip(Tooltip::for_action_title(
641 "Clear Keystrokes",
642 &ClearKeystrokes,
643 ))
644 .when(!is_recording || !is_focused, |this| {
645 this.icon_color(Color::Muted)
646 })
647 .on_click(cx.listener(|this, _event, window, cx| {
648 this.clear_keystrokes(&ClearKeystrokes, window, cx);
649 })),
650 ),
651 )
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use fs::FakeFs;
659 use gpui::{Entity, TestAppContext, VisualTestContext};
660 use project::Project;
661 use settings::SettingsStore;
662 use workspace::Workspace;
663
664 pub struct KeystrokeInputTestHelper {
665 input: Entity<KeystrokeInput>,
666 current_modifiers: Modifiers,
667 cx: VisualTestContext,
668 }
669
670 impl KeystrokeInputTestHelper {
671 /// Creates a new test helper with default settings
672 pub fn new(mut cx: VisualTestContext) -> Self {
673 let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx));
674
675 let mut helper = Self {
676 input,
677 current_modifiers: Modifiers::default(),
678 cx,
679 };
680
681 helper.start_recording();
682 helper
683 }
684
685 /// Sets search mode on the input
686 pub fn with_search_mode(&mut self, search: bool) -> &mut Self {
687 self.input.update(&mut self.cx, |input, _| {
688 input.set_search(search);
689 });
690 self
691 }
692
693 /// Sends a keystroke event based on string description
694 /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
695 #[track_caller]
696 pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
697 self.expect_is_recording(true);
698 let keystroke_str = if keystroke_input.ends_with('-') {
699 format!("{}_", keystroke_input)
700 } else {
701 keystroke_input.to_string()
702 };
703
704 let mut keystroke = Keystroke::parse(&keystroke_str)
705 .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input));
706
707 // Remove the dummy key if we added it for modifier-only keystrokes
708 if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") {
709 keystroke.key = "".to_string();
710 }
711
712 // Combine current modifiers with keystroke modifiers
713 keystroke.modifiers |= self.current_modifiers;
714
715 self.input.update_in(&mut self.cx, |input, window, cx| {
716 input.handle_keystroke(&keystroke, window, cx);
717 });
718
719 // Don't update current_modifiers for keystrokes with actual keys
720 if keystroke.key.is_empty() {
721 self.current_modifiers = keystroke.modifiers;
722 }
723 self
724 }
725
726 /// Sends a modifier change event based on string description
727 /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
728 #[track_caller]
729 pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
730 self.expect_is_recording(true);
731 let new_modifiers = if modifiers == "-all" {
732 Modifiers::default()
733 } else {
734 self.parse_modifier_change(modifiers)
735 };
736
737 let event = ModifiersChangedEvent {
738 modifiers: new_modifiers,
739 capslock: gpui::Capslock::default(),
740 };
741
742 self.input.update_in(&mut self.cx, |input, window, cx| {
743 input.on_modifiers_changed(&event, window, cx);
744 });
745
746 self.current_modifiers = new_modifiers;
747 self
748 }
749
750 /// Sends multiple events in sequence
751 /// Each event string is either a keystroke or modifier change
752 #[track_caller]
753 pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
754 self.expect_is_recording(true);
755 for event in events {
756 if event.starts_with('+') || event.starts_with('-') {
757 self.send_modifiers(event);
758 } else {
759 self.send_keystroke(event);
760 }
761 }
762 self
763 }
764
765 #[track_caller]
766 fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) {
767 let expected_keystrokes: Result<Vec<Keystroke>, _> = expected
768 .iter()
769 .map(|s| {
770 let keystroke_str = if s.ends_with('-') {
771 format!("{}_", s)
772 } else {
773 s.to_string()
774 };
775
776 let mut keystroke = Keystroke::parse(&keystroke_str)?;
777
778 // Remove the dummy key if we added it for modifier-only keystrokes
779 if s.ends_with('-') && keystroke_str.ends_with("_") {
780 keystroke.key = "".to_string();
781 }
782
783 Ok(keystroke)
784 })
785 .collect();
786
787 let expected_keystrokes = expected_keystrokes
788 .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e));
789
790 assert_eq!(
791 actual.len(),
792 expected_keystrokes.len(),
793 "Keystroke count mismatch. Expected: {:?}, Actual: {:?}",
794 expected_keystrokes
795 .iter()
796 .map(|k| k.unparse())
797 .collect::<Vec<_>>(),
798 actual.iter().map(|k| k.unparse()).collect::<Vec<_>>()
799 );
800
801 for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate()
802 {
803 assert_eq!(
804 actual.unparse(),
805 expected.unparse(),
806 "Keystroke {} mismatch. Expected: '{}', Actual: '{}'",
807 i,
808 expected.unparse(),
809 actual.unparse()
810 );
811 }
812 }
813
814 /// Verifies that the keystrokes match the expected strings
815 #[track_caller]
816 pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
817 let actual = self
818 .input
819 .read_with(&mut self.cx, |input, _| input.keystrokes.clone());
820 Self::expect_keystrokes_equal(&actual, expected);
821 self
822 }
823
824 #[track_caller]
825 pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
826 let actual = self
827 .input
828 .read_with(&mut self.cx, |input, _| input.close_keystrokes.clone())
829 .unwrap_or_default();
830 Self::expect_keystrokes_equal(&actual, expected);
831 self
832 }
833
834 /// Verifies that there are no keystrokes
835 #[track_caller]
836 pub fn expect_empty(&mut self) -> &mut Self {
837 self.expect_keystrokes(&[])
838 }
839
840 /// Starts recording keystrokes
841 #[track_caller]
842 pub fn start_recording(&mut self) -> &mut Self {
843 self.expect_is_recording(false);
844 self.input.update_in(&mut self.cx, |input, window, cx| {
845 input.start_recording(&StartRecording, window, cx);
846 });
847 self
848 }
849
850 /// Stops recording keystrokes
851 pub fn stop_recording(&mut self) -> &mut Self {
852 self.expect_is_recording(true);
853 self.input.update_in(&mut self.cx, |input, window, cx| {
854 input.stop_recording(&StopRecording, window, cx);
855 });
856 self
857 }
858
859 /// Clears all keystrokes
860 pub fn clear_keystrokes(&mut self) -> &mut Self {
861 self.input.update_in(&mut self.cx, |input, window, cx| {
862 input.clear_keystrokes(&ClearKeystrokes, window, cx);
863 });
864 self
865 }
866
867 /// Verifies the recording state
868 #[track_caller]
869 pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self {
870 let actual = self
871 .input
872 .update_in(&mut self.cx, |input, window, _| input.is_recording(window));
873 assert_eq!(
874 actual, expected,
875 "Recording state mismatch. Expected: {}, Actual: {}",
876 expected, actual
877 );
878 self
879 }
880
881 pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self {
882 let task = self.input.update_in(&mut self.cx, |input, _, _| {
883 input.clear_close_keystrokes_timer.take()
884 });
885 let task = task.expect("No close keystroke capture end timer task");
886 self.cx
887 .executor()
888 .advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT);
889 task.await;
890 self
891 }
892
893 /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
894 fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
895 let mut modifiers = self.current_modifiers;
896
897 if let Some(to_add) = modifiers_str.strip_prefix('+') {
898 // Add modifiers
899 for modifier in to_add.split('+') {
900 match modifier {
901 "ctrl" | "control" => modifiers.control = true,
902 "alt" | "option" => modifiers.alt = true,
903 "shift" => modifiers.shift = true,
904 "cmd" | "command" => modifiers.platform = true,
905 "fn" | "function" => modifiers.function = true,
906 _ => panic!("Unknown modifier: {}", modifier),
907 }
908 }
909 } else if let Some(to_remove) = modifiers_str.strip_prefix('-') {
910 // Remove modifiers
911 for modifier in to_remove.split('+') {
912 match modifier {
913 "ctrl" | "control" => modifiers.control = false,
914 "alt" | "option" => modifiers.alt = false,
915 "shift" => modifiers.shift = false,
916 "cmd" | "command" => modifiers.platform = false,
917 "fn" | "function" => modifiers.function = false,
918 _ => panic!("Unknown modifier: {}", modifier),
919 }
920 }
921 }
922
923 modifiers
924 }
925 }
926
927 async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper {
928 cx.update(|cx| {
929 let settings_store = SettingsStore::test(cx);
930 cx.set_global(settings_store);
931 theme::init(theme::LoadThemes::JustBase, cx);
932 language::init(cx);
933 project::Project::init_settings(cx);
934 workspace::init_settings(cx);
935 });
936
937 let fs = FakeFs::new(cx.executor());
938 let project = Project::test(fs, [], cx).await;
939 let workspace =
940 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
941 let cx = VisualTestContext::from_window(*workspace, cx);
942 KeystrokeInputTestHelper::new(cx)
943 }
944
945 #[gpui::test]
946 async fn test_basic_keystroke_input(cx: &mut TestAppContext) {
947 init_test(cx)
948 .await
949 .send_keystroke("a")
950 .clear_keystrokes()
951 .expect_empty();
952 }
953
954 #[gpui::test]
955 async fn test_modifier_handling(cx: &mut TestAppContext) {
956 init_test(cx)
957 .await
958 .with_search_mode(true)
959 .send_events(&["+ctrl", "a", "-ctrl"])
960 .expect_keystrokes(&["ctrl-a"]);
961 }
962
963 #[gpui::test]
964 async fn test_multiple_modifiers(cx: &mut TestAppContext) {
965 init_test(cx)
966 .await
967 .send_keystroke("cmd-shift-z")
968 .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]);
969 }
970
971 #[gpui::test]
972 async fn test_search_mode_behavior(cx: &mut TestAppContext) {
973 init_test(cx)
974 .await
975 .with_search_mode(true)
976 .send_events(&["+cmd", "shift-f", "-cmd"])
977 // In search mode, when completing a modifier-only keystroke with a key,
978 // only the original modifiers are preserved, not the keystroke's modifiers
979 .expect_keystrokes(&["cmd-f"]);
980 }
981
982 #[gpui::test]
983 async fn test_keystroke_limit(cx: &mut TestAppContext) {
984 init_test(cx)
985 .await
986 .send_keystroke("a")
987 .send_keystroke("b")
988 .send_keystroke("c")
989 .expect_keystrokes(&["a", "b", "c"]) // At max limit
990 .send_keystroke("d")
991 .expect_empty(); // Should clear when exceeding limit
992 }
993
994 #[gpui::test]
995 async fn test_modifier_release_all(cx: &mut TestAppContext) {
996 init_test(cx)
997 .await
998 .with_search_mode(true)
999 .send_events(&["+ctrl+shift", "a", "-all"])
1000 .expect_keystrokes(&["ctrl-shift-a"]);
1001 }
1002
1003 #[gpui::test]
1004 async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) {
1005 init_test(cx)
1006 .await
1007 .with_search_mode(true)
1008 .send_events(&["+ctrl+shift", "a", "-ctrl"])
1009 .expect_keystrokes(&["ctrl-shift-a"])
1010 .send_events(&["+ctrl"])
1011 .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]);
1012 }
1013
1014 #[gpui::test]
1015 async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) {
1016 init_test(cx)
1017 .await
1018 .with_search_mode(false)
1019 .send_events(&["+ctrl+shift", "a", "-all"])
1020 .expect_keystrokes(&["ctrl-shift-a"]);
1021 }
1022
1023 #[gpui::test]
1024 async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) {
1025 init_test(cx)
1026 .await
1027 .with_search_mode(false)
1028 .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3
1029 .expect_empty(); // Should clear when exceeding limit
1030 }
1031
1032 #[gpui::test]
1033 async fn test_complex_modifier_sequences(cx: &mut TestAppContext) {
1034 init_test(cx)
1035 .await
1036 .with_search_mode(true)
1037 .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"])
1038 .expect_keystrokes(&["ctrl-shift-alt-a"]);
1039 }
1040
1041 #[gpui::test]
1042 async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) {
1043 init_test(cx)
1044 .await
1045 .with_search_mode(true)
1046 .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1047 .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes
1048 }
1049
1050 #[gpui::test]
1051 async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) {
1052 init_test(cx)
1053 .await
1054 .with_search_mode(false)
1055 .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1056 .expect_empty(); // Modifier-only sequences get filtered in non-search mode
1057 }
1058
1059 #[gpui::test]
1060 async fn test_rapid_modifier_changes(cx: &mut TestAppContext) {
1061 init_test(cx)
1062 .await
1063 .with_search_mode(true)
1064 .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"])
1065 .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]);
1066 }
1067
1068 #[gpui::test]
1069 async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) {
1070 init_test(cx)
1071 .await
1072 .with_search_mode(true)
1073 .send_events(&["+ctrl", "a", "-ctrl", "b"])
1074 .expect_keystrokes(&["ctrl-a", "b"])
1075 .clear_keystrokes()
1076 .expect_empty();
1077 }
1078
1079 #[gpui::test]
1080 async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) {
1081 init_test(cx)
1082 .await
1083 .with_search_mode(false)
1084 .send_events(&["+ctrl", "a"])
1085 .expect_keystrokes(&["ctrl-a", "ctrl-"])
1086 .send_events(&["-ctrl"])
1087 .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes
1088 }
1089
1090 #[gpui::test]
1091 async fn test_all_modifiers_at_once(cx: &mut TestAppContext) {
1092 init_test(cx)
1093 .await
1094 .with_search_mode(true)
1095 .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"])
1096 .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]);
1097 }
1098
1099 #[gpui::test]
1100 async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) {
1101 init_test(cx)
1102 .await
1103 .with_search_mode(true)
1104 .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit)
1105 .expect_keystrokes(&["a", "b", "c"])
1106 .send_events(&["d"]) // should clear when exceeding
1107 .expect_empty();
1108 }
1109
1110 #[gpui::test]
1111 async fn test_function_modifier_key(cx: &mut TestAppContext) {
1112 init_test(cx)
1113 .await
1114 .with_search_mode(true)
1115 .send_events(&["+fn", "f1", "-fn"])
1116 .expect_keystrokes(&["fn-f1"]);
1117 }
1118
1119 #[gpui::test]
1120 async fn test_start_stop_recording(cx: &mut TestAppContext) {
1121 init_test(cx)
1122 .await
1123 .send_events(&["a", "b"])
1124 .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes
1125 .stop_recording()
1126 .expect_is_recording(false)
1127 .start_recording()
1128 .send_events(&["c"])
1129 .expect_keystrokes(&["c"]);
1130 }
1131
1132 #[gpui::test]
1133 async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) {
1134 init_test(cx)
1135 .await
1136 .with_search_mode(true)
1137 .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"])
1138 .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]);
1139 }
1140
1141 #[gpui::test]
1142 async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) {
1143 init_test(cx)
1144 .await
1145 .with_search_mode(true)
1146 .send_events(&[]) // No events at all
1147 .expect_empty();
1148 }
1149
1150 #[gpui::test]
1151 async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) {
1152 init_test(cx)
1153 .await
1154 .with_search_mode(true)
1155 .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"])
1156 .expect_keystrokes(&["ctrl-shift-a"]);
1157 }
1158
1159 #[gpui::test]
1160 async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) {
1161 init_test(cx)
1162 .await
1163 .with_search_mode(true)
1164 .send_events(&["a", "escape", "escape", "escape"])
1165 .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording
1166 .expect_is_recording(false);
1167 }
1168
1169 #[gpui::test]
1170 async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) {
1171 init_test(cx)
1172 .await
1173 .with_search_mode(false)
1174 .send_events(&["a", "escape", "escape", "escape"])
1175 .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape
1176 }
1177
1178 #[gpui::test]
1179 async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) {
1180 init_test(cx)
1181 .await
1182 .with_search_mode(true)
1183 .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit
1184 .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes
1185 }
1186
1187 #[gpui::test]
1188 async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) {
1189 init_test(cx)
1190 .await
1191 .with_search_mode(true)
1192 .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a'
1193 .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered
1194 }
1195
1196 #[gpui::test]
1197 async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) {
1198 init_test(cx)
1199 .await
1200 .with_search_mode(true)
1201 .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit)
1202 .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection
1203 }
1204
1205 #[gpui::test]
1206 async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) {
1207 init_test(cx)
1208 .await
1209 .with_search_mode(true)
1210 .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close
1211 .expect_keystrokes(&["escape", "escape"])
1212 .expect_is_recording(true); // Should remain in keystrokes, no close triggered
1213 }
1214
1215 #[gpui::test]
1216 async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) {
1217 init_test(cx)
1218 .await
1219 .with_search_mode(true)
1220 .send_events(&["a", "escape", "escape", "escape"])
1221 .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape
1222 .expect_is_recording(false);
1223 }
1224
1225 #[gpui::test]
1226 async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) {
1227 init_test(cx)
1228 .await
1229 .with_search_mode(true)
1230 .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close
1231 .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit
1232 }
1233
1234 #[gpui::test]
1235 async fn test_triple_escape_only(cx: &mut TestAppContext) {
1236 init_test(cx)
1237 .await
1238 .with_search_mode(true)
1239 .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
1240 .expect_empty();
1241 }
1242
1243 #[gpui::test]
1244 async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) {
1245 init_test(cx)
1246 .await
1247 .send_events(&["+ctrl", "g", "-ctrl", "escape"])
1248 .expect_keystrokes(&["ctrl-g", "escape"])
1249 .wait_for_close_keystroke_capture_end()
1250 .await
1251 .send_events(&["escape", "escape"])
1252 .expect_keystrokes(&["ctrl-g", "escape", "escape"])
1253 .expect_close_keystrokes(&["escape", "escape"])
1254 .send_keystroke("escape")
1255 .expect_keystrokes(&["ctrl-g", "escape"]);
1256 }
1257}