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(
&mut self,
event: &ModifiersChangedEvent,
_window: &mut Window,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.stop_propagation();
let keystrokes_len = self.keystrokes.len();
if self.previous_modifiers.modified()
&& event.modifiers.is_subset_of(&self.previous_modifiers)
{
self.previous_modifiers &= event.modifiers;
cx.stop_propagation();
return;
}
self.keystrokes_changed(cx);
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
&& keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
{
if !self.search && !event.modifiers.modified() {
self.keystrokes.pop();
return;
}
if self.search {
if self.previous_modifiers.modified() {
last.modifiers |= event.modifiers;
self.previous_modifiers |= event.modifiers;
} else {
self.keystrokes.push(Self::dummy(event.modifiers));
self.previous_modifiers |= event.modifiers;
}
} else if !event.modifiers.modified() {
self.keystrokes.pop();
self.previous_modifiers |= event.modifiers;
} else {
last.modifiers = event.modifiers;
return;
}
self.keystrokes_changed(cx);
} else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(event.modifiers));
if self.search {
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(
@ -287,56 +289,47 @@ impl KeystrokeInput {
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.stop_propagation();
let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
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()
let mut keystroke = keystroke.clone();
if let Some(last) = self.keystrokes.last()
&& last.key.is_empty()
&& key_len <= Self::KEYSTROKE_COUNT_MAX
&& (!self.search || self.previous_modifiers.modified())
{
if self.search {
last.key = keystroke.key.clone();
if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{
self.upsert_close_keystrokes_start(self.keystrokes.len() - 1, cx);
}
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));
}
self.keystrokes_changed(cx);
cx.stop_propagation();
let key = keystroke.key.clone();
keystroke = last.clone();
keystroke.key = key;
self.keystrokes.pop();
}
if close_keystroke_result == CloseKeystrokeResult::Partial {
self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
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 {
if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
self.clear_keystrokes(&ClearKeystrokes, window, cx);
return;
}
self.keystrokes.push(keystroke.clone());
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>) {
@ -429,6 +422,7 @@ impl KeystrokeInput {
) {
self.keystrokes.clear();
self.keystrokes_changed(cx);
self.end_close_keystrokes_capture();
}
fn is_recording(&self, window: &Window) -> bool {
@ -657,6 +651,7 @@ mod tests {
use super::*;
use fs::FakeFs;
use gpui::{Entity, TestAppContext, VisualTestContext};
use itertools::Itertools as _;
use project::Project;
use settings::SettingsStore;
use workspace::Workspace;
@ -712,7 +707,7 @@ mod tests {
// Combine current modifiers with keystroke 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);
});
@ -739,7 +734,7 @@ mod tests {
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);
});
@ -857,10 +852,14 @@ mod tests {
}
/// Clears all keystrokes
#[track_caller]
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| {
input.clear_keystrokes(&ClearKeystrokes, window, cx);
});
KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
self.current_modifiers = Default::default();
self
}
@ -891,37 +890,103 @@ mod tests {
}
/// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
#[track_caller]
fn parse_modifier_change(&self, modifiers_str: &str) -> 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('+') {
// Add modifiers
for modifier in to_add.split('+') {
match modifier {
"ctrl" | "control" => modifiers.control = true,
"alt" | "option" => modifiers.alt = true,
"shift" => modifiers.shift = true,
"cmd" | "command" => modifiers.platform = true,
"fn" | "function" => modifiers.function = true,
_ => panic!("Unknown modifier: {}", modifier),
}
}
} else if let Some(to_remove) = modifiers_str.strip_prefix('-') {
// Remove modifiers
for modifier in to_remove.split('+') {
match modifier {
"ctrl" | "control" => modifiers.control = false,
"alt" | "option" => modifiers.alt = false,
"shift" => modifiers.shift = false,
"cmd" | "command" => modifiers.platform = false,
"fn" | "function" => modifiers.function = false,
_ => panic!("Unknown modifier: {}", modifier),
}
value = true;
split_char = '+';
remaining = to_add;
} else {
let to_remove = modifiers_str
.strip_prefix('-')
.expect("Modifier string must start with '+' or '-'");
value = false;
split_char = '-';
remaining = to_remove;
}
for modifier in remaining.split(split_char) {
match modifier {
"ctrl" | "control" => modifiers.control = value,
"alt" | "option" => modifiers.alt = value,
"shift" => modifiers.shift = value,
"cmd" | "command" | "platform" => modifiers.platform = value,
"fn" | "function" => modifiers.function = value,
_ => panic!("Unknown modifier: {}", modifier),
}
}
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 {
@ -1254,4 +1319,70 @@ mod tests {
.send_keystroke("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();
}
}