1use std::process::Command;
12
13use anyhow::{Context, Result};
14use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15
16pub trait KeySender: Send + Sync {
23 fn send(&self, session: &str, key: &EncodedKey) -> Result<()>;
24
25 fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()>;
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ScrollDirection {
38 Up,
39 Down,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct EncodedKey {
48 pub args: Vec<String>,
53}
54
55impl EncodedKey {
56 fn named(name: impl Into<String>) -> Self {
57 Self {
58 args: vec![name.into()],
59 }
60 }
61
62 fn literal(text: impl Into<String>) -> Self {
63 Self {
64 args: vec!["-l".into(), text.into()],
65 }
66 }
67}
68
69pub fn encode_key(ev: KeyEvent) -> Option<EncodedKey> {
82 let ctrl = ev.modifiers.contains(KeyModifiers::CONTROL);
83 let alt = ev.modifiers.contains(KeyModifiers::ALT);
84 let shift = ev.modifiers.contains(KeyModifiers::SHIFT);
85
86 let prefix = match (ctrl, alt) {
88 (true, true) => "C-M-",
89 (true, false) => "C-",
90 (false, true) => "M-",
91 (false, false) => "",
92 };
93
94 match ev.code {
95 KeyCode::Char(c) => {
99 if ctrl || alt {
100 let normalised = c.to_ascii_lowercase();
103 Some(EncodedKey::named(format!("{prefix}{normalised}")))
104 } else if c == ';' {
105 Some(EncodedKey::literal("\\;".to_string()))
115 } else {
116 Some(EncodedKey::literal(c.to_string()))
117 }
118 }
119 KeyCode::Enter => Some(EncodedKey::named(format!("{prefix}Enter"))),
121 KeyCode::Tab => {
122 if shift && !ctrl && !alt {
125 Some(EncodedKey::named("BTab"))
126 } else {
127 Some(EncodedKey::named(format!("{prefix}Tab")))
128 }
129 }
130 KeyCode::BackTab => Some(EncodedKey::named("BTab")),
131 KeyCode::Backspace => Some(EncodedKey::named(format!("{prefix}BSpace"))),
132 KeyCode::Delete => Some(EncodedKey::named(format!("{prefix}DC"))),
133 KeyCode::Up => Some(EncodedKey::named(format!("{prefix}Up"))),
134 KeyCode::Down => Some(EncodedKey::named(format!("{prefix}Down"))),
135 KeyCode::Left => Some(EncodedKey::named(format!("{prefix}Left"))),
136 KeyCode::Right => Some(EncodedKey::named(format!("{prefix}Right"))),
137 KeyCode::Home => Some(EncodedKey::named(format!("{prefix}Home"))),
138 KeyCode::End => Some(EncodedKey::named(format!("{prefix}End"))),
139 KeyCode::PageUp => Some(EncodedKey::named(format!("{prefix}PPage"))),
140 KeyCode::PageDown => Some(EncodedKey::named(format!("{prefix}NPage"))),
141 KeyCode::Insert => Some(EncodedKey::named(format!("{prefix}IC"))),
142 KeyCode::F(n) if (1..=12).contains(&n) => Some(EncodedKey::named(format!("{prefix}F{n}"))),
143 KeyCode::Esc => Some(EncodedKey::named("Escape")),
148 _ => None,
151 }
152}
153
154#[derive(Debug, Default, Clone, Copy)]
158pub struct TmuxKeySender;
159
160impl KeySender for TmuxKeySender {
161 fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
162 let mut cmd = Command::new("tmux");
163 cmd.args(["send-keys", "-t", session]);
164 for arg in &key.args {
165 cmd.arg(arg);
166 }
167 let output = cmd
168 .output()
169 .with_context(|| format!("invoke tmux send-keys -t {session}"))?;
170 let _ = output;
174 Ok(())
175 }
176
177 fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
178 if matches!(direction, ScrollDirection::Up) {
186 let _ = Command::new("tmux")
187 .args(["copy-mode", "-e", "-t", session])
188 .output()
189 .with_context(|| format!("invoke tmux copy-mode -e -t {session}"))?;
190 }
191 let cmd = match direction {
192 ScrollDirection::Up => "scroll-up",
193 ScrollDirection::Down => "scroll-down",
194 };
195 let _ = Command::new("tmux")
196 .args(["send-keys", "-t", session, "-X", cmd])
197 .output()
198 .with_context(|| format!("invoke tmux send-keys -t {session} -X {cmd}"))?;
199 Ok(())
200 }
201}
202
203pub mod test_support {
207 use super::*;
208 use std::sync::Mutex;
209
210 #[derive(Default)]
215 pub struct MockKeySender {
216 pub calls: Mutex<Vec<(String, EncodedKey)>>,
217 pub scroll_calls: Mutex<Vec<(String, ScrollDirection)>>,
218 }
219
220 impl KeySender for MockKeySender {
221 fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
222 self.calls
223 .lock()
224 .unwrap()
225 .push((session.to_string(), key.clone()));
226 Ok(())
227 }
228
229 fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
230 self.scroll_calls
231 .lock()
232 .unwrap()
233 .push((session.to_string(), direction));
234 Ok(())
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crossterm::event::{KeyEventKind, KeyEventState};
243
244 fn k(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
245 KeyEvent {
246 code,
247 modifiers: mods,
248 kind: KeyEventKind::Press,
249 state: KeyEventState::NONE,
250 }
251 }
252
253 #[test]
254 fn printable_char_uses_literal_form() {
255 let enc = encode_key(k(KeyCode::Char('a'), KeyModifiers::NONE)).unwrap();
256 assert_eq!(enc.args, vec!["-l".to_string(), "a".to_string()]);
257 }
258
259 #[test]
260 fn shifted_printable_char_keeps_literal_form() {
261 let enc = encode_key(k(KeyCode::Char('A'), KeyModifiers::SHIFT)).unwrap();
264 assert_eq!(enc.args, vec!["-l".to_string(), "A".to_string()]);
265 }
266
267 #[test]
268 fn punctuation_uses_literal_form() {
269 let enc = encode_key(k(KeyCode::Char('~'), KeyModifiers::NONE)).unwrap();
273 assert_eq!(enc.args, vec!["-l".to_string(), "~".to_string()]);
274 }
275
276 #[test]
277 fn semicolon_is_backslash_escaped_in_literal_form() {
278 let enc = encode_key(k(KeyCode::Char(';'), KeyModifiers::NONE)).unwrap();
285 assert_eq!(
286 enc.args,
287 vec!["-l".to_string(), "\\;".to_string()],
288 "bare `;` must be sent as `\\;` so tmux's command parser \
289 doesn't eat it as a separator"
290 );
291 }
292
293 #[test]
294 fn ctrl_c_passes_through_as_named_chord() {
295 let enc = encode_key(k(KeyCode::Char('c'), KeyModifiers::CONTROL)).unwrap();
298 assert_eq!(enc.args, vec!["C-c".to_string()]);
299 }
300
301 #[test]
302 fn ctrl_uppercase_normalises_to_lowercase() {
303 let enc = encode_key(k(
306 KeyCode::Char('C'),
307 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
308 ))
309 .unwrap();
310 assert_eq!(enc.args, vec!["C-c".to_string()]);
311 }
312
313 #[test]
314 fn alt_char_uses_named_form() {
315 let enc = encode_key(k(KeyCode::Char('x'), KeyModifiers::ALT)).unwrap();
316 assert_eq!(enc.args, vec!["M-x".to_string()]);
317 }
318
319 #[test]
320 fn ctrl_alt_char_combines_prefixes() {
321 let enc = encode_key(k(
322 KeyCode::Char('a'),
323 KeyModifiers::CONTROL | KeyModifiers::ALT,
324 ))
325 .unwrap();
326 assert_eq!(enc.args, vec!["C-M-a".to_string()]);
327 }
328
329 #[test]
330 fn enter_named() {
331 let enc = encode_key(k(KeyCode::Enter, KeyModifiers::NONE)).unwrap();
332 assert_eq!(enc.args, vec!["Enter".to_string()]);
333 }
334
335 #[test]
336 fn backspace_named() {
337 let enc = encode_key(k(KeyCode::Backspace, KeyModifiers::NONE)).unwrap();
338 assert_eq!(enc.args, vec!["BSpace".to_string()]);
339 }
340
341 #[test]
342 fn arrows_named() {
343 for (code, name) in [
344 (KeyCode::Up, "Up"),
345 (KeyCode::Down, "Down"),
346 (KeyCode::Left, "Left"),
347 (KeyCode::Right, "Right"),
348 ] {
349 let enc = encode_key(k(code, KeyModifiers::NONE)).unwrap();
350 assert_eq!(enc.args, vec![name.to_string()], "encoding {code:?}");
351 }
352 }
353
354 #[test]
355 fn shift_tab_uses_btab() {
356 let from_tab = encode_key(k(KeyCode::Tab, KeyModifiers::SHIFT)).unwrap();
360 assert_eq!(from_tab.args, vec!["BTab".to_string()]);
361 let from_backtab = encode_key(k(KeyCode::BackTab, KeyModifiers::NONE)).unwrap();
362 assert_eq!(from_backtab.args, vec!["BTab".to_string()]);
363 }
364
365 #[test]
366 fn function_keys_named() {
367 let enc = encode_key(k(KeyCode::F(7), KeyModifiers::NONE)).unwrap();
368 assert_eq!(enc.args, vec!["F7".to_string()]);
369 let ctrl_f4 = encode_key(k(KeyCode::F(4), KeyModifiers::CONTROL)).unwrap();
370 assert_eq!(ctrl_f4.args, vec!["C-F4".to_string()]);
371 }
372
373 #[test]
374 fn page_keys_use_tmux_short_names() {
375 assert_eq!(
377 encode_key(k(KeyCode::PageUp, KeyModifiers::NONE))
378 .unwrap()
379 .args,
380 vec!["PPage".to_string()]
381 );
382 assert_eq!(
383 encode_key(k(KeyCode::PageDown, KeyModifiers::NONE))
384 .unwrap()
385 .args,
386 vec!["NPage".to_string()]
387 );
388 }
389
390 #[test]
391 fn mock_records_session_and_key() {
392 use test_support::MockKeySender;
393 let mock = MockKeySender::default();
394 let enc = encode_key(k(KeyCode::Char('h'), KeyModifiers::NONE)).unwrap();
395 mock.send("t-p-a", &enc).unwrap();
396 let calls = mock.calls.lock().unwrap();
397 assert_eq!(calls.len(), 1);
398 assert_eq!(calls[0].0, "t-p-a");
399 assert_eq!(calls[0].1, enc);
400 }
401
402 #[test]
403 fn mock_records_scroll_session_and_direction() {
404 use test_support::MockKeySender;
405 let mock = MockKeySender::default();
406 mock.scroll("t-p-a", ScrollDirection::Up).unwrap();
407 mock.scroll("t-p-a", ScrollDirection::Down).unwrap();
408 let calls = mock.scroll_calls.lock().unwrap();
409 assert_eq!(
410 *calls,
411 vec![
412 ("t-p-a".to_string(), ScrollDirection::Up),
413 ("t-p-a".to_string(), ScrollDirection::Down),
414 ]
415 );
416 }
417}