use super::{OverlayId, OverlayKind, OverlayStack};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct KeyFacts {
pub(crate) is_escape: bool,
pub(crate) bound_action: Option<String>,
pub(crate) is_command_chord: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum KeyRoute {
Terminal,
CloseTop(OverlayId),
Replace { close: OverlayId, action: String },
OpenAbove { action: String },
ToOverlay(OverlayId),
Consume,
}
impl KeyRoute {
pub(crate) fn feeds_egui(&self) -> bool {
!matches!(
self,
KeyRoute::CloseTop(_) | KeyRoute::Replace { .. } | KeyRoute::OpenAbove { .. }
)
}
pub(crate) fn continues_to_key_dispatch(&self) -> bool {
match self {
KeyRoute::Terminal => true,
KeyRoute::ToOverlay(id) => id.kind() == OverlayKind::Mode,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EscapeBehavior {
Close,
Delegate,
Inert,
}
pub(crate) const fn escape_behavior(id: OverlayId) -> EscapeBehavior {
match id {
OverlayId::TriggerConfirm | OverlayId::AgentCommandConfirm | OverlayId::UpdateDialog => {
EscapeBehavior::Inert
}
OverlayId::AiInspector | OverlayId::AppMenu | OverlayId::TabContextMenu => {
EscapeBehavior::Delegate
}
_ => match id.kind() {
OverlayKind::Mode if !id.guards_terminal() => EscapeBehavior::Delegate,
_ => EscapeBehavior::Close,
},
}
}
pub(crate) fn route_key(stack: &OverlayStack, key: &KeyFacts, egui_focused: bool) -> KeyRoute {
let Some(top) = stack.top_key_owner(egui_focused) else {
return KeyRoute::Terminal;
};
if key.is_escape {
return match escape_behavior(top) {
EscapeBehavior::Close => KeyRoute::CloseTop(top),
EscapeBehavior::Delegate => KeyRoute::ToOverlay(top),
EscapeBehavior::Inert => KeyRoute::Consume,
};
}
let chord_may_act = key.is_command_chord || !egui_focused;
if let Some(action) = key.bound_action.as_deref()
&& chord_may_act
{
if top.toggle_action() == Some(action) {
return KeyRoute::CloseTop(top);
}
if let Some(target) = OverlayId::opened_by_action(action)
&& target != top
{
match top.kind() {
OverlayKind::Popup if stack.entries().contains(&target) => {
return KeyRoute::CloseTop(top);
}
OverlayKind::Popup => {
return KeyRoute::Replace {
close: top,
action: action.to_string(),
};
}
OverlayKind::Panel => {
return KeyRoute::OpenAbove {
action: action.to_string(),
};
}
OverlayKind::Modal | OverlayKind::Mode => {}
}
}
}
match top.kind() {
OverlayKind::Mode | OverlayKind::Panel => KeyRoute::ToOverlay(top),
OverlayKind::Modal | OverlayKind::Popup => KeyRoute::Consume,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(action: Option<&str>, command: bool) -> KeyFacts {
KeyFacts {
is_escape: false,
bound_action: action.map(str::to_string),
is_command_chord: command,
}
}
fn escape() -> KeyFacts {
KeyFacts {
is_escape: true,
..KeyFacts::default()
}
}
fn stack(ids: &[OverlayId]) -> OverlayStack {
OverlayStack::from_open(ids.iter().copied())
}
#[test]
fn no_overlay_routes_to_the_terminal() {
assert_eq!(
route_key(&stack(&[]), &key(None, false), false),
KeyRoute::Terminal
);
assert_eq!(route_key(&stack(&[]), &escape(), false), KeyRoute::Terminal);
}
#[test]
fn the_top_overlay_receives_keys_first() {
let s = stack(&[OverlayId::TreePicker, OverlayId::CommandPalette]);
assert_eq!(
route_key(&s, &escape(), true),
KeyRoute::CloseTop(OverlayId::CommandPalette)
);
let s = stack(&[OverlayId::CommandPalette, OverlayId::QuitConfirm]);
assert_eq!(
route_key(&s, &escape(), false),
KeyRoute::CloseTop(OverlayId::QuitConfirm)
);
assert!(!KeyRoute::CloseTop(OverlayId::CommandPalette).feeds_egui());
}
#[test]
fn a_modal_consumes_every_unused_key() {
let s = stack(&[OverlayId::QuitConfirm]);
for k in [
key(None, false),
key(None, true),
key(Some("new_tab"), true),
key(Some("split_right"), true),
] {
let route = route_key(&s, &k, false);
assert_eq!(route, KeyRoute::Consume, "{k:?}");
assert!(!route.continues_to_key_dispatch(), "never reaches the PTY");
}
}
#[test]
fn a_modal_is_never_replaced_by_another_overlays_chord() {
let s = stack(&[OverlayId::QuitConfirm]);
assert_eq!(
route_key(&s, &key(Some("toggle_help"), true), false),
KeyRoute::Consume
);
let s = stack(&[OverlayId::MuxLastTab]);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), true), false),
KeyRoute::Consume
);
}
#[test]
fn a_popup_consumes_every_unused_key() {
let s = stack(&[OverlayId::CommandPalette]);
assert_eq!(route_key(&s, &key(None, false), true), KeyRoute::Consume);
assert_eq!(
route_key(&s, &key(Some("new_tab"), true), true),
KeyRoute::Consume
);
assert!(KeyRoute::Consume.feeds_egui());
}
#[test]
fn escape_goes_to_the_top_overlay_only() {
let s = stack(&[
OverlayId::AiInspector,
OverlayId::Search,
OverlayId::CommandHistory,
]);
assert_eq!(
route_key(&s, &escape(), true),
KeyRoute::CloseTop(OverlayId::CommandHistory)
);
}
#[test]
fn escape_on_an_answer_bearing_dialog_is_consumed_not_closing() {
for id in [
OverlayId::TriggerConfirm,
OverlayId::AgentCommandConfirm,
OverlayId::UpdateDialog,
] {
assert_eq!(
route_key(&stack(&[id]), &escape(), false),
KeyRoute::Consume,
"{id:?}"
);
}
}
#[test]
fn the_toggle_chord_closes_its_own_overlay() {
for id in [
OverlayId::CommandPalette,
OverlayId::CommandHistory,
OverlayId::ClipboardHistory,
OverlayId::Search,
OverlayId::TreePicker,
OverlayId::SessionPicker,
OverlayId::Help,
OverlayId::AgentUsage,
] {
let action = id.toggle_action().expect("popups with a chord");
assert_eq!(
route_key(&stack(&[id]), &key(Some(action), true), true),
KeyRoute::CloseTop(id),
"{id:?}"
);
}
}
#[test]
fn another_overlays_chord_replaces_a_popup() {
let s = stack(&[OverlayId::CommandHistory]);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), true), true),
KeyRoute::Replace {
close: OverlayId::CommandHistory,
action: "toggle_command_palette".to_string(),
}
);
}
#[test]
fn a_chord_for_an_overlay_already_open_beneath_reveals_it() {
let s = stack(&[OverlayId::Search, OverlayId::CommandPalette]);
assert_eq!(
route_key(&s, &key(Some("toggle_search"), true), true),
KeyRoute::CloseTop(OverlayId::CommandPalette)
);
}
#[test]
fn a_bare_letter_chord_is_typing_while_a_text_field_is_focused() {
let s = stack(&[OverlayId::CommandPalette]);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), false), true),
KeyRoute::Consume
);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), false), false),
KeyRoute::CloseTop(OverlayId::CommandPalette)
);
}
#[test]
fn an_unfocused_panel_leaves_keys_to_the_terminal() {
let s = stack(&[OverlayId::AiInspector]);
assert_eq!(route_key(&s, &escape(), false), KeyRoute::Terminal);
assert_eq!(route_key(&s, &key(None, false), false), KeyRoute::Terminal);
assert_eq!(
route_key(&s, &key(None, false), true),
KeyRoute::ToOverlay(OverlayId::AiInspector)
);
assert_eq!(
route_key(&s, &key(Some("toggle_ai_inspector"), true), true),
KeyRoute::CloseTop(OverlayId::AiInspector)
);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), true), true),
KeyRoute::OpenAbove {
action: "toggle_command_palette".to_string()
}
);
assert_eq!(
route_key(&s, &escape(), true),
KeyRoute::ToOverlay(OverlayId::AiInspector)
);
}
#[test]
fn the_profile_drawer_closes_on_escape_while_focused() {
let s = stack(&[OverlayId::ProfileDrawer]);
assert_eq!(
route_key(&s, &escape(), true),
KeyRoute::CloseTop(OverlayId::ProfileDrawer)
);
assert_eq!(route_key(&s, &escape(), false), KeyRoute::Terminal);
}
#[test]
fn a_mode_owns_its_keys_and_its_escape() {
let s = stack(&[OverlayId::PaneHints]);
let route = route_key(&s, &key(None, false), false);
assert_eq!(route, KeyRoute::ToOverlay(OverlayId::PaneHints));
assert!(route.continues_to_key_dispatch(), "mode handlers run there");
assert_eq!(
route_key(&s, &escape(), false),
KeyRoute::ToOverlay(OverlayId::PaneHints)
);
assert_eq!(
route_key(&s, &key(Some("toggle_command_palette"), true), false),
KeyRoute::ToOverlay(OverlayId::PaneHints)
);
let s = stack(&[OverlayId::CopyMode]);
assert_eq!(
route_key(&s, &key(Some("toggle_copy_mode"), true), false),
KeyRoute::CloseTop(OverlayId::CopyMode)
);
}
}