use crate::{ButtonMask, Key, Modifiers, MouseButton, NamedKey, TerminalCellSize, UiEvent};
use ratatui::crossterm::event::{
Event as CtEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton as CtMouseButton,
MouseEvent, MouseEventKind,
};
const UNSCALED_CELL: TerminalCellSize = TerminalCellSize {
width: 1.0,
height: 1.0,
};
pub fn crossterm_to_uievents(event: CtEvent) -> Vec<UiEvent> {
crossterm_to_uievents_scaled(event, UNSCALED_CELL)
}
pub fn crossterm_to_uievents_scaled(event: CtEvent, cell_size: TerminalCellSize) -> Vec<UiEvent> {
match event {
CtEvent::Key(k) => crossterm_key_to_uievent(k).into_iter().collect(),
CtEvent::Mouse(m) => crossterm_mouse_to_uievent_scaled(m, cell_size)
.into_iter()
.collect(),
CtEvent::Resize(w, h) => vec![crate::event::window_resized(w as f32, h as f32, 1.0)],
CtEvent::Paste(text) => vec![UiEvent::ClipboardPaste(text)],
CtEvent::FocusGained => vec![UiEvent::WindowFocused(true)],
CtEvent::FocusLost => vec![UiEvent::WindowFocused(false)],
}
}
pub fn crossterm_key_to_uievent(event: KeyEvent) -> Option<UiEvent> {
if event.kind == KeyEventKind::Release {
return None;
}
let key = crossterm_keycode_to_key(event.code)?;
let modifiers = crossterm_modifiers_to_quadraui(event.modifiers);
let repeat = event.kind == KeyEventKind::Repeat;
Some(UiEvent::KeyPressed {
key,
modifiers,
repeat,
})
}
pub fn crossterm_mouse_to_uievent(event: MouseEvent) -> Option<UiEvent> {
crossterm_mouse_to_uievent_scaled(event, UNSCALED_CELL)
}
pub fn crossterm_mouse_to_uievent_scaled(
event: MouseEvent,
cell_size: TerminalCellSize,
) -> Option<UiEvent> {
let cell_size = if cell_size.width <= 0.0 || cell_size.height <= 0.0 {
UNSCALED_CELL
} else {
cell_size
};
let x = event.column as f32 / cell_size.width;
let y = event.row as f32 / cell_size.height;
let modifiers = crossterm_modifiers_to_quadraui(event.modifiers);
match event.kind {
MouseEventKind::Down(b) => Some(crate::event::mouse_down(
crossterm_mouse_button_to_quadraui(b),
x,
y,
modifiers,
)),
MouseEventKind::Up(b) => Some(crate::event::mouse_up(
crossterm_mouse_button_to_quadraui(b),
x,
y,
)),
MouseEventKind::Drag(b) => Some(crate::event::mouse_moved(x, y, button_mask_with_held(b))),
MouseEventKind::Moved => Some(crate::event::mouse_moved(x, y, ButtonMask::default())),
MouseEventKind::ScrollUp => Some(crate::event::scroll(0.0, -1.0, x, y)),
MouseEventKind::ScrollDown => Some(crate::event::scroll(0.0, 1.0, x, y)),
MouseEventKind::ScrollLeft => Some(crate::event::scroll(-1.0, 0.0, x, y)),
MouseEventKind::ScrollRight => Some(crate::event::scroll(1.0, 0.0, x, y)),
}
}
pub use crate::dispatch::DoubleClickDetector;
fn crossterm_keycode_to_key(code: KeyCode) -> Option<Key> {
let named = match code {
KeyCode::Char(c) => return Some(Key::Char(c)),
KeyCode::Esc => NamedKey::Escape,
KeyCode::Tab => NamedKey::Tab,
KeyCode::BackTab => NamedKey::BackTab,
KeyCode::Enter => NamedKey::Enter,
KeyCode::Backspace => NamedKey::Backspace,
KeyCode::Delete => NamedKey::Delete,
KeyCode::Insert => NamedKey::Insert,
KeyCode::Home => NamedKey::Home,
KeyCode::End => NamedKey::End,
KeyCode::PageUp => NamedKey::PageUp,
KeyCode::PageDown => NamedKey::PageDown,
KeyCode::Up => NamedKey::Up,
KeyCode::Down => NamedKey::Down,
KeyCode::Left => NamedKey::Left,
KeyCode::Right => NamedKey::Right,
KeyCode::F(n) => NamedKey::F(n),
KeyCode::CapsLock => NamedKey::CapsLock,
KeyCode::NumLock => NamedKey::NumLock,
KeyCode::ScrollLock => NamedKey::ScrollLock,
KeyCode::Menu => NamedKey::Menu,
_ => return None,
};
Some(Key::Named(named))
}
fn crossterm_modifiers_to_quadraui(m: KeyModifiers) -> Modifiers {
Modifiers {
shift: m.contains(KeyModifiers::SHIFT),
ctrl: m.contains(KeyModifiers::CONTROL),
alt: m.contains(KeyModifiers::ALT),
cmd: m.contains(KeyModifiers::SUPER) | m.contains(KeyModifiers::META),
}
}
fn crossterm_mouse_button_to_quadraui(b: CtMouseButton) -> MouseButton {
match b {
CtMouseButton::Left => MouseButton::Left,
CtMouseButton::Right => MouseButton::Right,
CtMouseButton::Middle => MouseButton::Middle,
}
}
pub fn uievent_to_crossterm(ev: UiEvent) -> Option<CtEvent> {
match ev {
UiEvent::KeyPressed {
key,
modifiers,
repeat,
} => synth_keyevent(&key, modifiers, repeat).map(CtEvent::Key),
UiEvent::CharTyped(_) => None, UiEvent::MouseDown { .. }
| UiEvent::MouseUp { .. }
| UiEvent::MouseMoved { .. }
| UiEvent::Scroll { .. } => synth_mouseevent(&ev).map(CtEvent::Mouse),
UiEvent::WindowResized { viewport } => Some(CtEvent::Resize(
viewport.width as u16,
viewport.height as u16,
)),
UiEvent::ClipboardPaste(text) => Some(CtEvent::Paste(text)),
UiEvent::WindowFocused(true) => Some(CtEvent::FocusGained),
UiEvent::WindowFocused(false) => Some(CtEvent::FocusLost),
_ => None,
}
}
pub fn synth_keyevent(key: &Key, modifiers: Modifiers, repeat: bool) -> Option<KeyEvent> {
use ratatui::crossterm::event::KeyEventState;
let code = match key {
Key::Char(c) => KeyCode::Char(*c),
Key::Named(n) => match n {
NamedKey::Escape => KeyCode::Esc,
NamedKey::Tab => KeyCode::Tab,
NamedKey::BackTab => KeyCode::BackTab,
NamedKey::Enter => KeyCode::Enter,
NamedKey::Backspace => KeyCode::Backspace,
NamedKey::Delete => KeyCode::Delete,
NamedKey::Insert => KeyCode::Insert,
NamedKey::Home => KeyCode::Home,
NamedKey::End => KeyCode::End,
NamedKey::PageUp => KeyCode::PageUp,
NamedKey::PageDown => KeyCode::PageDown,
NamedKey::Up => KeyCode::Up,
NamedKey::Down => KeyCode::Down,
NamedKey::Left => KeyCode::Left,
NamedKey::Right => KeyCode::Right,
NamedKey::F(n) => KeyCode::F(*n),
NamedKey::CapsLock => KeyCode::CapsLock,
NamedKey::NumLock => KeyCode::NumLock,
NamedKey::ScrollLock => KeyCode::ScrollLock,
NamedKey::Menu => KeyCode::Menu,
},
};
let mut mods = KeyModifiers::empty();
if modifiers.shift {
mods |= KeyModifiers::SHIFT;
}
if modifiers.ctrl {
mods |= KeyModifiers::CONTROL;
}
if modifiers.alt {
mods |= KeyModifiers::ALT;
}
if modifiers.cmd {
mods |= KeyModifiers::SUPER;
}
let kind = if repeat {
KeyEventKind::Repeat
} else {
KeyEventKind::Press
};
Some(KeyEvent {
code,
modifiers: mods,
kind,
state: KeyEventState::empty(),
})
}
pub fn synth_mouseevent(ev: &UiEvent) -> Option<MouseEvent> {
let (kind, position, modifiers) = match ev {
UiEvent::MouseDown {
button,
position,
modifiers,
..
} => (
MouseEventKind::Down(quadraui_button_to_crossterm(*button)?),
*position,
*modifiers,
),
UiEvent::MouseUp {
button, position, ..
} => (
MouseEventKind::Up(quadraui_button_to_crossterm(*button)?),
*position,
Modifiers::default(),
),
UiEvent::MouseMoved { position, buttons } => {
let kind = if buttons.left {
MouseEventKind::Drag(CtMouseButton::Left)
} else if buttons.right {
MouseEventKind::Drag(CtMouseButton::Right)
} else if buttons.middle {
MouseEventKind::Drag(CtMouseButton::Middle)
} else {
MouseEventKind::Moved
};
(kind, *position, Modifiers::default())
}
UiEvent::Scroll {
delta, position, ..
} => {
let kind = if delta.y > 0.0 {
MouseEventKind::ScrollUp
} else if delta.y < 0.0 {
MouseEventKind::ScrollDown
} else if delta.x < 0.0 {
MouseEventKind::ScrollLeft
} else {
MouseEventKind::ScrollRight
};
(kind, *position, Modifiers::default())
}
_ => return None,
};
Some(MouseEvent {
kind,
column: position.x.max(0.0) as u16,
row: position.y.max(0.0) as u16,
modifiers: quadraui_modifiers_to_crossterm(modifiers),
})
}
fn quadraui_button_to_crossterm(b: MouseButton) -> Option<CtMouseButton> {
match b {
MouseButton::Left => Some(CtMouseButton::Left),
MouseButton::Right => Some(CtMouseButton::Right),
MouseButton::Middle => Some(CtMouseButton::Middle),
_ => None,
}
}
fn quadraui_modifiers_to_crossterm(m: Modifiers) -> KeyModifiers {
let mut out = KeyModifiers::empty();
if m.shift {
out |= KeyModifiers::SHIFT;
}
if m.ctrl {
out |= KeyModifiers::CONTROL;
}
if m.alt {
out |= KeyModifiers::ALT;
}
if m.cmd {
out |= KeyModifiers::SUPER;
}
out
}
fn button_mask_with_held(b: CtMouseButton) -> ButtonMask {
match b {
CtMouseButton::Left => ButtonMask {
left: true,
right: false,
middle: false,
},
CtMouseButton::Right => ButtonMask {
left: false,
right: true,
middle: false,
},
CtMouseButton::Middle => ButtonMask {
left: false,
right: false,
middle: true,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton as CtMouseButton,
MouseEvent, MouseEventKind,
};
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
}
}
fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::empty(),
}
}
#[test]
fn char_keypress_translates_to_keypressed_char() {
let ev = crossterm_key_to_uievent(key(KeyCode::Char('a'), KeyModifiers::empty()));
assert_eq!(
ev,
Some(UiEvent::KeyPressed {
key: Key::Char('a'),
modifiers: Modifiers::default(),
repeat: false,
})
);
}
#[test]
fn ctrl_p_translates_with_ctrl_modifier() {
let ev = crossterm_key_to_uievent(key(KeyCode::Char('p'), KeyModifiers::CONTROL)).unwrap();
match ev {
UiEvent::KeyPressed { key, modifiers, .. } => {
assert_eq!(key, Key::Char('p'));
assert!(modifiers.ctrl);
assert!(!modifiers.shift);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn named_keys_translate() {
for (code, named) in &[
(KeyCode::Esc, NamedKey::Escape),
(KeyCode::Enter, NamedKey::Enter),
(KeyCode::Backspace, NamedKey::Backspace),
(KeyCode::Tab, NamedKey::Tab),
(KeyCode::BackTab, NamedKey::BackTab),
(KeyCode::Up, NamedKey::Up),
(KeyCode::Down, NamedKey::Down),
(KeyCode::Left, NamedKey::Left),
(KeyCode::Right, NamedKey::Right),
(KeyCode::PageUp, NamedKey::PageUp),
(KeyCode::PageDown, NamedKey::PageDown),
(KeyCode::Home, NamedKey::Home),
(KeyCode::End, NamedKey::End),
(KeyCode::Delete, NamedKey::Delete),
(KeyCode::Insert, NamedKey::Insert),
(KeyCode::F(1), NamedKey::F(1)),
(KeyCode::F(12), NamedKey::F(12)),
] {
let ev = crossterm_key_to_uievent(key(*code, KeyModifiers::empty())).unwrap();
match ev {
UiEvent::KeyPressed { key, .. } => {
assert_eq!(key, Key::Named(*named), "for code {:?}", code);
}
other => panic!("unexpected variant: {:?}", other),
}
}
}
#[test]
fn key_release_drops() {
let mut ev = key(KeyCode::Char('a'), KeyModifiers::empty());
ev.kind = KeyEventKind::Release;
assert!(crossterm_key_to_uievent(ev).is_none());
}
#[test]
fn key_repeat_marks_repeat_true() {
let mut ev = key(KeyCode::Char('a'), KeyModifiers::empty());
ev.kind = KeyEventKind::Repeat;
let translated = crossterm_key_to_uievent(ev).unwrap();
match translated {
UiEvent::KeyPressed { repeat, .. } => assert!(repeat),
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn mouse_down_left_translates() {
let ev =
crossterm_mouse_to_uievent(mouse(MouseEventKind::Down(CtMouseButton::Left), 10, 5))
.unwrap();
assert!(matches!(
ev,
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
..
}
));
if let UiEvent::MouseDown { position, .. } = ev {
assert_eq!(position.x, 10.0);
assert_eq!(position.y, 5.0);
}
}
#[test]
fn mouse_drag_carries_held_button() {
let ev =
crossterm_mouse_to_uievent(mouse(MouseEventKind::Drag(CtMouseButton::Right), 12, 8))
.unwrap();
match ev {
UiEvent::MouseMoved { buttons, .. } => {
assert!(!buttons.left);
assert!(buttons.right);
assert!(!buttons.middle);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn mouse_scroll_up_translates_to_positive_y() {
let ev = crossterm_mouse_to_uievent(mouse(MouseEventKind::ScrollUp, 0, 0)).unwrap();
match ev {
UiEvent::Scroll { delta, .. } => {
assert_eq!(delta.y, 1.0);
assert_eq!(delta.x, 0.0);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn mouse_scroll_down_translates_to_negative_y() {
let ev = crossterm_mouse_to_uievent(mouse(MouseEventKind::ScrollDown, 0, 0)).unwrap();
match ev {
UiEvent::Scroll { delta, .. } => assert_eq!(delta.y, -1.0),
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn unscaled_mouse_translation_is_byte_identical_to_scaled_with_unit_cell() {
let raw = mouse(MouseEventKind::Down(CtMouseButton::Left), 37, 21);
let unscaled = crossterm_mouse_to_uievent(raw);
let scaled = crossterm_mouse_to_uievent_scaled(raw, UNSCALED_CELL);
assert_eq!(unscaled, scaled);
match unscaled.unwrap() {
UiEvent::MouseDown { position, .. } => {
assert_eq!(position.x, 37.0);
assert_eq!(position.y, 21.0);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn pixel_mode_scaling_produces_fractional_coordinates() {
let cell = TerminalCellSize::new(10.0, 20.0);
let raw = mouse(MouseEventKind::Moved, 25, 13);
let ev = crossterm_mouse_to_uievent_scaled(raw, cell).unwrap();
match ev {
UiEvent::MouseMoved { position, .. } => {
assert_eq!(position.x, 2.5);
assert!((position.y - 0.65).abs() < f32::EPSILON);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn pixel_mode_resolves_finer_than_a_single_cell() {
let cell = TerminalCellSize::new(10.0, 20.0);
let raw_columns = [10u16, 13, 16, 19];
let pixel_mode_xs: Vec<f32> = raw_columns
.iter()
.map(|&col| {
let ev =
crossterm_mouse_to_uievent_scaled(mouse(MouseEventKind::Moved, col, 0), cell)
.unwrap();
match ev {
UiEvent::MouseMoved { position, .. } => position.x,
other => panic!("unexpected variant: {:?}", other),
}
})
.collect();
let mut cell_quantised: Vec<i32> = pixel_mode_xs.iter().map(|x| x.floor() as i32).collect();
cell_quantised.dedup();
assert_eq!(
cell_quantised.len(),
1,
"cell-quantised reporting of this same travel must collapse to one position, \
got {pixel_mode_xs:?}"
);
let mut distinct_pixel_mode: Vec<f32> = pixel_mode_xs.clone();
distinct_pixel_mode.dedup_by(|a, b| (*a - *b).abs() < f32::EPSILON);
assert!(
distinct_pixel_mode.len() > 1,
"pixel-mode division must yield several distinct positions across one cell's \
worth of pixel travel, got {pixel_mode_xs:?}"
);
}
#[test]
fn degenerate_cell_size_falls_back_to_identity_instead_of_producing_nan() {
let raw = mouse(MouseEventKind::Down(CtMouseButton::Left), 37, 21);
for degenerate in [
TerminalCellSize::new(0.0, 0.0),
TerminalCellSize::new(-1.0, 1.0),
TerminalCellSize::new(1.0, -1.0),
] {
let ev = crossterm_mouse_to_uievent_scaled(raw, degenerate).unwrap();
match ev {
UiEvent::MouseDown { position, .. } => {
assert!(position.x.is_finite() && position.y.is_finite());
assert_eq!(position.x, 37.0);
assert_eq!(position.y, 21.0);
}
other => panic!("unexpected variant: {:?}", other),
}
}
}
#[test]
fn resize_translates_to_window_resized() {
let evs = crossterm_to_uievents(CtEvent::Resize(120, 40));
assert_eq!(evs.len(), 1);
match &evs[0] {
UiEvent::WindowResized { viewport } => {
assert_eq!(viewport.width, 120.0);
assert_eq!(viewport.height, 40.0);
assert_eq!(viewport.scale, 1.0);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn paste_translates_to_clipboard_paste() {
let evs = crossterm_to_uievents(CtEvent::Paste("hello".into()));
assert_eq!(evs, vec![UiEvent::ClipboardPaste("hello".into())]);
}
#[test]
fn focus_gained_translates() {
let evs = crossterm_to_uievents(CtEvent::FocusGained);
assert_eq!(evs, vec![UiEvent::WindowFocused(true)]);
}
#[test]
fn focus_lost_translates() {
let evs = crossterm_to_uievents(CtEvent::FocusLost);
assert_eq!(evs, vec![UiEvent::WindowFocused(false)]);
}
#[test]
fn shift_ctrl_modifiers_combine() {
let mods = crossterm_modifiers_to_quadraui(KeyModifiers::SHIFT | KeyModifiers::CONTROL);
assert!(mods.shift);
assert!(mods.ctrl);
assert!(!mods.alt);
assert!(!mods.cmd);
}
#[test]
fn super_modifier_maps_to_cmd() {
let mods = crossterm_modifiers_to_quadraui(KeyModifiers::SUPER);
assert!(mods.cmd);
}
#[test]
fn key_roundtrips_through_synth() {
for code in &[
KeyCode::Char('a'),
KeyCode::Char('Z'),
KeyCode::Esc,
KeyCode::Enter,
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Backspace,
KeyCode::Delete,
KeyCode::Up,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Insert,
KeyCode::F(7),
] {
let original = key(*code, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
let ui = crossterm_key_to_uievent(original).unwrap();
let UiEvent::KeyPressed {
key: k,
modifiers,
repeat,
} = ui
else {
panic!("not a key press for {:?}", code);
};
let round = synth_keyevent(&k, modifiers, repeat).unwrap();
assert_eq!(round.code, *code, "code mismatch for {:?}", code);
assert_eq!(round.modifiers, original.modifiers);
assert_eq!(round.kind, KeyEventKind::Press);
}
}
#[test]
fn mouse_down_roundtrips() {
let original = mouse(MouseEventKind::Down(CtMouseButton::Left), 7, 12);
let ui = crossterm_mouse_to_uievent(original).unwrap();
let round = synth_mouseevent(&ui).unwrap();
assert!(matches!(
round.kind,
MouseEventKind::Down(CtMouseButton::Left)
));
assert_eq!(round.column, 7);
assert_eq!(round.row, 12);
}
#[test]
fn mouse_drag_roundtrips() {
let original = mouse(MouseEventKind::Drag(CtMouseButton::Right), 30, 5);
let ui = crossterm_mouse_to_uievent(original).unwrap();
let round = synth_mouseevent(&ui).unwrap();
assert!(matches!(
round.kind,
MouseEventKind::Drag(CtMouseButton::Right)
));
}
#[test]
fn scroll_up_roundtrips() {
let original = mouse(MouseEventKind::ScrollUp, 0, 0);
let ui = crossterm_mouse_to_uievent(original).unwrap();
let round = synth_mouseevent(&ui).unwrap();
assert!(matches!(round.kind, MouseEventKind::ScrollUp));
}
}