mirror of
https://github.com/openai/codex.git
synced 2026-08-28 13:58:49 +00:00
## What changed - Use one binding-aware description for each keymap action instead of swapping in separate text for the selected item. - Keep the action menu content unchanged as the selection moves between items. - Update key and key-chord replacement descriptions to name the affected binding directly. ## Testing - Extend the keymap action menu test to move through every item and verify that the rendered picker remains unchanged. GitOrigin-RevId: bbd2bd08ca444c76c28c5213ae077ad85bc50f27
1953 lines
66 KiB
Rust
1953 lines
66 KiB
Rust
//! Guided keymap remapping UI for `/keymap`.
|
||
//!
|
||
//! This module owns the interactive editing flow that starts from a resolved
|
||
//! [`RuntimeKeymap`] and produces a new root-level [`TuiKeymap`] override. The
|
||
//! picker and action menus show users the currently active binding, which may
|
||
//! come from defaults, global fallback, or explicit config, while writes always
|
||
//! target the concrete `tui.keymap.<context>.<action>` slot selected by the
|
||
//! user.
|
||
//!
|
||
//! The flow is intentionally split into three steps: choose an action, choose
|
||
//! whether to replace/add/remove a binding, then capture either one terminal
|
||
//! key event or a bounded two-stroke key chord. Validation happens after
|
||
//! capture by reusing runtime keymap resolution, so conflict rules stay
|
||
//! centralized in `keymap.rs` instead of being duplicated in the UI.
|
||
//!
|
||
//! This module does not persist config files directly. It emits app events with
|
||
//! the edited config so the app layer can decide how to save, reload, and
|
||
//! surface errors.
|
||
|
||
mod actions;
|
||
mod capture;
|
||
mod debug;
|
||
mod picker;
|
||
|
||
pub(crate) use actions::KeymapActionFilter;
|
||
use capture::KeymapCaptureView;
|
||
pub(crate) use debug::build_keymap_debug_view;
|
||
pub(crate) use picker::KEYMAP_PICKER_VIEW_ID;
|
||
#[cfg(test)]
|
||
pub(crate) use picker::build_keymap_picker_params;
|
||
#[cfg(test)]
|
||
pub(crate) use picker::build_keymap_picker_params_for_selected_action;
|
||
pub(crate) use picker::build_keymap_picker_params_for_selected_action_with_filter;
|
||
pub(crate) use picker::build_keymap_picker_params_with_filter;
|
||
|
||
use codex_config::types::KeybindingSpec;
|
||
use codex_config::types::KeybindingsSpec;
|
||
use codex_config::types::MAX_FUNCTION_KEY;
|
||
use codex_config::types::TuiKeymap;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
use ratatui::style::Stylize;
|
||
use ratatui::text::Line;
|
||
|
||
use crate::app_event::AppEvent;
|
||
use crate::app_event::KeymapCaptureMode;
|
||
use crate::app_event::KeymapEditIntent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::bottom_pane::ColumnWidthMode;
|
||
use crate::bottom_pane::SelectionDescriptionLayout;
|
||
use crate::bottom_pane::SelectionItem;
|
||
use crate::bottom_pane::SelectionViewParams;
|
||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||
use crate::key_hint::KeyBinding;
|
||
use crate::keymap::RuntimeKeymap;
|
||
use crate::keymap::bindings_for_action;
|
||
use crate::keymap::keymap_action_id;
|
||
use crate::render::renderable::ColumnRenderable;
|
||
use actions::KEYMAP_ACTIONS;
|
||
use actions::action_label;
|
||
use actions::binding_slot;
|
||
use actions::format_action_binding_summary;
|
||
#[cfg(test)]
|
||
use debug::KeymapDebugView;
|
||
|
||
pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu";
|
||
pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu";
|
||
const KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH: u16 = 24;
|
||
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
pub(crate) enum KeymapEditOutcome {
|
||
/// The edit produced a new config snapshot and user-facing status message.
|
||
Updated {
|
||
keymap_config: Box<TuiKeymap>,
|
||
bindings: Vec<String>,
|
||
message: String,
|
||
},
|
||
/// The requested edit resolved to the same effective binding set.
|
||
Unchanged { message: String },
|
||
}
|
||
|
||
fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> {
|
||
if binding == "unbound" {
|
||
binding.to_string().dim()
|
||
} else {
|
||
binding.to_string().cyan()
|
||
}
|
||
}
|
||
|
||
fn keymap_action_menu_hint_line() -> Line<'static> {
|
||
Line::from(vec![
|
||
"enter".cyan(),
|
||
" select · ".dim(),
|
||
"esc".cyan(),
|
||
" back".dim(),
|
||
])
|
||
}
|
||
|
||
fn open_capture_action(
|
||
context: String,
|
||
action: String,
|
||
intent: KeymapEditIntent,
|
||
capture_mode: KeymapCaptureMode,
|
||
) -> Box<dyn Fn(&AppEventSender) + Send + Sync> {
|
||
Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenKeymapCapture {
|
||
context: context.clone(),
|
||
action: action.clone(),
|
||
intent: intent.clone(),
|
||
capture_mode,
|
||
});
|
||
})
|
||
}
|
||
|
||
fn action_menu_item(
|
||
name: &str,
|
||
description: String,
|
||
context: &str,
|
||
action: &str,
|
||
intent: KeymapEditIntent,
|
||
capture_mode: KeymapCaptureMode,
|
||
) -> SelectionItem {
|
||
SelectionItem {
|
||
name: name.to_string(),
|
||
description: Some(description),
|
||
actions: vec![open_capture_action(
|
||
context.to_string(),
|
||
action.to_string(),
|
||
intent,
|
||
capture_mode,
|
||
)],
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Build the action-specific menu after a user chooses a shortcut row.
|
||
///
|
||
/// The menu is based on both active runtime bindings and root config state: the
|
||
/// active bindings decide whether replace/add choices are available, while the
|
||
/// config state decides whether "remove custom binding" can restore fallback
|
||
/// behavior. Passing stale context/action strings yields a generic fallback
|
||
/// menu rather than panicking, because selection views can outlive config reloads.
|
||
pub(crate) fn build_keymap_action_menu_params(
|
||
context: String,
|
||
action: String,
|
||
runtime_keymap: &RuntimeKeymap,
|
||
keymap_config: &TuiKeymap,
|
||
) -> SelectionViewParams {
|
||
let current_bindings =
|
||
active_binding_specs(runtime_keymap, &context, &action).unwrap_or_else(|_| Vec::new());
|
||
let current_binding = if current_bindings.is_empty() {
|
||
"unbound".to_string()
|
||
} else {
|
||
current_bindings.join(", ")
|
||
};
|
||
let active_binding_count = current_bindings.len();
|
||
let custom_binding = has_custom_binding(keymap_config, &context, &action).unwrap_or(false);
|
||
let descriptor = KEYMAP_ACTIONS
|
||
.iter()
|
||
.find(|descriptor| descriptor.context == context && descriptor.action == action);
|
||
let context_label = descriptor
|
||
.map(|descriptor| descriptor.context_label)
|
||
.unwrap_or(context.as_str())
|
||
.to_string();
|
||
let description = descriptor
|
||
.map(|descriptor| descriptor.description)
|
||
.unwrap_or("Configure this shortcut.");
|
||
let remove_disabled_reason =
|
||
(!custom_binding).then(|| "No custom root override to remove.".to_string());
|
||
let label = action_label(&action);
|
||
let remove_context = context.clone();
|
||
let remove_action = action.clone();
|
||
let config_path = format!("tui.keymap.{context}.{action}");
|
||
let source = if custom_binding {
|
||
"Custom root override".cyan()
|
||
} else {
|
||
"Default keymap".dim()
|
||
};
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from("Edit Shortcut".bold()));
|
||
header.push(Line::from(vec![
|
||
label.bold(),
|
||
" · ".dim(),
|
||
context_label.dim(),
|
||
]));
|
||
header.push(Line::from(vec![
|
||
"Current ".dim(),
|
||
key_binding_span(¤t_binding),
|
||
" · ".dim(),
|
||
source,
|
||
]));
|
||
header.push(Line::from(vec![
|
||
"Config ".dim(),
|
||
format!("`{config_path}`").cyan(),
|
||
]));
|
||
header.push(Line::from(description.to_string().dim()));
|
||
|
||
let mut items = Vec::new();
|
||
match active_binding_count {
|
||
0 => {
|
||
items.push(action_menu_item(
|
||
"Set key",
|
||
"Capture a key for this unbound action.".to_string(),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::SingleKey,
|
||
));
|
||
}
|
||
1 => {
|
||
items.push(action_menu_item(
|
||
"Replace binding",
|
||
format!("Capture a replacement key for `{current_binding}`."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::SingleKey,
|
||
));
|
||
items.push(action_menu_item(
|
||
"Add alternate binding",
|
||
format!("Keep `{current_binding}` and add another key."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::AddAlternate,
|
||
KeymapCaptureMode::SingleKey,
|
||
));
|
||
}
|
||
_ => {
|
||
let replace_one_context = context.clone();
|
||
let replace_one_action = action.clone();
|
||
items.push(SelectionItem {
|
||
name: "Replace one binding...".to_string(),
|
||
description: Some("Choose which existing binding to replace.".to_string()),
|
||
actions: vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenKeymapReplaceBindingMenu {
|
||
context: replace_one_context.clone(),
|
||
action: replace_one_action.clone(),
|
||
});
|
||
})],
|
||
..Default::default()
|
||
});
|
||
items.push(action_menu_item(
|
||
"Replace all bindings",
|
||
format!("Replace `{current_binding}` with one key."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::SingleKey,
|
||
));
|
||
items.push(action_menu_item(
|
||
"Add alternate binding",
|
||
format!("Keep `{current_binding}` and add another key."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::AddAlternate,
|
||
KeymapCaptureMode::SingleKey,
|
||
));
|
||
}
|
||
}
|
||
if active_binding_count == 0 {
|
||
items.push(action_menu_item(
|
||
"Set key chord",
|
||
"Capture two consecutive keys for this action.".to_string(),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::Chord,
|
||
));
|
||
} else {
|
||
items.push(action_menu_item(
|
||
"Replace with key chord",
|
||
format!("Replace `{current_binding}` with a two-stroke key chord."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::Chord,
|
||
));
|
||
items.push(action_menu_item(
|
||
"Add alternate key chord",
|
||
format!("Keep `{current_binding}` and add a two-stroke key chord."),
|
||
&context,
|
||
&action,
|
||
KeymapEditIntent::AddAlternate,
|
||
KeymapCaptureMode::Chord,
|
||
));
|
||
}
|
||
items.push(SelectionItem {
|
||
name: "Remove custom binding".to_string(),
|
||
description: custom_binding.then(|| "Restore the default keymap binding.".to_string()),
|
||
disabled_reason: remove_disabled_reason,
|
||
disabled_gutter_marker: Some("–"),
|
||
actions: vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::KeymapCleared {
|
||
context: remove_context.clone(),
|
||
action: remove_action.clone(),
|
||
});
|
||
})],
|
||
..Default::default()
|
||
});
|
||
items.push(SelectionItem {
|
||
name: "Back to shortcuts".to_string(),
|
||
description: Some("Return to the shortcut list.".to_string()),
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
});
|
||
|
||
SelectionViewParams {
|
||
view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID),
|
||
header: Box::new(header),
|
||
footer_note: Some(Line::from(vec![
|
||
"Changes write the root ".dim(),
|
||
"`tui.keymap.*`".cyan(),
|
||
" override.".dim(),
|
||
])),
|
||
footer_hint: Some(keymap_action_menu_hint_line()),
|
||
items,
|
||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||
description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow {
|
||
min_description_width: KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH,
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_keymap_replace_binding_menu_params(
|
||
context: String,
|
||
action: String,
|
||
runtime_keymap: &RuntimeKeymap,
|
||
) -> SelectionViewParams {
|
||
let bindings = active_binding_specs(runtime_keymap, &context, &action).unwrap_or_default();
|
||
let label = action_label(&action);
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from("Replace Binding".bold()));
|
||
header.push(Line::from(vec![
|
||
label.bold(),
|
||
" · ".dim(),
|
||
format!("{context}.{action}").dim(),
|
||
]));
|
||
header.push(Line::from("Choose the binding to replace.".dim()));
|
||
|
||
let items = bindings
|
||
.into_iter()
|
||
.flat_map(|binding| {
|
||
[
|
||
SelectionItem {
|
||
name: binding.clone(),
|
||
description: Some(format!("Replace `{binding}` with another key.")),
|
||
actions: vec![open_capture_action(
|
||
context.clone(),
|
||
action.clone(),
|
||
KeymapEditIntent::ReplaceOne {
|
||
old_key: binding.clone(),
|
||
},
|
||
KeymapCaptureMode::SingleKey,
|
||
)],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: format!("{binding} (key chord)"),
|
||
description: Some(format!("Replace `{binding}` with a two-stroke key chord.")),
|
||
actions: vec![open_capture_action(
|
||
context.clone(),
|
||
action.clone(),
|
||
KeymapEditIntent::ReplaceOne { old_key: binding },
|
||
KeymapCaptureMode::Chord,
|
||
)],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
]
|
||
})
|
||
.collect();
|
||
|
||
SelectionViewParams {
|
||
view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID),
|
||
header: Box::new(header),
|
||
footer_hint: Some(keymap_action_menu_hint_line()),
|
||
items,
|
||
col_width_mode: ColumnWidthMode::Fixed,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_keymap_conflict_params(
|
||
context: String,
|
||
action: String,
|
||
key: String,
|
||
intent: KeymapEditIntent,
|
||
error: String,
|
||
) -> SelectionViewParams {
|
||
let capture_mode = if key.contains(' ') {
|
||
KeymapCaptureMode::Chord
|
||
} else {
|
||
KeymapCaptureMode::SingleKey
|
||
};
|
||
SelectionViewParams {
|
||
title: Some("Shortcut Conflict".to_string()),
|
||
subtitle: Some(format!("{context}.{action} cannot use `{key}`.")),
|
||
footer_note: Some(Line::from(error)),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items: vec![
|
||
SelectionItem {
|
||
name: "Pick another key".to_string(),
|
||
description: Some("Return to key capture for this action.".to_string()),
|
||
actions: vec![open_capture_action(context, action, intent, capture_mode)],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Cancel".to_string(),
|
||
description: Some("Leave keymap unchanged.".to_string()),
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
],
|
||
col_width_mode: ColumnWidthMode::Fixed,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Build the transient capture view for the selected keymap edit.
|
||
///
|
||
/// The view displays the current binding summary from the latest runtime map
|
||
/// and then delegates the captured key back to the app event loop. Unknown
|
||
/// actions are rendered as unbound so the eventual edit path can report the
|
||
/// stale selection with a precise error.
|
||
pub(crate) fn build_keymap_capture_view(
|
||
context: String,
|
||
action: String,
|
||
intent: KeymapEditIntent,
|
||
capture_mode: KeymapCaptureMode,
|
||
runtime_keymap: &RuntimeKeymap,
|
||
app_event_tx: AppEventSender,
|
||
) -> KeymapCaptureView {
|
||
let current_binding = format_action_binding_summary(runtime_keymap, &context, &action);
|
||
let label = action_label(&action);
|
||
KeymapCaptureView::new(
|
||
context,
|
||
action,
|
||
intent,
|
||
capture_mode,
|
||
label,
|
||
current_binding,
|
||
app_event_tx,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn keymap_with_replacement(
|
||
keymap: &TuiKeymap,
|
||
context: &str,
|
||
action: &str,
|
||
key: &str,
|
||
) -> Result<TuiKeymap, String> {
|
||
keymap_with_bindings(keymap, context, action, &[key.to_string()])
|
||
}
|
||
|
||
/// Apply a captured key to one action and return the edited root config.
|
||
///
|
||
/// The current effective bindings come from `runtime_keymap`, so adding an
|
||
/// alternate to a default-only action first materializes those defaults into
|
||
/// root config before appending the captured key. Replacing one binding guards
|
||
/// against stale menus by requiring the selected `old_key` to still be active;
|
||
/// otherwise a user could overwrite a binding that changed after the menu was
|
||
/// opened.
|
||
pub(crate) fn keymap_with_edit(
|
||
keymap: &TuiKeymap,
|
||
runtime_keymap: &RuntimeKeymap,
|
||
context: &str,
|
||
action: &str,
|
||
key: &str,
|
||
intent: &KeymapEditIntent,
|
||
) -> Result<KeymapEditOutcome, String> {
|
||
let current_bindings = active_binding_specs(runtime_keymap, context, action)?;
|
||
let next_bindings = match intent {
|
||
KeymapEditIntent::ReplaceAll => vec![key.to_string()],
|
||
KeymapEditIntent::AddAlternate => {
|
||
if current_bindings.iter().any(|binding| binding == key) {
|
||
return Ok(KeymapEditOutcome::Unchanged {
|
||
message: format!("No change: `{context}.{action}` already uses `{key}`."),
|
||
});
|
||
}
|
||
let mut bindings = current_bindings.clone();
|
||
bindings.push(key.to_string());
|
||
bindings
|
||
}
|
||
KeymapEditIntent::ReplaceOne { old_key } => {
|
||
if !current_bindings.iter().any(|binding| binding == old_key) {
|
||
return Err(format!(
|
||
"`{context}.{action}` no longer uses `{old_key}`. Reopen /keymap and choose a binding again."
|
||
));
|
||
}
|
||
let bindings = current_bindings
|
||
.iter()
|
||
.map(|binding| {
|
||
if binding == old_key {
|
||
key.to_string()
|
||
} else {
|
||
binding.clone()
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
dedup_bindings(bindings)
|
||
}
|
||
};
|
||
|
||
if next_bindings == current_bindings {
|
||
return Ok(KeymapEditOutcome::Unchanged {
|
||
message: format!("No change: `{context}.{action}` already uses `{key}`."),
|
||
});
|
||
}
|
||
|
||
let message = match intent {
|
||
KeymapEditIntent::ReplaceAll => format!("Remapped `{context}.{action}` to `{key}`."),
|
||
KeymapEditIntent::AddAlternate => format!("Added `{key}` to `{context}.{action}`."),
|
||
KeymapEditIntent::ReplaceOne { old_key } => {
|
||
format!("Replaced `{old_key}` with `{key}` for `{context}.{action}`.")
|
||
}
|
||
};
|
||
|
||
Ok(KeymapEditOutcome::Updated {
|
||
keymap_config: Box::new(keymap_with_bindings(
|
||
keymap,
|
||
context,
|
||
action,
|
||
&next_bindings,
|
||
)?),
|
||
bindings: next_bindings,
|
||
message,
|
||
})
|
||
}
|
||
|
||
fn keymap_with_bindings(
|
||
keymap: &TuiKeymap,
|
||
context: &str,
|
||
action: &str,
|
||
keys: &[String],
|
||
) -> Result<TuiKeymap, String> {
|
||
let mut keymap = keymap.clone();
|
||
let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| {
|
||
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
|
||
})?;
|
||
*slot = Some(match keys {
|
||
[key] => KeybindingsSpec::One(KeybindingSpec(key.clone())),
|
||
keys => KeybindingsSpec::Many(
|
||
keys.iter()
|
||
.map(|key| KeybindingSpec(key.clone()))
|
||
.collect::<Vec<_>>(),
|
||
),
|
||
});
|
||
Ok(keymap)
|
||
}
|
||
|
||
/// Return the active config key specs for one runtime action.
|
||
///
|
||
/// This converts resolved [`crate::key_hint::KeyBinding`] values back into
|
||
/// canonical config strings for display and for edit operations that need to
|
||
/// preserve existing bindings. Callers should treat errors as stale UI state,
|
||
/// because valid menu entries should always point at known actions.
|
||
pub(crate) fn active_binding_specs(
|
||
runtime_keymap: &RuntimeKeymap,
|
||
context: &str,
|
||
action: &str,
|
||
) -> Result<Vec<String>, String> {
|
||
if let Some(action_id) = keymap_action_id(context, action)
|
||
&& let Some(specs) = runtime_keymap.chords.configured_specs(action_id)
|
||
{
|
||
return Ok(specs.to_vec());
|
||
}
|
||
|
||
let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| {
|
||
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
|
||
})?;
|
||
bindings
|
||
.iter()
|
||
.map(|binding| binding_to_config_key_spec(*binding))
|
||
.collect()
|
||
}
|
||
|
||
fn dedup_bindings(bindings: Vec<String>) -> Vec<String> {
|
||
bindings.into_iter().fold(Vec::new(), |mut deduped, key| {
|
||
if !deduped.contains(&key) {
|
||
deduped.push(key);
|
||
}
|
||
deduped
|
||
})
|
||
}
|
||
|
||
/// Remove the root-level custom binding for one action.
|
||
///
|
||
/// Clearing the slot with `None` is different from setting an empty binding
|
||
/// list: `None` restores default/global fallback behavior, while an empty list
|
||
/// explicitly unbinds the action in runtime resolution.
|
||
pub(crate) fn keymap_without_custom_binding(
|
||
keymap: &TuiKeymap,
|
||
context: &str,
|
||
action: &str,
|
||
) -> Result<TuiKeymap, String> {
|
||
let mut keymap = keymap.clone();
|
||
let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| {
|
||
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
|
||
})?;
|
||
*slot = None;
|
||
Ok(keymap)
|
||
}
|
||
|
||
fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result<bool, String> {
|
||
let mut keymap = keymap.clone();
|
||
let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| {
|
||
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
|
||
})?;
|
||
Ok(slot.is_some())
|
||
}
|
||
|
||
fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result<String, String> {
|
||
binding_to_config_key_spec(KeyBinding::from_event(key_event))
|
||
}
|
||
|
||
fn binding_to_config_key_spec(binding: KeyBinding) -> Result<String, String> {
|
||
let (code, modifiers) = binding.parts();
|
||
key_parts_to_config_key_spec(code, modifiers)
|
||
}
|
||
|
||
fn key_parts_to_config_key_spec(
|
||
code: KeyCode,
|
||
mut modifiers: KeyModifiers,
|
||
) -> Result<String, String> {
|
||
let (code, normalized_modifiers) = crate::key_hint::normalize_key_parts(code, modifiers);
|
||
modifiers = normalized_modifiers;
|
||
|
||
let supported_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
|
||
if !modifiers.difference(supported_modifiers).is_empty() {
|
||
return Err(
|
||
"Only ctrl, alt, and shift modifiers can be stored in `tui.keymap`.".to_string(),
|
||
);
|
||
}
|
||
|
||
let key = match code {
|
||
KeyCode::Enter => "enter".to_string(),
|
||
KeyCode::Tab => "tab".to_string(),
|
||
KeyCode::Backspace => "backspace".to_string(),
|
||
KeyCode::Esc => "esc".to_string(),
|
||
KeyCode::Delete => "delete".to_string(),
|
||
KeyCode::Up => "up".to_string(),
|
||
KeyCode::Down => "down".to_string(),
|
||
KeyCode::Left => "left".to_string(),
|
||
KeyCode::Right => "right".to_string(),
|
||
KeyCode::Home => "home".to_string(),
|
||
KeyCode::End => "end".to_string(),
|
||
KeyCode::PageUp => "page-up".to_string(),
|
||
KeyCode::PageDown => "page-down".to_string(),
|
||
KeyCode::F(number) if (1..=MAX_FUNCTION_KEY).contains(&number) => format!("f{number}"),
|
||
KeyCode::F(_) => {
|
||
return Err(format!(
|
||
"Only function keys F1 through F{MAX_FUNCTION_KEY} can be stored in `tui.keymap`."
|
||
));
|
||
}
|
||
KeyCode::Char(' ') => "space".to_string(),
|
||
KeyCode::Char(mut ch) => {
|
||
if ch == '-' {
|
||
return Ok(format_key_spec(modifiers, "minus"));
|
||
}
|
||
if !ch.is_ascii() || ch.is_ascii_control() {
|
||
return Err("Only printable ASCII keys can be stored in `tui.keymap`.".to_string());
|
||
}
|
||
if ch.is_ascii_uppercase() {
|
||
modifiers.insert(KeyModifiers::SHIFT);
|
||
ch = ch.to_ascii_lowercase();
|
||
}
|
||
ch.to_string()
|
||
}
|
||
_ => {
|
||
return Err("That key is not supported by `tui.keymap`.".to_string());
|
||
}
|
||
};
|
||
|
||
Ok(format_key_spec(modifiers, &key))
|
||
}
|
||
|
||
fn format_key_spec(modifiers: KeyModifiers, key: &str) -> String {
|
||
let mut parts = Vec::new();
|
||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||
parts.push("ctrl");
|
||
}
|
||
if modifiers.contains(KeyModifiers::ALT) {
|
||
parts.push("alt");
|
||
}
|
||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||
parts.push("shift");
|
||
}
|
||
parts.push(key);
|
||
parts.join("-")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::picker::KEYMAP_ALL_TAB_ID;
|
||
use super::picker::KEYMAP_COMMON_TAB_ID;
|
||
use super::picker::KEYMAP_CUSTOM_TAB_ID;
|
||
use super::picker::KEYMAP_DEBUG_TAB_ID;
|
||
use super::picker::KEYMAP_UNBOUND_TAB_ID;
|
||
use super::*;
|
||
use crate::bottom_pane::BottomPane;
|
||
use crate::bottom_pane::BottomPaneParams;
|
||
use crate::bottom_pane::BottomPaneView;
|
||
use crate::bottom_pane::ListSelectionView;
|
||
use crate::bottom_pane::SelectionTab;
|
||
use crate::render::renderable::Renderable;
|
||
use crate::tui::FrameRequester;
|
||
use insta::assert_snapshot;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use tokio::sync::mpsc::UnboundedReceiver;
|
||
use tokio::sync::mpsc::unbounded_channel;
|
||
|
||
fn app_event_sender() -> AppEventSender {
|
||
let (tx, _rx) = unbounded_channel();
|
||
AppEventSender::new(tx)
|
||
}
|
||
|
||
fn render_capture(view: &KeymapCaptureView, width: u16, height: u16) -> Buffer {
|
||
let area = Rect::new(0, 0, width, height);
|
||
let mut buf = Buffer::empty(area);
|
||
view.render(area, &mut buf);
|
||
buf
|
||
}
|
||
|
||
fn render_debug(view: &KeymapDebugView, width: u16) -> String {
|
||
let height = view.desired_height(width);
|
||
let area = Rect::new(0, 0, width, height);
|
||
let mut buf = Buffer::empty(area);
|
||
view.render(area, &mut buf);
|
||
render_buffer(&buf)
|
||
}
|
||
|
||
fn render_picker(params: SelectionViewParams, width: u16) -> String {
|
||
let view =
|
||
ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list);
|
||
render_picker_from_view(&view, width)
|
||
}
|
||
|
||
fn render_picker_from_view(view: &ListSelectionView, width: u16) -> String {
|
||
let height = view.desired_height(width);
|
||
let area = Rect::new(0, 0, width, height);
|
||
let mut buf = Buffer::empty(area);
|
||
view.render(area, &mut buf);
|
||
render_buffer(&buf)
|
||
}
|
||
|
||
fn fast_mode_action_filter() -> KeymapActionFilter {
|
||
KeymapActionFilter {
|
||
fast_mode_enabled: true,
|
||
}
|
||
}
|
||
|
||
fn render_buffer(buf: &Buffer) -> String {
|
||
let area = buf.area();
|
||
(0..area.height)
|
||
.map(|row| {
|
||
let mut line = String::new();
|
||
for col in 0..area.width {
|
||
let symbol = buf[(col, row)].symbol();
|
||
if symbol.is_empty() {
|
||
line.push(' ');
|
||
} else {
|
||
line.push_str(symbol);
|
||
}
|
||
}
|
||
line.trim_end().to_string()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn test_pane() -> (BottomPane, AppEventSender, UnboundedReceiver<AppEvent>) {
|
||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let pane = BottomPane::new(BottomPaneParams {
|
||
app_event_tx: tx.clone(),
|
||
frame_requester: FrameRequester::test_dummy(),
|
||
has_input_focus: true,
|
||
enhanced_keys_supported: false,
|
||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||
disable_paste_burst: false,
|
||
animations_enabled: false,
|
||
skills: Some(Vec::new()),
|
||
});
|
||
(pane, tx, rx)
|
||
}
|
||
|
||
fn selection_tab<'a>(params: &'a SelectionViewParams, id: &str) -> &'a SelectionTab {
|
||
params
|
||
.tabs
|
||
.iter()
|
||
.find(|tab| tab.id == id)
|
||
.expect("selection tab")
|
||
}
|
||
|
||
fn selection_item<'a>(params: &'a SelectionViewParams, name: &str) -> &'a SelectionItem {
|
||
params
|
||
.items
|
||
.iter()
|
||
.find(|item| item.name == name)
|
||
.expect("selection item")
|
||
}
|
||
|
||
fn action_menu_rows(params: &SelectionViewParams) -> String {
|
||
params
|
||
.items
|
||
.iter()
|
||
.map(|item| {
|
||
format!(
|
||
"{} | {} | {}",
|
||
item.name,
|
||
item.description.as_deref().unwrap_or_default(),
|
||
item.disabled_reason.as_deref().unwrap_or("enabled")
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
#[test]
|
||
fn picker_covers_every_replaceable_action() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params_with_filter(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
fast_mode_action_filter(),
|
||
);
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
|
||
assert!(params.items.is_empty());
|
||
assert_eq!(all_tab.items.len(), KEYMAP_ACTIONS.len());
|
||
assert!(
|
||
all_tab.items.iter().all(|item| !item.dismiss_on_select),
|
||
"keymap picker should stay open behind the action menu"
|
||
);
|
||
assert!(KEYMAP_ACTIONS.iter().all(|descriptor| {
|
||
binding_slot(
|
||
&mut TuiKeymap::default(),
|
||
descriptor.context,
|
||
descriptor.action,
|
||
)
|
||
.is_some()
|
||
}));
|
||
assert!(KEYMAP_ACTIONS.iter().all(|descriptor| {
|
||
bindings_for_action(&runtime, descriptor.context, descriptor.action).is_some()
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn picker_hides_fast_mode_action_when_feature_is_disabled() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
|
||
assert!(
|
||
all_tab
|
||
.items
|
||
.iter()
|
||
.all(|item| item.name != "Toggle Fast Mode")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_shows_fast_mode_action_when_feature_is_enabled() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params_with_filter(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
fast_mode_action_filter(),
|
||
);
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
let common_tab = selection_tab(¶ms, KEYMAP_COMMON_TAB_ID);
|
||
let app_tab = selection_tab(¶ms, "app-shortcuts");
|
||
let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
||
|
||
for tab in [all_tab, common_tab, app_tab, unbound_tab] {
|
||
assert!(
|
||
tab.items.iter().any(|item| item.name == "Toggle Fast Mode"),
|
||
"expected Toggle Fast Mode in {}",
|
||
tab.label
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn keymap_picker_fast_mode_enabled_snapshot() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params_with_filter(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
fast_mode_action_filter(),
|
||
);
|
||
|
||
assert_snapshot!(
|
||
"keymap_picker_fast_mode_enabled",
|
||
render_picker(params, /*width*/ 120)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_common_tab_lists_curated_actions() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let common_tab = selection_tab(¶ms, KEYMAP_COMMON_TAB_ID);
|
||
let actions = common_tab
|
||
.items
|
||
.iter()
|
||
.map(|item| {
|
||
item.search_value
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.split_whitespace()
|
||
.take(2)
|
||
.collect::<Vec<_>>()
|
||
.join(".")
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
assert_eq!(
|
||
actions,
|
||
vec![
|
||
"Composer.submit",
|
||
"Chat.interrupt_turn",
|
||
"Editor.insert_newline",
|
||
"Composer.queue",
|
||
"Global.open_external_editor",
|
||
"Global.copy",
|
||
"Global.toggle_vim_mode",
|
||
"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",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_approval_tab_lists_all_approval_actions() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let approval_tab = selection_tab(¶ms, "approval-shortcuts");
|
||
let actions = approval_tab
|
||
.items
|
||
.iter()
|
||
.map(|item| {
|
||
item.search_value
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.split_whitespace()
|
||
.take(2)
|
||
.collect::<Vec<_>>()
|
||
.join(".")
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
actions,
|
||
vec![
|
||
"Approval.open_fullscreen",
|
||
"Approval.open_thread",
|
||
"Approval.approve",
|
||
"Approval.approve_for_session",
|
||
"Approval.approve_for_prefix",
|
||
"Approval.deny",
|
||
"Approval.decline",
|
||
"Approval.cancel",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_content_snapshot() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
let snapshot = params
|
||
.tabs
|
||
.iter()
|
||
.map(|tab| {
|
||
let selectable = tab.items.iter().filter(|item| !item.is_disabled).count();
|
||
format!("tab: {} ({selectable} selectable)", tab.label)
|
||
})
|
||
.chain(all_tab.items.iter().take(13).map(|item| {
|
||
format!(
|
||
"{} | {} | {}",
|
||
item.name,
|
||
item.description.as_deref().unwrap_or_default(),
|
||
item.search_value.as_deref().unwrap_or_default()
|
||
)
|
||
}))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert_snapshot!("keymap_picker_first_actions", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_customized_tab_contains_root_overrides() {
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.expect("replace binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let params = build_keymap_picker_params(&runtime, &keymap);
|
||
let custom_tab = selection_tab(¶ms, KEYMAP_CUSTOM_TAB_ID);
|
||
let composer_tab = selection_tab(¶ms, "composer-shortcuts");
|
||
|
||
assert_eq!(
|
||
custom_tab
|
||
.items
|
||
.iter()
|
||
.map(|item| item.name.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec!["Submit"]
|
||
);
|
||
assert!(
|
||
composer_tab
|
||
.items
|
||
.iter()
|
||
.any(|item| item.description.as_deref() == Some("ctrl-enter"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_unbound_tab_lists_default_unbound_actions() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
||
|
||
assert_eq!(
|
||
unbound_tab
|
||
.items
|
||
.iter()
|
||
.map(|item| (
|
||
item.name.as_str(),
|
||
item.description.as_deref(),
|
||
item.is_disabled
|
||
))
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
("Toggle Vim Mode", Some("unbound"), false),
|
||
("Previous Permission Mode", Some("unbound"), false),
|
||
("Next Permission Mode", Some("unbound"), false),
|
||
("Kill Whole Line", Some("unbound"), false),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_debug_tab_is_last_and_opens_inspector() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let debug_tab = params.tabs.last().expect("debug tab");
|
||
|
||
assert_eq!(debug_tab.id, KEYMAP_DEBUG_TAB_ID);
|
||
assert_eq!(debug_tab.label, "Debug");
|
||
assert_eq!(debug_tab.items.len(), 1);
|
||
assert_eq!(debug_tab.items[0].name, "Inspect keypresses");
|
||
assert_eq!(
|
||
debug_tab.items[0].description.as_deref(),
|
||
Some("Press Enter to start. Then press any key to inspect it; Ctrl+C exits.")
|
||
);
|
||
assert!(
|
||
params
|
||
.tab_footer_hints
|
||
.iter()
|
||
.any(|(tab_id, _)| tab_id == KEYMAP_DEBUG_TAB_ID)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_selected_action_starts_on_matching_all_tab_row() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params_for_selected_action(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"submit",
|
||
);
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
|
||
assert_eq!(params.initial_tab_id.as_deref(), Some(KEYMAP_ALL_TAB_ID));
|
||
assert_eq!(
|
||
params.initial_selected_idx,
|
||
all_tab.items.iter().position(|item| item.name == "Submit")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_all_tab_items_remain_searchable() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||
let snapshot = all_tab
|
||
.items
|
||
.iter()
|
||
.take(12)
|
||
.map(|item| {
|
||
format!(
|
||
"{} | {} | {}",
|
||
item.name,
|
||
item.description.as_deref().unwrap_or_default(),
|
||
item.search_value.as_deref().unwrap_or_default()
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert_snapshot!("keymap_picker_all_tab_search", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_wide_render_snapshot() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
|
||
assert_snapshot!("keymap_picker_wide", render_picker(params, /*width*/ 120));
|
||
}
|
||
|
||
#[test]
|
||
fn picker_narrow_render_snapshot() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
|
||
assert_snapshot!("keymap_picker_narrow", render_picker(params, /*width*/ 78));
|
||
}
|
||
|
||
#[test]
|
||
fn picker_custom_render_snapshot() {
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.expect("replace binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let params = build_keymap_picker_params(&runtime, &keymap);
|
||
|
||
assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120));
|
||
}
|
||
|
||
#[test]
|
||
fn picker_global_chord_render_snapshot() {
|
||
let keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"global",
|
||
"open_transcript",
|
||
&["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
|
||
)
|
||
.expect("global chord binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let params = build_keymap_picker_params(&runtime, &keymap);
|
||
|
||
assert_snapshot!(
|
||
"keymap_picker_global_chord",
|
||
render_picker(params, /*width*/ 120)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn configured_global_chord_alternatives_remain_in_order() {
|
||
let keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"global",
|
||
"open_transcript",
|
||
&["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
|
||
)
|
||
.expect("global chord bindings");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
|
||
assert_eq!(
|
||
active_binding_specs(&runtime, "global", "open_transcript"),
|
||
Ok(vec!["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()])
|
||
);
|
||
assert_eq!(
|
||
format_action_binding_summary(&runtime, "global", "open_transcript"),
|
||
"ctrl-t, ctrl-x ctrl-t"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn picker_narrow_uses_compact_tabs() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||
let rendered = render_picker(params, /*width*/ 78);
|
||
|
||
assert!(rendered.contains("Keymap"));
|
||
assert!(rendered.contains("Open Transcript"));
|
||
assert!(rendered.contains("ctrl-t"));
|
||
assert!(!rendered.contains("Selected Action"));
|
||
assert!(!rendered.contains("Source: default keymap"));
|
||
}
|
||
|
||
#[test]
|
||
fn action_menu_content_snapshot() {
|
||
let unbound_keymap = keymap_with_bindings(&TuiKeymap::default(), "global", "copy", &[])
|
||
.expect("unbound copy");
|
||
let unbound_runtime = RuntimeKeymap::from_config(&unbound_keymap).expect("runtime keymap");
|
||
let unbound_params = build_keymap_action_menu_params(
|
||
"global".to_string(),
|
||
"copy".to_string(),
|
||
&unbound_runtime,
|
||
&unbound_keymap,
|
||
);
|
||
|
||
let single_keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.expect("replace binding");
|
||
let single_runtime = RuntimeKeymap::from_config(&single_keymap).expect("runtime keymap");
|
||
let single_params = build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&single_runtime,
|
||
&single_keymap,
|
||
);
|
||
|
||
let multi_keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"submit",
|
||
&["ctrl-enter".to_string(), "alt-shift-enter".to_string()],
|
||
)
|
||
.expect("multi binding");
|
||
let multi_runtime = RuntimeKeymap::from_config(&multi_keymap).expect("runtime keymap");
|
||
let multi_params = build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&multi_runtime,
|
||
&multi_keymap,
|
||
);
|
||
let replace_params = build_keymap_replace_binding_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&multi_runtime,
|
||
);
|
||
let snapshot = [
|
||
"unbound:",
|
||
&action_menu_rows(&unbound_params),
|
||
"",
|
||
"single:",
|
||
&action_menu_rows(&single_params),
|
||
"",
|
||
"multi:",
|
||
&action_menu_rows(&multi_params),
|
||
"",
|
||
"replace picker:",
|
||
&action_menu_rows(&replace_params),
|
||
]
|
||
.join("\n");
|
||
|
||
assert_snapshot!("keymap_action_menu", snapshot);
|
||
|
||
for params in [unbound_params, single_params, multi_params, replace_params] {
|
||
let item_count = params.items.len();
|
||
let mut view =
|
||
ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list);
|
||
let initial = render_picker_from_view(&view, /*width*/ 160).replace('›', " ");
|
||
for _ in 1..item_count {
|
||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||
assert_eq!(
|
||
render_picker_from_view(&view, /*width*/ 160).replace('›', " "),
|
||
initial,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn action_menu_responsive_render_snapshot() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let render_at = |width| {
|
||
let params = build_keymap_action_menu_params(
|
||
"global".to_string(),
|
||
"open_transcript".to_string(),
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
);
|
||
render_picker(params, width)
|
||
};
|
||
let snapshot = [
|
||
"48 columns:",
|
||
&render_at(/*width*/ 48),
|
||
"",
|
||
"64 columns:",
|
||
&render_at(/*width*/ 64),
|
||
"",
|
||
"96 columns:",
|
||
&render_at(/*width*/ 96),
|
||
]
|
||
.join("\n");
|
||
|
||
assert_snapshot!("keymap_action_menu_responsive", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn action_menu_disables_clear_when_action_has_no_custom_binding() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
);
|
||
|
||
assert_eq!(params.view_id, Some(KEYMAP_ACTION_MENU_VIEW_ID));
|
||
let replace = selection_item(¶ms, "Replace binding");
|
||
let add_alternate = selection_item(¶ms, "Add alternate binding");
|
||
let remove = selection_item(¶ms, "Remove custom binding");
|
||
let back = selection_item(¶ms, "Back to shortcuts");
|
||
assert_eq!(
|
||
remove.disabled_reason.as_deref(),
|
||
Some("No custom root override to remove.")
|
||
);
|
||
assert!(
|
||
!replace.dismiss_on_select,
|
||
"replace should keep the action menu under key capture"
|
||
);
|
||
assert!(
|
||
!add_alternate.dismiss_on_select,
|
||
"add alternate should keep the action menu under key capture"
|
||
);
|
||
assert!(!remove.dismiss_on_select, "clear-key waits for save result");
|
||
assert!(
|
||
back.dismiss_on_select,
|
||
"back should dismiss the action menu"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn capture_view_snapshot() {
|
||
let view = KeymapCaptureView::new(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
KeymapEditIntent::ReplaceAll,
|
||
KeymapCaptureMode::SingleKey,
|
||
"Submit".to_string(),
|
||
"enter".to_string(),
|
||
app_event_sender(),
|
||
);
|
||
|
||
assert_snapshot!(
|
||
"keymap_capture_view",
|
||
format!("{:?}", render_capture(&view, /*width*/ 80, /*height*/ 8))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn debug_view_initial_snapshot() {
|
||
let view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default());
|
||
|
||
assert_snapshot!(
|
||
"keymap_debug_view_initial",
|
||
render_debug(&view, /*width*/ 80)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn debug_view_shows_delayed_missing_key_hint() {
|
||
let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default());
|
||
view.show_delayed_hint_for_test();
|
||
|
||
let rendered = render_debug(&view, /*width*/ 100);
|
||
assert!(rendered.contains("Still waiting?"));
|
||
assert_snapshot!("keymap_debug_view_delayed_hint", rendered);
|
||
}
|
||
|
||
#[test]
|
||
fn debug_view_reports_detected_key_and_matching_actions() {
|
||
let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default());
|
||
view.show_delayed_hint_for_test();
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||
|
||
let rendered = render_debug(&view, /*width*/ 100);
|
||
assert!(!rendered.contains("Still waiting?"));
|
||
assert_snapshot!("keymap_debug_view_match", rendered);
|
||
}
|
||
|
||
#[test]
|
||
fn debug_view_uses_custom_binding_source() {
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "global", "copy", "ctrl-x").unwrap();
|
||
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||
let mut view = build_keymap_debug_view(&runtime, &keymap);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||
|
||
let rendered = render_debug(&view, /*width*/ 100);
|
||
assert!(rendered.contains("global.copy (Copy)"));
|
||
assert!(rendered.contains("[Custom]"));
|
||
}
|
||
|
||
#[test]
|
||
fn debug_view_labels_custom_global_fallback_source() {
|
||
let mut keymap = TuiKeymap::default();
|
||
keymap.global.queue = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-q".to_string())));
|
||
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||
let mut view = build_keymap_debug_view(&runtime, &keymap);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL));
|
||
|
||
let rendered = render_debug(&view, /*width*/ 100);
|
||
assert!(rendered.contains("composer.queue (Queue)"));
|
||
assert!(rendered.contains("[Custom global]"));
|
||
}
|
||
|
||
#[test]
|
||
fn capture_completion_returns_to_selected_keymap_picker_row() {
|
||
let (mut pane, tx, mut rx) = test_pane();
|
||
let runtime = RuntimeKeymap::defaults();
|
||
pane.show_selection_view(build_keymap_picker_params(&runtime, &TuiKeymap::default()));
|
||
pane.show_selection_view(build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
));
|
||
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
let AppEvent::OpenKeymapCapture {
|
||
context,
|
||
action,
|
||
intent,
|
||
capture_mode,
|
||
} = rx.try_recv().expect("open capture event")
|
||
else {
|
||
panic!("expected OpenKeymapCapture event");
|
||
};
|
||
assert_eq!(intent, KeymapEditIntent::ReplaceAll);
|
||
assert_eq!(capture_mode, KeymapCaptureMode::SingleKey);
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID));
|
||
|
||
pane.show_view(Box::new(build_keymap_capture_view(
|
||
context,
|
||
action,
|
||
intent,
|
||
capture_mode,
|
||
&runtime,
|
||
tx,
|
||
)));
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::CONTROL));
|
||
|
||
let AppEvent::KeymapCaptured {
|
||
context,
|
||
action,
|
||
key,
|
||
intent,
|
||
} = rx.try_recv().expect("captured key event")
|
||
else {
|
||
panic!("expected KeymapCaptured event");
|
||
};
|
||
assert_eq!(context, "composer");
|
||
assert_eq!(action, "submit");
|
||
assert_eq!(key, "ctrl-shift-k");
|
||
assert_eq!(intent, KeymapEditIntent::ReplaceAll);
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID));
|
||
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), &context, &action, &key).unwrap();
|
||
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||
let params =
|
||
build_keymap_picker_params_for_selected_action(&runtime, &keymap, &context, &action);
|
||
let selected_idx = params.initial_selected_idx;
|
||
assert!(
|
||
pane.replace_active_views_with_selection_view(
|
||
&[
|
||
KEYMAP_PICKER_VIEW_ID,
|
||
KEYMAP_ACTION_MENU_VIEW_ID,
|
||
KEYMAP_REPLACE_BINDING_MENU_VIEW_ID,
|
||
],
|
||
params
|
||
),
|
||
"successful assignment should return to the main picker"
|
||
);
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID));
|
||
assert_eq!(
|
||
pane.selected_index_for_active_view(KEYMAP_PICKER_VIEW_ID),
|
||
selected_idx
|
||
);
|
||
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
assert!(
|
||
pane.no_modal_or_popup_active(),
|
||
"the original picker should not remain behind the refreshed picker"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clear_completion_returns_to_selected_keymap_picker_row() {
|
||
let (mut pane, _tx, mut rx) = test_pane();
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.unwrap();
|
||
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||
pane.show_selection_view(build_keymap_picker_params(&runtime, &keymap));
|
||
let action_menu = build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"submit".to_string(),
|
||
&runtime,
|
||
&keymap,
|
||
);
|
||
let remove_idx = action_menu
|
||
.items
|
||
.iter()
|
||
.position(|item| item.name == "Remove custom binding")
|
||
.expect("remove custom binding menu item");
|
||
pane.show_selection_view(action_menu);
|
||
|
||
for _ in 0..remove_idx {
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
}
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
let AppEvent::KeymapCleared { context, action } =
|
||
rx.try_recv().expect("clear keymap event")
|
||
else {
|
||
panic!("expected KeymapCleared event");
|
||
};
|
||
assert_eq!(context, "composer");
|
||
assert_eq!(action, "submit");
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID));
|
||
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let params = build_keymap_picker_params_for_selected_action(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
&context,
|
||
&action,
|
||
);
|
||
let selected_idx = params.initial_selected_idx;
|
||
assert!(
|
||
pane.replace_active_views_with_selection_view(
|
||
&[
|
||
KEYMAP_PICKER_VIEW_ID,
|
||
KEYMAP_ACTION_MENU_VIEW_ID,
|
||
KEYMAP_REPLACE_BINDING_MENU_VIEW_ID,
|
||
],
|
||
params
|
||
),
|
||
"successful clear should return to the main picker"
|
||
);
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID));
|
||
assert_eq!(
|
||
pane.selected_index_for_active_view(KEYMAP_PICKER_VIEW_ID),
|
||
selected_idx
|
||
);
|
||
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
assert!(
|
||
pane.no_modal_or_popup_active(),
|
||
"the original picker should not remain behind the refreshed picker"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_one_completion_drops_focused_keymap_submenus() {
|
||
let (mut pane, _tx, _rx) = test_pane();
|
||
let runtime = RuntimeKeymap::defaults();
|
||
pane.show_selection_view(build_keymap_picker_params(&runtime, &TuiKeymap::default()));
|
||
pane.show_selection_view(build_keymap_action_menu_params(
|
||
"composer".to_string(),
|
||
"toggle_shortcuts".to_string(),
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
));
|
||
pane.show_selection_view(build_keymap_replace_binding_menu_params(
|
||
"composer".to_string(),
|
||
"toggle_shortcuts".to_string(),
|
||
&runtime,
|
||
));
|
||
assert_eq!(
|
||
pane.active_view_id(),
|
||
Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID)
|
||
);
|
||
|
||
let params = build_keymap_picker_params_for_selected_action(
|
||
&runtime,
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"toggle_shortcuts",
|
||
);
|
||
assert!(
|
||
pane.replace_active_views_with_selection_view(
|
||
&[
|
||
KEYMAP_PICKER_VIEW_ID,
|
||
KEYMAP_ACTION_MENU_VIEW_ID,
|
||
KEYMAP_REPLACE_BINDING_MENU_VIEW_ID,
|
||
],
|
||
params
|
||
),
|
||
"successful replace-one should return to the main picker"
|
||
);
|
||
assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID));
|
||
|
||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
assert!(
|
||
pane.no_modal_or_popup_active(),
|
||
"the parent action menu should not remain behind the picker"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn key_capture_serializes_modifier_order_for_config() {
|
||
let event = KeyEvent::new(
|
||
KeyCode::Char('K'),
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
);
|
||
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(event),
|
||
Ok("ctrl-alt-shift-k".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn key_capture_serializes_special_keys() {
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)),
|
||
Ok("shift-page-down".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn key_capture_serializes_function_keys_through_f24() {
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::from(KeyCode::F(13))),
|
||
Ok("f13".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::from(KeyCode::F(24))),
|
||
Ok("f24".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::from(KeyCode::F(25))),
|
||
Err("Only function keys F1 through F24 can be stored in `tui.keymap`.".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn key_capture_serializes_c0_control_chars_as_ctrl_bindings() {
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(
|
||
KeyCode::Char('\u{000a}'),
|
||
KeyModifiers::NONE,
|
||
)),
|
||
Ok("ctrl-j".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(
|
||
KeyCode::Char('\u{0015}'),
|
||
KeyModifiers::NONE,
|
||
)),
|
||
Ok("ctrl-u".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(
|
||
KeyCode::Char('\u{0010}'),
|
||
KeyModifiers::NONE,
|
||
)),
|
||
Ok("ctrl-p".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn key_capture_serializes_minus_as_named_key() {
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)),
|
||
Ok("minus".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::ALT)),
|
||
Ok("alt-minus".to_string())
|
||
);
|
||
assert_eq!(
|
||
key_event_to_config_key_spec(KeyEvent::new(
|
||
KeyCode::Char('-'),
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
)),
|
||
Ok("ctrl-alt-minus".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replacement_sets_single_binding() {
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.expect("replace binding");
|
||
|
||
assert_eq!(
|
||
keymap.composer.submit,
|
||
Some(KeybindingsSpec::One(KeybindingSpec(
|
||
"ctrl-enter".to_string()
|
||
)))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_all_collapses_multi_binding_to_single() {
|
||
let keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"submit",
|
||
&["ctrl-enter".to_string(), "alt-shift-enter".to_string()],
|
||
)
|
||
.expect("multi binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let outcome = keymap_with_edit(
|
||
&keymap,
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"ctrl-shift-enter",
|
||
&KeymapEditIntent::ReplaceAll,
|
||
)
|
||
.expect("replace all");
|
||
|
||
let KeymapEditOutcome::Updated {
|
||
keymap_config,
|
||
bindings,
|
||
..
|
||
} = outcome
|
||
else {
|
||
panic!("expected updated keymap");
|
||
};
|
||
assert_eq!(bindings, vec!["ctrl-shift-enter"]);
|
||
assert_eq!(
|
||
keymap_config.composer.submit,
|
||
Some(KeybindingsSpec::One(KeybindingSpec(
|
||
"ctrl-shift-enter".to_string()
|
||
)))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_alternate_grows_single_binding() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let outcome = keymap_with_edit(
|
||
&TuiKeymap::default(),
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"ctrl-enter",
|
||
&KeymapEditIntent::AddAlternate,
|
||
)
|
||
.expect("add alternate");
|
||
|
||
let KeymapEditOutcome::Updated {
|
||
keymap_config,
|
||
bindings,
|
||
..
|
||
} = outcome
|
||
else {
|
||
panic!("expected updated keymap");
|
||
};
|
||
assert_eq!(bindings, vec!["enter", "ctrl-enter"]);
|
||
assert_eq!(
|
||
keymap_config.composer.submit,
|
||
Some(KeybindingsSpec::Many(vec![
|
||
KeybindingSpec("enter".to_string()),
|
||
KeybindingSpec("ctrl-enter".to_string())
|
||
]))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_alternate_grows_default_multi_binding() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let outcome = keymap_with_edit(
|
||
&TuiKeymap::default(),
|
||
&runtime,
|
||
"editor",
|
||
"move_left",
|
||
"ctrl-shift-b",
|
||
&KeymapEditIntent::AddAlternate,
|
||
)
|
||
.expect("add alternate");
|
||
|
||
let KeymapEditOutcome::Updated {
|
||
keymap_config,
|
||
bindings,
|
||
..
|
||
} = outcome
|
||
else {
|
||
panic!("expected updated keymap");
|
||
};
|
||
assert_eq!(bindings, vec!["left", "ctrl-b", "ctrl-shift-b"]);
|
||
assert_eq!(
|
||
keymap_config.editor.move_left,
|
||
Some(KeybindingsSpec::Many(vec![
|
||
KeybindingSpec("left".to_string()),
|
||
KeybindingSpec("ctrl-b".to_string()),
|
||
KeybindingSpec("ctrl-shift-b".to_string())
|
||
]))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_alternate_duplicate_is_noop() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let outcome = keymap_with_edit(
|
||
&TuiKeymap::default(),
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"enter",
|
||
&KeymapEditIntent::AddAlternate,
|
||
)
|
||
.expect("duplicate alternate");
|
||
|
||
assert_eq!(
|
||
outcome,
|
||
KeymapEditOutcome::Unchanged {
|
||
message: "No change: `composer.submit` already uses `enter`.".to_string()
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_one_preserves_other_bindings() {
|
||
let keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"submit",
|
||
&["ctrl-enter".to_string(), "alt-shift-enter".to_string()],
|
||
)
|
||
.expect("multi binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let outcome = keymap_with_edit(
|
||
&keymap,
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"ctrl-shift-enter",
|
||
&KeymapEditIntent::ReplaceOne {
|
||
old_key: "ctrl-enter".to_string(),
|
||
},
|
||
)
|
||
.expect("replace one");
|
||
|
||
let KeymapEditOutcome::Updated {
|
||
keymap_config,
|
||
bindings,
|
||
..
|
||
} = outcome
|
||
else {
|
||
panic!("expected updated keymap");
|
||
};
|
||
assert_eq!(bindings, vec!["ctrl-shift-enter", "alt-shift-enter"]);
|
||
assert_eq!(
|
||
keymap_config.composer.submit,
|
||
Some(KeybindingsSpec::Many(vec![
|
||
KeybindingSpec("ctrl-shift-enter".to_string()),
|
||
KeybindingSpec("alt-shift-enter".to_string())
|
||
]))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_one_deduplicates_replacement() {
|
||
let keymap = keymap_with_bindings(
|
||
&TuiKeymap::default(),
|
||
"composer",
|
||
"submit",
|
||
&["ctrl-enter".to_string(), "ctrl-shift-enter".to_string()],
|
||
)
|
||
.expect("multi binding");
|
||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||
let outcome = keymap_with_edit(
|
||
&keymap,
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"ctrl-shift-enter",
|
||
&KeymapEditIntent::ReplaceOne {
|
||
old_key: "ctrl-enter".to_string(),
|
||
},
|
||
)
|
||
.expect("replace one");
|
||
|
||
let KeymapEditOutcome::Updated {
|
||
keymap_config,
|
||
bindings,
|
||
..
|
||
} = outcome
|
||
else {
|
||
panic!("expected updated keymap");
|
||
};
|
||
assert_eq!(bindings, vec!["ctrl-shift-enter"]);
|
||
assert_eq!(
|
||
keymap_config.composer.submit,
|
||
Some(KeybindingsSpec::One(KeybindingSpec(
|
||
"ctrl-shift-enter".to_string()
|
||
)))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_one_rejects_stale_old_key() {
|
||
let runtime = RuntimeKeymap::defaults();
|
||
let err = keymap_with_edit(
|
||
&TuiKeymap::default(),
|
||
&runtime,
|
||
"composer",
|
||
"submit",
|
||
"ctrl-enter",
|
||
&KeymapEditIntent::ReplaceOne {
|
||
old_key: "alt-enter".to_string(),
|
||
},
|
||
)
|
||
.expect_err("stale old key");
|
||
|
||
assert!(err.contains("composer.submit"));
|
||
assert!(err.contains("alt-enter"));
|
||
}
|
||
|
||
#[test]
|
||
fn clear_removes_custom_binding() {
|
||
let keymap =
|
||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||
.expect("replace binding");
|
||
|
||
assert_eq!(has_custom_binding(&keymap, "composer", "submit"), Ok(true));
|
||
|
||
let cleared =
|
||
keymap_without_custom_binding(&keymap, "composer", "submit").expect("clear binding");
|
||
|
||
assert_eq!(cleared.composer.submit, None);
|
||
assert_eq!(
|
||
has_custom_binding(&cleared, "composer", "submit"),
|
||
Ok(false)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn replacement_rejects_unknown_action() {
|
||
let err = keymap_with_replacement(&TuiKeymap::default(), "composer", "nope", "ctrl-enter")
|
||
.expect_err("unknown action");
|
||
|
||
assert!(err.contains("composer.nope"));
|
||
}
|
||
}
|