keystroke_input.rs

   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}