use std::collections::BTreeMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::state::{
ChannelActionItem, ChannelActionKind, EmojiReactionItem, FocusPane, GuildActionItem,
GuildActionKind, MemberActionItem, MemberActionKind, MessageActionItem, MessageActionKind,
};
use crate::{
config::{KeymapBinding, KeymapOptions},
discord::{ReactionEmoji, password_auth::MfaMethod},
};
mod actions;
mod chord;
mod composer;
mod runtime;
pub use actions::OptionsCategoryShortcut;
pub(in crate::tui) use actions::{
AttachmentViewerAction, ChannelSwitcherAction, ComposerAction, ComposerCompletionAction,
DashboardAction, DebugLogPopupAction, EmojiReactionPickerAction, GlobalAction,
LeaderActionMenuAction, LoginBusyAction, LoginGlobalAction, LoginMfaSelectAction,
LoginModeSelectAction, LoginPasswordInputAction, LoginTextInputAction,
MessageConfirmationAction, OptionsPopupAction, PaneFilterAction, PollVotePickerAction,
PopupListAction, ProfilePopupAction, ProfilePopupTabAction, ReactionUsersPopupAction,
ScrollAction, SearchPopupAction, SelectionAction, SelectionKeySet, UiAction,
};
pub(in crate::tui) use chord::KeyChord;
#[cfg(test)]
use chord::parse_angle_key;
use chord::{
KeySequence, char_chord, ctrl_chord, key_chord, key_chords_match_same_event,
modified_key_chord, parse_sequence_token,
};
use composer::ComposerKeyBindings;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct KeyBindings {
keymap: KeyMap,
action_shortcuts: ActionShortcutBindings,
composer: ComposerKeyBindings,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::tui) struct KeymapBindingSummary {
pub scope: &'static str,
pub action: String,
pub keys: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct KeyMap {
leader: KeyChord,
root: KeyMapNode,
specs: BTreeMap<UiAction, KeyMapActionSpec>,
group_titles: Vec<(Vec<KeyChord>, String)>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct KeyMapNode {
action: Option<KeyMapAction>,
children: Vec<KeyMapChild>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct KeyMapAction {
action: UiAction,
label: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct KeyMapActionSpec {
sequences: Vec<Vec<KeyChord>>,
label: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct ActionShortcutBindings {
guild: Vec<ActionShortcutBinding<GuildActionKind>>,
channel: Vec<ActionShortcutBinding<ChannelActionKind>>,
message: Vec<ActionShortcutBinding<MessageActionKind>>,
member: Vec<ActionShortcutBinding<MemberActionKind>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ActionShortcutBinding<K> {
kind: K,
shortcuts: Vec<KeyChord>,
description: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct KeyMapChild {
key: KeyChord,
node: KeyMapNode,
}
const MAX_KEYMAP_MAPPINGS: usize = 128;
const MAX_KEYMAP_SEQUENCE_CHORDS: usize = 16;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::tui) struct LeaderShortcutItem {
pub key: String,
pub label: String,
pub has_children: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::tui) enum KeyMapLookup {
Pending,
Action(UiAction),
}
impl KeyBindings {
pub fn from_options(keymap_options: &KeymapOptions) -> Self {
Self {
keymap: KeyMap::from_options_lossy(keymap_options),
action_shortcuts: ActionShortcutBindings::from_options_lossy(keymap_options),
composer: ComposerKeyBindings::from_options_lossy(keymap_options),
}
}
pub(in crate::tui) fn try_from_options(
keymap_options: &KeymapOptions,
) -> std::result::Result<Self, String> {
let keymap = KeyMap::try_from_options(keymap_options)?;
let action_shortcuts = ActionShortcutBindings::try_from_options(keymap_options)?;
let composer = ComposerKeyBindings::try_from_options(keymap_options)?;
Ok(Self {
keymap,
action_shortcuts,
composer,
})
}
}
impl ActionShortcutBindings {
fn from_options_lossy(options: &KeymapOptions) -> Self {
Self {
guild: parse_action_scope_lossy(
&options.guild_actions,
GuildActionKind::from_keymap_name,
),
channel: parse_action_scope_lossy(
&options.channel_actions,
ChannelActionKind::from_keymap_name,
),
message: parse_action_scope_lossy(
&options.message_actions,
MessageActionKind::from_keymap_name,
),
member: parse_action_scope_lossy(
&options.member_actions,
MemberActionKind::from_keymap_name,
),
}
}
fn try_from_options(options: &KeymapOptions) -> std::result::Result<Self, String> {
Ok(Self {
guild: parse_action_scope(
"keymap.guild_actions",
&options.guild_actions,
GuildActionKind::from_keymap_name,
)?,
channel: parse_action_scope(
"keymap.channel_actions",
&options.channel_actions,
ChannelActionKind::from_keymap_name,
)?,
message: parse_action_scope(
"keymap.message_actions",
&options.message_actions,
MessageActionKind::from_keymap_name,
)?,
member: parse_action_scope(
"keymap.member_actions",
&options.member_actions,
MemberActionKind::from_keymap_name,
)?,
})
}
fn binding_summaries(&self) -> Vec<KeymapBindingSummary> {
let mut summaries = Vec::new();
summaries.extend(self.guild.iter().map(|binding| KeymapBindingSummary {
scope: "keymap.guild_actions",
action: binding.kind.name().to_owned(),
keys: key_labels(&binding.shortcuts),
}));
summaries.extend(self.channel.iter().map(|binding| KeymapBindingSummary {
scope: "keymap.channel_actions",
action: binding.kind.name().to_owned(),
keys: key_labels(&binding.shortcuts),
}));
summaries.extend(self.message.iter().map(|binding| KeymapBindingSummary {
scope: "keymap.message_actions",
action: binding.kind.name().to_owned(),
keys: key_labels(&binding.shortcuts),
}));
summaries.extend(self.member.iter().map(|binding| KeymapBindingSummary {
scope: "keymap.member_actions",
action: binding.kind.name().to_owned(),
keys: key_labels(&binding.shortcuts),
}));
summaries
}
}
impl Default for KeyMap {
fn default() -> Self {
let leader = char_chord(' ');
let specs = default_keymap_specs(leader);
Self::from_specs(leader, &specs, default_keymap_group_titles(leader))
.expect("default keymap has no conflicts")
}
}
impl KeyMap {
fn from_options_lossy(options: &KeymapOptions) -> Self {
let Ok(leader) = keymap_leader(options) else {
return Self::default();
};
let group_titles = keymap_group_titles_with_defaults(
leader,
parse_keymap_groups_lossy(&options.groups, leader),
);
let mut configured_specs = BTreeMap::new();
for (action_name, binding) in options.mappings.iter().take(MAX_KEYMAP_MAPPINGS) {
let Some(action) = UiAction::from_keymap_name(action_name) else {
continue;
};
let Some(spec) = parse_keymap_binding_lossy(action_name, action, binding, leader)
else {
continue;
};
let previous = configured_specs.insert(action, spec);
if Self::from_specs(leader, &configured_specs, group_titles.clone()).is_err() {
if let Some(previous) = previous {
configured_specs.insert(action, previous);
} else {
configured_specs.remove(&action);
}
}
}
let mut specs = default_keymap_specs(leader);
remove_default_keymap_conflicts(&mut specs, &configured_specs);
specs.extend(configured_specs);
Self::from_specs(leader, &specs, group_titles).expect("default keymap has no conflicts")
}
fn try_from_options(options: &KeymapOptions) -> std::result::Result<Self, String> {
let leader = keymap_leader(options)?;
if options.mappings.len() > MAX_KEYMAP_MAPPINGS {
return Err(format!(
"keymap supports at most {MAX_KEYMAP_MAPPINGS} mappings"
));
}
let group_titles = keymap_group_titles_with_defaults(
leader,
parse_keymap_groups(&options.groups, leader)?,
);
let mut configured_specs = BTreeMap::new();
for (action_name, binding) in &options.mappings {
let action = UiAction::from_keymap_name(action_name)
.ok_or_else(|| format!("unknown keymap action `{action_name}`"))?;
let spec = parse_keymap_binding(action_name, action, binding, leader)?;
configured_specs.insert(action, spec);
}
Self::from_specs(leader, &configured_specs, group_titles.clone())?;
let mut specs = default_keymap_specs(leader);
remove_default_keymap_conflicts(&mut specs, &configured_specs);
specs.extend(configured_specs);
Self::from_specs(leader, &specs, group_titles)
}
fn from_specs(
leader: KeyChord,
specs: &BTreeMap<UiAction, KeyMapActionSpec>,
group_titles: Vec<(Vec<KeyChord>, String)>,
) -> std::result::Result<Self, String> {
let mut keymap = Self {
leader,
root: KeyMapNode::default(),
specs: specs.clone(),
group_titles,
};
for (action, spec) in specs {
if *action == UiAction::ClosePopup {
continue;
}
for sequence in &spec.sequences {
keymap.insert(sequence, *action, spec.label.clone())?;
}
}
Ok(keymap)
}
fn insert(
&mut self,
sequence: &[KeyChord],
action: UiAction,
label: String,
) -> std::result::Result<(), String> {
if sequence.is_empty() {
return Err(format!("{} keymap cannot be empty", action.name()));
}
let mut node = &mut self.root;
for (position, key) in sequence.iter().enumerate() {
if node.action.is_some() {
return Err(format!(
"{} conflicts with an existing shorter keymap prefix",
action.name()
));
}
let index = match node.children.iter().position(|child| child.key == *key) {
Some(index) => index,
None => {
node.children.push(KeyMapChild {
key: *key,
node: KeyMapNode::default(),
});
node.children.len() - 1
}
};
node = &mut node.children[index].node;
if position + 1 == sequence.len() && !node.children.is_empty() {
return Err(format!(
"{} conflicts with an existing longer keymap prefix",
action.name()
));
}
}
let new_action = KeyMapAction { action, label };
if let Some(previous) = node.action.replace(new_action)
&& previous.action != action
{
return Err(format!(
"{} conflicts with {}",
action.name(),
previous.action.name()
));
}
Ok(())
}
fn lookup(&self, sequence: &[KeyChord]) -> Option<KeyMapLookup> {
let node = self.node(sequence)?;
node.action
.as_ref()
.map(|action| KeyMapLookup::Action(action.action))
.or_else(|| (!node.children.is_empty()).then_some(KeyMapLookup::Pending))
}
fn children(&self, sequence: &[KeyChord]) -> Vec<LeaderShortcutItem> {
self.node(sequence)
.map(|node| {
node.children
.iter()
.map(|child| {
let mut child_sequence = sequence.to_vec();
child_sequence.push(child.key);
LeaderShortcutItem {
key: child.key.label(),
label: child
.node
.action
.as_ref()
.map(|action| action.label.clone())
.or_else(|| self.group_title(&child_sequence))
.unwrap_or_else(|| "prefix".to_owned()),
has_children: !child.node.children.is_empty(),
}
})
.collect()
})
.unwrap_or_default()
}
fn group_title(&self, sequence: &[KeyChord]) -> Option<String> {
self.group_titles
.iter()
.find(|(candidate, _)| candidate.as_slice() == sequence)
.map(|(_, title)| title.clone())
}
fn first_sequence_label(&self, action: UiAction) -> String {
self.specs
.get(&action)
.and_then(|spec| spec.sequences.first())
.and_then(|sequence| sequence.last())
.map(|chord| chord.label())
.unwrap_or_default()
}
fn node(&self, sequence: &[KeyChord]) -> Option<&KeyMapNode> {
let mut node = &self.root;
for key in sequence {
let event = KeyEvent::new(key.code, key.modifiers);
node = &node
.children
.iter()
.find(|child| child.key.matches(event))?
.node;
}
Some(node)
}
}
fn keymap_leader(options: &KeymapOptions) -> std::result::Result<KeyChord, String> {
let leader = match options.leader.as_deref() {
Some(value) => {
let sequence = KeySequence::parse(value, char_chord(' '))?;
let [leader] = sequence.0.as_slice() else {
return Err("leader must be a single key".to_owned());
};
*leader
}
None => char_chord(' '),
}
.canonical();
if is_reserved_keymap_chord(leader) {
return Err(format!("leader: {} is reserved", leader.label()));
}
Ok(leader)
}
fn parse_keymap_sequence(
action_name: &str,
sequence: &str,
leader: KeyChord,
) -> std::result::Result<KeySequence, String> {
let sequence =
KeySequence::parse(sequence, leader).map_err(|error| format!("{action_name}: {error}"))?;
if sequence.0.len() > MAX_KEYMAP_SEQUENCE_CHORDS {
return Err(format!(
"{action_name}: keymap sequence supports at most {MAX_KEYMAP_SEQUENCE_CHORDS} keys"
));
}
if sequence.0.len() == 1 {
if sequence.0.first().copied() == Some(leader) {
return Err(format!(
"{action_name}: single-key keymap sequences cannot use <leader>"
));
}
return Ok(sequence);
}
Ok(sequence)
}
fn parse_keymap_binding_lossy(
action_name: &str,
action: UiAction,
binding: &KeymapBinding,
leader: KeyChord,
) -> Option<KeyMapActionSpec> {
let sequences = binding
.keys
.iter()
.filter_map(|sequence| parse_keymap_sequence(action_name, sequence, leader).ok())
.map(|sequence| sequence.0)
.filter(|sequence| action != UiAction::ClosePopup || sequence.len() == 1)
.collect::<Vec<_>>();
(!sequences.is_empty()).then(|| KeyMapActionSpec {
sequences,
label: binding
.description
.clone()
.unwrap_or_else(|| action.label().to_owned()),
})
}
fn parse_keymap_binding(
action_name: &str,
action: UiAction,
binding: &KeymapBinding,
leader: KeyChord,
) -> std::result::Result<KeyMapActionSpec, String> {
let mut sequences = Vec::new();
for sequence in &binding.keys {
let sequence = parse_keymap_sequence(action_name, sequence, leader)?.0;
if action == UiAction::ClosePopup && sequence.len() != 1 {
return Err(format!(
"{action_name}: popup close key must be a single key"
));
}
sequences.push(sequence);
}
if sequences.is_empty() {
return Err(format!(
"{action_name}: keymap entry must include at least one key"
));
}
Ok(KeyMapActionSpec {
sequences,
label: binding
.description
.clone()
.unwrap_or_else(|| action.label().to_owned()),
})
}
fn parse_keymap_groups_lossy(
groups: &BTreeMap<String, String>,
leader: KeyChord,
) -> Vec<(Vec<KeyChord>, String)> {
groups
.iter()
.filter_map(|(sequence, title)| {
parse_keymap_group(sequence, title, leader)
.ok()
.map(|(sequence, title)| (sequence.0, title))
})
.collect()
}
fn default_keymap_group_titles(leader: KeyChord) -> Vec<(Vec<KeyChord>, String)> {
vec![(vec![leader, char_chord('v')], "Voice".to_owned())]
}
fn keymap_group_titles_with_defaults(
leader: KeyChord,
configured: Vec<(Vec<KeyChord>, String)>,
) -> Vec<(Vec<KeyChord>, String)> {
let mut titles = default_keymap_group_titles(leader);
for (sequence, title) in configured {
if let Some((_, existing)) = titles
.iter_mut()
.find(|(candidate, _)| candidate == &sequence)
{
*existing = title;
} else {
titles.push((sequence, title));
}
}
titles
}
fn parse_keymap_groups(
groups: &BTreeMap<String, String>,
leader: KeyChord,
) -> std::result::Result<Vec<(Vec<KeyChord>, String)>, String> {
let mut parsed = Vec::new();
for (sequence, title) in groups {
let (sequence, title) = parse_keymap_group(sequence, title, leader)?;
parsed.push((sequence.0, title));
}
Ok(parsed)
}
fn parse_keymap_group(
sequence: &str,
title: &str,
leader: KeyChord,
) -> std::result::Result<(KeySequence, String), String> {
let sequence = KeySequence::parse(sequence, leader)
.map_err(|error| format!("keymap group `{sequence}`: {error}"))?;
if sequence.0.is_empty() {
return Err("keymap group cannot be empty".to_owned());
}
Ok((sequence, title.to_owned()))
}
fn parse_action_scope_lossy<K: Copy + Eq>(
bindings: &BTreeMap<String, KeymapBinding>,
parse_kind: fn(&str) -> Option<K>,
) -> Vec<ActionShortcutBinding<K>> {
let mut parsed = Vec::new();
for (action_name, binding) in bindings.iter().take(MAX_KEYMAP_MAPPINGS) {
let Some(kind) = parse_kind(action_name) else {
continue;
};
let Some(binding) = parse_action_shortcut_binding_lossy(binding) else {
continue;
};
parsed.retain(|item: &ActionShortcutBinding<K>| item.kind != kind);
parsed.push(ActionShortcutBinding {
kind,
shortcuts: binding.0,
description: binding.1,
});
}
parsed
}
fn parse_action_scope<K: Copy + Eq>(
scope_name: &str,
bindings: &BTreeMap<String, KeymapBinding>,
parse_kind: fn(&str) -> Option<K>,
) -> std::result::Result<Vec<ActionShortcutBinding<K>>, String> {
if bindings.len() > MAX_KEYMAP_MAPPINGS {
return Err(format!(
"{scope_name} supports at most {MAX_KEYMAP_MAPPINGS} mappings"
));
}
let mut parsed = Vec::new();
for (action_name, binding) in bindings {
let kind = parse_kind(action_name)
.ok_or_else(|| format!("unknown {scope_name} action `{action_name}`"))?;
let (shortcuts, description) = parse_action_shortcut_binding(action_name, binding)?;
parsed.retain(|item: &ActionShortcutBinding<K>| item.kind != kind);
parsed.push(ActionShortcutBinding {
kind,
shortcuts,
description,
});
}
Ok(parsed)
}
fn parse_action_shortcut_binding_lossy(
binding: &KeymapBinding,
) -> Option<(Vec<KeyChord>, Option<String>)> {
let shortcuts = binding
.keys
.iter()
.filter_map(|key| parse_action_shortcut_key(key).ok())
.collect::<Vec<_>>();
(!shortcuts.is_empty()).then(|| (shortcuts, binding.description.clone()))
}
fn parse_action_shortcut_binding(
action_name: &str,
binding: &KeymapBinding,
) -> std::result::Result<(Vec<KeyChord>, Option<String>), String> {
let mut shortcuts = Vec::new();
for key in &binding.keys {
shortcuts.push(
parse_action_shortcut_key(key).map_err(|error| format!("{action_name}: {error}"))?,
);
}
if shortcuts.is_empty() {
return Err(format!(
"{action_name}: action shortcut entry must include at least one key"
));
}
Ok((shortcuts, binding.description.clone()))
}
fn parse_action_shortcut_key(value: &str) -> std::result::Result<KeyChord, String> {
let sequence = KeySequence::parse(value, char_chord(' '))?;
let [key] = sequence.0.as_slice() else {
return Err("action shortcut must be a single key".to_owned());
};
match key.code {
KeyCode::Char(value) if !value.is_whitespace() => Ok(key.canonical()),
_ => Err("action shortcut must be a character key".to_owned()),
}
}
impl UiAction {
pub(in crate::tui) fn from_name(name: &str) -> Option<Self> {
all_ui_actions()
.iter()
.copied()
.find(|action| action.name() == name)
}
fn from_keymap_name(name: &str) -> Option<Self> {
match name {
"ScrollMessageViewportDown" => Some(Self::ScrollViewportDown),
"ScrollMessageViewportUp" => Some(Self::ScrollViewportUp),
_ => Self::from_name(name),
}
}
pub(in crate::tui) fn name(self) -> &'static str {
match self {
UiAction::StartComposer => "StartComposer",
UiAction::OpenPaneFilter => "OpenPaneFilter",
UiAction::ClosePopup => "ClosePopup",
UiAction::FocusGuildPane => "FocusGuildPane",
UiAction::FocusChannelPane => "FocusChannelPane",
UiAction::FocusMessagePane => "FocusMessagePane",
UiAction::FocusMemberPane => "FocusMemberPane",
UiAction::SelectNext => "SelectNext",
UiAction::SelectPrevious => "SelectPrevious",
UiAction::CycleFocusNext => "CycleFocusNext",
UiAction::CycleFocusPrevious => "CycleFocusPrevious",
UiAction::HalfPageDown => "HalfPageDown",
UiAction::HalfPageUp => "HalfPageUp",
UiAction::ScrollViewportDown => "ScrollViewportDown",
UiAction::ScrollViewportUp => "ScrollViewportUp",
UiAction::JumpTop => "JumpTop",
UiAction::JumpBottom => "JumpBottom",
UiAction::ScrollHorizontalLeft => "ScrollHorizontalLeft",
UiAction::ScrollHorizontalRight => "ScrollHorizontalRight",
UiAction::ResizePaneLeft => "ResizePaneLeft",
UiAction::ResizePaneRight => "ResizePaneRight",
UiAction::Quit => "Quit",
UiAction::CopyMessage => "CopyMessage",
UiAction::ReactMessage => "ReactMessage",
UiAction::ReplyMessage => "ReplyMessage",
UiAction::DeleteMessage => "DeleteMessage",
UiAction::EditMessage => "EditMessage",
UiAction::OpenMessageUrl => "OpenMessageUrl",
UiAction::PlayMedia => "PlayMedia",
UiAction::ViewMessageAttachment => "ViewMessageAttachment",
UiAction::ShowMessageProfile => "ShowMessageProfile",
UiAction::PinMessage => "PinMessage",
UiAction::OpenThread => "OpenThread",
UiAction::ShowReactionUsers => "ShowReactionUsers",
UiAction::OpenPollVotePicker => "OpenPollVotePicker",
UiAction::GoToReferencedMessage => "GoToReferencedMessage",
UiAction::ToggleGuildPane => "ToggleGuildPane",
UiAction::ToggleChannelPane => "ToggleChannelPane",
UiAction::ToggleMemberPane => "ToggleMemberPane",
UiAction::OpenFocusedPaneAction => "OpenFocusedPaneAction",
UiAction::OpenCurrentUserProfile => "OpenCurrentUserProfile",
UiAction::OpenOptions => "OpenOptions",
UiAction::ChannelSwitcher => "ChannelSwitcher",
UiAction::OpenDisplayOptions => "OpenDisplayOptions",
UiAction::OpenComposerOptions => "OpenComposerOptions",
UiAction::OpenNotificationOptions => "OpenNotificationOptions",
UiAction::OpenVoiceOptions => "OpenVoiceOptions",
UiAction::VoiceDeafen => "VoiceDeafen",
UiAction::VoiceMute => "VoiceMute",
UiAction::VoiceLeave => "VoiceLeave",
}
}
fn label(self) -> &'static str {
match self {
UiAction::StartComposer => "start composer",
UiAction::OpenPaneFilter => "filter/search pane",
UiAction::ClosePopup => "close popup",
UiAction::FocusGuildPane => "focus Servers",
UiAction::FocusChannelPane => "focus Channels",
UiAction::FocusMessagePane => "focus Messages",
UiAction::FocusMemberPane => "focus Members",
UiAction::SelectNext => "select next",
UiAction::SelectPrevious => "select previous",
UiAction::CycleFocusNext => "focus next",
UiAction::CycleFocusPrevious => "focus previous",
UiAction::HalfPageDown => "half page down",
UiAction::HalfPageUp => "half page up",
UiAction::ScrollViewportDown => "scroll viewport down",
UiAction::ScrollViewportUp => "scroll viewport up",
UiAction::JumpTop => "jump top",
UiAction::JumpBottom => "jump bottom",
UiAction::ScrollHorizontalLeft => "scroll left",
UiAction::ScrollHorizontalRight => "scroll right",
UiAction::ResizePaneLeft => "resize pane left",
UiAction::ResizePaneRight => "resize pane right",
UiAction::Quit => "quit",
UiAction::CopyMessage => "copy message",
UiAction::ReactMessage => "react",
UiAction::ReplyMessage => "reply",
UiAction::DeleteMessage => "delete message",
UiAction::EditMessage => "edit message",
UiAction::OpenMessageUrl => "open URL",
UiAction::PlayMedia => "play media",
UiAction::ViewMessageAttachment => "view attachment",
UiAction::ShowMessageProfile => "show message sender profile",
UiAction::PinMessage => "pin message",
UiAction::OpenThread => "open thread",
UiAction::ShowReactionUsers => "show reacted users",
UiAction::OpenPollVotePicker => "choose poll votes",
UiAction::GoToReferencedMessage => "go to referenced message",
UiAction::ToggleGuildPane => "toggle Servers",
UiAction::ToggleChannelPane => "toggle Channels",
UiAction::ToggleMemberPane => "toggle Members",
UiAction::OpenFocusedPaneAction => "Actions",
UiAction::OpenCurrentUserProfile => "My profile",
UiAction::OpenOptions => "Options",
UiAction::ChannelSwitcher => "Switch channels",
UiAction::OpenDisplayOptions => "Display options",
UiAction::OpenComposerOptions => "Composer options",
UiAction::OpenNotificationOptions => "Notification options",
UiAction::OpenVoiceOptions => "Voice options",
UiAction::VoiceDeafen => "deafen voice",
UiAction::VoiceMute => "mute voice",
UiAction::VoiceLeave => "leave voice",
}
}
}
impl GuildActionKind {
fn from_keymap_name(name: &str) -> Option<Self> {
match name {
"MarkAsRead" => Some(Self::MarkAsRead),
"MuteServer" | "ToggleMute" => Some(Self::ToggleMute),
"LeaveServer" => Some(Self::LeaveServer),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::NoActionsYet => "NoActionsYet",
Self::MarkAsRead => "MarkAsRead",
Self::ToggleMute => "ToggleMute",
Self::LeaveServer => "LeaveServer",
}
}
}
impl ChannelActionKind {
fn from_keymap_name(name: &str) -> Option<Self> {
match name {
"JoinVoice" => Some(Self::JoinVoice),
"LeaveVoice" => Some(Self::LeaveVoice),
"ShowPinnedMessages" | "LoadPinnedMessages" => Some(Self::LoadPinnedMessages),
"ShowThreads" => Some(Self::ShowThreads),
"MarkAsRead" => Some(Self::MarkAsRead),
"MuteChannel" | "ToggleMute" => Some(Self::ToggleMute),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::JoinVoice => "JoinVoice",
Self::LeaveVoice => "LeaveVoice",
Self::LoadPinnedMessages => "ShowPinnedMessages",
Self::ShowThreads => "ShowThreads",
Self::MarkAsRead => "MarkAsRead",
Self::ToggleMute => "ToggleMute",
}
}
}
impl MemberActionKind {
fn from_keymap_name(name: &str) -> Option<Self> {
match name {
"ShowProfile" => Some(Self::ShowProfile),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::ShowProfile => "ShowProfile",
}
}
}
impl MessageActionKind {
fn from_keymap_name(name: &str) -> Option<Self> {
match name {
"CopyMessage" => Some(Self::CopyContent),
"ReactMessage" => Some(Self::OpenReactionPicker),
"ReplyMessage" => Some(Self::Reply),
"DeleteMessage" => Some(Self::OpenDeleteConfirmation),
"EditMessage" => Some(Self::Edit),
"OpenMessageUrl" => Some(Self::OpenUrl),
"PlayMedia" => Some(Self::PlayMedia),
"ViewMessageAttachment" => Some(Self::ViewAttachment),
"ShowMessageProfile" => Some(Self::ShowProfile),
"PinMessage" => Some(Self::OpenPinConfirmation),
"OpenThread" => Some(Self::OpenThread),
"ShowReactionUsers" => Some(Self::ShowReactionUsers),
"OpenPollVotePicker" => Some(Self::OpenPollVotePicker),
"GoToReferencedMessage" => Some(Self::GoToReferencedMessage),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::CopyContent => "CopyMessage",
Self::OpenReactionPicker => "ReactMessage",
Self::Reply => "ReplyMessage",
Self::OpenDeleteConfirmation => "DeleteMessage",
Self::Edit => "EditMessage",
Self::OpenUrl => "OpenMessageUrl",
Self::PlayMedia => "PlayMedia",
Self::ViewAttachment => "ViewMessageAttachment",
Self::ShowProfile => "ShowMessageProfile",
Self::OpenPinConfirmation => "PinMessage",
Self::OpenThread => "OpenThread",
Self::ShowReactionUsers => "ShowReactionUsers",
Self::OpenPollVotePicker => "OpenPollVotePicker",
Self::GoToReferencedMessage => "GoToReferencedMessage",
}
}
}
fn all_ui_actions() -> &'static [UiAction] {
&[
UiAction::StartComposer,
UiAction::OpenPaneFilter,
UiAction::ClosePopup,
UiAction::FocusGuildPane,
UiAction::FocusChannelPane,
UiAction::FocusMessagePane,
UiAction::FocusMemberPane,
UiAction::SelectNext,
UiAction::SelectPrevious,
UiAction::CycleFocusNext,
UiAction::CycleFocusPrevious,
UiAction::HalfPageDown,
UiAction::HalfPageUp,
UiAction::ScrollViewportDown,
UiAction::ScrollViewportUp,
UiAction::JumpTop,
UiAction::JumpBottom,
UiAction::ScrollHorizontalLeft,
UiAction::ScrollHorizontalRight,
UiAction::ResizePaneLeft,
UiAction::ResizePaneRight,
UiAction::Quit,
UiAction::CopyMessage,
UiAction::ReactMessage,
UiAction::ReplyMessage,
UiAction::DeleteMessage,
UiAction::EditMessage,
UiAction::OpenMessageUrl,
UiAction::PlayMedia,
UiAction::ViewMessageAttachment,
UiAction::ShowMessageProfile,
UiAction::PinMessage,
UiAction::OpenThread,
UiAction::ShowReactionUsers,
UiAction::OpenPollVotePicker,
UiAction::GoToReferencedMessage,
UiAction::ToggleGuildPane,
UiAction::ToggleChannelPane,
UiAction::ToggleMemberPane,
UiAction::OpenFocusedPaneAction,
UiAction::OpenCurrentUserProfile,
UiAction::OpenOptions,
UiAction::ChannelSwitcher,
UiAction::OpenDisplayOptions,
UiAction::OpenComposerOptions,
UiAction::OpenNotificationOptions,
UiAction::OpenVoiceOptions,
UiAction::VoiceDeafen,
UiAction::VoiceMute,
UiAction::VoiceLeave,
]
}
fn is_reserved_keymap_chord(chord: KeyChord) -> bool {
matches!(
chord.code,
KeyCode::Enter | KeyCode::Esc | KeyCode::Backspace | KeyCode::Delete
) || matches!((chord.code, chord.modifiers), (KeyCode::Char(value), KeyModifiers::CONTROL) if matches!(value.to_ascii_lowercase(), 'c' | 'n' | 'p'))
}
fn default_keymap_specs(leader: KeyChord) -> BTreeMap<UiAction, KeyMapActionSpec> {
let mut specs = BTreeMap::new();
for action in all_ui_actions() {
let action_sequences = match *action {
UiAction::StartComposer => vec![vec![char_chord('i')]],
UiAction::OpenPaneFilter => vec![vec![char_chord('/')]],
UiAction::ClosePopup => vec![vec![char_chord('q')]],
UiAction::FocusGuildPane => vec![vec![char_chord('1')]],
UiAction::FocusChannelPane => vec![vec![char_chord('2')]],
UiAction::FocusMessagePane => vec![vec![char_chord('3')]],
UiAction::FocusMemberPane => vec![vec![char_chord('4')]],
UiAction::SelectNext => vec![vec![char_chord('j')]],
UiAction::SelectPrevious => vec![vec![char_chord('k')]],
UiAction::CycleFocusNext => vec![
vec![key_chord(KeyCode::Tab)],
vec![char_chord('l')],
vec![key_chord(KeyCode::Right)],
],
UiAction::CycleFocusPrevious => vec![
vec![KeyChord {
code: KeyCode::Tab,
modifiers: KeyModifiers::SHIFT,
}],
vec![char_chord('h')],
vec![key_chord(KeyCode::Left)],
],
UiAction::HalfPageDown => vec![vec![ctrl_chord('d')]],
UiAction::HalfPageUp => vec![vec![ctrl_chord('u')]],
UiAction::ScrollViewportDown => vec![vec![char_chord('J')]],
UiAction::ScrollViewportUp => vec![vec![char_chord('K')]],
UiAction::JumpTop => vec![vec![char_chord('g'), char_chord('g')]],
UiAction::JumpBottom => vec![vec![char_chord('G')]],
UiAction::ScrollHorizontalLeft => vec![vec![char_chord('H')]],
UiAction::ScrollHorizontalRight => vec![vec![char_chord('L')]],
UiAction::ResizePaneLeft => vec![
vec![modified_key_chord(KeyCode::Char('h'), KeyModifiers::ALT)],
vec![modified_key_chord(KeyCode::Left, KeyModifiers::ALT)],
],
UiAction::ResizePaneRight => vec![
vec![modified_key_chord(KeyCode::Char('l'), KeyModifiers::ALT)],
vec![modified_key_chord(KeyCode::Right, KeyModifiers::ALT)],
],
UiAction::Quit => vec![vec![char_chord('q')]],
UiAction::CopyMessage => vec![vec![char_chord('y')]],
UiAction::ReactMessage => vec![vec![char_chord('r')]],
UiAction::ReplyMessage => vec![vec![char_chord('R')]],
UiAction::DeleteMessage => vec![vec![char_chord('d')]],
UiAction::EditMessage => vec![vec![char_chord('e')]],
UiAction::OpenMessageUrl => vec![vec![char_chord('o')]],
UiAction::PlayMedia => vec![vec![char_chord('x')]],
UiAction::ViewMessageAttachment => vec![vec![char_chord('v')]],
UiAction::ShowMessageProfile
| UiAction::PinMessage
| UiAction::OpenThread
| UiAction::ShowReactionUsers
| UiAction::OpenPollVotePicker
| UiAction::GoToReferencedMessage => Vec::new(),
UiAction::ToggleGuildPane => vec![vec![leader, char_chord('1')]],
UiAction::ToggleChannelPane => vec![vec![leader, char_chord('2')]],
UiAction::ToggleMemberPane => vec![vec![leader, char_chord('4')]],
UiAction::OpenFocusedPaneAction => vec![vec![leader, char_chord('a')]],
UiAction::OpenCurrentUserProfile => vec![vec![leader, char_chord('p')]],
UiAction::OpenOptions => vec![vec![leader, char_chord('o')]],
UiAction::ChannelSwitcher => vec![vec![leader, leader]],
UiAction::OpenDisplayOptions
| UiAction::OpenComposerOptions
| UiAction::OpenNotificationOptions
| UiAction::OpenVoiceOptions => Vec::new(),
UiAction::VoiceDeafen => vec![vec![leader, char_chord('v'), char_chord('d')]],
UiAction::VoiceMute => vec![vec![leader, char_chord('v'), char_chord('m')]],
UiAction::VoiceLeave => vec![vec![leader, char_chord('v'), char_chord('l')]],
};
if !action_sequences.is_empty() {
specs.insert(
*action,
KeyMapActionSpec {
sequences: action_sequences,
label: action.label().to_owned(),
},
);
}
}
specs
}
fn remove_default_keymap_conflicts(
defaults: &mut BTreeMap<UiAction, KeyMapActionSpec>,
configured: &BTreeMap<UiAction, KeyMapActionSpec>,
) {
defaults.retain(|default_action, default_spec| {
if configured.contains_key(default_action) {
return false;
}
default_spec.sequences.retain(|default_sequence| {
!configured
.iter()
.filter(|(configured_action, _)| **configured_action != UiAction::ClosePopup)
.any(|(_, configured_spec)| {
configured_spec.sequences.iter().any(|configured_sequence| {
keymap_sequences_conflict(default_sequence, configured_sequence)
})
})
});
!default_spec.sequences.is_empty()
});
}
fn keymap_sequences_conflict(left: &[KeyChord], right: &[KeyChord]) -> bool {
let left = canonical_keymap_sequence(left);
let right = canonical_keymap_sequence(right);
left.starts_with(&right) || right.starts_with(&left)
}
fn canonical_keymap_sequence(sequence: &[KeyChord]) -> Vec<KeyChord> {
sequence.iter().map(|chord| chord.canonical()).collect()
}
fn keymap_sequence_label(sequence: &[KeyChord], leader: Option<KeyChord>) -> String {
sequence
.iter()
.map(|chord| keymap_popup_key_label(*chord, leader))
.collect::<Vec<_>>()
.join(" ")
}
fn key_labels(keys: &[KeyChord]) -> Vec<String> {
keys.iter()
.map(|key| keymap_popup_key_label(*key, None))
.collect()
}
fn keymap_popup_key_label(key: KeyChord, leader: Option<KeyChord>) -> String {
if leader.is_some_and(|leader| key.matches_chord(leader)) {
"<leader>".to_owned()
} else {
key.title_label()
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
fn char_chords(values: &[char]) -> Vec<KeyChord> {
values.iter().copied().map(char_chord).collect()
}
#[test]
fn key_chord_parses_bare_keys_and_labels() {
let chord = KeyChord::from_str("k").expect("key should parse");
assert_eq!(chord.code, KeyCode::Char('k'));
assert_eq!(chord.modifiers, KeyModifiers::NONE);
assert_eq!(chord.label(), "k");
}
#[test]
fn key_chord_rejects_legacy_plus_modifier_syntax() {
let cases = ["ctrl+k", "control+k", "shift+tab", "alt+backspace"];
for value in cases {
assert!(
KeyChord::from_str(value).is_err(),
"{value} should not parse as a key chord"
);
}
}
#[test]
fn angle_key_parses_neovim_modifier_aliases() {
let cases = [
("C-f", KeyCode::Char('f'), KeyModifiers::CONTROL, "Ctrl+f"),
("C-w", KeyCode::Char('w'), KeyModifiers::CONTROL, "Ctrl+w"),
("S-f", KeyCode::Char('f'), KeyModifiers::SHIFT, "Shift+f"),
("A-f", KeyCode::Char('f'), KeyModifiers::ALT, "Alt+f"),
("M-f", KeyCode::Char('f'), KeyModifiers::ALT, "Alt+f"),
(
"C-S-f",
KeyCode::Char('f'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
"Ctrl+Shift+f",
),
];
for (value, code, modifiers, label) in cases {
let chord = parse_angle_key(value).expect("angle key should parse");
assert_eq!(chord.code, code);
assert_eq!(chord.modifiers, modifiers);
assert_eq!(chord.label(), label);
}
}
#[test]
fn angle_key_rejects_non_vim_modifier_spellings() {
let cases = [
"ctrl+w",
"C+w",
"ctrl-w",
"control-w",
"shift-f",
"alt-f",
"c-w",
];
for value in cases {
assert!(
parse_angle_key(value).is_err(),
"{value} should not parse as an angle key"
);
}
}
#[test]
fn keymap_rejects_legacy_modifier_syntax_in_mixed_tokens() {
let cases = [
"ctrl+u<C-w>",
"<C-w>ctrl+u",
"ctrl-w",
"C-w",
"alt-backspace",
];
for value in cases {
let keymap = KeymapOptions {
mappings: [("ChannelSwitcher".to_owned(), KeymapBinding::one(value))]
.into_iter()
.collect(),
..Default::default()
};
assert!(
KeyBindings::try_from_options(&keymap).is_err(),
"{value} should not parse as a keymap sequence"
);
}
}
#[test]
fn key_chord_preserves_uppercase_letter_keys() {
let chord = KeyChord::from_str("R").expect("uppercase key should parse");
assert_eq!(chord.code, KeyCode::Char('R'));
assert!(chord.matches(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)));
assert!(!chord.matches(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)));
}
#[test]
fn shifted_angle_letter_matches_shifted_terminal_event() {
let chord = parse_angle_key("S-f").expect("shifted key should parse");
assert!(chord.matches(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT)));
assert!(!chord.matches(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)));
}
#[test]
fn ui_action_names_match_future_colon_command_names() {
assert_eq!(
UiAction::from_name("SelectNext"),
Some(UiAction::SelectNext)
);
assert_eq!(
UiAction::from_name("SelectPrevious"),
Some(UiAction::SelectPrevious)
);
assert_eq!(
UiAction::from_name("ClosePopup"),
Some(UiAction::ClosePopup)
);
assert_eq!(
UiAction::from_name("ScrollViewportDown"),
Some(UiAction::ScrollViewportDown)
);
assert_eq!(
UiAction::from_name("ScrollViewportUp"),
Some(UiAction::ScrollViewportUp)
);
assert_eq!(
UiAction::from_name("ToggleGuildPane"),
Some(UiAction::ToggleGuildPane)
);
assert_eq!(UiAction::from_name("VoiceMute"), Some(UiAction::VoiceMute));
assert_eq!(
UiAction::from_name("VoiceLeave"),
Some(UiAction::VoiceLeave)
);
assert_eq!(
UiAction::from_name("ChannelSwitcher"),
Some(UiAction::ChannelSwitcher)
);
assert_eq!(
UiAction::from_name("OpenFocusedPaneAction"),
Some(UiAction::OpenFocusedPaneAction)
);
assert_eq!(UiAction::from_name("Quit"), Some(UiAction::Quit));
assert_eq!(UiAction::from_name("OpenVoiceActions"), None);
}
#[test]
fn default_keymap_uses_leader_v_voice_group() {
let key_bindings = KeyBindings::default();
let mut prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Pending)
);
prefix.push(KeyChord::from_str("v").expect("v should parse"));
let children = key_bindings.leader_keymap_children(&prefix);
assert!(
children
.iter()
.any(|item| item.key == "m" && item.label == "mute voice")
);
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::VoiceMute))
);
assert!(
key_bindings
.leader_keymap_children(&key_bindings.leader_keymap_prefix())
.iter()
.any(|item| item.key == "v" && item.label == "Voice" && item.has_children)
);
}
#[test]
fn close_popup_defaults_to_esc_and_q_and_can_be_remapped() {
let key_bindings = KeyBindings::default();
assert!(key_bindings.is_popup_close_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert!(
key_bindings.is_popup_close_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
);
let documented_keymap = KeymapOptions {
mappings: [("ClosePopup".to_owned(), KeymapBinding::one("q"))]
.into_iter()
.collect(),
..Default::default()
};
let documented_key_bindings = KeyBindings::try_from_options(&documented_keymap)
.expect("documented close popup keymap parses");
assert!(
documented_key_bindings
.is_popup_close_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
assert!(
documented_key_bindings
.is_popup_close_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
);
let keymap = KeymapOptions {
mappings: [(
"ClosePopup".to_owned(),
KeymapBinding {
keys: vec!["x".to_owned(), "<C-g>".to_owned()],
description: None,
},
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("close popup keymap parses");
assert!(key_bindings.is_popup_close_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert!(
!key_bindings.is_popup_close_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
);
assert!(
key_bindings.is_popup_close_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
);
assert!(
key_bindings
.is_popup_close_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL))
);
}
#[test]
fn default_keymap_uses_g_prefix() {
let key_bindings = KeyBindings::default();
let prefix = [KeyChord::from_str("g").expect("g should parse")];
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
Some(KeyMapLookup::Pending)
);
assert_eq!(key_bindings.keymap_prefix_title(&prefix), "g");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::JumpTop))
);
let children = key_bindings.leader_keymap_children(&prefix);
assert!(
children
.iter()
.any(|item| item.key == "g" && item.label == "jump top")
);
for menu_only_key in ['p', 't', 'u', 'c', 'P'] {
assert_eq!(
key_bindings.keymap_lookup_direct_key(KeyEvent::new(
KeyCode::Char(menu_only_key),
KeyModifiers::NONE
)),
None,
"{menu_only_key} should not be a default direct message action binding"
);
}
}
#[test]
fn scoped_action_keymaps_override_pane_action_shortcuts_and_labels() {
let keymap = KeymapOptions {
guild_actions: [(
"MuteServer".to_owned(),
KeymapBinding {
keys: vec!["x".to_owned()],
description: Some("mute server".to_owned()),
},
)]
.into_iter()
.collect(),
channel_actions: [("MuteChannel".to_owned(), KeymapBinding::one("x"))]
.into_iter()
.collect(),
message_actions: [("GoToReferencedMessage".to_owned(), KeymapBinding::one("g"))]
.into_iter()
.collect(),
member_actions: [("ShowProfile".to_owned(), KeymapBinding::one("s"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("scoped action keymaps should parse");
let guild_actions = [GuildActionItem {
kind: GuildActionKind::ToggleMute,
label: "Mute server".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.guild_action_shortcuts(&guild_actions, 0),
char_chords(&['x'])
);
assert_eq!(
key_bindings.guild_action_label(&guild_actions[0]),
"mute server"
);
let channel_actions = [ChannelActionItem {
kind: ChannelActionKind::ToggleMute,
label: "Mute channel".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.channel_action_shortcuts(&channel_actions, 0),
char_chords(&['x'])
);
let message_actions = [MessageActionItem {
kind: MessageActionKind::GoToReferencedMessage,
label: "Go to referenced message".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.message_action_shortcuts(&message_actions, 0),
char_chords(&['g'])
);
let member_actions = [MemberActionItem {
kind: MemberActionKind::ShowProfile,
label: "Show profile".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.member_action_shortcuts(&member_actions, 0),
char_chords(&['s'])
);
}
#[test]
fn message_action_menu_shortcuts_follow_message_action_scope() {
let keymap = KeymapOptions {
mappings: [
("ReplyMessage".to_owned(), KeymapBinding::one("n")),
("OpenThread".to_owned(), KeymapBinding::one("gt")),
]
.into_iter()
.collect(),
message_actions: [
(
"ReplyMessage".to_owned(),
KeymapBinding {
keys: vec!["m".to_owned()],
description: Some("reply from menu".to_owned()),
},
),
("OpenThread".to_owned(), KeymapBinding::one("T")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("message keymap should parse");
let actions = [MessageActionItem {
kind: MessageActionKind::Reply,
label: "reply".to_owned(),
enabled: true,
}];
assert_eq!(key_bindings.message_action_shortcut_label(&actions, 0), "m");
assert_eq!(
key_bindings.message_action_label(&actions[0]),
"reply from menu"
);
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
Some(UiAction::ReplyMessage)
);
let thread_actions = [MessageActionItem {
kind: MessageActionKind::OpenThread,
label: "open thread".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.message_action_shortcuts(&thread_actions, 0),
char_chords(&['T'])
);
let direct_thread_prefix = [KeyChord::from_str("g").expect("g should parse")];
assert_eq!(
key_bindings.keymap_lookup_with_key(
&direct_thread_prefix,
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::OpenThread))
);
assert_eq!(
key_bindings.dashboard_action_for_ui_action(UiAction::OpenThread, FocusPane::Messages),
Some(DashboardAction::MessageShortcut(
MessageActionKind::OpenThread
))
);
assert_eq!(
key_bindings.message_action_label(&thread_actions[0]),
"open thread"
);
}
#[test]
fn scoped_action_keymaps_reject_actions_outside_their_scope() {
let keymap = KeymapOptions {
guild_actions: [("MuteChannel".to_owned(), KeymapBinding::one("x"))]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&keymap).is_err());
}
#[test]
fn scoped_action_keymaps_try_later_keys_when_first_key_conflicts() {
let keymap = KeymapOptions {
channel_actions: [
("ShowPinnedMessages".to_owned(), KeymapBinding::one("x")),
(
"MuteChannel".to_owned(),
KeymapBinding {
keys: vec!["x".to_owned(), "z".to_owned()],
description: None,
},
),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("scoped action keymaps should parse");
let actions = [
ChannelActionItem {
kind: ChannelActionKind::LoadPinnedMessages,
label: "Show pinned messages".to_owned(),
enabled: true,
},
ChannelActionItem {
kind: ChannelActionKind::ToggleMute,
label: "Mute channel".to_owned(),
enabled: true,
},
];
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 0),
char_chords(&['1'])
);
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 1),
char_chords(&['z'])
);
}
#[test]
fn scoped_action_keymaps_keep_multiple_unique_aliases() {
let keymap = KeymapOptions {
channel_actions: [(
"MuteChannel".to_owned(),
KeymapBinding {
keys: vec!["x".to_owned(), "u".to_owned()],
description: None,
},
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("scoped action keymaps should parse");
let actions = [ChannelActionItem {
kind: ChannelActionKind::ToggleMute,
label: "Mute channel".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 0),
char_chords(&['x', 'u'])
);
}
#[test]
fn scoped_action_keymaps_keep_modified_shortcuts_distinct() {
let keymap = KeymapOptions {
channel_actions: [(
"MuteChannel".to_owned(),
KeymapBinding {
keys: vec!["u".to_owned(), "<C-u>".to_owned()],
description: None,
},
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("scoped action keymaps should parse");
let actions = [ChannelActionItem {
kind: ChannelActionKind::ToggleMute,
label: "Mute channel".to_owned(),
enabled: true,
}];
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 0),
vec![
KeyChord::from_str("u").expect("u should parse"),
parse_angle_key("C-u").expect("C-u should parse"),
]
);
}
#[test]
fn scoped_action_keymaps_do_not_reuse_conflicting_numeric_keys_as_fallbacks() {
let keymap = KeymapOptions {
channel_actions: [
("ShowPinnedMessages".to_owned(), KeymapBinding::one("1")),
("MuteChannel".to_owned(), KeymapBinding::one("1")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("scoped action keymaps should parse");
let actions = [
ChannelActionItem {
kind: ChannelActionKind::LoadPinnedMessages,
label: "Show pinned messages".to_owned(),
enabled: true,
},
ChannelActionItem {
kind: ChannelActionKind::ToggleMute,
label: "Mute channel".to_owned(),
enabled: true,
},
];
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 0),
char_chords(&['2'])
);
assert_eq!(
key_bindings.channel_action_shortcuts(&actions, 1),
char_chords(&['3'])
);
}
#[test]
fn composer_keymaps_override_default_composer_shortcuts() {
let keymap = KeymapOptions {
composer: [
("OpenEditor".to_owned(), KeymapBinding::one("<C-o>")),
(
"DeletePreviousWord".to_owned(),
KeymapBinding {
keys: vec!["<A-backspace>".to_owned()],
description: None,
},
),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("composer keymap parses");
assert_eq!(
key_bindings.composer_action(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)),
ComposerAction::OpenInEditor
);
assert_eq!(
key_bindings.composer_action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)),
ComposerAction::Ignore
);
assert_eq!(
key_bindings.composer_action(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)),
ComposerAction::DeletePreviousWord
);
assert_eq!(
key_bindings.composer_action(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)),
ComposerAction::Ignore
);
}
#[test]
fn profile_popup_editing_uses_configured_composer_text_keys() {
let keymap = KeymapOptions {
composer: [
("PasteClipboard".to_owned(), KeymapBinding::one("<C-y>")),
("Submit".to_owned(), KeymapBinding::one("<C-s>")),
("Close".to_owned(), KeymapBinding::one("<C-q>")),
(
"DeletePreviousWord".to_owned(),
KeymapBinding::one("<A-backspace>"),
),
("MoveCursorLeft".to_owned(), KeymapBinding::one("<A-left>")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("composer keymap parses");
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
true,
),
Some(ProfilePopupAction::PasteClipboard)
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
true,
),
Some(ProfilePopupAction::StartOrCommitEdit)
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL),
true,
),
Some(ProfilePopupAction::Close)
);
assert_eq!(
key_bindings
.profile_popup_action(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), true),
Some(ProfilePopupAction::DeletePreviousWord)
);
assert_eq!(
key_bindings
.profile_popup_action(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), true),
Some(ProfilePopupAction::MoveCursorLeft)
);
}
#[test]
fn composer_keymaps_reject_unknown_actions_and_conflicts() {
let unknown = KeymapOptions {
composer: [("MuteChannel".to_owned(), KeymapBinding::one("<C-m>"))]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&unknown).is_err());
let conflicting = KeymapOptions {
composer: [
("OpenEditor".to_owned(), KeymapBinding::one("<C-o>")),
("ClearInput".to_owned(), KeymapBinding::one("<C-o>")),
]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&conflicting).is_err());
let shifted_printable_conflict = KeymapOptions {
composer: [
("OpenEditor".to_owned(), KeymapBinding::one("A")),
("ClearInput".to_owned(), KeymapBinding::one("<S-a>")),
]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&shifted_printable_conflict).is_err());
}
#[test]
fn options_category_shortcut_labels_keep_contextual_defaults() {
let key_bindings = KeyBindings::default();
assert_eq!(
key_bindings.options_category_shortcut_label(OptionsCategoryShortcut::Display),
"d"
);
assert_eq!(
key_bindings.options_category_shortcut_label(OptionsCategoryShortcut::Composer),
"c"
);
assert_eq!(
key_bindings.options_category_shortcut_label(OptionsCategoryShortcut::Notifications),
"n"
);
assert_eq!(
key_bindings.options_category_shortcut_label(OptionsCategoryShortcut::Voice),
"v"
);
}
#[test]
fn keymap_parses_leader_start_composer_sequence() {
let keymap = KeymapOptions {
mappings: [("StartComposer".to_owned(), KeymapBinding::one("<leader>e"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("keymap should parse");
let leader_prefix = key_bindings.leader_keymap_prefix();
assert!(
key_bindings
.leader_keymap_children(&leader_prefix)
.iter()
.any(|item| item.key == "e" && item.label == "start composer")
);
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::StartComposer))
);
}
#[test]
fn keymap_parses_nested_leader_reply_sequence() {
let keymap = KeymapOptions {
mappings: [("ReplyMessage".to_owned(), KeymapBinding::one("<leader>m r"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("keymap should parse");
let mut prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Pending)
);
prefix.push(KeyChord::from_str("m").expect("m should parse"));
let children = key_bindings.leader_keymap_children(&prefix);
assert_eq!(children[0].key, "r");
assert_eq!(children[0].label, "reply");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::ReplyMessage))
);
}
#[test]
fn keymap_allows_navigation_keys_after_leader_prefix() {
let keymap = KeymapOptions {
mappings: [("StartComposer".to_owned(), KeymapBinding::one("<leader>j"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("leader j should parse");
let leader_prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::StartComposer))
);
}
#[test]
fn keymap_parses_adjacent_angle_key_after_leader() {
let keymap = KeymapOptions {
mappings: [(
"ChannelSwitcher".to_owned(),
KeymapBinding::one("<leader><space>"),
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("leader space should parse");
let leader_prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::ChannelSwitcher))
);
}
#[test]
fn keymap_parses_adjacent_control_key_after_leader() {
let keymap = KeymapOptions {
mappings: [(
"ChannelSwitcher".to_owned(),
KeymapBinding::one("<leader><C-w>"),
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("leader C-w should parse");
let leader_prefix = key_bindings.leader_keymap_prefix();
assert!(
key_bindings
.leader_keymap_children(&leader_prefix)
.iter()
.any(|item| item.key == "Ctrl+w" && item.label == "Switch channels")
);
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
),
Some(KeyMapLookup::Action(UiAction::ChannelSwitcher))
);
}
#[test]
fn keymap_parses_direct_sequence() {
let keymap = KeymapOptions {
mappings: [("ChannelSwitcher".to_owned(), KeymapBinding::one("<C-w>"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("direct key should parse");
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)),
Some(UiAction::ChannelSwitcher)
);
assert_eq!(
key_bindings.keymap_lookup_with_key(
&key_bindings.leader_keymap_prefix(),
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)
),
None
);
}
#[test]
fn default_leader_p_opens_current_user_profile() {
let key_bindings = KeyBindings::default();
let leader_prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
),
Some(KeyMapLookup::Action(UiAction::OpenCurrentUserProfile))
);
assert!(
key_bindings
.leader_keymap_children(&leader_prefix)
.iter()
.any(|item| item.key == "p" && item.label == "My profile")
);
}
#[test]
fn keymap_parses_compact_non_leader_prefix_sequence() {
let keymap = KeymapOptions {
mappings: [("ChannelSwitcher".to_owned(), KeymapBinding::one("<C-w>f"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("prefix should parse");
let prefix = [KeyChord {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
}];
assert_eq!(
key_bindings.keymap.lookup(&prefix),
Some(KeyMapLookup::Pending)
);
assert_eq!(key_bindings.keymap_prefix_title(&prefix), "<C-w>");
assert_eq!(key_bindings.leader_keymap_children(&prefix)[0].key, "f");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::ChannelSwitcher))
);
}
#[test]
fn keymap_parses_plain_compact_prefix_sequence() {
let keymap = KeymapOptions {
mappings: [("VoiceDeafen".to_owned(), KeymapBinding::one("fd"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("prefix should parse");
let prefix = [KeyChord::from_str("f").expect("f should parse")];
assert_eq!(
key_bindings.keymap.lookup(&prefix),
Some(KeyMapLookup::Pending)
);
assert_eq!(key_bindings.keymap_prefix_title(&prefix), "f");
assert_eq!(key_bindings.leader_keymap_children(&prefix)[0].key, "d");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::VoiceDeafen))
);
}
#[test]
fn keymap_configured_prefix_disables_conflicting_default_shortcut() {
let keymap = KeymapOptions {
mappings: [("VoiceDeafen".to_owned(), KeymapBinding::one("dd"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("prefix should parse");
let prefix = [KeyChord::from_str("d").expect("d should parse")];
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
Some(KeyMapLookup::Pending)
);
assert_eq!(key_bindings.leader_keymap_children(&prefix)[0].key, "d");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::VoiceDeafen))
);
}
#[test]
fn keymap_configured_mapping_removes_canonical_default_alias_conflicts() {
let keymap = KeymapOptions {
mappings: [("VoiceDeafen".to_owned(), KeymapBinding::one("<S-tab> d"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("prefix should parse");
let prefix = [parse_angle_key("S-tab").expect("S-tab should parse")];
assert_eq!(
key_bindings.keymap_lookup_root_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)),
Some(KeyMapLookup::Pending)
);
assert_eq!(key_bindings.leader_keymap_children(&prefix)[0].key, "d");
assert_eq!(
key_bindings.keymap_lookup_with_key(
&prefix,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::VoiceDeafen))
);
}
#[test]
fn keymap_can_remap_quit_action() {
let keymap = KeymapOptions {
mappings: [("Quit".to_owned(), KeymapBinding::one("x"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("quit should parse");
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::Quit))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
None
);
}
#[test]
fn keymap_can_remap_navigation_selection_actions() {
let keymap = KeymapOptions {
mappings: [
("SelectNext".to_owned(), KeymapBinding::one("n")),
("SelectPrevious".to_owned(), KeymapBinding::one("p")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("selection keys should parse");
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::SelectNext))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::SelectPrevious))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
None
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
SelectionKeySet::Navigation,
),
Some(SelectionAction::Next)
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
SelectionKeySet::Navigation,
),
Some(SelectionAction::Previous)
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
SelectionKeySet::Navigation,
),
None
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
SelectionKeySet::TextSafe,
),
None
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
SelectionKeySet::Navigation,
),
Some(SelectionAction::Next)
);
assert_eq!(
key_bindings.selection_action(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
SelectionKeySet::Navigation,
),
Some(SelectionAction::Previous)
);
}
#[test]
fn keymap_can_remap_viewport_scroll_actions() {
let keymap = KeymapOptions {
mappings: [
("ScrollViewportDown".to_owned(), KeymapBinding::one("N")),
("ScrollViewportUp".to_owned(), KeymapBinding::one("P")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("viewport scroll keys should parse");
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::ScrollViewportDown))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::ScrollViewportUp))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE)),
None
);
assert_eq!(
key_bindings
.dashboard_action_for_ui_action(UiAction::ScrollViewportDown, FocusPane::Messages,),
Some(DashboardAction::ScrollViewportDown)
);
assert_eq!(
key_bindings
.dashboard_action_for_ui_action(UiAction::ScrollViewportUp, FocusPane::Messages),
Some(DashboardAction::ScrollViewportUp)
);
assert_eq!(
key_bindings
.dashboard_action_for_ui_action(UiAction::ScrollViewportDown, FocusPane::Channels,),
Some(DashboardAction::ScrollViewportDown)
);
}
#[test]
fn keymap_accepts_legacy_message_viewport_scroll_action_names() {
let keymap = KeymapOptions {
mappings: [
(
"ScrollMessageViewportDown".to_owned(),
KeymapBinding::one("N"),
),
(
"ScrollMessageViewportUp".to_owned(),
KeymapBinding::one("P"),
),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("legacy scroll keys should parse");
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::ScrollViewportDown))
);
assert_eq!(
key_bindings
.keymap_lookup_root_key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)),
Some(KeyMapLookup::Action(UiAction::ScrollViewportUp))
);
}
#[test]
fn keymap_maps_message_shortcuts_to_message_actions() {
let key_bindings = KeyBindings::default();
let cases = [
(UiAction::CopyMessage, MessageActionKind::CopyContent),
(
UiAction::ReactMessage,
MessageActionKind::OpenReactionPicker,
),
(UiAction::ReplyMessage, MessageActionKind::Reply),
(
UiAction::DeleteMessage,
MessageActionKind::OpenDeleteConfirmation,
),
(UiAction::EditMessage, MessageActionKind::Edit),
(UiAction::OpenMessageUrl, MessageActionKind::OpenUrl),
(UiAction::PlayMedia, MessageActionKind::PlayMedia),
(
UiAction::ViewMessageAttachment,
MessageActionKind::ViewAttachment,
),
(UiAction::ShowMessageProfile, MessageActionKind::ShowProfile),
(UiAction::PinMessage, MessageActionKind::OpenPinConfirmation),
(UiAction::OpenThread, MessageActionKind::OpenThread),
(
UiAction::ShowReactionUsers,
MessageActionKind::ShowReactionUsers,
),
(
UiAction::OpenPollVotePicker,
MessageActionKind::OpenPollVotePicker,
),
(
UiAction::GoToReferencedMessage,
MessageActionKind::GoToReferencedMessage,
),
];
for (ui_action, message_action) in cases {
assert_eq!(
key_bindings.dashboard_action_for_ui_action(ui_action, FocusPane::Messages),
Some(DashboardAction::MessageShortcut(message_action))
);
assert_eq!(
key_bindings.dashboard_action_for_ui_action(ui_action, FocusPane::Channels),
None
);
}
}
#[test]
fn close_popup_rejects_multi_key_sequences() {
let keymap = KeymapOptions {
mappings: [("ClosePopup".to_owned(), KeymapBinding::one("zz"))]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&keymap).is_err());
}
#[test]
fn keymap_rejects_fixed_control_selection_keys() {
for key in ["<C-n>", "<C-p>", "<C-N>", "<C-P>"] {
let keymap = KeymapOptions {
mappings: [("StartComposer".to_owned(), KeymapBinding::one(key))]
.into_iter()
.collect(),
..Default::default()
};
assert!(
KeyBindings::try_from_options(&keymap).is_err(),
"{key} should stay reserved for row movement"
);
}
}
#[test]
fn default_keymap_maps_resize_shortcuts_to_dashboard_actions() {
let key_bindings = KeyBindings::default();
let cases = [
(
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT),
UiAction::ResizePaneLeft,
DashboardAction::ResizePaneLeft,
),
(
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
UiAction::ResizePaneLeft,
DashboardAction::ResizePaneLeft,
),
(
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT),
UiAction::ResizePaneRight,
DashboardAction::ResizePaneRight,
),
(
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
UiAction::ResizePaneRight,
DashboardAction::ResizePaneRight,
),
];
for (key, ui_action, dashboard_action) in cases {
assert_eq!(key_bindings.keymap_lookup_direct_key(key), Some(ui_action));
assert_eq!(
key_bindings.dashboard_action_for_ui_action(ui_action, FocusPane::Messages),
Some(dashboard_action)
);
}
}
#[test]
fn keymap_can_remap_resize_actions() {
let keymap = KeymapOptions {
mappings: [
("ResizePaneLeft".to_owned(), KeymapBinding::one("<C-h>")),
("ResizePaneRight".to_owned(), KeymapBinding::one("<C-l>")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("resize keys should parse");
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)),
Some(UiAction::ResizePaneLeft)
);
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)),
Some(UiAction::ResizePaneRight)
);
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT)),
None
);
assert_eq!(
key_bindings
.keymap_lookup_direct_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT)),
None
);
}
#[test]
fn keymap_uses_configured_description_for_shortcut_label() {
let keymap = KeymapOptions {
mappings: [(
"ChannelSwitcher".to_owned(),
KeymapBinding {
keys: vec!["<C-w>f".to_owned()],
description: Some("find channel".to_owned()),
},
)]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("description should parse");
let prefix = [KeyChord {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
}];
assert_eq!(
key_bindings.leader_keymap_children(&prefix)[0].label,
"find channel"
);
}
#[test]
fn keymap_uses_configured_group_title() {
let keymap = KeymapOptions {
groups: [("<C-w>".to_owned(), "Window".to_owned())]
.into_iter()
.collect(),
mappings: [("ChannelSwitcher".to_owned(), KeymapBinding::one("<C-w>f"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("group should parse");
let prefix = [KeyChord {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
}];
assert_eq!(key_bindings.keymap_prefix_title(&prefix), "Window");
}
#[test]
fn keymap_uses_default_group_title() {
let key_bindings = KeyBindings::default();
let prefix = [key_bindings.keymap.leader, char_chord('v')];
assert_eq!(key_bindings.keymap_prefix_title(&prefix), "Voice");
}
#[test]
fn lossy_keymap_keeps_valid_mapping_when_another_mapping_is_invalid() {
let keymap = KeymapOptions {
mappings: [
("StartComposer".to_owned(), KeymapBinding::one("<leader>e")),
("ReplyMessage".to_owned(), KeymapBinding::one("Enter")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::from_options(&keymap);
let leader_prefix = key_bindings.leader_keymap_prefix();
assert_eq!(
key_bindings.keymap_lookup_with_key(
&leader_prefix,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)
),
Some(KeyMapLookup::Action(UiAction::StartComposer))
);
}
#[test]
fn keymap_summaries_include_active_direct_and_composer_bindings() {
let key_bindings = KeyBindings::default();
let summaries = key_bindings.binding_summaries();
assert!(summaries.iter().any(|summary| {
summary.scope == "keymap"
&& summary.action == "StartComposer"
&& summary.keys.iter().any(|key| key == "i")
}));
assert!(summaries.iter().any(|summary| {
summary.scope == "keymap.composer"
&& summary.action == "Submit"
&& summary.keys.iter().any(|key| key == "<Enter>")
}));
assert!(summaries.iter().any(|summary| {
summary.scope == "keymap"
&& summary.action == "ToggleGuildPane"
&& summary.keys.iter().any(|key| key == "<leader> 1")
}));
}
#[test]
fn keymap_uses_custom_leader_key() {
let keymap = KeymapOptions {
leader: Some("<C-k>".to_owned()),
mappings: [("StartComposer".to_owned(), KeymapBinding::one("<leader>e"))]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings =
KeyBindings::try_from_options(&keymap).expect("custom leader should parse");
assert!(
key_bindings.is_leader_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL))
);
}
#[test]
fn profile_popup_uses_configured_selection_and_scroll_keys() {
let keymap = KeymapOptions {
mappings: [
("SelectNext".to_owned(), KeymapBinding::one("n")),
("SelectPrevious".to_owned(), KeymapBinding::one("p")),
("ScrollViewportDown".to_owned(), KeymapBinding::one("N")),
("ScrollViewportUp".to_owned(), KeymapBinding::one("P")),
]
.into_iter()
.collect(),
..Default::default()
};
let key_bindings = KeyBindings::try_from_options(&keymap).expect("keymap should parse");
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
false,
),
Some(ProfilePopupAction::NextField)
);
assert_eq!(
key_bindings
.profile_popup_action(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), false),
Some(ProfilePopupAction::StartOrCommitEdit)
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
false,
),
Some(ProfilePopupAction::PreviousField)
);
assert_eq!(
key_bindings
.profile_popup_action(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), false),
None
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE),
false,
),
Some(ProfilePopupAction::Scroll(ScrollAction::Down))
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE),
false,
),
Some(ProfilePopupAction::Scroll(ScrollAction::Up))
);
assert_eq!(
key_bindings.profile_popup_action(
KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE),
false,
),
None
);
}
#[test]
fn keymap_rejects_overlong_sequences() {
let long_sequence = std::iter::once("<leader>".to_owned())
.chain((0..MAX_KEYMAP_SEQUENCE_CHORDS).map(|_| "x".to_owned()))
.collect::<Vec<_>>()
.join(" ");
let keymap = KeymapOptions {
mappings: [(
"StartComposer".to_owned(),
KeymapBinding::one(long_sequence),
)]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&keymap).is_err());
}
#[test]
fn keymap_rejects_ambiguous_leaf_and_prefix_mappings() {
let keymap = KeymapOptions {
mappings: [
("StartComposer".to_owned(), KeymapBinding::one("<leader>m")),
("ReplyMessage".to_owned(), KeymapBinding::one("<leader>m r")),
]
.into_iter()
.collect(),
..Default::default()
};
assert!(KeyBindings::try_from_options(&keymap).is_err());
}
}