git.lucas.co / cce-keyboard
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 }