summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 15:49:32 +0900
committerAdam Malczewski <[email protected]>2026-06-13 15:49:32 +0900
commit11812b0374d5de395e2c17532c6bf89a903ee043 (patch)
tree029f1d222aadf0d43f96fa071c313e4cdd119202
parent803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9 (diff)
downloadunbox-11812b0374d5de395e2c17532c6bf89a903ee043.tar.gz
unbox-11812b0374d5de395e2c17532c6bf89a903ee043.zip
Slice 5b: config-driven keybindings — Super→fuzzel, Alt+Tab; kernel exports WAYLAND_DISPLAY
ext-keybindings (new core ext) reads unbox.toml: tap-Super spawns fuzzel, Alt+Tab/Alt+Shift+Tab rotate focus across all toplevels, plus Alt+F1 and Ctrl+Alt+Backspace (quit). ext-xdg-shell's hardcoded keybinds removed (migrated to the toml). Kernel setenv()s WAYLAND_DISPLAY at startup so extension-spawned clients connect to unbox, not the launching session — fixes fuzzel "no monitors" on the real seat. build + build-asan green: third-party Mesa/EGL/DRM + vendored-RmlUi sanitizer noise suppressed (suppressions/), our code stays leak-checked; a real libwayland leak in the layer-shell client test fixed. Harness: spawn-env + sanitizer-noise rules, diagnose-real-seat skill, GLOSSARY keybinding/action/tap-binding. Real-seat verified on the CF-AX3.
-rw-r--r--.skills/diagnose-real-seat.md21
-rw-r--r--.unbox/rules/sanitizer-noise.md5
-rw-r--r--.unbox/rules/spawn-env.md5
-rw-r--r--GLOSSARY.md8
-rw-r--r--meson.build16
-rw-r--r--notes/plan.md3
-rw-r--r--packages/ext-keybindings/ext-keybindings.md61
-rw-r--r--packages/ext-keybindings/include/unbox/ext-keybindings/ext_keybindings.hpp47
-rw-r--r--packages/ext-keybindings/meson.build73
-rw-r--r--packages/ext-keybindings/src/config.cpp104
-rw-r--r--packages/ext-keybindings/src/config.hpp43
-rw-r--r--packages/ext-keybindings/src/extension.cpp285
-rw-r--r--packages/ext-keybindings/src/focus_ring.hpp118
-rw-r--r--packages/ext-keybindings/src/policy.hpp344
-rw-r--r--packages/ext-keybindings/tests/test_glue.cpp61
-rw-r--r--packages/ext-keybindings/tests/test_policy.cpp436
-rw-r--r--packages/ext-layer-shell/tests/test_client.cpp12
-rw-r--r--packages/ext-xdg-shell/ext-xdg-shell.md19
-rw-r--r--packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp20
-rw-r--r--packages/ext-xdg-shell/src/extension.cpp44
-rw-r--r--packages/ext-xdg-shell/src/policy.hpp74
-rw-r--r--packages/ext-xdg-shell/tests/test_policy.cpp93
-rw-r--r--packages/host-bin/meson.build2
-rw-r--r--packages/host-bin/src/main.cpp18
-rw-r--r--packages/kernel/src/server.cpp62
-rw-r--r--packages/kernel/tests/test_kernel.cpp34
-rw-r--r--subprojects/tomlplusplus.wrap10
-rw-r--r--suppressions/lsan.txt19
-rw-r--r--suppressions/ubsan.txt8
-rw-r--r--tasks.md8
-rw-r--r--unbox.toml37
31 files changed, 1849 insertions, 241 deletions
diff --git a/.skills/diagnose-real-seat.md b/.skills/diagnose-real-seat.md
new file mode 100644
index 0000000..ff2e967
--- /dev/null
+++ b/.skills/diagnose-real-seat.md
@@ -0,0 +1,21 @@
+Use when a feature works headless/nested but fails ONLY on the real DRM seat (input, spawning, outputs) — i.e. under the bare-TTY `~/start-unbox.sh`.
+---
+# /diagnose-real-seat — real-seat-only bug checklist
+
+1. Reproduce headless first: `WLR_BACKENDS=headless ./build/packages/host-bin/unbox`.
+ Works headless but not on the seat → seat/DRM/spawn-specific, not logic.
+2. Capture logs without leaving the seat: `~/start-unbox.sh --debug [-s foot]`
+ writes /tmp/unbox.log and prints the diagnostic lines on exit.
+3. What a key REALLY emits: `~/capture-keys.sh` (evtest over ALL input devices)
+ → /tmp/keycap.log. (Both CF-AX3 Super keys = KEY_LEFTMETA → Super_L 0xffeb.)
+4. What clients RECEIVE (globals/outputs): `~/dump-outputs.sh` runs `~/wloutdump`
+ inside unbox → /tmp/wlout.txt (wayland-info/weston-info are NOT installed).
+ Compare the real output against the headless baseline.
+5. A live process's EFFECTIVE env: /proc/<pid>/environ does NOT reflect runtime
+ setenv(). Read it by launching the binary as gdb's OWN child (ptrace_scope=1
+ allows tracing your child): `gdb --batch -ex 'break wl_display_run' -ex run
+ -ex 'call (char*)getenv("WAYLAND_DISPLAY")' -ex kill ./build/.../unbox`.
+6. Spawned client reached the WRONG compositor → check WAYLAND_DISPLAY in the
+ spawn env (.unbox/rules/spawn-env.md); libwayland defaults to wayland-0 (parent).
+7. Escape a stuck session: Ctrl+Alt+Backspace (VT-switch isn't implemented yet);
+ from another VT `pkill -x unbox` (never `-f` — it kills your own shell).
diff --git a/.unbox/rules/sanitizer-noise.md b/.unbox/rules/sanitizer-noise.md
new file mode 100644
index 0000000..45ee266
--- /dev/null
+++ b/.unbox/rules/sanitizer-noise.md
@@ -0,0 +1,5 @@
+# sanitizer-noise
+build-asan must stay green WITHOUT blanket-disabling checks (never detect_leaks=0).
+Third-party process-lifetime noise (Mesa/EGL/DRM driver globals; a vendored RmlUi
+vptr downcast) goes in suppressions/ matched to those frames ONLY. A leak or UB
+whose stack has an unbox:: frame is OURS — fix it, never suppress it.
diff --git a/.unbox/rules/spawn-env.md b/.unbox/rules/spawn-env.md
new file mode 100644
index 0000000..88ba379
--- /dev/null
+++ b/.unbox/rules/spawn-env.md
@@ -0,0 +1,5 @@
+# spawn-env
+Any process spawned to run a Wayland client (launcher, terminal, …) MUST get the
+compositor's own WAYLAND_DISPLAY. The kernel setenv()s it at startup; don't build
+a child env that drops it, and don't trust the inherited parent env (it points at
+the session that launched unbox). Scar: spawned fuzzel hit parent labwc → "no monitors".
diff --git a/GLOSSARY.md b/GLOSSARY.md
index febe708..e68c9a7 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -47,6 +47,14 @@
| **data binding** | RMLUi's model↔document binding; the ONLY way extension state reaches RML. | — |
| **touch-mode** | The substrate state signalling finger input (auto-flipped, debounced). NO automatic visual scaling (user decision, slice 5) — extensions may adapt affordances via the change notification (spacing, invisible hit zones, OSK auto-show). | tablet mode |
+## Input & keybindings
+
+| Term | Meaning | Aliases to avoid |
+|---|---|---|
+| **keybinding** | A key-combo → action mapping declared in `unbox.toml` (`[[keybind]]`), matched on the kernel's `key_filter`. Owned by ext-keybindings. | shortcut, hotkey, accelerator |
+| **action** | The verb a keybinding triggers: `spawn`, `focus-next`, `focus-prev`, `close-active`, `quit`. | command (reserved for the `spawn` shell string) |
+| **tap binding** | A keybinding on a bare modifier (e.g. `"Super"`) that fires on release only if it was pressed and released with nothing in between. | — |
+
## Workflow
| Term | Meaning | Aliases to avoid |
diff --git a/meson.build b/meson.build
index 11a46d8..846b5e6 100644
--- a/meson.build
+++ b/meson.build
@@ -31,8 +31,24 @@ rmlui_dep = rmlui_proj.dependency('rmlui_core')
doctest_dep = dependency('doctest')
+# Sanitizer test environment. The substrate's GL tests pull in Mesa/EGL/DRM,
+# which keep process-lifetime driver globals (reclaimed at exit, not leaks), and
+# vendored RmlUi trips a benign vptr downcast during element teardown. Point
+# LSan/UBSan at suppression files scoped to THOSE third-party frames only — our
+# own code stays fully checked, so an unbox:: leak/UB still fails the suite.
+# Applied by default to every `meson test` (harmless in the non-sanitized build).
+add_test_setup(
+ 'suppressed',
+ is_default: true,
+ env: [
+ 'LSAN_OPTIONS=suppressions=' + (meson.project_source_root() / 'suppressions' / 'lsan.txt'),
+ 'UBSAN_OPTIONS=suppressions=' + (meson.project_source_root() / 'suppressions' / 'ubsan.txt') + ':print_stacktrace=1:halt_on_error=1',
+ ],
+)
+
# Units. Adding one? ALL FOUR steps of .unbox/rules/unit-registration.md.
subdir('packages/kernel')
subdir('packages/ext-xdg-shell')
subdir('packages/ext-layer-shell')
+subdir('packages/ext-keybindings')
subdir('packages/host-bin')
diff --git a/notes/plan.md b/notes/plan.md
index a2650ba..cc83241 100644
--- a/notes/plan.md
+++ b/notes/plan.md
@@ -63,6 +63,8 @@ solves), and the trigger that would reopen it.
| **xwayland: optional extension, OFF by default** | RAM; this is an experimental DE — X11 apps opt in | — |
| Vocabulary source: **wlroots' own names** | P8 + prefer training-baked terms; Wayland's synonym swamp (surface/view/window/toplevel, output/monitor/display) is severe | — |
| **touch-mode causes NO visual change** (state + typed notification only; dp-ratio stays 1.0) | User found any automatic scaling jarring on hardware (slice-5 hands-on, three iterations: 1.6→1.25→none); extensions adapt affordances explicitly via `on_touch_mode_changed` | Real-seat ergonomics (slice 9+) show finger targets genuinely too small |
+| **Keybindings are config-driven via `unbox.toml`** — ext-keybindings (core) is the first `unbox.toml` consumer/parser; external fuzzel-on-Super stands in for an in-process launcher for now | Fastest path to a usable DE (Super→fuzzel, Alt+Tab); exercises the key_filter + ext-xdg-shell focus contract with no new UI | A bespoke in-process launcher/taskbar lands (slice 6) |
+| **Kernel exports `WAYLAND_DISPLAY`** (setenv at startup) so any process an extension spawns connects to unbox, not the session that launched unbox | Spawned clients inherit the process env; without it fuzzel hit the parent labwc (`wayland-0`) → "no monitors" | — |
## 3. Architecture
@@ -139,7 +141,6 @@ trusted.
| Catch2 vs doctest revisit | doctest | doctest blocks something real |
| dmabuf render-format negotiation (`wlr_renderer_get_render_formats` is private in wlroots 0.20) | hardcoded ARGB8888/LINEAR (verified on crocus) | wlroots bump slice or a GPU that rejects it |
| window placement policy (new toplevels overlap at origin) | tinywl parity: no placement | slice 7 tiling (or earlier if it blocks testing) |
-| ext-keybindings (config-driven) + first unbox.toml parsing | key Filter chain (slice 4) + bindings in ext-xdg-shell | own slice after 6 (user, slice-5 planning) |
## 8. References
diff --git a/packages/ext-keybindings/ext-keybindings.md b/packages/ext-keybindings/ext-keybindings.md
new file mode 100644
index 0000000..7014b58
--- /dev/null
+++ b/packages/ext-keybindings/ext-keybindings.md
@@ -0,0 +1,61 @@
+# ext-keybindings
+
+Config-driven compositor keybindings — the first step to a usable DE. A **core**
+extension (`id "keybindings"`, `depends_on {"xdg-shell"}`). It is a LEAF
+consumer: its whole contract is the `create()` factory in
+`include/unbox/ext-keybindings/ext_keybindings.hpp` — it exports no hooks or
+services.
+
+## Why it exists
+Compositor shortcuts must live in ONE policy unit, not be hardcoded in the
+shell. This unit owns them and reads them from `unbox.toml` (toml++). It also
+SUBSUMES the two shortcuts ext-xdg-shell used to hardcode (`Alt+F1` focus-cycle,
+`Ctrl+Alt+Backspace` quit), preserved as compiled defaults.
+
+## Action vocabulary (`action = ...`)
+- `spawn` — run `command` via `/bin/sh -c` (requires a non-empty `command`).
+- `focus-next` / `focus-prev` — rotate keyboard focus across ALL mapped windows.
+- `close-active` — close the focused toplevel (no-op if none).
+- `quit` — `wl_display_terminate`.
+
+Combos are `Mod+...+Key` (mods: `Super`/`Logo`, `Alt`, `Ctrl`/`Control`,
+`Shift`, case-insensitive; final token an xkb keysym name). A BARE modifier
+(`"Super"`) is a TAP binding. Unknown action / malformed combo = log + skip that
+entry; never abort. No config / parse error / zero valid bindings → compiled
+defaults (out-of-the-box == the repo-root sample `unbox.toml`).
+
+## Focus ring: STABLE map order, not MRU
+`focus-next`/`focus-prev` rotate a list kept in **map order** (append on
+`on_toplevel_mapped`, drop on `on_toplevel_unmapped`, move-cursor on
+`on_toplevel_focused`). Repeated Alt+Tab therefore walks all N windows and
+wraps — it does NOT ping-pong the two most-recently-used (MRU was rejected as
+wrong). The `Toplevel*` is stored only between its mapped and unmapped events
+(the supported borrow window) and never dereferenced inside the pure ring core.
+
+## The tap-Super gotcha
+A bare-modifier binding fires on the modifier's RELEASE only if it was pressed
+and released with NOTHING in between. The matcher arms on Super-down, marks
+"used" on any other key press (or any Super-carrying chord), and fires on
+Super-up only if still unused. The modifier press/release are NEVER consumed
+(other combos need Super held); a fired chord IS consumed (`handled = true`), a
+fired tap consumes nothing (the modifier already passed through). Pointer
+Super+click cannot mark the tap used (the pointer is not in the key_filter) —
+accepted for now.
+
+## labwc-nested caveat
+In nested dev under the live labwc session the parent compositor may swallow
+Alt+Tab and the Super tap before they reach unbox, so live FEEL cannot be
+verified here — that needs the orchestrator's hands-on on the real seat. No
+Escape combo is bound (an established decision keeps all Escape chords passing
+through to the parent session).
+
+## Layout
+- `include/unbox/ext-keybindings/ext_keybindings.hpp` — the factory (contract).
+- `src/policy.hpp` — combo parser + matcher/tap state machine (pure;
+ xkbcommon-only).
+- `src/focus_ring.hpp` — stable-rotation ring over opaque tokens (pure).
+- `src/config.{hpp,cpp}` — toml++ loader, string → bindings + warnings (pure).
+- `src/extension.cpp` — glue: key_filter link, xdg-shell event subscriptions,
+ fork/exec spawn, focus/close/terminate effects.
+- `tests/test_policy.cpp` — the four cores, doctest-hard.
+- `tests/test_glue.cpp` — headless install/activate/dispatch/shutdown smoke.
diff --git a/packages/ext-keybindings/include/unbox/ext-keybindings/ext_keybindings.hpp b/packages/ext-keybindings/include/unbox/ext-keybindings/ext_keybindings.hpp
new file mode 100644
index 0000000..cb3a4f6
--- /dev/null
+++ b/packages/ext-keybindings/include/unbox/ext-keybindings/ext_keybindings.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <memory>
+#include <optional>
+#include <string>
+
+// ext-keybindings — config-driven compositor keybindings as a CORE extension.
+//
+// The first step to a usable DE. Two user-facing features plus the compositor
+// shortcuts ext-xdg-shell used to hardcode:
+// * Tap the Super (logo) key alone -> spawn an external launcher (fuzzel).
+// * Alt+Tab / Alt+Shift+Tab -> rotate keyboard focus across ALL mapped
+// windows (forward / back, wrapping), in STABLE mapping order — not MRU.
+// * Alt+F1 -> focus-next, Ctrl+Alt+Backspace -> quit (the shortcuts
+// ext-xdg-shell drops this wave; preserved here as compiled defaults).
+//
+// Tier: core. Manifest id "keybindings", depends_on {"xdg-shell"} — it consumes
+// ext-xdg-shell's Service (window focus + toplevel lifecycle events) for the
+// focus ring, fetched in activate() via host.service<ext_xdg_shell::Service>().
+// Input arrives through the kernel's key_filter (a Filter<KeyEvent>): a matched
+// binding sets handled=true to consume the key before it reaches the client.
+//
+// This header is the unit's WHOLE cross-extension contract: a factory only
+// (mirroring ext-layer-shell). It exports NO hooks or services — it is a leaf
+// consumer. Single wl_event_loop thread throughout.
+
+namespace unbox::ext_keybindings {
+
+// Construct the extension (ownership transfer to the caller; host-bin installs
+// it via Server::install). Construction is cheap and side-effect free per the
+// Extension contract; ALL wiring — config load, key_filter subscription,
+// xdg-shell event subscriptions — happens in activate().
+//
+// config_path: the explicit unbox.toml path (host-bin --config). If nullopt,
+// activate() discovers $XDG_CONFIG_HOME/unbox/unbox.toml then
+// ~/.config/unbox/unbox.toml. If no file is found OR it fails to parse OR it
+// contains no valid bindings, the extension logs and degrades to the compiled-in
+// DEFAULTS (Super->spawn fuzzel, Alt+Tab/Alt+Shift+Tab focus rotation,
+// Alt+F1->focus-next, Ctrl+Alt+Backspace->quit). A bad/missing config NEVER
+// throws out of activate(); the ONLY fatal (a thrown exception) is a missing
+// ext-xdg-shell Service, which is a broken core session.
+[[nodiscard]] auto create(std::optional<std::string> config_path = std::nullopt)
+ -> std::unique_ptr<kernel::Extension>;
+
+} // namespace unbox::ext_keybindings
diff --git a/packages/ext-keybindings/meson.build b/packages/ext-keybindings/meson.build
new file mode 100644
index 0000000..4629fed
--- /dev/null
+++ b/packages/ext-keybindings/meson.build
@@ -0,0 +1,73 @@
+# ext-keybindings — config-driven compositor keybindings as a CORE extension.
+# Public headers (the contract): include/unbox/ext-keybindings/
+# ext_keybindings.hpp — the extension factory (the WHOLE cross-extension
+# surface; this is a LEAF consumer — it exports no hooks/services).
+#
+# Decision cores live in src/ and are wlroots/GL-free: the combo parser
+# (xkbcommon only), the toml loader (toml++), the matcher + tap state machine,
+# and the focus ring. The glue (src/extension.cpp) is the thin effectful edge
+# that binds the kernel key_filter, the ext-xdg-shell toplevel events, fork/exec,
+# and wl_display_terminate.
+
+ext_keybindings_inc = include_directories('include')
+
+# toml++ (header-only): an APPROVED dep (notes/plan.md §2), first use here as a
+# Meson wrap kept private to THIS unit (subprojects/tomlplusplus.wrap).
+tomlplusplus_dep = dependency('tomlplusplus')
+
+# Glue library. Needs the kernel ABI, ext-xdg-shell's public contract (the glue
+# includes its header to consume the Service + Toplevel), xkbcommon (the combo
+# parser resolves keysym names), and toml++ (the config loader).
+ext_keybindings_lib = static_library(
+ 'unbox-ext-keybindings',
+ 'src/extension.cpp',
+ 'src/config.cpp',
+ include_directories: ext_keybindings_inc,
+ dependencies: [kernel_dep, ext_xdg_shell_dep, xkbcommon_dep, tomlplusplus_dep],
+)
+
+# What host-bin links against: the factory. kernel_dep rides through for the
+# Extension ABI the factory returns. ext-xdg-shell stays a build-time dep of OUR
+# lib only (consumers of the factory do not need it).
+ext_keybindings_dep = declare_dependency(
+ link_with: ext_keybindings_lib,
+ include_directories: ext_keybindings_inc,
+ dependencies: [kernel_dep],
+)
+
+# Tests, asymmetric: the pure decision cores doctest-hard (combo parser, toml
+# loader, matcher + tap SM, focus ring) with NO kernel/wlroots running, plus a
+# lenient headless glue smoke (install + activate + dispatch + clean shutdown).
+# The pure-core TU compiles the core sources directly and needs `src` on the
+# include path (a unit may read its own src/) plus xkbcommon + toml++.
+ext_keybindings_policy_test = executable(
+ 'ext-keybindings-policy-tests',
+ 'tests/test_policy.cpp',
+ 'src/config.cpp',
+ include_directories: [ext_keybindings_inc, include_directories('src')],
+ dependencies: [doctest_dep, xkbcommon_dep, tomlplusplus_dep],
+)
+test(
+ 'ext-keybindings-policy',
+ ext_keybindings_policy_test,
+ suite: 'ext-keybindings',
+)
+
+# Glue test: install + activate + dispatch + shutdown on the wlr headless
+# backend, with ext-xdg-shell present (keybindings depends on it). Lenient.
+ext_keybindings_glue_test = executable(
+ 'ext-keybindings-glue-tests',
+ 'tests/test_glue.cpp',
+ dependencies: [ext_keybindings_dep, ext_xdg_shell_dep, doctest_dep],
+)
+test(
+ 'ext-keybindings-glue',
+ ext_keybindings_glue_test,
+ suite: 'ext-keybindings',
+)
+
+# Aggregate alias the brief builds: `ninja -C build ext-keybindings-tests`.
+alias_target('ext-keybindings-tests',
+ ext_keybindings_policy_test,
+ ext_keybindings_glue_test,
+)
diff --git a/packages/ext-keybindings/src/config.cpp b/packages/ext-keybindings/src/config.cpp
new file mode 100644
index 0000000..4d1045f
--- /dev/null
+++ b/packages/ext-keybindings/src/config.cpp
@@ -0,0 +1,104 @@
+#include "config.hpp"
+
+#include <string>
+
+// toml++ is consumed via its PREBUILT subproject library, which is compiled with
+// exceptions enabled (toml::v3::ex::parse). We therefore use the throwing parse
+// and catch toml::parse_error HERE so a syntax error becomes a clean
+// parse_error result, never an exception escaping into activate() (the brief's
+// hard rule). Matching the lib's exception mode avoids the toml::v3::noex link
+// mismatch that TOML_EXCEPTIONS=0 in this TU would cause.
+#include <toml++/toml.hpp>
+
+namespace unbox::ext_keybindings::config {
+
+auto load_from_string(std::string_view toml_text) -> LoadResult {
+ LoadResult result;
+
+ toml::table root;
+ try {
+ root = toml::parse(toml_text);
+ } catch (const toml::parse_error& err) {
+ result.parse_error = true;
+ std::string msg = "unbox.toml parse error: ";
+ msg += std::string(err.description());
+ result.warnings.push_back(std::move(msg));
+ return result;
+ }
+
+ const toml::node* keybind_node = root.get("keybind");
+ if (keybind_node == nullptr) {
+ // No [[keybind]] at all: not an error, just zero bindings. The glue
+ // falls back to defaults.
+ result.warnings.emplace_back("unbox.toml has no [[keybind]] entries");
+ return result;
+ }
+
+ const toml::array* entries = keybind_node->as_array();
+ if (entries == nullptr) {
+ result.warnings.emplace_back("'keybind' must be an array of tables ([[keybind]])");
+ return result;
+ }
+
+ std::size_t idx = 0;
+ for (const toml::node& node : *entries) {
+ const std::string where = "keybind #" + std::to_string(idx);
+ ++idx;
+
+ const toml::table* entry = node.as_table();
+ if (entry == nullptr) {
+ result.warnings.push_back(where + ": not a table");
+ continue;
+ }
+
+ // keys (required, string).
+ const toml::node* keys_node = entry->get("keys");
+ if (keys_node == nullptr || !keys_node->is_string()) {
+ result.warnings.push_back(where + ": missing or non-string 'keys'");
+ continue;
+ }
+ const std::string keys = keys_node->value<std::string>().value();
+
+ // action (required, string).
+ const toml::node* action_node = entry->get("action");
+ if (action_node == nullptr || !action_node->is_string()) {
+ result.warnings.push_back(where + ": missing or non-string 'action'");
+ continue;
+ }
+ const std::string action_str = action_node->value<std::string>().value();
+ const auto action = policy::action_from_string(action_str);
+ if (!action) {
+ result.warnings.push_back(where + ": unknown action '" + action_str + "'");
+ continue;
+ }
+
+ // command (required iff action == spawn; string when present).
+ std::string command;
+ const toml::node* command_node = entry->get("command");
+ if (command_node != nullptr) {
+ if (!command_node->is_string()) {
+ result.warnings.push_back(where + ": 'command' must be a string");
+ continue;
+ }
+ command = command_node->value<std::string>().value();
+ }
+ if (*action == policy::Action::spawn && command.empty()) {
+ result.warnings.push_back(where + ": action 'spawn' requires a non-empty 'command'");
+ continue;
+ }
+
+ // combo (validated last so a bad combo skips with a clear message).
+ const auto combo = policy::parse_combo(keys);
+ if (!combo) {
+ result.warnings.push_back(where + ": malformed key combo '" + keys + "'");
+ continue;
+ }
+
+ result.bindings.push_back(policy::Binding{
+ .combo = *combo, .action = *action, .command = std::move(command)});
+ }
+
+ return result;
+}
+
+} // namespace unbox::ext_keybindings::config
diff --git a/packages/ext-keybindings/src/config.hpp b/packages/ext-keybindings/src/config.hpp
new file mode 100644
index 0000000..d15babd
--- /dev/null
+++ b/packages/ext-keybindings/src/config.hpp
@@ -0,0 +1,43 @@
+#pragma once
+
+#include "policy.hpp"
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+// Pure decision core: the toml loader. Parses an unbox.toml document (as TEXT —
+// file discovery/reading is an effect kept in the glue) into a list of
+// policy::Binding, skipping malformed entries and recording a human-readable
+// warning for each skip. toml++ is the only dependency; no wlroots, no kernel.
+// Doctest-covered in tests/test_policy.cpp.
+
+namespace unbox::ext_keybindings::config {
+
+// The outcome of loading. `bindings` holds every well-formed [[keybind]] (in
+// document order). `warnings` holds one message per skipped/ill-formed entry or
+// a single parse-error message. `parse_error` is true iff the document itself
+// failed to parse (toml syntax error) — in that case `bindings` is empty and
+// `warnings` has the parser's message.
+//
+// NOTE: an empty document, or one with zero valid bindings, is NOT a parse
+// error — it returns empty `bindings` with parse_error=false. The glue decides
+// to fall back to compiled defaults when `bindings` ends up empty (whatever the
+// cause), and logs every warning.
+struct LoadResult {
+ std::vector<policy::Binding> bindings;
+ std::vector<std::string> warnings;
+ bool parse_error = false;
+};
+
+// Parse `toml_text` and extract the [[keybind]] array-of-tables per the
+// USER-APPROVED schema:
+// keys = "Mod+...+Key" | "Mod" (required, string)
+// action = "spawn" | "focus-next" | "focus-prev" | "close-active" | "quit"
+// command = "..." (required for action="spawn")
+// Each entry is validated independently: a malformed combo, unknown action,
+// missing/empty keys, missing command for spawn, or wrong value types skip that
+// ONE entry (with a warning) and never abort the rest.
+[[nodiscard]] auto load_from_string(std::string_view toml_text) -> LoadResult;
+
+} // namespace unbox::ext_keybindings::config
diff --git a/packages/ext-keybindings/src/extension.cpp b/packages/ext-keybindings/src/extension.cpp
new file mode 100644
index 0000000..b582a31
--- /dev/null
+++ b/packages/ext-keybindings/src/extension.cpp
@@ -0,0 +1,285 @@
+#include <unbox/ext-keybindings/ext_keybindings.hpp>
+
+#include "config.hpp"
+#include "focus_ring.hpp"
+#include "policy.hpp"
+
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
+#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdlib>
+#include <fstream>
+#include <memory>
+#include <optional>
+#include <sstream>
+#include <stdexcept>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <sys/wait.h>
+#include <unistd.h>
+
+// ext-keybindings glue: the thin effectful edge. The decision cores live in
+// src/policy.hpp (combo parser, matcher + tap SM), src/focus_ring.hpp (stable
+// rotation), and src/config.* (toml loader) — all wlroots/GL-free and
+// doctest-hard. THIS file only: loads the config file (effect), threads the
+// kernel key_filter through the Matcher, mirrors ext-xdg-shell's toplevel
+// lifecycle/focus events into the FocusRing, and performs the effects (fork/exec
+// spawn, Toplevel::focus()/close(), wl_display_terminate).
+
+namespace unbox::ext_keybindings {
+namespace {
+
+using kernel::Host;
+
+// ext-xdg-shell's Toplevel* is the opaque token the focus ring rotates over. We
+// NEVER deref it inside the ring; the glue derefs only a LIVE borrow (between its
+// mapped and unmapped events) to call focus()/close().
+using Toplevel = ext_xdg_shell::Toplevel;
+
+// ---- spawn (effect): run a shell command without leaking zombies ------------
+//
+// Double-fork: the intermediate child forks the actual command then _exit()s
+// immediately, so the grandchild is reparented to init (pid 1) and we never need
+// a SIGCHLD handler — we waitpid() only the short-lived intermediate child. The
+// command runs via `/bin/sh -c` (the brief's contract). Never blocks the event
+// loop: the parent returns as soon as the intermediate child is reaped (which is
+// immediate, since it only forks + exits).
+void spawn_command(const std::string& command) {
+ if (command.empty()) {
+ return;
+ }
+ const pid_t intermediate = fork();
+ if (intermediate < 0) {
+ wlr_log(WLR_ERROR, "ext-keybindings: fork failed for command '%s'",
+ command.c_str());
+ return;
+ }
+ if (intermediate == 0) {
+ // Intermediate child: detach into its own session, then fork the
+ // grandchild that exec()s the command.
+ setsid();
+ const pid_t grandchild = fork();
+ if (grandchild == 0) {
+ execl("/bin/sh", "/bin/sh", "-c", command.c_str(), static_cast<char*>(nullptr));
+ _exit(127); // exec failed
+ }
+ _exit(0); // intermediate exits immediately; grandchild -> init
+ }
+ // Parent: reap the intermediate child so it never lingers as a zombie. It
+ // exits right away, so this does not block the event loop.
+ int status = 0;
+ waitpid(intermediate, &status, 0);
+}
+
+// ---- config load (effect): discover + read the file, parse, fall back -------
+//
+// Returns the bindings to install. Logs every warning. Discovery order when no
+// explicit path: $XDG_CONFIG_HOME/unbox/unbox.toml, then ~/.config/unbox/
+// unbox.toml. No readable file, a parse error, or a file with zero valid
+// bindings -> the compiled-in DEFAULTS. Never throws.
+auto read_file(const std::string& path, std::string& out) -> bool {
+ std::ifstream in(path, std::ios::binary);
+ if (!in) {
+ return false;
+ }
+ std::ostringstream ss;
+ ss << in.rdbuf();
+ out = ss.str();
+ return true;
+}
+
+auto discover_config_path(const std::optional<std::string>& explicit_path)
+ -> std::optional<std::string> {
+ if (explicit_path) {
+ return explicit_path; // host-bin --config: use it verbatim
+ }
+ if (const char* xdg = std::getenv("XDG_CONFIG_HOME"); xdg != nullptr && xdg[0] != '\0') {
+ return std::string(xdg) + "/unbox/unbox.toml";
+ }
+ if (const char* home = std::getenv("HOME"); home != nullptr && home[0] != '\0') {
+ return std::string(home) + "/.config/unbox/unbox.toml";
+ }
+ return std::nullopt;
+}
+
+auto load_bindings(const std::optional<std::string>& explicit_path)
+ -> std::vector<policy::Binding> {
+ const std::optional<std::string> path = discover_config_path(explicit_path);
+ if (!path) {
+ wlr_log(WLR_INFO, "ext-keybindings: no config path; using compiled defaults");
+ return policy::default_bindings();
+ }
+
+ std::string text;
+ if (!read_file(*path, text)) {
+ if (explicit_path) {
+ wlr_log(WLR_ERROR,
+ "ext-keybindings: --config '%s' not readable; using defaults",
+ path->c_str());
+ } else {
+ wlr_log(WLR_INFO, "ext-keybindings: no config at '%s'; using defaults",
+ path->c_str());
+ }
+ return policy::default_bindings();
+ }
+
+ config::LoadResult loaded = config::load_from_string(text);
+ for (const std::string& w : loaded.warnings) {
+ wlr_log(WLR_ERROR, "ext-keybindings: %s", w.c_str());
+ }
+ if (loaded.bindings.empty()) {
+ wlr_log(WLR_INFO,
+ "ext-keybindings: '%s' yielded no valid bindings; using defaults",
+ path->c_str());
+ return policy::default_bindings();
+ }
+ wlr_log(WLR_INFO, "ext-keybindings: loaded %zu binding(s) from '%s'",
+ loaded.bindings.size(), path->c_str());
+ return loaded.bindings;
+}
+
+// ---- The extension ----------------------------------------------------------
+
+class KeybindingsExt final : public kernel::Extension {
+public:
+ explicit KeybindingsExt(std::optional<std::string> config_path)
+ : config_path_(std::move(config_path)),
+ matcher_(load_bindings(config_path_)) {}
+
+ auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ host_ = &host;
+
+ // The ONLY fatal: a missing ext-xdg-shell Service (our focus ring + the
+ // window-targeting actions are meaningless without it; depends_on
+ // guarantees it activated first, so absence is a broken core session).
+ shell_ = host.service<ext_xdg_shell::Service>();
+ if (shell_ == nullptr) {
+ throw std::runtime_error(
+ "ext-keybindings: ext-xdg-shell Service unavailable (depends_on "
+ "\"xdg-shell\" not satisfied)");
+ }
+
+ // Input path: thread every key through the Matcher. Non-matching keys
+ // pass through untouched; a matched chord is consumed, a tap fires its
+ // action without consuming the (already-forwarded) modifier.
+ key_filter_ = host.subscribe(
+ host.key_filter(), [this](kernel::KeyEvent ev) {
+ const auto out = matcher_.feed(ev.keysym, ev.modifiers, ev.pressed);
+ if (out.fired != policy::Matcher::npos) {
+ const policy::Binding& b = matcher_.bindings()[out.fired];
+ run_action(b);
+ if (out.consume) {
+ ev.handled = true;
+ }
+ }
+ return ev;
+ });
+
+ // Focus ring: mirror ext-xdg-shell's toplevel lifecycle + focus into the
+ // stable map-order ring. The Toplevel* borrow is valid from mapped until
+ // unmapped (its contract), so storing it in the ring between those two
+ // events is the supported pattern; we drop it on unmapped and never
+ // deref it after.
+ mapped_ = host.subscribe(
+ shell_->on_toplevel_mapped(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ ring_.add(e.toplevel);
+ // A freshly mapped toplevel is the focused one (per ext-xdg-shell
+ // map-focus); seed the cursor so the FIRST Alt+Tab steps off it.
+ ring_.note_focused(e.toplevel);
+ });
+ unmapped_ = host.subscribe(
+ shell_->on_toplevel_unmapped(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ ring_.remove(e.toplevel);
+ });
+ focused_ = host.subscribe(
+ shell_->on_toplevel_focused(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ // Catches click/tap-to-focus and Alt+F1, so rotation always
+ // continues from wherever focus ACTUALLY is.
+ ring_.note_focused(e.toplevel);
+ });
+ }
+
+private:
+ void run_action(const policy::Binding& b) {
+ switch (b.action) {
+ case policy::Action::spawn:
+ spawn_command(b.command);
+ return;
+ case policy::Action::focus_next:
+ rotate(/*forward=*/true);
+ return;
+ case policy::Action::focus_prev:
+ rotate(/*forward=*/false);
+ return;
+ case policy::Action::close_active:
+ close_active();
+ return;
+ case policy::Action::quit:
+ wl_display_terminate(host_->display());
+ return;
+ }
+ }
+
+ void rotate(bool forward) {
+ // ring_.next()/prev() return `const Token*` (Token == Toplevel*), i.e.
+ // a pointer to a stored, still-live Toplevel*; null means 0 windows.
+ Toplevel* const* p = forward ? ring_.next() : ring_.prev();
+ if (p == nullptr || *p == nullptr) {
+ return; // 0 windows
+ }
+ Toplevel* next = *p;
+ next->focus();
+ // Set current ourselves rather than relying solely on the focused event
+ // echoing back (brief). The on_toplevel_focused subscription still keeps
+ // us in sync with external focus changes.
+ ring_.set_current(next);
+ }
+
+ void close_active() {
+ // current() returns the live cursor token (valid until its unmapped
+ // event), or null if none. The stored token IS a mutable Toplevel*.
+ Toplevel* const* cur = ring_.current();
+ if (cur != nullptr && *cur != nullptr) {
+ (*cur)->close();
+ }
+ }
+
+ const kernel::Manifest manifest_{
+ .id = "keybindings",
+ .tier = kernel::Tier::core,
+ .depends_on = {"xdg-shell"},
+ };
+
+ std::optional<std::string> config_path_;
+
+ // Decision cores (constructed before any wiring; matcher_ owns the parsed
+ // bindings). Declared before the subscriptions so they outlive callbacks
+ // that capture `this` (members tear down in reverse declaration order:
+ // subscriptions drop first, then the cores).
+ policy::Matcher matcher_;
+ policy::FocusRing<Toplevel*> ring_;
+
+ Host* host_ = nullptr;
+ ext_xdg_shell::Service* shell_ = nullptr; // borrow; fetched in activate()
+
+ // RAII subscriptions — destruction unsubscribes (listener-lifetime). Last
+ // members so they release FIRST at teardown, before the cores they touch.
+ kernel::Subscription key_filter_;
+ kernel::Subscription mapped_;
+ kernel::Subscription unmapped_;
+ kernel::Subscription focused_;
+};
+
+} // namespace
+
+auto create(std::optional<std::string> config_path)
+ -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<KeybindingsExt>(std::move(config_path));
+}
+
+} // namespace unbox::ext_keybindings
diff --git a/packages/ext-keybindings/src/focus_ring.hpp b/packages/ext-keybindings/src/focus_ring.hpp
new file mode 100644
index 0000000..d89a54c
--- /dev/null
+++ b/packages/ext-keybindings/src/focus_ring.hpp
@@ -0,0 +1,118 @@
+#pragma once
+
+#include <algorithm>
+#include <cstddef>
+#include <vector>
+
+// Pure decision core: the focus ring (stable mapping-order rotation). Operates
+// over OPAQUE tokens — the glue passes ext_xdg_shell::Toplevel* as a void-ish
+// token and NEVER dereferences it here; this core only compares identity and
+// computes the next/previous token. No wlroots, no kernel; doctest-covered in
+// tests/test_policy.cpp.
+//
+// "Rotate across all" means STABLE mapping-order rotation, not MRU: windows keep
+// their insertion (map) order, so repeated focus-next walks all N windows in a
+// fixed cycle instead of ping-ponging the two most-recently-used. `current_` is
+// just a cursor into that stable order; noting an external focus only MOVES the
+// cursor, it never reorders the ring.
+
+namespace unbox::ext_keybindings::policy {
+
+template <typename Token>
+class FocusRing {
+public:
+ // A window mapped: append in map order (stable). Ignored if already present
+ // (defensive — the glue keys add on on_toplevel_mapped, which is once).
+ void add(Token t) {
+ if (index_of(t) == npos) {
+ order_.push_back(t);
+ }
+ }
+
+ // A window unmapped: remove it. If it was the current cursor target, the
+ // cursor is cleared (next focus-next starts from the front, focus-prev from
+ // the back) — the glue must never deref the removed token again.
+ void remove(Token t) {
+ const std::size_t i = index_of(t);
+ if (i == npos) {
+ return;
+ }
+ order_.erase(order_.begin() + static_cast<std::ptrdiff_t>(i));
+ if (has_current_ && current_ == t) {
+ has_current_ = false;
+ }
+ }
+
+ // Note that focus actually moved to `t` (map-focus, click/tap-to-focus, or
+ // our own rotation echoing back). Moves the cursor WITHOUT reordering the
+ // ring. A token not in the ring is ignored (it has no slot to rotate from).
+ void note_focused(Token t) {
+ if (index_of(t) != npos) {
+ current_ = t;
+ has_current_ = true;
+ }
+ }
+
+ [[nodiscard]] auto size() const -> std::size_t { return order_.size(); }
+ [[nodiscard]] auto empty() const -> bool { return order_.empty(); }
+
+ // The token focus-next should move to, or no value if there is nothing to
+ // do (0 windows). With 1 window, returns that window (re-focus / no-op at
+ // the glue). With an unknown/cleared cursor, starts at the FRONT.
+ [[nodiscard]] auto next() const -> const Token* {
+ if (order_.empty()) {
+ return nullptr;
+ }
+ const std::size_t cur = current_index();
+ if (cur == npos) {
+ return &order_.front();
+ }
+ const std::size_t nxt = (cur + 1) % order_.size();
+ return &order_[nxt];
+ }
+
+ // The token focus-prev should move to, or no value (0 windows). Unknown
+ // cursor starts at the BACK.
+ [[nodiscard]] auto prev() const -> const Token* {
+ if (order_.empty()) {
+ return nullptr;
+ }
+ const std::size_t cur = current_index();
+ if (cur == npos) {
+ return &order_.back();
+ }
+ const std::size_t prv = (cur + order_.size() - 1) % order_.size();
+ return &order_[prv];
+ }
+
+ // The glue calls this AFTER it has driven focus to `t` (the brief: set
+ // current yourself, don't depend solely on the focused event echoing back).
+ void set_current(Token t) { note_focused(t); }
+
+ // For tests/diagnostics: the current cursor token, or nullptr if none.
+ [[nodiscard]] auto current() const -> const Token* {
+ return has_current_ ? &current_ : nullptr;
+ }
+
+private:
+ static constexpr std::size_t npos = static_cast<std::size_t>(-1);
+
+ [[nodiscard]] auto index_of(Token t) const -> std::size_t {
+ for (std::size_t i = 0; i < order_.size(); ++i) {
+ if (order_[i] == t) {
+ return i;
+ }
+ }
+ return npos;
+ }
+
+ [[nodiscard]] auto current_index() const -> std::size_t {
+ return has_current_ ? index_of(current_) : npos;
+ }
+
+ std::vector<Token> order_; // stable map order
+ Token current_{};
+ bool has_current_ = false;
+};
+
+} // namespace unbox::ext_keybindings::policy
diff --git a/packages/ext-keybindings/src/policy.hpp b/packages/ext-keybindings/src/policy.hpp
new file mode 100644
index 0000000..fbb2b3e
--- /dev/null
+++ b/packages/ext-keybindings/src/policy.hpp
@@ -0,0 +1,344 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <xkbcommon/xkbcommon.h>
+
+// Pure decision core (no wlroots / GL / RMLUi; xkbcommon is used only for the
+// keysym-name resolution the brief sanctions for the combo parser). The glue
+// translates kernel KeyEvents into these calls and acts on the results. Heavily
+// doctest-covered in tests/test_policy.cpp with nothing running. This file calls
+// nothing in the glue — it only parses and decides.
+
+namespace unbox::ext_keybindings::policy {
+
+// ---- WLR_MODIFIER_* bits ----------------------------------------------------
+//
+// Mirrored here as plain constants so the core does not pull wlr.hpp; the glue
+// masks the live modifier state against the same WLR_MODIFIER_* values (the
+// kernel's KeyEvent::modifiers is a WLR_MODIFIER_* mask).
+inline constexpr std::uint32_t mod_shift = 1u << 0; // WLR_MODIFIER_SHIFT
+inline constexpr std::uint32_t mod_ctrl = 1u << 2; // WLR_MODIFIER_CTRL
+inline constexpr std::uint32_t mod_alt = 1u << 3; // WLR_MODIFIER_ALT
+inline constexpr std::uint32_t mod_logo = 1u << 6; // WLR_MODIFIER_LOGO (Super)
+
+// The modifier bits a combo match is allowed to require. We match these EXACTLY
+// (no stray Caps/Num lock affects the decision: those bits are outside this
+// mask and ignored).
+inline constexpr std::uint32_t mod_relevant = mod_shift | mod_ctrl | mod_alt | mod_logo;
+
+// The xkb keysyms for the two logo (Super) keys, so the tap state machine can
+// recognize the bare-modifier press/release without an xkbcommon include in the
+// glue's hot path. Stable XKB_KEY_* numeric values.
+inline constexpr std::uint32_t keysym_super_l = 0xffeb; // XKB_KEY_Super_L
+inline constexpr std::uint32_t keysym_super_r = 0xffec; // XKB_KEY_Super_R
+
+[[nodiscard]] inline auto is_super_keysym(std::uint32_t keysym) -> bool {
+ return keysym == keysym_super_l || keysym == keysym_super_r;
+}
+
+// ---- Action vocabulary ------------------------------------------------------
+
+enum class Action {
+ spawn, // run `command` via `sh -c`
+ focus_next, // rotate focus forward across mapped windows (wrapping)
+ focus_prev, // rotate focus backward (wrapping)
+ close_active, // close the focused toplevel (no-op if none)
+ quit, // wl_display_terminate
+};
+
+// Map an action token (lowercased) to the enum; nullopt = unknown action.
+[[nodiscard]] inline auto action_from_string(std::string_view s) -> std::optional<Action> {
+ if (s == "spawn") {
+ return Action::spawn;
+ }
+ if (s == "focus-next") {
+ return Action::focus_next;
+ }
+ if (s == "focus-prev") {
+ return Action::focus_prev;
+ }
+ if (s == "close-active") {
+ return Action::close_active;
+ }
+ if (s == "quit") {
+ return Action::quit;
+ }
+ return std::nullopt;
+}
+
+// ---- Combo ------------------------------------------------------------------
+//
+// A parsed `keys` string. Either a normal modifier+key chord, or a bare-modifier
+// TAP (is_tap == true; `modifiers` holds the single tapped modifier mask, and
+// `keysym` is unused). `modifiers` is a WLR_MODIFIER_* mask; `keysym` is the xkb
+// keysym of the final key token.
+struct Combo {
+ std::uint32_t modifiers = 0;
+ std::uint32_t keysym = 0;
+ bool is_tap = false;
+
+ [[nodiscard]] auto operator==(const Combo&) const -> bool = default;
+};
+
+// Map a single modifier token (case-insensitive) to its WLR_MODIFIER_* bit;
+// nullopt = not a known modifier name.
+[[nodiscard]] inline auto modifier_bit(std::string_view tok) -> std::optional<std::uint32_t> {
+ // Lowercase compare (tokens are short; avoid allocating).
+ auto eq = [tok](std::string_view name) {
+ if (tok.size() != name.size()) {
+ return false;
+ }
+ for (std::size_t i = 0; i < tok.size(); ++i) {
+ char c = tok[i];
+ if (c >= 'A' && c <= 'Z') {
+ c = static_cast<char>(c - 'A' + 'a');
+ }
+ if (c != name[i]) {
+ return false;
+ }
+ }
+ return true;
+ };
+ if (eq("super") || eq("logo")) {
+ return mod_logo;
+ }
+ if (eq("alt")) {
+ return mod_alt;
+ }
+ if (eq("ctrl") || eq("control")) {
+ return mod_ctrl;
+ }
+ if (eq("shift")) {
+ return mod_shift;
+ }
+ return std::nullopt;
+}
+
+// Resolve a final key token to an xkb keysym (case-insensitive). Returns 0
+// (XKB_KEY_NoSymbol) if the name does not resolve.
+[[nodiscard]] inline auto keysym_from_token(const std::string& tok) -> std::uint32_t {
+ return xkb_keysym_from_name(tok.c_str(), XKB_KEYSYM_CASE_INSENSITIVE);
+}
+
+// Parse a `keys` string ("Super", "Alt+Tab", "Ctrl+Alt+BackSpace", "Super+d")
+// into a Combo. Rules (brief schema):
+// * `Mod(+Mod...)+Key` -> a chord: modifier bits OR'd, final token a keysym.
+// * a SINGLE bare modifier token ("Super") -> a TAP binding.
+// * Returns nullopt for: an empty string, an empty token (leading/trailing/
+// double '+'), an unknown final keysym, a modifier name used as the final
+// key, or a non-modifier token used where a modifier belongs.
+[[nodiscard]] inline auto parse_combo(std::string_view keys) -> std::optional<Combo> {
+ if (keys.empty()) {
+ return std::nullopt;
+ }
+
+ // Split on '+'.
+ std::vector<std::string_view> tokens;
+ std::size_t start = 0;
+ for (std::size_t i = 0; i <= keys.size(); ++i) {
+ if (i == keys.size() || keys[i] == '+') {
+ tokens.push_back(keys.substr(start, i - start));
+ start = i + 1;
+ }
+ }
+ for (std::string_view t : tokens) {
+ if (t.empty()) {
+ return std::nullopt; // leading/trailing/double '+'
+ }
+ }
+
+ // Bare single modifier -> TAP.
+ if (tokens.size() == 1) {
+ if (auto m = modifier_bit(tokens.front())) {
+ return Combo{.modifiers = *m, .keysym = 0, .is_tap = true};
+ }
+ // A single non-modifier token is a key with no modifiers — fall through
+ // to the chord path so e.g. "F1" alone (if ever configured) resolves.
+ }
+
+ // Chord: every token but the last is a modifier; the last is the key.
+ Combo combo{};
+ for (std::size_t i = 0; i + 1 < tokens.size(); ++i) {
+ auto m = modifier_bit(tokens[i]);
+ if (!m) {
+ return std::nullopt; // a non-modifier where a modifier belongs
+ }
+ combo.modifiers |= *m;
+ }
+ const std::string final_tok(tokens.back());
+ // A modifier name as the FINAL token (and not the bare-tap case above) is
+ // malformed — e.g. "Alt+Shift" with no key.
+ if (modifier_bit(final_tok)) {
+ return std::nullopt;
+ }
+ combo.keysym = keysym_from_token(final_tok);
+ if (combo.keysym == 0) {
+ return std::nullopt; // unknown keysym name
+ }
+ return combo;
+}
+
+// ---- Binding ----------------------------------------------------------------
+//
+// One [[keybind]]: a parsed combo + the action it triggers (+ the spawn command
+// for Action::spawn). Produced by the toml loader (config.hpp); consumed by the
+// Matcher.
+struct Binding {
+ Combo combo;
+ Action action = Action::quit;
+ std::string command; // only meaningful for Action::spawn
+
+ [[nodiscard]] auto operator==(const Binding&) const -> bool = default;
+};
+
+// ---- Matcher + tap state machine --------------------------------------------
+//
+// The decision core for the input path: fed a sequence of (keysym, modifiers,
+// pressed) it reports which Binding (if any) fires for each event, so the glue
+// can consume the key and run the action. Holds the bare-Super TAP state.
+//
+// Semantics (brief):
+// * CHORD bindings fire on PRESS when the keysym matches and the relevant
+// modifier bits match EXACTLY (so Alt+Tab does not fire for Ctrl+Alt+Tab).
+// A chord that USES Super marks the tap as "used" so a tap does not also
+// fire on the eventual Super release.
+// * TAP bindings fire on the modifier's RELEASE iff it was pressed and
+// released with nothing in between (no other key press, no chord use).
+// * The modifier press/release themselves are NEVER consumed (other combos
+// need the modifier held); only a firing tap is an effect (and the glue
+// consumes nothing on a tap either — the modifier already passed through).
+class Matcher {
+public:
+ // Result of feeding one event. `fired` is the index into the bindings list
+ // of the binding that should run, or npos for "nothing fires". `consume` is
+ // true iff the glue should set KeyEvent::handled (suppress client forward):
+ // true for a fired CHORD press, false otherwise (taps consume nothing).
+ static constexpr std::size_t npos = static_cast<std::size_t>(-1);
+ struct Outcome {
+ std::size_t fired = npos;
+ bool consume = false;
+ };
+
+ explicit Matcher(std::vector<Binding> bindings) : bindings_(std::move(bindings)) {
+ // Does any binding tap on Super? Cache so non-Super sessions skip SM.
+ for (const Binding& b : bindings_) {
+ if (b.combo.is_tap && (b.combo.modifiers & mod_logo) != 0) {
+ super_tap_ = true;
+ break;
+ }
+ }
+ }
+
+ [[nodiscard]] auto bindings() const -> const std::vector<Binding>& { return bindings_; }
+
+ // Feed one key event. Updates the tap state machine and returns what fires.
+ auto feed(std::uint32_t keysym, std::uint32_t modifiers, bool pressed) -> Outcome {
+ // --- Tap state machine for Super ---
+ if (is_super_keysym(keysym)) {
+ if (pressed) {
+ // A fresh Super press arms the tap (only if nothing else was
+ // already held that would make this not a clean tap). Re-press
+ // while armed (auto-repeat) keeps the armed state.
+ if (!super_down_) {
+ super_down_ = true;
+ super_used_ = false;
+ }
+ } else {
+ // Super release: fire the bare-Super tap iff it was a clean tap.
+ Outcome out{};
+ if (super_down_ && !super_used_) {
+ out.fired = find_super_tap();
+ out.consume = false; // taps never consume
+ }
+ super_down_ = false;
+ super_used_ = false;
+ return out;
+ }
+ return Outcome{}; // the Super press itself is never consumed
+ }
+
+ // --- Any non-Super key press while Super is armed marks it used ---
+ if (pressed && super_down_) {
+ super_used_ = true;
+ }
+ // A chord that explicitly carries Super in its modifier mask also marks
+ // the tap used (covers Super+click-style chords routed as key presses,
+ // and guards the case where the modifier bit is set even if the Super
+ // keysym was not the one we tracked).
+ if (pressed && (modifiers & mod_logo) != 0) {
+ super_used_ = true;
+ }
+
+ // --- Chord matching (presses only) ---
+ if (pressed) {
+ const std::uint32_t rel = modifiers & mod_relevant;
+ for (std::size_t i = 0; i < bindings_.size(); ++i) {
+ const Binding& b = bindings_[i];
+ if (b.combo.is_tap) {
+ continue;
+ }
+ if (b.combo.keysym == keysym && (b.combo.modifiers & mod_relevant) == rel) {
+ return Outcome{.fired = i, .consume = true};
+ }
+ }
+ }
+ return Outcome{};
+ }
+
+ // Whether the tap SM is even relevant (no Super tap configured -> the glue
+ // could skip, but feed() is cheap; exposed for tests/diagnostics).
+ [[nodiscard]] auto tracks_super_tap() const -> bool { return super_tap_; }
+
+private:
+ [[nodiscard]] auto find_super_tap() const -> std::size_t {
+ if (!super_tap_) {
+ return npos;
+ }
+ for (std::size_t i = 0; i < bindings_.size(); ++i) {
+ const Binding& b = bindings_[i];
+ if (b.combo.is_tap && (b.combo.modifiers & mod_logo) != 0) {
+ return i;
+ }
+ }
+ return npos;
+ }
+
+ std::vector<Binding> bindings_;
+ bool super_tap_ = false; // any binding taps on Super?
+ bool super_down_ = false;
+ bool super_used_ = false;
+};
+
+// ---- Compiled-in DEFAULTS ---------------------------------------------------
+//
+// The out-of-the-box bindings, matching the sample unbox.toml the orchestrator
+// commits at repo root EXACTLY (brief):
+// Super -> spawn fuzzel
+// Alt+Tab -> focus-next
+// Alt+Shift+Tab -> focus-prev
+// Alt+F1 -> focus-next (was ext-xdg-shell's Alt+F1 cycle)
+// Ctrl+Alt+Backspace -> quit (was ext-xdg-shell's terminate)
+// Built from parse_combo so the default keysyms resolve through the SAME path as
+// configured ones (no hand-coded keysym numbers to drift).
+[[nodiscard]] inline auto default_bindings() -> std::vector<Binding> {
+ std::vector<Binding> out;
+ auto add = [&out](std::string_view keys, Action action, std::string command) {
+ if (auto c = parse_combo(keys)) {
+ out.push_back(Binding{.combo = *c, .action = action, .command = std::move(command)});
+ }
+ };
+ add("Super", Action::spawn, "fuzzel");
+ add("Alt+Tab", Action::focus_next, {});
+ add("Alt+Shift+Tab", Action::focus_prev, {});
+ add("Alt+F1", Action::focus_next, {});
+ add("Ctrl+Alt+BackSpace", Action::quit, {});
+ return out;
+}
+
+} // namespace unbox::ext_keybindings::policy
diff --git a/packages/ext-keybindings/tests/test_glue.cpp b/packages/ext-keybindings/tests/test_glue.cpp
new file mode 100644
index 0000000..e5b6fb8
--- /dev/null
+++ b/packages/ext-keybindings/tests/test_glue.cpp
@@ -0,0 +1,61 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include <unbox/ext-keybindings/ext_keybindings.hpp>
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
+#include <unbox/kernel/server.hpp>
+
+#include <cstdlib>
+#include <memory>
+
+// Glue tests — lenient, headless. The decision cores (combo parser, toml loader,
+// matcher + tap SM, focus ring) are proven hard in test_policy.cpp; here we only
+// verify the extension installs alongside ext-xdg-shell (its dependency),
+// activates (fetching the Service — the only fatal path), drives the event loop,
+// and shuts down cleanly with all RAII subscriptions releasing in order.
+
+namespace {
+
+auto make_headless_server() -> std::unique_ptr<unbox::kernel::Server> {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ return unbox::kernel::Server::create({});
+}
+
+} // namespace
+
+TEST_CASE("ext-keybindings installs and activates atop ext-xdg-shell") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_xdg_shell::create());
+ server->install(unbox::ext_keybindings::create());
+ // Topological activation runs xdg-shell first (keybindings depends_on it),
+ // so keybindings finds the Service. A missing-Service throw would propagate.
+ server->activate_extensions();
+ CHECK(!server->socket_name().empty());
+}
+
+TEST_CASE("ext-keybindings dispatches and shuts down cleanly") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_xdg_shell::create());
+ server->install(unbox::ext_keybindings::create());
+ server->activate_extensions();
+ for (int i = 0; i < 5; ++i) {
+ CHECK(server->dispatch(10));
+ }
+ // Destruction tears down the key_filter link + the three xdg-shell event
+ // subscriptions in reverse declaration order with no leaked listeners.
+}
+
+TEST_CASE("ext-keybindings degrades to defaults for a bad explicit config path") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_xdg_shell::create());
+ // A non-existent --config path must NOT throw out of activate(); the
+ // extension logs and uses compiled defaults.
+ server->install(unbox::ext_keybindings::create(
+ std::string("/nonexistent/path/to/unbox.toml")));
+ server->activate_extensions();
+ for (int i = 0; i < 3; ++i) {
+ CHECK(server->dispatch(10));
+ }
+}
diff --git a/packages/ext-keybindings/tests/test_policy.cpp b/packages/ext-keybindings/tests/test_policy.cpp
new file mode 100644
index 0000000..0774418
--- /dev/null
+++ b/packages/ext-keybindings/tests/test_policy.cpp
@@ -0,0 +1,436 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "config.hpp"
+#include "focus_ring.hpp"
+#include "policy.hpp"
+
+#include <string>
+#include <vector>
+
+// Pure-core tests — the heart of this unit. No kernel, no wlroots (xkbcommon is
+// used by the combo parser as the brief sanctions). Four cores: combo parser,
+// toml loader, matcher + tap state machine, focus ring.
+
+namespace pol = unbox::ext_keybindings::policy;
+namespace cfg = unbox::ext_keybindings::config;
+
+using pol::Action;
+using pol::Binding;
+using pol::Combo;
+using pol::Matcher;
+using pol::parse_combo;
+
+// xkb keysyms used across tests (stable XKB_KEY_* numeric values).
+static constexpr std::uint32_t kTab = 0xff09; // XKB_KEY_Tab
+static constexpr std::uint32_t kF1 = 0xffbe; // XKB_KEY_F1
+static constexpr std::uint32_t kBackSpace = 0xff08; // XKB_KEY_BackSpace
+static constexpr std::uint32_t kD = 0x064; // XKB_KEY_d
+
+// ============================================================================
+// combo parser
+// ============================================================================
+
+TEST_CASE("bare modifier parses as a TAP") {
+ auto c = parse_combo("Super");
+ REQUIRE(c.has_value());
+ CHECK(c->is_tap);
+ CHECK(c->modifiers == pol::mod_logo);
+
+ CHECK(parse_combo("alt")->is_tap);
+ CHECK(parse_combo("CTRL")->is_tap);
+ CHECK(parse_combo("Shift")->is_tap);
+ CHECK(parse_combo("logo")->modifiers == pol::mod_logo); // Super synonym
+}
+
+TEST_CASE("each modifier name maps to its WLR bit (case-insensitive)") {
+ CHECK(parse_combo("Alt+Tab")->modifiers == pol::mod_alt);
+ CHECK(parse_combo("ctrl+Tab")->modifiers == pol::mod_ctrl);
+ CHECK(parse_combo("CONTROL+Tab")->modifiers == pol::mod_ctrl); // synonym
+ CHECK(parse_combo("shift+Tab")->modifiers == pol::mod_shift);
+ CHECK(parse_combo("super+d")->modifiers == pol::mod_logo);
+}
+
+TEST_CASE("multi-modifier chord ORs the bits, last token is the key") {
+ auto c = parse_combo("Alt+Shift+Tab");
+ REQUIRE(c.has_value());
+ CHECK_FALSE(c->is_tap);
+ CHECK(c->modifiers == (pol::mod_alt | pol::mod_shift));
+ CHECK(c->keysym == kTab);
+
+ auto q = parse_combo("Ctrl+Alt+BackSpace");
+ REQUIRE(q.has_value());
+ CHECK(q->modifiers == (pol::mod_ctrl | pol::mod_alt));
+ CHECK(q->keysym == kBackSpace);
+}
+
+TEST_CASE("final keysym resolves case-insensitively") {
+ CHECK(parse_combo("Alt+tab")->keysym == kTab);
+ CHECK(parse_combo("Alt+TAB")->keysym == kTab);
+ CHECK(parse_combo("Alt+F1")->keysym == kF1);
+ CHECK(parse_combo("Super+d")->keysym == kD);
+}
+
+TEST_CASE("malformed combos return nullopt") {
+ CHECK_FALSE(parse_combo("").has_value()); // empty
+ CHECK_FALSE(parse_combo("Alt+").has_value()); // trailing +
+ CHECK_FALSE(parse_combo("+Tab").has_value()); // leading +
+ CHECK_FALSE(parse_combo("Alt++Tab").has_value()); // double +
+ CHECK_FALSE(parse_combo("Alt+Boguskey").has_value()); // unknown keysym
+ CHECK_FALSE(parse_combo("Alt+Shift").has_value()); // modifier as final key
+ CHECK_FALSE(parse_combo("Nope+Tab").has_value()); // unknown modifier
+}
+
+// ============================================================================
+// toml loader
+// ============================================================================
+
+TEST_CASE("loader parses the canonical schema") {
+ const std::string toml = R"(
+[[keybind]]
+keys = "Super"
+action = "spawn"
+command = "fuzzel"
+
+[[keybind]]
+keys = "Alt+Tab"
+action = "focus-next"
+
+[[keybind]]
+keys = "Alt+Shift+Tab"
+action = "focus-prev"
+)";
+ auto r = cfg::load_from_string(toml);
+ CHECK_FALSE(r.parse_error);
+ REQUIRE(r.bindings.size() == 3);
+
+ CHECK(r.bindings[0].combo.is_tap);
+ CHECK(r.bindings[0].action == Action::spawn);
+ CHECK(r.bindings[0].command == "fuzzel");
+
+ CHECK(r.bindings[1].action == Action::focus_next);
+ CHECK(r.bindings[1].combo.keysym == kTab);
+
+ CHECK(r.bindings[2].action == Action::focus_prev);
+ CHECK(r.bindings[2].combo.modifiers == (pol::mod_alt | pol::mod_shift));
+}
+
+TEST_CASE("loader skips malformed entries but keeps the rest") {
+ const std::string toml = R"(
+[[keybind]]
+keys = "Alt+Tab"
+action = "focus-next"
+
+[[keybind]]
+keys = "Alt+Bogus"
+action = "focus-next"
+
+[[keybind]]
+keys = "Alt+F1"
+action = "no-such-action"
+
+[[keybind]]
+keys = "Super"
+action = "spawn"
+
+[[keybind]]
+keys = "Alt+F1"
+action = "quit"
+)";
+ auto r = cfg::load_from_string(toml);
+ CHECK_FALSE(r.parse_error);
+ // kept: Alt+Tab and Alt+F1->quit. skipped: bad combo, bad action, spawn w/o command.
+ REQUIRE(r.bindings.size() == 2);
+ CHECK(r.bindings[0].action == Action::focus_next);
+ CHECK(r.bindings[1].action == Action::quit);
+ CHECK(r.warnings.size() == 3);
+}
+
+TEST_CASE("a toml syntax error is a parse_error with no bindings") {
+ auto r = cfg::load_from_string("this is = = not valid toml [[[");
+ CHECK(r.parse_error);
+ CHECK(r.bindings.empty());
+ CHECK_FALSE(r.warnings.empty());
+}
+
+TEST_CASE("empty / keybind-less document yields zero bindings, not an error") {
+ auto empty = cfg::load_from_string("");
+ CHECK_FALSE(empty.parse_error);
+ CHECK(empty.bindings.empty());
+
+ auto other = cfg::load_from_string("title = \"unrelated\"\n");
+ CHECK_FALSE(other.parse_error);
+ CHECK(other.bindings.empty());
+}
+
+TEST_CASE("compiled defaults match the documented out-of-the-box set") {
+ auto d = pol::default_bindings();
+ REQUIRE(d.size() == 5);
+ CHECK(d[0].combo.is_tap);
+ CHECK(d[0].combo.modifiers == pol::mod_logo);
+ CHECK(d[0].action == Action::spawn);
+ CHECK(d[0].command == "fuzzel");
+ CHECK(d[1].combo == parse_combo("Alt+Tab").value());
+ CHECK(d[1].action == Action::focus_next);
+ CHECK(d[2].combo == parse_combo("Alt+Shift+Tab").value());
+ CHECK(d[2].action == Action::focus_prev);
+ CHECK(d[3].combo == parse_combo("Alt+F1").value());
+ CHECK(d[3].action == Action::focus_next);
+ CHECK(d[4].combo == parse_combo("Ctrl+Alt+BackSpace").value());
+ CHECK(d[4].action == Action::quit);
+}
+
+// ============================================================================
+// matcher + tap state machine
+// ============================================================================
+
+static auto make_matcher() -> Matcher {
+ return Matcher(pol::default_bindings());
+}
+
+TEST_CASE("chord fires on press and consumes, exact-modifier match") {
+ auto m = make_matcher();
+ // Alt+Tab press -> focus-next, consumed.
+ auto out = m.feed(kTab, pol::mod_alt, true);
+ REQUIRE(out.fired != Matcher::npos);
+ CHECK(m.bindings()[out.fired].action == Action::focus_next);
+ CHECK(out.consume);
+}
+
+TEST_CASE("chord does not fire on release") {
+ auto m = make_matcher();
+ auto out = m.feed(kTab, pol::mod_alt, false);
+ CHECK(out.fired == Matcher::npos);
+}
+
+TEST_CASE("exact-modifier: extra modifier bits do not match a narrower combo") {
+ auto m = make_matcher();
+ // Ctrl+Alt+Tab must NOT fire Alt+Tab (relevant mods differ).
+ auto out = m.feed(kTab, pol::mod_alt | pol::mod_ctrl, true);
+ CHECK(out.fired == Matcher::npos);
+}
+
+TEST_CASE("Alt+Shift+Tab fires focus-prev, not Alt+Tab") {
+ auto m = make_matcher();
+ auto out = m.feed(kTab, pol::mod_alt | pol::mod_shift, true);
+ REQUIRE(out.fired != Matcher::npos);
+ CHECK(m.bindings()[out.fired].action == Action::focus_prev);
+}
+
+TEST_CASE("tap-Super: press then release with nothing between FIRES on release") {
+ auto m = make_matcher();
+ // Super press: nothing fires, not consumed.
+ auto down = m.feed(pol::keysym_super_l, pol::mod_logo, true);
+ CHECK(down.fired == Matcher::npos);
+ CHECK_FALSE(down.consume);
+ // Super release: tap fires (spawn fuzzel), NOT consumed.
+ auto up = m.feed(pol::keysym_super_l, 0, false);
+ REQUIRE(up.fired != Matcher::npos);
+ CHECK(m.bindings()[up.fired].action == Action::spawn);
+ CHECK_FALSE(up.consume);
+}
+
+// ---- REAL-SEAT decider (DEBUG brief) ---------------------------------------
+// Hardware capture (/tmp/keycap.log) proved BOTH the keyboard Super and the
+// tablet Super emit evdev 125 -> xkb keysym Super_L (0xffeb). This is the EXACT
+// press->release sequence the real seat produces, fed through the matcher with
+// nothing between. It MUST yield the spawn "fuzzel" action. The kernel does not
+// promise whether KeyEvent::modifiers carries WLR_MODIFIER_LOGO on a lone Super
+// press/release, so we assert the tap fires for BOTH possible modifier masks.
+
+TEST_CASE("REAL-SEAT: lone Super_L press->release fires spawn (modifiers == 0 both edges)") {
+ // The pessimistic case: the kernel reports NO modifier bits on the lone
+ // Super press AND release (the modifier mask is computed pre-/post- the key
+ // itself, depending on the kernel's ordering). The tap must still fire,
+ // because the matcher keys the tap on the KEYSYM, never on modifiers == 0.
+ auto m = make_matcher();
+ auto down = m.feed(0xffeb /*Super_L*/, 0, true);
+ CHECK(down.fired == Matcher::npos);
+ CHECK_FALSE(down.consume);
+ auto up = m.feed(0xffeb /*Super_L*/, 0, false);
+ REQUIRE(up.fired != Matcher::npos);
+ CHECK(m.bindings()[up.fired].action == Action::spawn);
+ CHECK(m.bindings()[up.fired].command == "fuzzel");
+ CHECK_FALSE(up.consume);
+}
+
+TEST_CASE("REAL-SEAT: lone Super_L press->release fires spawn (WLR_MODIFIER_LOGO set)") {
+ // The optimistic case: the kernel reports WLR_MODIFIER_LOGO on the press
+ // (and possibly the release). Must ALSO fire — the LOGO bit on the lone
+ // Super press must NOT be treated as a Super-carrying chord that marks the
+ // tap "used" (the keysym IS the Super key, so it arms rather than gates).
+ auto m = make_matcher();
+ auto down = m.feed(0xffeb /*Super_L*/, pol::mod_logo, true);
+ CHECK(down.fired == Matcher::npos);
+ auto up = m.feed(0xffeb /*Super_L*/, pol::mod_logo, false);
+ REQUIRE(up.fired != Matcher::npos);
+ CHECK(m.bindings()[up.fired].action == Action::spawn);
+ CHECK(m.bindings()[up.fired].command == "fuzzel");
+ CHECK_FALSE(up.consume);
+}
+
+TEST_CASE("REAL-SEAT: the tablet Super (a Super_R name) also fires the same tap") {
+ // The user wants both Super keys treated identically. On this hardware both
+ // emit Super_L, but bind portability: a Super_R event must fire the same
+ // bare-"Super" tap binding.
+ auto m = make_matcher();
+ m.feed(0xffec /*Super_R*/, 0, true);
+ auto up = m.feed(0xffec /*Super_R*/, 0, false);
+ REQUIRE(up.fired != Matcher::npos);
+ CHECK(m.bindings()[up.fired].action == Action::spawn);
+}
+
+TEST_CASE("REAL-SEAT suppression: Super down, a down, a up, Super up -> NO tap") {
+ // The brief's explicit suppression case. A key pressed while Super is held
+ // marks the tap used; the eventual Super release must NOT fire.
+ auto m = make_matcher();
+ m.feed(0xffeb /*Super_L*/, pol::mod_logo, true);
+ m.feed(kD, pol::mod_logo, true); // 'a'/'d' down while Super held
+ m.feed(kD, pol::mod_logo, false); // 'a'/'d' up (release never fires anyway)
+ auto up = m.feed(0xffeb /*Super_L*/, pol::mod_logo, false);
+ CHECK(up.fired == Matcher::npos); // tap suppressed
+}
+
+TEST_CASE("tap-Super gated: another key pressed while held suppresses the tap") {
+ auto m = make_matcher();
+ m.feed(pol::keysym_super_l, pol::mod_logo, true);
+ // A different key goes down while Super is held -> tap used.
+ m.feed(kD, pol::mod_logo, true); // Super+d (no binding for it -> npos)
+ auto up = m.feed(pol::keysym_super_l, 0, false);
+ CHECK(up.fired == Matcher::npos); // tap suppressed
+}
+
+TEST_CASE("tap-Super gated: a Super-carrying chord suppresses the tap") {
+ auto m = make_matcher();
+ m.feed(pol::keysym_super_l, pol::mod_logo, true);
+ // Even a key whose modifier mask includes logo marks the tap used.
+ m.feed(kTab, pol::mod_logo, true);
+ auto up = m.feed(pol::keysym_super_l, 0, false);
+ CHECK(up.fired == Matcher::npos);
+}
+
+TEST_CASE("tap-Super: a non-tap session never fires a tap") {
+ Matcher m(std::vector<Binding>{
+ Binding{.combo = parse_combo("Alt+Tab").value(), .action = Action::focus_next, .command = {}}});
+ CHECK_FALSE(m.tracks_super_tap());
+ m.feed(pol::keysym_super_l, pol::mod_logo, true);
+ auto up = m.feed(pol::keysym_super_l, 0, false);
+ CHECK(up.fired == Matcher::npos);
+}
+
+TEST_CASE("modifier press/release are never consumed") {
+ auto m = make_matcher();
+ CHECK_FALSE(m.feed(pol::keysym_super_l, pol::mod_logo, true).consume);
+ CHECK_FALSE(m.feed(pol::keysym_super_l, 0, false).consume);
+}
+
+// ============================================================================
+// focus ring
+// ============================================================================
+
+// Opaque tokens: integers stand in for Toplevel* (the ring never derefs them).
+using Ring = unbox::ext_keybindings::policy::FocusRing<int>;
+
+TEST_CASE("empty ring: next/prev yield nothing") {
+ Ring r;
+ CHECK(r.empty());
+ CHECK(r.next() == nullptr);
+ CHECK(r.prev() == nullptr);
+}
+
+TEST_CASE("single window: next/prev return that window") {
+ Ring r;
+ r.add(1);
+ REQUIRE(r.next() != nullptr);
+ CHECK(*r.next() == 1);
+ REQUIRE(r.prev() != nullptr);
+ CHECK(*r.prev() == 1);
+}
+
+TEST_CASE("rotation walks ALL N in stable map order, wrapping (not MRU ping-pong)") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ // No current set yet -> next starts at front.
+ REQUIRE(*r.next() == 10);
+ r.set_current(10);
+ // Repeated next must visit 20, 30, then wrap to 10 — all three, in order.
+ CHECK(*r.next() == 20);
+ r.set_current(20);
+ CHECK(*r.next() == 30);
+ r.set_current(30);
+ CHECK(*r.next() == 10); // wrap
+ r.set_current(10);
+ CHECK(*r.next() == 20); // and keeps walking, never ping-ponging 10<->30
+}
+
+TEST_CASE("prev walks backward and wraps to the back") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ r.set_current(10);
+ CHECK(*r.prev() == 30); // wrap to back
+ r.set_current(30);
+ CHECK(*r.prev() == 20);
+ r.set_current(20);
+ CHECK(*r.prev() == 10);
+}
+
+TEST_CASE("unknown / cleared cursor: next starts at front, prev at back") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ CHECK(*r.next() == 10);
+ CHECK(*r.prev() == 30);
+}
+
+TEST_CASE("removing the current window clears the cursor; next restarts at front") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ r.set_current(20);
+ r.remove(20);
+ CHECK(r.size() == 2);
+ CHECK(r.current() == nullptr);
+ CHECK(*r.next() == 10); // cursor cleared -> front
+}
+
+TEST_CASE("removing a non-current window preserves the cursor and order") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ r.set_current(30);
+ r.remove(10);
+ REQUIRE(r.current() != nullptr);
+ CHECK(*r.current() == 30);
+ // order now {20,30}; next from 30 wraps to 20.
+ CHECK(*r.next() == 20);
+}
+
+TEST_CASE("external focus reposition: note_focused moves the cursor, not order") {
+ Ring r;
+ r.add(10);
+ r.add(20);
+ r.add(30);
+ r.set_current(10);
+ // User clicks window 30 (external focus) -> cursor jumps to 30.
+ r.note_focused(30);
+ REQUIRE(r.current() != nullptr);
+ CHECK(*r.current() == 30);
+ // Alt+Tab from there wraps to 10, proving order is unchanged.
+ CHECK(*r.next() == 10);
+}
+
+TEST_CASE("note_focused on an unknown token is ignored") {
+ Ring r;
+ r.add(10);
+ r.set_current(10);
+ r.note_focused(999); // not in ring
+ REQUIRE(r.current() != nullptr);
+ CHECK(*r.current() == 10);
+}
diff --git a/packages/ext-layer-shell/tests/test_client.cpp b/packages/ext-layer-shell/tests/test_client.cpp
index 5c1d232..dd0b998 100644
--- a/packages/ext-layer-shell/tests/test_client.cpp
+++ b/packages/ext-layer-shell/tests/test_client.cpp
@@ -168,6 +168,18 @@ TEST_CASE("a real client's nil-output layer surface receives a configure") {
if (c.surface != nullptr) {
wl_surface_destroy(c.surface);
}
+ if (c.layer_shell != nullptr) {
+ zwlr_layer_shell_v1_destroy(c.layer_shell);
+ }
+ if (c.output != nullptr) {
+ wl_output_destroy(c.output);
+ }
+ if (c.compositor != nullptr) {
+ wl_compositor_destroy(c.compositor);
+ }
+ if (c.registry != nullptr) {
+ wl_registry_destroy(c.registry);
+ }
wl_display_flush(c.display);
pump(*server, c.display);
wl_display_disconnect(c.display);
diff --git a/packages/ext-xdg-shell/ext-xdg-shell.md b/packages/ext-xdg-shell/ext-xdg-shell.md
index c8cc837..fa6d56a 100644
--- a/packages/ext-xdg-shell/ext-xdg-shell.md
+++ b/packages/ext-xdg-shell/ext-xdg-shell.md
@@ -8,9 +8,9 @@ extension-creates-the-global split), not by the kernel.
## Why it exists
The slice-4 kernel boots featureless: it owns input/output/scene/seat glue and
emits a typed catalogue, but names no shell policy. This unit is the minimal
-shell that makes a session usable: toplevels appear, focus follows
-click/tap/cycle, pointers and touch route to clients, windows move/resize on
-request, and the dev keybindings (Ctrl+Alt+Backspace / Alt+F1) live here.
+shell that makes a session usable: toplevels appear, focus follows click/tap,
+pointers and touch route to clients, and windows move/resize on request.
+Compositor keybindings (focus-cycle, terminate) now live in ext-keybindings.
## Side-effect graph
- **Creates:** the `wlr_xdg_shell` v3 global on `host.display()`.
@@ -19,8 +19,8 @@ request, and the dev keybindings (Ctrl+Alt+Backspace / Alt+F1) live here.
(forward `notify_button` + click-to-focus + grab begin/reset),
`on_pointer_axis` (forward `notify_axis`), `on_pointer_frame` (notify_frame),
`on_touch_down/motion/up` (tap-to-focus + down/up/motion notify),
- `on_touch_frame` (notify_frame), and `key_filter` (consume
- Ctrl+Alt+Backspace→terminate / Alt+F1→cycle, pass everything else).
+ `on_touch_frame` (notify_frame). `key_filter` is NOT subscribed here;
+ ext-keybindings owns all compositor keybindings.
- **Binds (raw xdg-shell signals, RAII `Listener`):** `new_toplevel`,
`new_popup`, and per-entity map/unmap/commit/destroy/request_move/
request_resize/request_maximize/request_fullscreen.
@@ -56,11 +56,6 @@ it is never read by another unit.
grab: during a grab we suppress the client touch-motion notify entirely (the
compositor consumes the drag) and drive the window from the raw layout
coords, so the moving-surface origin never fights the grab.
-- **Terminate is `Ctrl+Alt+Backspace`** (the canonical X11 kill-the-server
- chord). It deliberately shares NO key with labwc's defaults — no Escape at all
- — after the user vetoed any overlap (even Alt+Shift+Escape was too close to
- labwc's `A-Escape` "Exit labwc"). Every Escape combo now passes THROUGH
- unconsumed; pure-core tests guard both that and the new chord.
- **Interactive move/resize grab is a pure state machine** (`policy::
GrabMachine`), NOT an ad-hoc cursor-mode flag. The grab is a deterministic
function of (which inputs are down, client-requested-move/resize): it engages
@@ -88,10 +83,6 @@ it is never read by another unit.
pure sequence tests pass clean; the bug was the missing seat notify.) Touch
grabs stay balanced because `process_touch_up`/`_cancel` always send the
matching `wlr_seat_touch_notify_up`/`_cancel`.
-- **Alt+F1 cycle focuses the back of the focus order** (least-recently
- focused), matching the former kernel; the picked window then moves to front,
- so repeated presses walk the stack. The binding key is consumed even with
- fewer than two windows (no client should see a half-handled compositor combo).
- Teardown is pure RAII (reverse declaration order); there is no manual
cleanup. The `wlr_xdg_shell` global and scene nodes are display/scene-owned
and outlive nothing of ours improperly.
diff --git a/packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp b/packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp
index b58887d..a830a4b 100644
--- a/packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp
+++ b/packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp
@@ -10,14 +10,18 @@
//
// Recreates the kernel's former tinywl-shape shell against the kernel ABI
// alone: the wlr_xdg_shell v3 global, toplevel/popup lifecycle, click/tap-to-
-// focus, pointer/touch routing to clients, Alt+F1 focus-cycle, Alt+Escape
-// terminate, client-requested interactive move/resize, and maximize/
-// fullscreen configure replies. Tier: core. depends_on: none.
+// focus, pointer/touch routing to clients, client-requested interactive
+// move/resize, and maximize/fullscreen configure replies. Tier: core.
+// depends_on: none.
+//
+// Compositor keybindings (focus-cycle, terminate) are NOT handled here; they
+// live in ext-keybindings, which subscribes to the kernel's key_filter and
+// calls Toplevel::focus() on this extension's Service.
//
// This header is the unit's CONTRACT — the only surface downstream slices
-// (taskbar, tiling) couple to. It is intentionally minimal: future consumers
-// change-request exactly what they need. Everything here runs on the single
-// wl_event_loop thread.
+// (taskbar, tiling, ext-keybindings) couple to. It is intentionally minimal:
+// future consumers change-request exactly what they need. Everything here runs
+// on the single wl_event_loop thread.
namespace unbox::ext_xdg_shell {
@@ -90,7 +94,9 @@ public:
-> unbox::kernel::Event<const ToplevelEvent&>& = 0;
// Keyboard focus moved to this toplevel (map-focus, click/tap-to-focus, or
- // Alt+F1 cycle). Payload borrow valid for the call.
+ // programmatic Toplevel::focus() call). Payload borrow valid for the call.
+ // NOTE: ext-keybindings' Alt+Tab cycle calls Toplevel::focus(), which
+ // produces this event — callers may rely on that guarantee.
[[nodiscard]] virtual auto on_toplevel_focused()
-> unbox::kernel::Event<const ToplevelEvent&>& = 0;
diff --git a/packages/ext-xdg-shell/src/extension.cpp b/packages/ext-xdg-shell/src/extension.cpp
index c98962e..003c4b9 100644
--- a/packages/ext-xdg-shell/src/extension.cpp
+++ b/packages/ext-xdg-shell/src/extension.cpp
@@ -8,8 +8,6 @@
#include <unbox/kernel/wlr.hpp>
#include <cstdint>
-#include <iterator>
-#include <list>
#include <memory>
#include <stdexcept>
#include <string_view>
@@ -149,12 +147,7 @@ public:
process_touch_cancel(e);
});
sub_touch_frame_ = host.subscribe(host.on_touch_frame(),
- [this] { wlr_seat_touch_notify_frame(host_->seat()); });
-
- // Keyboard policy: consume Ctrl+Alt+Backspace / Alt+F1, pass the rest.
- sub_key_ = host.subscribe(host.key_filter(), [this](kernel::KeyEvent ev) {
- return filter_key(ev);
- });
+ [this] { wlr_seat_touch_notify_frame(host_->seat()); });
// Register the typed service so downstream slices link against us.
host.provide_service<Service>(&service_);
@@ -190,10 +183,6 @@ public:
}
wlr_scene_node_raise_to_top(&entry->scene_tree->node);
- // Move to front of focus order (front = focused).
- focus_order_.remove(entry);
- focus_order_.push_front(entry);
-
wlr_xdg_toplevel_set_activated(entry->xdg_toplevel, true);
if (wlr_keyboard* kb = wlr_seat_get_keyboard(seat)) {
wlr_seat_keyboard_notify_enter(seat, surface, kb->keycodes, kb->num_keycodes,
@@ -224,7 +213,6 @@ private:
entry->map.connect(xdg_toplevel->base->surface->events.map, [this, entry](void*) {
entry->mapped = true;
- focus_order_.push_front(entry);
focus_toplevel(entry);
emit(on_mapped_, entry);
});
@@ -235,7 +223,6 @@ private:
end_grab();
}
entry->mapped = false;
- focus_order_.remove(entry);
});
entry->commit.connect(xdg_toplevel->base->surface->events.commit, [entry](void*) {
if (entry->xdg_toplevel->base->initial_commit) {
@@ -584,32 +571,6 @@ private:
}
}
- // ---- keyboard policy (consume-or-pass filter) ----
- auto filter_key(kernel::KeyEvent ev) -> kernel::KeyEvent {
- if (ev.handled) {
- return ev; // an earlier link already consumed it
- }
- switch (policy::match_keybinding(ev.keysym, ev.modifiers, ev.pressed)) {
- case policy::KeyAction::terminate:
- wl_display_terminate(host_->display());
- ev.handled = true;
- break;
- case policy::KeyAction::cycle_focus: {
- const std::size_t idx = policy::cycle_next(focus_order_.size());
- if (idx != policy::no_selection) {
- auto it = focus_order_.begin();
- std::advance(it, idx);
- focus_toplevel(*it);
- }
- ev.handled = true; // consume the binding key even with <2 windows
- break;
- }
- case policy::KeyAction::none:
- break;
- }
- return ev;
- }
-
void emit(kernel::Event<const ToplevelEvent&>& ev, ToplevelEntry* entry) {
const ToplevelEvent payload{entry};
ev.emit(payload);
@@ -652,8 +613,6 @@ private:
// Window/popup ownership.
std::unordered_map<wlr_xdg_toplevel*, std::unique_ptr<ToplevelEntry>> toplevels_;
std::unordered_map<wlr_xdg_popup*, std::unique_ptr<PopupEntry>> popups_;
- // Focus order: front = focused; MAPPED toplevels only.
- std::list<ToplevelEntry*> focus_order_;
// Grab state (one at a time). The pure machine owns the move/resize/none
// mode + button-down tracking; these hold the geometry for the active grab.
@@ -685,7 +644,6 @@ private:
Subscription sub_touch_up_;
Subscription sub_touch_cancel_;
Subscription sub_touch_frame_;
- Subscription sub_key_;
friend struct ServiceImpl;
};
diff --git a/packages/ext-xdg-shell/src/policy.hpp b/packages/ext-xdg-shell/src/policy.hpp
index 9419fbd..afc6a55 100644
--- a/packages/ext-xdg-shell/src/policy.hpp
+++ b/packages/ext-xdg-shell/src/policy.hpp
@@ -1,62 +1,17 @@
#pragma once
-#include <cstddef>
#include <cstdint>
-#include <vector>
// Pure decision core (no wlroots / GL / RMLUi). The glue translates wlroots
// input into these calls and acts on the results. Heavily doctest-covered in
// tests/test_policy.cpp without the kernel present. This file calls nothing in
// the glue — it only computes.
+//
+// Compositor keybindings (Ctrl+Alt+Backspace terminate, Alt+F1 focus-cycle)
+// have been removed from this unit; they now live in ext-keybindings.
namespace unbox::ext_xdg_shell::policy {
-// What a matched compositor keybinding asks the glue to do. `none` means the
-// key was not a binding and must pass through to the focused client.
-enum class KeyAction {
- none, // not a binding; do not consume
- terminate, // Ctrl+Alt+Backspace: wl_display_terminate
- cycle_focus, // Alt+F1: focus the next mapped toplevel
-};
-
-// xkb keysym values we care about (kept as plain constants so the core needs
-// no xkbcommon include; the glue passes xkb_keysym_t straight through). These
-// are the stable XKB_KEY_* numeric values.
-inline constexpr std::uint32_t keysym_backspace = 0xff08; // XKB_KEY_BackSpace
-inline constexpr std::uint32_t keysym_f1 = 0xffbe; // XKB_KEY_F1
-
-// WLR_MODIFIER_* bits. Defined here (not pulled from wlr.hpp) so the core
-// stays wlroots-free; the glue masks the live modifier state against these.
-inline constexpr std::uint32_t modifier_ctrl = 1 << 2; // WLR_MODIFIER_CTRL
-inline constexpr std::uint32_t modifier_alt = 8; // WLR_MODIFIER_ALT
-
-// Decide what a key press maps to. Only PRESSES match; everything else is
-// `none` (pass-through).
-//
-// Bindings (settled):
-// Ctrl+Alt+Backspace -> terminate. The canonical X11 kill-the-server chord;
-// it shares NO key with the parent labwc session's defaults (no Escape at
-// all), per the user veto on any overlap (.skills/nested-run.md). Any
-// Escape combo — plain Alt+Escape AND Alt+Shift+Escape — now passes
-// THROUGH unconsumed.
-// Alt+F1 -> cycle focus.
-[[nodiscard]] inline auto match_keybinding(std::uint32_t keysym, std::uint32_t modifiers,
- bool pressed) -> KeyAction {
- if (!pressed) {
- return KeyAction::none;
- }
- // Terminate needs BOTH Ctrl and Alt with Backspace.
- if (keysym == keysym_backspace &&
- (modifiers & modifier_ctrl) != 0 && (modifiers & modifier_alt) != 0) {
- return KeyAction::terminate;
- }
- // Cycle needs Alt held with F1.
- if (keysym == keysym_f1 && (modifiers & modifier_alt) != 0) {
- return KeyAction::cycle_focus;
- }
- return KeyAction::none;
-}
-
// ---- Interactive move/resize grab state machine (pure) ----------------------
//
// Root-causes the user-observed bug: "dragging a titlebar doesn't move the
@@ -221,27 +176,4 @@ private:
std::int32_t origin_touch_id_ = 0;
};
-// Choose the toplevel to focus next when cycling, given the current
-// focus-ordered list (front = currently focused, as the glue maintains it).
-// Returns an INDEX into `order`, or a sentinel meaning "do nothing".
-//
-// Semantics mirror the slice-2 kernel: cycling focuses the LAST entry in the
-// focus order (the least-recently-focused mapped toplevel), and only when
-// there are at least two windows — so repeated Alt+F1 walks the stack. With
-// fewer than two windows there is nothing to cycle to.
-inline constexpr std::size_t no_selection = static_cast<std::size_t>(-1);
-
-[[nodiscard]] inline auto cycle_next(std::size_t count) -> std::size_t {
- if (count < 2) {
- return no_selection;
- }
- return count - 1; // the back of the focus order
-}
-
-// Convenience overload taking the list directly (tests read more clearly).
-template <typename T>
-[[nodiscard]] auto cycle_next(const std::vector<T>& order) -> std::size_t {
- return cycle_next(order.size());
-}
-
} // namespace unbox::ext_xdg_shell::policy
diff --git a/packages/ext-xdg-shell/tests/test_policy.cpp b/packages/ext-xdg-shell/tests/test_policy.cpp
index 7fcc03b..3a95e6b 100644
--- a/packages/ext-xdg-shell/tests/test_policy.cpp
+++ b/packages/ext-xdg-shell/tests/test_policy.cpp
@@ -4,67 +4,13 @@
#include "policy.hpp"
#include <cstdint>
-#include <string>
-#include <vector>
// Pure decision core — strict, no wlroots, no kernel running. Exercises the
-// keybinding match, the cycle-next selection over a model list, and the
// interactive-grab state machine (the user-observed drag bug).
+// Keybinding tests removed: compositor keybindings now live in ext-keybindings.
using namespace unbox::ext_xdg_shell::policy;
-namespace {
-constexpr std::uint32_t keysym_escape = 0xff1b; // XKB_KEY_Escape (now unbound)
-constexpr std::uint32_t modifier_shift = 1; // WLR_MODIFIER_SHIFT
-} // namespace
-
-TEST_CASE("Ctrl+Alt+Backspace on press maps to terminate") {
- CHECK(match_keybinding(keysym_backspace, modifier_ctrl | modifier_alt, /*pressed=*/true) ==
- KeyAction::terminate);
-}
-
-TEST_CASE("terminate needs BOTH Ctrl and Alt with Backspace") {
- CHECK(match_keybinding(keysym_backspace, modifier_alt, true) == KeyAction::none);
- CHECK(match_keybinding(keysym_backspace, modifier_ctrl, true) == KeyAction::none);
- CHECK(match_keybinding(keysym_backspace, /*modifiers=*/0, true) == KeyAction::none);
-}
-
-TEST_CASE("no Escape combo is bound any more (no parent-session overlap)") {
- // User veto: quitting must share NO keys with labwc. Both plain Alt+Escape
- // AND Alt+Shift+Escape must pass through unconsumed now.
- CHECK(match_keybinding(keysym_escape, modifier_alt, /*pressed=*/true) == KeyAction::none);
- CHECK(match_keybinding(keysym_escape, modifier_alt | modifier_shift, true) ==
- KeyAction::none);
- CHECK(match_keybinding(keysym_escape, modifier_ctrl | modifier_alt, true) ==
- KeyAction::none);
-}
-
-TEST_CASE("Alt+F1 on press maps to cycle_focus") {
- CHECK(match_keybinding(keysym_f1, modifier_alt, true) == KeyAction::cycle_focus);
-}
-
-TEST_CASE("cycle binding requires the Alt modifier") {
- CHECK(match_keybinding(keysym_f1, 0, true) == KeyAction::none);
- CHECK(match_keybinding(keysym_f1, modifier_ctrl, true) == KeyAction::none);
-}
-
-TEST_CASE("an unbound key passes through") {
- constexpr std::uint32_t keysym_a = 0x0061; // XKB_KEY_a
- CHECK(match_keybinding(keysym_a, modifier_alt, true) == KeyAction::none);
- CHECK(match_keybinding(keysym_a, modifier_ctrl | modifier_alt, true) == KeyAction::none);
-}
-
-TEST_CASE("releases never match a binding (press-only)") {
- CHECK(match_keybinding(keysym_backspace, modifier_ctrl | modifier_alt, /*pressed=*/false) ==
- KeyAction::none);
- CHECK(match_keybinding(keysym_f1, modifier_alt, false) == KeyAction::none);
-}
-
-TEST_CASE("extra modifiers alongside Alt still match cycle") {
- CHECK(match_keybinding(keysym_f1, modifier_alt | modifier_shift, true) ==
- KeyAction::cycle_focus);
-}
-
// ---- interactive grab state machine (the user-observed move bug) -----------
TEST_CASE("the exact user scenario: press -> request_move -> motion follows; "
@@ -348,41 +294,4 @@ TEST_CASE("touch down DURING an active pointer grab does not hijack or end it")
touch_grab_cycle(g, id + 1);
}
-TEST_CASE("cycle_next picks the back of the focus order when >= 2 windows") {
- CHECK(cycle_next(std::size_t{2}) == 1);
- CHECK(cycle_next(std::size_t{3}) == 2);
- CHECK(cycle_next(std::size_t{5}) == 4);
-}
-
-TEST_CASE("cycle_next does nothing with fewer than two windows") {
- CHECK(cycle_next(std::size_t{0}) == no_selection);
- CHECK(cycle_next(std::size_t{1}) == no_selection);
-}
-TEST_CASE("cycle_next list overload mirrors the count overload") {
- std::vector<std::string> none;
- std::vector<std::string> one{"a"};
- std::vector<std::string> three{"a", "b", "c"};
- CHECK(cycle_next(none) == no_selection);
- CHECK(cycle_next(one) == no_selection);
- CHECK(cycle_next(three) == 2); // index of "c", the least-recently focused
-}
-
-TEST_CASE("repeated cycling walks the stack (model simulation)") {
- // Model the glue's focus_order as a front=focused list; cycling focuses
- // the back, which then moves to the front. Two windows ping-pong; three
- // walk in a stable rotation.
- std::vector<std::string> order{"top", "mid", "bot"};
- auto cycle = [&] {
- const std::size_t idx = cycle_next(order.size());
- REQUIRE(idx != no_selection);
- std::string picked = order[idx];
- order.erase(order.begin() + static_cast<long>(idx));
- order.insert(order.begin(), picked);
- return picked;
- };
- CHECK(cycle() == "bot"); // order -> bot, top, mid
- CHECK(order == std::vector<std::string>{"bot", "top", "mid"});
- CHECK(cycle() == "mid"); // order -> mid, bot, top
- CHECK(order == std::vector<std::string>{"mid", "bot", "top"});
-}
diff --git a/packages/host-bin/meson.build b/packages/host-bin/meson.build
index 4c14a4b..71802a4 100644
--- a/packages/host-bin/meson.build
+++ b/packages/host-bin/meson.build
@@ -4,5 +4,5 @@
executable(
'unbox',
'src/main.cpp',
- dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep],
+ dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep],
)
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index 2992ff5..847bf89 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -1,5 +1,6 @@
#include "demo_ui.hpp"
+#include <unbox/ext-keybindings/ext_keybindings.hpp>
#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
#include <unbox/kernel/kernel.hpp>
@@ -7,12 +8,14 @@
#include <cstdio>
#include <exception>
+#include <optional>
+#include <string>
#include <string_view>
namespace {
void print_usage(const char* argv0) {
- std::printf("usage: %s [-s <startup command>] [--ui-demo]\n", argv0);
+ std::printf("usage: %s [-s <startup command>] [--config <path>] [--ui-demo]\n", argv0);
}
} // namespace
@@ -20,13 +23,18 @@ void print_usage(const char* argv0) {
auto main(int argc, char* argv[]) -> int {
unbox::kernel::Server::Options options;
bool ui_demo = false;
+ std::optional<std::string> config_path;
for (int i = 1; i < argc; ++i) {
const std::string_view arg = argv[i];
if (arg == "-s" && i + 1 < argc) {
options.startup_cmd = argv[++i];
+ } else if (arg == "--config" && i + 1 < argc) {
+ // Explicit unbox.toml for ext-keybindings; if omitted it discovers
+ // the XDG path, then falls back to compiled-in defaults.
+ config_path = argv[++i];
} else if (arg == "--ui-demo") {
- // Slice-5 acceptance demo (temporary until slice 6's real UI
- // extensions): a ui surface via the public substrate contract.
+ // Slice-5 acceptance demo (temporary until the real UI extensions):
+ // a ui surface via the public substrate contract.
ui_demo = true;
} else {
print_usage(argv[0]);
@@ -38,9 +46,11 @@ auto main(int argc, char* argv[]) -> int {
auto server = unbox::kernel::Server::create(std::move(options));
// The composition root: the ONLY place that names every extension.
- // install() transfers ownership; run() activates in dependency order.
+ // install() transfers ownership; run() activates in dependency order
+ // (ext-keybindings depends_on xdg-shell, resolved topologically).
server->install(unbox::ext_xdg_shell::create());
server->install(unbox::ext_layer_shell::create());
+ server->install(unbox::ext_keybindings::create(config_path));
if (ui_demo) {
server->install(unbox::host_bin::create_demo_ui());
}
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index d02cf78..a0627f4 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -206,6 +206,15 @@ void Server::Impl::init() {
}
socket = socket_cstr;
+ // Advertise OUR socket in the PROCESS environment. The process inherited
+ // WAYLAND_DISPLAY from its parent (e.g. labwc's wayland-0), but our real
+ // socket is whatever wl_display_add_socket_auto just picked. Without this,
+ // children spawned by extensions (ext-keybindings forks fuzzel) inherit the
+ // stale parent value and connect to the WRONG compositor -> "no monitors".
+ // setenv here makes every child (the -s startup spawn AND extension spawns)
+ // reach unbox by default. tinywl/sway do exactly this.
+ setenv("WAYLAND_DISPLAY", socket.c_str(), 1);
+
if (!wlr_backend_start(backend)) {
throw std::runtime_error("failed to start the wlr_backend");
}
@@ -406,14 +415,50 @@ void Server::Impl::shutdown() {
void Server::Impl::handle_new_output(wlr_output* wlr_output) {
wlr_output_init_render(wlr_output, allocator, renderer);
- wlr_output_state state;
- wlr_output_state_init(&state);
- wlr_output_state_set_enabled(&state, true);
- if (wlr_output_mode* mode = wlr_output_preferred_mode(wlr_output)) {
- wlr_output_state_set_mode(&state, mode);
+ // Enable + set a mode, then commit. The committed mode is what gives the
+ // output a non-zero width/height — and wlr_output_layout (below) advertises
+ // the client-facing wl_output global ONLY for an output whose width/height
+ // are > 0 (see output_update_global in wlroots' wlr_output_layout.c). So a
+ // FAILED modeset leaves size 0 and the output silently global-less: clients
+ // that need an output (layer-shell: fuzzel et al.) see "no monitors".
+ //
+ // Headless commits trivially succeed; the DRM modeset can fail for the
+ // preferred mode. tinywl ignores the commit result (single-mode demo); we
+ // must not. Try the preferred mode, then any other reported mode, then a
+ // mode-less enable, so every backend ends up with a committed, advertisable
+ // output where the hardware allows one at all.
+ auto try_commit = [&](wlr_output_mode* mode) -> bool {
+ wlr_output_state state;
+ wlr_output_state_init(&state);
+ wlr_output_state_set_enabled(&state, true);
+ if (mode != nullptr) {
+ wlr_output_state_set_mode(&state, mode);
+ }
+ const bool ok = wlr_output_commit_state(wlr_output, &state);
+ wlr_output_state_finish(&state);
+ return ok;
+ };
+
+ bool committed = try_commit(wlr_output_preferred_mode(wlr_output));
+ if (!committed) {
+ wlr_output_mode* mode = nullptr;
+ wl_list_for_each(mode, &wlr_output->modes, link) {
+ if (try_commit(mode)) {
+ committed = true;
+ break;
+ }
+ }
+ }
+ if (!committed) {
+ // Mode-less enable (modeless backends, or hardware that rejected every
+ // mode). On a modeful backend this generally won't yield a usable size,
+ // but it is the last resort and keeps the output enabled.
+ committed = try_commit(nullptr);
+ }
+ if (!committed) {
+ wlr_log(WLR_ERROR, "output %s: every commit failed; no wl_output global",
+ wlr_output->name);
}
- wlr_output_commit_state(wlr_output, &state);
- wlr_output_state_finish(&state);
auto owned = std::make_unique<Output>();
Output* output = owned.get();
@@ -443,11 +488,12 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
outputs.remove_if([output](const auto& owned) { return owned.get() == output; });
});
+ // Adding to the layout auto-advertises the wl_output global for an output
+ // with a committed size (output_update_global in wlr_output_layout.c).
wlr_output_layout_output* layout_output = wlr_output_layout_add_auto(output_layout, wlr_output);
wlr_scene_output* scene_output = wlr_scene_output_create(scene, wlr_output);
wlr_scene_output_layout_add_output(scene_layout, layout_output, scene_output);
- wlr_log(WLR_INFO, "new output %s", wlr_output->name);
const OutputEvent ev{wlr_output};
ev_output_added.emit(ev);
}
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 0fc2c57..dbb5c02 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -34,14 +34,48 @@ TEST_CASE("server boots and shuts down on the headless backend") {
setenv("WLR_BACKENDS", "headless", 1);
setenv("WLR_RENDERER", "pixman", 1);
+ // Simulate the inherited-parent value (labwc's wayland-0) the real bug left
+ // in place. The startup setenv must OVERWRITE this with our own socket.
+ setenv("WAYLAND_DISPLAY", "wayland-stale-parent", 1);
+
auto server = unbox::kernel::Server::create({});
CHECK(!server->socket_name().empty());
+
+ // Regression guard for the real bug: after startup the PROCESS environment's
+ // WAYLAND_DISPLAY must name OUR socket (not the inherited parent value), so
+ // every child — the -s startup spawn AND any extension's spawn — connects to
+ // unbox by default instead of the wrong compositor ("no monitors").
+ const char* env_display = getenv("WAYLAND_DISPLAY");
+ REQUIRE(env_display != nullptr);
+ CHECK(std::string(env_display) == server->socket_name());
+
for (int i = 0; i < 3; ++i) {
CHECK(server->dispatch(10));
}
// Destruction runs the full tinywl shutdown sequence.
}
+TEST_CASE("server boots with a headless output present and advertised") {
+ // The headless backend creates its output during wlr_backend_start (inside
+ // Server::create), so it is enabled + committed + globalled before this
+ // returns. We assert the boot path survives an output being present and the
+ // event loop pumps cleanly — the headless analogue of the DRM advertise the
+ // wl_output-global guarantee (the layout auto-advertises the global for an
+ // output with a committed size).
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+
+ auto server = unbox::kernel::Server::create({});
+ CHECK(!server->socket_name().empty());
+ server->activate_extensions();
+ for (int i = 0; i < 5; ++i) {
+ CHECK(server->dispatch(10));
+ }
+
+ unsetenv("WLR_HEADLESS_OUTPUTS");
+}
+
// ============================================================================
// The ui substrate — contract-critical facade. A TEST extension creates a ui
// surface through the PUBLIC Host::ui() path, binds a scalar + event, and the
diff --git a/subprojects/tomlplusplus.wrap b/subprojects/tomlplusplus.wrap
new file mode 100644
index 0000000..c1f492c
--- /dev/null
+++ b/subprojects/tomlplusplus.wrap
@@ -0,0 +1,10 @@
+[wrap-file]
+directory = tomlplusplus-3.4.0
+source_url = https://github.com/marzer/tomlplusplus/archive/refs/tags/v3.4.0.tar.gz
+source_filename = tomlplusplus-3.4.0.tar.gz
+source_hash = 8517f65938a4faae9ccf8ebb36631a38c1cadfb5efa85d9a72e15b9e97d25155
+source_fallback_url = https://wrapdb.mesonbuild.com/v2/tomlplusplus_3.4.0-1/get_source/tomlplusplus-3.4.0.tar.gz
+wrapdb_version = 3.4.0-1
+
+[provide]
+dependency_names = tomlplusplus
diff --git a/suppressions/lsan.txt b/suppressions/lsan.txt
new file mode 100644
index 0000000..fae4899
--- /dev/null
+++ b/suppressions/lsan.txt
@@ -0,0 +1,19 @@
+# LeakSanitizer suppressions — THIRD-PARTY ONLY.
+#
+# These libraries allocate process-lifetime globals (GPU driver caches, EGL/GL
+# state, DRM bookkeeping) that the OS reclaims at process exit — not real leaks,
+# and not fixable by us. Suppressing them keeps `meson test -C build-asan` green
+# WHILE leak detection stays ON for unbox's own code: a leak whose stack has an
+# unbox:: frame is NOT matched here and still fails the suite.
+#
+# Verified on the kernel substrate GL tests: all leak records frame in Mesa/EGL/
+# DRM, none in packages/. A suppression matches if ANY frame in the leak's stack
+# is in the named module.
+leak:libgallium
+leak:libEGL_mesa
+leak:libGLX_mesa
+leak:libglapi
+leak:libgbm
+leak:libdrm
+leak:swrast
+leak:driCreateNewScreen
diff --git a/suppressions/ubsan.txt b/suppressions/ubsan.txt
new file mode 100644
index 0000000..6f6c290
--- /dev/null
+++ b/suppressions/ubsan.txt
@@ -0,0 +1,8 @@
+# UBSan suppressions — vendored RmlUi only.
+#
+# RmlUi performs a downcast on a Rml::Element while it is mid-destruction, which
+# trips -fsanitize=vptr (the vptr no longer resolves to ElementDocument). It is
+# benign and lives in vendored third-party sources we do not edit. Our own code
+# is not listed here, so real UB in unbox:: still fails.
+vptr:Element.cpp
+vptr:ElementDocument.cpp
diff --git a/tasks.md b/tasks.md
index 133d722..e9993d1 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,6 +5,13 @@
## Now
+**Just landed — usability slice (user-driven, real-seat verified on the CF-AX3):**
+`ext-keybindings` (new core ext) reads keybindings from `unbox.toml`: tap-Super →
+spawn fuzzel, Alt+Tab / Alt+Shift+Tab → stable focus rotation over all toplevels,
+Alt+F1, Ctrl+Alt+Backspace → quit. ext-xdg-shell's hardcoded keybinds migrated
+out. Kernel now exports `WAYLAND_DISPLAY` so extension-spawned clients reach unbox
+(was the fuzzel "no monitors" root cause). build + build-asan both green.
+
**Next action:** Slice 6 — ext-taskbar + ext-launcher (first standard
extensions; prove the ui-substrate contract). Queued into it from
slice 5: list/container data bindings (taskbar will change-request the
@@ -22,6 +29,7 @@ extension retires (replaced by the real consumers).
| 3 | **THE SPIKE:** RMLUi→scene bridge | **DONE — GO** 2026-06-12 | met: Plan A (dmabuf FBO→wlr_buffer→wlr_scene_buffer) verified nested+headless on HD 4400; Plan B fallback verified; orientation fixed + position-aware guard; input proof on-screen; RSS ≈83 MiB; ASan/UBSan clean in our code (known noise: Mesa leak reports + 2 benign UBSan downcasts inside vendored RMLUi). glFinish→fence and format negotiation deferred to the real substrate (slice 4+) |
| 4 | Extension host + contracts: bus, manifests, static registration; xdg-shell/layer-shell refactored OUT of kernel into core extensions | **DONE** 2026-06-12 | met: kernel boots featureless (names no feature); typed Event/Filter bus error-isolated + topo activation; ext-xdg-shell (toplevels, focus, grabs via pure GrabMachine, button/axis routing, Ctrl+Alt+Backspace quit) + ext-layer-shell (fuzzel verified, pure arrangement core) pass suites; typed surface→scene-tree registry replaced the data-field convention; first protocol codegen (wlr-layer-shell XML vendored); user hands-on: all input paths verified incl. touch; 68 cases green + ASan clean; idle RSS ≈73 MiB |
| 5 | Input routing + ergonomics contract: unified pointer/touch→RMLUi events, keybinding filter chain, touch-mode RCSS variables | **DONE** 2026-06-13 | met (user hands-on): real ui substrate (`Host::ui()` → UiSurface, scalar+event bindings, dmabuf+fence+swapchain); same demo surface driven by mouse AND finger; consume-or-pass with implicit-grab ownership (press owner gets release, per touch point too); touch-mode = state+notification only, NO visual scaling (user decision); touch-initiated grabs incl. pointer/touch alternation (seat release-leak fixed); keybinding chain satisfied by slice-4 Filter (ext-keybindings deferred); 113 doctest cases green, ASan clean, idle RSS ≈78 MiB |
+| 5b | Usability: `ext-keybindings` (config-driven `unbox.toml`) — Super→fuzzel, Alt+Tab focus rotation; ext-xdg-shell keybinds migrated; kernel exports `WAYLAND_DISPLAY` for spawned clients | **DONE** 2026-06-13 | met (real-seat, user-confirmed): fuzzel opens on Super, Alt+Tab cycles all windows, quit works; build + build-asan both green (3rd-party Mesa/RmlUi sanitizer noise suppressed; a real libwayland leak in the layer-shell client test fixed) |
| 6 | First standard extensions: ext-taskbar + ext-launcher | pending | proves the ui-substrate contract is complete (friction = bad contract) |
| 7 | ext-window-tiling: pure layout core + thin scene glue | pending | layout math 100% doctest-covered, zero wlroots types in core |
| 8 | ext-osk: RML keyboard ui surface injecting via wlr_seat | pending | type into foot via touch only; auto-show on text-input focus |
diff --git a/unbox.toml b/unbox.toml
new file mode 100644
index 0000000..e6fd085
--- /dev/null
+++ b/unbox.toml
@@ -0,0 +1,37 @@
+# unbox.toml — sample configuration
+#
+# Copy to $XDG_CONFIG_HOME/unbox/unbox.toml (i.e. ~/.config/unbox/unbox.toml)
+# and edit. If no config file is found, unbox uses these exact defaults.
+#
+# Keybindings -------------------------------------------------------------------
+# Each [[keybind]] maps a key combo to one action.
+#
+# keys "Mod+...+Key" — modifiers (Super, Alt, Ctrl, Shift; order/case
+# insensitive) then an xkb key name (Tab, F1, Return, space,
+# BackSpace, d, ...). A BARE modifier with no "+Key" (e.g. "Super")
+# is a TAP binding: it fires only when that modifier is pressed and
+# released with nothing pressed in between.
+# action one of: spawn (needs `command`) · focus-next · focus-prev ·
+# close-active · quit
+# command (spawn only) shell line, run via `sh -c`, so args/quoting work.
+
+[[keybind]]
+keys = "Super" # tap the Windows key
+action = "spawn"
+command = "fuzzel"
+
+[[keybind]]
+keys = "Alt+Tab" # next window (all windows, wraps around)
+action = "focus-next"
+
+[[keybind]]
+keys = "Alt+Shift+Tab" # previous window
+action = "focus-prev"
+
+[[keybind]]
+keys = "Alt+F1" # legacy focus cycle (kept from the tinywl port)
+action = "focus-next"
+
+[[keybind]]
+keys = "Ctrl+Alt+Backspace" # quit the unbox session
+action = "quit"