keymap_ui: Keyboard navigation for keybind edit modal (#34482)

Adds keyboard navigation to the keybind edit modal. Using up/down arrows
to select the previous/next input editor, and `cmd-enter` to save +
`escape` to exit

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-07-15 13:03:19 -05:00 committed by GitHub
parent 3ecdfc9b5a
commit ebbf02e25b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 228 additions and 97 deletions

View file

@ -1129,5 +1129,21 @@
"escape escape escape": "keystroke_input::StopRecording", "escape escape escape": "keystroke_input::StopRecording",
"delete": "keystroke_input::ClearKeystrokes" "delete": "keystroke_input::ClearKeystrokes"
} }
},
{
"context": "KeybindEditorModal",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "menu::Confirm",
"escape": "menu::Cancel"
}
},
{
"context": "KeybindEditorModal > Editor",
"use_key_equivalents": true,
"bindings": {
"up": "menu::SelectPrevious",
"down": "menu::SelectNext"
}
} }
] ]

View file

@ -1226,5 +1226,21 @@
"escape escape escape": "keystroke_input::StopRecording", "escape escape escape": "keystroke_input::StopRecording",
"delete": "keystroke_input::ClearKeystrokes" "delete": "keystroke_input::ClearKeystrokes"
} }
},
{
"context": "KeybindEditorModal",
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "menu::Confirm",
"escape": "menu::Cancel"
}
},
{
"context": "KeybindEditorModal > Editor",
"use_key_equivalents": true,
"bindings": {
"up": "menu::SelectPrevious",
"down": "menu::SelectNext"
}
} }
] ]

View file

