mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add configurable keymap support (#18593)
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
This commit is contained in:
committed by
GitHub
Unverified
parent
a61c785040
commit
5e737372ee
@@ -0,0 +1,235 @@
|
||||
//! Catalog and accessors for keymap actions shown by `/keymap`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct KeymapActionDescriptor {
|
||||
pub(super) context: &'static str,
|
||||
pub(super) context_label: &'static str,
|
||||
pub(super) action: &'static str,
|
||||
pub(super) description: &'static str,
|
||||
}
|
||||
|
||||
const fn action(
|
||||
context: &'static str,
|
||||
context_label: &'static str,
|
||||
action: &'static str,
|
||||
description: &'static str,
|
||||
) -> KeymapActionDescriptor {
|
||||
KeymapActionDescriptor {
|
||||
context,
|
||||
context_label,
|
||||
action,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "open_transcript", "Open the transcript overlay."),
|
||||
action("global", "Global", "open_external_editor", "Open the current draft in an external editor."),
|
||||
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
|
||||
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||
action("composer", "Composer", "submit", "Submit the current composer draft."),
|
||||
action("composer", "Composer", "queue", "Queue the draft while a task is running."),
|
||||
action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."),
|
||||
action("composer", "Composer", "history_search_previous", "Open history search or move to the previous match."),
|
||||
action("composer", "Composer", "history_search_next", "Move to the next history search match."),
|
||||
action("editor", "Editor", "insert_newline", "Insert a newline in the editor."),
|
||||
action("editor", "Editor", "move_left", "Move the cursor left."),
|
||||
action("editor", "Editor", "move_right", "Move the cursor right."),
|
||||
action("editor", "Editor", "move_up", "Move the cursor up."),
|
||||
action("editor", "Editor", "move_down", "Move the cursor down."),
|
||||
action("editor", "Editor", "move_word_left", "Move to the beginning of the previous word."),
|
||||
action("editor", "Editor", "move_word_right", "Move to the end of the next word."),
|
||||
action("editor", "Editor", "move_line_start", "Move to the beginning of the line."),
|
||||
action("editor", "Editor", "move_line_end", "Move to the end of the line."),
|
||||
action("editor", "Editor", "delete_backward", "Delete one grapheme to the left."),
|
||||
action("editor", "Editor", "delete_forward", "Delete one grapheme to the right."),
|
||||
action("editor", "Editor", "delete_backward_word", "Delete the previous word."),
|
||||
action("editor", "Editor", "delete_forward_word", "Delete the next word."),
|
||||
action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."),
|
||||
action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."),
|
||||
action("editor", "Editor", "yank", "Paste the kill buffer."),
|
||||
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
|
||||
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
|
||||
action("pager", "Pager", "page_up", "Scroll up by one page."),
|
||||
action("pager", "Pager", "page_down", "Scroll down by one page."),
|
||||
action("pager", "Pager", "half_page_up", "Scroll up by half a page."),
|
||||
action("pager", "Pager", "half_page_down", "Scroll down by half a page."),
|
||||
action("pager", "Pager", "jump_top", "Jump to the beginning."),
|
||||
action("pager", "Pager", "jump_bottom", "Jump to the end."),
|
||||
action("pager", "Pager", "close", "Close the pager overlay."),
|
||||
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
|
||||
action("list", "List", "move_up", "Move list selection up."),
|
||||
action("list", "List", "move_down", "Move list selection down."),
|
||||
action("list", "List", "accept", "Accept the current list selection."),
|
||||
action("list", "List", "cancel", "Cancel and close selection views."),
|
||||
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
|
||||
action("approval", "Approval", "open_thread", "Open the approval source thread when available."),
|
||||
action("approval", "Approval", "approve", "Approve the primary option."),
|
||||
action("approval", "Approval", "approve_for_session", "Approve for the session when available."),
|
||||
action("approval", "Approval", "approve_for_prefix", "Approve with an exec-policy prefix when available."),
|
||||
action("approval", "Approval", "deny", "Choose the explicit deny option when available."),
|
||||
action("approval", "Approval", "decline", "Decline and provide corrective guidance."),
|
||||
action("approval", "Approval", "cancel", "Cancel an elicitation request."),
|
||||
];
|
||||
|
||||
pub(super) fn action_label(action: &str) -> String {
|
||||
action
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
format!("{}{}", first.to_ascii_uppercase(), chars.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) fn binding_slot<'a>(
|
||||
keymap: &'a mut TuiKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> Option<&'a mut Option<KeybindingsSpec>> {
|
||||
match (context, action) {
|
||||
("global", "open_transcript") => Some(&mut keymap.global.open_transcript),
|
||||
("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor),
|
||||
("global", "copy") => Some(&mut keymap.global.copy),
|
||||
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||
("composer", "submit") => Some(&mut keymap.composer.submit),
|
||||
("composer", "queue") => Some(&mut keymap.composer.queue),
|
||||
("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts),
|
||||
("composer", "history_search_previous") => Some(&mut keymap.composer.history_search_previous),
|
||||
("composer", "history_search_next") => Some(&mut keymap.composer.history_search_next),
|
||||
("editor", "insert_newline") => Some(&mut keymap.editor.insert_newline),
|
||||
("editor", "move_left") => Some(&mut keymap.editor.move_left),
|
||||
("editor", "move_right") => Some(&mut keymap.editor.move_right),
|
||||
("editor", "move_up") => Some(&mut keymap.editor.move_up),
|
||||
("editor", "move_down") => Some(&mut keymap.editor.move_down),
|
||||
("editor", "move_word_left") => Some(&mut keymap.editor.move_word_left),
|
||||
("editor", "move_word_right") => Some(&mut keymap.editor.move_word_right),
|
||||
("editor", "move_line_start") => Some(&mut keymap.editor.move_line_start),
|
||||
("editor", "move_line_end") => Some(&mut keymap.editor.move_line_end),
|
||||
("editor", "delete_backward") => Some(&mut keymap.editor.delete_backward),
|
||||
("editor", "delete_forward") => Some(&mut keymap.editor.delete_forward),
|
||||
("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word),
|
||||
("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word),
|
||||
("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start),
|
||||
("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end),
|
||||
("editor", "yank") => Some(&mut keymap.editor.yank),
|
||||
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
|
||||
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
|
||||
("pager", "page_up") => Some(&mut keymap.pager.page_up),
|
||||
("pager", "page_down") => Some(&mut keymap.pager.page_down),
|
||||
("pager", "half_page_up") => Some(&mut keymap.pager.half_page_up),
|
||||
("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down),
|
||||
("pager", "jump_top") => Some(&mut keymap.pager.jump_top),
|
||||
("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom),
|
||||
("pager", "close") => Some(&mut keymap.pager.close),
|
||||
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
|
||||
("list", "move_up") => Some(&mut keymap.list.move_up),
|
||||
("list", "move_down") => Some(&mut keymap.list.move_down),
|
||||
("list", "accept") => Some(&mut keymap.list.accept),
|
||||
("list", "cancel") => Some(&mut keymap.list.cancel),
|
||||
("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen),
|
||||
("approval", "open_thread") => Some(&mut keymap.approval.open_thread),
|
||||
("approval", "approve") => Some(&mut keymap.approval.approve),
|
||||
("approval", "approve_for_session") => Some(&mut keymap.approval.approve_for_session),
|
||||
("approval", "approve_for_prefix") => Some(&mut keymap.approval.approve_for_prefix),
|
||||
("approval", "deny") => Some(&mut keymap.approval.deny),
|
||||
("approval", "decline") => Some(&mut keymap.approval.decline),
|
||||
("approval", "cancel") => Some(&mut keymap.approval.cancel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) fn bindings_for_action<'a>(
|
||||
runtime_keymap: &'a RuntimeKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> Option<&'a [KeyBinding]> {
|
||||
match (context, action) {
|
||||
("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()),
|
||||
("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()),
|
||||
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
|
||||
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
|
||||
("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()),
|
||||
("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()),
|
||||
("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()),
|
||||
("composer", "history_search_previous") => Some(runtime_keymap.composer.history_search_previous.as_slice()),
|
||||
("composer", "history_search_next") => Some(runtime_keymap.composer.history_search_next.as_slice()),
|
||||
("editor", "insert_newline") => Some(runtime_keymap.editor.insert_newline.as_slice()),
|
||||
("editor", "move_left") => Some(runtime_keymap.editor.move_left.as_slice()),
|
||||
("editor", "move_right") => Some(runtime_keymap.editor.move_right.as_slice()),
|
||||
("editor", "move_up") => Some(runtime_keymap.editor.move_up.as_slice()),
|
||||
("editor", "move_down") => Some(runtime_keymap.editor.move_down.as_slice()),
|
||||
("editor", "move_word_left") => Some(runtime_keymap.editor.move_word_left.as_slice()),
|
||||
("editor", "move_word_right") => Some(runtime_keymap.editor.move_word_right.as_slice()),
|
||||
("editor", "move_line_start") => Some(runtime_keymap.editor.move_line_start.as_slice()),
|
||||
("editor", "move_line_end") => Some(runtime_keymap.editor.move_line_end.as_slice()),
|
||||
("editor", "delete_backward") => Some(runtime_keymap.editor.delete_backward.as_slice()),
|
||||
("editor", "delete_forward") => Some(runtime_keymap.editor.delete_forward.as_slice()),
|
||||
("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()),
|
||||
("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()),
|
||||
("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()),
|
||||
("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()),
|
||||
("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()),
|
||||
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
|
||||
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
|
||||
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
|
||||
("pager", "page_down") => Some(runtime_keymap.pager.page_down.as_slice()),
|
||||
("pager", "half_page_up") => Some(runtime_keymap.pager.half_page_up.as_slice()),
|
||||
("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()),
|
||||
("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()),
|
||||
("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()),
|
||||
("pager", "close") => Some(runtime_keymap.pager.close.as_slice()),
|
||||
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
|
||||
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),
|
||||
("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()),
|
||||
("list", "accept") => Some(runtime_keymap.list.accept.as_slice()),
|
||||
("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()),
|
||||
("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()),
|
||||
("approval", "open_thread") => Some(runtime_keymap.approval.open_thread.as_slice()),
|
||||
("approval", "approve") => Some(runtime_keymap.approval.approve.as_slice()),
|
||||
("approval", "approve_for_session") => Some(runtime_keymap.approval.approve_for_session.as_slice()),
|
||||
("approval", "approve_for_prefix") => Some(runtime_keymap.approval.approve_for_prefix.as_slice()),
|
||||
("approval", "deny") => Some(runtime_keymap.approval.deny.as_slice()),
|
||||
("approval", "decline") => Some(runtime_keymap.approval.decline.as_slice()),
|
||||
("approval", "cancel") => Some(runtime_keymap.approval.cancel.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String {
|
||||
let mut seen = BTreeSet::new();
|
||||
let specs = bindings
|
||||
.iter()
|
||||
.filter_map(|binding| super::binding_to_config_key_spec(*binding).ok())
|
||||
.filter(|spec| seen.insert(spec.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if specs.is_empty() {
|
||||
"unbound".to_string()
|
||||
} else {
|
||||
specs.join(", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Shortcut picker construction for `/keymap`.
|
||||
|
||||
use codex_config::types::TuiKeymap;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionRowDisplay;
|
||||
use crate::bottom_pane::SelectionTab;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::actions::KEYMAP_ACTIONS;
|
||||
use super::actions::action_label;
|
||||
use super::actions::bindings_for_action;
|
||||
use super::actions::format_binding_summary;
|
||||
use super::has_custom_binding;
|
||||
|
||||
pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker";
|
||||
pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts";
|
||||
pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts";
|
||||
pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts";
|
||||
pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts";
|
||||
const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12;
|
||||
const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct KeymapActionRow {
|
||||
context: &'static str,
|
||||
context_label: &'static str,
|
||||
action: &'static str,
|
||||
label: String,
|
||||
description: &'static str,
|
||||
binding_summary: String,
|
||||
custom_binding: bool,
|
||||
}
|
||||
|
||||
impl KeymapActionRow {
|
||||
fn is_unbound(&self) -> bool {
|
||||
self.binding_summary == "unbound"
|
||||
}
|
||||
}
|
||||
|
||||
struct KeymapContextTab {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
contexts: &'static [&'static str],
|
||||
}
|
||||
|
||||
const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
||||
("composer", "submit"),
|
||||
("editor", "insert_newline"),
|
||||
("composer", "queue"),
|
||||
("global", "open_external_editor"),
|
||||
("global", "copy"),
|
||||
("editor", "delete_backward_word"),
|
||||
("editor", "delete_forward_word"),
|
||||
("editor", "move_word_left"),
|
||||
("editor", "move_word_right"),
|
||||
("global", "open_transcript"),
|
||||
("pager", "close"),
|
||||
("pager", "page_up"),
|
||||
("pager", "page_down"),
|
||||
("approval", "open_fullscreen"),
|
||||
("approval", "approve"),
|
||||
("approval", "approve_for_session"),
|
||||
("approval", "decline"),
|
||||
("approval", "cancel"),
|
||||
];
|
||||
|
||||
const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
|
||||
KeymapContextTab {
|
||||
id: "app-shortcuts",
|
||||
label: "App",
|
||||
description: "Global and chat-level shortcuts.",
|
||||
contexts: &["global", "chat"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "composer-shortcuts",
|
||||
label: "Composer",
|
||||
description: "Composer submission and queue shortcuts.",
|
||||
contexts: &["composer"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "editor-shortcuts",
|
||||
label: "Editor",
|
||||
description: "Inline editor movement and editing shortcuts.",
|
||||
contexts: &["editor"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "navigation-shortcuts",
|
||||
label: "Navigation",
|
||||
description: "Pager and selection-list navigation shortcuts.",
|
||||
contexts: &["pager", "list"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "approval-shortcuts",
|
||||
label: "Approval",
|
||||
description: "Approval prompt shortcuts.",
|
||||
contexts: &["approval"],
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn build_keymap_picker_params(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
) -> SelectionViewParams {
|
||||
build_keymap_picker_params_for_action(
|
||||
runtime_keymap,
|
||||
keymap_config,
|
||||
/*selected_action*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_keymap_picker_params_for_selected_action(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> SelectionViewParams {
|
||||
build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action)))
|
||||
}
|
||||
|
||||
fn build_keymap_picker_params_for_action(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
selected_action: Option<(&str, &str)>,
|
||||
) -> SelectionViewParams {
|
||||
let rows = build_keymap_rows(runtime_keymap, keymap_config);
|
||||
let total = rows.len();
|
||||
let custom_count = rows.iter().filter(|row| row.custom_binding).count();
|
||||
let unbound_count = rows.iter().filter(|row| row.is_unbound()).count();
|
||||
let initial_selected_idx = selected_action.and_then(|(context, action)| {
|
||||
rows.iter()
|
||||
.position(|row| row.context == context && row.action == action)
|
||||
});
|
||||
let name_column_width = rows
|
||||
.iter()
|
||||
.map(|row| KEYMAP_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(row.label.as_str()))
|
||||
.max();
|
||||
|
||||
let mut tabs = Vec::new();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_ALL_TAB_ID.to_string(),
|
||||
label: "All".to_string(),
|
||||
header: keymap_header(
|
||||
"All configurable shortcuts.".to_string(),
|
||||
format!("{total} actions, {custom_count} customized, {unbound_count} unbound."),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
rows.iter(),
|
||||
"No shortcuts available",
|
||||
"No configurable shortcuts are available.",
|
||||
),
|
||||
});
|
||||
|
||||
let common_rows = keymap_common_rows(&rows);
|
||||
let common_count = common_rows.len();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_COMMON_TAB_ID.to_string(),
|
||||
label: "Common".to_string(),
|
||||
header: keymap_header(
|
||||
"Frequently customized shortcuts.".to_string(),
|
||||
action_count_line(common_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
common_rows,
|
||||
"No common shortcuts",
|
||||
"No common shortcut actions are available.",
|
||||
),
|
||||
});
|
||||
|
||||
let custom_rows = rows
|
||||
.iter()
|
||||
.filter(|row| row.custom_binding)
|
||||
.collect::<Vec<_>>();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_CUSTOM_TAB_ID.to_string(),
|
||||
label: format!("Customized ({custom_count})"),
|
||||
header: keymap_header(
|
||||
"Root-level shortcut overrides.".to_string(),
|
||||
action_count_line(custom_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
custom_rows,
|
||||
"No customized shortcuts",
|
||||
"No root-level keymap overrides have been configured.",
|
||||
),
|
||||
});
|
||||
|
||||
let unbound_rows = rows
|
||||
.iter()
|
||||
.filter(|row| row.is_unbound())
|
||||
.collect::<Vec<_>>();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_UNBOUND_TAB_ID.to_string(),
|
||||
label: format!("Unbound ({unbound_count})"),
|
||||
header: keymap_header(
|
||||
"Actions without an active shortcut.".to_string(),
|
||||
action_count_line(unbound_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
unbound_rows,
|
||||
"No unbound shortcuts",
|
||||
"Every configurable action currently has a shortcut.",
|
||||
),
|
||||
});
|
||||
|
||||
for tab in KEYMAP_CONTEXT_TABS {
|
||||
let tab_rows = rows
|
||||
.iter()
|
||||
.filter(|row| tab.contexts.contains(&row.context))
|
||||
.collect::<Vec<_>>();
|
||||
let count = tab_rows.len();
|
||||
tabs.push(SelectionTab {
|
||||
id: tab.id.to_string(),
|
||||
label: tab.label.to_string(),
|
||||
header: keymap_header(tab.description.to_string(), action_count_line(count)),
|
||||
items: keymap_selection_items(
|
||||
tab_rows,
|
||||
"No shortcuts in this group",
|
||||
"No configurable actions are available in this group.",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(KEYMAP_PICKER_VIEW_ID),
|
||||
header: Box::new(()),
|
||||
footer_hint: Some(keymap_picker_hint_line()),
|
||||
tabs,
|
||||
initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()),
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search shortcuts".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
row_display: SelectionRowDisplay::SingleLine,
|
||||
name_column_width,
|
||||
initial_selected_idx,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_keymap_rows(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
) -> Vec<KeymapActionRow> {
|
||||
KEYMAP_ACTIONS
|
||||
.iter()
|
||||
.map(|descriptor| {
|
||||
let bindings =
|
||||
bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)
|
||||
.unwrap_or(&[]);
|
||||
KeymapActionRow {
|
||||
context: descriptor.context,
|
||||
context_label: descriptor.context_label,
|
||||
action: descriptor.action,
|
||||
label: action_label(descriptor.action),
|
||||
description: descriptor.description,
|
||||
binding_summary: format_binding_summary(bindings),
|
||||
custom_binding: has_custom_binding(
|
||||
keymap_config,
|
||||
descriptor.context,
|
||||
descriptor.action,
|
||||
)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keymap_common_rows(rows: &[KeymapActionRow]) -> Vec<&KeymapActionRow> {
|
||||
KEYMAP_COMMON_ACTIONS
|
||||
.iter()
|
||||
.filter_map(|(context, action)| {
|
||||
rows.iter()
|
||||
.find(|row| row.context == *context && row.action == *action)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keymap_selection_items<'a>(
|
||||
rows: impl IntoIterator<Item = &'a KeymapActionRow>,
|
||||
empty_name: &str,
|
||||
empty_description: &str,
|
||||
) -> Vec<SelectionItem> {
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(keymap_selection_item)
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() {
|
||||
return vec![SelectionItem {
|
||||
name: empty_name.to_string(),
|
||||
description: Some(empty_description.to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn keymap_selection_item(row: &KeymapActionRow) -> SelectionItem {
|
||||
let context = row.context.to_string();
|
||||
let action = row.action.to_string();
|
||||
let source = if row.custom_binding {
|
||||
"Custom"
|
||||
} else {
|
||||
"Default"
|
||||
};
|
||||
let search_value = format!(
|
||||
"{} {} {} {} {} {}",
|
||||
row.context_label, row.action, row.label, row.description, row.binding_summary, source
|
||||
);
|
||||
|
||||
SelectionItem {
|
||||
name: row.label.clone(),
|
||||
name_prefix_spans: keymap_row_prefix(row),
|
||||
description: Some(row.binding_summary.clone()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenKeymapActionMenu {
|
||||
context: context.clone(),
|
||||
action: action.clone(),
|
||||
});
|
||||
})],
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn keymap_row_prefix(row: &KeymapActionRow) -> Vec<Span<'static>> {
|
||||
let indicator = if row.custom_binding {
|
||||
"*".cyan()
|
||||
} else if row.is_unbound() {
|
||||
"-".dim()
|
||||
} else {
|
||||
" ".into()
|
||||
};
|
||||
|
||||
vec![
|
||||
format!(
|
||||
"{:<width$} ",
|
||||
row.context_label,
|
||||
width = KEYMAP_CONTEXT_LABEL_WIDTH
|
||||
)
|
||||
.dim(),
|
||||
indicator,
|
||||
" ".dim(),
|
||||
]
|
||||
}
|
||||
|
||||
fn keymap_header(description: String, summary: String) -> Box<dyn Renderable> {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Keymap".bold()));
|
||||
header.push(Line::from(description.dim()));
|
||||
header.push(Line::from(summary.dim()));
|
||||
Box::new(header)
|
||||
}
|
||||
|
||||
fn action_count_line(count: usize) -> String {
|
||||
match count {
|
||||
1 => "1 action.".to_string(),
|
||||
_ => format!("{count} actions."),
|
||||
}
|
||||
}
|
||||
|
||||
fn keymap_picker_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"left/right".cyan(),
|
||||
" group · ".dim(),
|
||||
"enter".cyan(),
|
||||
" edit shortcut · ".dim(),
|
||||
"*".cyan(),
|
||||
" custom · ".dim(),
|
||||
"-".cyan(),
|
||||
" unbound · ".dim(),
|
||||
"esc".cyan(),
|
||||
" close".dim(),
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user