1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! Translation from KERNEL's parsed [`crate::input::Event`]s to the ui
//! routing vocabulary, plus the default quit policy.
//!
//! The two vocabularies stay separate on purpose: `input` speaks the
//! terminal's full richness (kitty release/repeat events, lock-key
//! modifiers, caps replies), `ui::event` speaks what ROUTING needs
//! (identity + modifiers + position). This is the lossy, documented seam
//! — everything dropped here is listed on `convert_event`.
use crate::input::{Event, KeyCode, KeyEventKind, Mods as InMods, MouseKind as InMouseKind};
use crate::ui::{Key, KeyEvent, Mods, MouseButton, MouseEvent, MouseKind, UiEvent};
/// Kernel modifier bits -> ui modifier bits. The two layouts differ
/// (kernel mirrors the kitty wire encoding; ui packs for chord tables),
/// so this must map field-by-field, never transmute. Lock keys are
/// dropped: Ctrl+S must match whether or not caps lock is latched.
pub(crate) fn convert_mods(m: InMods) -> Mods {
let mut out = Mods::NONE;
if m.contains(InMods::SHIFT) {
out = out | Mods::SHIFT;
}
if m.contains(InMods::CTRL) {
out = out | Mods::CTRL;
}
if m.contains(InMods::ALT) {
out = out | Mods::ALT;
}
if m.contains(InMods::SUPER) {
out = out | Mods::SUPER;
}
out
}
/// Key-identity conversion, shared with the key-state service
/// (`app::keys` taps the pre-conversion stream and must speak the same
/// vocabulary routing does).
pub(crate) fn convert_key(code: KeyCode) -> Option<Key> {
Some(match code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Enter => Key::Enter,
KeyCode::Tab => Key::Tab,
KeyCode::Backspace => Key::Backspace,
KeyCode::Esc => Key::Escape,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Insert => Key::Insert,
KeyCode::Delete => Key::Delete,
KeyCode::F(n) => Key::F(n),
// Bare modifiers, lock keys, media keys, unidentified: routing has
// no binding vocabulary for these yet (ui::Key grows when a widget
// needs one — extension, not re-parse).
KeyCode::CapsLock
| KeyCode::ScrollLock
| KeyCode::NumLock
| KeyCode::PrintScreen
| KeyCode::Pause
| KeyCode::Menu
| KeyCode::Modifier(_)
| KeyCode::Functional(_)
| KeyCode::Unidentified => return None,
})
}
/// Convert a kernel event into a routable ui event.
///
/// Returns `None` for events routing cannot use yet (documented drops):
/// key RELEASE events (kitty; press/repeat dispatch), bare-modifier and
/// unnamed functional keys, wheel-left/right, mouse buttons past the
/// classic three, paste (cycle-3 text-input work), terminal focus
/// in/out (distinct from widget focus — wiring them to hover/focus
/// policy is a widgets decision), caps replies (handled by the driver's
/// probe), resize (handled by the driver as geometry, not routing), and
/// `Unknown` (deliberately swallowed garbage).
pub(crate) fn convert_event(event: &Event) -> Option<UiEvent> {
match event {
Event::Key(k) => {
if k.kind == KeyEventKind::Release {
return None;
}
let key = convert_key(k.code)?;
Some(UiEvent::Key(KeyEvent::new(
key,
convert_mods(k.mods.without_locks()),
)))
}
Event::Mouse(m) => {
let button = |b: crate::input::MouseButton| -> Option<MouseButton> {
match b {
crate::input::MouseButton::Left => Some(MouseButton::Left),
crate::input::MouseButton::Middle => Some(MouseButton::Middle),
crate::input::MouseButton::Right => Some(MouseButton::Right),
_ => None,
}
};
let kind = match m.kind {
InMouseKind::Down => MouseKind::Down(button(m.button)?),
InMouseKind::Up => MouseKind::Up(button(m.button)?),
InMouseKind::Drag => MouseKind::Drag(button(m.button)?),
InMouseKind::Move => MouseKind::Move,
InMouseKind::WheelUp => MouseKind::ScrollUp,
InMouseKind::WheelDown => MouseKind::ScrollDown,
// Horizontal wheels are real on macOS trackpads (KERNEL
// trap 3): routed since cycle 3.
InMouseKind::WheelLeft => MouseKind::ScrollLeft,
InMouseKind::WheelRight => MouseKind::ScrollRight,
};
Some(UiEvent::Mouse(MouseEvent {
pos: m.pos,
kind,
mods: convert_mods(m.mods),
}))
}
// Paste routes WHOLE to the focused widget (KERNEL trap 4: never
// synthesized into per-char keys — that would reintroduce the
// injection attack bracketed paste exists to prevent).
Event::Paste(s) => Some(UiEvent::Paste(s.clone())),
Event::FocusGained
| Event::FocusLost
| Event::Resize(_)
| Event::CapsReply(_)
| Event::Unknown(_) => None,
}
}
/// Default quit chord: Ctrl+C. Raw mode clears ISIG, so Ctrl+C arrives as
/// an ordinary key event and quit policy belongs here (KERNEL request 3).
/// An app overrides by CONSUMING the event: any handler/shortcut that
/// handles Ctrl+C (returns consumed from dispatch) suppresses the default.
pub(crate) fn is_default_quit(event: &UiEvent) -> bool {
matches!(
event,
UiEvent::Key(k) if k.key == Key::Char('c') && k.mods == Mods::CTRL
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::KeyEvent as InKeyEvent;
#[test]
fn keys_and_mods_map_field_by_field() {
// Kernel ALT=2/CTRL=4 vs ui CTRL=2/ALT=4: transmuting would swap
// them — this is the regression the test pins.
let ev = Event::Key(InKeyEvent::char('s').with_mods(InMods::CTRL | InMods::ALT));
let Some(UiEvent::Key(k)) = convert_event(&ev) else {
panic!("must convert")
};
assert_eq!(k.key, Key::Char('s'));
assert!(k.mods.contains(Mods::CTRL) && k.mods.contains(Mods::ALT));
assert!(!k.mods.contains(Mods::SHIFT));
}
#[test]
fn locks_strip_and_releases_drop() {
let ev = Event::Key(InKeyEvent::char('s').with_mods(InMods::CTRL | InMods::CAPS_LOCK));
let Some(UiEvent::Key(k)) = convert_event(&ev) else {
panic!()
};
assert_eq!(k.mods, Mods::CTRL, "caps lock must not break chords");
let mut rel = InKeyEvent::char('a');
rel.kind = KeyEventKind::Release;
assert_eq!(convert_event(&Event::Key(rel)), None);
}
#[test]
fn ctrl_c_is_the_default_quit() {
let ev = Event::Key(InKeyEvent::char('c').with_mods(InMods::CTRL));
let ui = convert_event(&ev).expect("converts");
assert!(is_default_quit(&ui));
let plain = convert_event(&Event::Key(InKeyEvent::plain(KeyCode::Char('c')))).unwrap();
assert!(!is_default_quit(&plain));
}
}