keymap_ui: Additional cleanup (#35299)

Closes #ISSUE

Additional cleanup and testing for the keystroke input including
- Focused testing of the "previous modifiers" logic in search mode
- Not merging unmodified keystrokes into previous modifier only bindings
(extension of #35208)
- Fixing a bug where input would overflow in search mode when entering
only modifiers
- Additional testing logic to ensure keystrokes updated events are
always emitted correctly

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-07-29 17:04:00 -05:00 committed by GitHub
parent 48e085a523
commit 9f69b53869
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -239,46 +239,48 @@ impl KeystrokeInput {
fn on_modifiers_changed( fn on_modifiers_changed(
&mut self, &mut self,
event: &ModifiersChangedEvent, event: &ModifiersChangedEvent,
_window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
cx.stop_propagation();
let keystrokes_len = self.keystrokes.len(); let keystrokes_len = self.keystrokes.len();
if self.previous_modifiers.modified() if self.previous_modifiers.modified()
&& event.modifiers.is_subset_of(&self.previous_modifiers) && event.modifiers.is_subset_of(&self.previous_modifiers)
{ {
self.previous_modifiers &= event.modifiers; self.previous_modifiers &= event.modifiers;
cx.stop_propagation();
return; return;
} }
self.keystrokes_changed(cx);
if let Some(last) = self.keystrokes.last_mut() if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty() && last.key.is_empty()
&& keystrokes_len <= Self::KEYSTROKE_COUNT_MAX && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
{ {
if !self.search && !event.modifiers.modified() {
self.keystrokes.pop();
return;
}
if self.search { if self.search {
if self.previous_modifiers.modified() { if self.previous_modifiers.modified() {
last.modifiers |= event.modifiers; last.modifiers |= event.modifiers;
self.previous_modifiers |= event.modifiers;
} else { } else {
self.keystrokes.push(Self::dummy(event.modifiers)); self.keystrokes.push(Self::dummy(event.modifiers));
self.previous_modifiers |= event.modifiers;
} }
} else if !event.modifiers.modified() { self.previous_modifiers |= event.modifiers;
self.keystrokes.pop();
} else { } else {
last.modifiers = event.modifiers; last.modifiers = event.modifiers;
return;
} }
self.keystrokes_changed(cx);
} else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(event.modifiers)); self.keystrokes.push(Self::dummy(event.modifiers));
if self.search { if self.search {
self.previous_modifiers |= event.modifiers; self.previous_modifiers |= event.modifiers;
} }
self.keystrokes_changed(cx);
} }
cx.stop_propagation(); if keystrokes_len >= Self::KEYSTROKE_COUNT_MAX {
self.clear_keystrokes(&ClearKeystrokes, window, cx);
}
} }
fn handle_keystroke( fn handle_keystroke(
@ -287,56 +289,47 @@ impl KeystrokeInput {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
cx.stop_propagation();
let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
if close_keystroke_result == CloseKeystrokeResult::Close { if close_keystroke_result == CloseKeystrokeResult::Close {
self.stop_recording(&StopRecording, window, cx); self.stop_recording(&StopRecording, window, cx);
return; return;
} }
let key_len = self.keystrokes.len();
if let Some(last) = self.keystrokes.last_mut() let mut keystroke = keystroke.clone();
if let Some(last) = self.keystrokes.last()
&& last.key.is_empty() && last.key.is_empty()
&& key_len <= Self::KEYSTROKE_COUNT_MAX && (!self.search || self.previous_modifiers.modified())
{ {
if self.search { let key = keystroke.key.clone();
last.key = keystroke.key.clone(); keystroke = last.clone();
if close_keystroke_result == CloseKeystrokeResult::Partial keystroke.key = key;
&& self.close_keystrokes_start.is_none() self.keystrokes.pop();
{ }
self.upsert_close_keystrokes_start(self.keystrokes.len() - 1, cx);
} if close_keystroke_result == CloseKeystrokeResult::Partial {
if self.search { self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
self.previous_modifiers = keystroke.modifiers; if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
} else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
&& keystroke.modifiers.modified()
{
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
self.keystrokes_changed(cx);
cx.stop_propagation();
return; return;
} else {
self.keystrokes.pop();
} }
} }
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
if close_keystroke_result == CloseKeystrokeResult::Partial if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
&& 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.clear_keystrokes(&ClearKeystrokes, window, cx);
return;
} }
self.keystrokes.push(keystroke.clone());
self.keystrokes_changed(cx); self.keystrokes_changed(cx);
cx.stop_propagation();
if self.search {
self.previous_modifiers = keystroke.modifiers;
return;
}
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() {
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
} }
fn on_inner_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>) {
@ -429,6 +422,7 @@ impl KeystrokeInput {
) { ) {
self.keystrokes.clear(); self.keystrokes.clear();
self.keystrokes_changed(cx); self.keystrokes_changed(cx);
self.end_close_keystrokes_capture();
} }
fn is_recording(&self, window: &Window) -> bool { fn is_recording(&self, window: &Window) -> bool {
@ -657,6 +651,7 @@ mod tests {
use super::*; use super::*;
use fs::FakeFs; use fs::FakeFs;
use gpui::{Entity, TestAppContext, VisualTestContext}; use gpui::{Entity, TestAppContext, VisualTestContext};
use itertools::Itertools as _;
use project::Project; use project::Project;
use settings::SettingsStore; use settings::SettingsStore;
use workspace::Workspace; use workspace::Workspace;
@ -712,7 +707,7 @@ mod tests {
// Combine current modifiers with keystroke modifiers // Combine current modifiers with keystroke modifiers
keystroke.modifiers |= self.current_modifiers; keystroke.modifiers |= self.current_modifiers;
self.input.update_in(&mut self.cx, |input, window, cx| { self.update_input(|input, window, cx| {
input.handle_keystroke(&keystroke, window, cx); input.handle_keystroke(&keystroke, window, cx);
}); });
@ -739,7 +734,7 @@ mod tests {
capslock: gpui::Capslock::default(), capslock: gpui::Capslock::default(),
}; };
self.input.update_in(&mut self.cx, |input, window, cx| { self.update_input(|input, window, cx| {
input.on_modifiers_changed(&event, window, cx); input.on_modifiers_changed(&event, window, cx);
}); });
@ -857,10 +852,14 @@ mod tests {
} }
/// Clears all keystrokes /// Clears all keystrokes
#[track_caller]
pub fn clear_keystrokes(&mut self) -> &mut Self { pub fn clear_keystrokes(&mut self) -> &mut Self {
let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
self.input.update_in(&mut self.cx, |input, window, cx| { self.input.update_in(&mut self.cx, |input, window, cx| {
input.clear_keystrokes(&ClearKeystrokes, window, cx); input.clear_keystrokes(&ClearKeystrokes, window, cx);
}); });
KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
self.current_modifiers = Default::default();
self self
} }
@ -891,37 +890,103 @@ mod tests {
} }
/// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt" /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
#[track_caller]
fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers { fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
let mut modifiers = self.current_modifiers; let mut modifiers = self.current_modifiers;
assert!(!modifiers_str.is_empty(), "Empty modifier string");
let value;
let split_char;
let remaining;
if let Some(to_add) = modifiers_str.strip_prefix('+') { if let Some(to_add) = modifiers_str.strip_prefix('+') {
// Add modifiers value = true;
for modifier in to_add.split('+') { split_char = '+';
match modifier { remaining = to_add;
"ctrl" | "control" => modifiers.control = true, } else {
"alt" | "option" => modifiers.alt = true, let to_remove = modifiers_str
"shift" => modifiers.shift = true, .strip_prefix('-')
"cmd" | "command" => modifiers.platform = true, .expect("Modifier string must start with '+' or '-'");
"fn" | "function" => modifiers.function = true, value = false;
_ => panic!("Unknown modifier: {}", modifier), split_char = '-';
} remaining = to_remove;
} }
} else if let Some(to_remove) = modifiers_str.strip_prefix('-') {
// Remove modifiers for modifier in remaining.split(split_char) {
for modifier in to_remove.split('+') { match modifier {
match modifier { "ctrl" | "control" => modifiers.control = value,
"ctrl" | "control" => modifiers.control = false, "alt" | "option" => modifiers.alt = value,
"alt" | "option" => modifiers.alt = false, "shift" => modifiers.shift = value,
"shift" => modifiers.shift = false, "cmd" | "command" | "platform" => modifiers.platform = value,
"cmd" | "command" => modifiers.platform = false, "fn" | "function" => modifiers.function = value,
"fn" | "function" => modifiers.function = false, _ => panic!("Unknown modifier: {}", modifier),
_ => panic!("Unknown modifier: {}", modifier),
}
} }
} }
modifiers modifiers
} }
#[track_caller]
fn update_input<R>(
&mut self,
cb: impl FnOnce(&mut KeystrokeInput, &mut Window, &mut Context<KeystrokeInput>) -> R,
) -> R {
let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
let result = self.input.update_in(&mut self.cx, cb);
KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
return result;
}
}
struct KeystrokeUpdateTracker {
initial_keystrokes: Vec<Keystroke>,
_subscription: Subscription,
input: Entity<KeystrokeInput>,
received_keystrokes_updated: bool,
}
impl KeystrokeUpdateTracker {
fn new(input: Entity<KeystrokeInput>, cx: &mut VisualTestContext) -> Entity<Self> {
cx.new(|cx| Self {
initial_keystrokes: input.read_with(cx, |input, _| input.keystrokes.clone()),
_subscription: cx.subscribe(&input, |this: &mut Self, _, _, _| {
this.received_keystrokes_updated = true;
}),
input,
received_keystrokes_updated: false,
})
}
#[track_caller]
fn finish(this: Entity<Self>, cx: &VisualTestContext) {
let (received_keystrokes_updated, initial_keystrokes_str, updated_keystrokes_str) =
this.read_with(cx, |this, cx| {
let updated_keystrokes = this
.input
.read_with(cx, |input, _| input.keystrokes.clone());
let initial_keystrokes_str = keystrokes_str(&this.initial_keystrokes);
let updated_keystrokes_str = keystrokes_str(&updated_keystrokes);
(
this.received_keystrokes_updated,
initial_keystrokes_str,
updated_keystrokes_str,
)
});
if received_keystrokes_updated {
assert_ne!(
initial_keystrokes_str, updated_keystrokes_str,
"Received keystrokes_updated event, expected different keystrokes"
);
} else {
assert_eq!(
initial_keystrokes_str, updated_keystrokes_str,
"Received no keystrokes_updated event, expected same keystrokes"
);
}
fn keystrokes_str(ks: &[Keystroke]) -> String {
ks.iter().map(|ks| ks.unparse()).join(" ")
}
}
} }
async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper { async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper {
@ -1254,4 +1319,70 @@ mod tests {
.send_keystroke("escape") .send_keystroke("escape")
.expect_keystrokes(&["ctrl-g", "escape"]); .expect_keystrokes(&["ctrl-g", "escape"]);
} }
#[gpui::test]
async fn test_search_previous_modifiers_are_sticky(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(true)
.send_events(&["+ctrl+alt", "-ctrl", "j"])
.expect_keystrokes(&["ctrl-alt-j"]);
}
#[gpui::test]
async fn test_previous_modifiers_can_be_entered_separately(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(true)
.send_events(&["+ctrl", "-ctrl"])
.expect_keystrokes(&["ctrl-"])
.send_events(&["+alt", "-alt"])
.expect_keystrokes(&["ctrl-", "alt-"]);
}
#[gpui::test]
async fn test_previous_modifiers_reset_on_key(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(true)
.send_events(&["+ctrl+alt", "-ctrl", "+shift"])
.expect_keystrokes(&["ctrl-shift-alt-"])
.send_keystroke("j")
.expect_keystrokes(&["ctrl-shift-alt-j"])
.send_keystroke("i")
.expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"])
.send_events(&["-shift-alt", "+cmd"])
.expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]);
}
#[gpui::test]
async fn test_previous_modifiers_reset_on_release_all(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(true)
.send_events(&["+ctrl+alt", "-ctrl", "+shift"])
.expect_keystrokes(&["ctrl-shift-alt-"])
.send_events(&["-all", "j"])
.expect_keystrokes(&["ctrl-shift-alt-", "j"]);
}
#[gpui::test]
async fn test_search_repeat_modifiers(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(true)
.send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
.expect_keystrokes(&["ctrl-", "alt-", "shift-"])
.send_events(&["+cmd"])
.expect_empty();
}
#[gpui::test]
async fn test_not_search_repeat_modifiers(cx: &mut TestAppContext) {
init_test(cx)
.await
.with_search_mode(false)
.send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
.expect_empty();
}
} }