keystroke_input.rs

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