From efa3cc13efa0bf100fcdac315e81dc31433e9c46 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 29 Jul 2025 13:10:51 -0500 Subject: [PATCH] keymap_ui: Test keystroke input (#35286) Closes #ISSUE Separate out the keystroke input into it's own component and add a bunch of tests for it's core keystroke+modifier event handling logic Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/settings_ui/Cargo.toml | 4 + crates/settings_ui/src/keybindings.rs | 546 +------- .../src/ui_components/keystroke_input.rs | 1165 +++++++++++++++++ crates/settings_ui/src/ui_components/mod.rs | 1 + 4 files changed, 1179 insertions(+), 537 deletions(-) create mode 100644 crates/settings_ui/src/ui_components/keystroke_input.rs diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 25f033469d..e8434c1a32 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -48,3 +48,7 @@ workspace.workspace = true [dev-dependencies] db = {"workspace"= true, "features" = ["test-support"]} +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 5ff91246f4..70afe1729c 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -11,11 +11,10 @@ use editor::{CompletionProvider, Editor, EditorEvent}; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context, - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, - KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, - ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, - actions, anchored, deferred, div, + Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, Keystroke, MouseButton, + Point, ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task, + TextStyleRefinement, WeakEntity, actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -35,7 +34,10 @@ use workspace::{ use crate::{ keybindings::persistence::KEYBINDING_EDITORS, - ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, + ui_components::{ + keystroke_input::{ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording}, + table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, + }, }; const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static(""); @@ -72,18 +74,6 @@ actions!( ] ); -actions!( - keystroke_input, - [ - /// Starts recording keystrokes - StartRecording, - /// Stops recording keystrokes - StopRecording, - /// Clears the recorded keystrokes - ClearKeystrokes, - ] -); - pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); @@ -393,7 +383,7 @@ impl KeymapEditor { let keystroke_editor = cx.new(|cx| { let mut keystroke_editor = KeystrokeInput::new(None, window, cx); - keystroke_editor.search = true; + keystroke_editor.set_search(true); keystroke_editor }); @@ -2979,524 +2969,6 @@ async fn remove_keybinding( Ok(()) } -#[derive(PartialEq, Eq, Debug, Copy, Clone)] -enum CloseKeystrokeResult { - Partial, - Close, - None, -} - -struct KeystrokeInput { - keystrokes: Vec, - placeholder_keystrokes: Option>, - outer_focus_handle: FocusHandle, - inner_focus_handle: FocusHandle, - intercept_subscription: Option, - _focus_subscriptions: [Subscription; 2], - search: bool, - /// Handles tripe escape to stop recording - close_keystrokes: Option>, - close_keystrokes_start: Option, - previous_modifiers: Modifiers, -} - -impl KeystrokeInput { - const KEYSTROKE_COUNT_MAX: usize = 3; - - fn new( - placeholder_keystrokes: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let outer_focus_handle = cx.focus_handle(); - let inner_focus_handle = cx.focus_handle(); - let _focus_subscriptions = [ - cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in), - cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out), - ]; - Self { - keystrokes: Vec::new(), - placeholder_keystrokes, - inner_focus_handle, - outer_focus_handle, - intercept_subscription: None, - _focus_subscriptions, - search: false, - close_keystrokes: None, - close_keystrokes_start: None, - previous_modifiers: Modifiers::default(), - } - } - - fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { - self.keystrokes = keystrokes; - self.keystrokes_changed(cx); - } - - fn dummy(modifiers: Modifiers) -> Keystroke { - return Keystroke { - modifiers, - key: "".to_string(), - key_char: None, - }; - } - - fn keystrokes_changed(&self, cx: &mut Context) { - cx.emit(()); - cx.notify(); - } - - fn key_context() -> KeyContext { - let mut key_context = KeyContext::default(); - key_context.add("KeystrokeInput"); - key_context - } - - fn handle_possible_close_keystroke( - &mut self, - keystroke: &Keystroke, - window: &mut Window, - cx: &mut Context, - ) -> CloseKeystrokeResult { - let Some(keybind_for_close_action) = window - .highest_precedence_binding_for_action_in_context(&StopRecording, Self::key_context()) - else { - log::trace!("No keybinding to stop recording keystrokes in keystroke input"); - self.close_keystrokes.take(); - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - }; - let action_keystrokes = keybind_for_close_action.keystrokes(); - - if let Some(mut close_keystrokes) = self.close_keystrokes.take() { - let mut index = 0; - - while index < action_keystrokes.len() && index < close_keystrokes.len() { - if !close_keystrokes[index].should_match(&action_keystrokes[index]) { - break; - } - index += 1; - } - if index == close_keystrokes.len() { - if index >= action_keystrokes.len() { - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - if keystroke.should_match(&action_keystrokes[index]) { - if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 { - self.stop_recording(&StopRecording, window, cx); - return CloseKeystrokeResult::Close; - } else { - close_keystrokes.push(keystroke.clone()); - self.close_keystrokes = Some(close_keystrokes); - return CloseKeystrokeResult::Partial; - } - } else { - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - } - } else if let Some(first_action_keystroke) = action_keystrokes.first() - && keystroke.should_match(first_action_keystroke) - { - self.close_keystrokes = Some(vec![keystroke.clone()]); - return CloseKeystrokeResult::Partial; - } - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - - fn on_modifiers_changed( - &mut self, - event: &ModifiersChangedEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let keystrokes_len = self.keystrokes.len(); - - if self.previous_modifiers.modified() - && event.modifiers.is_subset_of(&self.previous_modifiers) - { - self.previous_modifiers &= event.modifiers; - cx.stop_propagation(); - return; - } - - if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() - && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX - { - if self.search { - if self.previous_modifiers.modified() { - last.modifiers |= event.modifiers; - self.previous_modifiers |= event.modifiers; - } else { - self.keystrokes.push(Self::dummy(event.modifiers)); - self.previous_modifiers |= event.modifiers; - } - } else if !event.modifiers.modified() { - self.keystrokes.pop(); - } else { - last.modifiers = event.modifiers; - } - - self.keystrokes_changed(cx); - } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { - self.keystrokes.push(Self::dummy(event.modifiers)); - if self.search { - self.previous_modifiers |= event.modifiers; - } - self.keystrokes_changed(cx); - } - cx.stop_propagation(); - } - - fn handle_keystroke( - &mut self, - keystroke: &Keystroke, - window: &mut Window, - cx: &mut Context, - ) { - let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); - if close_keystroke_result != CloseKeystrokeResult::Close { - let key_len = self.keystrokes.len(); - if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() - && key_len <= Self::KEYSTROKE_COUNT_MAX - { - if self.search { - last.key = keystroke.key.clone(); - if close_keystroke_result == CloseKeystrokeResult::Partial - && self.close_keystrokes_start.is_none() - { - self.close_keystrokes_start = Some(self.keystrokes.len() - 1); - } - if self.search { - self.previous_modifiers = keystroke.modifiers; - } - self.keystrokes_changed(cx); - cx.stop_propagation(); - return; - } else { - self.keystrokes.pop(); - } - } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - if close_keystroke_result == CloseKeystrokeResult::Partial - && self.close_keystrokes_start.is_none() - { - self.close_keystrokes_start = Some(self.keystrokes.len()); - } - self.keystrokes.push(keystroke.clone()); - if self.search { - self.previous_modifiers = keystroke.modifiers; - } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); - } - } else if close_keystroke_result != CloseKeystrokeResult::Partial { - self.clear_keystrokes(&ClearKeystrokes, window, cx); - } - } - self.keystrokes_changed(cx); - cx.stop_propagation(); - } - - fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context) { - if self.intercept_subscription.is_none() { - let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { - this.handle_keystroke(&event.keystroke, window, cx); - }); - self.intercept_subscription = Some(cx.intercept_keystrokes(listener)) - } - } - - fn on_inner_focus_out( - &mut self, - _event: gpui::FocusOutEvent, - _window: &mut Window, - cx: &mut Context, - ) { - self.intercept_subscription.take(); - cx.notify(); - } - - fn keystrokes(&self) -> &[Keystroke] { - if let Some(placeholders) = self.placeholder_keystrokes.as_ref() - && self.keystrokes.is_empty() - { - return placeholders; - } - if !self.search - && self - .keystrokes - .last() - .map_or(false, |last| last.key.is_empty()) - { - return &self.keystrokes[..self.keystrokes.len() - 1]; - } - return &self.keystrokes; - } - - fn render_keystrokes(&self, is_recording: bool) -> impl Iterator { - let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() - && self.keystrokes.is_empty() - { - if is_recording { - &[] - } else { - placeholders.as_slice() - } - } else { - &self.keystrokes - }; - keystrokes.iter().map(move |keystroke| { - h_flex().children(ui::render_keystroke( - keystroke, - Some(Color::Default), - Some(rems(0.875).into()), - ui::PlatformStyle::platform(), - false, - )) - }) - } - - fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context) { - window.focus(&self.inner_focus_handle); - self.clear_keystrokes(&ClearKeystrokes, window, cx); - self.previous_modifiers = window.modifiers(); - cx.stop_propagation(); - } - - fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context) { - if !self.inner_focus_handle.is_focused(window) { - return; - } - window.focus(&self.outer_focus_handle); - if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() - && close_keystrokes_start < self.keystrokes.len() - { - self.keystrokes.drain(close_keystrokes_start..); - } - self.close_keystrokes.take(); - cx.notify(); - } - - fn clear_keystrokes( - &mut self, - _: &ClearKeystrokes, - _window: &mut Window, - cx: &mut Context, - ) { - self.keystrokes.clear(); - self.keystrokes_changed(cx); - } -} - -impl EventEmitter<()> for KeystrokeInput {} - -impl Focusable for KeystrokeInput { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.outer_focus_handle.clone() - } -} - -impl Render for KeystrokeInput { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.theme().colors(); - let is_focused = self.outer_focus_handle.contains_focused(window, cx); - let is_recording = self.inner_focus_handle.is_focused(window); - - let horizontal_padding = rems_from_px(64.); - - let recording_bg_color = colors - .editor_background - .blend(colors.text_accent.opacity(0.1)); - - let recording_pulse = |color: Color| { - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(Color::Error) - .with_animation( - "recording-pulse", - Animation::new(std::time::Duration::from_secs(2)) - .repeat() - .with_easing(gpui::pulsating_between(0.4, 0.8)), - { - let color = color.color(cx); - move |this, delta| this.color(Color::Custom(color.opacity(delta))) - }, - ) - }; - - let recording_indicator = h_flex() - .h_4() - .pr_1() - .gap_0p5() - .border_1() - .border_color(colors.border) - .bg(colors - .editor_background - .blend(colors.text_accent.opacity(0.1))) - .rounded_sm() - .child(recording_pulse(Color::Error)) - .child( - Label::new("REC") - .size(LabelSize::XSmall) - .weight(FontWeight::SEMIBOLD) - .color(Color::Error), - ); - - let search_indicator = h_flex() - .h_4() - .pr_1() - .gap_0p5() - .border_1() - .border_color(colors.border) - .bg(colors - .editor_background - .blend(colors.text_accent.opacity(0.1))) - .rounded_sm() - .child(recording_pulse(Color::Accent)) - .child( - Label::new("SEARCH") - .size(LabelSize::XSmall) - .weight(FontWeight::SEMIBOLD) - .color(Color::Accent), - ); - - let record_icon = if self.search { - IconName::MagnifyingGlass - } else { - IconName::PlayFilled - }; - - h_flex() - .id("keystroke-input") - .track_focus(&self.outer_focus_handle) - .py_2() - .px_3() - .gap_2() - .min_h_10() - .w_full() - .flex_1() - .justify_between() - .rounded_lg() - .overflow_hidden() - .map(|this| { - if is_recording { - this.bg(recording_bg_color) - } else { - this.bg(colors.editor_background) - } - }) - .border_1() - .border_color(colors.border_variant) - .when(is_focused, |parent| { - parent.border_color(colors.border_focused) - }) - .key_context(Self::key_context()) - .on_action(cx.listener(Self::start_recording)) - .on_action(cx.listener(Self::clear_keystrokes)) - .child( - h_flex() - .w(horizontal_padding) - .gap_0p5() - .justify_start() - .flex_none() - .when(is_recording, |this| { - this.map(|this| { - if self.search { - this.child(search_indicator) - } else { - this.child(recording_indicator) - } - }) - }), - ) - .child( - h_flex() - .id("keystroke-input-inner") - .track_focus(&self.inner_focus_handle) - .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) - .size_full() - .when(!self.search, |this| { - this.focus(|mut style| { - style.border_color = Some(colors.border_focused); - style - }) - }) - .w_full() - .min_w_0() - .justify_center() - .flex_wrap() - .gap(ui::DynamicSpacing::Base04.rems(cx)) - .children(self.render_keystrokes(is_recording)), - ) - .child( - h_flex() - .w(horizontal_padding) - .gap_0p5() - .justify_end() - .flex_none() - .map(|this| { - if is_recording { - this.child( - IconButton::new("stop-record-btn", IconName::StopFilled) - .shape(ui::IconButtonShape::Square) - .map(|this| { - this.tooltip(Tooltip::for_action_title( - if self.search { - "Stop Searching" - } else { - "Stop Recording" - }, - &StopRecording, - )) - }) - .icon_color(Color::Error) - .on_click(cx.listener(|this, _event, window, cx| { - this.stop_recording(&StopRecording, window, cx); - })), - ) - } else { - this.child( - IconButton::new("record-btn", record_icon) - .shape(ui::IconButtonShape::Square) - .map(|this| { - this.tooltip(Tooltip::for_action_title( - if self.search { - "Start Searching" - } else { - "Start Recording" - }, - &StartRecording, - )) - }) - .when(!is_focused, |this| this.icon_color(Color::Muted)) - .on_click(cx.listener(|this, _event, window, cx| { - this.start_recording(&StartRecording, window, cx); - })), - ) - } - }) - .child( - IconButton::new("clear-btn", IconName::Delete) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::for_action_title( - "Clear Keystrokes", - &ClearKeystrokes, - )) - .when(!is_recording || !is_focused, |this| { - this.icon_color(Color::Muted) - }) - .on_click(cx.listener(|this, _event, window, cx| { - this.clear_keystrokes(&ClearKeystrokes, window, cx); - })), - ), - ) - } -} - fn collect_contexts_from_assets() -> Vec { let mut keymap_assets = vec![ util::asset_str::(settings::DEFAULT_KEYMAP_PATH), diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs new file mode 100644 index 0000000000..08ffe3575b --- /dev/null +++ b/crates/settings_ui/src/ui_components/keystroke_input.rs @@ -0,0 +1,1165 @@ +use gpui::{ + Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, + Keystroke, Modifiers, ModifiersChangedEvent, Subscription, actions, +}; +use ui::{ + ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, + ParentElement as _, Render, Styled as _, Tooltip, Window, prelude::*, +}; + +actions!( + keystroke_input, + [ + /// Starts recording keystrokes + StartRecording, + /// Stops recording keystrokes + StopRecording, + /// Clears the recorded keystrokes + ClearKeystrokes, + ] +); + +const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput"; + +enum CloseKeystrokeResult { + Partial, + Close, + None, +} + +impl PartialEq for CloseKeystrokeResult { + fn eq(&self, other: &Self) -> bool { + matches!( + (self, other), + (CloseKeystrokeResult::Partial, CloseKeystrokeResult::Partial) + | (CloseKeystrokeResult::Close, CloseKeystrokeResult::Close) + | (CloseKeystrokeResult::None, CloseKeystrokeResult::None) + ) + } +} + +pub struct KeystrokeInput { + keystrokes: Vec, + placeholder_keystrokes: Option>, + outer_focus_handle: FocusHandle, + inner_focus_handle: FocusHandle, + intercept_subscription: Option, + _focus_subscriptions: [Subscription; 2], + search: bool, + /// Handles triple escape to stop recording + close_keystrokes: Option>, + close_keystrokes_start: Option, + previous_modifiers: Modifiers, + #[cfg(test)] + recording: bool, +} + +impl KeystrokeInput { + const KEYSTROKE_COUNT_MAX: usize = 3; + + pub fn new( + placeholder_keystrokes: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let outer_focus_handle = cx.focus_handle(); + let inner_focus_handle = cx.focus_handle(); + let _focus_subscriptions = [ + cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in), + cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out), + ]; + Self { + keystrokes: Vec::new(), + placeholder_keystrokes, + inner_focus_handle, + outer_focus_handle, + intercept_subscription: None, + _focus_subscriptions, + search: false, + close_keystrokes: None, + close_keystrokes_start: None, + previous_modifiers: Modifiers::default(), + #[cfg(test)] + recording: false, + } + } + + pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { + self.keystrokes = keystrokes; + self.keystrokes_changed(cx); + } + + pub fn set_search(&mut self, search: bool) { + self.search = search; + } + + pub fn keystrokes(&self) -> &[Keystroke] { + if let Some(placeholders) = self.placeholder_keystrokes.as_ref() + && self.keystrokes.is_empty() + { + return placeholders; + } + if !self.search + && self + .keystrokes + .last() + .map_or(false, |last| last.key.is_empty()) + { + return &self.keystrokes[..self.keystrokes.len() - 1]; + } + return &self.keystrokes; + } + + fn dummy(modifiers: Modifiers) -> Keystroke { + return Keystroke { + modifiers, + key: "".to_string(), + key_char: None, + }; + } + + fn keystrokes_changed(&self, cx: &mut Context) { + cx.emit(()); + cx.notify(); + } + + fn key_context() -> KeyContext { + let mut key_context = KeyContext::default(); + key_context.add(KEY_CONTEXT_VALUE); + key_context + } + + fn determine_stop_recording_binding(window: &mut Window) -> Option { + if cfg!(test) { + Some(gpui::KeyBinding::new( + "escape escape escape", + StopRecording, + Some(KEY_CONTEXT_VALUE), + )) + } else { + window.highest_precedence_binding_for_action_in_context( + &StopRecording, + Self::key_context(), + ) + } + } + + fn handle_possible_close_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> CloseKeystrokeResult { + let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else { + log::trace!("No keybinding to stop recording keystrokes in keystroke input"); + self.close_keystrokes.take(); + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + }; + let action_keystrokes = keybind_for_close_action.keystrokes(); + + if let Some(mut close_keystrokes) = self.close_keystrokes.take() { + let mut index = 0; + + while index < action_keystrokes.len() && index < close_keystrokes.len() { + if !close_keystrokes[index].should_match(&action_keystrokes[index]) { + break; + } + index += 1; + } + if index == close_keystrokes.len() { + if index >= action_keystrokes.len() { + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + } + if keystroke.should_match(&action_keystrokes[index]) { + if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 { + self.stop_recording(&StopRecording, window, cx); + return CloseKeystrokeResult::Close; + } else { + close_keystrokes.push(keystroke.clone()); + self.close_keystrokes = Some(close_keystrokes); + return CloseKeystrokeResult::Partial; + } + } else { + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + } + } + } else if let Some(first_action_keystroke) = action_keystrokes.first() + && keystroke.should_match(first_action_keystroke) + { + self.close_keystrokes = Some(vec![keystroke.clone()]); + return CloseKeystrokeResult::Partial; + } + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + } + + fn on_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + _window: &mut Window, + cx: &mut Context, + ) { + let keystrokes_len = self.keystrokes.len(); + + if self.previous_modifiers.modified() + && event.modifiers.is_subset_of(&self.previous_modifiers) + { + self.previous_modifiers &= event.modifiers; + cx.stop_propagation(); + return; + } + + if let Some(last) = self.keystrokes.last_mut() + && last.key.is_empty() + && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX + { + if self.search { + if self.previous_modifiers.modified() { + last.modifiers |= event.modifiers; + self.previous_modifiers |= event.modifiers; + } else { + self.keystrokes.push(Self::dummy(event.modifiers)); + self.previous_modifiers |= event.modifiers; + } + } else if !event.modifiers.modified() { + self.keystrokes.pop(); + } else { + last.modifiers = event.modifiers; + } + + self.keystrokes_changed(cx); + } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { + self.keystrokes.push(Self::dummy(event.modifiers)); + if self.search { + self.previous_modifiers |= event.modifiers; + } + self.keystrokes_changed(cx); + } + cx.stop_propagation(); + } + + fn handle_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) { + let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); + if close_keystroke_result != CloseKeystrokeResult::Close { + let key_len = self.keystrokes.len(); + if let Some(last) = self.keystrokes.last_mut() + && last.key.is_empty() + && key_len <= Self::KEYSTROKE_COUNT_MAX + { + if self.search { + last.key = keystroke.key.clone(); + if close_keystroke_result == CloseKeystrokeResult::Partial + && self.close_keystrokes_start.is_none() + { + self.close_keystrokes_start = Some(self.keystrokes.len() - 1); + } + if self.search { + self.previous_modifiers = keystroke.modifiers; + } + self.keystrokes_changed(cx); + cx.stop_propagation(); + return; + } else { + self.keystrokes.pop(); + } + } + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + if close_keystroke_result == CloseKeystrokeResult::Partial + && self.close_keystrokes_start.is_none() + { + self.close_keystrokes_start = Some(self.keystrokes.len()); + } + self.keystrokes.push(keystroke.clone()); + if self.search { + self.previous_modifiers = keystroke.modifiers; + } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX + && keystroke.modifiers.modified() + { + self.keystrokes.push(Self::dummy(keystroke.modifiers)); + } + } else if close_keystroke_result != CloseKeystrokeResult::Partial { + self.clear_keystrokes(&ClearKeystrokes, window, cx); + } + } + self.keystrokes_changed(cx); + cx.stop_propagation(); + } + + fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context) { + if self.intercept_subscription.is_none() { + let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { + this.handle_keystroke(&event.keystroke, window, cx); + }); + self.intercept_subscription = Some(cx.intercept_keystrokes(listener)) + } + } + + fn on_inner_focus_out( + &mut self, + _event: gpui::FocusOutEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.intercept_subscription.take(); + cx.notify(); + } + + fn render_keystrokes(&self, is_recording: bool) -> impl Iterator { + let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() + && self.keystrokes.is_empty() + { + if is_recording { + &[] + } else { + placeholders.as_slice() + } + } else { + &self.keystrokes + }; + keystrokes.iter().map(move |keystroke| { + h_flex().children(ui::render_keystroke( + keystroke, + Some(Color::Default), + Some(rems(0.875).into()), + ui::PlatformStyle::platform(), + false, + )) + }) + } + + pub fn start_recording( + &mut self, + _: &StartRecording, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.inner_focus_handle); + self.clear_keystrokes(&ClearKeystrokes, window, cx); + self.previous_modifiers = window.modifiers(); + #[cfg(test)] + { + self.recording = true; + } + cx.stop_propagation(); + } + + pub fn stop_recording( + &mut self, + _: &StopRecording, + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_recording(window) { + return; + } + window.focus(&self.outer_focus_handle); + if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() + && close_keystrokes_start < self.keystrokes.len() + { + self.keystrokes.drain(close_keystrokes_start..); + } + self.close_keystrokes.take(); + #[cfg(test)] + { + self.recording = false; + } + cx.notify(); + } + + pub fn clear_keystrokes( + &mut self, + _: &ClearKeystrokes, + _window: &mut Window, + cx: &mut Context, + ) { + self.keystrokes.clear(); + self.keystrokes_changed(cx); + } + + fn is_recording(&self, window: &Window) -> bool { + #[cfg(test)] + { + if true { + // in tests, we just need a simple bool that is toggled on start and stop recording + return self.recording; + } + } + // however, in the real world, checking if the inner focus handle is focused + // is a much more reliable check, as the intercept keystroke handlers are installed + // on focus of the inner focus handle, thereby ensuring our recording state does + // not get de-synced + return self.inner_focus_handle.is_focused(window); + } +} + +impl EventEmitter<()> for KeystrokeInput {} + +impl Focusable for KeystrokeInput { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.outer_focus_handle.clone() + } +} + +impl Render for KeystrokeInput { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors(); + let is_focused = self.outer_focus_handle.contains_focused(window, cx); + let is_recording = self.is_recording(window); + + let horizontal_padding = rems_from_px(64.); + + let recording_bg_color = colors + .editor_background + .blend(colors.text_accent.opacity(0.1)); + + let recording_pulse = |color: Color| { + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Error) + .with_animation( + "recording-pulse", + Animation::new(std::time::Duration::from_secs(2)) + .repeat() + .with_easing(gpui::pulsating_between(0.4, 0.8)), + { + let color = color.color(cx); + move |this, delta| this.color(Color::Custom(color.opacity(delta))) + }, + ) + }; + + let recording_indicator = h_flex() + .h_4() + .pr_1() + .gap_0p5() + .border_1() + .border_color(colors.border) + .bg(colors + .editor_background + .blend(colors.text_accent.opacity(0.1))) + .rounded_sm() + .child(recording_pulse(Color::Error)) + .child( + Label::new("REC") + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Error), + ); + + let search_indicator = h_flex() + .h_4() + .pr_1() + .gap_0p5() + .border_1() + .border_color(colors.border) + .bg(colors + .editor_background + .blend(colors.text_accent.opacity(0.1))) + .rounded_sm() + .child(recording_pulse(Color::Accent)) + .child( + Label::new("SEARCH") + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Accent), + ); + + let record_icon = if self.search { + IconName::MagnifyingGlass + } else { + IconName::PlayFilled + }; + + h_flex() + .id("keystroke-input") + .track_focus(&self.outer_focus_handle) + .py_2() + .px_3() + .gap_2() + .min_h_10() + .w_full() + .flex_1() + .justify_between() + .rounded_lg() + .overflow_hidden() + .map(|this| { + if is_recording { + this.bg(recording_bg_color) + } else { + this.bg(colors.editor_background) + } + }) + .border_1() + .border_color(colors.border_variant) + .when(is_focused, |parent| { + parent.border_color(colors.border_focused) + }) + .key_context(Self::key_context()) + .on_action(cx.listener(Self::start_recording)) + .on_action(cx.listener(Self::clear_keystrokes)) + .child( + h_flex() + .w(horizontal_padding) + .gap_0p5() + .justify_start() + .flex_none() + .when(is_recording, |this| { + this.map(|this| { + if self.search { + this.child(search_indicator) + } else { + this.child(recording_indicator) + } + }) + }), + ) + .child( + h_flex() + .id("keystroke-input-inner") + .track_focus(&self.inner_focus_handle) + .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) + .size_full() + .when(!self.search, |this| { + this.focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + }) + .w_full() + .min_w_0() + .justify_center() + .flex_wrap() + .gap(ui::DynamicSpacing::Base04.rems(cx)) + .children(self.render_keystrokes(is_recording)), + ) + .child( + h_flex() + .w(horizontal_padding) + .gap_0p5() + .justify_end() + .flex_none() + .map(|this| { + if is_recording { + this.child( + IconButton::new("stop-record-btn", IconName::StopFilled) + .shape(IconButtonShape::Square) + .map(|this| { + this.tooltip(Tooltip::for_action_title( + if self.search { + "Stop Searching" + } else { + "Stop Recording" + }, + &StopRecording, + )) + }) + .icon_color(Color::Error) + .on_click(cx.listener(|this, _event, window, cx| { + this.stop_recording(&StopRecording, window, cx); + })), + ) + } else { + this.child( + IconButton::new("record-btn", record_icon) + .shape(IconButtonShape::Square) + .map(|this| { + this.tooltip(Tooltip::for_action_title( + if self.search { + "Start Searching" + } else { + "Start Recording" + }, + &StartRecording, + )) + }) + .when(!is_focused, |this| this.icon_color(Color::Muted)) + .on_click(cx.listener(|this, _event, window, cx| { + this.start_recording(&StartRecording, window, cx); + })), + ) + } + }) + .child( + IconButton::new("clear-btn", IconName::Delete) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::for_action_title( + "Clear Keystrokes", + &ClearKeystrokes, + )) + .when(!is_recording || !is_focused, |this| { + this.icon_color(Color::Muted) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.clear_keystrokes(&ClearKeystrokes, window, cx); + })), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use project::Project; + use settings::SettingsStore; + use workspace::Workspace; + + pub struct KeystrokeInputTestHelper { + input: Entity, + current_modifiers: Modifiers, + cx: VisualTestContext, + } + + impl KeystrokeInputTestHelper { + /// Creates a new test helper with default settings + pub fn new(mut cx: VisualTestContext) -> Self { + let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx)); + + let mut helper = Self { + input, + current_modifiers: Modifiers::default(), + cx, + }; + + helper.start_recording(); + helper + } + + /// Sets search mode on the input + pub fn with_search_mode(&mut self, search: bool) -> &mut Self { + self.input.update(&mut self.cx, |input, _| { + input.set_search(search); + }); + self + } + + /// Sends a keystroke event based on string description + /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape" + pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self { + self.expect_is_recording(true); + let keystroke_str = if keystroke_input.ends_with('-') { + format!("{}_", keystroke_input) + } else { + keystroke_input.to_string() + }; + + let mut keystroke = Keystroke::parse(&keystroke_str) + .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input)); + + // Remove the dummy key if we added it for modifier-only keystrokes + if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") { + keystroke.key = "".to_string(); + } + + // Combine current modifiers with keystroke modifiers + keystroke.modifiers |= self.current_modifiers; + + self.input.update_in(&mut self.cx, |input, window, cx| { + input.handle_keystroke(&keystroke, window, cx); + }); + + // Don't update current_modifiers for keystrokes with actual keys + if keystroke.key.is_empty() { + self.current_modifiers = keystroke.modifiers; + } + self + } + + /// Sends a modifier change event based on string description + /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all" + pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self { + self.expect_is_recording(true); + let new_modifiers = if modifiers == "-all" { + Modifiers::default() + } else { + self.parse_modifier_change(modifiers) + }; + + let event = ModifiersChangedEvent { + modifiers: new_modifiers, + capslock: gpui::Capslock::default(), + }; + + self.input.update_in(&mut self.cx, |input, window, cx| { + input.on_modifiers_changed(&event, window, cx); + }); + + self.current_modifiers = new_modifiers; + self + } + + /// Sends multiple events in sequence + /// Each event string is either a keystroke or modifier change + pub fn send_events(&mut self, events: &[&str]) -> &mut Self { + self.expect_is_recording(true); + for event in events { + if event.starts_with('+') || event.starts_with('-') { + self.send_modifiers(event); + } else { + self.send_keystroke(event); + } + } + self + } + + /// Verifies that the keystrokes match the expected strings + #[track_caller] + pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self { + let expected_keystrokes: Result, _> = expected + .iter() + .map(|s| { + let keystroke_str = if s.ends_with('-') { + format!("{}_", s) + } else { + s.to_string() + }; + + let mut keystroke = Keystroke::parse(&keystroke_str)?; + + // Remove the dummy key if we added it for modifier-only keystrokes + if s.ends_with('-') && keystroke_str.ends_with("_") { + keystroke.key = "".to_string(); + } + + Ok(keystroke) + }) + .collect(); + + let expected_keystrokes = expected_keystrokes + .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e)); + + let actual = self + .input + .read_with(&mut self.cx, |input, _| input.keystrokes.clone()); + assert_eq!( + actual.len(), + expected_keystrokes.len(), + "Keystroke count mismatch. Expected: {:?}, Actual: {:?}", + expected_keystrokes + .iter() + .map(|k| k.unparse()) + .collect::>(), + actual.iter().map(|k| k.unparse()).collect::>() + ); + + for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate() + { + assert_eq!( + actual.unparse(), + expected.unparse(), + "Keystroke {} mismatch. Expected: '{}', Actual: '{}'", + i, + expected.unparse(), + actual.unparse() + ); + } + self + } + + /// Verifies that there are no keystrokes + #[track_caller] + pub fn expect_empty(&mut self) -> &mut Self { + self.expect_keystrokes(&[]) + } + + /// Starts recording keystrokes + #[track_caller] + pub fn start_recording(&mut self) -> &mut Self { + self.expect_is_recording(false); + self.input.update_in(&mut self.cx, |input, window, cx| { + input.start_recording(&StartRecording, window, cx); + }); + self + } + + /// Stops recording keystrokes + pub fn stop_recording(&mut self) -> &mut Self { + self.expect_is_recording(true); + self.input.update_in(&mut self.cx, |input, window, cx| { + input.stop_recording(&StopRecording, window, cx); + }); + self + } + + /// Clears all keystrokes + pub fn clear_keystrokes(&mut self) -> &mut Self { + self.input.update_in(&mut self.cx, |input, window, cx| { + input.clear_keystrokes(&ClearKeystrokes, window, cx); + }); + self + } + + /// Verifies the recording state + #[track_caller] + pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self { + let actual = self + .input + .update_in(&mut self.cx, |input, window, _| input.is_recording(window)); + assert_eq!( + actual, expected, + "Recording state mismatch. Expected: {}, Actual: {}", + expected, actual + ); + self + } + + /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt" + fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers { + let mut modifiers = self.current_modifiers; + + if let Some(to_add) = modifiers_str.strip_prefix('+') { + // Add modifiers + for modifier in to_add.split('+') { + match modifier { + "ctrl" | "control" => modifiers.control = true, + "alt" | "option" => modifiers.alt = true, + "shift" => modifiers.shift = true, + "cmd" | "command" => modifiers.platform = true, + "fn" | "function" => modifiers.function = true, + _ => panic!("Unknown modifier: {}", modifier), + } + } + } else if let Some(to_remove) = modifiers_str.strip_prefix('-') { + // Remove modifiers + for modifier in to_remove.split('+') { + match modifier { + "ctrl" | "control" => modifiers.control = false, + "alt" | "option" => modifiers.alt = false, + "shift" => modifiers.shift = false, + "cmd" | "command" => modifiers.platform = false, + "fn" | "function" => modifiers.function = false, + _ => panic!("Unknown modifier: {}", modifier), + } + } + } + + modifiers + } + } + + async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + project::Project::init_settings(cx); + workspace::init_settings(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = VisualTestContext::from_window(*workspace, cx); + KeystrokeInputTestHelper::new(cx) + } + + #[gpui::test] + async fn test_basic_keystroke_input(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("a") + .clear_keystrokes() + .expect_empty(); + } + + #[gpui::test] + async fn test_modifier_handling(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-a"]); + } + + #[gpui::test] + async fn test_multiple_modifiers(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("cmd-shift-z") + .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]); + } + + #[gpui::test] + async fn test_search_mode_behavior(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+cmd", "shift-f", "-cmd"]) + // In search mode, when completing a modifier-only keystroke with a key, + // only the original modifiers are preserved, not the keystroke's modifiers + .expect_keystrokes(&["cmd-f"]); + } + + #[gpui::test] + async fn test_keystroke_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("a") + .send_keystroke("b") + .send_keystroke("c") + .expect_keystrokes(&["a", "b", "c"]) // At max limit + .send_keystroke("d") + .expect_empty(); // Should clear when exceeding limit + } + + #[gpui::test] + async fn test_modifier_release_all(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a"]) + .send_events(&["+ctrl"]) + .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]); + } + + #[gpui::test] + async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl+shift", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3 + .expect_empty(); // Should clear when exceeding limit + } + + #[gpui::test] + async fn test_complex_modifier_sequences(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"]) + .expect_keystrokes(&["ctrl-shift-alt-a"]); + } + + #[gpui::test] + async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"]) + .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes + } + + #[gpui::test] + async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"]) + .expect_empty(); // Modifier-only sequences get filtered in non-search mode + } + + #[gpui::test] + async fn test_rapid_modifier_changes(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"]) + .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]); + } + + #[gpui::test] + async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "a", "-ctrl", "b"]) + .expect_keystrokes(&["ctrl-a", "b"]) + .clear_keystrokes() + .expect_empty(); + } + + #[gpui::test] + async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "a"]) + .expect_keystrokes(&["ctrl-a", "ctrl-"]) + .send_events(&["-ctrl"]) + .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes + } + + #[gpui::test] + async fn test_all_modifiers_at_once(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]); + } + + #[gpui::test] + async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit) + .expect_keystrokes(&["a", "b", "c"]) + .send_events(&["d"]) // should clear when exceeding + .expect_empty(); + } + + #[gpui::test] + async fn test_function_modifier_key(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+fn", "f1", "-fn"]) + .expect_keystrokes(&["fn-f1"]); + } + + #[gpui::test] + async fn test_start_stop_recording(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_events(&["a", "b"]) + .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes + .stop_recording() + .expect_is_recording(false) + .start_recording() + .send_events(&["c"]) + .expect_keystrokes(&["c"]); + } + + #[gpui::test] + async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]); + } + + #[gpui::test] + async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&[]) // No events at all + .expect_empty(); + } + + #[gpui::test] + async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording + .expect_is_recording(false); + } + + #[gpui::test] + async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape + } + + #[gpui::test] + async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit + .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes + } + + #[gpui::test] + async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a' + .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered + } + + #[gpui::test] + async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit) + .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection + } + + #[gpui::test] + async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close + .expect_keystrokes(&["escape", "escape"]) + .expect_is_recording(true); // Should remain in keystrokes, no close triggered + } + + #[gpui::test] + async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape + .expect_is_recording(false); + } + + #[gpui::test] + async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close + .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit + } + + #[gpui::test] + async fn test_triple_escape_only(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence + .expect_empty(); + } +} diff --git a/crates/settings_ui/src/ui_components/mod.rs b/crates/settings_ui/src/ui_components/mod.rs index 13971b0a5d..5d6463a61a 100644 --- a/crates/settings_ui/src/ui_components/mod.rs +++ b/crates/settings_ui/src/ui_components/mod.rs @@ -1 +1,2 @@ +pub mod keystroke_input; pub mod table;