use std::fs::File;
use std::io::{IsTerminal, Write, stdout};
use std::path::Path;
use std::process::ExitCode;
use anyhow::{Context, Result};
#[cfg(unix)]
use ratatui::crossterm::event::PopKeyboardEnhancementFlags;
use ratatui::crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyModifiers, MouseEvent,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crate::app::runtime::{self, Chunk};
use crate::shared::i18n::Locale;
use crate::shared::keys;
use crate::widgets::input_box::{InputBox, KeyOutcome};
const SAMPLE: &str = "one two three";
const SAMPLE_CURSOR: usize = 8;
struct Echo<W: Write> {
term: W,
file: Option<File>,
}
impl<W: Write> Echo<W> {
fn line(&mut self, text: &str) -> Result<()> {
write!(self.term, "{text}\r\n")?;
self.term.flush()?;
if let Some(file) = &mut self.file {
writeln!(file, "{text}")?;
}
Ok(())
}
}
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
let _ = execute!(stdout(), DisableMouseCapture, DisableBracketedPaste);
#[cfg(unix)]
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
}
}
#[derive(Debug, PartialEq, Eq)]
enum Control {
Go,
ToggleMouse,
Quit,
}
fn control_of(key: &KeyEvent) -> Control {
if key.code == KeyCode::F(10) {
return Control::Quit;
}
if key.modifiers == KeyModifiers::CONTROL {
match keys::hotkey_char(key) {
Some('q') => return Control::Quit,
Some('w') => return Control::ToggleMouse,
_ => {}
}
}
Control::Go
}
pub fn run(output: Option<&Path>, loc: &Locale) -> Result<ExitCode> {
debug_assert!(stdout().is_terminal());
let file = output.map(|path| create_record(path, loc)).transpose()?;
let mut echo = Echo {
term: stdout(),
file,
};
enable_raw_mode()?;
let restore = Restore;
for line in header(loc, runtime::enable_key_modes(), &env_var) {
echo.line(&line)?;
}
echo_loop(runtime::read_batch, set_mouse_capture, &mut echo, loc)?;
drop(restore);
if let Some(path) = output {
println!(
"{}",
loc.tf("cli.keys.saved", &[("path", &path.display().to_string())])
);
}
Ok(ExitCode::SUCCESS)
}
fn create_record(path: &Path, loc: &Locale) -> Result<File> {
File::create_new(path).with_context(|| {
loc.tf(
"cli.keys.cannot_write",
&[("path", &path.display().to_string())],
)
})
}
fn set_mouse_capture(on: bool) -> Result<()> {
if on {
execute!(stdout(), EnableMouseCapture)?;
} else {
execute!(stdout(), DisableMouseCapture)?;
}
Ok(())
}
fn echo_loop<W: Write>(
mut next: impl FnMut() -> Result<Vec<Event>>,
mut set_mouse: impl FnMut(bool) -> Result<()>,
echo: &mut Echo<W>,
loc: &Locale,
) -> Result<()> {
let mut mouse = false;
loop {
for chunk in runtime::chunk_batch(next()?) {
let control = match &chunk {
Chunk::Event(Event::Key(key)) => control_of(key),
_ => Control::Go,
};
if control == Control::ToggleMouse {
mouse = !mouse;
set_mouse(mouse)?;
}
echo.line(&describe(&chunk, &control, mouse, loc))?;
if control == Control::Quit {
return Ok(());
}
}
}
}
fn header(loc: &Locale, kitty: Option<bool>, env: &dyn Fn(&str) -> Option<String>) -> Vec<String> {
let kitty = match kitty {
Some(true) => loc.t("cli.keys.kitty_on"),
Some(false) => loc.t("cli.keys.kitty_off"),
None => loc.t("cli.keys.kitty_none"),
};
vec![
loc.tf("cli.keys.title", &[("version", env!("CARGO_PKG_VERSION"))]),
loc.tf("cli.keys.terminal", &[("terminal", &terminal_facts(env))]),
kitty.to_string(),
loc.tf("cli.keys.newline", &[("chord", keys::newline_chord())]),
loc.tf("cli.keys.sample", &[("sample", &sketch(&sample_box()))]),
loc.t("cli.keys.controls").to_string(),
String::new(),
]
}
fn env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn terminal_facts(env: &dyn Fn(&str) -> Option<String>) -> String {
let mut facts = vec![std::env::consts::OS.to_string()];
for name in ["TERM_PROGRAM", "TERM_PROGRAM_VERSION", "TERM", "COLORTERM"] {
if let Some(value) = env(name) {
facts.push(format!("{name}={value}"));
}
}
if env("WT_SESSION").is_some() {
facts.push("WT_SESSION".to_string());
}
facts.join(", ")
}
fn describe(chunk: &Chunk, control: &Control, mouse: bool, loc: &Locale) -> String {
let (label, outcome) = match chunk {
Chunk::Paste(text) => ("Paste (keys)".to_string(), paste_outcome(text, loc)),
Chunk::Event(Event::Paste(text)) => ("Paste".to_string(), paste_outcome(text, loc)),
Chunk::Event(Event::Key(key)) => {
let outcome = match control {
Control::Quit => loc.t("cli.keys.quit").to_string(),
Control::ToggleMouse if mouse => loc.t("cli.keys.mouse_on").to_string(),
Control::ToggleMouse => loc.t("cli.keys.mouse_off").to_string(),
Control::Go => key_outcome(key, loc),
};
(key_label(key), outcome)
}
Chunk::Event(Event::Mouse(mouse)) => ("Mouse".to_string(), mouse_outcome(mouse)),
Chunk::Event(Event::Resize(width, height)) => (
"Resize".to_string(),
loc.tf(
"cli.keys.window",
&[
("width", &width.to_string()),
("height", &height.to_string()),
],
),
),
Chunk::Event(other) => (format!("{other:?}"), String::new()),
};
format!("{label:<24} → {outcome}").trim_end().to_string()
}
fn paste_outcome(text: &str, loc: &Locale) -> String {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
loc.tf(
"cli.keys.paste",
&[
("chars", &text.chars().count().to_string()),
("lines", &(text.matches('\n').count() + 1).to_string()),
("preview", &preview(&text)),
],
)
}
fn key_label(key: &KeyEvent) -> String {
let mut parts: Vec<String> = modifier_names(key.modifiers)
.into_iter()
.map(str::to_string)
.collect();
parts.push(match key.code {
KeyCode::Char(c) => format!("'{}'", c.escape_debug()),
KeyCode::F(n) => format!("F{n}"),
other => format!("{other:?}"),
});
let mut label = parts.join("+");
for (name, _) in key.state.iter_names() {
label.push(' ');
label.push_str(name);
}
label
}
fn modifier_names(modifiers: KeyModifiers) -> Vec<&'static str> {
[
(KeyModifiers::CONTROL, "Ctrl"),
(KeyModifiers::ALT, "Alt"),
(KeyModifiers::SHIFT, "Shift"),
(KeyModifiers::SUPER, "Super"),
(KeyModifiers::HYPER, "Hyper"),
(KeyModifiers::META, "Meta"),
]
.into_iter()
.filter(|(flag, _)| modifiers.contains(*flag))
.map(|(_, name)| name)
.collect()
}
fn key_outcome(key: &KeyEvent, loc: &Locale) -> String {
let mut field = sample_box();
if keys::is_line_break(key) {
field.insert_newline();
return sketch(&field);
}
if key.code == KeyCode::Enter {
return loc.t("cli.keys.send").to_string();
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& !keys::is_altgr_text(key)
&& let Some(physical) = keys::hotkey_char(key)
{
let chord = format!("Ctrl+{}", physical.to_ascii_uppercase());
return loc.tf("cli.keys.shortcut", &[("chord", &chord)]);
}
match field.on_key(*key) {
KeyOutcome::Ignored => loc.t("cli.keys.ignored").to_string(),
KeyOutcome::Edited | KeyOutcome::Moved => sketch(&field),
}
}
fn mouse_outcome(mouse: &MouseEvent) -> String {
let mut text = format!("{:?} at {},{}", mouse.kind, mouse.column + 1, mouse.row + 1);
for name in modifier_names(mouse.modifiers) {
text.push(' ');
text.push_str(name);
}
text
}
fn sample_box() -> InputBox {
let mut field = InputBox::new();
field.set_text(SAMPLE);
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
for _ in SAMPLE_CURSOR..SAMPLE.chars().count() {
field.on_key(left);
}
field
}
fn sketch(field: &InputBox) -> String {
let cursor = field.cursor();
let selection = field.selection_span();
let mut out = String::new();
for (row, line) in field.line_strings().iter().enumerate() {
if row > 0 {
out.push('⏎');
}
let chars: Vec<char> = line.chars().collect();
for col in 0..=chars.len() {
let at = (row, col);
if selection.is_some_and(|(_, end)| end == at) {
out.push('»');
}
if cursor == at {
out.push('|');
}
if selection.is_some_and(|(start, _)| start == at) {
out.push('«');
}
if let Some(&c) = chars.get(col) {
out.extend(c.escape_debug());
}
}
}
out
}
fn preview(text: &str) -> String {
const SHOWN: usize = 40;
let mut out: String = text
.chars()
.take(SHOWN)
.map(|c| {
if c == '\n' {
"⏎".to_string()
} else {
c.escape_debug().to_string()
}
})
.collect();
if text.chars().count() > SHOWN {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
use ratatui::crossterm::event::{KeyEventState, MouseButton, MouseEventKind};
fn en() -> &'static Locale {
locale(Lang::En)
}
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn outcome(code: KeyCode, modifiers: KeyModifiers) -> String {
key_outcome(&key(code, modifiers), en())
}
#[test]
fn every_key_starts_between_two_words() {
assert_eq!(sketch(&sample_box()), "one two |three");
}
#[test]
fn a_word_move_and_a_character_move_land_apart() {
for (code, modifiers) in [
(KeyCode::Char('b'), KeyModifiers::ALT),
(KeyCode::Left, KeyModifiers::ALT),
(KeyCode::Left, KeyModifiers::CONTROL),
] {
assert_eq!(
outcome(code, modifiers),
"one |two three",
"{code:?} {modifiers:?}"
);
}
assert_eq!(outcome(KeyCode::Left, KeyModifiers::NONE), "one two| three");
assert_eq!(
outcome(KeyCode::Char('f'), KeyModifiers::ALT),
"one two three|"
);
}
#[test]
fn the_three_line_breaks_break_the_line_and_enter_sends() {
for (code, modifiers) in [
(KeyCode::Enter, KeyModifiers::SHIFT),
(KeyCode::Enter, KeyModifiers::ALT),
(KeyCode::Char('j'), KeyModifiers::CONTROL),
] {
assert_eq!(
outcome(code, modifiers),
"one two ⏎|three",
"{code:?} {modifiers:?}"
);
}
assert_eq!(
outcome(KeyCode::Enter, KeyModifiers::NONE),
"sends the message"
);
}
#[test]
fn typing_deleting_and_selecting_show_in_the_sketch() {
assert_eq!(
outcome(KeyCode::Char('x'), KeyModifiers::NONE),
"one two x|three"
);
assert_eq!(outcome(KeyCode::Backspace, KeyModifiers::ALT), "one |three");
assert_eq!(
outcome(KeyCode::Backspace, KeyModifiers::NONE),
"one two|three"
);
assert_eq!(
outcome(KeyCode::Right, KeyModifiers::SHIFT),
"one two «t»|hree"
);
assert_eq!(
outcome(KeyCode::Left, KeyModifiers::SHIFT),
"one two|« »three"
);
assert_eq!(outcome(KeyCode::Home, KeyModifiers::NONE), "|one two three");
}
#[test]
fn shortcuts_and_untyped_chords_say_so() {
assert_eq!(
outcome(KeyCode::Char('x'), KeyModifiers::ALT),
"the input box does nothing with it"
);
assert_eq!(
outcome(KeyCode::Char('l'), KeyModifiers::CONTROL),
"the shortcut Ctrl+L"
);
assert_eq!(
outcome(KeyCode::F(1), KeyModifiers::NONE),
"the input box does nothing with it"
);
}
#[test]
fn the_label_is_the_event_as_crossterm_gave_it() {
assert_eq!(
key_label(&key(KeyCode::Char('b'), KeyModifiers::ALT)),
"Alt+'b'"
);
assert_eq!(
key_label(&key(
KeyCode::Enter,
KeyModifiers::SHIFT | KeyModifiers::CONTROL
)),
"Ctrl+Shift+Enter"
);
assert_eq!(
key_label(&key(KeyCode::Char(' '), KeyModifiers::NONE)),
"' '"
);
assert_eq!(
key_label(&key(KeyCode::Char('\u{1}'), KeyModifiers::NONE)),
"'\\u{1}'"
);
assert_eq!(key_label(&key(KeyCode::F(1), KeyModifiers::NONE)), "F1");
let mut keypad = key(KeyCode::Left, KeyModifiers::NONE);
keypad.state = KeyEventState::KEYPAD;
assert_eq!(key_label(&keypad), "Left KEYPAD");
}
fn line(event: Event, control: &Control, mouse: bool) -> String {
describe(&Chunk::Event(event), control, mouse, en())
}
#[test]
fn quit_and_the_mouse_switch_are_the_apps_own_keys() {
assert_eq!(
control_of(&key(KeyCode::Char('q'), KeyModifiers::CONTROL)),
Control::Quit
);
assert_eq!(
control_of(&key(KeyCode::F(10), KeyModifiers::NONE)),
Control::Quit
);
assert_eq!(
control_of(&key(KeyCode::Char('w'), KeyModifiers::CONTROL)),
Control::ToggleMouse
);
assert_eq!(
control_of(&key(KeyCode::Char('q'), KeyModifiers::NONE)),
Control::Go
);
assert_eq!(
control_of(&key(KeyCode::Char('e'), KeyModifiers::CONTROL)),
Control::Go
);
let quit = Event::Key(key(KeyCode::Char('q'), KeyModifiers::CONTROL));
assert_eq!(
line(quit, &Control::Quit, false),
format!("{:<24} → quits", "Ctrl+'q'")
);
let switch = Event::Key(key(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(line(switch, &Control::ToggleMouse, true).ends_with("mouse capture on"));
}
#[test]
fn the_batch_is_split_as_the_app_splits_it() {
let press = |c| Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE));
let enter = Event::Key(key(KeyCode::Enter, KeyModifiers::NONE));
let chunks = runtime::chunk_batch(vec![press('h'), press('i'), enter, press('!')]);
let lines: Vec<String> = chunks
.iter()
.map(|chunk| describe(chunk, &Control::Go, false, en()))
.collect();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with("Paste (keys)"), "{lines:?}");
assert!(
lines[0].contains("characters: 4, lines: 2: hi⏎!"),
"{lines:?}"
);
let one = runtime::chunk_batch(vec![press('x')]);
assert_eq!(
describe(&one[0], &Control::Go, false, en()),
format!("{:<24} → one two x|three", "'x'")
);
}
#[test]
fn a_paste_a_click_and_a_resize_are_shown() {
let paste = line(Event::Paste("hi\nthere".into()), &Control::Go, false);
assert!(paste.starts_with("Paste "), "{paste}");
assert!(
paste.contains("characters: 8, lines: 2: hi⏎there"),
"{paste}"
);
let long = preview(&"x".repeat(50));
assert_eq!(long.chars().count(), 41);
assert!(long.ends_with('…'));
let click = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row: 9,
modifiers: KeyModifiers::SHIFT,
});
assert!(line(click, &Control::Go, true).ends_with("Down(Left) at 5,10 Shift"));
assert!(line(Event::Resize(80, 24), &Control::Go, false).ends_with("80×24"));
assert_eq!(
line(Event::FocusGained, &Control::Go, false),
"FocusGained →"
);
}
fn no_env(_: &str) -> Option<String> {
None
}
#[test]
fn the_header_names_the_terminal_and_the_way_out() {
let lines = header(en(), Some(false), &no_env);
let text = lines.join("\n");
assert!(text.contains(std::env::consts::OS), "{text}");
assert!(text.contains("one two |three"), "{text}");
assert!(text.contains("Ctrl+Q"), "{text}");
assert!(text.contains("not answered"), "{text}");
assert_eq!(lines.last().map(String::as_str), Some(""));
let answered = header(en(), Some(true), &no_env).join("\n");
assert!(answered.contains("answered, and turned on"), "{answered}");
let windows = header(en(), None, &no_env).join("\n");
assert!(windows.contains("not asked"), "{windows}");
}
#[test]
fn the_terminal_is_named_by_its_variables() {
let env = |name: &str| match name {
"TERM_PROGRAM" => Some("Apple_Terminal".to_string()),
"TERM_PROGRAM_VERSION" => Some("466".to_string()),
"TERM" => Some("xterm-256color".to_string()),
"WT_SESSION" => Some("a-session-id".to_string()),
_ => None,
};
let facts = terminal_facts(&env);
assert_eq!(
facts,
format!(
"{}, TERM_PROGRAM=Apple_Terminal, TERM_PROGRAM_VERSION=466, \
TERM=xterm-256color, WT_SESSION",
std::env::consts::OS
)
);
assert_eq!(terminal_facts(&no_env), std::env::consts::OS);
}
#[test]
fn the_loop_echoes_each_piece_and_stops_at_the_quit() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("keys.txt");
let mut echo = Echo {
term: Vec::new(),
file: Some(create_record(&path, en()).unwrap()),
};
let press = |c| Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE));
let ctrl = |c| Event::Key(key(KeyCode::Char(c), KeyModifiers::CONTROL));
let mut batches = std::collections::VecDeque::from(vec![
vec![press('x')],
vec![ctrl('w')],
vec![press('h'), press('i')],
vec![ctrl('w'), ctrl('q'), press('z')],
]);
let mut switched = Vec::new();
echo_loop(
|| Ok(batches.pop_front().expect("read after the quit")),
|on| {
switched.push(on);
Ok(())
},
&mut echo,
en(),
)
.unwrap();
assert_eq!(switched, vec![true, false]);
let term = String::from_utf8(echo.term).unwrap();
let lines: Vec<&str> = term.split_terminator("\r\n").collect();
assert_eq!(lines.len(), 5, "{term}");
assert!(lines[0].ends_with("one two x|three"), "{term}");
assert!(lines[1].ends_with("mouse capture on"), "{term}");
assert!(lines[2].starts_with("Paste (keys)"), "{term}");
assert!(lines[3].ends_with("mouse capture off"), "{term}");
assert!(lines[4].ends_with("quits"), "{term}");
drop(echo.file);
let saved = std::fs::read_to_string(&path).unwrap();
assert_eq!(saved, format!("{}\n", lines.join("\n")));
}
#[test]
fn an_existing_record_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("keys.txt");
std::fs::write(&path, "Terminal.app\n").unwrap();
let error = create_record(&path, en()).unwrap_err();
assert!(format!("{error:#}").contains("Cannot create"), "{error:#}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "Terminal.app\n");
}
}