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