From 67c765a99abc30af83eaf0756237d9471302d7b6 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 11 Jul 2025 17:06:06 -0500 Subject: [PATCH] keymap_ui: Dual-phase focus for keystroke input (#34312) 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)
Video https://github.com/user-attachments/assets/490538d0-f092-4df1-a53a-a47d7efe157b
Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/settings_ui/src/keybindings.rs | 88 +++++++++++++++++++-------- 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 7fc2e070875451a6d6351ad891876b8350692baf..c78a370f1232eb219b7ba31d3113f2427672fccd 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/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, placeholder_keystrokes: Option>, highlight_on_focus: bool, - focus_handle: FocusHandle, + outer_focus_handle: FocusHandle, + inner_focus_handle: FocusHandle, intercept_subscription: Option, _focus_subscriptions: [Subscription; 2], } @@ -1850,16 +1852,18 @@ impl KeystrokeInput { window: &mut Window, cx: &mut Context, ) -> 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) { + 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, 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, + cx: &mut Context, ) { 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) -> 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(());