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)


<details><summary>Video</summary>


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


</details>

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-07-11 17:06:06 -05:00 committed by GitHub
parent 206cce6783
commit 67c765a99a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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(());