on-screen keyboard
git clone https://git.lucas.co/cce-keyboard.git
src/main.rs (19.9K)
1 //! cce-keyboard — an on-screen keyboard.
2 //!
3 //! A board of keys on an Overlay-layer surface along the bottom of the
4 //! screen. The surface takes no keyboard focus, so pressing a key never
5 //! moves focus off the window being typed into; the key is sent to that
6 //! window through a `zwp_virtual_keyboard_v1` device (`vkbd.rs`) carrying the
7 //! session's own keymap (`keymap.rs`), which also labels the character keys.
8 //!
9 //! Keys act on press and release with the pointer, so holding one repeats in
10 //! the focused window like a held hardware key. Shift, Ctrl, Alt and Super
11 //! latch: a tap applies to the next key, a second tap locks (rim lit), a
12 //! third releases. Fn latches the same way and swaps the number row to
13 //! F1–F12 / Del and the arrows to Home / PgDn / PgUp / End (Esc becomes `).
14 //!
15 //! One instance per session. `cce-keyboard [toggle|show|hide]` (default
16 //! `toggle`) forwards to the running board over `cce_ui::ipc::instance`, or
17 //! becomes it; hiding exits, so a closed board costs nothing — bind
18 //! `cce-keyboard` in input.kdl to summon it.
19 //!
20 //! Config (`~/.config/cce/cce-keyboard/config.kdl`), read at startup:
21 //! `height 280` (logical px) · `width 0` (0 spans the output) ·
22 //! `margin 0` (px above the bottom edge) · `reserve true` (windows tile
23 //! above the board rather than under it). The height is capped to fit the
24 //! smallest output (`Config::fit`).
25
26 mod keymap;
27 mod layout;
28 mod vkbd;
29
30 use std::sync::Mutex;
31
32 use cce_ui::colors::{button_background_color, button_hover_color, button_press_color, control_label_color_u8};
33 use cce_ui::engine::{
34 Application, LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings, LogicalPosition,
35 LogicalSize, WindowSettings,
36 };
37 use cce_ui::layout::{button_corner_radius, button_font, button_height, control_gap, parse_font_string, root_plate_inset};
38 use cce_ui::scene::layout::Rect;
39 use cce_ui::scene::paint::{AlignH, AlignV, ControlPlate, DisplayList, PaintCtx, PlateStance, TextAttrs, TextLayout};
40 use cce_ui::scene::Material;
41 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
42
43 use keymap::Keymap;
44 use layout::{Action, KeyDef, KeyRect, Modifier};
45 use vkbd::VirtualKeyboard;
46
47 /// The instance socket: `/tmp/cce-keyboard-<WAYLAND_DISPLAY>.sock`.
48 const SOCKET_PREFIX: &str = "cce-keyboard";
49
50 struct Config {
51 height: u32,
52 /// 0 spans the output.
53 width: u32,
54 margin: i32,
55 reserve: bool,
56 }
57
58 impl Config {
59 fn load() -> Self {
60 let mut config = Config { height: 280, width: 0, margin: 0, reserve: true };
61 let path = cce_ui::config::get_app_config_path(SOCKET_PREFIX);
62 let Ok(text) = std::fs::read_to_string(&path) else { return config };
63 let doc = match text.parse::<kdl::KdlDocument>() {
64 Ok(doc) => doc,
65 Err(e) => {
66 log::warn!("[cce-keyboard] {}: {e} — using defaults", path.display());
67 return config;
68 }
69 };
70 let value = |name: &str| doc.get(name).and_then(|n| n.entries().first()).map(|e| e.value().clone());
71 let int = |name: &str| value(name).and_then(|v| v.as_i64());
72 if let Some(h) = int("height") {
73 config.height = h.clamp(120, 1200) as u32;
74 }
75 if let Some(w) = int("width") {
76 config.width = w.clamp(0, 8000) as u32;
77 }
78 if let Some(m) = int("margin") {
79 config.margin = m.clamp(0, 2000) as i32;
80 }
81 if let Some(r) = value("reserve").and_then(|v| v.as_bool()) {
82 config.reserve = r;
83 }
84 config
85 }
86
87 /// Fit the board to an output of `(w, h)` logical px. The compositor
88 /// closes a layer surface whose exclusive zone leaves less than half the
89 /// output (cce-compositor `layer_shell.rs`, river's rule), so the board
90 /// never takes more than 45% of the height with its margin; a width
91 /// past the output's edge spans it instead.
92 fn fit(&mut self, (w, h): (u32, u32)) {
93 let most = ((h as f32 * 0.45) as i32 - self.margin).max(60) as u32;
94 if self.height > most {
95 log::info!("[cce-keyboard] height {} does not fit a {w}x{h} output — using {most}", self.height);
96 self.height = most;
97 }
98 if self.width > w {
99 self.width = 0;
100 }
101 }
102 }
103
104 /// The smallest enabled output's logical size, from `ccectl outputs --json`
105 /// (one JSON object per line). The board may land on any of them — the
106 /// compositor picks an output for an unassigned layer surface — so it must
107 /// fit the smallest.
108 fn smallest_output() -> Option<(u32, u32)> {
109 let ccectl = std::env::var("HOME")
110 .map(|home| format!("{home}/.local/bin/ccectl"))
111 .ok()
112 .filter(|p| std::path::Path::new(p).exists())
113 .unwrap_or_else(|| "ccectl".to_string());
114 let out = std::process::Command::new(ccectl).args(["outputs", "--json"]).output().ok()?;
115 String::from_utf8_lossy(&out.stdout)
116 .lines()
117 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
118 .filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
119 .filter_map(|o| Some((o.get("logical_w")?.as_u64()? as u32, o.get("logical_h")?.as_u64()? as u32)))
120 .filter(|&(w, h)| w > 0 && h > 0)
121 .min_by_key(|&(w, h)| w as u64 * h as u64)
122 }
123
124 /// A latching key's state: off, for the next key, or until tapped again.
125 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126 enum Latch {
127 #[default]
128 Off,
129 Once,
130 Locked,
131 }
132
133 impl Latch {
134 fn tapped(self) -> Self {
135 match self {
136 Latch::Off => Latch::Once,
137 Latch::Once => Latch::Locked,
138 Latch::Locked => Latch::Off,
139 }
140 }
141
142 /// After a key has been sent: a one-shot latch is used up.
143 fn spent(self) -> Self {
144 if self == Latch::Once {
145 Latch::Off
146 } else {
147 self
148 }
149 }
150
151 fn on(self) -> bool {
152 self != Latch::Off
153 }
154 }
155
156 /// The key the pointer is holding down, and what pressing it sent.
157 struct Held {
158 at: (usize, usize),
159 action: Action,
160 /// The modifiers pressed around it, released after it in reverse.
161 mods: Vec<Modifier>,
162 }
163
164 #[derive(Debug, Clone)]
165 enum Message {
166 Hide,
167 }
168
169 /// The keymap and keyboard `main` set up before the runner starts, for
170 /// `KeyboardApp::new` (`engine::run` takes no arguments).
171 static PARKED: Mutex<Option<(Keymap, VirtualKeyboard)>> = Mutex::new(None);
172
173 struct KeyboardApp {
174 config: Config,
175 rows: Vec<Vec<KeyDef>>,
176 keymap: Keymap,
177 vk: VirtualKeyboard,
178 /// Indexed by [`Modifier::index`].
179 latches: [Latch; 4],
180 fn_latch: Latch,
181 held: Option<Held>,
182 hover: Option<(usize, usize)>,
183 size: (f32, f32),
184 }
185
186 impl KeyboardApp {
187 fn geometry(&self) -> (Vec<Vec<KeyRect>>, f32) {
188 let inset = root_plate_inset();
189 let (w, h) = self.size;
190 let area = (inset, inset, (w - 2.0 * inset).max(0.0), (h - 2.0 * inset).max(0.0));
191 // The ladder's control gap spaces a row of buttons; a board is a
192 // dense grid of them, where that gap would eat a third of each key.
193 // Never wider than it, but no more than a share of the row pitch.
194 let gap = control_gap().min(area.3 / self.rows.len().max(1) as f32 * 0.15);
195 (layout::place(&self.rows, area, gap), gap)
196 }
197
198 fn key_at(&self, pos: LogicalPosition) -> Option<(usize, usize)> {
199 let (rects, gap) = self.geometry();
200 layout::hit(&rects, gap, pos.x, pos.y)
201 }
202
203 fn shift_on(&self) -> bool {
204 self.latches[Modifier::Shift.index()].on()
205 }
206
207 fn press(&mut self, at: (usize, usize)) {
208 let action = self.rows[at.0][at.1].action(self.fn_latch.on());
209 let mut mods = Vec::new();
210 match action {
211 Action::Mod(m) => self.latches[m.index()] = self.latches[m.index()].tapped(),
212 Action::Fn => self.fn_latch = self.fn_latch.tapped(),
213 Action::Hide => {}
214 Action::Code(code) => {
215 mods = Modifier::ALL.into_iter().filter(|m| self.latches[m.index()].on()).collect();
216 for m in &mods {
217 self.vk.key(m.code(), true);
218 }
219 if !mods.is_empty() {
220 self.vk.modifiers(self.keymap.mask(mods.iter().copied()));
221 }
222 self.vk.key(code, true);
223 }
224 }
225 self.held = Some(Held { at, action, mods });
226 }
227
228 /// Let go of the held key; `over` is the key under the pointer now.
229 fn release(&mut self, over: Option<(usize, usize)>) -> Option<Message> {
230 let held = self.held.take()?;
231 match held.action {
232 Action::Code(code) => {
233 self.vk.key(code, false);
234 for m in held.mods.iter().rev() {
235 self.vk.key(m.code(), false);
236 }
237 if !held.mods.is_empty() {
238 self.vk.modifiers(0);
239 }
240 for latch in &mut self.latches {
241 *latch = latch.spent();
242 }
243 self.fn_latch = self.fn_latch.spent();
244 None
245 }
246 // Only a release still on the key closes the board: sliding off
247 // is the way to change your mind.
248 Action::Hide => (over == Some(held.at)).then_some(Message::Hide),
249 Action::Mod(_) | Action::Fn => None,
250 }
251 }
252
253 fn paint_key(&self, pc: &mut PaintCtx, at: (usize, usize), r: KeyRect, row_h: f32) {
254 let action = self.rows[at.0][at.1].action(self.fn_latch.on());
255 let latch = match action {
256 Action::Mod(m) => self.latches[m.index()],
257 Action::Fn => self.fn_latch,
258 _ => Latch::Off,
259 };
260 let down = self.held.as_ref().is_some_and(|h| h.at == at) || latch.on();
261 let face = if down {
262 button_press_color()
263 } else if self.hover == Some(at) {
264 button_hover_color()
265 } else {
266 button_background_color()
267 };
268 let rect = Rect { x: r.x, y: r.y, width: r.w, height: r.h };
269 // A key is a keycap: a raised plate that sinks flush while it is
270 // down — held by the pointer, or a latched modifier. A locked latch
271 // also lights the plate's own rim, the focus-ring treatment.
272 let stance = if down { PlateStance::Flush } else { PlateStance::Raised };
273 let tint = (latch == Latch::Locked).then(ControlPlate::focus_tint);
274 let plate = ControlPlate::control(rect, button_corner_radius(), stance, Material::face(face));
275 pc.control_plate(&plate.with_tint(tint));
276
277 let (family, base) = parse_font_string(&button_font());
278 let size = base.unwrap_or(14.0) * (row_h / button_height().max(1.0)).clamp(1.0, 2.0);
279 let color = control_label_color_u8();
280 let centered = TextLayout {
281 wrap_width: Some(rect.width),
282 box_height: rect.height,
283 align_h: AlignH::Center,
284 align_v: AlignV::Middle,
285 };
286
287 if let Some((name, icon)) = layout::fixed_label(action) {
288 let glyph = icon.and_then(|icon| cce_ui::upload_icon(icon, 64));
289 if let Some((image, iw, ih)) = glyph {
290 let s = (rect.width.min(rect.height) * 0.45).max(4.0);
291 let (iw, ih) = (iw as f32, ih as f32);
292 let (dw, dh) = if iw >= ih { (s, s * ih / iw.max(1.0)) } else { (s * iw / ih.max(1.0), s) };
293 let at = Rect {
294 x: rect.x + (rect.width - dw) / 2.0,
295 y: rect.y + (rect.height - dh) / 2.0,
296 width: dw,
297 height: dh,
298 };
299 pc.image(image, at, 1.0);
300 } else {
301 pc.text_boxed(name, rect.x, rect.y, size * 0.8, color, Some(family), None, TextAttrs::default(), centered);
302 }
303 return;
304 }
305
306 let Action::Code(code) = action else { return };
307 let Some(label) = self.keymap.label(code) else { return };
308 let shift = self.shift_on();
309 let main = if shift { label.shifted.as_deref().or(label.plain.as_deref()) } else { label.plain.as_deref() };
310 if let Some(main) = main {
311 pc.text_boxed(main, rect.x, rect.y, size, color, Some(family.clone()), None, TextAttrs::default(), centered);
312 }
313 if !shift {
314 if let Some(corner) = label.corner() {
315 let small = size * 0.6;
316 let pad = (rect.height * 0.12).max(2.0);
317 let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
318 pc.text_faded(corner, rect.x + pad, rect.y + pad * 0.5, small, color, 0.55, Some(family), bounds);
319 }
320 }
321 }
322 }
323
324 impl Application for KeyboardApp {
325 type Message = Message;
326
327 fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
328 // The app keeps calloop's sender; `AppSender` converts into it.
329 let sender: calloop::channel::Sender<Self::Message> = sender.into();
330 let (keymap, vk) = PARKED
331 .lock()
332 .unwrap_or_else(|e| e.into_inner())
333 .take()
334 .expect("main parks the keymap and keyboard before running");
335 cce_ui::ipc::instance::serve(move |line| match line.trim() {
336 "toggle" | "hide" => {
337 sender.send(Message::Hide).ok()?;
338 Some("hidden".into())
339 }
340 "show" => Some("shown".into()),
341 other => Some(format!("unknown command {other:?} — toggle, show or hide")),
342 });
343 let mut config = Config::load();
344 if let Some(output) = smallest_output() {
345 config.fit(output);
346 }
347 let size = (config.width.max(1) as f32, config.height as f32);
348 Self {
349 config,
350 rows: layout::rows(),
351 keymap,
352 vk,
353 latches: [Latch::Off; 4],
354 fn_latch: Latch::Off,
355 held: None,
356 hover: None,
357 size,
358 }
359 }
360
361 fn settings(&self) -> WindowSettings {
362 WindowSettings {
363 title: "Keyboard".to_string(),
364 app_id: SOCKET_PREFIX.to_string(),
365 width: self.config.width,
366 height: self.config.height,
367 fullscreen: false,
368 min_size: None,
369 }
370 }
371
372 fn layer(&self) -> Option<LayerSettings> {
373 let anchor = if self.config.width == 0 {
374 LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT
375 } else {
376 LayerAnchor::BOTTOM
377 };
378 Some(LayerSettings {
379 // Overlay, so the board also stands over a fullscreen window.
380 layer: LayerKind::Overlay,
381 anchor,
382 exclusive_zone: if self.config.reserve { self.config.height as i32 } else { 0 },
383 // Never take focus: the window being typed into must keep it.
384 keyboard_interactivity: LayerKeyboardInteractivity::None,
385 margin: (0, 0, self.config.margin, 0),
386 namespace: SOCKET_PREFIX.to_string(),
387 })
388 }
389
390 fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
391 match msg {
392 Message::Hide => {
393 // Give up the socket before the close fade, so a summon
394 // during the fade starts a fresh board instead of being
395 // answered by this one and dropped with it.
396 cce_ui::ipc::instance::cleanup();
397 *exit = true;
398 }
399 }
400 }
401
402 fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
403
404 fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
405 self.size = (width, height);
406 }
407
408 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
409 let hover = self.key_at(pos);
410 if hover != self.hover {
411 self.hover = hover;
412 *needs_rebuild = true;
413 }
414 }
415
416 fn handle_mouse_input(
417 &mut self,
418 button: MouseButton,
419 state: ElementState,
420 pos: LogicalPosition,
421 needs_rebuild: &mut bool,
422 ) -> Option<Self::Message> {
423 if button != MouseButton::Left {
424 return None;
425 }
426 *needs_rebuild = true;
427 match state {
428 ElementState::Pressed => {
429 // A second button-down without a release between (a lost
430 // release) lets go of the first key before taking the next.
431 let _ = self.release(None);
432 if let Some(at) = self.key_at(pos) {
433 self.press(at);
434 }
435 None
436 }
437 ElementState::Released => self.release(self.key_at(pos)),
438 }
439 }
440
441 fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
442
443 fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
444 None
445 }
446
447 fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
448 self.size = (size.width, size.height);
449 let (rects, _) = self.geometry();
450 let mut pc = PaintCtx::new();
451 pc.root_plate(size.width, size.height);
452 for (r, row) in rects.iter().enumerate() {
453 for (i, key) in row.iter().enumerate() {
454 self.paint_key(&mut pc, (r, i), *key, key.h);
455 }
456 }
457 Some(pc.finish())
458 }
459
460 fn display_list_text(&self) -> bool {
461 true
462 }
463
464 fn on_exit(&mut self) {
465 // Nothing may stay pressed in the seat once the board is gone; the
466 // keyboard's own Drop releases whatever this misses.
467 let _ = self.release(None);
468 }
469 }
470
471 fn main() {
472 env_logger::init();
473 let command = std::env::args().nth(1).unwrap_or_else(|| "toggle".to_string());
474 match command.as_str() {
475 "toggle" | "show" | "hide" => {}
476 "-h" | "--help" | "help" => {
477 println!("usage: cce-keyboard [toggle|show|hide] (default toggle)");
478 return;
479 }
480 other => {
481 eprintln!("cce-keyboard: unknown command {other:?} — toggle, show or hide");
482 std::process::exit(2);
483 }
484 }
485 if cce_ui::ipc::instance::forward_or_claim(SOCKET_PREFIX, &command) {
486 return;
487 }
488 // No board was up: there is nothing to hide.
489 if command == "hide" {
490 cce_ui::ipc::instance::cleanup();
491 return;
492 }
493 let codes = layout::all_codes(&layout::rows());
494 let Some(keymap) = Keymap::compile(&codes) else {
495 eprintln!("cce-keyboard: could not compile the session keymap");
496 cce_ui::ipc::instance::cleanup();
497 std::process::exit(1);
498 };
499 let vk = match VirtualKeyboard::connect(&keymap.text) {
500 Ok(vk) => vk,
501 Err(e) => {
502 eprintln!("cce-keyboard: {e}");
503 cce_ui::ipc::instance::cleanup();
504 std::process::exit(1);
505 }
506 };
507 *PARKED.lock().unwrap_or_else(|e| e.into_inner()) = Some((keymap, vk));
508 cce_ui::engine::run::<KeyboardApp>();
509 cce_ui::ipc::instance::cleanup();
510 }
511
512 #[cfg(test)]
513 mod tests {
514 use super::*;
515
516 #[test]
517 fn the_board_leaves_the_compositor_half_the_output() {
518 let mut config = Config { height: 280, width: 0, margin: 0, reserve: true };
519 config.fit((640, 360));
520 assert_eq!(config.height, 162);
521 let mut config = Config { height: 280, width: 2000, margin: 20, reserve: true };
522 config.fit((1440, 900));
523 assert_eq!((config.height, config.width), (280, 0));
524 let mut config = Config { height: 400, width: 800, margin: 20, reserve: true };
525 config.fit((1440, 900));
526 assert_eq!((config.height, config.width), (385, 800));
527 }
528
529 #[test]
530 fn a_latch_cycles_once_locked_off() {
531 let l = Latch::Off.tapped();
532 assert_eq!(l, Latch::Once);
533 assert_eq!(l.tapped(), Latch::Locked);
534 assert_eq!(l.tapped().tapped(), Latch::Off);
535 }
536
537 #[test]
538 fn only_a_one_shot_latch_is_spent_by_a_key() {
539 assert_eq!(Latch::Once.spent(), Latch::Off);
540 assert_eq!(Latch::Locked.spent(), Latch::Locked);
541 assert_eq!(Latch::Off.spent(), Latch::Off);
542 }
543 }