git.lucas.co / cce-keyboard
on-screen keyboard
git clone https://git.lucas.co/cce-keyboard.git

commit3dcb50c1a67513842b4d677d587d79cbd6ca5b1f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-04 15:53
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
+}