Files
codex/codex-rs/tui/src/keymap_setup.rs
Benjamin Carlsson 45a3edc02a Keep keymap action descriptions stable while navigating (#39992)
## 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
2026-08-21 18:47:03 +00:00

1953 lines
66 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&current_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(&params, 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(&params, 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(&params, KEYMAP_ALL_TAB_ID);
let common_tab = selection_tab(&params, KEYMAP_COMMON_TAB_ID);
let app_tab = selection_tab(&params, "app-shortcuts");
let unbound_tab = selection_tab(&params, 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(&params, 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(&params, "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(&params, 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(&params, KEYMAP_CUSTOM_TAB_ID);
let composer_tab = selection_tab(&params, "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(&params, 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(&params, 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(&params, 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(&params, "Replace binding");
let add_alternate = selection_item(&params, "Add alternate binding");
let remove = selection_item(&params, "Remove custom binding");
let back = selection_item(&params, "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"));
}
}