xkb_type/keyboard.rs
1//! Virtual keyboard via evdev/uinput.
2//!
3//! Creates a virtual keyboard device via `/dev/uinput` and injects key events
4//! using the reverse XKB keymap to produce the correct characters regardless
5//! of the user's keyboard layout.
6
7use std::thread;
8use std::time::Duration;
9
10use anyhow::Context;
11use evdev::{uinput::VirtualDevice, AttributeSet, EventType, InputEvent, Key};
12// Logging via the `log` crate (only active with `logging` feature).
13#[cfg(feature = "logging")]
14use log::{debug, warn};
15#[cfg(not(feature = "logging"))]
16macro_rules! debug {
17 ($($arg:tt)*) => {};
18}
19#[cfg(not(feature = "logging"))]
20macro_rules! warn {
21 ($($arg:tt)*) => {};
22}
23
24use crate::keymap::{KeyboardLayout, XkbKeymap};
25use crate::{default_clipboard, ClipboardBackend, KeyTap};
26
27/// Delay after creating the virtual device on the runtime path (e.g. when
28/// the daemon re-creates the keyboard after an error). Short, because this
29/// runs on the user's typing path — every error-recovery would otherwise
30/// stall typing.
31const DEVICE_SETTLE_DELAY: Duration = Duration::from_millis(200);
32
33/// Longer delay used at daemon startup (prewarm), so X11 has time to
34/// process MappingNotify and attach the new device's keymap before the
35/// first character is typed. Without this, the very first typed character
36/// after boot can race the X11 keymap refresh and drop accented keys
37/// (notably `<LVL3>` characters like `é` on `us:qwerty-fr`).
38const INITIAL_WARMUP_DELAY: Duration = Duration::from_secs(1);
39
40/// Virtual keyboard that injects keystrokes via uinput.
41pub struct Keyboard {
42 device: VirtualDevice,
43 keymap: XkbKeymap,
44 clipboard: Box<dyn ClipboardBackend>,
45 key_delay: Duration,
46}
47
48impl Keyboard {
49 /// Create a new virtual keyboard with auto-detected layout and clipboard.
50 ///
51 /// This is the most ergonomic constructor: it picks up the active
52 /// XKB layout from the compositor (Hyprland / Sway / env vars), picks
53 /// the right clipboard backend for the current display server, and
54 /// builds the uinput device.
55 ///
56 /// `key_delay` is the inter-event delay. Raise it for TUIs that drop
57 /// characters in raw mode (e.g. Node/Ink-based apps like Claude Code).
58 ///
59 /// Requires write access to `/dev/uinput`.
60 ///
61 /// For full control over keymap/clipboard, see
62 /// [`Keyboard::with_components`]; to force a specific XKB layout, see
63 /// [`Keyboard::with_layout`].
64 pub fn new(key_delay: Duration) -> anyhow::Result<Self> {
65 let layout = KeyboardLayout::detect();
66 let keymap = XkbKeymap::from_layout(&layout)?;
67 Self::with_components(keymap, default_clipboard(), key_delay)
68 }
69
70 /// Like [`Keyboard::new`], but uses a longer post-creation settle
71 /// delay suited for the **first** virtual keyboard created in a
72 /// process. Call this from a startup prewarm so X11 has time to
73 /// process MappingNotify and attach the new device's keymap before
74 /// any character is typed.
75 ///
76 /// Subsequent recreates (e.g. on error recovery) should keep using
77 /// [`Keyboard::new`], which uses a shorter settle so the user's
78 /// typing path is not stalled by 1s on every recovery.
79 pub fn new_prewarm(key_delay: Duration) -> anyhow::Result<Self> {
80 let layout = KeyboardLayout::detect();
81 let keymap = XkbKeymap::from_layout(&layout)?;
82 Self::with_components_settle(keymap, default_clipboard(), key_delay, INITIAL_WARMUP_DELAY)
83 }
84
85 /// Create a virtual keyboard for a specific XKB layout (and optional
86 /// variant), with the auto-detected clipboard backend.
87 ///
88 /// Useful when the application already knows the user's layout and
89 /// wants to skip the compositor probe (e.g. it's stored in a config
90 /// file).
91 pub fn with_layout(
92 layout: &str,
93 variant: Option<&str>,
94 key_delay: Duration,
95 ) -> anyhow::Result<Self> {
96 let detected = KeyboardLayout {
97 layout: layout.to_string(),
98 variant: variant.unwrap_or("").to_string(),
99 };
100 let keymap = XkbKeymap::from_layout(&detected)?;
101 Self::with_components(keymap, default_clipboard(), key_delay)
102 }
103
104 /// Create a virtual keyboard from explicit components (keymap +
105 /// clipboard backend).
106 ///
107 /// Use this when you need to inject a custom [`XkbKeymap`] (e.g. for
108 /// tests) or a non-default clipboard backend
109 /// ([`crate::NoopClipboard`] / [`crate::WaylandClipboard`] /
110 /// [`crate::X11Clipboard`]).
111 ///
112 /// Requires write access to `/dev/uinput` (user must be in the `input`
113 /// group or have the appropriate udev rule installed).
114 ///
115 /// `key_delay` is the inter-event delay. Raise it for TUIs that drop
116 /// characters in raw mode (e.g. Node/Ink-based apps like Claude Code).
117 pub fn with_components(
118 keymap: XkbKeymap,
119 clipboard: Box<dyn ClipboardBackend>,
120 key_delay: Duration,
121 ) -> anyhow::Result<Self> {
122 Self::with_components_settle(keymap, clipboard, key_delay, DEVICE_SETTLE_DELAY)
123 }
124
125 /// Same as [`Keyboard::with_components`], but with an explicit
126 /// post-creation settle delay. Kept private — callers should pick
127 /// between [`Keyboard::new`] (short settle, runtime path) and
128 /// [`Keyboard::new_prewarm`] (long settle, startup path).
129 fn with_components_settle(
130 keymap: XkbKeymap,
131 clipboard: Box<dyn ClipboardBackend>,
132 key_delay: Duration,
133 settle: Duration,
134 ) -> anyhow::Result<Self> {
135 // Register all key codes we might need (1..=247 covers standard
136 // keys; KEY_MAX on Linux is ~767 but codes beyond 247 are rare).
137 let mut keys = AttributeSet::<Key>::new();
138 for code in 1..=247 {
139 keys.insert(Key::new(code));
140 }
141
142 let device = evdev::uinput::VirtualDeviceBuilder::new()
143 .context("failed to create VirtualDeviceBuilder")?
144 .name("whisrs virtual keyboard")
145 .with_keys(&keys)
146 .context("failed to register key events")?
147 .build()
148 .context("failed to build uinput virtual device — check /dev/uinput permissions")?;
149
150 // Give the kernel (and on X11, the server) time to register the
151 // new device and refresh its keymap before any keys are sent.
152 thread::sleep(settle);
153
154 debug!("uinput virtual keyboard created");
155
156 Ok(Self {
157 device,
158 keymap,
159 clipboard,
160 key_delay,
161 })
162 }
163
164 // -----------------------------------------------------------------------
165 // Public API
166 // -----------------------------------------------------------------------
167
168 pub fn set_key_delay(&mut self, key_delay: Duration) {
169 self.key_delay = key_delay;
170 }
171
172 /// Type `text` by injecting keystrokes through the virtual device.
173 ///
174 /// Characters found in the keymap are typed via direct key events
175 /// (with Shift/AltGr modifiers as needed, including dead-key sequences).
176 /// Characters *not* found in the keymap are accumulated and pasted as
177 /// a batch via the clipboard (Ctrl+V), with clipboard save/restore
178 /// so the user's clipboard is not permanently overwritten.
179 pub fn type_text(&mut self, text: &str) -> anyhow::Result<()> {
180 self.release_all_modifiers()?;
181
182 let mut paste_buf = String::new();
183
184 for ch in text.chars() {
185 // Copy the lookup result to release the immutable borrow on
186 // self.keymap before calling mutable methods below.
187 let mapping = self.keymap.lookup(ch).copied();
188 if let Some(mapping) = mapping {
189 // Flush any pending paste buffer first.
190 if !paste_buf.is_empty() {
191 self.paste_text(&paste_buf)?;
192 paste_buf.clear();
193 }
194 self.tap_key(&mapping.main)?;
195 if let Some(follow) = mapping.follow.as_ref() {
196 self.tap_key(follow)?;
197 }
198 } else {
199 // Character not in keymap — accumulate for clipboard paste.
200 paste_buf.push(ch);
201 }
202 }
203
204 // Flush remaining paste buffer.
205 if !paste_buf.is_empty() {
206 self.paste_text(&paste_buf)?;
207 }
208
209 Ok(())
210 }
211
212 /// Emit Backspace `count` times.
213 pub fn backspace(&mut self, count: usize) -> anyhow::Result<()> {
214 self.release_all_modifiers()?;
215
216 for _ in 0..count {
217 self.tap_key(&KeyTap {
218 keycode: Key::KEY_BACKSPACE.code(),
219 shift: false,
220 altgr: false,
221 })?;
222 }
223
224 Ok(())
225 }
226
227 /// Press all keys in `keys`, then release them in reverse order.
228 ///
229 /// Each press and release is separated by `self.key_delay`.
230 pub fn send_combo(&mut self, keys: &[Key]) -> anyhow::Result<()> {
231 // Press all keys in order.
232 for key in keys {
233 self.device
234 .emit(&[InputEvent::new(EventType::KEY, key.code(), 1)])?;
235 thread::sleep(self.key_delay);
236 }
237
238 // Release in reverse order.
239 for key in keys.iter().rev() {
240 self.device
241 .emit(&[InputEvent::new(EventType::KEY, key.code(), 0)])?;
242 thread::sleep(self.key_delay);
243 }
244
245 Ok(())
246 }
247
248 // -----------------------------------------------------------------------
249 // Internal helpers
250 // -----------------------------------------------------------------------
251
252 /// Press or release a modifier key.
253 fn set_modifier(&mut self, modifier: Key, pressed: bool) -> anyhow::Result<()> {
254 let value = if pressed { 1 } else { 0 };
255 self.device
256 .emit(&[InputEvent::new(EventType::KEY, modifier.code(), value)])?;
257 thread::sleep(self.key_delay);
258 Ok(())
259 }
260
261 /// Press and release a single key, with Shift and/or AltGr held as needed.
262 ///
263 /// AltGr is the keycode XKB exposes for `ISO_Level3_Shift`. On some
264 /// X11 layouts that is a dedicated `<LVL3>` keycode rather than
265 /// `KEY_RIGHTALT`; using the XKB keycode keeps third-level characters
266 /// like `é` working with uinput.
267 fn tap_key(&mut self, tap: &KeyTap) -> anyhow::Result<()> {
268 let level3_key = Key::new(self.keymap.level3_keycode());
269
270 if tap.shift {
271 self.set_modifier(Key::KEY_LEFTSHIFT, true)?;
272 }
273 if tap.altgr {
274 self.set_modifier(level3_key, true)?;
275 }
276
277 self.device
278 .emit(&[InputEvent::new(EventType::KEY, tap.keycode, 1)])?;
279 thread::sleep(self.key_delay);
280 self.device
281 .emit(&[InputEvent::new(EventType::KEY, tap.keycode, 0)])?;
282 thread::sleep(self.key_delay);
283
284 if tap.altgr {
285 self.set_modifier(level3_key, false)?;
286 }
287 if tap.shift {
288 self.set_modifier(Key::KEY_LEFTSHIFT, false)?;
289 }
290
291 Ok(())
292 }
293
294 /// Release all modifier keys to prevent interference with injected text.
295 fn release_all_modifiers(&mut self) -> anyhow::Result<()> {
296 let level3_key = Key::new(self.keymap.level3_keycode());
297 let modifiers = [
298 Key::KEY_LEFTSHIFT,
299 Key::KEY_RIGHTSHIFT,
300 Key::KEY_LEFTCTRL,
301 Key::KEY_RIGHTCTRL,
302 Key::KEY_LEFTALT,
303 Key::KEY_RIGHTALT,
304 level3_key,
305 Key::KEY_LEFTMETA,
306 Key::KEY_RIGHTMETA,
307 ];
308
309 for modifier in &modifiers {
310 self.device
311 .emit(&[InputEvent::new(EventType::KEY, modifier.code(), 0)])?;
312 }
313 thread::sleep(self.key_delay);
314
315 Ok(())
316 }
317
318 /// Inject Ctrl+V to paste from clipboard.
319 fn inject_ctrl_v(&mut self) -> anyhow::Result<()> {
320 self.set_modifier(Key::KEY_LEFTCTRL, true)?;
321 self.device
322 .emit(&[InputEvent::new(EventType::KEY, Key::KEY_V.code(), 1)])?;
323 thread::sleep(self.key_delay);
324 self.device
325 .emit(&[InputEvent::new(EventType::KEY, Key::KEY_V.code(), 0)])?;
326 thread::sleep(self.key_delay);
327 self.set_modifier(Key::KEY_LEFTCTRL, false)?;
328 Ok(())
329 }
330
331 /// Set clipboard to `text`, inject Ctrl+V, then restore previous content.
332 fn paste_text(&mut self, text: &str) -> anyhow::Result<()> {
333 // Save current clipboard.
334 let saved = self.clipboard.get_text().ok();
335
336 // Set new clipboard content.
337 self.clipboard
338 .set_text(text)
339 .context("failed to set clipboard for paste")?;
340
341 // Small delay to ensure clipboard is ready.
342 thread::sleep(Duration::from_millis(10));
343
344 // Inject Ctrl+V.
345 self.release_all_modifiers()?;
346 self.inject_ctrl_v()?;
347
348 // Small delay before restoring clipboard.
349 thread::sleep(Duration::from_millis(50));
350
351 // Restore previous clipboard content.
352 if let Some(previous) = saved {
353 if let Err(_e) = self.clipboard.set_text(&previous) {
354 warn!("failed to restore clipboard: {_e}");
355 }
356 }
357
358 Ok(())
359 }
360}