Files
oak-gpui/crates/settings_ui/src/keybindings.rs
T
Ben Kunkle 4e6b7ee3ea keymap_ui: Hover tooltip for action documentation (#33862)
Closes #ISSUE

Show the documentation for an action when hovered. As a bonus, also show
the humanized command palette name!

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-03 16:35:20 +00:00

1240 lines
43 KiB
Rust

use std::{ops::Range, sync::Arc};
use anyhow::{Context as _, anyhow};
use collections::HashSet;
use editor::{Editor, EditorEvent};
use feature_flags::FeatureFlagViewExt;
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
AppContext as _, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText, Subscription,
WeakEntity, actions, div, transparent_black,
};
use language::{Language, LanguageConfig};
use settings::KeybindSource;
use util::ResultExt;
use ui::{
ActiveTheme as _, App, BorrowAppContext, ContextMenu, ParentElement as _, Render, SharedString,
Styled as _, Tooltip, Window, prelude::*, right_click_menu,
};
use workspace::{Item, ModalView, SerializableItem, Workspace, register_serializable_item};
use crate::{
SettingsUiFeatureFlag,
keybindings::persistence::KEYBINDING_EDITORS,
ui_components::table::{Table, TableInteractionState},
};
actions!(
zed,
[
/// Opens the keymap editor.
OpenKeymapEditor
]
);
const KEYMAP_EDITOR_NAMESPACE: &'static str = "keymap_editor";
actions!(
keymap_editor,
[
/// Edits the selected key binding.
EditBinding,
/// Copies the action name to clipboard.
CopyAction,
/// Copies the context predicate to clipboard.
CopyContext
]
);
pub fn init(cx: &mut App) {
let keymap_event_channel = KeymapEventChannel::new();
cx.set_global(keymap_event_channel);
cx.on_action(|_: &OpenKeymapEditor, cx| {
workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| {
let existing = workspace
.active_pane()
.read(cx)
.items()
.find_map(|item| item.downcast::<KeymapEditor>());
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
let keymap_editor =
cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx));
workspace.add_item_to_active_pane(Box::new(keymap_editor), None, true, window, cx);
}
});
});
cx.observe_new(|_workspace: &mut Workspace, window, cx| {
let Some(window) = window else { return };
let keymap_ui_actions = [std::any::TypeId::of::<OpenKeymapEditor>()];
command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&keymap_ui_actions);
filter.hide_namespace(KEYMAP_EDITOR_NAMESPACE);
});
cx.observe_flag::<SettingsUiFeatureFlag, _>(
window,
move |is_enabled, _workspace, _, cx| {
if is_enabled {
command_palette_hooks::CommandPaletteFilter::update_global(
cx,
|filter, _cx| {
filter.show_action_types(keymap_ui_actions.iter());
filter.show_namespace(KEYMAP_EDITOR_NAMESPACE);
},
);
} else {
command_palette_hooks::CommandPaletteFilter::update_global(
cx,
|filter, _cx| {
filter.hide_action_types(&keymap_ui_actions);
filter.hide_namespace(KEYMAP_EDITOR_NAMESPACE);
},
);
}
},
)
.detach();
})
.detach();
register_serializable_item::<KeymapEditor>(cx);
}
pub struct KeymapEventChannel {}
impl Global for KeymapEventChannel {}
impl KeymapEventChannel {
fn new() -> Self {
Self {}
}
pub fn trigger_keymap_changed(cx: &mut App) {
let Some(_event_channel) = cx.try_global::<Self>() else {
// don't panic if no global defined. This usually happens in tests
return;
};
cx.update_global(|_event_channel: &mut Self, _| {
/* triggers observers in KeymapEditors */
});
}
}
struct KeymapEditor {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
_keymap_subscription: Subscription,
keybindings: Vec<ProcessedKeybinding>,
// corresponds 1 to 1 with keybindings
string_match_candidates: Arc<Vec<StringMatchCandidate>>,
matches: Vec<StringMatch>,
table_interaction_state: Entity<TableInteractionState>,
filter_editor: Entity<Editor>,
selected_index: Option<usize>,
}
impl EventEmitter<()> for KeymapEditor {}
impl Focusable for KeymapEditor {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
return self.filter_editor.focus_handle(cx);
}
}
impl KeymapEditor {
fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let _keymap_subscription =
cx.observe_global::<KeymapEventChannel>(Self::update_keybindings);
let table_interaction_state = TableInteractionState::new(window, cx);
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Filter action names…", cx);
editor
});
cx.subscribe(&filter_editor, |this, _, e: &EditorEvent, cx| {
if !matches!(e, EditorEvent::BufferEdited) {
return;
}
this.update_matches(cx);
})
.detach();
let mut this = Self {
workspace,
keybindings: vec![],
string_match_candidates: Arc::new(vec![]),
matches: vec![],
focus_handle: focus_handle.clone(),
_keymap_subscription,
table_interaction_state,
filter_editor,
selected_index: None,
};
this.update_keybindings(cx);
this
}
fn current_query(&self, cx: &mut Context<Self>) -> String {
self.filter_editor.read(cx).text(cx)
}
fn update_matches(&self, cx: &mut Context<Self>) {
let query = self.current_query(cx);
cx.spawn(async move |this, cx| Self::process_query(this, query, cx).await)
.detach();
}
async fn process_query(
this: WeakEntity<Self>,
query: String,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let query = command_palette::normalize_action_query(&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,
true,
true,
keybind_count,
&Default::default(),
executor,
)
.await;
this.update(cx, |this, cx| {
if query.is_empty() {
// apply default sort
// sorts by source precedence, and alphabetically by action name within each source
matches.sort_by_key(|match_item| {
let keybind = &this.keybindings[match_item.candidate_id];
let source = keybind.source.as_ref().map(|s| s.0);
use KeybindSource::*;
let source_precedence = match source {
Some(User) => 0,
Some(Vim) => 1,
Some(Base) => 2,
Some(Default) => 3,
None => 4,
};
return (source_precedence, keybind.action.as_ref());
});
}
this.selected_index.take();
this.scroll_to_item(0, ScrollStrategy::Top, cx);
this.matches = matches;
cx.notify();
})
}
fn process_bindings(
json_language: Arc<Language>,
cx: &mut App,
) -> (Vec<ProcessedKeybinding>, Vec<StringMatchCandidate>) {
let key_bindings_ptr = cx.key_bindings();
let lock = key_bindings_ptr.borrow();
let key_bindings = lock.bindings();
let mut unmapped_action_names =
HashSet::from_iter(cx.all_action_names().into_iter().copied());
let action_documentation = cx.action_documentation();
let mut processed_bindings = Vec::new();
let mut string_match_candidates = Vec::new();
for key_binding in key_bindings {
let source = key_binding.meta().map(settings::KeybindSource::from_meta);
let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx);
let ui_key_binding = Some(
ui::KeyBinding::new_from_gpui(key_binding.clone(), cx)
.vim_mode(source == Some(settings::KeybindSource::Vim)),
);
let context = key_binding
.predicate()
.map(|predicate| KeybindContextString::Local(predicate.to_string().into()))
.unwrap_or(KeybindContextString::Global);
let source = source.map(|source| (source, source.name().into()));
let action_name = key_binding.action().name();
unmapped_action_names.remove(&action_name);
let action_input = key_binding
.action_input()
.map(|input| SyntaxHighlightedText::new(input, json_language.clone()));
let action_docs = action_documentation.get(action_name).copied();
let index = processed_bindings.len();
let string_match_candidate = StringMatchCandidate::new(index, &action_name);
processed_bindings.push(ProcessedKeybinding {
keystroke_text: keystroke_text.into(),
ui_key_binding,
action: action_name.into(),
action_input,
action_docs,
context: Some(context),
source,
});
string_match_candidates.push(string_match_candidate);
}
let empty = SharedString::new_static("");
for action_name in unmapped_action_names.into_iter() {
let index = processed_bindings.len();
let string_match_candidate = StringMatchCandidate::new(index, &action_name);
processed_bindings.push(ProcessedKeybinding {
keystroke_text: empty.clone(),
ui_key_binding: None,
action: action_name.into(),
action_input: None,
action_docs: action_documentation.get(action_name).copied(),
context: None,
source: None,
});
string_match_candidates.push(string_match_candidate);
}
(processed_bindings, string_match_candidates)
}
fn update_keybindings(&mut self, cx: &mut Context<KeymapEditor>) {
let workspace = self.workspace.clone();
cx.spawn(async move |this, cx| {
let json_language = Self::load_json_language(workspace, cx).await;
let query = this.update(cx, |this, cx| {
let (key_bindings, string_match_candidates) =
Self::process_bindings(json_language.clone(), cx);
this.keybindings = key_bindings;
this.string_match_candidates = Arc::new(string_match_candidates);
this.matches = this
.string_match_candidates
.iter()
.enumerate()
.map(|(ix, candidate)| StringMatch {
candidate_id: ix,
score: 0.0,
positions: vec![],
string: candidate.string.clone(),
})
.collect();
this.current_query(cx)
})?;
// calls cx.notify
Self::process_query(this, query, cx).await
})
.detach_and_log_err(cx);
}
async fn load_json_language(
workspace: WeakEntity<Workspace>,
cx: &mut AsyncApp,
) -> Arc<Language> {
let json_language_task = workspace
.read_with(cx, |workspace, cx| {
workspace
.project()
.read(cx)
.languages()
.language_for_name("JSON")
})
.context("Failed to load JSON language")
.log_err();
let json_language = match json_language_task {
Some(task) => task.await.context("Failed to load JSON language").log_err(),
None => None,
};
return json_language.unwrap_or_else(|| {
Arc::new(Language::new(
LanguageConfig {
name: "JSON".into(),
..Default::default()
},
Some(tree_sitter_json::LANGUAGE.into()),
))
});
}
fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext {
let mut dispatch_context = KeyContext::new_with_defaults();
dispatch_context.add("KeymapEditor");
dispatch_context.add("menu");
dispatch_context
}
fn scroll_to_item(&self, index: usize, strategy: ScrollStrategy, cx: &mut App) {
let index = usize::min(index, self.matches.len().saturating_sub(1));
self.table_interaction_state.update(cx, |this, _cx| {
this.scroll_handle.scroll_to_item(index, strategy);
});
}
fn focus_search(
&mut self,
_: &search::FocusSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self
.filter_editor
.focus_handle(cx)
.contains_focused(window, cx)
{
window.focus(&self.filter_editor.focus_handle(cx));
} else {
self.filter_editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
});
}
self.selected_index.take();
}
fn selected_binding(&self) -> Option<&ProcessedKeybinding> {
self.selected_index
.and_then(|match_index| self.matches.get(match_index))
.map(|r#match| r#match.candidate_id)
.and_then(|keybind_index| self.keybindings.get(keybind_index))
}
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
if let Some(selected) = self.selected_index {
let selected = selected + 1;
if selected >= self.matches.len() {
self.select_last(&Default::default(), window, cx);
} else {
self.selected_index = Some(selected);
self.scroll_to_item(selected, ScrollStrategy::Center, cx);
cx.notify();
}
} else {
self.select_first(&Default::default(), window, cx);
}
}
fn select_previous(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(selected) = self.selected_index {
if selected == 0 {
return;
}
let selected = selected - 1;
if selected >= self.matches.len() {
self.select_last(&Default::default(), window, cx);
} else {
self.selected_index = Some(selected);
self.scroll_to_item(selected, ScrollStrategy::Center, cx);
cx.notify();
}
} else {
self.select_last(&Default::default(), window, cx);
}
}
fn select_first(
&mut self,
_: &menu::SelectFirst,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.matches.get(0).is_some() {
self.selected_index = Some(0);
self.scroll_to_item(0, ScrollStrategy::Center, cx);
cx.notify();
}
}
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
if self.matches.last().is_some() {
let index = self.matches.len() - 1;
self.selected_index = Some(index);
self.scroll_to_item(index, ScrollStrategy::Center, cx);
cx.notify();
}
}
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
self.edit_selected_keybinding(window, cx);
}
fn edit_selected_keybinding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(keybind) = self.selected_binding() else {
return;
};
self.workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
workspace.toggle_modal(window, cx, |window, cx| {
let modal = KeybindingEditorModal::new(keybind.clone(), fs, window, cx);
window.focus(&modal.focus_handle(cx));
modal
});
})
.log_err();
}
fn edit_binding(&mut self, _: &EditBinding, window: &mut Window, cx: &mut Context<Self>) {
self.edit_selected_keybinding(window, cx);
}
fn copy_context_to_clipboard(
&mut self,
_: &CopyContext,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let context = self
.selected_binding()
.and_then(|binding| binding.context.as_ref())
.and_then(KeybindContextString::local_str)
.map(|context| context.to_string());
let Some(context) = context else {
return;
};
cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone()));
}
fn copy_action_to_clipboard(
&mut self,
_: &CopyAction,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let action = self
.selected_binding()
.map(|binding| binding.action.to_string());
let Some(action) = action else {
return;
};
cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone()));
}
}
#[derive(Clone)]
struct ProcessedKeybinding {
keystroke_text: SharedString,
ui_key_binding: Option<ui::KeyBinding>,
action: SharedString,
action_input: Option<SyntaxHighlightedText>,
action_docs: Option<&'static str>,
context: Option<KeybindContextString>,
source: Option<(KeybindSource, SharedString)>,
}
#[derive(Clone, Debug, IntoElement)]
enum KeybindContextString {
Global,
Local(SharedString),
}
impl KeybindContextString {
const GLOBAL: SharedString = SharedString::new_static("<global>");
pub fn local(&self) -> Option<&SharedString> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name) => Some(name),
}
}
pub fn local_str(&self) -> Option<&str> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name) => Some(name),
}
}
}
impl RenderOnce for KeybindContextString {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
match self {
KeybindContextString::Global => KeybindContextString::GLOBAL.clone(),
KeybindContextString::Local(name) => name,
}
}
}
impl Item for KeymapEditor {
type Event = ();
fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
"Keymap Editor".into()
}
}
impl Render for KeymapEditor {
fn render(&mut self, window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
let row_count = self.matches.len();
let theme = cx.theme();
v_flex()
.id("keymap-editor")
.track_focus(&self.focus_handle)
.key_context(self.dispatch_context(window, cx))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::focus_search))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::edit_binding))
.on_action(cx.listener(Self::copy_action_to_clipboard))
.on_action(cx.listener(Self::copy_context_to_clipboard))
.size_full()
.p_2()
.gap_1()
.bg(theme.colors().editor_background)
.child(
h_flex()
.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()),
)
.child(
Table::new()
.interactable(&self.table_interaction_state)
.striped()
.column_widths([rems(16.), rems(16.), rems(16.), rems(32.), rems(8.)])
.header(["Action", "Arguments", "Keystrokes", "Context", "Source"])
.uniform_list(
"keymap-editor-table",
row_count,
cx.processor(move |this, range: Range<usize>, _window, _cx| {
range
.filter_map(|index| {
let candidate_id = this.matches.get(index)?.candidate_id;
let binding = &this.keybindings[candidate_id];
let action = div()
.child(binding.action.clone())
.id(("keymap action", index))
.tooltip({
let action_name = binding.action.clone();
let action_docs = binding.action_docs;
move |_, cx| {
let action_tooltip = Tooltip::new(
command_palette::humanize_action_name(
&action_name,
),
);
let action_tooltip = match action_docs {
Some(docs) => action_tooltip.meta(docs),
None => action_tooltip,
};
cx.new(|_| action_tooltip).into()
}
})
.into_any_element();
let keystrokes = binding.ui_key_binding.clone().map_or(
binding.keystroke_text.clone().into_any_element(),
IntoElement::into_any_element,
);
let action_input = binding
.action_input
.clone()
.map_or(gpui::Empty.into_any_element(), |input| {
input.into_any_element()
});
let context = binding
.context
.clone()
.map_or(gpui::Empty.into_any_element(), |context| {
context.into_any_element()
});
let source = binding
.source
.clone()
.map(|(_source, name)| name)
.unwrap_or_default()
.into_any_element();
Some([action, action_input, keystrokes, context, source])
})
.collect()
}),
)
.map_row(
cx.processor(|this, (row_index, row): (usize, Div), _window, cx| {
let is_selected = this.selected_index == Some(row_index);
let row = row
.id(("keymap-table-row", row_index))
.on_click(cx.listener(move |this, _event, _window, _cx| {
this.selected_index = Some(row_index);
}))
.border_2()
.border_color(transparent_black())
.when(is_selected, |row| {
row.border_color(cx.theme().colors().panel_focused_border)
});
right_click_menu(("keymap-table-row-menu", row_index))
.trigger({
let this = cx.weak_entity();
move |is_menu_open: bool, _window, cx| {
if is_menu_open {
this.update(cx, |this, cx| {
if this.selected_index != Some(row_index) {
this.selected_index = Some(row_index);
cx.notify();
}
})
.ok();
}
row
}
})
.menu({
let this = cx.weak_entity();
move |window, cx| build_keybind_context_menu(&this, window, cx)
})
.into_any_element()
}),
),
)
}
}
#[derive(Debug, Clone, IntoElement)]
struct SyntaxHighlightedText {
text: SharedString,
language: Arc<Language>,
}
impl SyntaxHighlightedText {
pub fn new(text: impl Into<SharedString>, language: Arc<Language>) -> Self {
Self {
text: text.into(),
language,
}
}
}
impl RenderOnce for SyntaxHighlightedText {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let text_style = window.text_style();
let syntax_theme = cx.theme().syntax();
let text = self.text.clone();
let highlights = self
.language
.highlight_text(&text.as_ref().into(), 0..text.len());
let mut runs = Vec::with_capacity(highlights.len());
let mut offset = 0;
for (highlight_range, highlight_id) in highlights {
// Add un-highlighted text before the current highlight
if highlight_range.start > offset {
runs.push(text_style.to_run(highlight_range.start - offset));
}
let mut run_style = text_style.clone();
if let Some(highlight_style) = highlight_id.style(syntax_theme) {
run_style = run_style.highlight(highlight_style);
}
// add the highlighted range
runs.push(run_style.to_run(highlight_range.len()));
offset = highlight_range.end;
}
// Add any remaining un-highlighted text
if offset < text.len() {
runs.push(text_style.to_run(text.len() - offset));
}
return StyledText::new(text).with_runs(runs);
}
}
struct KeybindingEditorModal {
editing_keybind: ProcessedKeybinding,
keybind_editor: Entity<KeystrokeInput>,
fs: Arc<dyn Fs>,
error: Option<String>,
}
impl ModalView for KeybindingEditorModal {}
impl EventEmitter<DismissEvent> for KeybindingEditorModal {}
impl Focusable for KeybindingEditorModal {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.keybind_editor.focus_handle(cx)
}
}
impl KeybindingEditorModal {
pub fn new(
editing_keybind: ProcessedKeybinding,
fs: Arc<dyn Fs>,
_window: &mut Window,
cx: &mut App,
) -> Self {
let keybind_editor = cx.new(KeystrokeInput::new);
Self {
editing_keybind,
fs,
keybind_editor,
error: None,
}
}
}
impl Render for KeybindingEditorModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().colors();
return v_flex()
.w(rems(34.))
.elevation_3(cx)
.child(
v_flex()
.p_3()
.gap_2()
.child(
v_flex().child(Label::new("Edit Keystroke")).child(
Label::new(
"Input the desired keystroke for the selected action and hit save.",
)
.color(Color::Muted),
),
)
.child(self.keybind_editor.clone()),
)
.child(
h_flex()
.p_2()
.w_full()
.gap_1()
.justify_end()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(
Button::new("cancel", "Cancel")
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
)
.child(Button::new("save-btn", "Save").on_click(cx.listener(
|this, _event, _window, cx| {
let existing_keybind = this.editing_keybind.clone();
let fs = this.fs.clone();
let new_keystrokes = this
.keybind_editor
.read_with(cx, |editor, _| editor.keystrokes.clone());
if new_keystrokes.is_empty() {
this.error = Some("Keystrokes cannot be empty".to_string());
cx.notify();
return;
}
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
cx.spawn(async move |this, cx| {
if let Err(err) = save_keybinding_update(
existing_keybind,
&new_keystrokes,
&fs,
tab_size,
)
.await
{
this.update(cx, |this, cx| {
this.error = Some(err.to_string());
cx.notify();
})
.log_err();
}
})
.detach();
},
))),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.bg(theme.background)
.border_color(theme.border)
.border_2()
.rounded_md()
.child(error),
)
});
}
}
async fn save_keybinding_update(
existing: ProcessedKeybinding,
new_keystrokes: &[Keystroke],
fs: &Arc<dyn Fs>,
tab_size: usize,
) -> anyhow::Result<()> {
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
.context("Failed to load keymap file")?;
let existing_keystrokes = existing
.ui_key_binding
.as_ref()
.map(|keybinding| keybinding.keystrokes.as_slice())
.unwrap_or_default();
let context = existing
.context
.as_ref()
.and_then(KeybindContextString::local_str);
let input = existing
.action_input
.as_ref()
.map(|input| input.text.as_ref());
let operation = if existing.ui_key_binding.is_some() {
settings::KeybindUpdateOperation::Replace {
target: settings::KeybindUpdateTarget {
context,
keystrokes: existing_keystrokes,
action_name: &existing.action,
use_key_equivalents: false,
input,
},
target_source: existing
.source
.map(|(source, _name)| source)
.unwrap_or(KeybindSource::User),
source: settings::KeybindUpdateTarget {
context,
keystrokes: new_keystrokes,
action_name: &existing.action,
use_key_equivalents: false,
input,
},
}
} else {
anyhow::bail!("Adding new bindings not implemented yet");
};
let updated_keymap_contents =
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
.context("Failed to update keybinding")?;
fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents)
.await
.context("Failed to write keymap file")?;
Ok(())
}
struct KeystrokeInput {
keystrokes: Vec<Keystroke>,
focus_handle: FocusHandle,
}
impl KeystrokeInput {
fn new(cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
Self {
keystrokes: Vec::new(),
focus_handle,
}
}
fn on_modifiers_changed(
&mut self,
event: &ModifiersChangedEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
{
if !event.modifiers.modified() {
self.keystrokes.pop();
} else {
last.modifiers = event.modifiers;
}
} else {
self.keystrokes.push(Keystroke {
modifiers: event.modifiers,
key: "".to_string(),
key_char: None,
});
}
cx.stop_propagation();
cx.notify();
}
fn on_key_down(
&mut self,
event: &gpui::KeyDownEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if event.is_held {
return;
}
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
{
*last = event.keystroke.clone();
} else {
self.keystrokes.push(event.keystroke.clone());
}
cx.stop_propagation();
cx.notify();
}
fn on_key_up(
&mut self,
event: &gpui::KeyUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(last) = self.keystrokes.last_mut()
&& !last.key.is_empty()
&& last.modifiers == event.keystroke.modifiers
{
self.keystrokes.push(Keystroke {
modifiers: event.keystroke.modifiers,
key: "".to_string(),
key_char: None,
});
}
cx.stop_propagation();
cx.notify();
}
}
impl Focusable for KeystrokeInput {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for KeystrokeInput {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.theme().colors();
return h_flex()
.id("keybinding_input")
.track_focus(&self.focus_handle)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_key_down(cx.listener(Self::on_key_down))
.on_key_up(cx.listener(Self::on_key_up))
.focus(|mut style| {
style.border_color = Some(colors.border_focused);
style
})
.py_2()
.px_3()
.gap_2()
.min_h_8()
.w_full()
.justify_between()
.bg(colors.editor_background)
.border_1()
.rounded_md()
.flex_1()
.overflow_hidden()
.child(
h_flex()
.w_full()
.min_w_0()
.justify_center()
.flex_wrap()
.gap(ui::DynamicSpacing::Base04.rems(cx))
.children(self.keystrokes.iter().map(|keystroke| {
h_flex().children(ui::render_keystroke(
keystroke,
None,
Some(rems(0.875).into()),
ui::PlatformStyle::platform(),
false,
))
})),
)
.child(
h_flex()
.gap_0p5()
.flex_none()
.child(
IconButton::new("backspace-btn", IconName::Delete)
.tooltip(Tooltip::text("Delete Keystroke"))
.on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.pop();
cx.notify();
})),
)
.child(
IconButton::new("clear-btn", IconName::Eraser)
.tooltip(Tooltip::text("Clear Keystrokes"))
.on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.clear();
cx.notify();
})),
),
);
}
}
fn build_keybind_context_menu(
this: &WeakEntity<KeymapEditor>,
window: &mut Window,
cx: &mut App,
) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |menu, _window, cx| {
let Some(this) = this.upgrade() else {
return menu;
};
let selected_binding = this.read_with(cx, |this, _cx| this.selected_binding().cloned());
let Some(selected_binding) = selected_binding else {
return menu;
};
let selected_binding_has_context = selected_binding
.context
.as_ref()
.and_then(KeybindContextString::local)
.is_some();
menu.action("Edit Binding", Box::new(EditBinding))
.action("Copy action", Box::new(CopyAction))
.action_disabled_when(
!selected_binding_has_context,
"Copy Context",
Box::new(CopyContext),
)
})
}
impl SerializableItem for KeymapEditor {
fn serialized_item_kind() -> &'static str {
"KeymapEditor"
}
fn cleanup(
workspace_id: workspace::WorkspaceId,
alive_items: Vec<workspace::ItemId>,
_window: &mut Window,
cx: &mut App,
) -> gpui::Task<gpui::Result<()>> {
workspace::delete_unloaded_items(
alive_items,
workspace_id,
"keybinding_editors",
&KEYBINDING_EDITORS,
cx,
)
}
fn deserialize(
_project: Entity<project::Project>,
workspace: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> gpui::Task<gpui::Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
if KEYBINDING_EDITORS
.get_keybinding_editor(item_id, workspace_id)?
.is_some()
{
cx.update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace, window, cx)))
} else {
Err(anyhow!("No keybinding editor to deserialize"))
}
})
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut ui::Context<Self>,
) -> Option<gpui::Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
Some(cx.background_spawn(async move {
KEYBINDING_EDITORS
.save_keybinding_editor(item_id, workspace_id)
.await
}))
}
fn should_serialize(&self, _event: &Self::Event) -> bool {
false
}
}
mod persistence {
use db::{define_connection, query, sqlez_macros::sql};
use workspace::WorkspaceDb;
define_connection! {
pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
&[sql!(
CREATE TABLE keybinding_editors (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
}
impl KeybindingEditorDb {
query! {
pub async fn save_keybinding_editor(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<()> {
INSERT OR REPLACE INTO keybinding_editors(item_id, workspace_id)
VALUES (?, ?)
}
}
query! {
pub fn get_keybinding_editor(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<Option<workspace::ItemId>> {
SELECT item_id
FROM keybinding_editors
WHERE item_id = ? AND workspace_id = ?
}
}
}
}