use std::process::Command;
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub trait KeySender: Send + Sync {
fn send(&self, session: &str, key: &EncodedKey) -> Result<()>;
fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Up,
Down,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedKey {
pub args: Vec<String>,
}
impl EncodedKey {
fn named(name: impl Into<String>) -> Self {
Self {
args: vec![name.into()],
}
}
fn literal(text: impl Into<String>) -> Self {
Self {
args: vec!["-l".into(), text.into()],
}
}
}
pub fn encode_key(ev: KeyEvent) -> Option<EncodedKey> {
let ctrl = ev.modifiers.contains(KeyModifiers::CONTROL);
let alt = ev.modifiers.contains(KeyModifiers::ALT);
let shift = ev.modifiers.contains(KeyModifiers::SHIFT);
let prefix = match (ctrl, alt) {
(true, true) => "C-M-",
(true, false) => "C-",
(false, true) => "M-",
(false, false) => "",
};
match ev.code {
KeyCode::Char(c) => {
if ctrl || alt {
let normalised = c.to_ascii_lowercase();
Some(EncodedKey::named(format!("{prefix}{normalised}")))
} else if c == ';' {
Some(EncodedKey::literal("\\;".to_string()))
} else {
Some(EncodedKey::literal(c.to_string()))
}
}
KeyCode::Enter => Some(EncodedKey::named(format!("{prefix}Enter"))),
KeyCode::Tab => {
if shift && !ctrl && !alt {
Some(EncodedKey::named("BTab"))
} else {
Some(EncodedKey::named(format!("{prefix}Tab")))
}
}
KeyCode::BackTab => Some(EncodedKey::named("BTab")),
KeyCode::Backspace => Some(EncodedKey::named(format!("{prefix}BSpace"))),
KeyCode::Delete => Some(EncodedKey::named(format!("{prefix}DC"))),
KeyCode::Up => Some(EncodedKey::named(format!("{prefix}Up"))),
KeyCode::Down => Some(EncodedKey::named(format!("{prefix}Down"))),
KeyCode::Left => Some(EncodedKey::named(format!("{prefix}Left"))),
KeyCode::Right => Some(EncodedKey::named(format!("{prefix}Right"))),
KeyCode::Home => Some(EncodedKey::named(format!("{prefix}Home"))),
KeyCode::End => Some(EncodedKey::named(format!("{prefix}End"))),
KeyCode::PageUp => Some(EncodedKey::named(format!("{prefix}PPage"))),
KeyCode::PageDown => Some(EncodedKey::named(format!("{prefix}NPage"))),
KeyCode::Insert => Some(EncodedKey::named(format!("{prefix}IC"))),
KeyCode::F(n) if (1..=12).contains(&n) => Some(EncodedKey::named(format!("{prefix}F{n}"))),
KeyCode::Esc => Some(EncodedKey::named("Escape")),
_ => None,
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TmuxKeySender;
impl KeySender for TmuxKeySender {
fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
let mut cmd = Command::new("tmux");
cmd.args(["send-keys", "-t", session]);
for arg in &key.args {
cmd.arg(arg);
}
let output = cmd
.output()
.with_context(|| format!("invoke tmux send-keys -t {session}"))?;
let _ = output;
Ok(())
}
fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
if matches!(direction, ScrollDirection::Up) {
let _ = Command::new("tmux")
.args(["copy-mode", "-e", "-t", session])
.output()
.with_context(|| format!("invoke tmux copy-mode -e -t {session}"))?;
}
let cmd = match direction {
ScrollDirection::Up => "scroll-up",
ScrollDirection::Down => "scroll-down",
};
let _ = Command::new("tmux")
.args(["send-keys", "-t", session, "-X", cmd])
.output()
.with_context(|| format!("invoke tmux send-keys -t {session} -X {cmd}"))?;
Ok(())
}
}
pub mod test_support {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
pub struct MockKeySender {
pub calls: Mutex<Vec<(String, EncodedKey)>>,
pub scroll_calls: Mutex<Vec<(String, ScrollDirection)>>,
}
impl KeySender for MockKeySender {
fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
self.calls
.lock()
.unwrap()
.push((session.to_string(), key.clone()));
Ok(())
}
fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
self.scroll_calls
.lock()
.unwrap()
.push((session.to_string(), direction));
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEventKind, KeyEventState};
fn k(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn printable_char_uses_literal_form() {
let enc = encode_key(k(KeyCode::Char('a'), KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec!["-l".to_string(), "a".to_string()]);
}
#[test]
fn shifted_printable_char_keeps_literal_form() {
let enc = encode_key(k(KeyCode::Char('A'), KeyModifiers::SHIFT)).unwrap();
assert_eq!(enc.args, vec!["-l".to_string(), "A".to_string()]);
}
#[test]
fn punctuation_uses_literal_form() {
let enc = encode_key(k(KeyCode::Char('~'), KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec!["-l".to_string(), "~".to_string()]);
}
#[test]
fn semicolon_is_backslash_escaped_in_literal_form() {
let enc = encode_key(k(KeyCode::Char(';'), KeyModifiers::NONE)).unwrap();
assert_eq!(
enc.args,
vec!["-l".to_string(), "\\;".to_string()],
"bare `;` must be sent as `\\;` so tmux's command parser \
doesn't eat it as a separator"
);
}
#[test]
fn ctrl_c_passes_through_as_named_chord() {
let enc = encode_key(k(KeyCode::Char('c'), KeyModifiers::CONTROL)).unwrap();
assert_eq!(enc.args, vec!["C-c".to_string()]);
}
#[test]
fn ctrl_uppercase_normalises_to_lowercase() {
let enc = encode_key(k(
KeyCode::Char('C'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
))
.unwrap();
assert_eq!(enc.args, vec!["C-c".to_string()]);
}
#[test]
fn alt_char_uses_named_form() {
let enc = encode_key(k(KeyCode::Char('x'), KeyModifiers::ALT)).unwrap();
assert_eq!(enc.args, vec!["M-x".to_string()]);
}
#[test]
fn ctrl_alt_char_combines_prefixes() {
let enc = encode_key(k(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
))
.unwrap();
assert_eq!(enc.args, vec!["C-M-a".to_string()]);
}
#[test]
fn enter_named() {
let enc = encode_key(k(KeyCode::Enter, KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec!["Enter".to_string()]);
}
#[test]
fn backspace_named() {
let enc = encode_key(k(KeyCode::Backspace, KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec!["BSpace".to_string()]);
}
#[test]
fn arrows_named() {
for (code, name) in [
(KeyCode::Up, "Up"),
(KeyCode::Down, "Down"),
(KeyCode::Left, "Left"),
(KeyCode::Right, "Right"),
] {
let enc = encode_key(k(code, KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec![name.to_string()], "encoding {code:?}");
}
}
#[test]
fn shift_tab_uses_btab() {
let from_tab = encode_key(k(KeyCode::Tab, KeyModifiers::SHIFT)).unwrap();
assert_eq!(from_tab.args, vec!["BTab".to_string()]);
let from_backtab = encode_key(k(KeyCode::BackTab, KeyModifiers::NONE)).unwrap();
assert_eq!(from_backtab.args, vec!["BTab".to_string()]);
}
#[test]
fn function_keys_named() {
let enc = encode_key(k(KeyCode::F(7), KeyModifiers::NONE)).unwrap();
assert_eq!(enc.args, vec!["F7".to_string()]);
let ctrl_f4 = encode_key(k(KeyCode::F(4), KeyModifiers::CONTROL)).unwrap();
assert_eq!(ctrl_f4.args, vec!["C-F4".to_string()]);
}
#[test]
fn page_keys_use_tmux_short_names() {
assert_eq!(
encode_key(k(KeyCode::PageUp, KeyModifiers::NONE))
.unwrap()
.args,
vec!["PPage".to_string()]
);
assert_eq!(
encode_key(k(KeyCode::PageDown, KeyModifiers::NONE))
.unwrap()
.args,
vec!["NPage".to_string()]
);
}
#[test]
fn mock_records_session_and_key() {
use test_support::MockKeySender;
let mock = MockKeySender::default();
let enc = encode_key(k(KeyCode::Char('h'), KeyModifiers::NONE)).unwrap();
mock.send("t-p-a", &enc).unwrap();
let calls = mock.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "t-p-a");
assert_eq!(calls[0].1, enc);
}
#[test]
fn mock_records_scroll_session_and_direction() {
use test_support::MockKeySender;
let mock = MockKeySender::default();
mock.scroll("t-p-a", ScrollDirection::Up).unwrap();
mock.scroll("t-p-a", ScrollDirection::Down).unwrap();
let calls = mock.scroll_calls.lock().unwrap();
assert_eq!(
*calls,
vec![
("t-p-a".to_string(), ScrollDirection::Up),
("t-p-a".to_string(), ScrollDirection::Down),
]
);
}
}