From 7eb739d489086cc8a79cd7a0431974e4cdb0f384 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:29:29 -0400 Subject: [PATCH] Add initial support for search by keystroke to keybinding editor (#34274) This PR adds preliminary support for searching keybindings by keystrokes in the keybinding editor. Release Notes: - N/A --- assets/keymaps/default-linux.json | 5 +- assets/keymaps/default-macos.json | 3 +- crates/settings_ui/src/keybindings.rs | 253 ++++++++++++++++++++++---- 3 files changed, 219 insertions(+), 42 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8a46e6c234..489e4e6d0c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1112,7 +1112,10 @@ "context": "KeymapEditor", "use_key_equivalents": true, "bindings": { - "ctrl-f": "search::FocusSearch" + "ctrl-f": "search::FocusSearch", + "alt-find": "keymap_editor::ToggleKeystrokeSearch", + "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", + "alt-c": "keymap_editor::ToggleConflictFilter" } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index cb1cf572fb..c7ab7c9273 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1211,7 +1211,8 @@ "context": "KeymapEditor", "use_key_equivalents": true, "bindings": { - "cmd-f": "search::FocusSearch" + "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", + "cmd-alt-c": "keymap_editor::ToggleConflictFilter" } } ] diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 58c2ac8f8e..56be9481e5 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -10,7 +10,7 @@ use feature_flags::FeatureFlagViewExt; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter, + Action, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText, Subscription, WeakEntity, actions, div, }; @@ -57,7 +57,11 @@ actions!( /// Copies the action name to clipboard. CopyAction, /// Copies the context predicate to clipboard. - CopyContext + CopyContext, + /// Toggles Conflict Filtering + ToggleConflictFilter, + /// Toggle Keystroke search + ToggleKeystrokeSearch, ] ); @@ -143,6 +147,22 @@ impl KeymapEventChannel { } #[derive(Default, PartialEq)] +enum SearchMode { + #[default] + Normal, + KeyStroke, +} + +impl SearchMode { + fn invert(&self) -> Self { + match self { + SearchMode::Normal => SearchMode::KeyStroke, + SearchMode::KeyStroke => SearchMode::Normal, + } + } +} + +#[derive(Default, PartialEq, Copy, Clone)] enum FilterState { #[default] All, @@ -221,11 +241,13 @@ struct KeymapEditor { keybindings: Vec, keybinding_conflict_state: ConflictState, filter_state: FilterState, + search_mode: SearchMode, // corresponds 1 to 1 with keybindings string_match_candidates: Arc>, matches: Vec, table_interaction_state: Entity, filter_editor: Entity, + keystroke_editor: Entity, selected_index: Option, } @@ -245,6 +267,12 @@ impl KeymapEditor { cx.observe_global::(Self::update_keybindings); let table_interaction_state = TableInteractionState::new(window, cx); + let keystroke_editor = cx.new(|cx| { + let mut keystroke_editor = KeystrokeInput::new(window, cx); + keystroke_editor.highlight_on_focus = false; + keystroke_editor + }); + let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Filter action names…", cx); @@ -260,17 +288,28 @@ impl KeymapEditor { }) .detach(); + cx.subscribe(&keystroke_editor, |this, _, _, cx| { + if matches!(this.search_mode, SearchMode::Normal) { + return; + } + + this.update_matches(cx); + }) + .detach(); + let mut this = Self { workspace, keybindings: vec![], keybinding_conflict_state: ConflictState::default(), filter_state: FilterState::default(), + search_mode: SearchMode::default(), string_match_candidates: Arc::new(vec![]), matches: vec![], focus_handle: focus_handle.clone(), _keymap_subscription, table_interaction_state, filter_editor, + keystroke_editor, selected_index: None, }; @@ -279,30 +318,47 @@ impl KeymapEditor { this } - fn current_query(&self, cx: &mut Context) -> String { + fn current_action_query(&self, cx: &App) -> String { self.filter_editor.read(cx).text(cx) } - fn update_matches(&self, cx: &mut Context) { - let query = self.current_query(cx); + fn current_keystroke_query(&self, cx: &App) -> Vec { + match self.search_mode { + SearchMode::KeyStroke => self + .keystroke_editor + .read(cx) + .keystrokes() + .iter() + .cloned() + .collect(), + SearchMode::Normal => Default::default(), + } + } - cx.spawn(async move |this, cx| Self::process_query(this, query, cx).await) - .detach(); + fn update_matches(&self, cx: &mut Context) { + let action_query = self.current_action_query(cx); + let keystroke_query = self.current_keystroke_query(cx); + + cx.spawn(async move |this, cx| { + Self::process_query(this, action_query, keystroke_query, cx).await + }) + .detach(); } async fn process_query( this: WeakEntity, - query: String, + action_query: String, + keystroke_query: Vec, cx: &mut AsyncApp, ) -> anyhow::Result<()> { - let query = command_palette::normalize_action_query(&query); + let action_query = command_palette::normalize_action_query(&action_query); let (string_match_candidates, keybind_count) = this.read_with(cx, |this, _| { (this.string_match_candidates.clone(), this.keybindings.len()) })?; let executor = cx.background_executor().clone(); let mut matches = fuzzy::match_strings( &string_match_candidates, - &query, + &action_query, true, true, keybind_count, @@ -321,7 +377,26 @@ impl KeymapEditor { FilterState::All => {} } - if query.is_empty() { + match this.search_mode { + SearchMode::KeyStroke => { + matches.retain(|item| { + this.keybindings[item.candidate_id] + .ui_key_binding + .as_ref() + .is_some_and(|binding| { + keystroke_query.iter().all(|key| { + binding.keystrokes.iter().any(|keystroke| { + keystroke.key == key.key + && keystroke.modifiers == key.modifiers + }) + }) + }) + }); + } + SearchMode::Normal => {} + } + + if action_query.is_empty() { // apply default sort // sorts by source precedence, and alphabetically by action name within each source matches.sort_by_key(|match_item| { @@ -432,7 +507,7 @@ impl KeymapEditor { let json_language = load_json_language(workspace.clone(), cx).await; let rust_language = load_rust_language(workspace.clone(), cx).await; - let query = this.update(cx, |this, cx| { + let (action_query, keystroke_query) = this.update(cx, |this, cx| { let (key_bindings, string_match_candidates) = Self::process_bindings(json_language, rust_language, cx); @@ -455,10 +530,13 @@ impl KeymapEditor { string: candidate.string.clone(), }) .collect(); - this.current_query(cx) + ( + this.current_action_query(cx), + this.current_keystroke_query(cx), + ) })?; // calls cx.notify - Self::process_query(this, query, cx).await + Self::process_query(this, action_query, keystroke_query, cx).await }) .detach_and_log_err(cx); } @@ -664,6 +742,33 @@ impl KeymapEditor { }; cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone())); } + + fn toggle_conflict_filter( + &mut self, + _: &ToggleConflictFilter, + _: &mut Window, + cx: &mut Context, + ) { + self.filter_state = self.filter_state.invert(); + self.update_matches(cx); + } + + fn toggle_keystroke_search( + &mut self, + _: &ToggleKeystrokeSearch, + window: &mut Window, + cx: &mut Context, + ) { + self.search_mode = self.search_mode.invert(); + self.update_matches(cx); + + match self.search_mode { + SearchMode::KeyStroke => { + window.focus(&self.keystroke_editor.focus_handle(cx)); + } + SearchMode::Normal => {} + } + } } #[derive(Clone)] @@ -763,41 +868,97 @@ impl Render for KeymapEditor { .on_action(cx.listener(Self::delete_binding)) .on_action(cx.listener(Self::copy_action_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_keystroke_search)) .size_full() .p_2() .gap_1() .bg(theme.colors().editor_background) .child( h_flex() + .p_2() + .gap_1() .key_context({ let mut context = KeyContext::new_with_defaults(); context.add("BufferSearchBar"); context }) - .h_8() - .pl_2() - .pr_1() - .py_1() - .border_1() - .border_color(theme.colors().border) - .rounded_lg() - .child(self.filter_editor.clone()) - .when(self.keybinding_conflict_state.any_conflicts(), |this| { - this.child( - IconButton::new("KeymapEditorConflictIcon", IconName::Warning) - .tooltip(Tooltip::text(match self.filter_state { - FilterState::All => "Show conflicts", - FilterState::Conflicts => "Hide conflicts", - })) - .selected_icon_color(Color::Error) - .toggle_state(matches!(self.filter_state, FilterState::Conflicts)) - .on_click(cx.listener(|this, _, _, cx| { - this.filter_state = this.filter_state.invert(); - this.update_matches(cx); - })), - ) - }), + .child( + div() + .size_full() + .h_8() + .pl_2() + .pr_1() + .py_1() + .border_1() + .border_color(theme.colors().border) + .rounded_lg() + .child(self.filter_editor.clone()), + ) + .child( + // TODO: Ask Mikyala if there's a way to get have items be aligned by horizontally + // without embedding a h_flex in another h_flex + h_flex() + .when(self.keybinding_conflict_state.any_conflicts(), |this| { + this.child( + IconButton::new("KeymapEditorConflictIcon", IconName::Warning) + .tooltip({ + let filter_state = self.filter_state; + + move |window, cx| { + Tooltip::for_action( + match filter_state { + FilterState::All => "Show conflicts", + FilterState::Conflicts => "Hide conflicts", + }, + &ToggleConflictFilter, + window, + cx, + ) + } + }) + .selected_icon_color(Color::Error) + .toggle_state(matches!( + self.filter_state, + FilterState::Conflicts + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleConflictFilter.boxed_clone(), + cx, + ); + }), + ) + }) + .child( + IconButton::new("KeymapEditorToggleFiltersIcon", IconName::Filter) + .tooltip(|window, cx| { + Tooltip::for_action( + "Toggle Keystroke Search", + &ToggleKeystrokeSearch, + window, + cx, + ) + }) + .toggle_state(matches!(self.search_mode, SearchMode::KeyStroke)) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleKeystrokeSearch.boxed_clone(), + cx, + ); + }), + ), + ), ) + .when(matches!(self.search_mode, SearchMode::KeyStroke), |this| { + this.child( + div() + .child(self.keystroke_editor.clone()) + .border_1() + .border_color(theme.colors().border) + .rounded_lg(), + ) + }) .child( Table::new() .interactable(&self.table_interaction_state) @@ -1522,6 +1683,7 @@ async fn remove_keybinding( struct KeystrokeInput { keystrokes: Vec, + highlight_on_focus: bool, focus_handle: FocusHandle, intercept_subscription: Option, _focus_subscriptions: [Subscription; 2], @@ -1536,6 +1698,7 @@ impl KeystrokeInput { ]; Self { keystrokes: Vec::new(), + highlight_on_focus: true, focus_handle, intercept_subscription: None, _focus_subscriptions, @@ -1553,6 +1716,7 @@ impl KeystrokeInput { { if !event.modifiers.modified() { self.keystrokes.pop(); + cx.emit(()); } else { last.modifiers = event.modifiers; } @@ -1562,6 +1726,7 @@ impl KeystrokeInput { key: "".to_string(), key_char: None, }); + cx.emit(()); } cx.stop_propagation(); cx.notify(); @@ -1575,6 +1740,7 @@ impl KeystrokeInput { } else if Some(keystroke) != self.keystrokes.last() { self.keystrokes.push(keystroke.clone()); } + cx.emit(()); cx.stop_propagation(); cx.notify(); } @@ -1589,6 +1755,7 @@ impl KeystrokeInput { && !last.key.is_empty() && last.modifiers == event.keystroke.modifiers { + cx.emit(()); self.keystrokes.push(Keystroke { modifiers: event.keystroke.modifiers, key: "".to_string(), @@ -1629,6 +1796,8 @@ impl KeystrokeInput { } } +impl EventEmitter<()> for KeystrokeInput {} + impl Focusable for KeystrokeInput { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() @@ -1645,9 +1814,11 @@ impl Render for KeystrokeInput { .track_focus(&self.focus_handle) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) .on_key_up(cx.listener(Self::on_key_up)) - .focus(|mut style| { - style.border_color = Some(colors.border_focused); - style + .when(self.highlight_on_focus, |this| { + this.focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) }) .py_2() .px_3() @@ -1688,6 +1859,7 @@ impl Render for KeystrokeInput { .when(!is_focused, |this| this.icon_color(Color::Muted)) .on_click(cx.listener(|this, _event, _window, cx| { this.keystrokes.pop(); + cx.emit(()); cx.notify(); })), ) @@ -1697,6 +1869,7 @@ impl Render for KeystrokeInput { .when(!is_focused, |this| this.icon_color(Color::Muted)) .on_click(cx.listener(|this, _event, _window, cx| { this.keystrokes.clear(); + cx.emit(()); cx.notify(); })), ),