use crate::view::Frame;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const DEFAULT_PREFIX: u8 = 1;
macro_rules! actions {
($($variant:ident => $group:ident, $label:literal, $key:literal;)+) => {
#[derive(
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum Action {
$($variant,)+
}
impl Action {
pub const ALL: &'static [Self] = &[$(Self::$variant,)+];
pub const fn group(self) -> Group {
match self {
$(Self::$variant => Group::$group,)+
}
}
pub const fn label(self) -> &'static str {
match self {
$(Self::$variant => $label,)+
}
}
}
pub const DEFAULT_BINDINGS: &[BindingSpec] = &[
$(BindingSpec { key: $key, action: Action::$variant },)+
];
};
}
actions! {
SplitSide => Panes, "split side by side", b'|';
SplitStack => Panes, "split stacked", b'-';
FocusLeft => Focus, "focus left", b'h';
FocusRight => Focus, "focus right", b'l';
FocusUp => Focus, "focus up", b'k';
FocusDown => Focus, "focus down", b'j';
ClosePane => Panes, "close pane", b'x';
ResizeMode => Panes, "resize split", b'r';
CopyMode => Panes, "history and copy", b'[';
NewTab => Tabs, "new tab", b't';
NextTab => Tabs, "next tab", b'n';
PreviousTab => Tabs, "previous tab", b'p';
ChooseTab => Tabs, "choose tab", b'w';
RenameTab => Tabs, "rename tab", b',';
CloseTab => Tabs, "close tab", b'c';
ChooseWorkspace => Workspaces, "choose workspace", b's';
NewWorkspace => Workspaces, "new workspace", b'a';
Detach => Session, "detach", b'd';
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Group {
Panes,
Focus,
Tabs,
Workspaces,
Session,
}
impl Group {
pub const fn label(self) -> &'static str {
match self {
Self::Panes => "Panes",
Self::Focus => "Focus",
Self::Tabs => "Tabs",
Self::Workspaces => "Workspaces",
Self::Session => "Session",
}
}
}
pub struct BindingSpec {
pub key: u8,
pub action: Action,
}
impl Action {
pub fn unavailable(self, frame: &Frame, workspaces: bool) -> Option<&'static str> {
let visible = frame.layout.len();
let live_focus = frame.focused_pane().is_some_and(|pane| pane.exit.is_none());
match self {
Self::Detach | Self::NewTab => None,
Self::ChooseWorkspace | Self::NewWorkspace => {
(!workspaces).then_some("Not available through this attachment")
}
Self::ClosePane | Self::CopyMode => (!live_focus).then_some("No live pane"),
Self::SplitSide | Self::SplitStack => {
frame.focused.is_none().then_some("No active pane")
}
Self::NextTab | Self::PreviousTab => (frame.tabs.len() < 2).then_some("Only one tab"),
Self::ChooseTab | Self::RenameTab | Self::CloseTab => {
frame.active_tab.is_none().then_some("No active tab")
}
Self::FocusLeft
| Self::FocusRight
| Self::FocusUp
| Self::FocusDown
| Self::ResizeMode => (visible < 2).then_some("No split to adjust"),
}
}
}
#[must_use]
pub const fn canonical_key(key: u8) -> u8 {
match key {
b'A'..=b'Z' => key.to_ascii_lowercase(),
b'!' => b'1',
b'@' => b'2',
b'#' => b'3',
b'$' => b'4',
b'%' => b'5',
b'^' => b'6',
b'&' => b'7',
b'*' => b'8',
b'(' => b'9',
b')' => b'0',
b'_' => b'-',
b'+' => b'=',
b'{' => b'[',
b'}' => b']',
b'|' => b'\\',
b':' => b';',
b'"' => b'\'',
b'<' => b',',
b'>' => b'.',
b'?' => b'/',
b'~' => b'`',
_ => key,
}
}
pub fn key_name(key: u8) -> String {
match key {
0 => "C-@".to_owned(),
1..=26 => format!("C-{}", char::from(key + b'a' - 1)),
27 => "Esc".to_owned(),
28..=31 => format!("C-{}", char::from(key + b'@')),
32 => "Space".to_owned(),
127 => "DEL".to_owned(),
128..=255 => format!("0x{key:02x}"),
_ => char::from(key).to_string(),
}
}
pub fn key_byte(value: &str) -> Option<u8> {
if let Some(value) = value.strip_prefix("C-")
&& value.len() == 1
{
return value
.bytes()
.next()
.map(|byte| byte.to_ascii_uppercase() & 0x1f);
}
match value {
"Esc" => return Some(27),
"Space" => return Some(32),
"DEL" => return Some(127),
_ => {}
}
if let Some(hex) = value.strip_prefix("0x")
&& hex.len() == 2
{
return u8::from_str_radix(hex, 16).ok();
}
(value.len() == 1)
.then(|| value.as_bytes().first().copied())
.flatten()
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientBindings {
prefix: u8,
#[serde(deserialize_with = "known_actions")]
bindings: BTreeMap<u8, Action>,
}
fn known_actions<'de, D>(deserializer: D) -> Result<BTreeMap<u8, Action>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Maybe {
Known(Action),
Unknown(serde::de::IgnoredAny),
}
let raw: BTreeMap<u8, Maybe> = BTreeMap::deserialize(deserializer)?;
Ok(raw
.into_iter()
.filter_map(|(key, action)| match action {
Maybe::Known(action) => Some((key, action)),
Maybe::Unknown(_) => None,
})
.collect())
}
impl ClientBindings {
pub fn new(prefix: u8, bindings: impl IntoIterator<Item = (u8, Action)>) -> Self {
let mut map = BTreeMap::new();
for (key, action) in bindings {
if canonical_key(key) != canonical_key(prefix) {
map.insert(key, action);
}
}
Self {
prefix,
bindings: map,
}
}
#[must_use]
pub fn prefix(&self) -> u8 {
self.prefix
}
#[must_use]
pub fn action(&self, key: u8) -> Option<Action> {
if let Some(action) = self.bindings.get(&key) {
return Some(*action);
}
let wanted = canonical_key(key);
self.bindings
.iter()
.find(|(bound, _)| canonical_key(**bound) == wanted)
.map(|(_, action)| *action)
}
pub fn entries(&self) -> Vec<(u8, Action)> {
let mut entries: Vec<_> = self
.bindings
.iter()
.map(|(key, action)| (*key, *action))
.collect();
entries.sort_by_key(|(key, action)| {
let rank = Action::ALL
.iter()
.position(|a| a == action)
.unwrap_or(usize::MAX);
(action.group(), rank, *key)
});
entries
}
}
impl Default for ClientBindings {
fn default() -> Self {
Self::new(
DEFAULT_PREFIX,
DEFAULT_BINDINGS.iter().map(|spec| (spec.key, spec.action)),
)
}
}
pub fn configured_bindings(config: &crate::config::Config) -> anyhow::Result<ClientBindings> {
config.validate()?;
let byte =
|key: &str| key_byte(key).ok_or_else(|| anyhow::anyhow!("`{key}` must encode one byte"));
let prefix = byte(&config.prefix)?;
let bindings = config
.bindings
.iter()
.map(|(key, action)| Ok((byte(key)?, *action)))
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(ClientBindings::new(prefix, bindings))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_action_is_bound_once_by_default_and_labelled() {
let bindings = ClientBindings::default();
for action in Action::ALL {
assert!(
bindings.entries().iter().any(|(_, bound)| bound == action),
"{action:?} unbound"
);
assert!(!action.label().is_empty());
}
assert_eq!(bindings.entries().len(), Action::ALL.len());
assert_eq!(bindings.action(bindings.prefix()), None);
}
#[test]
fn unknown_actions_from_another_release_are_dropped_not_fatal() {
let json = r#"{"prefix":1,"bindings":{"63":"help","120":"close-pane","88":"close-tab"}}"#;
let bindings: ClientBindings = serde_json::from_str(json).unwrap_or_default();
assert_eq!(bindings.action(b'x'), Some(Action::ClosePane));
assert_eq!(
bindings.action(b'X'),
Some(Action::CloseTab),
"an older server's shifted binding still matches exactly first"
);
assert_eq!(bindings.action(b'?'), None, "unknown `help` dropped");
assert_eq!(bindings.entries().len(), 2);
}
#[test]
fn key_notation_round_trips() {
assert_eq!(key_byte("C-a"), Some(1));
assert_eq!(key_byte("C-A"), Some(1));
assert_eq!(key_byte("Esc"), Some(27));
assert_eq!(key_byte("|"), Some(b'|'));
assert_eq!(key_byte("ab"), None);
assert_eq!(key_byte(""), None);
for key in [1_u8, 27, 32, 127, b'x', 200] {
assert_eq!(key_byte(&key_name(key)), Some(key), "{key}");
}
}
#[test]
fn availability_follows_frame_context() {
let frame = Frame::default();
assert!(Action::SplitSide.unavailable(&frame, true).is_some());
assert!(Action::ResizeMode.unavailable(&frame, true).is_some());
assert!(Action::NextTab.unavailable(&frame, true).is_some());
assert!(Action::ChooseWorkspace.unavailable(&frame, false).is_some());
assert!(Action::ChooseWorkspace.unavailable(&frame, true).is_none());
assert!(Action::Detach.unavailable(&frame, false).is_none());
}
#[test]
fn keys_match_without_shift_and_shifted_twins_are_rejected() {
let bindings = ClientBindings::default();
assert_eq!(
bindings.action(b'\\'),
Some(Action::SplitSide),
"\\ is | without Shift"
);
assert_eq!(bindings.action(b'|'), Some(Action::SplitSide));
assert_eq!(bindings.action(b'_'), Some(Action::SplitStack));
assert_eq!(bindings.action(b'X'), Some(Action::ClosePane));
assert_eq!(bindings.action(b'C'), Some(Action::CloseTab));
assert_eq!(bindings.action(b'A'), Some(Action::NewWorkspace));
assert_eq!(
bindings.action(b'?'),
None,
"no help action; ? is an unknown key"
);
assert_eq!(canonical_key(b'{'), b'[');
assert_eq!(canonical_key(1), 1);
let mut config = crate::config::Config::default();
config.bindings.insert("X".into(), Action::CloseTab);
assert!(
configured_bindings(&config).is_err(),
"x and X are the same key"
);
let mut config = crate::config::Config {
prefix: "b".into(),
..Default::default()
};
config.bindings.insert("B".into(), Action::Detach);
assert!(configured_bindings(&config).is_err());
}
}