on-screen keyboard
git clone https://git.lucas.co/cce-keyboard.git
cce-keyboard: an on-screen keyboard
A board of keys on an Overlay-layer surface along the bottom edge, taking no
keyboard focus, that types into the focused window through a
zwp_virtual_keyboard_v1 device on its own Wayland connection. The keymap it
uploads is the session's default (empty RMLVO, as cce-fx compiles its own),
and the character keys are labelled from it.
US ANSI rows with Fn in the Caps position: Shift/Ctrl/Alt/Super/Fn latch for
the next key, lock on a second tap, release on a third; Fn swaps in F1-F12,
Del and Home/PgDn/PgUp/End. Keys are raised control plates that sink flush
while down. Single instance: `cce-keyboard [toggle|show|hide]`, and hiding
exits. Config: height, width, margin, reserve (exclusive zone); the height
is capped so the compositor never closes the board for leaving less than
half the output.
Needs cce-compositor@01b1f5f3, or a click on a key takes focus off the
window being typed into.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.gitignore | 2 +
CLAUDE.md | 60 ++++++
Cargo.toml | 18 ++
Makefile | 15 ++
cce-keyboard.desktop | 10 +
src/keymap.rs | 90 +++++++++
src/layout.rs | 349 +++++++++++++++++++++++++++++++++
src/main.rs | 542 +++++++++++++++++++++++++++++++++++++++++++++++++++
src/vkbd.rs | 125 ++++++++++++
9 files changed, 1211 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..0bd407b
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,60 @@
+# cce-keyboard
+
+The on-screen keyboard. Read the workspace guide (`../cce-compositor/WORKSPACE.md`)
+first; this file covers only what is particular to this crate.
+
+## Shape
+
+- `src/main.rs` — the `Application`: an Overlay-layer surface anchored to the
+ bottom edge, `keyboard_interactivity none`, root plate with one control
+ plate per key (raised keycap at rest, flush while held or latched, rim lit
+ while locked). Single instance via `cce_ui::ipc::instance`;
+ `cce-keyboard [toggle|show|hide]`, and hiding exits.
+- `src/layout.rs` — the key table (evdev codes, unit widths, the Fn layer)
+ and the pure geometry (`place`, `hit`). Unit-tested; no Wayland.
+- `src/keymap.rs` — compiles the session's default keymap with
+ libxkbcommon (empty RMLVO, the same defaults `cce-compositor`'s
+ `xkb_config.rs` uses) and reads the character keys' labels from it.
+- `src/vkbd.rs` — `zwp_virtual_keyboard_v1` on a **second Wayland
+ connection** of its own. The protocol has no events, so nothing needs
+ dispatching, and keeping it off the runner's `EngineState` means cce-ui
+ needed no change.
+
+## Things that are deliberate
+
+- **No key repeat here.** The compositor gives virtual keyboards repeat info
+ (`keyboard.rs` `DEFAULT_REPEAT_*`) and the focused client repeats a held
+ key itself; the board just keeps the key down while the pointer does.
+- **Modifiers are pressed lazily**, around the next key, not when latched.
+ A latched Super must not hold the compositor in its Super-held adjust mode.
+ Both the modifier key events and an explicit `modifiers` mask are sent.
+- **The board fits the smallest output** (`Config::fit`, via `ccectl outputs
+ --json`): the compositor *destroys* a layer surface whose exclusive zone
+ leaves less than half the output (`layer_shell.rs`, river's rule). A
+ 280px board on a 360px-tall scale-2 shadow output vanished on map before
+ this.
+- **The key gap is capped at 15% of the row pitch**, never wider than the
+ ladder's `control_gap()`: the ladder spaces rows of buttons, and on a
+ dense grid its gap ate a third of every key.
+
+## Depends on a compositor fix
+
+A click on a layer surface used to give it keyboard focus whatever its
+`keyboard_interactivity` (`cursor.rs`, button and touch paths), which took
+focus off the window being typed into on the first key. Fixed by
+`layer_takes_click_focus` in cce-compositor; a session on an older `cce-fx`
+shows the board, latches modifiers, and types nothing.
+
+## Verifying
+
+Shadow only (never run a build from the plain shell):
+
+```sh
+cce-shadow start --new --bin /abs/path/target/release/cce-fx
+cce-shadow spawn /home/lsgalante/.local/bin/cce-text-editor
+cce-shadow spawn env CCE_FONTS_DIR=… CCE_ICONS_DIR=… /abs/path/target/release/cce-keyboard
+cce-shadow ctl pointer-move-to <x> <y>; cce-shadow ctl pointer-click
+```
+
+Check `ctl windows` still shows the editor `focused=true` after clicking keys,
+and do a `--scale 2` pass (pointer coords are logical: shot px ÷ 2).
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..dc67780
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "cce-keyboard"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "32621051c8f1301a905abb0a2f5d7bcf77941e7a" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+# zwp_virtual_keyboard_v1: how the keys reach the focused window.
+wayland-protocols-misc = { version = "0.3", features = ["client"] }
+# The keymap uploaded with the virtual keyboard, and the labels read from it.
+xkbcommon = "0.7"
+rustix = { version = "1", features = ["fs", "time"] }
+kdl = "4.6"
+serde_json = "1"
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..54612c2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+.PHONY: build install run clean
+
+build:
+ cargo build --release
+
+# Binaries are enumerated by ccebuild from cargo metadata — never name one here.
+install: build
+ @command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+ ccebuild install --no-build cce-keyboard
+
+run:
+ cargo run
+
+clean:
+ cargo clean
diff --git a/cce-keyboard.desktop b/cce-keyboard.desktop
new file mode 100644
index 0000000..f26a33e
--- /dev/null
+++ b/cce-keyboard.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Keyboard
+Comment=On-screen keyboard
+Exec=cce-keyboard toggle
+Icon=cce-keyboard
+Categories=Utility;Accessibility;
+Keywords=osk;onscreen;virtual;keyboard;
+Terminal=false
+StartupNotify=false
diff --git a/src/keymap.rs b/src/keymap.rs
new file mode 100644
index 0000000..6a8665f
--- /dev/null
+++ b/src/keymap.rs
@@ -0,0 +1,90 @@
+//! The keymap the virtual keyboard uploads, and the key labels read from it.
+//!
+//! Compiled from the same RMLVO the compositor's default keymap is: empty
+//! names, which libxkbcommon fills from `XKB_DEFAULT_*` and then the system
+//! defaults (`cce-compositor` `xkb_config.rs` passes NULL names). So the board
+//! labels and types whatever layout the session's hardware keyboard has.
+
+use std::collections::HashMap;
+
+use xkbcommon::xkb;
+
+use crate::layout::Modifier;
+
+/// What a character key prints, plain and shifted. `None` for a key that
+/// prints nothing printable (a control character, or nothing at all).
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct Label {
+ pub plain: Option<String>,
+ pub shifted: Option<String>,
+}
+
+pub struct Keymap {
+ /// The keymap in XKB text format, for `zwp_virtual_keyboard_v1.keymap`.
+ pub text: String,
+ /// Each [`Modifier`]'s mask, indexed by [`Modifier::index`].
+ masks: [u32; 4],
+ labels: HashMap<u32, Label>,
+}
+
+impl Keymap {
+ /// Compile the session's default keymap and label `codes` (evdev) from it.
+ pub fn compile(codes: &[u32]) -> Option<Self> {
+ let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
+ let keymap = xkb::Keymap::new_from_names(&context, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)?;
+ let mut masks = [0; 4];
+ for m in Modifier::ALL {
+ let index = keymap.mod_get_index(m.xkb_name());
+ if index != xkb::MOD_INVALID {
+ masks[m.index()] = 1 << index;
+ }
+ }
+ let shift = masks[Modifier::Shift.index()];
+ let plain = xkb::State::new(&keymap);
+ let mut shifted = xkb::State::new(&keymap);
+ shifted.update_mask(shift, 0, 0, 0, 0, 0);
+ let printable = |s: String| (!s.is_empty() && !s.chars().any(char::is_control)).then_some(s);
+ let labels = codes
+ .iter()
+ .map(|&code| {
+ // xkb keycodes are evdev codes offset by 8.
+ let kc = xkb::Keycode::new(code + 8);
+ (code, Label { plain: printable(plain.key_get_utf8(kc)), shifted: printable(shifted.key_get_utf8(kc)) })
+ })
+ .collect();
+ Some(Self { text: keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1), masks, labels })
+ }
+
+ pub fn label(&self, code: u32) -> Option<&Label> {
+ self.labels.get(&code)
+ }
+
+ /// The depressed-modifier mask for `mods` held.
+ pub fn mask(&self, mods: impl IntoIterator<Item = Modifier>) -> u32 {
+ mods.into_iter().fold(0, |acc, m| acc | self.masks[m.index()])
+ }
+}
+
+impl Label {
+ /// The small legend in a key's corner: the shifted symbol, when it is
+ /// not simply the capital of the plain one (a digit's `!`, not Q's `Q`).
+ pub fn corner(&self) -> Option<&str> {
+ let (plain, shifted) = (self.plain.as_deref()?, self.shifted.as_deref()?);
+ (shifted != plain && shifted != plain.to_uppercase()).then_some(shifted)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_letter_has_no_corner_legend_and_a_digit_does() {
+ let q = Label { plain: Some("q".into()), shifted: Some("Q".into()) };
+ assert_eq!(q.corner(), None);
+ let one = Label { plain: Some("1".into()), shifted: Some("!".into()) };
+ assert_eq!(one.corner(), Some("!"));
+ let dead = Label { plain: Some("1".into()), shifted: None };
+ assert_eq!(dead.corner(), None);
+ }
+}
diff --git a/src/layout.rs b/src/layout.rs
new file mode 100644
index 0000000..ca2d149
--- /dev/null
+++ b/src/layout.rs
@@ -0,0 +1,349 @@
+//! The key table and its geometry: pure data and arithmetic, no Wayland.
+//!
+//! Five rows of 15 units each, a US ANSI board with the Caps Lock position
+//! given to Fn. Key codes are evdev (`linux/input-event-codes.h`), which is
+//! what `zwp_virtual_keyboard_v1.key` takes; the labels of character keys are
+//! not here at all — they are read from the uploaded keymap (`keymap.rs`), so
+//! a non-US layout labels itself.
+
+/// Every row is this many units wide.
+pub const ROW_UNITS: f32 = 15.0;
+
+/// evdev key codes the board sends.
+pub mod code {
+ pub const ESC: u32 = 1;
+ pub const N1: u32 = 2;
+ pub const N0: u32 = 11;
+ pub const MINUS: u32 = 12;
+ pub const EQUAL: u32 = 13;
+ pub const BACKSPACE: u32 = 14;
+ pub const TAB: u32 = 15;
+ pub const Q: u32 = 16;
+ pub const P: u32 = 25;
+ pub const LEFTBRACE: u32 = 26;
+ pub const RIGHTBRACE: u32 = 27;
+ pub const ENTER: u32 = 28;
+ pub const LEFTCTRL: u32 = 29;
+ pub const A: u32 = 30;
+ pub const L: u32 = 38;
+ pub const SEMICOLON: u32 = 39;
+ pub const APOSTROPHE: u32 = 40;
+ pub const GRAVE: u32 = 41;
+ pub const LEFTSHIFT: u32 = 42;
+ pub const BACKSLASH: u32 = 43;
+ pub const Z: u32 = 44;
+ pub const SLASH: u32 = 53;
+ pub const LEFTALT: u32 = 56;
+ pub const SPACE: u32 = 57;
+ pub const F1: u32 = 59;
+ pub const F11: u32 = 87;
+ pub const F12: u32 = 88;
+ pub const HOME: u32 = 102;
+ pub const UP: u32 = 103;
+ pub const PAGEUP: u32 = 104;
+ pub const LEFT: u32 = 105;
+ pub const RIGHT: u32 = 106;
+ pub const END: u32 = 107;
+ pub const DOWN: u32 = 108;
+ pub const PAGEDOWN: u32 = 109;
+ pub const DELETE: u32 = 111;
+ pub const LEFTMETA: u32 = 125;
+}
+
+/// A modifier the board latches. Order is press order around a key.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Modifier {
+ Ctrl,
+ Alt,
+ Super,
+ Shift,
+}
+
+impl Modifier {
+ pub const ALL: [Modifier; 4] = [Modifier::Ctrl, Modifier::Alt, Modifier::Super, Modifier::Shift];
+
+ pub fn index(self) -> usize {
+ self as usize
+ }
+
+ /// The key pressed for it (always the left one).
+ pub fn code(self) -> u32 {
+ match self {
+ Modifier::Ctrl => code::LEFTCTRL,
+ Modifier::Alt => code::LEFTALT,
+ Modifier::Super => code::LEFTMETA,
+ Modifier::Shift => code::LEFTSHIFT,
+ }
+ }
+
+ /// The xkb modifier name its mask is looked up by.
+ pub fn xkb_name(self) -> &'static str {
+ match self {
+ Modifier::Ctrl => "Control",
+ Modifier::Alt => "Mod1",
+ Modifier::Super => "Mod4",
+ Modifier::Shift => "Shift",
+ }
+ }
+
+ fn label(self) -> &'static str {
+ match self {
+ Modifier::Ctrl => "Ctrl",
+ Modifier::Alt => "Alt",
+ Modifier::Super => "Super",
+ Modifier::Shift => "Shift",
+ }
+ }
+}
+
+/// What a key does.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Action {
+ /// Send this evdev code, held as long as the pointer holds the key.
+ Code(u32),
+ /// Latch a modifier for the next key; a second tap locks it.
+ Mod(Modifier),
+ /// Latch the Fn layer: the keys with a second action switch to it.
+ Fn,
+ /// Close the keyboard.
+ Hide,
+}
+
+/// One key: its action, its action on the Fn layer, and its width in units.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct KeyDef {
+ pub action: Action,
+ pub fn_action: Option<Action>,
+ pub units: f32,
+}
+
+impl KeyDef {
+ /// The action in force with the Fn layer on or off.
+ pub fn action(&self, fn_layer: bool) -> Action {
+ match (fn_layer, self.fn_action) {
+ (true, Some(a)) => a,
+ _ => self.action,
+ }
+ }
+}
+
+const fn key(code: u32) -> KeyDef {
+ KeyDef { action: Action::Code(code), fn_action: None, units: 1.0 }
+}
+
+const fn wide(action: Action, units: f32) -> KeyDef {
+ KeyDef { action, fn_action: None, units }
+}
+
+const fn layered(code: u32, fn_code: u32, units: f32) -> KeyDef {
+ KeyDef { action: Action::Code(code), fn_action: Some(Action::Code(fn_code)), units }
+}
+
+/// The board, top row first.
+pub fn rows() -> Vec<Vec<KeyDef>> {
+ use code::*;
+ let run = |from: u32, to: u32| (from..=to).map(key).collect::<Vec<_>>();
+ let mut number = vec![layered(ESC, GRAVE, 1.0)];
+ number.extend((N1..=N0).map(|c| layered(c, F1 + (c - N1), 1.0)));
+ number.extend([layered(MINUS, F11, 1.0), layered(EQUAL, F12, 1.0), layered(BACKSPACE, DELETE, 2.0)]);
+
+ let mut top = vec![wide(Action::Code(TAB), 1.5)];
+ top.extend(run(Q, P));
+ top.extend([key(LEFTBRACE), key(RIGHTBRACE), wide(Action::Code(BACKSLASH), 1.5)]);
+
+ let mut home = vec![wide(Action::Fn, 1.75)];
+ home.extend(run(A, L));
+ home.extend([key(SEMICOLON), key(APOSTROPHE), wide(Action::Code(ENTER), 2.25)]);
+
+ let mut bottom = vec![wide(Action::Mod(Modifier::Shift), 2.25)];
+ bottom.extend(run(Z, SLASH));
+ bottom.push(wide(Action::Mod(Modifier::Shift), 2.75));
+
+ let space = vec![
+ wide(Action::Mod(Modifier::Ctrl), 1.5),
+ wide(Action::Mod(Modifier::Super), 1.25),
+ wide(Action::Mod(Modifier::Alt), 1.25),
+ wide(Action::Code(SPACE), 6.0),
+ layered(LEFT, HOME, 1.0),
+ layered(DOWN, PAGEDOWN, 1.0),
+ layered(UP, PAGEUP, 1.0),
+ layered(RIGHT, END, 1.0),
+ wide(Action::Hide, 1.0),
+ ];
+ vec![number, top, home, bottom, space]
+}
+
+/// Every code the board can send, for the keymap's label pass.
+pub fn all_codes(rows: &[Vec<KeyDef>]) -> Vec<u32> {
+ let mut codes: Vec<u32> = rows
+ .iter()
+ .flatten()
+ .flat_map(|k| [Some(k.action), k.fn_action])
+ .flatten()
+ .filter_map(|a| match a {
+ Action::Code(c) => Some(c),
+ _ => None,
+ })
+ .collect();
+ codes.sort_unstable();
+ codes.dedup();
+ codes
+}
+
+/// A key that is not a character: the name it wears, or the bundled
+/// cce-icons glyph standing in for it (with the name as the fallback).
+pub fn fixed_label(action: Action) -> Option<(&'static str, Option<&'static str>)> {
+ use code::*;
+ let named = |name| Some((name, None));
+ match action {
+ Action::Mod(m) => named(m.label()),
+ Action::Fn => named("Fn"),
+ Action::Hide => Some(("Hide", Some("chevron-down"))),
+ Action::Code(c) => match c {
+ ESC => named("Esc"),
+ BACKSPACE => named("Back"),
+ DELETE => named("Del"),
+ TAB => named("Tab"),
+ ENTER => named("Enter"),
+ SPACE => named(""),
+ LEFT => Some(("←", Some("arrow-left"))),
+ RIGHT => Some(("→", Some("arrow-right"))),
+ UP => Some(("↑", Some("arrow-up"))),
+ DOWN => Some(("↓", Some("arrow-down"))),
+ HOME => named("Home"),
+ END => named("End"),
+ PAGEUP => named("PgUp"),
+ PAGEDOWN => named("PgDn"),
+ F1..=68 => FN_NAMES.get((c - F1) as usize).map(|n| (*n, None)),
+ F11 => named("F11"),
+ F12 => named("F12"),
+ _ => None,
+ },
+ }
+}
+
+const FN_NAMES: [&str; 10] = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10"];
+
+/// A key's rectangle, in the surface's logical px.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct KeyRect {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+}
+
+/// Where every key sits inside `area` (x, y, w, h), keys `gap` apart.
+///
+/// Units are a pitch, not a width: a key `u` units wide spans `u` pitches
+/// less one gap, so the columns line up from row to row the way a real
+/// board's do, whatever the gap.
+pub fn place(rows: &[Vec<KeyDef>], area: (f32, f32, f32, f32), gap: f32) -> Vec<Vec<KeyRect>> {
+ let (ax, ay, aw, ah) = area;
+ let n = rows.len().max(1) as f32;
+ let pitch_x = (aw + gap) / ROW_UNITS;
+ let pitch_y = (ah + gap) / n;
+ rows.iter()
+ .enumerate()
+ .map(|(r, row)| {
+ let mut at = 0.0;
+ row.iter()
+ .map(|k| {
+ let rect = KeyRect {
+ x: ax + at * pitch_x,
+ y: ay + r as f32 * pitch_y,
+ w: (k.units * pitch_x - gap).max(0.0),
+ h: (pitch_y - gap).max(0.0),
+ };
+ at += k.units;
+ rect
+ })
+ .collect()
+ })
+ .collect()
+}
+
+/// The key under (`x`, `y`), as (row, index). The gaps belong to the keys
+/// on either side of them — half each — so a press between two keys is
+/// never lost; only a press outside the board misses.
+pub fn hit(rects: &[Vec<KeyRect>], gap: f32, x: f32, y: f32) -> Option<(usize, usize)> {
+ let half = gap / 2.0;
+ rects.iter().enumerate().find_map(|(r, row)| {
+ row.iter()
+ .position(|k| x >= k.x - half && x < k.x + k.w + half && y >= k.y - half && y < k.y + k.h + half)
+ .map(|i| (r, i))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_row_is_fifteen_units() {
+ for (r, row) in rows().iter().enumerate() {
+ let units: f32 = row.iter().map(|k| k.units).sum();
+ assert!((units - ROW_UNITS).abs() < 1e-4, "row {r} is {units} units");
+ }
+ }
+
+ #[test]
+ fn rows_fill_the_area_edge_to_edge() {
+ let rows = rows();
+ let rects = place(&rows, (10.0, 20.0, 1000.0, 300.0), 6.0);
+ for row in &rects {
+ let first = row.first().unwrap();
+ let last = row.last().unwrap();
+ assert!((first.x - 10.0).abs() < 1e-3);
+ assert!((last.x + last.w - 1010.0).abs() < 1e-3, "row ends at {}", last.x + last.w);
+ }
+ let last = rects.last().unwrap()[0];
+ assert!((last.y + last.h - 320.0).abs() < 1e-3);
+ }
+
+ #[test]
+ fn neighbours_are_one_gap_apart() {
+ let rows = rows();
+ let rects = place(&rows, (0.0, 0.0, 900.0, 250.0), 8.0);
+ for row in &rects {
+ for pair in row.windows(2) {
+ assert!((pair[1].x - (pair[0].x + pair[0].w) - 8.0).abs() < 1e-3);
+ }
+ }
+ }
+
+ #[test]
+ fn a_press_in_a_gap_lands_on_a_neighbour() {
+ let rows = rows();
+ let gap = 10.0;
+ let rects = place(&rows, (0.0, 0.0, 1500.0, 500.0), gap);
+ let q = rects[1][1];
+ // Just right of Q, inside the gap: still Q.
+ assert_eq!(hit(&rects, gap, q.x + q.w + 4.0, q.y + 5.0), Some((1, 1)));
+ // Just left of W, inside the same gap: W.
+ assert_eq!(hit(&rects, gap, q.x + q.w + 6.0, q.y + 5.0), Some((1, 2)));
+ assert_eq!(hit(&rects, gap, -20.0, 5.0), None);
+ }
+
+ #[test]
+ fn the_fn_layer_swaps_only_layered_keys() {
+ let rows = rows();
+ assert_eq!(rows[0][1].action(true), Action::Code(code::F1));
+ assert_eq!(rows[0][10].action(true), Action::Code(code::F1 + 9));
+ assert_eq!(rows[0][13].action(true), Action::Code(code::DELETE));
+ assert_eq!(rows[1][1].action(true), Action::Code(code::Q));
+ assert_eq!(rows[4][4].action(false), Action::Code(code::LEFT));
+ assert_eq!(rows[4][4].action(true), Action::Code(code::HOME));
+ }
+
+ #[test]
+ fn every_function_key_is_named() {
+ for row in rows() {
+ for k in row {
+ if let Some(Action::Code(c)) = k.fn_action {
+ assert!(fixed_label(Action::Code(c)).is_some() || c == code::GRAVE, "code {c} has no label");
+ }
+ }
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..2508d39
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,542 @@
+//! cce-keyboard — an on-screen keyboard.
+//!
+//! A board of keys on an Overlay-layer surface along the bottom of the
+//! screen. The surface takes no keyboard focus, so pressing a key never
+//! moves focus off the window being typed into; the key is sent to that
+//! window through a `zwp_virtual_keyboard_v1` device (`vkbd.rs`) carrying the
+//! session's own keymap (`keymap.rs`), which also labels the character keys.
+//!
+//! Keys act on press and release with the pointer, so holding one repeats in
+//! the focused window like a held hardware key. Shift, Ctrl, Alt and Super
+//! latch: a tap applies to the next key, a second tap locks (rim lit), a
+//! third releases. Fn latches the same way and swaps the number row to
+//! F1–F12 / Del and the arrows to Home / PgDn / PgUp / End (Esc becomes `).
+//!
+//! One instance per session. `cce-keyboard [toggle|show|hide]` (default
+//! `toggle`) forwards to the running board over `cce_ui::ipc::instance`, or
+//! becomes it; hiding exits, so a closed board costs nothing — bind
+//! `cce-keyboard` in input.kdl to summon it.
+//!
+//! Config (`~/.config/cce/cce-keyboard/config.kdl`), read at startup:
+//! `height 280` (logical px) · `width 0` (0 spans the output) ·
+//! `margin 0` (px above the bottom edge) · `reserve true` (windows tile
+//! above the board rather than under it). The height is capped to fit the
+//! smallest output (`Config::fit`).
+
+mod keymap;
+mod layout;
+mod vkbd;
+
+use std::sync::Mutex;
+
+use cce_ui::colors::{button_background_color, button_hover_color, button_press_color, control_label_color_u8};
+use cce_ui::engine::{
+ Application, EngineState, LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings, LogicalPosition,
+ LogicalSize, WindowSettings,
+};
+use cce_ui::layout::{button_corner_radius, button_font, button_height, control_gap, parse_font_string, root_plate_inset};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{AlignH, AlignV, ControlPlate, DisplayList, PaintCtx, PlateStance, TextAttrs, TextLayout};
+use cce_ui::scene::Material;
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+use wayland_client::QueueHandle;
+
+use keymap::Keymap;
+use layout::{Action, KeyDef, KeyRect, Modifier};
+use vkbd::VirtualKeyboard;
+
+/// The instance socket: `/tmp/cce-keyboard-<WAYLAND_DISPLAY>.sock`.
+const SOCKET_PREFIX: &str = "cce-keyboard";
+
+struct Config {
+ height: u32,
+ /// 0 spans the output.
+ width: u32,
+ margin: i32,
+ reserve: bool,
+}
+
+impl Config {
+ fn load() -> Self {
+ let mut config = Config { height: 280, width: 0, margin: 0, reserve: true };
+ let path = cce_ui::config::get_app_config_path(SOCKET_PREFIX);
+ let Ok(text) = std::fs::read_to_string(&path) else { return config };
+ let doc = match text.parse::<kdl::KdlDocument>() {
+ Ok(doc) => doc,
+ Err(e) => {
+ log::warn!("[cce-keyboard] {}: {e} — using defaults", path.display());
+ return config;
+ }
+ };
+ let value = |name: &str| doc.get(name).and_then(|n| n.entries().first()).map(|e| e.value().clone());
+ let int = |name: &str| value(name).and_then(|v| v.as_i64());
+ if let Some(h) = int("height") {
+ config.height = h.clamp(120, 1200) as u32;
+ }
+ if let Some(w) = int("width") {
+ config.width = w.clamp(0, 8000) as u32;
+ }
+ if let Some(m) = int("margin") {
+ config.margin = m.clamp(0, 2000) as i32;
+ }
+ if let Some(r) = value("reserve").and_then(|v| v.as_bool()) {
+ config.reserve = r;
+ }
+ config
+ }
+
+ /// Fit the board to an output of `(w, h)` logical px. The compositor
+ /// closes a layer surface whose exclusive zone leaves less than half the
+ /// output (cce-compositor `layer_shell.rs`, river's rule), so the board
+ /// never takes more than 45% of the height with its margin; a width
+ /// past the output's edge spans it instead.
+ fn fit(&mut self, (w, h): (u32, u32)) {
+ let most = ((h as f32 * 0.45) as i32 - self.margin).max(60) as u32;
+ if self.height > most {
+ log::info!("[cce-keyboard] height {} does not fit a {w}x{h} output — using {most}", self.height);
+ self.height = most;
+ }
+ if self.width > w {
+ self.width = 0;
+ }
+ }
+}
+
+/// The smallest enabled output's logical size, from `ccectl outputs --json`
+/// (one JSON object per line). The board may land on any of them — the
+/// compositor picks an output for an unassigned layer surface — so it must
+/// fit the smallest.
+fn smallest_output() -> Option<(u32, u32)> {
+ let ccectl = std::env::var("HOME")
+ .map(|home| format!("{home}/.local/bin/ccectl"))
+ .ok()
+ .filter(|p| std::path::Path::new(p).exists())
+ .unwrap_or_else(|| "ccectl".to_string());
+ let out = std::process::Command::new(ccectl).args(["outputs", "--json"]).output().ok()?;
+ String::from_utf8_lossy(&out.stdout)
+ .lines()
+ .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
+ .filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
+ .filter_map(|o| Some((o.get("logical_w")?.as_u64()? as u32, o.get("logical_h")?.as_u64()? as u32)))
+ .filter(|&(w, h)| w > 0 && h > 0)
+ .min_by_key(|&(w, h)| w as u64 * h as u64)
+}
+
+/// A latching key's state: off, for the next key, or until tapped again.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+enum Latch {
+ #[default]
+ Off,
+ Once,
+ Locked,
+}
+
+impl Latch {
+ fn tapped(self) -> Self {
+ match self {
+ Latch::Off => Latch::Once,
+ Latch::Once => Latch::Locked,
+ Latch::Locked => Latch::Off,
+ }
+ }
+
+ /// After a key has been sent: a one-shot latch is used up.
+ fn spent(self) -> Self {
+ if self == Latch::Once {
+ Latch::Off
+ } else {
+ self
+ }
+ }
+
+ fn on(self) -> bool {
+ self != Latch::Off
+ }
+}
+
+/// The key the pointer is holding down, and what pressing it sent.
+struct Held {
+ at: (usize, usize),
+ action: Action,
+ /// The modifiers pressed around it, released after it in reverse.
+ mods: Vec<Modifier>,
+}
+
+#[derive(Debug, Clone)]
+enum Message {
+ Hide,
+}
+
+/// The keymap and keyboard `main` set up before the runner starts, for
+/// `KeyboardApp::new` (`engine::run` takes no arguments).
+static PARKED: Mutex<Option<(Keymap, VirtualKeyboard)>> = Mutex::new(None);
+
+struct KeyboardApp {
+ config: Config,
+ rows: Vec<Vec<KeyDef>>,
+ keymap: Keymap,
+ vk: VirtualKeyboard,
+ /// Indexed by [`Modifier::index`].
+ latches: [Latch; 4],
+ fn_latch: Latch,
+ held: Option<Held>,
+ hover: Option<(usize, usize)>,
+ size: (f32, f32),
+}
+
+impl KeyboardApp {
+ fn geometry(&self) -> (Vec<Vec<KeyRect>>, f32) {
+ let inset = root_plate_inset();
+ let (w, h) = self.size;
+ let area = (inset, inset, (w - 2.0 * inset).max(0.0), (h - 2.0 * inset).max(0.0));
+ // The ladder's control gap spaces a row of buttons; a board is a
+ // dense grid of them, where that gap would eat a third of each key.
+ // Never wider than it, but no more than a share of the row pitch.
+ let gap = control_gap().min(area.3 / self.rows.len().max(1) as f32 * 0.15);
+ (layout::place(&self.rows, area, gap), gap)
+ }
+
+ fn key_at(&self, pos: LogicalPosition) -> Option<(usize, usize)> {
+ let (rects, gap) = self.geometry();
+ layout::hit(&rects, gap, pos.x, pos.y)
+ }
+
+ fn shift_on(&self) -> bool {
+ self.latches[Modifier::Shift.index()].on()
+ }
+
+ fn press(&mut self, at: (usize, usize)) {
+ let action = self.rows[at.0][at.1].action(self.fn_latch.on());
+ let mut mods = Vec::new();
+ match action {
+ Action::Mod(m) => self.latches[m.index()] = self.latches[m.index()].tapped(),
+ Action::Fn => self.fn_latch = self.fn_latch.tapped(),
+ Action::Hide => {}
+ Action::Code(code) => {
+ mods = Modifier::ALL.into_iter().filter(|m| self.latches[m.index()].on()).collect();
+ for m in &mods {
+ self.vk.key(m.code(), true);
+ }
+ if !mods.is_empty() {
+ self.vk.modifiers(self.keymap.mask(mods.iter().copied()));
+ }
+ self.vk.key(code, true);
+ }
+ }
+ self.held = Some(Held { at, action, mods });
+ }
+
+ /// Let go of the held key; `over` is the key under the pointer now.
+ fn release(&mut self, over: Option<(usize, usize)>) -> Option<Message> {
+ let held = self.held.take()?;
+ match held.action {
+ Action::Code(code) => {
+ self.vk.key(code, false);
+ for m in held.mods.iter().rev() {
+ self.vk.key(m.code(), false);
+ }
+ if !held.mods.is_empty() {
+ self.vk.modifiers(0);
+ }
+ for latch in &mut self.latches {
+ *latch = latch.spent();
+ }
+ self.fn_latch = self.fn_latch.spent();
+ None
+ }
+ // Only a release still on the key closes the board: sliding off
+ // is the way to change your mind.
+ Action::Hide => (over == Some(held.at)).then_some(Message::Hide),
+ Action::Mod(_) | Action::Fn => None,
+ }
+ }
+
+ fn paint_key(&self, pc: &mut PaintCtx, at: (usize, usize), r: KeyRect, row_h: f32) {
+ let action = self.rows[at.0][at.1].action(self.fn_latch.on());
+ let latch = match action {
+ Action::Mod(m) => self.latches[m.index()],
+ Action::Fn => self.fn_latch,
+ _ => Latch::Off,
+ };
+ let down = self.held.as_ref().is_some_and(|h| h.at == at) || latch.on();
+ let face = if down {
+ button_press_color()
+ } else if self.hover == Some(at) {
+ button_hover_color()
+ } else {
+ button_background_color()
+ };
+ let rect = Rect { x: r.x, y: r.y, width: r.w, height: r.h };
+ // A key is a keycap: a raised plate that sinks flush while it is
+ // down — held by the pointer, or a latched modifier. A locked latch
+ // also lights the plate's own rim, the focus-ring treatment.
+ let stance = if down { PlateStance::Flush } else { PlateStance::Raised };
+ let tint = (latch == Latch::Locked).then(ControlPlate::focus_tint);
+ let plate = ControlPlate::control(rect, button_corner_radius(), stance, Material::face(face));
+ pc.control_plate(&plate.with_tint(tint));
+
+ let (family, base) = parse_font_string(&button_font());
+ let size = base.unwrap_or(14.0) * (row_h / button_height().max(1.0)).clamp(1.0, 2.0);
+ let color = control_label_color_u8();
+ let centered = TextLayout {
+ wrap_width: Some(rect.width),
+ box_height: rect.height,
+ align_h: AlignH::Center,
+ align_v: AlignV::Middle,
+ };
+
+ if let Some((name, icon)) = layout::fixed_label(action) {
+ let glyph = icon.and_then(|icon| cce_ui::upload_icon(icon, 64));
+ if let Some((image, iw, ih)) = glyph {
+ let s = (rect.width.min(rect.height) * 0.45).max(4.0);
+ let (iw, ih) = (iw as f32, ih as f32);
+ let (dw, dh) = if iw >= ih { (s, s * ih / iw.max(1.0)) } else { (s * iw / ih.max(1.0), s) };
+ let at = Rect {
+ x: rect.x + (rect.width - dw) / 2.0,
+ y: rect.y + (rect.height - dh) / 2.0,
+ width: dw,
+ height: dh,
+ };
+ pc.image(image, at, 1.0);
+ } else {
+ pc.text_boxed(name, rect.x, rect.y, size * 0.8, color, Some(family), None, TextAttrs::default(), centered);
+ }
+ return;
+ }
+
+ let Action::Code(code) = action else { return };
+ let Some(label) = self.keymap.label(code) else { return };
+ let shift = self.shift_on();
+ let main = if shift { label.shifted.as_deref().or(label.plain.as_deref()) } else { label.plain.as_deref() };
+ if let Some(main) = main {
+ pc.text_boxed(main, rect.x, rect.y, size, color, Some(family.clone()), None, TextAttrs::default(), centered);
+ }
+ if !shift {
+ if let Some(corner) = label.corner() {
+ let small = size * 0.6;
+ let pad = (rect.height * 0.12).max(2.0);
+ let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
+ pc.text_faded(corner, rect.x + pad, rect.y + pad * 0.5, small, color, 0.55, Some(family), bounds);
+ }
+ }
+ }
+}
+
+impl Application for KeyboardApp {
+ type Message = Message;
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+ let (keymap, vk) = PARKED
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .take()
+ .expect("main parks the keymap and keyboard before running");
+ cce_ui::ipc::instance::serve(move |line| match line.trim() {
+ "toggle" | "hide" => {
+ sender.send(Message::Hide).ok()?;
+ Some("hidden".into())
+ }
+ "show" => Some("shown".into()),
+ other => Some(format!("unknown command {other:?} — toggle, show or hide")),
+ });
+ let mut config = Config::load();
+ if let Some(output) = smallest_output() {
+ config.fit(output);
+ }
+ let size = (config.width.max(1) as f32, config.height as f32);
+ Self {
+ config,
+ rows: layout::rows(),
+ keymap,
+ vk,
+ latches: [Latch::Off; 4],
+ fn_latch: Latch::Off,
+ held: None,
+ hover: None,
+ size,
+ }
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "Keyboard".to_string(),
+ app_id: SOCKET_PREFIX.to_string(),
+ width: self.config.width,
+ height: self.config.height,
+ fullscreen: false,
+ min_size: None,
+ }
+ }
+
+ fn layer(&self) -> Option<LayerSettings> {
+ let anchor = if self.config.width == 0 {
+ LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT
+ } else {
+ LayerAnchor::BOTTOM
+ };
+ Some(LayerSettings {
+ // Overlay, so the board also stands over a fullscreen window.
+ layer: LayerKind::Overlay,
+ anchor,
+ exclusive_zone: if self.config.reserve { self.config.height as i32 } else { 0 },
+ // Never take focus: the window being typed into must keep it.
+ keyboard_interactivity: LayerKeyboardInteractivity::None,
+ margin: (0, 0, self.config.margin, 0),
+ namespace: SOCKET_PREFIX.to_string(),
+ })
+ }
+
+ fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
+ match msg {
+ Message::Hide => {
+ // Give up the socket before the close fade, so a summon
+ // during the fade starts a fresh board instead of being
+ // answered by this one and dropped with it.
+ cce_ui::ipc::instance::cleanup();
+ *exit = true;
+ }
+ }
+ }
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
+ self.size = (width, height);
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let hover = self.key_at(pos);
+ if hover != self.hover {
+ self.hover = hover;
+ *needs_rebuild = true;
+ }
+ }
+
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ if button != MouseButton::Left {
+ return None;
+ }
+ *needs_rebuild = true;
+ match state {
+ ElementState::Pressed => {
+ // A second button-down without a release between (a lost
+ // release) lets go of the first key before taking the next.
+ let _ = self.release(None);
+ if let Some(at) = self.key_at(pos) {
+ self.press(at);
+ }
+ None
+ }
+ ElementState::Released => self.release(self.key_at(pos)),
+ }
+ }
+
+ fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+
+ fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
+ None
+ }
+
+ fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+ self.size = (size.width, size.height);
+ let (rects, _) = self.geometry();
+ let mut pc = PaintCtx::new();
+ pc.root_plate(size.width, size.height);
+ for (r, row) in rects.iter().enumerate() {
+ for (i, key) in row.iter().enumerate() {
+ self.paint_key(&mut pc, (r, i), *key, key.h);
+ }
+ }
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn on_exit(&mut self) {
+ // Nothing may stay pressed in the seat once the board is gone; the
+ // keyboard's own Drop releases whatever this misses.
+ let _ = self.release(None);
+ }
+}
+
+fn main() {
+ env_logger::init();
+ let command = std::env::args().nth(1).unwrap_or_else(|| "toggle".to_string());
+ match command.as_str() {
+ "toggle" | "show" | "hide" => {}
+ "-h" | "--help" | "help" => {
+ println!("usage: cce-keyboard [toggle|show|hide] (default toggle)");
+ return;
+ }
+ other => {
+ eprintln!("cce-keyboard: unknown command {other:?} — toggle, show or hide");
+ std::process::exit(2);
+ }
+ }
+ if cce_ui::ipc::instance::forward_or_claim(SOCKET_PREFIX, &command) {
+ return;
+ }
+ // No board was up: there is nothing to hide.
+ if command == "hide" {
+ cce_ui::ipc::instance::cleanup();
+ return;
+ }
+ let codes = layout::all_codes(&layout::rows());
+ let Some(keymap) = Keymap::compile(&codes) else {
+ eprintln!("cce-keyboard: could not compile the session keymap");
+ cce_ui::ipc::instance::cleanup();
+ std::process::exit(1);
+ };
+ let vk = match VirtualKeyboard::connect(&keymap.text) {
+ Ok(vk) => vk,
+ Err(e) => {
+ eprintln!("cce-keyboard: {e}");
+ cce_ui::ipc::instance::cleanup();
+ std::process::exit(1);
+ }
+ };
+ *PARKED.lock().unwrap_or_else(|e| e.into_inner()) = Some((keymap, vk));
+ cce_ui::engine::run::<KeyboardApp>();
+ cce_ui::ipc::instance::cleanup();
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_board_leaves_the_compositor_half_the_output() {
+ let mut config = Config { height: 280, width: 0, margin: 0, reserve: true };
+ config.fit((640, 360));
+ assert_eq!(config.height, 162);
+ let mut config = Config { height: 280, width: 2000, margin: 20, reserve: true };
+ config.fit((1440, 900));
+ assert_eq!((config.height, config.width), (280, 0));
+ let mut config = Config { height: 400, width: 800, margin: 20, reserve: true };
+ config.fit((1440, 900));
+ assert_eq!((config.height, config.width), (385, 800));
+ }
+
+ #[test]
+ fn a_latch_cycles_once_locked_off() {
+ let l = Latch::Off.tapped();
+ assert_eq!(l, Latch::Once);
+ assert_eq!(l.tapped(), Latch::Locked);
+ assert_eq!(l.tapped().tapped(), Latch::Off);
+ }
+
+ #[test]
+ fn only_a_one_shot_latch_is_spent_by_a_key() {
+ assert_eq!(Latch::Once.spent(), Latch::Off);
+ assert_eq!(Latch::Locked.spent(), Latch::Locked);
+ assert_eq!(Latch::Off.spent(), Latch::Off);
+ }
+}
diff --git a/src/vkbd.rs b/src/vkbd.rs
new file mode 100644
index 0000000..5ac9e08
--- /dev/null
+++ b/src/vkbd.rs
@@ -0,0 +1,125 @@
+//! `zwp_virtual_keyboard_v1`: how a key on the board reaches the window that
+//! has keyboard focus.
+//!
+//! It runs on a Wayland connection of its own rather than on the one the
+//! cce-ui runner drives the surface with. The protocol is requests only — the
+//! compositor never sends the keyboard an event — so this side needs no
+//! dispatching, only a flush after each request, and keeping it off the
+//! runner's `EngineState` leaves cce-ui untouched. The compositor treats the
+//! device like any keyboard (cce-compositor `input_manager.rs`
+//! `handle_new_virtual_keyboard`): its keys run the window manager's
+//! bindings, and the focused client repeats a held key at the virtual
+//! keyboard's repeat info, so the board sends no repeats of its own.
+
+use std::collections::BTreeSet;
+use std::io::Write;
+use std::os::fd::{AsFd, OwnedFd};
+
+use wayland_client::globals::{registry_queue_init, GlobalListContents};
+use wayland_client::protocol::{wl_keyboard, wl_registry, wl_seat};
+use wayland_client::{delegate_noop, Connection, Dispatch, EventQueue, QueueHandle};
+use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
+ zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
+};
+
+pub struct VirtualKeyboard {
+ conn: Connection,
+ _queue: EventQueue<Sink>,
+ keyboard: ZwpVirtualKeyboardV1,
+ /// Codes this side has pressed and not yet released, so a board that
+ /// closes mid-press never leaves a key stuck down in the seat.
+ down: BTreeSet<u32>,
+}
+
+/// The connection's dispatch state: nothing here has events worth reading.
+struct Sink;
+
+impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Sink {
+ fn event(
+ _: &mut Self,
+ _: &wl_registry::WlRegistry,
+ _: wl_registry::Event,
+ _: &GlobalListContents,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+delegate_noop!(Sink: ignore wl_seat::WlSeat);
+delegate_noop!(Sink: ZwpVirtualKeyboardManagerV1);
+delegate_noop!(Sink: ZwpVirtualKeyboardV1);
+
+impl VirtualKeyboard {
+ /// Connect, create the keyboard on the first seat, and upload `keymap`
+ /// (XKB text). The roundtrip at the end surfaces a refusal — a protocol
+ /// error on the create or the keymap — here, rather than as a silently
+ /// dead connection on the first key.
+ pub fn connect(keymap: &str) -> Result<Self, String> {
+ let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland display: {e}"))?;
+ let (globals, mut queue) = registry_queue_init::<Sink>(&conn).map_err(|e| format!("registry: {e}"))?;
+ let qh = queue.handle();
+ let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=1, ()).map_err(|e| format!("wl_seat: {e}"))?;
+ let manager: ZwpVirtualKeyboardManagerV1 = globals
+ .bind(&qh, 1..=1, ())
+ .map_err(|e| format!("the compositor offers no zwp_virtual_keyboard_manager_v1: {e}"))?;
+ let keyboard = manager.create_virtual_keyboard(&seat, &qh, ());
+ let fd = keymap_fd(keymap).map_err(|e| format!("keymap memfd: {e}"))?;
+ // The size counts the terminating NUL the format requires.
+ keyboard.keymap(wl_keyboard::KeymapFormat::XkbV1.into(), fd.as_fd(), keymap.len() as u32 + 1);
+ queue.roundtrip(&mut Sink).map_err(|e| format!("virtual keyboard refused: {e}"))?;
+ Ok(Self { conn, _queue: queue, keyboard, down: BTreeSet::new() })
+ }
+
+ /// Press or release `code` (evdev). A release of a key that is not down
+ /// is dropped.
+ pub fn key(&mut self, code: u32, pressed: bool) {
+ if pressed {
+ self.down.insert(code);
+ } else if !self.down.remove(&code) {
+ return;
+ }
+ let state = if pressed { wl_keyboard::KeyState::Pressed } else { wl_keyboard::KeyState::Released };
+ self.keyboard.key(now_ms(), code, state.into());
+ self.flush();
+ }
+
+ /// The depressed-modifier mask. Sent beside the modifier keys themselves
+ /// so the seat's state is stated, not inferred from the key stream.
+ pub fn modifiers(&mut self, depressed: u32) {
+ self.keyboard.modifiers(depressed, 0, 0, 0);
+ self.flush();
+ }
+
+ fn flush(&self) {
+ if let Err(e) = self.conn.flush() {
+ log::error!("[cce-keyboard] virtual keyboard connection lost: {e}");
+ }
+ }
+}
+
+impl Drop for VirtualKeyboard {
+ fn drop(&mut self) {
+ for code in std::mem::take(&mut self.down).into_iter().rev() {
+ self.keyboard.key(now_ms(), code, wl_keyboard::KeyState::Released.into());
+ }
+ self.keyboard.modifiers(0, 0, 0, 0);
+ self.keyboard.destroy();
+ let _ = self.conn.flush();
+ }
+}
+
+/// The keymap in an anonymous file, NUL-terminated, for the compositor to map.
+fn keymap_fd(keymap: &str) -> std::io::Result<OwnedFd> {
+ let fd = rustix::fs::memfd_create("cce-keyboard-keymap", rustix::fs::MemfdFlags::CLOEXEC)?;
+ let mut file = std::fs::File::from(fd);
+ file.write_all(keymap.as_bytes())?;
+ file.write_all(&[0])?;
+ Ok(file.into())
+}
+
+/// Key event time: CLOCK_MONOTONIC in ms, the clock input timestamps use.
+fn now_ms() -> u32 {
+ let t = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic);
+ (t.tv_sec as u64 * 1000 + t.tv_nsec as u64 / 1_000_000) as u32
+}