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