Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
test: behavioral test for the Escape-closes-status-menus arm
New verify/ directory: behavioral tests that a unit test cannot express
because the behavior needs a running compositor. Drivers start a private
cce-shadow instance, drive it with real Wayland clients, and assert on what
the compositor observably does.
verify/clients/ is the shared client crate — deliberately not a workspace
member (severed with an empty [workspace], own target/, invisible to
ccebuild), built on demand by the drivers:
- vkey injects key events through zwp_virtual_keyboard_v1 (fdd1209 is what
makes this reach the keyboard stack at all), with mod:MASK args for held
modifiers. Promoted from the session scratchpad where its predecessor was
lost once already.
- status-stub maps a cce-status* toplevel 400px tall — geometrically an open
in-surface menu, since expanded is defined as thicker than bar_height —
subscribes to the status socket's dismiss topic, prints one line per push,
and shrinks to a bar strip on the first one, reacting like the real bar.
verify/escape-dismiss-test composes the two and proves all three gates of
the arm in handle_builtin_binding (7db8c03):
1. Ctrl+Escape while expanded -> no dismiss (modifiers == 0 gate)
2. plain Escape while expanded -> exactly one dismiss, and the stub's
shrink is visible in ctl windows (the dismissal itself)
3. plain Escape after shrink -> no dismiss (any_expanded_status_segment
gate, now closed)
The wait_for helper takes predicate functions, not test "$(...)" args — a
command substitution in the arguments would be expanded once at call time
and the loop would poll a constant, which cost the first two runs.
Co-Authored-By: Claude Fable 5 <[email protected]>
CLAUDE.md | 28 ++++
verify/.gitignore | 1 +
verify/clients/Cargo.lock | 245 ++++++++++++++++++++++++++++++++++
verify/clients/Cargo.toml | 25 ++++
verify/clients/src/bin/status_stub.rs | 244 +++++++++++++++++++++++++++++++++
verify/clients/src/bin/vkey.rs | 142 ++++++++++++++++++++
verify/escape-dismiss-test | 96 +++++++++++++
7 files changed, 781 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 10bc5b6..dc5122a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -220,6 +220,34 @@ cargo test --lib <name> # single test by (substring) name
cargo test --lib config:: # tests in the config module
```
+### `verify/` — behavioral tests in a shadow session
+
+For behavior the unit tests cannot reach (it needs a running compositor),
+`verify/` holds self-contained test drivers that start a private `cce-shadow`
+instance, drive it with real Wayland clients, and assert on what the
+compositor observably does. `verify/clients/` is the shared client crate —
+deliberately **not** a workspace member (severed with an empty `[workspace]`,
+own `target/`, invisible to ccebuild), built on demand by the drivers:
+
+- **`vkey`** — injects key events through `zwp_virtual_keyboard_v1`
+ (wtype-style; evdev keycodes plus `mod:MASK` args for held modifiers).
+ This exercises the same `KeyboardGroup::handle_group_key` path hardware
+ keys take, so keybindings and builtins fire for injected keys.
+- **`status-stub`** — maps an xdg toplevel with a `cce-status*` app_id 400px
+ tall, which `any_expanded_status_segment` reads as an open in-surface menu
+ (expanded is geometric: thicker than `layout.bar_height`). It subscribes to
+ the status socket's `dismiss` topic, prints one line per push, and shrinks
+ to a bar strip on the first one — reacting the way the real bar does.
+
+`./verify/escape-dismiss-test` composes the two to prove all three gates of
+the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
+Escape stays out of the arm, a plain Escape while expanded pushes exactly one
+dismiss (and the stub's shrink is visible in `ctl windows`), and a plain
+Escape with nothing expanded stays quiet. The compositor binary is whatever
+`cce-shadow` resolves (installed first, then `target/release`); extra args
+pass through to `cce-shadow start`, so `--bin ../target/release/cce-fx` pins
+the tree's own build.
+
## Build pipeline (`build.rs`)
`build.rs` does a lot before Rust compiles:
diff --git a/verify/.gitignore b/verify/.gitignore
new file mode 100644
index 0000000..8615312
--- /dev/null
+++ b/verify/.gitignore
@@ -0,0 +1 @@
+/clients/target/
diff --git a/verify/clients/Cargo.lock b/verify/clients/Cargo.lock
new file mode 100644
index 0000000..936e008
--- /dev/null
+++ b/verify/clients/Cargo.lock
@@ -0,0 +1,245 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cc"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cce-verify-clients"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-misc",
+ "xkbcommon",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "xkbcommon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
+dependencies = [
+ "libc",
+ "memmap2",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
diff --git a/verify/clients/Cargo.toml b/verify/clients/Cargo.toml
new file mode 100644
index 0000000..a975aff
--- /dev/null
+++ b/verify/clients/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "cce-verify-clients"
+version = "0.1.0"
+edition = "2021"
+description = "Wayland test clients for behavioral verification in cce-shadow sessions"
+
+[[bin]]
+name = "vkey"
+path = "src/bin/vkey.rs"
+
+[[bin]]
+name = "status-stub"
+path = "src/bin/status_stub.rs"
+
+[dependencies]
+wayland-client = "0.31"
+wayland-protocols = { version = "0.32", features = ["client"] }
+wayland-protocols-misc = { version = "0.3", features = ["client"] }
+xkbcommon = "0.8"
+libc = "0.2"
+
+# Deliberately NOT a member of the cce workspace: these are test clients built
+# on demand by verify/ scripts, invisible to ccebuild and `cargo build` at the
+# workspace root. The empty table severs workspace resolution.
+[workspace]
diff --git a/verify/clients/src/bin/status_stub.rs b/verify/clients/src/bin/status_stub.rs
new file mode 100644
index 0000000..93dbd0e
--- /dev/null
+++ b/verify/clients/src/bin/status_stub.rs
@@ -0,0 +1,244 @@
+// status-stub — a fake status segment for behavioral tests.
+//
+// Maps an xdg toplevel with app_id "cce-status-teststub", which the compositor
+// roles as a StatusBar (any "cce-status*" app_id) and therefore sizes from the
+// client's own content. It maps EXPANDED_H tall — thicker than any sane
+// bar_height, so `any_expanded_status_segment` reads it as an open in-surface
+// menu. It subscribes to the `dismiss` topic on the compositor's status socket
+// and reacts the way the real bar does: on the first dismiss push it shrinks
+// to SHRUNK_H (back to a bar strip, menu closed), printing one
+// "dismiss <payload>" line to stdout per push so a driver script can count
+// them. Runs until killed (the cce-shadow stop sweep finds it by HOME).
+
+use std::io::{BufRead, Write};
+use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
+use std::sync::mpsc;
+use wayland_client::{
+ delegate_noop,
+ protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface},
+ Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+
+const WIDTH: i32 = 360;
+const EXPANDED_H: i32 = 400; // > bar_height: reads as an open menu
+const SHRUNK_H: i32 = 8; // <= bar_height (default 24): plain bar strip
+
+#[derive(Default)]
+struct State {
+ compositor: Option<wl_compositor::WlCompositor>,
+ shm: Option<wl_shm::WlShm>,
+ wm_base: Option<xdg_wm_base::XdgWmBase>,
+ configured: bool,
+ closed: bool,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+ fn event(
+ state: &mut Self,
+ registry: &wl_registry::WlRegistry,
+ event: wl_registry::Event,
+ _: &(),
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ ) {
+ if let wl_registry::Event::Global { name, interface, version } = event {
+ match interface.as_str() {
+ "wl_compositor" => {
+ state.compositor = Some(
+ registry.bind::<wl_compositor::WlCompositor, _, _>(name, version.min(4), qh, ()),
+ );
+ }
+ "wl_shm" => {
+ state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, 1, qh, ()));
+ }
+ "xdg_wm_base" => {
+ state.wm_base =
+ Some(registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, 1, qh, ()));
+ }
+ _ => {}
+ }
+ }
+ }
+}
+
+impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
+ fn event(
+ _: &mut Self,
+ wm_base: &xdg_wm_base::XdgWmBase,
+ event: xdg_wm_base::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ if let xdg_wm_base::Event::Ping { serial } = event {
+ wm_base.pong(serial);
+ }
+ }
+}
+
+impl Dispatch<xdg_surface::XdgSurface, ()> for State {
+ fn event(
+ state: &mut Self,
+ xdg_surface: &xdg_surface::XdgSurface,
+ event: xdg_surface::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ if let xdg_surface::Event::Configure { serial } = event {
+ xdg_surface.ack_configure(serial);
+ state.configured = true;
+ }
+ }
+}
+
+impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
+ fn event(
+ state: &mut Self,
+ _: &xdg_toplevel::XdgToplevel,
+ event: xdg_toplevel::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ // Configure sizes are ignored: a Status window sizes itself.
+ if let xdg_toplevel::Event::Close = event {
+ state.closed = true;
+ }
+ }
+}
+
+delegate_noop!(State: ignore wl_compositor::WlCompositor);
+delegate_noop!(State: ignore wl_shm::WlShm);
+delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
+delegate_noop!(State: ignore wl_buffer::WlBuffer);
+delegate_noop!(State: ignore wl_surface::WlSurface);
+
+/// One shm pool big enough for the expanded buffer; both buffers share it
+/// (contents are a solid fill, overlap does not matter).
+fn make_pool_fd() -> OwnedFd {
+ let size = (WIDTH * 4 * EXPANDED_H) as u64;
+ let fd = unsafe { libc::memfd_create(b"status-stub\0".as_ptr() as *const _, 0) };
+ assert!(fd >= 0, "memfd_create failed");
+ let file = unsafe { std::fs::File::from_raw_fd(fd) };
+ file.set_len(size).unwrap();
+ // Solid opaque slate; any visible pixels will do.
+ let mmapped = unsafe {
+ libc::mmap(
+ std::ptr::null_mut(),
+ size as usize,
+ libc::PROT_WRITE,
+ libc::MAP_SHARED,
+ file.as_raw_fd(),
+ 0,
+ )
+ };
+ assert!(mmapped != libc::MAP_FAILED, "mmap failed");
+ unsafe {
+ let px = mmapped as *mut u32;
+ for i in 0..(WIDTH * EXPANDED_H) as usize {
+ *px.add(i) = 0xff30_3a4a;
+ }
+ libc::munmap(mmapped, size as usize);
+ }
+ OwnedFd::from(file)
+}
+
+fn spawn_dismiss_listener(tx: mpsc::Sender<()>) {
+ std::thread::spawn(move || {
+ let display = std::env::var("WAYLAND_DISPLAY").expect("WAYLAND_DISPLAY not set");
+ let path = format!("/tmp/cce-status-interface-{}.sock", display);
+ let mut stream = None;
+ for _ in 0..50 {
+ match std::os::unix::net::UnixStream::connect(&path) {
+ Ok(s) => {
+ stream = Some(s);
+ break;
+ }
+ Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)),
+ }
+ }
+ let mut stream = stream.expect("could not connect to status socket");
+ stream.write_all(b"dismiss\n").unwrap();
+ let reader = std::io::BufReader::new(stream);
+ for line in reader.lines() {
+ let line = match line {
+ Ok(l) => l,
+ Err(_) => break,
+ };
+ println!("dismiss {}", line);
+ std::io::stdout().flush().ok();
+ let _ = tx.send(());
+ }
+ });
+}
+
+fn main() {
+ let conn = Connection::connect_to_env().expect("connect to wayland display");
+ let display = conn.display();
+ let mut queue = conn.new_event_queue();
+ let qh = queue.handle();
+ let _registry = display.get_registry(&qh, ());
+
+ let mut state = State::default();
+ queue.roundtrip(&mut state).unwrap();
+
+ let compositor = state.compositor.clone().expect("no wl_compositor");
+ let shm = state.shm.clone().expect("no wl_shm");
+ let wm_base = state.wm_base.clone().expect("no xdg_wm_base");
+
+ let surface = compositor.create_surface(&qh, ());
+ let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
+ let toplevel = xdg_surface.get_toplevel(&qh, ());
+ toplevel.set_app_id("cce-status-teststub".into());
+ toplevel.set_title("escape-dismiss-test".into());
+ surface.commit();
+
+ // First configure before the first buffer, per xdg-shell.
+ while !state.configured {
+ queue.blocking_dispatch(&mut state).unwrap();
+ }
+
+ let pool_fd = make_pool_fd();
+ let pool = shm.create_pool(pool_fd.as_fd(), WIDTH * 4 * EXPANDED_H, &qh, ());
+ let tall = pool.create_buffer(
+ 0, WIDTH, EXPANDED_H, WIDTH * 4, wl_shm::Format::Argb8888, &qh, (),
+ );
+ let short = pool.create_buffer(
+ 0, WIDTH, SHRUNK_H, WIDTH * 4, wl_shm::Format::Argb8888, &qh, (),
+ );
+
+ surface.attach(Some(&tall), 0, 0);
+ surface.damage_buffer(0, 0, WIDTH, EXPANDED_H);
+ surface.commit();
+
+ let (tx, rx) = mpsc::channel();
+ spawn_dismiss_listener(tx);
+
+ let mut shrunk = false;
+ while !state.closed {
+ conn.flush().unwrap();
+ if let Some(guard) = conn.prepare_read() {
+ let mut pfd = libc::pollfd {
+ fd: guard.connection_fd().as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ let n = unsafe { libc::poll(&mut pfd, 1, 100) };
+ if n > 0 && (pfd.revents & libc::POLLIN) != 0 {
+ let _ = guard.read();
+ } else {
+ drop(guard);
+ }
+ }
+ queue.dispatch_pending(&mut state).unwrap();
+
+ if !shrunk && rx.try_recv().is_ok() {
+ surface.attach(Some(&short), 0, 0);
+ surface.damage_buffer(0, 0, WIDTH, SHRUNK_H);
+ surface.commit();
+ shrunk = true;
+ }
+ }
+}
diff --git a/verify/clients/src/bin/vkey.rs b/verify/clients/src/bin/vkey.rs
new file mode 100644
index 0000000..6213900
--- /dev/null
+++ b/verify/clients/src/bin/vkey.rs
@@ -0,0 +1,142 @@
+// vkey — inject key events through zwp_virtual_keyboard_v1 (wtype-style),
+// for exercising the compositor's keyboard stack in cce-shadow sessions.
+//
+// Usage: vkey <keycode|mod:MASK> ...
+// keycode evdev code, pressed then released (h=35 e=18 l=38 o=24, 1=Escape)
+// mod:MASK set held modifiers for the keys that follow (xkb depressed mask
+// under the us keymap: shift=1 ctrl=4 alt=8 super=64); cleared on exit
+
+use std::io::Write;
+use std::os::fd::{BorrowedFd, FromRawFd, OwnedFd};
+use wayland_client::{
+ protocol::{wl_registry, wl_seat},
+ Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
+ zwp_virtual_keyboard_manager_v1, zwp_virtual_keyboard_v1,
+};
+
+#[derive(Default)]
+struct State {
+ seat: Option<wl_seat::WlSeat>,
+ manager: Option<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1>,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+ fn event(
+ state: &mut Self,
+ registry: &wl_registry::WlRegistry,
+ event: wl_registry::Event,
+ _: &(),
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ ) {
+ if let wl_registry::Event::Global {
+ name, interface, version,
+ } = event
+ {
+ match interface.as_str() {
+ "wl_seat" => {
+ state.seat =
+ Some(registry.bind::<wl_seat::WlSeat, _, _>(name, version.min(7), qh, ()));
+ }
+ "zwp_virtual_keyboard_manager_v1" => {
+ state.manager = Some(
+ registry
+ .bind::<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, _, _>(
+ name, 1, qh, (),
+ ),
+ );
+ }
+ _ => {}
+ }
+ }
+ }
+}
+
+impl Dispatch<wl_seat::WlSeat, ()> for State {
+ fn event(
+ _: &mut Self, _: &wl_seat::WlSeat, _: wl_seat::Event, _: &(), _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+impl Dispatch<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, ()> for State {
+ fn event(
+ _: &mut Self, _: &zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
+ _: zwp_virtual_keyboard_manager_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+impl Dispatch<zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1, ()> for State {
+ fn event(
+ _: &mut Self, _: &zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
+ _: zwp_virtual_keyboard_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+fn keymap_fd() -> (OwnedFd, u32) {
+ let ctx = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
+ let keymap = xkbcommon::xkb::Keymap::new_from_names(
+ &ctx, "", "", "us", "", None, xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
+ )
+ .expect("compile keymap");
+ let string = keymap.get_as_string(xkbcommon::xkb::KEYMAP_FORMAT_TEXT_V1);
+
+ let fd = unsafe { libc::memfd_create(b"vkey-keymap\0".as_ptr() as *const _, 0) };
+ assert!(fd >= 0, "memfd_create failed");
+ let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
+ file.write_all(string.as_bytes()).unwrap();
+ file.write_all(b"\0").unwrap();
+ let size = string.len() as u32 + 1;
+ (OwnedFd::from(file), size)
+}
+
+fn main() {
+ // args: keycodes, or "mod:<depressed-mask>" to change held modifiers
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ assert!(!args.is_empty(), "usage: vkey <keycode|mod:MASK> ...");
+
+ let conn = Connection::connect_to_env().expect("connect to wayland display");
+ let display = conn.display();
+ let mut queue = conn.new_event_queue();
+ let qh = queue.handle();
+ let _registry = display.get_registry(&qh, ());
+
+ let mut state = State::default();
+ queue.roundtrip(&mut state).unwrap();
+
+ let seat = state.seat.clone().expect("no wl_seat advertised");
+ let manager = state
+ .manager
+ .clone()
+ .expect("no zwp_virtual_keyboard_manager_v1 advertised");
+
+ let vk = manager.create_virtual_keyboard(&seat, &qh, ());
+
+ let (fd, size) = keymap_fd();
+ vk.keymap(1 /* XKB_V1 */, unsafe { BorrowedFd::borrow_raw(std::os::fd::AsRawFd::as_raw_fd(&fd)) }, size);
+ queue.roundtrip(&mut state).unwrap();
+
+ let mut t = 0u32;
+ for arg in &args {
+ if let Some(mask) = arg.strip_prefix("mod:") {
+ let depressed: u32 = mask.parse().expect("mod mask must be a number");
+ vk.modifiers(depressed, 0, 0, 0);
+ } else {
+ let keycode: u32 = arg.parse().expect("keycode must be a number");
+ vk.key(t, keycode, 1); // pressed
+ vk.key(t + 5, keycode, 0); // released
+ t += 10;
+ }
+ }
+ vk.modifiers(0, 0, 0, 0);
+ queue.roundtrip(&mut state).unwrap();
+
+ vk.destroy();
+ queue.roundtrip(&mut state).unwrap();
+ println!("sent {} event(s)", args.len());
+}
diff --git a/verify/escape-dismiss-test b/verify/escape-dismiss-test
new file mode 100755
index 0000000..3409f00
--- /dev/null
+++ b/verify/escape-dismiss-test
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# escape-dismiss-test — behavioral test for the Escape-closes-status-menus arm
+# in keyboard_group.rs::handle_builtin_binding (7db8c03), driven end to end
+# through the virtual-keyboard path (fdd1209).
+#
+# Setup: a private cce-shadow instance, with status-stub mapped as a status
+# segment 400px tall — geometrically "expanded", i.e. an open in-surface menu.
+# The stub subscribes to the `dismiss` topic and shrinks to a bar strip on the
+# first push, printing one line per push. vkey injects Escape through
+# zwp_virtual_keyboard_v1. Three assertions, one per gate of the arm:
+#
+# 1. Ctrl+Escape while expanded -> no dismiss (modifiers == 0 gate)
+# 2. plain Escape while expanded -> one dismiss, stub shrinks below
+# bar_height (the dismissal itself)
+# 3. plain Escape after shrink -> no dismiss (any_expanded_status_segment
+# gate, now closed)
+#
+# The compositor binary is whatever cce-shadow resolves (installed first, then
+# the workspace target/release); extra args are passed to `cce-shadow start`,
+# so `escape-dismiss-test --bin path/to/cce-fx` pins one explicitly.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(dirname "$here")
+shadow="$repo/scripts/cce-shadow"
+out=$(mktemp -d)
+inst=""
+
+cleanup() {
+ [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
+ rm -rf "$out"
+}
+trap cleanup EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ echo "--- stub log ---" >&2
+ cat "$out/stub.log" >&2 || true
+ echo "--- compositor log tail ---" >&2
+ [ -n "$inst" ] && "$shadow" --instance "$inst" logs 2>/dev/null | tail -20 >&2 || true
+ exit 1
+}
+
+echo "==> building test clients"
+cargo build --quiet --manifest-path "$here/clients/Cargo.toml"
+bin="$here/clients/target/debug"
+
+echo "==> starting shadow instance"
+inst=$("$shadow" start --new "$@" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
+[ -n "$inst" ] || fail "could not claim a shadow instance"
+sc() { "$shadow" --instance "$inst" "$@"; }
+
+stub_h() {
+ sc ctl windows --json 2>/dev/null \
+ | jq -r 'select(.app_id == "cce-status-teststub") | .h' | head -1
+}
+dismisses() { grep -c '^dismiss' "$out/stub.log" 2>/dev/null || true; }
+
+# Predicates for wait_for. They must be functions, not `test "$(...)" = x`
+# inline: a command substitution in wait_for's arguments would be expanded
+# once at call time, and the loop would poll a constant.
+stub_h_is() { [ "$(stub_h)" = "$1" ]; }
+dismiss_count_is() { [ "$(dismisses)" = "$1" ]; }
+
+wait_for() { # <description> <timeout-seconds> <predicate...>
+ local desc=$1 timeout=$2 i=0
+ shift 2
+ until "$@"; do
+ i=$((i + 1))
+ [ "$i" -ge $((timeout * 5)) ] && fail "timed out waiting for $desc"
+ sleep 0.2
+ done
+}
+
+echo "==> mapping expanded status stub"
+sc run "$bin/status-stub" >"$out/stub.log" 2>&1 &
+wait_for "stub mapped expanded (h=400)" 10 stub_h_is 400
+
+echo "==> gate 1: Ctrl+Escape while expanded must not dismiss"
+sc run "$bin/vkey" mod:4 1 mod:0 >/dev/null
+sleep 1
+[ "$(dismisses)" = 0 ] || fail "chorded Escape dismissed the menu (got $(dismisses) dismiss lines)"
+[ "$(stub_h)" = 400 ] || fail "stub shrank on chorded Escape"
+
+echo "==> gate 2: plain Escape while expanded must dismiss exactly once"
+sc run "$bin/vkey" 1 >/dev/null
+wait_for "dismiss push to reach the stub" 5 dismiss_count_is 1
+wait_for "stub to shrink below bar_height" 5 stub_h_is 8
+
+echo "==> gate 3: plain Escape with no expanded segment must stay quiet"
+sc run "$bin/vkey" 1 >/dev/null
+sleep 1
+[ "$(dismisses)" = 1 ] || fail "Escape dismissed with no menu open (got $(dismisses) dismiss lines)"
+
+echo "PASS: all three gates of the Escape-dismiss arm behave"