use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::settings::CtrlHSetting;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CtrlHPolicy {
#[default]
Chord,
Backspace,
}
impl CtrlHPolicy {
pub fn resolve(setting: CtrlHSetting, keyboard_enhanced: bool) -> Self {
Self::resolve_with(setting, keyboard_enhanced, tty_erase_is_bs())
}
pub fn resolve_with(
setting: CtrlHSetting,
keyboard_enhanced: bool,
tty_erase_is_bs: bool,
) -> Self {
let policy = match setting {
CtrlHSetting::Chord => Self::Chord,
CtrlHSetting::Backspace => Self::Backspace,
CtrlHSetting::Auto if keyboard_enhanced => Self::Chord,
CtrlHSetting::Auto if tty_erase_is_bs => Self::Backspace,
CtrlHSetting::Auto => Self::Chord,
};
tracing::debug!(
"ctrl_h: setting={setting:?} keyboard_enhanced={keyboard_enhanced} \
tty_erase_is_bs={tty_erase_is_bs} -> {policy:?}"
);
policy
}
pub fn loss_is_worth_reporting(self, setting: CtrlHSetting) -> bool {
self == Self::Backspace && setting != CtrlHSetting::Backspace
}
pub fn apply(self, key: KeyEvent) -> KeyEvent {
if self == Self::Backspace
&& key.code == KeyCode::Char('h')
&& key.modifiers == KeyModifiers::CONTROL
{
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
..key
}
} else {
key
}
}
}
#[cfg(unix)]
pub fn tty_erase_char() -> Option<u8> {
use std::io::IsTerminal;
use std::os::fd::AsFd;
let stdin = std::io::stdin();
if stdin.is_terminal() {
return erase_char_of(stdin.as_fd());
}
let tty = std::fs::File::open("/dev/tty").ok()?;
erase_char_of(tty.as_fd())
}
#[cfg(unix)]
fn erase_char_of(fd: std::os::fd::BorrowedFd<'_>) -> Option<u8> {
use rustix::termios::{SpecialCodeIndex, tcgetattr};
let termios = tcgetattr(fd).ok()?;
Some(termios.special_codes[SpecialCodeIndex::VERASE])
}
#[cfg(not(unix))]
pub fn tty_erase_char() -> Option<u8> {
None
}
fn tty_erase_is_bs() -> bool {
tty_erase_char() == Some(0x08)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctrl_h() -> KeyEvent {
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)
}
#[test]
fn auto_keeps_the_chord_under_the_kitty_protocol() {
assert_eq!(
CtrlHPolicy::resolve_with(CtrlHSetting::Auto, true, true),
CtrlHPolicy::Chord
);
}
#[test]
fn explicit_settings_ignore_the_terminal() {
for enhanced in [false, true] {
for erase_is_bs in [false, true] {
assert_eq!(
CtrlHPolicy::resolve_with(CtrlHSetting::Backspace, enhanced, erase_is_bs),
CtrlHPolicy::Backspace,
"explicit `backspace` must hold with enhanced={enhanced} erase_is_bs={erase_is_bs}"
);
assert_eq!(
CtrlHPolicy::resolve_with(CtrlHSetting::Chord, enhanced, erase_is_bs),
CtrlHPolicy::Chord,
"explicit `chord` must hold with enhanced={enhanced} erase_is_bs={erase_is_bs}"
);
}
}
}
#[test]
fn auto_reads_the_terminal() {
use CtrlHPolicy::{Backspace, Chord};
for (enhanced, erase_is_bs, expected) in [
(true, false, Chord),
(true, true, Chord),
(false, false, Chord),
(false, true, Backspace),
] {
assert_eq!(
CtrlHPolicy::resolve_with(CtrlHSetting::Auto, enhanced, erase_is_bs),
expected,
"enhanced={enhanced} erase_is_bs={erase_is_bs}"
);
}
}
#[test]
fn only_an_unasked_for_loss_is_reported() {
assert!(
CtrlHPolicy::Backspace.loss_is_worth_reporting(CtrlHSetting::Auto),
"auto resolved it by probe — the user never chose it"
);
assert!(
!CtrlHPolicy::Backspace.loss_is_worth_reporting(CtrlHSetting::Backspace),
"they asked for exactly this"
);
for setting in [
CtrlHSetting::Auto,
CtrlHSetting::Backspace,
CtrlHSetting::Chord,
] {
assert!(!CtrlHPolicy::Chord.loss_is_worth_reporting(setting));
}
}
#[test]
fn rewrites_ctrl_h_to_backspace() {
let key = CtrlHPolicy::Backspace.apply(ctrl_h());
assert_eq!(key.code, KeyCode::Backspace);
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
#[test]
fn chord_policy_rewrites_nothing() {
assert_eq!(CtrlHPolicy::Chord.apply(ctrl_h()), ctrl_h());
}
#[test]
fn leaves_neighbouring_chords_alone() {
for modifiers in [
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
KeyModifiers::CONTROL | KeyModifiers::ALT,
KeyModifiers::NONE,
KeyModifiers::ALT,
] {
let key = KeyEvent::new(KeyCode::Char('h'), modifiers);
assert_eq!(
CtrlHPolicy::Backspace.apply(key),
key,
"{modifiers:?}+h is not the byte 0x08"
);
}
}
#[test]
fn leaves_backspace_alone() {
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(CtrlHPolicy::Backspace.apply(key), key);
}
#[cfg(unix)]
#[test]
fn a_non_tty_descriptor_has_no_erase_char() {
use std::os::fd::AsFd;
let null = std::fs::File::open("/dev/null").unwrap();
assert_eq!(erase_char_of(null.as_fd()), None);
}
}