diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 15:49:32 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 15:49:32 +0900 |
| commit | 11812b0374d5de395e2c17532c6bf89a903ee043 (patch) | |
| tree | 029f1d222aadf0d43f96fa071c313e4cdd119202 | |
| parent | 803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9 (diff) | |
| download | unbox-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.
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_ ? ¤t_ : 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 @@ -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" |
