keymap_ui: Dual-phase focus for keystroke input (#34312)

Ben Kunkle created

Closes #ISSUE

An idea I and @MrSubidubi came up with, to improve UX around the
keystroke input.

Currently, there's a hard tradeoff with what to focus first in the edit
keybind modal, if we focus the keystroke input, it makes keybind
modification very easy, however, if you don't want to edit a keybind,
you must use the mouse to escape the keystroke input before editing
something else - breaking keyboard navigation.

The idea in this PR is to have a dual-phased focus system for the
keystroke input. There is an outer focus that has some sort of visual
indicator to communicate it is focused (currently a border). While the
outer focus region is focused, keystrokes are not intercepted. Then
there is a keybind (currently hardcoded to `enter`) to enter the inner
focus where keystrokes are intercepted, and which must be exited using
the mouse. When the inner focus region is focused, there is a visual
indicator for the fact it is "recording" (currently a hacked together
red pulsing recording icon)


<details><summary>Video</summary>


https://github.com/user-attachments/assets/490538d0-f092-4df1-a53a-a47d7efe157b


</details>

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

crates/settings_ui/src/keybindings.rs | 88 ++++++++++++++++++++--------
1 file changed, 62 insertions(+), 26 deletions(-)

Detailed changes

crates/settings_ui/src/keybindings.rs 🔗

@@ -10,9 +10,10 @@ use feature_flags::FeatureFlagViewExt;
 use fs::Fs;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    Action, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter,
-    FocusHandle, Focusable, Global, KeyContext, Keystroke, ModifiersChangedEvent, MouseButton,
-    Point, ScrollStrategy, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div,
+    Action, AnimationExt, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity,
+    EventEmitter, FocusHandle, Focusable, Global, KeyContext, KeyDownEvent, Keystroke,
+    ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, StyledText, Subscription,
+    WeakEntity, actions, anchored, deferred, div,
 };
 use language::{Language, LanguageConfig, ToOffset as _};
 use settings::{BaseKeymap, KeybindSource, KeymapFile, SettingsAssets};
@@ -1839,7 +1840,8 @@ struct KeystrokeInput {
     keystrokes: Vec<Keystroke>,
     placeholder_keystrokes: Option<Vec<Keystroke>>,
     highlight_on_focus: bool,
-    focus_handle: FocusHandle,
+    outer_focus_handle: FocusHandle,
+    inner_focus_handle: FocusHandle,
     intercept_subscription: Option<Subscription>,
     _focus_subscriptions: [Subscription; 2],
 }
@@ -1850,16 +1852,18 @@ impl KeystrokeInput {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let focus_handle = cx.focus_handle();
+        let outer_focus_handle = cx.focus_handle();
+        let inner_focus_handle = cx.focus_handle();
         let _focus_subscriptions = [
-            cx.on_focus_in(&focus_handle, window, Self::on_focus_in),
-            cx.on_focus_out(&focus_handle, window, Self::on_focus_out),
+            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,
             highlight_on_focus: true,
-            focus_handle,
+            inner_focus_handle,
+            outer_focus_handle,
             intercept_subscription: None,
             _focus_subscriptions,
         }
@@ -1926,7 +1930,7 @@ impl KeystrokeInput {
         cx.notify();
     }
 
-    fn on_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+    fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
         if self.intercept_subscription.is_none() {
             let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, _window, cx| {
                 this.handle_keystroke(&event.keystroke, cx);
@@ -1935,13 +1939,14 @@ impl KeystrokeInput {
         }
     }
 
-    fn on_focus_out(
+    fn on_inner_focus_out(
         &mut self,
         _event: gpui::FocusOutEvent,
         _window: &mut Window,
-        _cx: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) {
         self.intercept_subscription.take();
+        cx.notify();
     }
 
     fn keystrokes(&self) -> &[Keystroke] {
@@ -1984,26 +1989,18 @@ impl EventEmitter<()> for KeystrokeInput {}
 
 impl Focusable for KeystrokeInput {
     fn focus_handle(&self, _cx: &App) -> FocusHandle {
-        self.focus_handle.clone()
+        self.outer_focus_handle.clone()
     }
 }
 
 impl Render for KeystrokeInput {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let colors = cx.theme().colors();
-        let is_focused = self.focus_handle.is_focused(window);
+        let is_inner_focused = self.inner_focus_handle.is_focused(window);
 
         return h_flex()
-            .id("keybinding_input")
-            .track_focus(&self.focus_handle)
-            .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
-            .on_key_up(cx.listener(Self::on_key_up))
-            .when(self.highlight_on_focus, |this| {
-                this.focus(|mut style| {
-                    style.border_color = Some(colors.border_focused);
-                    style
-                })
-            })
+            .id("keystroke-input")
+            .track_focus(&self.outer_focus_handle)
             .py_2()
             .px_3()
             .gap_2()
@@ -2014,10 +2011,31 @@ impl Render for KeystrokeInput {
             .rounded_md()
             .overflow_hidden()
             .bg(colors.editor_background)
-            .border_1()
+            .border_2()
             .border_color(colors.border_variant)
+            .focus(|mut s| {
+                s.border_color = Some(colors.border_focused);
+                s
+            })
+            .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
+                // TODO: replace with action
+                if !event.keystroke.modifiers.modified() && event.keystroke.key == "enter" {
+                    window.focus(&this.inner_focus_handle);
+                    cx.notify();
+                }
+            }))
             .child(
                 h_flex()
+                    .id("keystroke-input-inner")
+                    .track_focus(&self.inner_focus_handle)
+                    .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
+                    .on_key_up(cx.listener(Self::on_key_up))
+                    .when(self.highlight_on_focus, |this| {
+                        this.focus(|mut style| {
+                            style.border_color = Some(colors.border_focused);
+                            style
+                        })
+                    })
                     .w_full()
                     .min_w_0()
                     .justify_center()
@@ -2029,10 +2047,28 @@ impl Render for KeystrokeInput {
                 h_flex()
                     .gap_0p5()
                     .flex_none()
+                    .when(is_inner_focused, |this| {
+                        this.child(
+                            Icon::new(IconName::Circle)
+                                .color(Color::Error)
+                                .with_animation(
+                                    "recording-pulse",
+                                    gpui::Animation::new(std::time::Duration::from_secs(1))
+                                        .repeat()
+                                        .with_easing(gpui::pulsating_between(0.8, 1.0)),
+                                    {
+                                        let color = Color::Error.color(cx);
+                                        move |this, delta| {
+                                            this.color(Color::Custom(color.opacity(delta)))
+                                        }
+                                    },
+                                ),
+                        )
+                    })
                     .child(
                         IconButton::new("backspace-btn", IconName::Delete)
                             .tooltip(Tooltip::text("Delete Keystroke"))
-                            .when(!is_focused, |this| this.icon_color(Color::Muted))
+                            .when(!is_inner_focused, |this| this.icon_color(Color::Muted))
                             .on_click(cx.listener(|this, _event, _window, cx| {
                                 this.keystrokes.pop();
                                 cx.emit(());
@@ -2042,7 +2078,7 @@ impl Render for KeystrokeInput {
                     .child(
                         IconButton::new("clear-btn", IconName::Eraser)
                             .tooltip(Tooltip::text("Clear Keystrokes"))
-                            .when(!is_focused, |this| this.icon_color(Color::Muted))
+                            .when(!is_inner_focused, |this| this.icon_color(Color::Muted))
                             .on_click(cx.listener(|this, _event, _window, cx| {
                                 this.keystrokes.clear();
                                 cx.emit(());