Add exact matching option to keymap editor search (#34497)

We know have the ability to filter matches in the keymap editor search
by exact keystroke matches. This allows user's to have the same behavior
as vscode when they toggle all actions with the same bindings

We also fixed a bug where conflicts weren't counted correctly when
saving a keymapping. This cause issues where warnings wouldn't appear
when they were supposed to.

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Anthony Eid 2025-07-16 12:14:09 -04:00 committed by GitHub
parent e339566dab
commit 9ab3d55211
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 150 additions and 53 deletions

1
assets/icons/equal.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-equal-icon lucide-equal"><line x1="5" x2="19" y1="9" y2="9"/><line x1="5" x2="19" y1="15" y2="15"/></svg>

After

Width:  |  Height:  |  Size: 308 B

View file

@ -107,6 +107,7 @@ pub enum IconName {
Ellipsis, Ellipsis,
EllipsisVertical, EllipsisVertical,
Envelope, Envelope,
Equal,
Eraser, Eraser,
Escape, Escape,
Exit, Exit,

View file

@ -66,6 +66,8 @@ actions!(
ToggleConflictFilter, ToggleConflictFilter,
/// Toggle Keystroke search /// Toggle Keystroke search
ToggleKeystrokeSearch, ToggleKeystrokeSearch,
/// Toggles exact matching for keystroke search
ToggleExactKeystrokeMatching,
] ]
); );
@ -176,14 +178,16 @@ impl KeymapEventChannel {
enum SearchMode { enum SearchMode {
#[default] #[default]
Normal, Normal,
KeyStroke, KeyStroke {
exact_match: bool,
},
} }
impl SearchMode { impl SearchMode {
fn invert(&self) -> Self { fn invert(&self) -> Self {
match self { match self {
SearchMode::Normal => SearchMode::KeyStroke, SearchMode::Normal => SearchMode::KeyStroke { exact_match: false },
SearchMode::KeyStroke => SearchMode::Normal, SearchMode::KeyStroke { .. } => SearchMode::Normal,
} }
} }
} }
@ -204,7 +208,11 @@ impl FilterState {
} }
} }
type ActionMapping = (SharedString, Option<SharedString>); #[derive(Debug, Default, PartialEq, Eq, Clone, Hash)]
struct ActionMapping {
keystroke_text: SharedString,
context: Option<SharedString>,
}
#[derive(Default)] #[derive(Default)]
struct ConflictState { struct ConflictState {
@ -257,6 +265,12 @@ impl ConflictState {
}) })
} }
fn will_conflict(&self, action_mapping: ActionMapping) -> Option<Vec<usize>> {
self.action_keybind_mapping
.get(&action_mapping)
.and_then(|indices| indices.is_empty().not().then_some(indices.clone()))
}
fn has_conflict(&self, candidate_idx: &usize) -> bool { fn has_conflict(&self, candidate_idx: &usize) -> bool {
self.conflicts.contains(candidate_idx) self.conflicts.contains(candidate_idx)
} }
@ -375,7 +389,7 @@ impl KeymapEditor {
fn current_keystroke_query(&self, cx: &App) -> Vec<Keystroke> { fn current_keystroke_query(&self, cx: &App) -> Vec<Keystroke> {
match self.search_mode { match self.search_mode {
SearchMode::KeyStroke => self SearchMode::KeyStroke { .. } => self
.keystroke_editor .keystroke_editor
.read(cx) .read(cx)
.keystrokes() .keystrokes()
@ -432,17 +446,27 @@ impl KeymapEditor {
} }
match this.search_mode { match this.search_mode {
SearchMode::KeyStroke => { SearchMode::KeyStroke { exact_match } => {
matches.retain(|item| { matches.retain(|item| {
this.keybindings[item.candidate_id] this.keybindings[item.candidate_id]
.keystrokes() .keystrokes()
.is_some_and(|keystrokes| { .is_some_and(|keystrokes| {
if exact_match {
keystroke_query.len() == keystrokes.len()
&& keystroke_query.iter().zip(keystrokes).all(
|(query, keystroke)| {
query.key == keystroke.key
&& query.modifiers == keystroke.modifiers
},
)
} else {
keystroke_query.iter().all(|key| { keystroke_query.iter().all(|key| {
keystrokes.iter().any(|keystroke| { keystrokes.iter().any(|keystroke| {
keystroke.key == key.key keystroke.key == key.key
&& keystroke.modifiers == key.modifiers && keystroke.modifiers == key.modifiers
}) })
}) })
}
}) })
}); });
} }
@ -699,7 +723,12 @@ impl KeymapEditor {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let weak = cx.weak_entity();
self.context_menu = self.selected_binding().map(|selected_binding| { self.context_menu = self.selected_binding().map(|selected_binding| {
let key_strokes = selected_binding
.keystrokes()
.map(Vec::from)
.unwrap_or_default();
let selected_binding_has_no_context = selected_binding let selected_binding_has_no_context = selected_binding
.context .context
.as_ref() .as_ref()
@ -727,6 +756,22 @@ impl KeymapEditor {
"Copy Context", "Copy Context",
Box::new(CopyContext), Box::new(CopyContext),
) )
.entry("Show matching keybindings", None, {
let weak = weak.clone();
let key_strokes = key_strokes.clone();
move |_, cx| {
weak.update(cx, |this, cx| {
this.filter_state = FilterState::All;
this.search_mode = SearchMode::KeyStroke { exact_match: true };
this.keystroke_editor.update(cx, |editor, cx| {
editor.set_keystrokes(key_strokes.clone(), cx);
});
})
.ok();
}
})
}); });
let context_menu_handle = context_menu.focus_handle(cx); let context_menu_handle = context_menu.focus_handle(cx);
@ -943,17 +988,32 @@ impl KeymapEditor {
// Update the keystroke editor to turn the `search` bool on // Update the keystroke editor to turn the `search` bool on
self.keystroke_editor.update(cx, |keystroke_editor, cx| { self.keystroke_editor.update(cx, |keystroke_editor, cx| {
keystroke_editor.set_search_mode(self.search_mode == SearchMode::KeyStroke); keystroke_editor
.set_search_mode(matches!(self.search_mode, SearchMode::KeyStroke { .. }));
cx.notify(); cx.notify();
}); });
match self.search_mode { match self.search_mode {
SearchMode::KeyStroke => { SearchMode::KeyStroke { .. } => {
window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx)); window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx));
} }
SearchMode::Normal => {} SearchMode::Normal => {}
} }
} }
fn toggle_exact_keystroke_matching(
&mut self,
_: &ToggleExactKeystrokeMatching,
_: &mut Window,
cx: &mut Context<Self>,
) {
let SearchMode::KeyStroke { exact_match } = &mut self.search_mode else {
return;
};
*exact_match = !(*exact_match);
self.on_query_changed(cx);
}
} }
#[derive(Clone)] #[derive(Clone)]
@ -970,13 +1030,14 @@ struct ProcessedKeybinding {
impl ProcessedKeybinding { impl ProcessedKeybinding {
fn get_action_mapping(&self) -> ActionMapping { fn get_action_mapping(&self) -> ActionMapping {
( ActionMapping {
self.keystroke_text.clone(), keystroke_text: self.keystroke_text.clone(),
self.context context: self
.context
.as_ref() .as_ref()
.and_then(|context| context.local()) .and_then(|context| context.local())
.cloned(), .cloned(),
) }
} }
fn keystrokes(&self) -> Option<&[Keystroke]> { fn keystrokes(&self) -> Option<&[Keystroke]> {
@ -1061,6 +1122,7 @@ impl Render for KeymapEditor {
.on_action(cx.listener(Self::copy_context_to_clipboard)) .on_action(cx.listener(Self::copy_context_to_clipboard))
.on_action(cx.listener(Self::toggle_conflict_filter)) .on_action(cx.listener(Self::toggle_conflict_filter))
.on_action(cx.listener(Self::toggle_keystroke_search)) .on_action(cx.listener(Self::toggle_keystroke_search))
.on_action(cx.listener(Self::toggle_exact_keystroke_matching))
.size_full() .size_full()
.p_2() .p_2()
.gap_1() .gap_1()
@ -1103,7 +1165,10 @@ impl Render for KeymapEditor {
cx, cx,
) )
}) })
.toggle_state(matches!(self.search_mode, SearchMode::KeyStroke)) .toggle_state(matches!(
self.search_mode,
SearchMode::KeyStroke { .. }
))
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx); window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx);
}), }),
@ -1141,9 +1206,14 @@ impl Render for KeymapEditor {
) )
}), }),
) )
.when(matches!(self.search_mode, SearchMode::KeyStroke), |this| { .when_some(
match self.search_mode {
SearchMode::Normal => None,
SearchMode::KeyStroke { exact_match } => Some(exact_match),
},
|this, exact_match| {
this.child( this.child(
div() h_flex()
.map(|this| { .map(|this| {
if self.keybinding_conflict_state.any_conflicts() { if self.keybinding_conflict_state.any_conflicts() {
this.pr(rems_from_px(54.)) this.pr(rems_from_px(54.))
@ -1151,9 +1221,28 @@ impl Render for KeymapEditor {
this.pr_7() this.pr_7()
} }
}) })
.child(self.keystroke_editor.clone()), .child(self.keystroke_editor.clone())
.child(
div().p_1().child(
IconButton::new(
"keystrokes-exact-match",
IconName::Equal,
) )
.shape(IconButtonShape::Square)
.toggle_state(exact_match)
.on_click(
cx.listener(|_, _, window, cx| {
window.dispatch_action(
ToggleExactKeystrokeMatching.boxed_clone(),
cx,
);
}), }),
),
),
),
)
},
),
) )
.child( .child(
Table::new() Table::new()
@ -1650,20 +1739,23 @@ impl KeybindingEditorModal {
Ok(input) => input, Ok(input) => input,
}; };
let action_mapping: ActionMapping = ( let action_mapping = ActionMapping {
ui::text_for_keystrokes(&new_keystrokes, cx).into(), keystroke_text: ui::text_for_keystrokes(&new_keystrokes, cx).into(),
new_context context: new_context.as_ref().map(Into::into),
.as_ref() };
.map(Into::into)
.or_else(|| existing_keybind.get_action_mapping().1),
);
if let Some(conflicting_indices) = self let conflicting_indices = if self.creating {
.keymap_editor self.keymap_editor
.read(cx)
.keybinding_conflict_state
.will_conflict(action_mapping)
} else {
self.keymap_editor
.read(cx) .read(cx)
.keybinding_conflict_state .keybinding_conflict_state
.conflicting_indices_for_mapping(action_mapping, self.editing_keybind_idx) .conflicting_indices_for_mapping(action_mapping, self.editing_keybind_idx)
{ };
if let Some(conflicting_indices) = conflicting_indices {
let first_conflicting_index = conflicting_indices[0]; let first_conflicting_index = conflicting_indices[0];
let conflicting_action_name = self let conflicting_action_name = self
.keymap_editor .keymap_editor
@ -1739,10 +1831,11 @@ impl KeybindingEditorModal {
.log_err(); .log_err();
} else { } else {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let action_mapping = ( let action_mapping = ActionMapping {
ui::text_for_keystrokes(new_keystrokes.as_slice(), cx).into(), keystroke_text: ui::text_for_keystrokes(new_keystrokes.as_slice(), cx)
new_context.map(SharedString::from), .into(),
); context: new_context.map(SharedString::from),
};
this.keymap_editor.update(cx, |keymap, cx| { this.keymap_editor.update(cx, |keymap, cx| {
keymap.previous_edit = Some(PreviousEdit::Keybinding { keymap.previous_edit = Some(PreviousEdit::Keybinding {
@ -2221,6 +2314,11 @@ impl KeystrokeInput {
} }
} }
fn set_keystrokes(&mut self, keystrokes: Vec<Keystroke>, cx: &mut Context<Self>) {
self.keystrokes = keystrokes;
self.keystrokes_changed(cx);
}
fn dummy(modifiers: Modifiers) -> Keystroke { fn dummy(modifiers: Modifiers) -> Keystroke {
return Keystroke { return Keystroke {
modifiers, modifiers,
@ -2438,14 +2536,11 @@ impl KeystrokeInput {
fn clear_keystrokes( fn clear_keystrokes(
&mut self, &mut self,
_: &ClearKeystrokes, _: &ClearKeystrokes,
window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if !self.outer_focus_handle.is_focused(window) {
return;
}
self.keystrokes.clear(); self.keystrokes.clear();
cx.notify(); self.keystrokes_changed(cx);
} }
} }