@ -1451,6 +1451,7 @@ struct KeybindingEditorModal {
error: Option<InputError>, error: Option<InputError>,
keymap_editor: Entity<KeymapEditor>, keymap_editor: Entity<KeymapEditor>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
focus_state: KeybindingEditorModalFocusState,
} }
impl ModalView for KeybindingEditorModal {} impl ModalView for KeybindingEditorModal {}
@ -1539,6 +1540,14 @@ impl KeybindingEditorModal {
}) })
}); });
let focus_state = KeybindingEditorModalFocusState::new(
keybind_editor.read_with(cx, |keybind_editor, cx| keybind_editor.focus_handle(cx)),
input_editor.as_ref().map(|input_editor| {
input_editor.read_with(cx, |input_editor, cx| input_editor.focus_handle(cx))
}),
context_editor.read_with(cx, |context_editor, cx| context_editor.focus_handle(cx)),
);
Self { Self {
creating: create, creating: create,
editing_keybind, editing_keybind,
@ -1550,6 +1559,7 @@ impl KeybindingEditorModal {
error: None, error: None,
keymap_editor, keymap_editor,
workspace, workspace,
focus_state,
} }
} }
@ -1731,6 +1741,33 @@ impl KeybindingEditorModal {
}) })
.detach(); .detach();
} }
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("KeybindEditorModal");
key_context
}
fn focus_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
self.focus_state.focus_next(window, cx);
}
fn focus_prev(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_state.focus_previous(window, cx);
}
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
self.save(cx);
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent)
}
} }
impl Render for KeybindingEditorModal { impl Render for KeybindingEditorModal {
@ -1739,93 +1776,156 @@ impl Render for KeybindingEditorModal {
let action_name = let action_name =
command_palette::humanize_action_name(&self.editing_keybind.action_name).to_string(); command_palette::humanize_action_name(&self.editing_keybind.action_name).to_string();
v_flex().w(rems(34.)).elevation_3(cx).child( v_flex()
Modal::new("keybinding_editor_modal", None) .w(rems(34.))
.header( .elevation_3(cx)
ModalHeader::new().child( .key_context(self.key_context())
v_flex() .on_action(cx.listener(Self::focus_next))
.pb_1p5() .on_action(cx.listener(Self::focus_prev))
.mb_1() .on_action(cx.listener(Self::confirm))
.gap_0p5() .on_action(cx.listener(Self::cancel))
.border_b_1() .child(
.border_color(theme.border_variant) Modal::new("keybinding_editor_modal", None)
.child(Label::new(action_name)) .header(
.when_some(self.editing_keybind.action_docs, |this, docs| { ModalHeader::new().child(
this.child( v_flex()
Label::new(docs).size(LabelSize::Small).color(Color::Muted), .pb_1p5()
) .mb_1()
}), .gap_0p5()
), .border_b_1()
) .border_color(theme.border_variant)
.section( .child(Label::new(action_name))
Section::new().child( .when_some(self.editing_keybind.action_docs, |this, docs| {
v_flex() this.child(
.gap_2() Label::new(docs).size(LabelSize::Small).color(Color::Muted),
.child( )
v_flex() }),
.child(Label::new("Edit Keystroke")) ),
.gap_1() )
.child(self.keybind_editor.clone()), .section(
) Section::new().child(
.when_some(self.input_editor.clone(), |this, editor| { v_flex()
this.child( .gap_2()
.child(
v_flex() v_flex()
.mt_1p5() .child(Label::new("Edit Keystroke"))
.gap_1() .gap_1()
.child(Label::new("Edit Arguments")) .child(self.keybind_editor.clone()),
.child(
div()
.w_full()
.py_1()
.px_1p5()
.rounded_lg()
.bg(theme.editor_background)
.border_1()
.border_color(theme.border_variant)
.child(editor),
),
) )
}) .when_some(self.input_editor.clone(), |this, editor| {
.child(self.context_editor.clone()) this.child(
.when_some(self.error.as_ref(), |this, error| { v_flex()
this.child( .mt_1p5()
Banner::new() .gap_1()
.map(|banner| match error { .child(Label::new("Edit Arguments"))
InputError::Error(_) => { .child(
banner.severity(ui::Severity::Error) div()
} .w_full()
InputError::Warning(_) => { .py_1()
banner.severity(ui::Severity::Warning) .px_1p5()
} .rounded_lg()
}) .bg(theme.editor_background)
// For some reason, the div overflows its container to the .border_1()
//right. The padding accounts for that. .border_color(theme.border_variant)
.child( .child(editor),
div() ),
.size_full() )
.pr_2() })
.child(Label::new(error.content())), .child(self.context_editor.clone())
), .when_some(self.error.as_ref(), |this, error| {
this.child(
Banner::new()
.map(|banner| match error {
InputError::Error(_) => {
banner.severity(ui::Severity::Error)
}
InputError::Warning(_) => {
banner.severity(ui::Severity::Warning)
}
})
// For some reason, the div overflows its container to the
//right. The padding accounts for that.
.child(
div()
.size_full()
.pr_2()
.child(Label::new(error.content())),
),
)
}),
),
)
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.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| {
this.save(cx);
},
))),
),
), ),
) )
.footer( }
ModalFooter::new().end_slot( }
h_flex()
.gap_1() struct KeybindingEditorModalFocusState {
.child( handles: Vec<FocusHandle>,
Button::new("cancel", "Cancel") }
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
) impl KeybindingEditorModalFocusState {
.child(Button::new("save-btn", "Save").on_click(cx.listener( fn new(
|this, _event, _window, cx| { keystrokes: FocusHandle,
this.save(cx); action_input: Option<FocusHandle>,
}, context: FocusHandle,
))), ) -> Self {
), Self {
), handles: Vec::from_iter(
) [Some(keystrokes), action_input, Some(context)]
.into_iter()
.flatten(),
),
}
}
fn focused_index(&self, window: &Window, cx: &App) -> Option<i32> {
self.handles
.iter()
.position(|handle| handle.contains_focused(window, cx))
.map(|i| i as i32)
}
fn focus_index(&self, mut index: i32, window: &mut Window) {
if index < 0 {
index = self.handles.len() as i32 - 1;
}
if index >= self.handles.len() as i32 {
index = 0;
}
window.focus(&self.handles[index as usize]);
}
fn focus_next(&self, window: &mut Window, cx: &App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index + 1
} else {
0
};
self.focus_index(index_to_focus, window);
}
fn focus_previous(&self, window: &mut Window, cx: &App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index - 1
} else {
self.handles.len() as i32 - 1
};
self.focus_index(index_to_focus, window);
} }
} }
@ -2207,24 +2307,23 @@ impl KeystrokeInput {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
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 {
return; if let Some(last) = self.keystrokes.last()
} && last.key.is_empty()
if let Some(last) = self.keystrokes.last() && self.keystrokes.len() <= Self::KEYSTROKE_COUNT_MAX
&& last.key.is_empty()
&& self.keystrokes.len() <= Self::KEYSTROKE_COUNT_MAX
{
self.keystrokes.pop();
}
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{ {
self.close_keystrokes_start = Some(self.keystrokes.len()); self.keystrokes.pop();
} }
self.keystrokes.push(keystroke.clone());
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(keystroke.modifiers)); if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{
self.close_keystrokes_start = Some(self.keystrokes.len());
}
self.keystrokes.push(keystroke.clone());
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
} }
} }
self.keystrokes_changed(cx); self.keystrokes_changed(cx);