keystroke_input.rs

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