keymap_ui: Clear close keystroke capture on timeout (#35289)

Closes #ISSUE

Introduces a mechanism whereby keystrokes that have a post-fix which
matches the prefix of the stop recording binding can still be entered.
The solution is to introduce a (as of right now) 300ms timeout before
the close keystroke state is wiped.

Previously, with the default stop recording binding `esc esc esc`,
searching or entering a binding ending in esc was not possible without
using the mouse. `e.g.` entering keystroke `ctrl-g esc` and then
attempting to hit `esc` three times would stop recording on the
penultimate `esc` press and the final `esc` would not be intercepted.
Now with the timeout, it is possible to enter `ctrl-g esc`, pause for a
moment, then hit `esc esc esc` and end the recording with the keystroke
input state being `ctrl-g esc`.

I arrived at 300ms for this delay as it was long enough that I didn't
run into it very often when trying to escape, but short enough that a
natural pause will almost always work as expected.

Release Notes:

- Keymap Editor: Added a short timeout to the stop recording keybind
handling in the keystroke input, so that it is now possible using the
default bindings as an example (custom bindings should work as well) to
search for/enter a binding ending with `escape` (with no modifier),
pause for a moment, then hit `escape escape escape` to stop recording
and search for/enter a keystroke ending with `escape`.
This commit is contained in:
Ben Kunkle 2025-07-29 16:24:57 -05:00 committed by GitHub
parent 5fa212183a
commit 85b712c04e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,6 +1,6 @@
use gpui::{
Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
Keystroke, Modifiers, ModifiersChangedEvent, Subscription, actions,
Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
};
use ui::{
ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
@ -21,6 +21,9 @@ actions!(
const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput";
const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration =
std::time::Duration::from_millis(300);
enum CloseKeystrokeResult {
Partial,
Close,
@ -46,10 +49,19 @@ pub struct KeystrokeInput {
intercept_subscription: Option<Subscription>,
_focus_subscriptions: [Subscription; 2],
search: bool,
/// Handles triple escape to stop recording
/// The sequence of close keystrokes being typed
close_keystrokes: Option<Vec<Keystroke>>,
close_keystrokes_start: Option<usize>,
previous_modifiers: Modifiers,
/// In order to support inputting keystrokes that end with a prefix of the
/// close keybind keystrokes, we clear the close keystroke capture info
/// on a timeout after a close keystroke is pressed
///
/// e.g. if close binding is `esc esc esc` and user wants to search for
/// `ctrl-g esc`, after entering the `ctrl-g esc`, hitting `esc` twice would
/// stop recording because of the sequence of three escapes making it
/// impossible to search for anything ending in `esc`
clear_close_keystrokes_timer: Option<Task<()>>,
#[cfg(test)]
recording: bool,
}
@ -79,6 +91,7 @@ impl KeystrokeInput {
close_keystrokes: None,
close_keystrokes_start: None,
previous_modifiers: Modifiers::default(),
clear_close_keystrokes_timer: None,
#[cfg(test)]
recording: false,
}
@ -144,6 +157,34 @@ impl KeystrokeInput {
}
}
fn upsert_close_keystrokes_start(&mut self, start: usize, cx: &mut Context<Self>) {
if self.close_keystrokes_start.is_some() {
return;
}
self.close_keystrokes_start = Some(start);
self.update_clear_close_keystrokes_timer(cx);
}
fn update_clear_close_keystrokes_timer(&mut self, cx: &mut Context<Self>) {
self.clear_close_keystrokes_timer = Some(cx.spawn(async |this, cx| {
cx.background_executor()
.timer(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT)
.await;
this.update(cx, |this, _cx| {
this.end_close_keystrokes_capture();
})
.ok();
}));
}
/// Interrupt the capture of close keystrokes, but do not clear the close keystrokes
/// from the input
fn end_close_keystrokes_capture(&mut self) -> Option<usize> {
self.close_keystrokes.take();
self.clear_close_keystrokes_timer.take();
return self.close_keystrokes_start.take();
}
fn handle_possible_close_keystroke(
&mut self,
keystroke: &Keystroke,
@ -152,8 +193,7 @@ impl KeystrokeInput {
) -> 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();
self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
};
let action_keystrokes = keybind_for_close_action.keystrokes();
@ -169,20 +209,20 @@ impl KeystrokeInput {
}
if index == close_keystrokes.len() {
if index >= action_keystrokes.len() {
self.close_keystrokes_start.take();
self.end_close_keystrokes_capture();
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);
close_keystrokes.push(keystroke.clone());
if close_keystrokes.len() == action_keystrokes.len() {
return CloseKeystrokeResult::Close;
} else {
close_keystrokes.push(keystroke.clone());
self.close_keystrokes = Some(close_keystrokes);
self.update_clear_close_keystrokes_timer(cx);
return CloseKeystrokeResult::Partial;
}
} else {
self.close_keystrokes_start.take();
self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
}
}
@ -192,7 +232,7 @@ impl KeystrokeInput {
self.close_keystrokes = Some(vec![keystroke.clone()]);
return CloseKeystrokeResult::Partial;
}
self.close_keystrokes_start.take();
self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
}
@ -248,36 +288,22 @@ impl KeystrokeInput {
cx: &mut Context<Self>,
) {
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::Close {
self.stop_recording(&StopRecording, window, cx);
return;
}
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());
self.upsert_close_keystrokes_start(self.keystrokes.len() - 1, cx);
}
self.keystrokes.push(keystroke.clone());
if self.search {
self.previous_modifiers = keystroke.modifiers;
} else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
@ -285,10 +311,30 @@ impl KeystrokeInput {
{
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();
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.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
}
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();
}
@ -365,8 +411,9 @@ impl KeystrokeInput {
&& close_keystrokes_start < self.keystrokes.len()
{
self.keystrokes.drain(close_keystrokes_start..);
self.keystrokes_changed(cx);
}
self.close_keystrokes.take();
self.end_close_keystrokes_capture();
#[cfg(test)]
{
self.recording = false;
@ -645,6 +692,7 @@ mod tests {
/// Sends a keystroke event based on string description
/// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
#[track_caller]
pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
self.expect_is_recording(true);
let keystroke_str = if keystroke_input.ends_with('-') {
@ -677,6 +725,7 @@ mod tests {
/// Sends a modifier change event based on string description
/// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
#[track_caller]
pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
self.expect_is_recording(true);
let new_modifiers = if modifiers == "-all" {
@ -700,6 +749,7 @@ mod tests {
/// Sends multiple events in sequence
/// Each event string is either a keystroke or modifier change
#[track_caller]
pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
self.expect_is_recording(true);
for event in events {
@ -712,9 +762,8 @@ mod tests {
self
}
/// Verifies that the keystrokes match the expected strings
#[track_caller]
pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) {
let expected_keystrokes: Result<Vec<Keystroke>, _> = expected
.iter()
.map(|s| {
@ -738,9 +787,6 @@ mod tests {
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(),
@ -763,6 +809,25 @@ mod tests {
actual.unparse()
);
}
}
/// Verifies that the keystrokes match the expected strings
#[track_caller]
pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
let actual = self
.input
.read_with(&mut self.cx, |input, _| input.keystrokes.clone());
Self::expect_keystrokes_equal(&actual, expected);
self
}
#[track_caller]
pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
let actual = self
.input
.read_with(&mut self.cx, |input, _| input.close_keystrokes.clone())
.unwrap_or_default();
Self::expect_keystrokes_equal(&actual, expected);
self
}
@ -813,6 +878,18 @@ mod tests {
self
}
pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self {
let task = self.input.update_in(&mut self.cx, |input, _, _| {
input.clear_close_keystrokes_timer.take()
});
let task = task.expect("No close keystroke capture end timer task");
self.cx
.executor()
.advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT);
task.await;
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;
@ -1162,4 +1239,19 @@ mod tests {
.send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
.expect_empty();
}
#[gpui::test]
async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) {
init_test(cx)
.await
.send_events(&["+ctrl", "g", "-ctrl", "escape"])
.expect_keystrokes(&["ctrl-g", "escape"])
.wait_for_close_keystroke_capture_end()
.await
.send_events(&["escape", "escape"])
.expect_keystrokes(&["ctrl-g", "escape", "escape"])
.expect_close_keystrokes(&["escape", "escape"])
.send_keystroke("escape")
.expect_keystrokes(&["ctrl-g", "escape"]);
}
}