use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::input::ButtonState;
use bevy::prelude::*;
use std::io::Write as _;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Mutex;
use crate::term::{FitBox, TermSize};
use crate::{KittyCamera, KittyConfig, KittySet};
#[derive(Message, Debug, Clone, Copy)]
pub struct KittyClick {
pub viewport: Vec2,
pub cell: (u16, u16),
}
#[derive(Message, Debug, Clone)]
pub struct KittyKey {
pub code: KeyCode,
pub logical: Key,
pub text: Option<String>,
pub pressed: bool,
}
enum TermEvent {
Click { col: u16, row: u16 },
Key {
code: KeyCode,
logical: Key,
text: Option<String>,
pressed: bool,
},
}
#[derive(Resource)]
struct InputChannel(Mutex<Receiver<TermEvent>>);
pub(crate) fn register_messages(app: &mut App) {
app.add_message::<KittyClick>();
app.add_message::<KittyKey>();
}
pub(crate) fn build(app: &mut App) {
if let Err(e) = enable_terminal_input() {
warn!("[kitty] could not enable terminal input ({e}); running without it");
return;
}
let (tx, rx) = std::sync::mpsc::channel::<TermEvent>();
spawn_reader(tx);
app.insert_resource(InputChannel(Mutex::new(rx)));
app.add_systems(Update, drain_input.in_set(KittySet::Input));
info!("[kitty] terminal input enabled (mouse + keyboard)");
}
fn enable_terminal_input() -> std::io::Result<()> {
use crossterm::event::{
EnableMouseCapture, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::enable_raw_mode;
enable_raw_mode()?;
let mut out = std::io::stdout();
execute!(out, EnableMouseCapture)?;
let _ = execute!(
out,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
);
Ok(())
}
fn restore_terminal() {
use crossterm::event::{DisableMouseCapture, PopKeyboardEnhancementFlags};
use crossterm::execute;
use crossterm::terminal::disable_raw_mode;
let mut out = std::io::stdout();
let _ = execute!(out, PopKeyboardEnhancementFlags);
let _ = execute!(out, DisableMouseCapture);
let _ = disable_raw_mode();
let mut buf = Vec::new();
crate::proto::leave_screen(&mut buf);
let _ = out.write_all(&buf);
let _ = out.flush();
}
fn spawn_reader(tx: Sender<TermEvent>) {
std::thread::Builder::new()
.name("kitty-input".into())
.spawn(move || {
use crossterm::event::{
read, Event, KeyCode as CtKey, KeyEventKind, KeyModifiers, MouseButton,
MouseEventKind,
};
loop {
let ev = match read() {
Ok(e) => e,
Err(e) => {
eprintln!("[kitty] input reader stopping: {e}");
restore_terminal();
return;
}
};
match ev {
Event::Mouse(m) => {
if let MouseEventKind::Down(MouseButton::Left) = m.kind {
let _ = tx.send(TermEvent::Click {
col: m.column + 1,
row: m.row + 1,
});
}
}
Event::Key(k) => {
if matches!(k.code, CtKey::Char('c'))
&& k.modifiers.contains(KeyModifiers::CONTROL)
{
restore_terminal();
std::process::exit(130);
}
let pressed = !matches!(k.kind, KeyEventKind::Release);
if let Some((code, logical, text)) = map_key(k.code) {
let _ = tx.send(TermEvent::Key {
code,
logical,
text,
pressed,
});
}
}
_ => {}
}
}
})
.expect("failed to spawn kitty input thread");
}
fn map_key(code: crossterm::event::KeyCode) -> Option<(KeyCode, Key, Option<String>)> {
use crossterm::event::KeyCode as C;
Some(match code {
C::Char(c) => {
let s = c.to_string();
(
char_to_keycode(c),
Key::Character(s.clone().into()),
Some(s),
)
}
C::Enter => (KeyCode::Enter, Key::Enter, None),
C::Backspace => (KeyCode::Backspace, Key::Backspace, None),
C::Esc => (KeyCode::Escape, Key::Escape, None),
C::Left => (KeyCode::ArrowLeft, Key::ArrowLeft, None),
C::Right => (KeyCode::ArrowRight, Key::ArrowRight, None),
C::Up => (KeyCode::ArrowUp, Key::ArrowUp, None),
C::Down => (KeyCode::ArrowDown, Key::ArrowDown, None),
C::Tab => (KeyCode::Tab, Key::Tab, None),
C::Delete => (KeyCode::Delete, Key::Delete, None),
C::Home => (KeyCode::Home, Key::Home, None),
C::End => (KeyCode::End, Key::End, None),
C::PageUp => (KeyCode::PageUp, Key::PageUp, None),
C::PageDown => (KeyCode::PageDown, Key::PageDown, None),
C::F(n @ 1..=12) => {
let (code, logical) = f_key(n)?;
(code, logical, None)
}
_ => return None,
})
}
fn f_key(n: u8) -> Option<(KeyCode, Key)> {
Some(match n {
1 => (KeyCode::F1, Key::F1),
2 => (KeyCode::F2, Key::F2),
3 => (KeyCode::F3, Key::F3),
4 => (KeyCode::F4, Key::F4),
5 => (KeyCode::F5, Key::F5),
6 => (KeyCode::F6, Key::F6),
7 => (KeyCode::F7, Key::F7),
8 => (KeyCode::F8, Key::F8),
9 => (KeyCode::F9, Key::F9),
10 => (KeyCode::F10, Key::F10),
11 => (KeyCode::F11, Key::F11),
12 => (KeyCode::F12, Key::F12),
_ => return None,
})
}
fn char_to_keycode(c: char) -> KeyCode {
match c.to_ascii_lowercase() {
'a' => KeyCode::KeyA,
'b' => KeyCode::KeyB,
'c' => KeyCode::KeyC,
'd' => KeyCode::KeyD,
'e' => KeyCode::KeyE,
'f' => KeyCode::KeyF,
'g' => KeyCode::KeyG,
'h' => KeyCode::KeyH,
'i' => KeyCode::KeyI,
'j' => KeyCode::KeyJ,
'k' => KeyCode::KeyK,
'l' => KeyCode::KeyL,
'm' => KeyCode::KeyM,
'n' => KeyCode::KeyN,
'o' => KeyCode::KeyO,
'p' => KeyCode::KeyP,
'q' => KeyCode::KeyQ,
'r' => KeyCode::KeyR,
's' => KeyCode::KeyS,
't' => KeyCode::KeyT,
'u' => KeyCode::KeyU,
'v' => KeyCode::KeyV,
'w' => KeyCode::KeyW,
'x' => KeyCode::KeyX,
'y' => KeyCode::KeyY,
'z' => KeyCode::KeyZ,
' ' => KeyCode::Space,
'0' => KeyCode::Digit0,
'1' => KeyCode::Digit1,
'2' => KeyCode::Digit2,
'3' => KeyCode::Digit3,
'4' => KeyCode::Digit4,
'5' => KeyCode::Digit5,
'6' => KeyCode::Digit6,
'7' => KeyCode::Digit7,
'8' => KeyCode::Digit8,
'9' => KeyCode::Digit9,
'-' => KeyCode::Minus,
'=' => KeyCode::Equal,
',' => KeyCode::Comma,
'.' => KeyCode::Period,
'/' => KeyCode::Slash,
';' => KeyCode::Semicolon,
'\'' => KeyCode::Quote,
'[' => KeyCode::BracketLeft,
']' => KeyCode::BracketRight,
'\\' => KeyCode::Backslash,
'`' => KeyCode::Backquote,
_ => KeyCode::Space,
}
}
#[allow(clippy::too_many_arguments)]
fn drain_input(
chan: Res<InputChannel>,
config: Res<KittyConfig>,
mut clicks: MessageWriter<KittyClick>,
mut kitty_keys: MessageWriter<KittyKey>,
mut key_events: MessageWriter<KeyboardInput>,
mut keys: ResMut<ButtonInput<KeyCode>>,
camera_q: Query<Entity, With<KittyCamera>>,
mut term: Local<Option<TermSize>>,
mut ticks: Local<u64>,
) {
*ticks += 1;
if term.is_none() || ticks.is_multiple_of(120) {
*term = Some(TermSize::query(config.terminal_size));
}
let fit = FitBox::compute(&term.unwrap(), config.virtual_size);
let window_stub = camera_q.single().unwrap_or(Entity::PLACEHOLDER);
let Ok(rx) = chan.0.lock() else {
error!("[kitty] input channel mutex poisoned; terminal input is dead this tick");
return;
};
while let Ok(ev) = rx.try_recv() {
match ev {
TermEvent::Click { col, row } => {
match fit.cell_to_viewport(col, row) {
Some(vp) => {
info!(
"[kitty] click cell ({col},{row}) -> viewport ({:.0},{:.0})",
vp.x, vp.y
);
clicks.write(KittyClick {
viewport: vp,
cell: (col, row),
});
}
None => {
debug!("[kitty] click cell ({col},{row}) is in the letterbox - ignored");
}
}
}
TermEvent::Key {
code,
logical,
text,
pressed,
} => {
let state = if pressed {
ButtonState::Pressed
} else {
ButtonState::Released
};
if pressed {
keys.press(code);
} else {
keys.release(code);
}
key_events.write(KeyboardInput {
key_code: code,
logical_key: logical.clone(),
state,
text: text.clone().map(Into::into),
repeat: false,
window: window_stub,
});
kitty_keys.write(KittyKey {
code,
logical,
text,
pressed,
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode as C;
#[test]
fn typed_characters_carry_their_text() {
let (code, logical, text) = map_key(C::Char('q')).unwrap();
assert_eq!(code, KeyCode::KeyQ);
assert_eq!(text.as_deref(), Some("q"));
assert!(matches!(logical, Key::Character(_)));
}
#[test]
fn uppercase_maps_to_the_same_physical_key_but_keeps_its_case() {
let (code, _, text) = map_key(C::Char('Q')).unwrap();
assert_eq!(code, KeyCode::KeyQ, "physical key is layout-level");
assert_eq!(
text.as_deref(),
Some("Q"),
"but the text must stay uppercase"
);
}
#[test]
fn control_keys_have_no_text() {
for k in [C::Enter, C::Esc, C::Backspace, C::Tab, C::Up] {
let (_, _, text) = map_key(k).unwrap();
assert!(text.is_none(), "{k:?} should carry no text");
}
}
#[test]
fn unmapped_keys_are_dropped_rather_than_guessed() {
assert!(map_key(C::Insert).is_none());
assert!(map_key(C::F(13)).is_none());
}
#[test]
fn punctuation_gets_a_real_keycode_not_space() {
for c in ['-', '=', ',', '.', '/', ';', '[', ']', '\\', '`', '\''] {
assert_ne!(
char_to_keycode(c),
KeyCode::Space,
"{c:?} should not fall back to Space"
);
}
assert_eq!(char_to_keycode('\u{263A}'), KeyCode::Space);
}
#[test]
fn function_keys_map_in_order() {
assert_eq!(f_key(1).unwrap().0, KeyCode::F1);
assert_eq!(f_key(12).unwrap().0, KeyCode::F12);
assert!(f_key(0).is_none());
assert!(f_key(13).is_none());
}
}