summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
committerAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
commitc102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (patch)
treef6dea2875b939c0f661292d8bfa0d79a96fe67d7
parent6949c3582ed1e480e70aabfcfa3a11b78007cc12 (diff)
downloadunbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.tar.gz
unbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.zip
Slice 4: extension host + typed bus; xdg-shell/layer-shell extracted to core extensions
The kernel now names NO concrete feature. It owns: the extension host (install/topological activate, missing-dep/cycle = startup error), the typed Event/Filter bus (error-isolated: a throwing extension is disabled, never the session; RAII Subscriptions), the Host API (per-extension facade: borrows, scene layers, event catalogue, typed services), the public RAII Listener, and a typed surface→scene-tree registry (Host::host_surface/scene_tree_for) that replaced the untyped wlr_surface.data convention both extensions flagged. - ext-xdg-shell (core): toplevel/popup lifecycle, focus-on-map, click/tap-to-focus, pointer/touch routing incl. button+axis (the kernel only moves the cursor and emits — a contract-doc lie caught by user hands-on), interactive move/resize via pure GrabMachine (fixes the request-arrives-after-release race: grab requires request ∧ button-down, release always ends it), Alt+F1 cycle, Ctrl+Alt+Backspace terminate (labwc's default A-Escape=Exit killed the dev session once — never again; see nested-run skill). - ext-layer-shell (core): wlr-layer-shell v1 (proto v5) for external clients; pure doctest-hard arrangement core; fuzzel verified visually nested (fix: seed outputs from output_layout at activate — events-only tracking missed pre-activation outputs; plus a scene-node double-free). - First protocol codegen: vendored wlr-layer-shell XML + wayland-scanner server-header propagated through kernel_dep; wlr.hpp grew a namespace→_namespace keyword fix for the generated header. - Glossary: 'scene layer' (user-approved). New rules earned: parallel-wave-builds, contract-docs. - User hands-on verified: typing, click-to-focus, drag-select, scroll, titlebar drag-move (slow + flick), Alt+F1, fuzzel + arrows, touch tap, Ctrl+Alt+Backspace. 68 doctest cases green, ASan/UBSan clean (our code), idle RSS ≈73 MiB.
-rw-r--r--.skills/nested-run.md7
-rw-r--r--.unbox/rules/contract-docs.md5
-rw-r--r--.unbox/rules/parallel-wave-builds.md5
-rw-r--r--GLOSSARY.md1
-rw-r--r--meson.build2
-rw-r--r--notes/plan.md4
-rw-r--r--packages/ext-layer-shell/ext-layer-shell.md83
-rw-r--r--packages/ext-layer-shell/include/unbox/ext-layer-shell/arrangement.hpp170
-rw-r--r--packages/ext-layer-shell/include/unbox/ext-layer-shell/ext_layer_shell.hpp38
-rw-r--r--packages/ext-layer-shell/meson.build106
-rw-r--r--packages/ext-layer-shell/src/ext_layer_shell.cpp370
-rw-r--r--packages/ext-layer-shell/tests/test_arrangement.cpp201
-rw-r--r--packages/ext-layer-shell/tests/test_client.cpp174
-rw-r--r--packages/ext-layer-shell/tests/test_glue.cpp55
-rw-r--r--packages/ext-xdg-shell/ext-xdg-shell.md77
-rw-r--r--packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp108
-rw-r--r--packages/ext-xdg-shell/meson.build35
-rw-r--r--packages/ext-xdg-shell/src/extension.cpp634
-rw-r--r--packages/ext-xdg-shell/src/policy.hpp173
-rw-r--r--packages/ext-xdg-shell/src/probe.hpp33
-rw-r--r--packages/ext-xdg-shell/tests/test_glue.cpp53
-rw-r--r--packages/ext-xdg-shell/tests/test_policy.cpp172
-rw-r--r--packages/host-bin/meson.build2
-rw-r--r--packages/host-bin/src/main.cpp8
-rw-r--r--packages/kernel/include/unbox/kernel/extension.hpp69
-rw-r--r--packages/kernel/include/unbox/kernel/hooks.hpp334
-rw-r--r--packages/kernel/include/unbox/kernel/host.hpp274
-rw-r--r--packages/kernel/include/unbox/kernel/listener.hpp72
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp33
-rw-r--r--packages/kernel/include/unbox/kernel/surface_registry.hpp134
-rw-r--r--packages/kernel/include/unbox/kernel/wlr.hpp22
-rw-r--r--packages/kernel/kernel.md99
-rw-r--r--packages/kernel/meson.build48
-rw-r--r--packages/kernel/src/input.cpp241
-rw-r--r--packages/kernel/src/listener.hpp59
-rw-r--r--packages/kernel/src/server.cpp191
-rw-r--r--packages/kernel/src/server_impl.hpp226
-rw-r--r--packages/kernel/src/toplevel.cpp190
-rw-r--r--packages/kernel/tests/test_kernel.cpp383
-rw-r--r--protocol/wlr-layer-shell-unstable-v1.xml407
-rw-r--r--tasks.md11
41 files changed, 4753 insertions, 556 deletions
diff --git a/.skills/nested-run.md b/.skills/nested-run.md
index 13d6bbd..b3e7c90 100644
--- a/.skills/nested-run.md
+++ b/.skills/nested-run.md
@@ -17,3 +17,10 @@ Use to smoke-test unbox visually without leaving the live labwc session.
6. **Touch caveat:** nested touch fidelity depends on what labwc forwards.
Final touch/gesture validation only counts on the real seat (slice 9+,
s6 service on seat0).
+7. **Parent-keybind hazard (learned the hard way):** labwc intercepts its
+ own keybinds BEFORE the nested window sees them — and labwc's default
+ `A-Escape` is **Exit labwc** (kills the whole dev session, terminals,
+ agents and all). Never bind or instruct `Alt+Escape` nested; unbox's
+ dev terminate is `Ctrl+Alt+Backspace` (user rule: quit must share NO
+ keys with parent-session bindings). Check new bindings against
+ labwc's defaults before testing them nested.
diff --git a/.unbox/rules/contract-docs.md b/.unbox/rules/contract-docs.md
new file mode 100644
index 0000000..9dc5656
--- /dev/null
+++ b/.unbox/rules/contract-docs.md
@@ -0,0 +1,5 @@
+# contract-docs
+A doc-comment in a public header is a contract CLAIM: another unit WILL
+build on it without reading your src. Never document behavior you did
+not implement and verify in this unit (slice 4 scar: "kernel forwards
+button/axis" was written but never true — shipped a dead-input bug).
diff --git a/.unbox/rules/parallel-wave-builds.md b/.unbox/rules/parallel-wave-builds.md
new file mode 100644
index 0000000..8778422
--- /dev/null
+++ b/.unbox/rules/parallel-wave-builds.md
@@ -0,0 +1,5 @@
+# parallel-wave-builds
+During a parallel wave the shared `build/` may transiently fail to
+configure while sibling units land their meson.build/files. Build YOUR
+targets, retry once after a pause, and NEVER report a sibling's
+mid-flight compile/configure state as a blocker or change-request.
diff --git a/GLOSSARY.md b/GLOSSARY.md
index a59e950..a3df14a 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -33,6 +33,7 @@
| **seat** | The input-device collection owning keyboard/pointer/touch focus (`wlr_seat`). | — |
| **scene** | The `wlr_scene` retained-mode node tree; provides damage tracking. | render graph, scene tree (write "scene") |
| **damage** | The output region needing redraw this frame. | dirty region |
+| **scene layer** | An ordered z-band of the scene tree (`background < bottom < normal < top < overlay`; wlr-layer-shell names + `normal` for toplevels). Extensions attach nodes per band, never fighting over stacking order. | z-layer, stacking layer, shell layer |
| **listener** | A `wl_listener`. Lives inside one unit's glue only; RAII-wrapped at every boundary. | — |
| **workspace** | A virtual-desktop grouping of toplevels. | desktop, tag |
| **nested session** | unbox running as a window inside the live labwc session (the dev mode). | embedded mode |
diff --git a/meson.build b/meson.build
index d1c8e76..11a46d8 100644
--- a/meson.build
+++ b/meson.build
@@ -33,4 +33,6 @@ doctest_dep = dependency('doctest')
# 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/host-bin')
diff --git a/notes/plan.md b/notes/plan.md
index 92fafb1..2ca8c75 100644
--- a/notes/plan.md
+++ b/notes/plan.md
@@ -137,7 +137,9 @@ trusted.
| clang-format style | defer config to slice 1 | first formatting dispute |
| 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 |
-| ui-substrate frame sync: glFinish → EGL fence + 2-deep swapchain | per-frame glFinish (spike fidelity) | real ui substrate lands (slice 4+) |
+| ui-substrate frame sync: glFinish → EGL fence + 2-deep swapchain | per-frame glFinish (spike fidelity) | real ui substrate lands (slice 5+) |
+| window placement policy (new toplevels overlap at origin) | tinywl parity: no placement | slice 7 tiling (or earlier if it blocks testing) |
+| layer-shell `on_demand` keyboard interactivity | only `exclusive`/`none` honored | slice 5 input routing |
## 8. References
diff --git a/packages/ext-layer-shell/ext-layer-shell.md b/packages/ext-layer-shell/ext-layer-shell.md
new file mode 100644
index 0000000..e96b054
--- /dev/null
+++ b/packages/ext-layer-shell/ext-layer-shell.md
@@ -0,0 +1,83 @@
+# ext-layer-shell
+
+wlr-layer-shell-unstable-v1 (protocol **version 5**, the wlroots-0.20 cap) for
+**external** clients: panels, launchers, wallpapers, on-screen keyboards, and
+the crash-isolation escape hatch from `notes/plan.md` §2. unbox's own RMLUi ui
+substrate does **not** go through layer-shell — this protocol exists so foreign
+processes can paint the desktop's edges.
+
+Tier `core`, manifest id `layer-shell`, no dependencies. `activate(Host&)`
+creates the `wlr_layer_shell_v1` global on `host.display()`.
+
+## Why it exists
+The kernel names no concrete protocol. Layer-shell is shell *policy* (which
+edge a panel reserves, which z-band it lands in), so it is an extension. The
+extension-creates-the-global split keeps the kernel featureless.
+
+## Side-effect graph
+- **Creates:** the `wlr_layer_shell_v1` global (one, at activation).
+- **Subscribes (kernel events):** `on_output_added` / `on_output_removed` —
+ to track the output set (assign one to outputless surfaces; re-arrange and
+ evict on output loss). Plus a one-shot enumeration of already-existing outputs
+ (`host.output_layout()->outputs`) at activate, since outputs predate
+ activation (see Gotchas).
+- **Binds (wlroots signals, via RAII `Listener`):** shell `new_surface`; per
+ surface its `wlr_surface.commit`, layer-surface `destroy`, and `new_popup`.
+- **Drives:** `wlr_scene_layer_surface_v1_configure` on every commit and output
+ change, attaching each surface's scene node under the kernel `SceneLayer`
+ band matching its protocol layer (background/bottom/top/overlay map 1:1;
+ `normal` is toplevels-only and never used here).
+- **Emits hooks:** none yet (see *Deferred*).
+
+## Surface → scene-tree association (typed kernel contract)
+For each layer surface we register its `wlr_surface` → our `wlr_scene_tree` via
+`Host::host_surface()`, holding the move-only `SurfaceRegistration` as a member
+of the `LayerSurface` (it unregisters on destruction). ext-xdg-shell resolves a
+popup's parent surface to our tree via `Host::scene_tree_for()`, so xdg popups
+parented to a layer surface attach correctly. This is the kernel-owned **typed**
+replacement for the old `wlr_surface.data` convention (now dead) — cross-unit
+surface→tree coupling routes through this contract, never through `.data`.
+
+## Pure core
+`include/unbox/ext-layer-shell/arrangement.hpp` — `Box`, `SurfaceState`,
+`exclusive_edge()`, `apply_exclusive()`. Zero wlroots types; the independent,
+doctest-hard mirror of the usable-area bookkeeping that
+`wlr_scene_layer_surface_v1_configure` performs. It is what tiling (slice 7)
+will read for per-output usable area. The glue keeps a per-output `Box` updated
+from the helper's `usable_area` out-param using this model's coordinate
+convention.
+
+## What was deferred (intentional)
+- **`on_demand` keyboard interactivity:** only `exclusive` (focus on map) and
+ `none` (leave alone) are honored. `on_demand` needs slice 5's input routing
+ (click-to-focus a layer surface) and is a documented TODO.
+- **A typed usable-area service / `usable-area-changed` Event:** not exported.
+ The per-output `Box` is computed and held internally; publishing it is left
+ to the consumer that actually needs it (tiling) so the contract is shaped by
+ a real caller, not guessed. Noted as a deliberate deferral.
+- **Popup glue beyond the registration:** `new_popup` is bound but does no
+ extra work; wlroots' scene helper wires popup nodes once a consumer resolves
+ the parent via `Host::scene_tree_for()` against our `host_surface()`
+ registration.
+
+## Gotchas
+- **Seed outputs at activate, do not rely on events alone.** `Server::create()`
+ starts the backend, so outputs exist BEFORE extensions activate; their
+ `on_output_added` already fired. We enumerate `host.output_layout()->outputs`
+ in `activate()` to catch them — events-only tracking left `outputs_` empty and
+ silently broke every output-less client (the fuzzel "no configure" bug). The
+ underlying Host contract gap (late subscribers miss state) is a standing
+ change-request in `reports/ext-layer-shell.md`.
+- **Never destroy the scene node in the layer-surface destroy handler.**
+ `wlr_scene_layer_surface_v1` installs its own internal destroy listener that
+ frees the scene tree; calling `wlr_scene_node_destroy` ourselves is a
+ use-after-free (signal-emit order between the two listeners is unspecified).
+ Our destroy handler only reclaims the usable area and erases the
+ `LayerSurface`.
+- An output-less surface arriving when **no** output exists yet is **parked**
+ (`pending_`, destroy-listener only) and placed once an output appears — not
+ closed. We only close on a hard failure (`wlr_scene_layer_surface_v1_create`
+ returning null).
+- The destroy handler's **last** action is `owner.erase(this)`, which deletes
+ the `LayerSurface`; copy any needed value (output, owner ref) into locals
+ first — nothing may touch members afterwards (listener-lifetime).
diff --git a/packages/ext-layer-shell/include/unbox/ext-layer-shell/arrangement.hpp b/packages/ext-layer-shell/include/unbox/ext-layer-shell/arrangement.hpp
new file mode 100644
index 0000000..638739a
--- /dev/null
+++ b/packages/ext-layer-shell/include/unbox/ext-layer-shell/arrangement.hpp
@@ -0,0 +1,170 @@
+#pragma once
+
+#include <cstdint>
+
+// Arrangement math — the PURE CORE of ext-layer-shell. Zero wlroots/GL/RMLUi
+// types: a self-contained model of the wlr-layer-shell usable-area bookkeeping
+// the compositor must keep as anchored, exclusive-zone surfaces are arranged on
+// an output. Input -> output only; doctest-covered hard. wlroots' own
+// wlr_scene_layer_surface_v1_configure performs the actual scene positioning
+// and mutates a usable_area box identically; this model is the independent,
+// testable mirror we keep for everything DOWNSTREAM of the scene helper (the
+// per-output usable area tiling, slice 7, will consume).
+//
+// Coordinate convention matches wlr_box: x/y are the box's top-left in output-
+// local pixels, growing right/down. All math is integer (pixel) arithmetic.
+//
+// No allocation, no threads, no I/O. Pure value types.
+
+namespace unbox::ext_layer_shell {
+
+// An axis-aligned integer pixel box (mirrors wlr_box, but wlroots-free so this
+// header stays a pure contract). width/height are >= 0 for a valid box.
+struct Box {
+ std::int32_t x = 0;
+ std::int32_t y = 0;
+ std::int32_t width = 0;
+ std::int32_t height = 0;
+
+ friend constexpr auto operator==(const Box&, const Box&) -> bool = default;
+};
+
+// Anchor bitfield — the wlr-layer-shell v1 `anchor` enum values verbatim
+// (top=1, bottom=2, left=4, right=8), combinable. A surface anchored to two
+// opposite edges spans that axis; anchored to all four it fills the output
+// (minus margins). Kept as named constants so the pure core never needs the
+// generated protocol header.
+namespace anchor {
+inline constexpr std::uint32_t top = 1;
+inline constexpr std::uint32_t bottom = 2;
+inline constexpr std::uint32_t left = 4;
+inline constexpr std::uint32_t right = 8;
+} // namespace anchor
+
+// Which single edge a positive exclusive zone reserves space on. `none` means
+// the surface reserves nothing (its exclusive zone is non-positive, or its
+// anchoring makes a positive zone meaningless per the protocol — see
+// exclusive_edge() below).
+enum class Edge : std::uint8_t { none, top, bottom, left, right };
+
+// The subset of a layer surface's committed state the arrangement math needs.
+// `exclusive_edge` is the protocol's optional explicit override (v5): when
+// Edge::none the edge is deduced from `anchor`; otherwise it forces the edge a
+// corner-anchored surface reserves on. Margins are per-edge insets from the
+// anchored edges.
+struct SurfaceState {
+ std::uint32_t anchor = 0; // OR of anchor::* bits
+ std::int32_t exclusive_zone = 0; // <0 = "stretch over"; 0 = avoid; >0 = reserve
+ std::int32_t margin_top = 0;
+ std::int32_t margin_right = 0;
+ std::int32_t margin_bottom = 0;
+ std::int32_t margin_left = 0;
+ Edge exclusive_edge = Edge::none; // explicit override; none = deduce
+};
+
+// The edge a positive exclusive zone is applied to, or Edge::none if the zone
+// reserves nothing. Mirrors wlr_layer_surface_v1_get_exclusive_edge():
+//
+// * a non-positive exclusive_zone reserves nothing;
+// * an explicit exclusive_edge wins if it is one of the anchored edges;
+// * otherwise the edge is deducible only when the surface is anchored to
+// exactly one edge, OR to one edge plus its two perpendicular edges (a
+// full-width/height strip). Anchoring to a bare corner, to two parallel
+// edges, or to all four edges makes a positive zone meaningless -> none.
+[[nodiscard]] constexpr auto exclusive_edge(const SurfaceState& s) -> Edge {
+ if (s.exclusive_zone <= 0) {
+ return Edge::none;
+ }
+ const bool t = (s.anchor & anchor::top) != 0;
+ const bool b = (s.anchor & anchor::bottom) != 0;
+ const bool l = (s.anchor & anchor::left) != 0;
+ const bool r = (s.anchor & anchor::right) != 0;
+
+ // Candidate edge from anchoring: a strip is anchored to one edge and
+ // (optionally) both of the perpendicular edges, but NOT to the opposite
+ // edge. Anchoring to all four, or to two parallel edges, yields no edge.
+ Edge deduced = Edge::none;
+ if (t && !b && (l == r)) {
+ deduced = Edge::top;
+ } else if (b && !t && (l == r)) {
+ deduced = Edge::bottom;
+ } else if (l && !r && (t == b)) {
+ deduced = Edge::left;
+ } else if (r && !l && (t == b)) {
+ deduced = Edge::right;
+ }
+
+ if (s.exclusive_edge != Edge::none) {
+ // Honor the explicit override only if it is an edge the surface is
+ // actually anchored to (the protocol raises invalid_exclusive_edge
+ // otherwise; wlroots clamps — we treat a non-anchored override as a
+ // no-op deduction so a misbehaving client cannot warp the usable area).
+ const bool anchored =
+ (s.exclusive_edge == Edge::top && t) ||
+ (s.exclusive_edge == Edge::bottom && b) ||
+ (s.exclusive_edge == Edge::left && l) ||
+ (s.exclusive_edge == Edge::right && r);
+ // Still require the override to be on a strip (deduced != none) or a
+ // corner where the override disambiguates a single anchored edge.
+ if (anchored) {
+ return s.exclusive_edge;
+ }
+ return Edge::none;
+ }
+ return deduced;
+}
+
+// Reduce `usable` by the space ONE surface reserves, returning the smaller box
+// the NEXT lower surface in the same arrangement pass may use. Mirrors the
+// usable_area mutation wlr_scene_layer_surface_v1_configure performs:
+//
+// * a non-reserving surface (exclusive_edge() == none) leaves `usable` as-is;
+// * a reserving surface shrinks `usable` on its edge by
+// (exclusive_zone + the margin on that edge), clamped so width/height never
+// go negative.
+//
+// Apply this in protocol z-order (overlay first within a pass is irrelevant to
+// the area; what matters is each surface sees the area left by those processed
+// before it — drive the surfaces in a stable order and the result is the
+// remaining usable area for non-exclusive content and for tiling).
+[[nodiscard]] constexpr auto apply_exclusive(Box usable, const SurfaceState& s) -> Box {
+ const Edge edge = exclusive_edge(s);
+ if (edge == Edge::none) {
+ return usable;
+ }
+ switch (edge) {
+ case Edge::top: {
+ const std::int32_t d = s.exclusive_zone + s.margin_top;
+ usable.y += d;
+ usable.height -= d;
+ break;
+ }
+ case Edge::bottom: {
+ const std::int32_t d = s.exclusive_zone + s.margin_bottom;
+ usable.height -= d;
+ break;
+ }
+ case Edge::left: {
+ const std::int32_t d = s.exclusive_zone + s.margin_left;
+ usable.x += d;
+ usable.width -= d;
+ break;
+ }
+ case Edge::right: {
+ const std::int32_t d = s.exclusive_zone + s.margin_right;
+ usable.width -= d;
+ break;
+ }
+ case Edge::none:
+ break;
+ }
+ if (usable.width < 0) {
+ usable.width = 0;
+ }
+ if (usable.height < 0) {
+ usable.height = 0;
+ }
+ return usable;
+}
+
+} // namespace unbox::ext_layer_shell
diff --git a/packages/ext-layer-shell/include/unbox/ext-layer-shell/ext_layer_shell.hpp b/packages/ext-layer-shell/include/unbox/ext-layer-shell/ext_layer_shell.hpp
new file mode 100644
index 0000000..3288d84
--- /dev/null
+++ b/packages/ext-layer-shell/include/unbox/ext-layer-shell/ext_layer_shell.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <memory>
+
+// ext-layer-shell — wlr-layer-shell-unstable-v1 (version 5) for EXTERNAL
+// clients: panels, launchers, wallpapers, on-screen keyboards, and the
+// crash-isolation escape hatch (notes/plan.md §2). unbox's OWN ui substrate
+// does NOT go through layer-shell.
+//
+// Tier: core. Manifest id "layer-shell", no dependencies. The extension owns
+// the wlr_layer_shell_v1 global (created on host.display() in activate()), maps
+// each protocol layer 1:1 onto a kernel SceneLayer band (background/bottom/top/
+// overlay; `normal` is toplevels-only and never used here), and keeps each
+// output's usable area up to date as anchored/exclusive surfaces come and go.
+//
+// This header is the unit's WHOLE cross-extension contract: a factory only. The
+// arrangement math (anchors, margins, exclusive-zone accumulation) is the pure
+// core in <unbox/ext-layer-shell/arrangement.hpp> — depend on THAT, not on this,
+// if you only need the usable-area model (tiling, slice 7).
+//
+// Single wl_event_loop thread throughout. Ownership of the returned extension
+// transfers to the caller (host-bin installs it into the Server).
+
+namespace unbox::ext_layer_shell {
+
+// Construct the extension. Cheap and side-effect free (per the Extension
+// contract); ALL wiring — global creation, signal binding, output tracking —
+// happens in activate(). host-bin installs the returned object via
+// Server::install(); the kernel calls activate() in topological order.
+//
+// Ownership: unique_ptr = transfer to the caller. The object must outlive the
+// session and is destroyed (RAII teardown of the global, listeners, and scene
+// nodes) at shutdown.
+[[nodiscard]] auto create() -> std::unique_ptr<kernel::Extension>;
+
+} // namespace unbox::ext_layer_shell
diff --git a/packages/ext-layer-shell/meson.build b/packages/ext-layer-shell/meson.build
new file mode 100644
index 0000000..e64c96d
--- /dev/null
+++ b/packages/ext-layer-shell/meson.build
@@ -0,0 +1,106 @@
+# ext-layer-shell — wlr-layer-shell-unstable-v1 (v5) for external clients.
+# Public headers (the contract): include/unbox/ext-layer-shell/
+# ext_layer_shell.hpp — the extension factory (cross-extension surface)
+# arrangement.hpp — the PURE CORE usable-area math (wlroots-free)
+
+ext_layer_shell_inc = include_directories('include')
+
+# Glue library: the extension implementation. Pulls kernel_dep (and through it
+# wlroots/wayland + the generated layer-shell protocol header via wlr.hpp).
+ext_layer_shell_lib = static_library(
+ 'unbox-ext-layer-shell',
+ 'src/ext_layer_shell.cpp',
+ include_directories: ext_layer_shell_inc,
+ dependencies: [kernel_dep],
+)
+
+# What host-bin (and any consumer of the usable-area model) links against. The
+# arrangement.hpp pure core rides on the include path with no wlroots in its
+# transitive closure; consuming the factory pulls kernel_dep for the Extension
+# ABI it returns.
+ext_layer_shell_dep = declare_dependency(
+ link_with: ext_layer_shell_lib,
+ include_directories: ext_layer_shell_inc,
+ dependencies: [kernel_dep],
+)
+
+# Pure-core test: arrangement math, doctest-hard. NO kernel/wlroots — the whole
+# point of the pure core is that it tests with nothing running.
+ext_layer_shell_arrangement_test = executable(
+ 'ext-layer-shell-arrangement-tests',
+ 'tests/test_arrangement.cpp',
+ include_directories: ext_layer_shell_inc,
+ dependencies: [doctest_dep],
+)
+test(
+ 'ext-layer-shell-arrangement',
+ ext_layer_shell_arrangement_test,
+ suite: 'ext-layer-shell',
+)
+
+# Glue test: install + activate + dispatch + clean shutdown on the wlr headless
+# backend. Lenient (a few integration checks, not coverage-chasing).
+ext_layer_shell_glue_test = executable(
+ 'ext-layer-shell-glue-tests',
+ 'tests/test_glue.cpp',
+ dependencies: [ext_layer_shell_dep, doctest_dep],
+)
+test(
+ 'ext-layer-shell-glue',
+ ext_layer_shell_glue_test,
+ suite: 'ext-layer-shell',
+)
+
+# Regression test for the "real client gets no configure" bug: a genuine
+# in-process wayland-CLIENT binds zwlr_layer_shell_v1, creates a layer surface
+# with a NIL output (exactly what fuzzel does), and must receive a configure.
+# This requires CLIENT-side bindings for the layer-shell protocol, generated
+# from the same vendored XML the kernel uses for the server side.
+wayland_client_dep = dependency('wayland-client')
+
+layer_shell_client_h = custom_target(
+ 'wlr-layer-shell-client-header',
+ input: meson.project_source_root() / 'protocol' / 'wlr-layer-shell-unstable-v1.xml',
+ output: 'wlr-layer-shell-unstable-v1-client-protocol.h',
+ command: [wayland_scanner, 'client-header', '@INPUT@', '@OUTPUT@'],
+)
+# Private code as a HEADER (not .c): the root project declares only C++, so a
+# generated .c has no compiler. wayland-scanner's private-code is plain C that
+# is valid C++; emitting it as a header lets the (single) C++ test TU #include
+# it exactly once. The output name ends in .h purely so meson treats it as a
+# header (no separate compile), not as a marshalling-code convention.
+layer_shell_client_code_h = custom_target(
+ 'wlr-layer-shell-client-code',
+ input: meson.project_source_root() / 'protocol' / 'wlr-layer-shell-unstable-v1.xml',
+ output: 'wlr-layer-shell-unstable-v1-client-protocol-code.h',
+ command: [wayland_scanner, 'private-code', '@INPUT@', '@OUTPUT@'],
+)
+
+# The layer-shell protocol's get_popup references xdg_popup, so its generated
+# marshalling tables reference xdg_popup_interface. We don't link a client-side
+# xdg-shell lib, so generate xdg-shell client code (header-form, same reason) to
+# supply that symbol. wayland-protocols ships the canonical xdg-shell.xml.
+wayland_protocols_dir = dependency('wayland-protocols').get_variable('pkgdatadir')
+xdg_shell_client_code_h = custom_target(
+ 'xdg-shell-client-code',
+ input: wayland_protocols_dir / 'stable' / 'xdg-shell' / 'xdg-shell.xml',
+ output: 'xdg-shell-client-protocol-code.h',
+ command: [wayland_scanner, 'private-code', '@INPUT@', '@OUTPUT@'],
+)
+
+ext_layer_shell_client_test = executable(
+ 'ext-layer-shell-client-tests',
+ 'tests/test_client.cpp',
+ layer_shell_client_h,
+ layer_shell_client_code_h,
+ xdg_shell_client_code_h,
+ dependencies: [ext_layer_shell_dep, wayland_client_dep, doctest_dep],
+)
+test(
+ 'ext-layer-shell-client',
+ ext_layer_shell_client_test,
+ suite: 'ext-layer-shell',
+ # A real socket handshake + cooperative event-loop pump; generous timeout so
+ # a slow CI box does not flake (the test fails fast on its own logic).
+ timeout: 60,
+)
diff --git a/packages/ext-layer-shell/src/ext_layer_shell.cpp b/packages/ext-layer-shell/src/ext_layer_shell.cpp
new file mode 100644
index 0000000..89a0012
--- /dev/null
+++ b/packages/ext-layer-shell/src/ext_layer_shell.cpp
@@ -0,0 +1,370 @@
+#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
+
+#include <unbox/ext-layer-shell/arrangement.hpp>
+#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/listener.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <algorithm>
+#include <memory>
+#include <stdexcept>
+#include <vector>
+
+// ext-layer-shell glue. The decision core (which edge a surface reserves, how
+// the usable area shrinks) lives in arrangement.hpp and is exercised without
+// wlroots; THIS file is the thin effectful edge that binds wlroots signals and
+// drives wlr_scene_layer_surface_v1_configure. The usable-area model in
+// arrangement.hpp mirrors what that helper mutates — we keep our own per-output
+// copy for bookkeeping and as the basis tiling (slice 7) will consume.
+
+namespace unbox::ext_layer_shell {
+namespace {
+
+using kernel::Host;
+using kernel::Listener;
+
+// Map a protocol layer to its kernel SceneLayer band. background/bottom/top/
+// overlay map 1:1; `normal` is toplevels-only and never a layer-shell band.
+auto band_for_layer(enum zwlr_layer_shell_v1_layer layer) -> kernel::SceneLayer {
+ switch (layer) {
+ case ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND:
+ return kernel::SceneLayer::background;
+ case ZWLR_LAYER_SHELL_V1_LAYER_BOTTOM:
+ return kernel::SceneLayer::bottom;
+ case ZWLR_LAYER_SHELL_V1_LAYER_TOP:
+ return kernel::SceneLayer::top;
+ case ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY:
+ return kernel::SceneLayer::overlay;
+ }
+ return kernel::SceneLayer::top; // unreachable: protocol validates the enum
+}
+
+class LayerShellExt;
+
+// One live layer surface: its scene node and the wlroots signal bindings.
+// Owned (unique_ptr) by LayerShellExt; destroyed when the wlroots layer surface
+// is destroyed (its own destroy handler erases it from the owner) or at
+// shutdown (reverse-declaration teardown of the owning extension).
+class LayerSurface {
+public:
+ LayerSurface(LayerShellExt& owner, wlr_layer_surface_v1* surface,
+ wlr_scene_layer_surface_v1* scene);
+
+ [[nodiscard]] auto wlr() const -> wlr_layer_surface_v1* { return surface_; }
+ [[nodiscard]] auto scene() const -> wlr_scene_layer_surface_v1* { return scene_; }
+ [[nodiscard]] auto output() const -> wlr_output* { return surface_->output; }
+
+private:
+ void update_keyboard_focus();
+
+ LayerShellExt& owner_;
+ wlr_layer_surface_v1* surface_; // borrow; kernel/wlroots-owned
+ wlr_scene_layer_surface_v1* scene_; // owned node, destroyed via destroy_
+ // Typed surface->scene-tree association (replaces the dead wlr_surface.data
+ // convention): lets ext-xdg-shell resolve popup parents via
+ // Host::scene_tree_for(). Declared AFTER scene_ so it tears down (releasing
+ // the map entry) before the node reference goes — reverse-declaration order.
+ kernel::SurfaceRegistration host_reg_;
+ Listener commit_;
+ Listener destroy_;
+ Listener new_popup_;
+};
+
+class LayerShellExt final : public kernel::Extension {
+public:
+ LayerShellExt() = default;
+
+ auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ host_ = &host;
+
+ // The global: version 5 (the vendored protocol XML / wlroots 0.20 cap).
+ shell_ = wlr_layer_shell_v1_create(host.display(), 5);
+ if (shell_ == nullptr) {
+ throw std::runtime_error(
+ "ext-layer-shell: wlr_layer_shell_v1_create failed");
+ }
+
+ new_surface_.connect(shell_->events.new_surface, [this](void* data) {
+ on_new_surface(static_cast<wlr_layer_surface_v1*>(data));
+ });
+
+ // Track outputs: assign one to outputless surfaces and re-arrange when
+ // the output set changes.
+ output_added_ = host.subscribe(
+ host.on_output_added(),
+ [this](const kernel::OutputEvent& e) { on_output_added(e.output); });
+ output_removed_ = host.subscribe(
+ host.on_output_removed(),
+ [this](const kernel::OutputEvent& e) { on_output_removed(e.output); });
+
+ // Seed outputs that ALREADY EXIST at activation. Server::create() starts
+ // the backend, so a nested/headless output is added during create() —
+ // BEFORE extensions activate at run(). on_output_added fires only for
+ // outputs added AFTER we subscribe above, so events-only tracking would
+ // miss the pre-existing one forever: an output-less layer surface (e.g.
+ // fuzzel, which passes nil output) would then get no output assigned, no
+ // wlr_scene_layer_surface_v1_configure, no configure event, and we'd
+ // close it. wlr_output_layout retains already-added outputs, so we
+ // enumerate them here. (Contract gap noted in the report: late
+ // subscribers miss kernel state; the kernel could replay or expose an
+ // outputs() borrow. Fixed within the unit with what exists today.)
+ wlr_output_layout_output* lo = nullptr;
+ wl_list_for_each(lo, &host.output_layout()->outputs, link) {
+ track_output(lo->output);
+ }
+ }
+
+ [[nodiscard]] auto host() -> Host& { return *host_; }
+
+ // Recompute `output`'s usable area from every live surface on it and push a
+ // configure to each. Called on commit and on output add/remove/change.
+ void arrange(wlr_output* output) {
+ if (output == nullptr || host_ == nullptr) {
+ return;
+ }
+ wlr_box full{};
+ wlr_output_layout_get_box(host_->output_layout(), output, &full);
+ if (wlr_box_empty(&full)) {
+ full = {.x = 0, .y = 0, .width = output->width, .height = output->height};
+ }
+ wlr_box usable = full;
+ for (const auto& ls : surfaces_) {
+ if (ls->output() == output) {
+ wlr_scene_layer_surface_v1_configure(ls->scene(), &full, &usable);
+ }
+ }
+ // Mirror the remaining usable area into our pure-core box (the basis a
+ // usable-area service would publish; deliberately not exported as a
+ // hook yet — see the report).
+ usable_area(output) = Box{usable.x, usable.y, usable.width, usable.height};
+ }
+
+ // Drop a surface from the owned set (called from its destroy handler as the
+ // LAST action — this deletes the LayerSurface).
+ void erase(LayerSurface* which) {
+ std::erase_if(surfaces_,
+ [which](const std::unique_ptr<LayerSurface>& p) {
+ return p.get() == which;
+ });
+ }
+
+private:
+ void on_new_surface(wlr_layer_surface_v1* surface) {
+ // Assign an output if the client did not request one (fuzzel passes
+ // nil): fall back to the first tracked output. If NO output exists yet,
+ // defer placement instead of closing the surface — wlroots requires an
+ // output set before the first configure, and the protocol guarantees
+ // the compositor will EVENTUALLY configure an unmapped surface. The
+ // surface is parked in pending_ and placed when an output appears.
+ if (surface->output == nullptr && outputs_.empty()) {
+ park_pending(surface);
+ return;
+ }
+ if (surface->output == nullptr) {
+ surface->output = outputs_.front();
+ }
+ place(surface);
+ }
+
+ // Create the scene node and the live LayerSurface for `surface` (which now
+ // has an output assigned). Its commit listener drives the first configure.
+ void place(wlr_layer_surface_v1* surface) {
+ wlr_scene_tree* parent =
+ host_->scene_layer(band_for_layer(surface->current.layer));
+ wlr_scene_layer_surface_v1* scene =
+ wlr_scene_layer_surface_v1_create(parent, surface);
+ if (scene == nullptr) {
+ wlr_layer_surface_v1_destroy(surface);
+ return;
+ }
+
+ // The LayerSurface registers surface->tree via Host::host_surface() in
+ // its constructor (the typed surface->scene-tree contract), so xdg
+ // popups parented to this layer surface resolve through
+ // Host::scene_tree_for() — no wlr_surface.data write here.
+ surfaces_.push_back(std::make_unique<LayerSurface>(*this, surface, scene));
+ }
+
+ // Park an output-less surface that arrived before any output exists. We
+ // hold only a destroy listener so a client that gives up before an output
+ // appears is dropped cleanly; placement happens in adopt_pending_surfaces.
+ void park_pending(wlr_layer_surface_v1* surface) {
+ auto pending = std::make_unique<Pending>();
+ pending->surface = surface;
+ Pending* raw = pending.get();
+ pending->destroy.connect(surface->events.destroy, [this, raw](void*) {
+ std::erase_if(pending_, [raw](const std::unique_ptr<Pending>& p) {
+ return p.get() == raw; // LAST action: deletes the Pending
+ });
+ });
+ pending_.push_back(std::move(pending));
+ }
+
+ // An output appeared: assign it to every parked surface and place them.
+ void adopt_pending_surfaces(wlr_output* output) {
+ if (pending_.empty()) {
+ return;
+ }
+ // Move the parked surfaces out first: place() binds a fresh destroy
+ // listener via LayerSurface, so the Pending's destroy listener must be
+ // gone before placement to avoid a double binding.
+ std::vector<wlr_layer_surface_v1*> ready;
+ for (auto& p : pending_) {
+ p->surface->output = output;
+ ready.push_back(p->surface);
+ }
+ pending_.clear(); // drops the parking destroy listeners
+ for (wlr_layer_surface_v1* s : ready) {
+ place(s);
+ }
+ }
+
+ // Start tracking `output` (idempotent — seeding at activate and a later
+ // on_output_added for the same output must not double-insert). Re-arranges
+ // the output so any surface already assigned to it (or pending placement)
+ // gets configured once it exists.
+ void track_output(wlr_output* output) {
+ if (output == nullptr) {
+ return;
+ }
+ if (std::find(outputs_.begin(), outputs_.end(), output) != outputs_.end()) {
+ return;
+ }
+ outputs_.push_back(output);
+ adopt_pending_surfaces(output);
+ arrange(output);
+ }
+
+ void on_output_added(wlr_output* output) { track_output(output); }
+
+ void on_output_removed(wlr_output* output) {
+ std::erase(outputs_, output);
+ usable_areas_.erase_output(output);
+ // Surfaces on a vanished output: close them. Snapshot first since each
+ // destroy handler erases from surfaces_.
+ std::vector<wlr_layer_surface_v1*> doomed;
+ for (const auto& ls : surfaces_) {
+ if (ls->output() == output) {
+ doomed.push_back(ls->wlr());
+ }
+ }
+ for (wlr_layer_surface_v1* s : doomed) {
+ wlr_layer_surface_v1_destroy(s);
+ }
+ }
+
+ // Per-output usable area (our pure-core mirror). N == #outputs (tiny); a
+ // flat pointer-keyed vector keeps the public header wlroots-free.
+ struct UsableEntry {
+ wlr_output* output;
+ Box area;
+ };
+ auto usable_area(wlr_output* o) -> Box& {
+ for (auto& e : usable_areas_.entries) {
+ if (e.output == o) {
+ return e.area;
+ }
+ }
+ usable_areas_.entries.push_back({o, Box{}});
+ return usable_areas_.entries.back().area;
+ }
+ struct UsableAreas {
+ std::vector<UsableEntry> entries;
+ void erase_output(wlr_output* o) {
+ std::erase_if(entries,
+ [o](const UsableEntry& e) { return e.output == o; });
+ }
+ };
+
+ const kernel::Manifest manifest_{
+ .id = "layer-shell", .tier = kernel::Tier::core, .depends_on = {}};
+
+ Host* host_ = nullptr;
+ wlr_layer_shell_v1* shell_ = nullptr;
+ Listener new_surface_;
+ kernel::Subscription output_added_;
+ kernel::Subscription output_removed_;
+
+ // A layer surface that arrived before any output existed. Held with only a
+ // destroy listener until an output appears (adopt_pending_surfaces), then
+ // placed. Owned; teardown drops the listener.
+ struct Pending {
+ wlr_layer_surface_v1* surface = nullptr; // borrow
+ Listener destroy;
+ };
+
+ std::vector<wlr_output*> outputs_; // borrows; tracked set
+ UsableAreas usable_areas_; // per-output mirror
+ std::vector<std::unique_ptr<Pending>> pending_; // owned; pre-output
+ std::vector<std::unique_ptr<LayerSurface>> surfaces_; // owned
+};
+
+// ---- LayerSurface ----------------------------------------------------------
+
+LayerSurface::LayerSurface(LayerShellExt& owner, wlr_layer_surface_v1* surface,
+ wlr_scene_layer_surface_v1* scene)
+ : owner_(owner), surface_(surface), scene_(scene) {
+ // Publish the typed surface->scene-tree association so xdg popups parented
+ // to this layer surface resolve to our tree via Host::scene_tree_for(). The
+ // RAII handle is a member; it unregisters when this LayerSurface dies.
+ host_reg_ = owner_.host().host_surface(surface_->surface, scene_->tree);
+
+ // Re-arrange this surface's output on every commit (covers the mandatory
+ // initial-commit configure and any later anchor/zone/size change), then
+ // re-evaluate keyboard focus.
+ commit_.connect(surface_->surface->events.commit, [this](void*) {
+ owner_.arrange(surface_->output);
+ update_keyboard_focus();
+ });
+
+ // The wlroots layer surface is about to be freed. Do NOT destroy the scene
+ // node here: wlr_scene_layer_surface_v1 installs its OWN layer_surface
+ // destroy listener that tears the scene tree down for us (confirmed by ASan
+ // — touching scene_->tree here is a use-after-free, since signal-emit order
+ // between our listener and wlroots' is unspecified). We only reclaim the
+ // area the surface reserved and erase ourselves. Copy what we need into
+ // locals FIRST: erase(this) deletes *this, after which no member (including
+ // owner_) may be touched, so we re-arrange through a captured owner ref.
+ destroy_.connect(surface_->events.destroy, [this](void*) {
+ LayerShellExt& owner = owner_;
+ wlr_output* out = surface_->output;
+ owner.erase(this); // deletes *this — LAST use of any member
+ owner.arrange(out);
+ });
+
+ // Popups parented to this layer surface resolve to our scene tree via the
+ // typed Host surface registration (host_reg_ above); ext-xdg-shell looks it
+ // up with Host::scene_tree_for() and wlroots' scene helper wires the popup
+ // nodes. Bound for completeness; no extra work here.
+ new_popup_.connect(surface_->events.new_popup, [](void*) {});
+}
+
+// Minimal v1 keyboard interactivity: focus an `exclusive` surface once mapped;
+// leave `none` alone. `on_demand` is deferred to slice 5's input routing.
+void LayerSurface::update_keyboard_focus() {
+ if (!surface_->surface->mapped) {
+ return;
+ }
+ if (surface_->current.keyboard_interactive !=
+ ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE) {
+ return;
+ }
+ wlr_seat* seat = owner_.host().seat();
+ wlr_keyboard* kbd = wlr_seat_get_keyboard(seat);
+ if (kbd != nullptr) {
+ wlr_seat_keyboard_notify_enter(seat, surface_->surface, kbd->keycodes,
+ kbd->num_keycodes, &kbd->modifiers);
+ } else {
+ wlr_seat_keyboard_notify_enter(seat, surface_->surface, nullptr, 0, nullptr);
+ }
+}
+
+} // namespace
+
+auto create() -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<LayerShellExt>();
+}
+
+} // namespace unbox::ext_layer_shell
diff --git a/packages/ext-layer-shell/tests/test_arrangement.cpp b/packages/ext-layer-shell/tests/test_arrangement.cpp
new file mode 100644
index 0000000..c5c047e
--- /dev/null
+++ b/packages/ext-layer-shell/tests/test_arrangement.cpp
@@ -0,0 +1,201 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include <unbox/ext-layer-shell/arrangement.hpp>
+
+using unbox::ext_layer_shell::apply_exclusive;
+using unbox::ext_layer_shell::Box;
+using unbox::ext_layer_shell::Edge;
+using unbox::ext_layer_shell::exclusive_edge;
+using unbox::ext_layer_shell::SurfaceState;
+namespace anchor = unbox::ext_layer_shell::anchor;
+
+namespace {
+
+// A 1920x1080 output at the layout origin, the canonical test stage.
+constexpr Box kOutput{0, 0, 1920, 1080};
+
+// Anchor a strip to a single edge plus both perpendicular edges (the common
+// full-width panel anchoring).
+constexpr std::uint32_t kTopStrip = anchor::top | anchor::left | anchor::right;
+constexpr std::uint32_t kBottomStrip = anchor::bottom | anchor::left | anchor::right;
+constexpr std::uint32_t kLeftStrip = anchor::left | anchor::top | anchor::bottom;
+constexpr std::uint32_t kRightStrip = anchor::right | anchor::top | anchor::bottom;
+
+} // namespace
+
+// ---- exclusive_edge: anchoring -> reserved edge -----------------------------
+
+TEST_CASE("non-positive exclusive zone reserves nothing") {
+ CHECK(exclusive_edge({.anchor = kTopStrip, .exclusive_zone = 0}) == Edge::none);
+ CHECK(exclusive_edge({.anchor = kTopStrip, .exclusive_zone = -1}) == Edge::none);
+}
+
+TEST_CASE("a full-width top strip reserves the top edge") {
+ CHECK(exclusive_edge({.anchor = kTopStrip, .exclusive_zone = 30}) == Edge::top);
+}
+
+TEST_CASE("each single-edge strip deduces its own edge") {
+ CHECK(exclusive_edge({.anchor = kTopStrip, .exclusive_zone = 1}) == Edge::top);
+ CHECK(exclusive_edge({.anchor = kBottomStrip, .exclusive_zone = 1}) == Edge::bottom);
+ CHECK(exclusive_edge({.anchor = kLeftStrip, .exclusive_zone = 1}) == Edge::left);
+ CHECK(exclusive_edge({.anchor = kRightStrip, .exclusive_zone = 1}) == Edge::right);
+}
+
+TEST_CASE("anchoring to a single bare edge reserves that edge") {
+ // Anchored only to top (no perpendicular edges) is still a meaningful strip.
+ CHECK(exclusive_edge({.anchor = anchor::top, .exclusive_zone = 5}) == Edge::top);
+ CHECK(exclusive_edge({.anchor = anchor::left, .exclusive_zone = 5}) == Edge::left);
+}
+
+TEST_CASE("anchoring to a bare corner reserves nothing (protocol rule)") {
+ CHECK(exclusive_edge({.anchor = anchor::top | anchor::left, .exclusive_zone = 9}) ==
+ Edge::none);
+ CHECK(exclusive_edge({.anchor = anchor::bottom | anchor::right, .exclusive_zone = 9}) ==
+ Edge::none);
+}
+
+TEST_CASE("anchoring to two PARALLEL edges reserves nothing") {
+ CHECK(exclusive_edge({.anchor = anchor::top | anchor::bottom, .exclusive_zone = 9}) ==
+ Edge::none);
+ CHECK(exclusive_edge({.anchor = anchor::left | anchor::right, .exclusive_zone = 9}) ==
+ Edge::none);
+}
+
+TEST_CASE("anchoring to all four edges reserves nothing") {
+ const std::uint32_t all = anchor::top | anchor::bottom | anchor::left | anchor::right;
+ CHECK(exclusive_edge({.anchor = all, .exclusive_zone = 9}) == Edge::none);
+}
+
+TEST_CASE("unanchored surface reserves nothing even with positive zone") {
+ CHECK(exclusive_edge({.anchor = 0, .exclusive_zone = 9}) == Edge::none);
+}
+
+// ---- exclusive_edge: explicit override (v5) ---------------------------------
+
+TEST_CASE("explicit exclusive_edge disambiguates a corner anchor") {
+ // Bottom-right corner: ambiguous by deduction, but an explicit edge the
+ // surface is anchored to resolves it.
+ SurfaceState s{.anchor = anchor::bottom | anchor::right,
+ .exclusive_zone = 12,
+ .exclusive_edge = Edge::bottom};
+ CHECK(exclusive_edge(s) == Edge::bottom);
+ s.exclusive_edge = Edge::right;
+ CHECK(exclusive_edge(s) == Edge::right);
+}
+
+TEST_CASE("explicit exclusive_edge not among anchored edges is ignored") {
+ // Anchored top-left only; asking to reserve the bottom edge is invalid.
+ SurfaceState s{.anchor = anchor::top | anchor::left,
+ .exclusive_zone = 12,
+ .exclusive_edge = Edge::bottom};
+ CHECK(exclusive_edge(s) == Edge::none);
+}
+
+// ---- apply_exclusive: single surface ----------------------------------------
+
+TEST_CASE("a non-reserving surface leaves the usable area untouched") {
+ const SurfaceState s{.anchor = kTopStrip, .exclusive_zone = 0};
+ CHECK(apply_exclusive(kOutput, s) == kOutput);
+}
+
+TEST_CASE("a top panel shrinks the usable area from the top") {
+ const SurfaceState s{.anchor = kTopStrip, .exclusive_zone = 30};
+ CHECK(apply_exclusive(kOutput, s) == Box{0, 30, 1920, 1050});
+}
+
+TEST_CASE("a bottom panel shrinks height but not the origin") {
+ const SurfaceState s{.anchor = kBottomStrip, .exclusive_zone = 40};
+ CHECK(apply_exclusive(kOutput, s) == Box{0, 0, 1920, 1040});
+}
+
+TEST_CASE("a left bar shifts x and shrinks width") {
+ const SurfaceState s{.anchor = kLeftStrip, .exclusive_zone = 50};
+ CHECK(apply_exclusive(kOutput, s) == Box{50, 0, 1870, 1080});
+}
+
+TEST_CASE("a right bar shrinks width but not the origin") {
+ const SurfaceState s{.anchor = kRightStrip, .exclusive_zone = 60};
+ CHECK(apply_exclusive(kOutput, s) == Box{0, 0, 1860, 1080});
+}
+
+TEST_CASE("the margin on the reserved edge adds to the reserved space") {
+ // Top panel with a 30px zone and a 10px top margin reserves 40px.
+ const SurfaceState s{
+ .anchor = kTopStrip, .exclusive_zone = 30, .margin_top = 10};
+ CHECK(apply_exclusive(kOutput, s) == Box{0, 40, 1920, 1040});
+}
+
+TEST_CASE("margins on non-reserved edges do not affect the usable area") {
+ const SurfaceState s{.anchor = kTopStrip,
+ .exclusive_zone = 30,
+ .margin_right = 100,
+ .margin_bottom = 100,
+ .margin_left = 100};
+ CHECK(apply_exclusive(kOutput, s) == Box{0, 30, 1920, 1050});
+}
+
+// ---- apply_exclusive: stacking several surfaces -----------------------------
+
+TEST_CASE("two perpendicular panels stack into a corner-reduced area") {
+ Box usable = kOutput;
+ usable = apply_exclusive(usable, {.anchor = kTopStrip, .exclusive_zone = 30});
+ usable = apply_exclusive(usable, {.anchor = kLeftStrip, .exclusive_zone = 50});
+ CHECK(usable == Box{50, 30, 1870, 1050});
+}
+
+TEST_CASE("two opposite panels reduce both ends of an axis") {
+ Box usable = kOutput;
+ usable = apply_exclusive(usable, {.anchor = kTopStrip, .exclusive_zone = 30});
+ usable = apply_exclusive(usable, {.anchor = kBottomStrip, .exclusive_zone = 40});
+ CHECK(usable == Box{0, 30, 1920, 1010});
+}
+
+TEST_CASE("four panels box in the usable area on every side") {
+ Box usable = kOutput;
+ usable = apply_exclusive(usable, {.anchor = kTopStrip, .exclusive_zone = 24});
+ usable = apply_exclusive(usable, {.anchor = kBottomStrip, .exclusive_zone = 24});
+ usable = apply_exclusive(usable, {.anchor = kLeftStrip, .exclusive_zone = 48});
+ usable = apply_exclusive(usable, {.anchor = kRightStrip, .exclusive_zone = 48});
+ CHECK(usable == Box{48, 24, 1824, 1032});
+}
+
+TEST_CASE("two stacked top panels each carve from the running usable area") {
+ Box usable = kOutput;
+ usable = apply_exclusive(usable, {.anchor = kTopStrip, .exclusive_zone = 30});
+ usable = apply_exclusive(usable, {.anchor = kTopStrip, .exclusive_zone = 20});
+ // Origin pushed down twice; height reduced by the sum.
+ CHECK(usable == Box{0, 50, 1920, 1030});
+}
+
+// ---- apply_exclusive: clamping / degenerate cases ---------------------------
+
+TEST_CASE("an over-large exclusive zone clamps the usable area to zero, not negative") {
+ const SurfaceState s{.anchor = kTopStrip, .exclusive_zone = 5000};
+ const Box out = apply_exclusive(kOutput, s);
+ CHECK(out.height == 0);
+ CHECK(out.width == 1920);
+ // y advanced by the full requested distance even though height clamped.
+ CHECK(out.y == 5000);
+}
+
+TEST_CASE("width clamps to zero on an over-large side reservation") {
+ const SurfaceState s{.anchor = kLeftStrip, .exclusive_zone = 5000};
+ const Box out = apply_exclusive(kOutput, s);
+ CHECK(out.width == 0);
+ CHECK(out.x == 5000);
+}
+
+TEST_CASE("a non-origin output box is reduced relative to its own origin") {
+ // Second monitor at x=1920.
+ const Box second{1920, 0, 1280, 1024};
+ const SurfaceState s{.anchor = kTopStrip, .exclusive_zone = 25};
+ CHECK(apply_exclusive(second, s) == Box{1920, 25, 1280, 999});
+}
+
+TEST_CASE("a -1 (stretch-over) surface never reduces the usable area") {
+ // Wallpaper/lockscreen anchored to all four with stretch-over zone.
+ const std::uint32_t all = anchor::top | anchor::bottom | anchor::left | anchor::right;
+ const SurfaceState s{.anchor = all, .exclusive_zone = -1};
+ CHECK(apply_exclusive(kOutput, s) == kOutput);
+}
diff --git a/packages/ext-layer-shell/tests/test_client.cpp b/packages/ext-layer-shell/tests/test_client.cpp
new file mode 100644
index 0000000..5c1d232
--- /dev/null
+++ b/packages/ext-layer-shell/tests/test_client.cpp
@@ -0,0 +1,174 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
+#include <unbox/kernel/server.hpp>
+
+#include <wayland-client.h>
+
+// The generated client header (and code) name a request argument `namespace` —
+// a C identifier but a C++ keyword. Same scoped-macro fix wlr.hpp documents for
+// the server side: rename it across exactly these two generated includes. The
+// only call that touches it (get_layer_surface's last arg) is a string literal
+// we pass positionally, so the rename never leaks into our own code.
+#define namespace namespace_
+#include "wlr-layer-shell-unstable-v1-client-protocol.h"
+// xdg-shell marshalling code: supplies xdg_popup_interface, referenced by the
+// layer-shell tables (get_popup). Code-form only; we use no xdg-shell requests.
+#include "xdg-shell-client-protocol-code.h"
+// The protocol marshalling tables/stubs (wayland-scanner private-code), emitted
+// as a header so it can be compiled by the C++ TU exactly once (see meson.build).
+#include "wlr-layer-shell-unstable-v1-client-protocol-code.h"
+#undef namespace
+
+#include <cstdlib>
+#include <cstring>
+#include <memory>
+#include <poll.h>
+
+// REGRESSION: a real wayland CLIENT (the fuzzel reproduction, in-process) binds
+// zwlr_layer_shell_v1, creates a layer surface with a NIL output, and MUST
+// receive a configure. Before the fix, ext-layer-shell's output tracking was
+// events-only: the headless output created during Server::create() (BEFORE
+// activation) was never tracked, so an output-less surface got no output, no
+// wlr_scene_layer_surface_v1_configure, no configure event — the exact symptom
+// in /tmp/opencode/fuzzel-trace.log (get_layer_surface(... nil ...) then a bare
+// closed() with no configure). This test would have caught it.
+//
+// Server and client share this single thread; we pump both loops cooperatively
+// (the standard libwayland prepare_read / read_events dance, non-blocking).
+
+namespace {
+
+struct Client {
+ wl_display* display = nullptr;
+ wl_registry* registry = nullptr;
+ wl_compositor* compositor = nullptr;
+ wl_output* output = nullptr;
+ zwlr_layer_shell_v1* layer_shell = nullptr;
+
+ wl_surface* surface = nullptr;
+ zwlr_layer_surface_v1* layer_surface = nullptr;
+
+ bool configured = false;
+ bool closed = false;
+ std::uint32_t configure_serial = 0;
+};
+
+void registry_global(void* data, wl_registry* reg, std::uint32_t name,
+ const char* iface, std::uint32_t version) {
+ auto* c = static_cast<Client*>(data);
+ if (std::strcmp(iface, wl_compositor_interface.name) == 0) {
+ c->compositor = static_cast<wl_compositor*>(
+ wl_registry_bind(reg, name, &wl_compositor_interface, 4));
+ } else if (std::strcmp(iface, wl_output_interface.name) == 0 &&
+ c->output == nullptr) {
+ c->output = static_cast<wl_output*>(
+ wl_registry_bind(reg, name, &wl_output_interface, version));
+ } else if (std::strcmp(iface, zwlr_layer_shell_v1_interface.name) == 0) {
+ c->layer_shell = static_cast<zwlr_layer_shell_v1*>(wl_registry_bind(
+ reg, name, &zwlr_layer_shell_v1_interface,
+ version < 4 ? version : 4));
+ }
+}
+void registry_global_remove(void*, wl_registry*, std::uint32_t) {}
+
+const wl_registry_listener kRegistryListener{registry_global,
+ registry_global_remove};
+
+void ls_configure(void* data, zwlr_layer_surface_v1* ls, std::uint32_t serial,
+ std::uint32_t, std::uint32_t) {
+ auto* c = static_cast<Client*>(data);
+ c->configured = true;
+ c->configure_serial = serial;
+ zwlr_layer_surface_v1_ack_configure(ls, serial);
+}
+void ls_closed(void* data, zwlr_layer_surface_v1*) {
+ static_cast<Client*>(data)->closed = true;
+}
+const zwlr_layer_surface_v1_listener kLayerSurfaceListener{ls_configure,
+ ls_closed};
+
+// Pump server and client once, without blocking the client read.
+void pump(unbox::kernel::Server& server, wl_display* client) {
+ wl_display_flush(client);
+ server.dispatch(5);
+
+ // Drain anything already queued, then do a guarded non-blocking read.
+ while (wl_display_prepare_read(client) != 0) {
+ wl_display_dispatch_pending(client);
+ }
+ wl_display_flush(client);
+
+ pollfd pfd{wl_display_get_fd(client), POLLIN, 0};
+ if (poll(&pfd, 1, 5) > 0 && (pfd.revents & POLLIN)) {
+ wl_display_read_events(client);
+ } else {
+ wl_display_cancel_read(client);
+ }
+ wl_display_dispatch_pending(client);
+}
+
+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); // output exists BEFORE activation
+ return unbox::kernel::Server::create({});
+}
+
+} // namespace
+
+TEST_CASE("a real client's nil-output layer surface receives a configure") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_layer_shell::create());
+ server->activate_extensions();
+ REQUIRE(!server->socket_name().empty());
+
+ Client c;
+ c.display = wl_display_connect(server->socket_name().c_str());
+ REQUIRE(c.display != nullptr);
+ c.registry = wl_display_get_registry(c.display);
+ wl_registry_add_listener(c.registry, &kRegistryListener, &c);
+
+ // Round 1: let the registry advertise globals and our binds reach the
+ // server (compositor, output, layer_shell).
+ for (int i = 0; i < 50 && (c.compositor == nullptr || c.layer_shell == nullptr);
+ ++i) {
+ pump(*server, c.display);
+ }
+ REQUIRE(c.compositor != nullptr);
+ REQUIRE(c.layer_shell != nullptr);
+
+ // Create a layer surface with a NIL output (exactly fuzzel's get_layer_surface
+ // call: output=nil, layer=overlay, namespace="launcher").
+ c.surface = wl_compositor_create_surface(c.compositor);
+ c.layer_surface = zwlr_layer_shell_v1_get_layer_surface(
+ c.layer_shell, c.surface, /*output=*/nullptr,
+ ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY, "launcher");
+ REQUIRE(c.layer_surface != nullptr);
+ zwlr_layer_surface_v1_add_listener(c.layer_surface, &kLayerSurfaceListener, &c);
+ zwlr_layer_surface_v1_set_size(c.layer_surface, 382, 386);
+ zwlr_layer_surface_v1_set_anchor(c.layer_surface, 0);
+ // The mandatory initial empty commit (no buffer): the compositor MUST reply
+ // with a configure.
+ wl_surface_commit(c.surface);
+
+ // Round 2: pump until the configure arrives (or we give up -> the bug).
+ for (int i = 0; i < 200 && !c.configured && !c.closed; ++i) {
+ pump(*server, c.display);
+ }
+
+ CHECK(c.configured); // the fix: a configure was sent
+ CHECK_FALSE(c.closed); // the bug: surface closed with no configure
+ CHECK(c.configure_serial != 0);
+
+ if (c.layer_surface != nullptr) {
+ zwlr_layer_surface_v1_destroy(c.layer_surface);
+ }
+ if (c.surface != nullptr) {
+ wl_surface_destroy(c.surface);
+ }
+ wl_display_flush(c.display);
+ pump(*server, c.display);
+ wl_display_disconnect(c.display);
+}
diff --git a/packages/ext-layer-shell/tests/test_glue.cpp b/packages/ext-layer-shell/tests/test_glue.cpp
new file mode 100644
index 0000000..6a1ac16
--- /dev/null
+++ b/packages/ext-layer-shell/tests/test_glue.cpp
@@ -0,0 +1,55 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
+#include <unbox/kernel/server.hpp>
+
+#include <cstdlib>
+#include <memory>
+
+// Glue tests — lenient, headless. The pure arrangement math is proven hard in
+// test_arrangement.cpp; here we only verify the extension installs, activates,
+// creates its global, drives the event loop, and shuts down cleanly under the
+// wlr headless backend (no GPU, no parent session).
+
+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-layer-shell installs, activates, and creates its global") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_layer_shell::create());
+ // Activation creates the wlr_layer_shell_v1 global on the display; a
+ // throwing activate() (e.g. create failure) would propagate here.
+ server->activate_extensions();
+ CHECK(!server->socket_name().empty());
+}
+
+TEST_CASE("ext-layer-shell dispatches and shuts down cleanly") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_layer_shell::create());
+ server->activate_extensions();
+ for (int i = 0; i < 5; ++i) {
+ CHECK(server->dispatch(10));
+ }
+ // Destruction runs the full shutdown sequence; the extension's RAII members
+ // (global listener, output subscriptions, scene nodes) release in reverse
+ // declaration order with no leaked listeners.
+}
+
+TEST_CASE("ext-layer-shell activates idempotently alongside the headless output") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_layer_shell::create());
+ server->activate_extensions();
+ server->activate_extensions(); // no-op second call
+ for (int i = 0; i < 3; ++i) {
+ CHECK(server->dispatch(10));
+ }
+}
diff --git a/packages/ext-xdg-shell/ext-xdg-shell.md b/packages/ext-xdg-shell/ext-xdg-shell.md
new file mode 100644
index 0000000..ce946ed
--- /dev/null
+++ b/packages/ext-xdg-shell/ext-xdg-shell.md
@@ -0,0 +1,77 @@
+# ext-xdg-shell — package notes
+
+**Tier:** core. **Manifest:** `{ id: "xdg-shell", tier: core, depends_on: {} }`.
+Recreates the kernel's former tinywl-shape window management as an extension,
+against the Host ABI alone. The xdg-shell v3 global is created HERE (the
+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.
+
+## Side-effect graph
+- **Creates:** the `wlr_xdg_shell` v3 global on `host.display()`.
+- **Subscribes (kernel catalogue):** `on_pointer_motion` (hit-test → seat
+ enter/motion, default xcursor over nothing, drives grabs), `on_pointer_button`
+ (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).
+- **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.
+- **Emits (exported `Event`s, adopt()ed):** `on_toplevel_mapped`,
+ `on_toplevel_unmapped`, `on_toplevel_focused`, each carrying a `Toplevel`
+ borrow.
+- **Provides (service):** `Service` (typed handle to the three Events).
+- **Registers (typed surface→tree contract):** each toplevel's and popup's
+ `wlr_surface` → its scene tree via `Host::host_surface()` (RAII handle held
+ in the entity); resolves popup parents via `Host::scene_tree_for()`.
+
+## Surface→scene-tree association (typed kernel contract)
+The old cross-unit `wlr_surface.data` / `wlr_xdg_surface.data` convention is
+DEAD. Cross-unit surface→tree coupling now routes through the kernel's typed
+`Host::host_surface()` / `Host::scene_tree_for()` registry. `new_popup`
+resolves its parent (our toplevel/ancestor popup OR a layer surface registered
+by ext-layer-shell) uniformly through `scene_tree_for()`. We still set
+`wlr_scene_tree.node.data = ToplevelEntry*` PRIVATELY — that is an intra-unit
+back-pointer for `toplevel_at`, which the registry contract explicitly permits;
+it is never read by another unit.
+
+## Gotchas the headers can't express
+- **All pointer forwarding is OURS.** The kernel forwards NOTHING to clients
+ (corrected host.hpp): this extension calls
+ `wlr_seat_pointer_notify_enter/motion/button/axis/frame` itself. Grabs
+ suppress the forward by simply not notifying while a move/resize is in flight.
+ (Forwarding button + axis was a real bug found hands-on in a nested session —
+ click-drag selection and wheel scroll in foot were dead without it.)
+- **Touch layout-origin-during-grab skew** (slice-2 parity): a touch point's
+ surface origin is captured at down-time and assumed stationary; if the
+ surface moves mid-touch (interactive grab) motion coords skew. Accepted until
+ slice 5.
+- **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 (button-down, client-requested-move/resize): it engages ONLY
+ while the button is held, every held motion moves/resizes (suppressing the
+ client pointer notify), and a button RELEASE always ends it. This kills the
+ user-observed bug where a titlebar drag didn't move while held but then
+ followed the cursor unclicked after release (the grab's lifetime had been
+ decoupled from the button; a late `request_move` could engage post-release).
+ The glue feeds press/release/request/motion in and executes the returned
+ action; the geometry (grab origin, resize box/edges) lives in the glue.
+- **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
new file mode 100644
index 0000000..815781d
--- /dev/null
+++ b/packages/ext-xdg-shell/include/unbox/ext-xdg-shell/ext_xdg_shell.hpp
@@ -0,0 +1,108 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+#include <unbox/kernel/hooks.hpp>
+
+#include <memory>
+#include <string_view>
+
+// ext-xdg-shell — window management as a CORE extension.
+//
+// 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.
+//
+// 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.
+
+namespace unbox::ext_xdg_shell {
+
+// ---- Toplevel: the opaque window handle carried by this extension's events --
+//
+// A borrow of one managed application window (xdg_toplevel). The pointer you
+// receive in an event payload is a BORROW valid ONLY until the matching
+// on_toplevel_unmapped fires for it (or, equivalently, until the underlying
+// xdg_toplevel is destroyed) — never store the raw Toplevel*; if you must
+// track a window across events, key your own map on identity captured during
+// on_toplevel_mapped and drop it on on_toplevel_unmapped. All methods are
+// valid only while the borrow is live, on the event-loop thread.
+class Toplevel {
+public:
+ virtual ~Toplevel() = default;
+ Toplevel(const Toplevel&) = delete;
+ auto operator=(const Toplevel&) -> Toplevel& = delete;
+
+ // The window's current xdg title, or "" if the client has set none. The
+ // returned view is valid only for the duration of the call (it aliases the
+ // client's title buffer); copy it if you need to keep it.
+ [[nodiscard]] virtual auto title() const -> std::string_view = 0;
+
+ // The window's xdg app_id (its application identity), or "" if unset. Same
+ // call-only lifetime as title().
+ [[nodiscard]] virtual auto app_id() const -> std::string_view = 0;
+
+ // Give this window keyboard focus and raise it within the normal layer
+ // (the click/tap-to-focus action, exposed so a taskbar entry can focus a
+ // window). No-op if it is no longer mapped.
+ virtual void focus() = 0;
+
+ // Ask the client to close this window (xdg toplevel close request). The
+ // client decides when/whether to honor it; the window stays valid until
+ // its own unmap/destroy then fires normally.
+ virtual void close() = 0;
+
+protected:
+ Toplevel() = default;
+};
+
+// ---- Exported event payload -------------------------------------------------
+//
+// Carries a borrow of the Toplevel the event concerns. Borrow lifetime is the
+// event-callback duration for *_focused, and "until the matching unmapped"
+// for *_mapped (see Toplevel above). Subscribe via your Host::subscribe so
+// your id is injected for error isolation.
+struct ToplevelEvent {
+ Toplevel* toplevel; // borrow; see Toplevel lifetime notes
+};
+
+// ---- The extension's exported hooks -----------------------------------------
+//
+// Fetch these through the service handle below (the typed cross-extension
+// coupling: a missing ext-xdg-shell is a link error on Service, never a string
+// lookup). The Events are adopt()ed by this extension in activate(), so a
+// throwing subscriber on one of them disables the SUBSCRIBER, not us.
+class Service {
+public:
+ virtual ~Service() = default;
+
+ // A toplevel just mapped (became visible) and was focused. The payload
+ // borrow stays valid until the matching on_toplevel_unmapped for it.
+ [[nodiscard]] virtual auto on_toplevel_mapped()
+ -> unbox::kernel::Event<const ToplevelEvent&>& = 0;
+
+ // A toplevel is unmapping (about to become invisible / be destroyed). The
+ // payload borrow is valid for THIS call only — drop any tracking of it now.
+ [[nodiscard]] virtual auto on_toplevel_unmapped()
+ -> 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.
+ [[nodiscard]] virtual auto on_toplevel_focused()
+ -> unbox::kernel::Event<const ToplevelEvent&>& = 0;
+
+protected:
+ Service() = default;
+};
+
+// ---- Factory ----------------------------------------------------------------
+//
+// Construct the extension; install() it into the Server (ownership transfer).
+// Construction is side-effect free — all wiring happens in activate(). The
+// manifest is { id: "xdg-shell", tier: core, depends_on: {} }.
+[[nodiscard]] auto make_extension() -> std::unique_ptr<unbox::kernel::Extension>;
+
+} // namespace unbox::ext_xdg_shell
diff --git a/packages/ext-xdg-shell/meson.build b/packages/ext-xdg-shell/meson.build
new file mode 100644
index 0000000..6fb1543
--- /dev/null
+++ b/packages/ext-xdg-shell/meson.build
@@ -0,0 +1,35 @@
+# ext-xdg-shell — window management as a core extension. Public headers (the
+# contract): include/unbox/ext-xdg-shell/. Consumes the kernel ABI only.
+
+ext_xdg_shell_inc = include_directories('include')
+
+ext_xdg_shell_lib = static_library(
+ 'unbox-ext-xdg-shell',
+ 'src/extension.cpp',
+ include_directories: ext_xdg_shell_inc,
+ dependencies: [kernel_dep],
+)
+
+# What consumers (host-bin, downstream slices) link against. The kernel's
+# wlroots/wayland propagation rides through kernel_dep; our public headers add
+# the include path. RMLUi stays kernel-private (we contribute no UI yet).
+ext_xdg_shell_dep = declare_dependency(
+ link_with: ext_xdg_shell_lib,
+ include_directories: ext_xdg_shell_inc,
+ dependencies: [kernel_dep],
+)
+
+# Tests, asymmetric: pure policy core (doctest, no wlroots) + a headless glue
+# smoke test (Server + activation probe). The test exe also compiles the glue
+# source directly so it can reach the private probe factory in src/.
+ext_xdg_shell_test = executable(
+ 'ext-xdg-shell-tests',
+ 'tests/test_policy.cpp',
+ 'tests/test_glue.cpp',
+ # `src` on the path so test_glue can reach the PRIVATE probe factory
+ # (src/probe.hpp) — a unit may read its own src/ (only OTHER units' src/ is
+ # off-limits). policy.hpp lives there too.
+ include_directories: [ext_xdg_shell_inc, include_directories('src')],
+ dependencies: [ext_xdg_shell_dep, doctest_dep],
+)
+test('ext-xdg-shell', ext_xdg_shell_test, suite: 'ext-xdg-shell')
diff --git a/packages/ext-xdg-shell/src/extension.cpp b/packages/ext-xdg-shell/src/extension.cpp
new file mode 100644
index 0000000..b200a39
--- /dev/null
+++ b/packages/ext-xdg-shell/src/extension.cpp
@@ -0,0 +1,634 @@
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
+
+#include "policy.hpp"
+#include "probe.hpp"
+
+#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/listener.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdint>
+#include <iterator>
+#include <list>
+#include <memory>
+#include <stdexcept>
+#include <string_view>
+#include <unordered_map>
+
+// The glue. Translates the kernel's input/output catalogue and the raw
+// xdg-shell signals into window-management policy, calls the pure core in
+// policy.hpp for decisions, and exports the typed Service/Events downstream
+// slices consume. Mirrors the former kernel toplevel.cpp/input.cpp tinywl
+// shape, re-expressed against the Host ABI.
+//
+// Everything runs on the single wl_event_loop thread. Every resource is a RAII
+// member of XdgShellExtension; teardown is reverse-declaration destruction (no
+// manual teardown lists — extension-agent.md).
+
+namespace unbox::ext_xdg_shell {
+namespace {
+
+using kernel::Host;
+using kernel::Listener;
+using kernel::Subscription;
+
+class XdgShellExtension;
+
+// One managed application window. Implements the public Toplevel handle; the
+// extension hands borrows of `Entry*` (upcast to Toplevel*) in event payloads.
+struct ToplevelEntry final : Toplevel {
+ XdgShellExtension* ext = nullptr;
+ wlr_xdg_toplevel* xdg_toplevel = nullptr;
+ wlr_scene_tree* scene_tree = nullptr;
+ bool mapped = false;
+
+ // Typed surface->scene-tree association (replaces the old .data
+ // convention); held here so it unregisters when the toplevel is destroyed.
+ kernel::SurfaceRegistration surface_reg;
+
+ Listener map;
+ Listener unmap;
+ Listener commit;
+ Listener destroy;
+ Listener request_move;
+ Listener request_resize;
+ Listener request_maximize;
+ Listener request_fullscreen;
+
+ // ---- public Toplevel contract ----
+ [[nodiscard]] auto title() const -> std::string_view override {
+ const char* t = xdg_toplevel != nullptr ? xdg_toplevel->title : nullptr;
+ return t != nullptr ? std::string_view{t} : std::string_view{};
+ }
+ [[nodiscard]] auto app_id() const -> std::string_view override {
+ const char* a = xdg_toplevel != nullptr ? xdg_toplevel->app_id : nullptr;
+ return a != nullptr ? std::string_view{a} : std::string_view{};
+ }
+ void focus() override; // defined after XdgShellExtension
+ void close() override {
+ if (xdg_toplevel != nullptr) {
+ wlr_xdg_toplevel_send_close(xdg_toplevel);
+ }
+ }
+};
+
+struct PopupEntry {
+ wlr_xdg_popup* xdg_popup = nullptr;
+ // Typed association for THIS popup's surface, so a nested popup parented to
+ // it resolves via Host::scene_tree_for().
+ kernel::SurfaceRegistration surface_reg;
+ Listener commit;
+ Listener destroy;
+};
+
+class XdgShellExtension final : public kernel::Extension, public ActivationProbe {
+public:
+ XdgShellExtension()
+ : manifest_{"xdg-shell", kernel::Tier::core, {}} {}
+
+ [[nodiscard]] auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ host_ = &host;
+ service_.ext = this;
+
+ // Export our hooks through the kernel's isolation registry so a
+ // throwing subscriber disables the SUBSCRIBER, not us.
+ host.adopt(on_mapped_);
+ host.adopt(on_unmapped_);
+ host.adopt(on_focused_);
+
+ // The xdg-shell v3 global lives HERE now (extension-creates-the-global
+ // split). wlr_xdg_shell is owned by the display; it dies with it.
+ xdg_shell_ = wlr_xdg_shell_create(host.display(), 3);
+ if (xdg_shell_ == nullptr) {
+ throw std::runtime_error("ext-xdg-shell: wlr_xdg_shell_create failed");
+ }
+ new_toplevel_.connect(xdg_shell_->events.new_toplevel, [this](void* data) {
+ handle_new_toplevel(static_cast<wlr_xdg_toplevel*>(data));
+ });
+ new_popup_.connect(xdg_shell_->events.new_popup, [this](void* data) {
+ handle_new_popup(static_cast<wlr_xdg_popup*>(data));
+ });
+
+ // Pointer routing is ENTIRELY ours: the kernel moves the cursor and
+ // emits, but forwards NOTHING to clients (host.hpp catalogue). We
+ // hit-test the scene and call wlr_seat_pointer_notify_enter/motion/
+ // button/axis/frame, plus focus + grab handling. Grabs suppress the
+ // forward simply by not notifying while a move/resize is in flight.
+ sub_motion_ = host.subscribe(host.on_pointer_motion(),
+ [this](const kernel::PointerMotionEvent& e) {
+ process_pointer_motion(e.lx, e.ly, e.time_msec);
+ });
+ sub_button_ = host.subscribe(host.on_pointer_button(),
+ [this](const kernel::PointerButtonEvent& e) {
+ process_pointer_button(e);
+ });
+ sub_axis_ = host.subscribe(host.on_pointer_axis(),
+ [this](const kernel::PointerAxisEvent& e) {
+ process_pointer_axis(e);
+ });
+ sub_frame_ = host.subscribe(host.on_pointer_frame(),
+ [this] { wlr_seat_pointer_notify_frame(host_->seat()); });
+
+ // Touch (slice-2 parity): tap-to-focus + down/up/motion routing.
+ sub_touch_down_ = host.subscribe(host.on_touch_down(),
+ [this](const kernel::TouchDownEvent& e) {
+ process_touch_down(e);
+ });
+ sub_touch_motion_ = host.subscribe(host.on_touch_motion(),
+ [this](const kernel::TouchMotionEvent& e) {
+ process_touch_motion(e);
+ });
+ sub_touch_up_ = host.subscribe(host.on_touch_up(),
+ [this](const kernel::TouchUpEvent& e) {
+ process_touch_up(e);
+ });
+ sub_touch_frame_ = host.subscribe(host.on_touch_frame(),
+ [this] { wlr_seat_touch_notify_frame(host_->seat()); });
+
+ // Keyboard policy: consume Alt+Escape / Alt+F1, pass everything else.
+ sub_key_ = host.subscribe(host.key_filter(), [this](kernel::KeyEvent ev) {
+ return filter_key(ev);
+ });
+
+ // Register the typed service so downstream slices link against us.
+ host.provide_service<Service>(&service_);
+
+ activated_ = true;
+ }
+
+ // Probe used by the headless integration test to confirm activation ran.
+ [[nodiscard]] auto activated() const -> bool override { return activated_; }
+
+ // ---- exported hooks (also reached through service_) ----
+ [[nodiscard]] auto on_mapped() -> kernel::Event<const ToplevelEvent&>& { return on_mapped_; }
+ [[nodiscard]] auto on_unmapped() -> kernel::Event<const ToplevelEvent&>& {
+ return on_unmapped_;
+ }
+ [[nodiscard]] auto on_focused() -> kernel::Event<const ToplevelEvent&>& { return on_focused_; }
+
+ // ---- focus / hit-test (used by ToplevelEntry::focus and the glue) ----
+ void focus_toplevel(ToplevelEntry* entry) {
+ if (entry == nullptr || !entry->mapped) {
+ return;
+ }
+ wlr_seat* seat = host_->seat();
+ wlr_surface* surface = entry->xdg_toplevel->base->surface;
+ wlr_surface* prev = seat->keyboard_state.focused_surface;
+ if (prev == surface) {
+ return;
+ }
+ if (prev != nullptr) {
+ if (wlr_xdg_toplevel* p = wlr_xdg_toplevel_try_from_wlr_surface(prev)) {
+ wlr_xdg_toplevel_set_activated(p, false);
+ }
+ }
+ 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,
+ &kb->modifiers);
+ }
+ emit(on_focused_, entry);
+ }
+
+private:
+ // ---- toplevel lifecycle ----
+ void handle_new_toplevel(wlr_xdg_toplevel* xdg_toplevel) {
+ auto owned = std::make_unique<ToplevelEntry>();
+ ToplevelEntry* entry = owned.get();
+ entry->ext = this;
+ entry->xdg_toplevel = xdg_toplevel;
+ entry->scene_tree = wlr_scene_xdg_surface_create(
+ host_->scene_layer(kernel::SceneLayer::normal), xdg_toplevel->base);
+ // PRIVATE bookkeeping: our own hit-test recovers the entry from the
+ // tree node's data (an intra-unit use of .data, which the registry
+ // contract explicitly still permits). The CROSS-UNIT surface->tree
+ // coupling goes through the typed registry below, never .data.
+ entry->scene_tree->node.data = entry;
+ // Typed surface->scene-tree association so popups (ours or descendants)
+ // resolve this toplevel's tree via Host::scene_tree_for().
+ entry->surface_reg =
+ host_->host_surface(xdg_toplevel->base->surface, entry->scene_tree);
+ toplevels_.emplace(xdg_toplevel, std::move(owned));
+
+ 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);
+ });
+ entry->unmap.connect(xdg_toplevel->base->surface->events.unmap, [this, entry](void*) {
+ emit(on_unmapped_, entry);
+ if (entry == grabbed_) {
+ grab_.on_grab_target_lost();
+ 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) {
+ // 0x0 configure: the client picks its own dimensions (tinywl).
+ wlr_xdg_toplevel_set_size(entry->xdg_toplevel, 0, 0);
+ }
+ });
+ entry->destroy.connect(xdg_toplevel->events.destroy, [this, entry](void*) {
+ // Last action: erases `entry` (and its Listeners with it).
+ toplevels_.erase(entry->xdg_toplevel);
+ });
+ entry->request_move.connect(xdg_toplevel->events.request_move, [this, entry](void*) {
+ begin_move(entry);
+ });
+ entry->request_resize.connect(xdg_toplevel->events.request_resize,
+ [this, entry](void* data) {
+ const auto* ev =
+ static_cast<wlr_xdg_toplevel_resize_event*>(data);
+ begin_resize(entry, ev->edges);
+ });
+ entry->request_maximize.connect(xdg_toplevel->events.request_maximize, [entry](void*) {
+ // Unsupported, but xdg-shell demands a configure reply.
+ if (entry->xdg_toplevel->base->initialized) {
+ wlr_xdg_surface_schedule_configure(entry->xdg_toplevel->base);
+ }
+ });
+ entry->request_fullscreen.connect(xdg_toplevel->events.request_fullscreen,
+ [entry](void*) {
+ if (entry->xdg_toplevel->base->initialized) {
+ wlr_xdg_surface_schedule_configure(
+ entry->xdg_toplevel->base);
+ }
+ });
+ }
+
+ // ---- popup lifecycle (typed surface->tree registry; .data convention dead)
+ void handle_new_popup(wlr_xdg_popup* xdg_popup) {
+ auto owned = std::make_unique<PopupEntry>();
+ PopupEntry* popup = owned.get();
+ popup->xdg_popup = xdg_popup;
+ popups_.emplace(xdg_popup, std::move(owned));
+
+ // Parent scene tree resolved through the typed kernel contract — works
+ // uniformly for an xdg parent (our toplevel/ancestor popup) AND a
+ // non-xdg parent (a layer surface registered by ext-layer-shell). No
+ // wlr_surface.data / wlr_xdg_surface.data agreement anymore.
+ wlr_scene_tree* parent_tree =
+ xdg_popup->parent != nullptr ? host_->scene_tree_for(xdg_popup->parent) : nullptr;
+ if (parent_tree != nullptr) {
+ wlr_scene_tree* popup_tree =
+ wlr_scene_xdg_surface_create(parent_tree, xdg_popup->base);
+ // Register this popup's surface so a nested popup parented to it can
+ // resolve through the same typed contract.
+ popup->surface_reg =
+ host_->host_surface(xdg_popup->base->surface, popup_tree);
+ }
+
+ popup->commit.connect(xdg_popup->base->surface->events.commit, [popup](void*) {
+ if (popup->xdg_popup->base->initial_commit) {
+ wlr_xdg_surface_schedule_configure(popup->xdg_popup->base);
+ }
+ });
+ popup->destroy.connect(xdg_popup->events.destroy, [this, popup](void*) {
+ // Last action: erases `popup` (and its Listeners with it).
+ popups_.erase(popup->xdg_popup);
+ });
+ }
+
+ // ---- scene hit-test (mirrors the former kernel toplevel_at) ----
+ auto toplevel_at(double lx, double ly, wlr_surface** surface, double* sx, double* sy)
+ -> ToplevelEntry* {
+ wlr_scene_node* node = wlr_scene_node_at(&host_->scene()->tree.node, lx, ly, sx, sy);
+ if (node == nullptr || node->type != WLR_SCENE_NODE_BUFFER) {
+ return nullptr;
+ }
+ wlr_scene_buffer* scene_buffer = wlr_scene_buffer_from_node(node);
+ wlr_scene_surface* scene_surface = wlr_scene_surface_try_from_buffer(scene_buffer);
+ if (scene_surface == nullptr) {
+ return nullptr;
+ }
+ *surface = scene_surface->surface;
+ // Walk up to the tree whose data we set: the ToplevelEntry root.
+ wlr_scene_tree* tree = node->parent;
+ while (tree != nullptr && tree->node.data == nullptr) {
+ tree = tree->node.parent;
+ }
+ if (tree == nullptr) {
+ return nullptr;
+ }
+ // Only our toplevel trees carry a ToplevelEntry* (popups carry a
+ // wlr_scene_tree* in base->data, not node.data, so they read null and
+ // are skipped here — same as the former kernel).
+ return static_cast<ToplevelEntry*>(tree->node.data);
+ }
+
+ // ---- interactive grabs ----
+ // Tear down the grab: clear the grab target and restore the default cursor.
+ // Called when the pure GrabMachine reports the grab ended (button release)
+ // or the grabbed toplevel vanished.
+ void end_grab() {
+ grabbed_ = nullptr;
+ wlr_cursor_set_xcursor(host_->cursor(), host_->cursor_manager(), "default");
+ }
+
+ // The client requested an interactive move. The pure machine decides
+ // whether a grab actually engages (only while the triggering button is
+ // held — a request with no button down is ignored, which is the fix for
+ // the "drags without clicking after release" bug). Geometry is captured
+ // only when the grab truly engages.
+ void begin_move(ToplevelEntry* entry) {
+ if (!grab_.on_request_move()) {
+ return; // no button held: do not start an unclicked drag
+ }
+ wlr_cursor* cursor = host_->cursor();
+ grabbed_ = entry;
+ grab_x_ = cursor->x - entry->scene_tree->node.x;
+ grab_y_ = cursor->y - entry->scene_tree->node.y;
+ }
+
+ void begin_resize(ToplevelEntry* entry, std::uint32_t edges) {
+ if (!grab_.on_request_resize()) {
+ return;
+ }
+ wlr_cursor* cursor = host_->cursor();
+ grabbed_ = entry;
+ wlr_box* geo = &entry->xdg_toplevel->base->geometry;
+ const double border_x = (entry->scene_tree->node.x + geo->x) +
+ ((edges & WLR_EDGE_RIGHT) != 0 ? geo->width : 0);
+ const double border_y = (entry->scene_tree->node.y + geo->y) +
+ ((edges & WLR_EDGE_BOTTOM) != 0 ? geo->height : 0);
+ grab_x_ = cursor->x - border_x;
+ grab_y_ = cursor->y - border_y;
+ grab_geobox_ = *geo;
+ grab_geobox_.x += entry->scene_tree->node.x;
+ grab_geobox_.y += entry->scene_tree->node.y;
+ resize_edges_ = edges;
+ }
+
+ void process_cursor_move() {
+ wlr_cursor* cursor = host_->cursor();
+ wlr_scene_node_set_position(&grabbed_->scene_tree->node,
+ static_cast<int>(cursor->x - grab_x_),
+ static_cast<int>(cursor->y - grab_y_));
+ }
+
+ void process_cursor_resize() {
+ wlr_cursor* cursor = host_->cursor();
+ const double border_x = cursor->x - grab_x_;
+ const double border_y = cursor->y - grab_y_;
+ int new_left = grab_geobox_.x;
+ int new_right = grab_geobox_.x + grab_geobox_.width;
+ int new_top = grab_geobox_.y;
+ int new_bottom = grab_geobox_.y + grab_geobox_.height;
+
+ if ((resize_edges_ & WLR_EDGE_TOP) != 0) {
+ new_top = static_cast<int>(border_y);
+ if (new_top >= new_bottom) {
+ new_top = new_bottom - 1;
+ }
+ } else if ((resize_edges_ & WLR_EDGE_BOTTOM) != 0) {
+ new_bottom = static_cast<int>(border_y);
+ if (new_bottom <= new_top) {
+ new_bottom = new_top + 1;
+ }
+ }
+ if ((resize_edges_ & WLR_EDGE_LEFT) != 0) {
+ new_left = static_cast<int>(border_x);
+ if (new_left >= new_right) {
+ new_left = new_right - 1;
+ }
+ } else if ((resize_edges_ & WLR_EDGE_RIGHT) != 0) {
+ new_right = static_cast<int>(border_x);
+ if (new_right <= new_left) {
+ new_right = new_left + 1;
+ }
+ }
+ wlr_box* geo = &grabbed_->xdg_toplevel->base->geometry;
+ wlr_scene_node_set_position(&grabbed_->scene_tree->node, new_left - geo->x,
+ new_top - geo->y);
+ wlr_xdg_toplevel_set_size(grabbed_->xdg_toplevel, new_right - new_left,
+ new_bottom - new_top);
+ }
+
+ // ---- pointer routing ----
+ void process_pointer_motion(double lx, double ly, std::uint32_t time_msec) {
+ switch (grab_.on_motion()) {
+ case policy::GrabAction::move_toplevel:
+ process_cursor_move();
+ return; // suppress client passthrough during a grab
+ case policy::GrabAction::resize_toplevel:
+ process_cursor_resize();
+ return;
+ case policy::GrabAction::end_grab:
+ case policy::GrabAction::none:
+ break; // passthrough below
+ }
+ double sx = 0;
+ double sy = 0;
+ wlr_surface* surface = nullptr;
+ ToplevelEntry* entry = toplevel_at(lx, ly, &surface, &sx, &sy);
+ if (entry == nullptr) {
+ // Over nothing: draw the default cursor ourselves.
+ wlr_cursor_set_xcursor(host_->cursor(), host_->cursor_manager(), "default");
+ }
+ wlr_seat* seat = host_->seat();
+ if (surface != nullptr) {
+ wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
+ wlr_seat_pointer_notify_motion(seat, time_msec, sx, sy);
+ } else {
+ wlr_seat_pointer_clear_focus(seat);
+ }
+ }
+
+ void process_pointer_button(const kernel::PointerButtonEvent& e) {
+ // Forwarding the button to the focused client is OURS (host.hpp: the
+ // kernel forwards nothing). Feed the press/release to the pure grab
+ // machine FIRST: it tracks button-down (so a later request_move only
+ // grabs while held) and reports when a release ends a grab.
+ const bool was_grabbing = grab_.grabbing();
+ const policy::GrabAction action = grab_.on_button(e.pressed);
+ if (action == policy::GrabAction::end_grab) {
+ end_grab(); // release ended the grab; consume it, no client notify
+ return;
+ }
+ if (was_grabbing) {
+ return; // still grabbing (shouldn't happen on a button, but be safe)
+ }
+
+ const auto state =
+ e.pressed ? WL_POINTER_BUTTON_STATE_PRESSED : WL_POINTER_BUTTON_STATE_RELEASED;
+ wlr_seat_pointer_notify_button(host_->seat(), e.time_msec, e.button, state);
+ if (e.pressed) {
+ // Click-to-focus on press.
+ double sx = 0;
+ double sy = 0;
+ wlr_surface* surface = nullptr;
+ focus_toplevel(toplevel_at(e.lx, e.ly, &surface, &sx, &sy));
+ }
+ }
+
+ void process_pointer_axis(const kernel::PointerAxisEvent& e) {
+ // Scroll forwarding is OURS (host.hpp: kernel forwards nothing).
+ // Suppress during a grab, consistent with button/motion.
+ if (grab_.grabbing()) {
+ return;
+ }
+ wlr_seat_pointer_notify_axis(host_->seat(), e.time_msec, e.orientation, e.delta,
+ e.delta_discrete, e.source,
+ WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL);
+ }
+
+ // ---- touch routing (slice-2 parity) ----
+ void process_touch_down(const kernel::TouchDownEvent& e) {
+ double sx = 0;
+ double sy = 0;
+ wlr_surface* surface = nullptr;
+ ToplevelEntry* entry = toplevel_at(e.lx, e.ly, &surface, &sx, &sy);
+ if (entry != nullptr) {
+ focus_toplevel(entry); // tap raises + focuses
+ }
+ if (surface != nullptr) {
+ // Record the surface's layout origin (lx - sx) so motion can derive
+ // surface-local coords. Known layout-origin-during-grab skew is
+ // accepted until slice 5 (see package doc).
+ touch_points_.insert_or_assign(e.touch_id,
+ TouchPoint{surface, e.lx - sx, e.ly - sy});
+ wlr_seat_touch_notify_down(host_->seat(), surface, e.time_msec, e.touch_id, sx, sy);
+ }
+ }
+
+ void process_touch_motion(const kernel::TouchMotionEvent& e) {
+ auto it = touch_points_.find(e.touch_id);
+ if (it == touch_points_.end()) {
+ return; // down landed on no surface; nothing grabbed
+ }
+ wlr_seat_touch_notify_motion(host_->seat(), e.time_msec, e.touch_id,
+ e.lx - it->second.origin_x, e.ly - it->second.origin_y);
+ }
+
+ void process_touch_up(const kernel::TouchUpEvent& e) {
+ touch_points_.erase(e.touch_id);
+ wlr_seat_touch_notify_up(host_->seat(), e.time_msec, e.touch_id);
+ }
+
+ // ---- 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);
+ }
+
+ // ---- the Service the public header exposes ----
+ struct ServiceImpl final : Service {
+ XdgShellExtension* ext = nullptr;
+ [[nodiscard]] auto on_toplevel_mapped()
+ -> kernel::Event<const ToplevelEvent&>& override {
+ return ext->on_mapped();
+ }
+ [[nodiscard]] auto on_toplevel_unmapped()
+ -> kernel::Event<const ToplevelEvent&>& override {
+ return ext->on_unmapped();
+ }
+ [[nodiscard]] auto on_toplevel_focused()
+ -> kernel::Event<const ToplevelEvent&>& override {
+ return ext->on_focused();
+ }
+ };
+
+ struct TouchPoint {
+ wlr_surface* surface = nullptr;
+ double origin_x = 0.0;
+ double origin_y = 0.0;
+ };
+
+ kernel::Manifest manifest_;
+ Host* host_ = nullptr;
+ wlr_xdg_shell* xdg_shell_ = nullptr;
+ bool activated_ = false;
+
+ // Exported hooks (adopt()ed; stable members — never moved).
+ kernel::Event<const ToplevelEvent&> on_mapped_;
+ kernel::Event<const ToplevelEvent&> on_unmapped_;
+ kernel::Event<const ToplevelEvent&> on_focused_;
+ ServiceImpl service_{};
+
+ // 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.
+ policy::GrabMachine grab_;
+ ToplevelEntry* grabbed_ = nullptr;
+ double grab_x_ = 0.0;
+ double grab_y_ = 0.0;
+ wlr_box grab_geobox_{};
+ std::uint32_t resize_edges_ = 0;
+
+ // Touch implicit grabs (touch_id -> origin surface + layout origin).
+ std::unordered_map<std::int32_t, TouchPoint> touch_points_;
+
+ // Raw xdg-shell signal listeners (RAII).
+ Listener new_toplevel_;
+ Listener new_popup_;
+
+ // Kernel-catalogue subscriptions (RAII; dropped on destruction).
+ Subscription sub_motion_;
+ Subscription sub_button_;
+ Subscription sub_axis_;
+ Subscription sub_frame_;
+ Subscription sub_touch_down_;
+ Subscription sub_touch_motion_;
+ Subscription sub_touch_up_;
+ Subscription sub_touch_frame_;
+ Subscription sub_key_;
+
+ friend struct ServiceImpl;
+};
+
+void ToplevelEntry::focus() { ext->focus_toplevel(this); }
+
+} // namespace
+
+auto make_extension() -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<XdgShellExtension>();
+}
+
+auto make_extension_with_probe() -> ExtensionWithProbe {
+ auto ext = std::make_unique<XdgShellExtension>();
+ ActivationProbe* probe = ext.get();
+ return {std::move(ext), probe};
+}
+
+} // namespace unbox::ext_xdg_shell
diff --git a/packages/ext-xdg-shell/src/policy.hpp b/packages/ext-xdg-shell/src/policy.hpp
new file mode 100644
index 0000000..309a435
--- /dev/null
+++ b/packages/ext-xdg-shell/src/policy.hpp
@@ -0,0 +1,173 @@
+#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.
+
+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
+// window while the button is held, but after releasing, unclicked motion drags
+// it." That symptom is a grab whose lifetime is decoupled from the button: the
+// grab outlives the press (so post-release motion still moves) and/or is not
+// actually engaged while the button is down.
+//
+// The fix is to make the grab a deterministic function of two facts the glue
+// feeds in — whether the pointer button is currently DOWN, and whether the
+// client has requested an interactive move/resize — and to gate every action on
+// them. Invariants this machine guarantees:
+// * A grab is ACTIVE iff a move/resize was requested AND the button is still
+// down. Motion moves/resizes the toplevel ONLY while active (and the glue
+// suppresses the client pointer notify then).
+// * A button RELEASE always ends the grab and restores passthrough — no
+// matter when the request arrived relative to the press/release.
+// * A move/resize request that arrives while NO button is down does NOT
+// engage a grab (so a stale/late request can never start an unclicked drag).
+// This is pure logic; the glue calls process_cursor_move/resize and the
+// seat/cursor effects when ask() says so.
+
+enum class GrabMode { none, move, resize };
+
+// What the glue should DO after feeding an event in.
+enum class GrabAction {
+ none, // nothing to do
+ move_toplevel, // run process_cursor_move (motion while move-grabbing)
+ resize_toplevel, // run process_cursor_resize (motion while resize-grabbing)
+ end_grab, // grab ended: reset cursor mode + restore default cursor
+};
+
+class GrabMachine {
+public:
+ [[nodiscard]] auto mode() const -> GrabMode { return mode_; }
+ [[nodiscard]] auto grabbing() const -> bool { return mode_ != GrabMode::none; }
+ [[nodiscard]] auto button_down() const -> bool { return button_down_; }
+
+ // The pointer button (the one that drives grabs) went down/up. A release
+ // ALWAYS tears down any active grab.
+ auto on_button(bool pressed) -> GrabAction {
+ button_down_ = pressed;
+ if (!pressed && mode_ != GrabMode::none) {
+ mode_ = GrabMode::none;
+ return GrabAction::end_grab;
+ }
+ return GrabAction::none;
+ }
+
+ // The client requested an interactive move/resize. Only engages while the
+ // button is actually held (a request with no button down is ignored — it
+ // would otherwise become the unclicked-drag bug).
+ auto on_request_move() -> bool {
+ if (!button_down_) {
+ return false;
+ }
+ mode_ = GrabMode::move;
+ return true;
+ }
+ auto on_request_resize() -> bool {
+ if (!button_down_) {
+ return false;
+ }
+ mode_ = GrabMode::resize;
+ return true;
+ }
+
+ // Pointer motion. Returns the action the glue must perform.
+ [[nodiscard]] auto on_motion() const -> GrabAction {
+ switch (mode_) {
+ case GrabMode::move:
+ return GrabAction::move_toplevel;
+ case GrabMode::resize:
+ return GrabAction::resize_toplevel;
+ case GrabMode::none:
+ return GrabAction::none; // passthrough: glue routes to the client
+ }
+ return GrabAction::none;
+ }
+
+ // The grabbed toplevel went away (unmap/destroy): drop the grab silently.
+ void on_grab_target_lost() {
+ mode_ = GrabMode::none;
+ }
+
+private:
+ GrabMode mode_ = GrabMode::none;
+ bool button_down_ = false;
+};
+
+// 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/src/probe.hpp b/packages/ext-xdg-shell/src/probe.hpp
new file mode 100644
index 0000000..281293c
--- /dev/null
+++ b/packages/ext-xdg-shell/src/probe.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <memory>
+
+// Test-only probe surface (PRIVATE — src/, never part of the contract). The
+// headless integration test needs to assert activate() actually ran on the
+// concrete extension object; the public make_extension() hides the type behind
+// kernel::Extension. This factory hands back the same Extension plus a borrowed
+// probe pointer the test can poll. Glue/shell test convenience only.
+
+namespace unbox::ext_xdg_shell {
+
+// A non-owning view onto the live extension for tests. Valid as long as the
+// returned unique_ptr (and thus the extension) is alive.
+class ActivationProbe {
+public:
+ virtual ~ActivationProbe() = default;
+ // True once activate() completed (global created, hooks/subscriptions
+ // wired, service registered).
+ [[nodiscard]] virtual auto activated() const -> bool = 0;
+};
+
+struct ExtensionWithProbe {
+ std::unique_ptr<unbox::kernel::Extension> extension; // install() this
+ ActivationProbe* probe = nullptr; // borrow into the above
+};
+
+// Same extension as make_extension(), but also yields a probe borrow.
+[[nodiscard]] auto make_extension_with_probe() -> ExtensionWithProbe;
+
+} // namespace unbox::ext_xdg_shell
diff --git a/packages/ext-xdg-shell/tests/test_glue.cpp b/packages/ext-xdg-shell/tests/test_glue.cpp
new file mode 100644
index 0000000..44d945e
--- /dev/null
+++ b/packages/ext-xdg-shell/tests/test_glue.cpp
@@ -0,0 +1,53 @@
+#include <doctest/doctest.h>
+
+#include "probe.hpp"
+
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
+#include <unbox/kernel/server.hpp>
+
+#include <cstdlib>
+#include <memory>
+
+// Glue smoke test on the wlr headless backend: create a Server, install the
+// extension, activate, dispatch a few turns, assert activation actually ran
+// (probe), and exercise clean shutdown on destruction. Lenient by design
+// (AGENTS.md testing policy) — the heavy correctness lives in test_policy.cpp.
+
+namespace {
+
+auto make_headless_server() -> std::unique_ptr<unbox::kernel::Server> {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+ return unbox::kernel::Server::create({});
+}
+
+} // namespace
+
+TEST_CASE("ext-xdg-shell activates on a headless server and runs clean") {
+ auto server = make_headless_server();
+
+ auto with_probe = unbox::ext_xdg_shell::make_extension_with_probe();
+ auto* probe = with_probe.probe;
+ REQUIRE(probe != nullptr);
+ CHECK_FALSE(probe->activated()); // construction is side-effect free
+
+ server->install(std::move(with_probe.extension));
+ server->activate_extensions();
+
+ // Activation must have created the xdg-shell global, wired the hooks, and
+ // registered the service.
+ CHECK(probe->activated());
+
+ for (int i = 0; i < 5; ++i) {
+ CHECK(server->dispatch(10));
+ }
+ // Destruction runs the extension teardown (RAII) then the server shutdown.
+}
+
+TEST_CASE("ext-xdg-shell is a core extension named xdg-shell") {
+ auto ext = unbox::ext_xdg_shell::make_extension();
+ const auto& m = ext->manifest();
+ CHECK(m.id == "xdg-shell");
+ CHECK(m.tier == unbox::kernel::Tier::core);
+ CHECK(m.depends_on.empty());
+}
diff --git a/packages/ext-xdg-shell/tests/test_policy.cpp b/packages/ext-xdg-shell/tests/test_policy.cpp
new file mode 100644
index 0000000..748e675
--- /dev/null
+++ b/packages/ext-xdg-shell/tests/test_policy.cpp
@@ -0,0 +1,172 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "policy.hpp"
+
+#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).
+
+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; "
+ "release -> motion is passthrough") {
+ GrabMachine g;
+ // Titlebar press: button goes down. No grab yet (client hasn't asked).
+ CHECK(g.on_button(/*pressed=*/true) == GrabAction::none);
+ CHECK_FALSE(g.grabbing());
+ CHECK(g.on_motion() == GrabAction::none); // nothing requested: passthrough
+
+ // Client responds to the press with xdg_toplevel.move while button HELD.
+ CHECK(g.on_request_move());
+ CHECK(g.grabbing());
+
+ // Motion WHILE HELD must move the toplevel (the bug: it didn't).
+ CHECK(g.on_motion() == GrabAction::move_toplevel);
+ CHECK(g.on_motion() == GrabAction::move_toplevel); // every held motion
+
+ // Release ends the grab.
+ CHECK(g.on_button(/*pressed=*/false) == GrabAction::end_grab);
+ CHECK_FALSE(g.grabbing());
+
+ // Motion WITHOUT clicking after release must NOT move (the bug: it did).
+ CHECK(g.on_motion() == GrabAction::none);
+}
+
+TEST_CASE("a move request that arrives with NO button down does not grab") {
+ // The deferred-request race: if request_move lands after the release, the
+ // button is already up, so no unclicked drag may start.
+ GrabMachine g;
+ g.on_button(true);
+ g.on_button(false); // pressed then released, no request yet
+ CHECK_FALSE(g.on_request_move()); // late request: button is up
+ CHECK_FALSE(g.grabbing());
+ CHECK(g.on_motion() == GrabAction::none);
+}
+
+TEST_CASE("resize grab: held motion resizes, release ends it") {
+ GrabMachine g;
+ g.on_button(true);
+ CHECK(g.on_request_resize());
+ CHECK(g.mode() == GrabMode::resize);
+ CHECK(g.on_motion() == GrabAction::resize_toplevel);
+ CHECK(g.on_button(false) == GrabAction::end_grab);
+ CHECK(g.on_motion() == GrabAction::none);
+}
+
+TEST_CASE("grab target lost (unmap/destroy mid-drag) drops the grab silently") {
+ GrabMachine g;
+ g.on_button(true);
+ g.on_request_move();
+ CHECK(g.grabbing());
+ g.on_grab_target_lost();
+ CHECK_FALSE(g.grabbing());
+ CHECK(g.on_motion() == GrabAction::none);
+ // A later release with no active grab is a no-op (not an end_grab).
+ CHECK(g.on_button(false) == GrabAction::none);
+}
+
+TEST_CASE("button-down state tracks across presses without a grab") {
+ GrabMachine g;
+ CHECK_FALSE(g.button_down());
+ g.on_button(true);
+ CHECK(g.button_down());
+ g.on_button(false);
+ CHECK_FALSE(g.button_down());
+}
+
+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 3ff936a..4c14a4b 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],
+ dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep],
)
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index cc17847..f907a6b 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -1,3 +1,5 @@
+#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
#include <unbox/kernel/kernel.hpp>
#include <unbox/kernel/server.hpp>
@@ -31,6 +33,12 @@ auto main(int argc, char* argv[]) -> int {
try {
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.
+ server->install(unbox::ext_xdg_shell::make_extension());
+ server->install(unbox::ext_layer_shell::create());
+
std::printf("unbox 0.0.1 (wlroots %s, RmlUi %s) on WAYLAND_DISPLAY=%s\n",
unbox::kernel::wlroots_version().c_str(),
unbox::kernel::rmlui_version().c_str(), server->socket_name().c_str());
diff --git a/packages/kernel/include/unbox/kernel/extension.hpp b/packages/kernel/include/unbox/kernel/extension.hpp
new file mode 100644
index 0000000..d47a46c
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/extension.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+// What an extension IS, to the kernel. An extension is an in-process unit that
+// contributes capabilities (hooks, services, ui surfaces, protocol glue)
+// through the Host API it receives in activate(). The kernel names no concrete
+// extension; host-bin (the composition root) names them all.
+//
+// Lifetime / deactivation: there is NO teardown method by design. An
+// extension's lifetime IS the session's; deactivation = destruction. Hold
+// every resource (Subscriptions, service registrations, scene nodes, wlroots
+// Listeners) as a member; they release in REVERSE declaration order when the
+// extension object is destroyed. So declare members in dependency order
+// (things that depend on the Host's borrows last). The kernel destroys
+// extensions in reverse activation (topological) order at shutdown.
+//
+// Everything runs on the single wl_event_loop thread.
+
+namespace unbox::kernel {
+
+class Host;
+
+// The activation tier. Lower tiers activate first and may be depended upon by
+// higher tiers; an extension may also depend on same-tier extensions by id
+// (resolved topologically within the install set). See GLOSSARY "core"/
+// "standard". The kernel itself is below all tiers and is always present.
+enum class Tier {
+ core, // minimum usable session: xdg-shell, layer-shell, keybindings…
+ standard, // on-by-default features: taskbar, launcher, tiling, OSK…
+};
+
+// An extension's self-declaration. `id` is for ACTIVATION ORDERING and
+// DIAGNOSTICS ONLY — capabilities are NEVER looked up by string (AGENTS.md:
+// string-keyed lookups are forbidden; cross-extension coupling goes through
+// exported typed hook/service symbols). Two installed extensions sharing an id
+// is a startup error; a depends_on naming an id not in the install set is a
+// startup error; a dependency cycle is a startup error.
+struct Manifest {
+ std::string id; // unique, stable, e.g. "ext-xdg-shell"
+ Tier tier = Tier::standard; // activation-order tier
+ std::vector<std::string> depends_on; // ids that must activate before this
+};
+
+// The base every extension implements. Construction is cheap and side-effect
+// free; ALL wiring happens in activate(). activate() is called once, in
+// topological order, before Server::run(). The Host& borrow is valid for the
+// whole session (until this extension is destroyed at shutdown) — but it is a
+// per-extension facade and must NOT be handed to another extension.
+class Extension {
+public:
+ virtual ~Extension() = default;
+
+ // Static identity; must return the same value every call (the Server reads
+ // it once at install time). No side effects.
+ [[nodiscard]] virtual auto manifest() const -> const Manifest& = 0;
+
+ // Wire up: subscribe to hooks, register services, create ui surfaces and
+ // scene nodes, attach wlroots Listeners — storing every returned RAII
+ // handle as a member of `this`. May throw to signal a fatal activation
+ // failure; the Server aborts startup with that error (activation failures
+ // are not isolated — a core extension that can't start is a broken
+ // session, surfaced to host-bin, not silently disabled). Runtime callback
+ // throws ARE isolated (hooks.hpp).
+ virtual void activate(Host& host) = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/hooks.hpp b/packages/kernel/include/unbox/kernel/hooks.hpp
new file mode 100644
index 0000000..a3e1dbb
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/hooks.hpp
@@ -0,0 +1,334 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <functional>
+#include <utility>
+#include <vector>
+
+// The typed extension bus — the architecture itself (AGENTS.md: "Cross-
+// extension coupling anchors to exported TYPED symbols"). Two hook kinds:
+//
+// Event<Args...> fire-and-forget, N listeners, error-isolated. The kernel
+// (or an extension) emits; every subscriber is invoked in
+// subscription order. A listener that throws is caught at
+// this boundary and its OWNING extension is disabled (all
+// its subscriptions dropped) — the emit still completes and
+// the remaining listeners still fire. The session never dies.
+//
+// Filter<T> an ordered value-in -> value-out chain. apply(v) threads v
+// through each link in subscription order; each link returns
+// the (possibly modified) value for the next. A throwing link
+// is skipped and its extension disabled; the chain continues
+// with the value as it stood before that link.
+//
+// Every subscribe() returns a move-only RAII Subscription whose destruction
+// unsubscribes (listener-lifetime.md). A callback MAY drop its own
+// Subscription, and an extension may be disabled mid-dispatch: removal is
+// deferred so the in-flight iteration stays valid (the compaction happens
+// after the dispatch unwinds).
+//
+// PURE CORE: no wlroots, no GL, no RMLUi types appear here. The bus is fully
+// exercisable with nothing running (tests/test_kernel.cpp). Everything runs on
+// the single wl_event_loop thread; no internal locking.
+
+namespace unbox::kernel {
+
+// Opaque per-extension identity, assigned by the Server at install time and
+// carried by each extension's Host. Hooks tag every subscription with the id
+// of the subscribing extension so a throwing callback disables the RIGHT one.
+// id 0 is reserved for "the kernel itself" (kernel-emitted, kernel-owned
+// subscriptions are never auto-disabled).
+enum class ExtensionId : std::uint32_t {};
+
+inline constexpr ExtensionId kernel_extension_id{0};
+
+namespace detail {
+
+// Shared sink the hooks call when a callback throws: it disables the owning
+// extension (dropping every subscription it holds across ALL hooks) and logs.
+// Implemented by the Server; injected into each hook at construction so the
+// bus core carries no kernel dependency. A null sink (default) means "no
+// isolation registry" — a throw is swallowed and logged-by-caller; used only
+// in pure-core tests that assert fan-out, where no extension registry exists.
+class DisableSink {
+public:
+ virtual ~DisableSink() = default;
+ // Called from inside a dispatch when `who`'s callback threw. MUST be
+ // re-entrant-safe w.r.t. the hook currently dispatching (the hook defers
+ // its own compaction); the sink marks the extension dead and requests
+ // each registered hook to purge that id once its dispatch unwinds.
+ virtual void disable(ExtensionId who) noexcept = 0;
+};
+
+} // namespace detail
+
+class Subscription;
+
+namespace detail {
+
+// Non-template base every hook derives from, so a single registry can purge an
+// extension's subscriptions across heterogeneous Event<...>/Filter<...>
+// instances without knowing their payload types.
+class HookBase {
+public:
+ virtual ~HookBase() = default;
+
+ // Drop every subscription owned by `who`. Safe to call during this hook's
+ // own dispatch: entries are tombstoned now and physically erased when the
+ // outermost dispatch finishes.
+ virtual void purge(ExtensionId who) noexcept = 0;
+
+ // Bind this hook to the isolation registry's sink (so a throwing callback
+ // here disables its owning extension everywhere). Called by Host::adopt()
+ // when an extension exports a hook it default-constructed as a member.
+ virtual void set_sink(DisableSink* sink) noexcept = 0;
+
+protected:
+ // Token identifying one subscription slot within a hook; handed to the
+ // Subscription so it can ask the hook to remove exactly that slot.
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ virtual void unsubscribe(Token token) noexcept = 0;
+
+ friend class unbox::kernel::Subscription;
+};
+
+} // namespace detail
+
+// Move-only RAII handle for one subscription. Destruction (or reset()/release-
+// by-move) unsubscribes. Holding it as a member of the extension (or of one of
+// the extension's RAII members) is the contract: when the extension is
+// destroyed, the member dies and the subscription drops. Never store a raw
+// hook reference or a bare callback across a unit boundary instead of this.
+class Subscription {
+public:
+ Subscription() = default;
+ Subscription(detail::HookBase* hook, std::uint64_t token) : hook_(hook), token_(token) {}
+
+ Subscription(Subscription&& other) noexcept
+ : hook_(other.hook_), token_(other.token_) {
+ other.hook_ = nullptr;
+ other.token_ = detail::HookBase::invalid_token;
+ }
+ auto operator=(Subscription&& other) noexcept -> Subscription& {
+ if (this != &other) {
+ reset();
+ hook_ = other.hook_;
+ token_ = other.token_;
+ other.hook_ = nullptr;
+ other.token_ = detail::HookBase::invalid_token;
+ }
+ return *this;
+ }
+ Subscription(const Subscription&) = delete;
+ auto operator=(const Subscription&) -> Subscription& = delete;
+
+ ~Subscription() { reset(); }
+
+ // Explicitly unsubscribe early. Idempotent. Safe to call from within the
+ // subscribed callback (the hook defers physical removal).
+ void reset() noexcept {
+ if (hook_ != nullptr) {
+ hook_->unsubscribe(token_);
+ hook_ = nullptr;
+ token_ = detail::HookBase::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return hook_ != nullptr; }
+
+private:
+ detail::HookBase* hook_ = nullptr;
+ std::uint64_t token_ = detail::HookBase::invalid_token;
+};
+
+// ---- Event<Args...> : fire-and-forget, N listeners, error-isolated ----------
+
+template <typename... Args>
+class Event final : public detail::HookBase {
+public:
+ using Callback = std::function<void(Args...)>;
+
+ Event() = default;
+ explicit Event(detail::DisableSink* sink) : sink_(sink) {}
+ Event(const Event&) = delete;
+ auto operator=(const Event&) -> Event& = delete;
+
+ // Subscribe `cb`, owned by extension `who`. Returns the RAII handle; let it
+ // die to unsubscribe. Listeners fire in subscription order on emit().
+ [[nodiscard]] auto subscribe(ExtensionId who, Callback cb) -> Subscription {
+ const Token token = ++next_token_;
+ entries_.push_back(Entry{token, who, std::move(cb), false});
+ return Subscription(this, token);
+ }
+
+ // Fire the event: invoke every live listener in subscription order with a
+ // copy of the args (Args are passed by value semantics of std::function;
+ // pass borrows as raw pointers/refs in Args to avoid copies — see the
+ // kernel catalogue in host.hpp). A listener that throws is caught here and
+ // its extension disabled; the emit still completes. Re-entrant emit() and
+ // unsubscribe-during-emit are safe.
+ void emit(Args... args) {
+ ++depth_;
+ const std::size_t n = entries_.size(); // new subscriptions during emit don't fire
+ for (std::size_t i = 0; i < n; ++i) {
+ Entry& e = entries_[i];
+ if (e.dead) {
+ continue;
+ }
+ try {
+ e.cb(args...);
+ } catch (...) {
+ disable_owner(e.who);
+ }
+ }
+ --depth_;
+ compact_if_idle();
+ }
+
+ void purge(ExtensionId who) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.who == who) {
+ e.dead = true;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void set_sink(detail::DisableSink* sink) noexcept override { sink_ = sink; }
+
+private:
+ struct Entry {
+ Token token;
+ ExtensionId who;
+ Callback cb;
+ bool dead;
+ };
+
+ void unsubscribe(Token token) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.token == token) {
+ e.dead = true;
+ break;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void disable_owner(ExtensionId who) noexcept {
+ if (sink_ != nullptr && who != kernel_extension_id) {
+ sink_->disable(who); // routes back through purge() on every hook
+ } else {
+ purge(who);
+ }
+ }
+
+ void compact_if_idle() noexcept {
+ if (depth_ != 0) {
+ return; // a dispatch is in flight; erasing now would invalidate it
+ }
+ std::erase_if(entries_, [](const Entry& e) { return e.dead; });
+ }
+
+ std::vector<Entry> entries_;
+ detail::DisableSink* sink_ = nullptr;
+ Token next_token_ = 0;
+ int depth_ = 0;
+};
+
+// ---- Filter<T> : ordered value-in -> value-out chain -------------------------
+
+template <typename T>
+class Filter final : public detail::HookBase {
+public:
+ // Each link receives the current value and returns the value for the next
+ // link. Take T by value and return T (the chain threads by value).
+ using Link = std::function<T(T)>;
+
+ Filter() = default;
+ explicit Filter(detail::DisableSink* sink) : sink_(sink) {}
+ Filter(const Filter&) = delete;
+ auto operator=(const Filter&) -> Filter& = delete;
+
+ // Append `link`, owned by extension `who`. Links run in subscription order.
+ [[nodiscard]] auto subscribe(ExtensionId who, Link link) -> Subscription {
+ const Token token = ++next_token_;
+ entries_.push_back(Entry{token, who, std::move(link), false});
+ return Subscription(this, token);
+ }
+
+ // Thread `value` through the chain and return the result. A link that
+ // throws is skipped (and its extension disabled); the chain continues with
+ // the value as it stood BEFORE that link. With no links, returns `value`.
+ [[nodiscard]] auto apply(T value) -> T {
+ ++depth_;
+ const std::size_t n = entries_.size();
+ for (std::size_t i = 0; i < n; ++i) {
+ Entry& e = entries_[i];
+ if (e.dead) {
+ continue;
+ }
+ try {
+ value = e.link(value);
+ } catch (...) {
+ disable_owner(e.who);
+ }
+ }
+ --depth_;
+ compact_if_idle();
+ return value;
+ }
+
+ void purge(ExtensionId who) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.who == who) {
+ e.dead = true;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void set_sink(detail::DisableSink* sink) noexcept override { sink_ = sink; }
+
+private:
+ struct Entry {
+ Token token;
+ ExtensionId who;
+ Link link;
+ bool dead;
+ };
+
+ void unsubscribe(Token token) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.token == token) {
+ e.dead = true;
+ break;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void disable_owner(ExtensionId who) noexcept {
+ if (sink_ != nullptr && who != kernel_extension_id) {
+ sink_->disable(who);
+ } else {
+ purge(who);
+ }
+ }
+
+ void compact_if_idle() noexcept {
+ if (depth_ != 0) {
+ return;
+ }
+ std::erase_if(entries_, [](const Entry& e) { return e.dead; });
+ }
+
+ std::vector<Entry> entries_;
+ detail::DisableSink* sink_ = nullptr;
+ Token next_token_ = 0;
+ int depth_ = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/host.hpp b/packages/kernel/include/unbox/kernel/host.hpp
new file mode 100644
index 0000000..7fb0eea
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/host.hpp
@@ -0,0 +1,274 @@
+#pragma once
+
+#include <unbox/kernel/hooks.hpp>
+#include <unbox/kernel/surface_registry.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdint>
+#include <typeindex>
+
+// The Host API: the typed facade an Extension receives in activate(). It is
+// PER-EXTENSION — the kernel hands each extension its own Host so that when a
+// hook callback throws, the bus knows which extension to disable. Never pass
+// your Host to another extension.
+//
+// Everything a Host exposes is valid for the SESSION lifetime (until your
+// extension is destroyed at shutdown), unless noted. The wlroots borrows
+// (display/scene/seat/cursor/output_layout, and the scene-layer trees) are
+// non-owning: the kernel owns them; you may attach nodes/listeners but never
+// destroy them. wlroots types arrive via wlr.hpp (a public header), so an
+// extension's own glue is first-class.
+//
+// Single wl_event_loop thread throughout.
+
+namespace unbox::kernel {
+
+// ---- Kernel event payloads --------------------------------------------------
+//
+// The kernel emits these for the generic glue it owns. Payloads carry the
+// data an extension needs to implement policy WITHOUT reading kernel src.
+// Pointers inside a payload are BORROWS valid only for the duration of the
+// emit call — never store them; store the wlr object's own stable handle if
+// you must track it, and drop it on its destroy event.
+
+// An output was added (after init_render + enable + scene wiring) or is about
+// to be removed (emitted from its destroy handler; the wlr_output is still
+// valid for the call, gone after). Distinguish via the two separate events.
+struct OutputEvent {
+ wlr_output* output; // borrow, valid for the call only
+};
+
+// Pointer motion already applied to the cursor; layout coords are the cursor's
+// post-move position. Emitted for both relative and absolute motion.
+struct PointerMotionEvent {
+ double lx; // cursor layout x AFTER the move
+ double ly; // cursor layout y AFTER the move
+ std::uint32_t time_msec;
+};
+
+// A pointer button. The kernel does NOT forward it to any client — it only
+// moves the cursor and emits this. The single pointer-routing extension is
+// responsible for calling wlr_seat_pointer_notify_button (exactly like
+// enter/motion/frame): that is what lets an interactive move/resize grab
+// suppress the forward by simply not notifying. Carries the cursor layout
+// position so a listener can hit-test the scene (click-to-focus, begin
+// interactive grab). `pressed` == button down.
+struct PointerButtonEvent {
+ std::uint32_t button; // linux/input-event-codes BTN_*
+ bool pressed;
+ double lx; // cursor layout x at the event
+ double ly; // cursor layout y at the event
+ std::uint32_t time_msec;
+};
+
+// A pointer axis (scroll) event. The kernel does NOT forward it to any client
+// — the single pointer-routing extension calls wlr_seat_pointer_notify_axis
+// (exactly like button/enter/motion/frame). Kernel only emits.
+struct PointerAxisEvent {
+ wl_pointer_axis orientation;
+ double delta;
+ std::int32_t delta_discrete;
+ wl_pointer_axis_source source;
+ std::uint32_t time_msec;
+};
+
+// A keyboard key, BEFORE it is forwarded to the focused client. Threaded
+// through the key_filter (see Host::key_filter): set `handled = true` to
+// CONSUME it (the kernel will not forward it to the client) — this is how
+// ext-keybindings/ext-xdg-shell implement compositor shortcuts. `keysym` is
+// the resolved xkb keysym of the (modified) press; `modifiers` is the active
+// modifier mask (WLR_MODIFIER_*).
+struct KeyEvent {
+ std::uint32_t keysym; // xkb_keysym_t
+ std::uint32_t keycode; // raw libinput keycode (for notify_key passthrough)
+ std::uint32_t modifiers; // WLR_MODIFIER_* mask
+ bool pressed; // true on press, false on release
+ std::uint32_t time_msec;
+ bool handled = false; // set true to consume (suppress client forward)
+};
+
+// A touch point went down / moved / up. `lx`/`ly` are layout coords (the
+// cursor path's absolute-to-layout mapping); the extension hit-tests the scene
+// itself to find the target surface (the kernel routes nothing to clients).
+struct TouchDownEvent {
+ std::int32_t touch_id;
+ double lx;
+ double ly;
+ std::uint32_t time_msec;
+};
+struct TouchMotionEvent {
+ std::int32_t touch_id;
+ double lx;
+ double ly;
+ std::uint32_t time_msec;
+};
+struct TouchUpEvent {
+ std::int32_t touch_id;
+ std::uint32_t time_msec;
+};
+struct TouchCancelEvent {
+ std::int32_t touch_id;
+};
+
+// ---- Scene layers -----------------------------------------------------------
+//
+// Ordered z-bands of the scene, so extensions never fight over node order.
+// Names follow wlr-layer-shell (background/bottom/top/overlay) plus `normal`
+// for application toplevels, which layer-shell lacks. Stacking is strictly
+// background < bottom < normal < top < overlay. An extension attaches its
+// nodes under the tree it gets from Host::scene_layer(); raising/lowering
+// WITHIN a layer is the extension's business, crossing layers is not.
+//
+// GLOSSARY: "scene layer" / SceneLayer is a NEW term (flagged for sign-off in
+// reports/kernel.md). It reuses wlr-layer-shell's band names verbatim.
+enum class SceneLayer {
+ background,
+ bottom,
+ normal,
+ top,
+ overlay,
+};
+
+// ---- Service registry keying (typed, never string) --------------------------
+//
+// A service is a single-responder request/response capability: one extension
+// registers an implementation of an abstract interface I; others fetch it by
+// the TYPE I. The public API is the templated provide_service<I>/service<I>
+// below; the type identity is the only key (no strings — a missing provider is
+// a nullptr the caller checks, and the INTERFACE TYPE is a compile/link
+// dependency on the providing unit's public header). Re-registering a type
+// replaces the previous provider.
+
+class Host {
+public:
+ virtual ~Host() = default;
+ Host(const Host&) = delete;
+ auto operator=(const Host&) -> Host& = delete;
+
+ // ---- Session borrows (kernel-owned; never destroy) ----
+ [[nodiscard]] virtual auto display() -> wl_display* = 0;
+ [[nodiscard]] virtual auto scene() -> wlr_scene* = 0;
+ [[nodiscard]] virtual auto seat() -> wlr_seat* = 0;
+ [[nodiscard]] virtual auto cursor() -> wlr_cursor* = 0;
+ // The kernel's shared xcursor theme manager (kernel-owned; never destroy).
+ // For an extension that sets the cursor image itself — e.g. ext-xdg-shell
+ // drawing the default cursor on passthrough and resize/move cursors during
+ // an interactive grab (wlr_cursor_set_xcursor(cursor(), cursor_manager(),
+ // name)). Loaded at 24px; the kernel only touches it on seat focus-clear.
+ [[nodiscard]] virtual auto cursor_manager() -> wlr_xcursor_manager* = 0;
+ [[nodiscard]] virtual auto output_layout() -> wlr_output_layout* = 0;
+
+ // The scene-tree for a z-band. Attach your nodes here. Stable for the
+ // session; never destroy it. The trees are created once in stacking order.
+ [[nodiscard]] virtual auto scene_layer(SceneLayer layer) -> wlr_scene_tree* = 0;
+
+ // ---- Typed surface -> scene-tree association ----
+ // Register that `surface` is hosted in `tree` (which YOUR extension owns).
+ // Returns a move-only RAII SurfaceRegistration; keep it as a member of the
+ // hosting entity so the association dies with the node. Re-registering the
+ // same surface replaces the mapping (the older handle becomes a no-op).
+ // This is the typed replacement for the old wlr_surface.data convention —
+ // cross-unit surface->tree coupling routes through here, never .data.
+ [[nodiscard]] auto host_surface(wlr_surface* surface, wlr_scene_tree* tree)
+ -> SurfaceRegistration {
+ const auto token = surface_store().set(surface, tree);
+ return SurfaceRegistration(&surface_store(), surface, token);
+ }
+ // Resolve `surface` to the scene tree it is hosted in, or null if no
+ // extension has registered it. The returned tree is a BORROW owned by the
+ // registering extension, valid only while that registration handle lives —
+ // never cache it across events; re-resolve each time.
+ [[nodiscard]] auto scene_tree_for(wlr_surface* surface) -> wlr_scene_tree* {
+ return static_cast<wlr_scene_tree*>(surface_store().get(surface));
+ }
+
+ // ---- Kernel event catalogue ----
+ // Subscribe through these to react to kernel-owned input/output. Each
+ // returns an Event/Filter you subscribe to with YOUR extension id (the
+ // Host supplies it; see subscribe helpers below). The kernel emits; you
+ // never emit on these.
+ [[nodiscard]] virtual auto on_output_added() -> Event<const OutputEvent&>& = 0;
+ [[nodiscard]] virtual auto on_output_removed() -> Event<const OutputEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_motion() -> Event<const PointerMotionEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_button() -> Event<const PointerButtonEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_axis() -> Event<const PointerAxisEvent&>& = 0;
+ // Pointer frame: emitted once per input frame after motion/button/axis;
+ // the extension routing pointer events to clients calls
+ // wlr_seat_pointer_notify_frame here. No payload.
+ [[nodiscard]] virtual auto on_pointer_frame() -> Event<>& = 0;
+ [[nodiscard]] virtual auto on_touch_down() -> Event<const TouchDownEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_motion() -> Event<const TouchMotionEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_up() -> Event<const TouchUpEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_cancel() -> Event<const TouchCancelEvent&>& = 0;
+ // Touch frame: emitted once per touch input frame; route via
+ // wlr_seat_touch_notify_frame. No payload.
+ [[nodiscard]] virtual auto on_touch_frame() -> Event<>& = 0;
+
+ // Key handling is a FILTER, not an Event: links run in order and may set
+ // KeyEvent::handled to CONSUME the key before the kernel forwards it to the
+ // focused client. The kernel applies this filter on every key, then
+ // forwards to the client only if the result is not handled. (This is the
+ // consume-or-pass channel the brief calls for; ext-keybindings lives here.)
+ [[nodiscard]] virtual auto key_filter() -> Filter<KeyEvent>& = 0;
+
+ // ---- Subscription helpers (tag with THIS extension's id) ----
+ // Prefer these over event.subscribe(id, cb): the Host injects your id so a
+ // throwing callback disables YOU and not someone else.
+ template <typename... Args, typename Fn>
+ [[nodiscard]] auto subscribe(Event<Args...>& ev, Fn&& fn) -> Subscription {
+ return ev.subscribe(extension_id(), std::forward<Fn>(fn));
+ }
+ template <typename T, typename Fn>
+ [[nodiscard]] auto subscribe(Filter<T>& flt, Fn&& fn) -> Subscription {
+ return flt.subscribe(extension_id(), std::forward<Fn>(fn));
+ }
+
+ // ---- Exporting your own hooks (cross-extension coupling) ----
+ // To expose a hook to OTHER extensions, declare an Event/Filter as a member
+ // of your extension and adopt() it in activate(): adoption binds it to the
+ // kernel's error-isolation registry (so a throwing subscriber on your hook
+ // disables the SUBSCRIBER's extension, not yours) and tracks it for purge.
+ // The hook is pinned (non-movable); keep it as a stable member and expose a
+ // reference via your public header or a service. Subscribers pass their own
+ // id (their Host::subscribe injects it). Adopt before anyone subscribes.
+ void adopt(detail::HookBase& hook) { adopt_hook(hook); }
+
+ // ---- Services (typed single-responder) ----
+ // Register `impl` as the provider of interface I for the session. `impl`
+ // is a NON-OWNING borrow: it must outlive every consumer — store it as a
+ // member of your extension (so it dies last, in reverse-activation order).
+ // Returns false if a provider for I was already registered (it is still
+ // replaced; the bool lets a provider detect a collision). No strings.
+ template <typename I>
+ auto provide_service(I* impl) -> bool {
+ return register_service(std::type_index(typeid(I)), static_cast<void*>(impl));
+ }
+ // Fetch the provider of interface I, or nullptr if none is registered yet.
+ // Do not cache across activation: fetch in activate() AFTER the provider's
+ // extension (declare it in your depends_on so it activates first).
+ template <typename I>
+ [[nodiscard]] auto service() -> I* {
+ return static_cast<I*>(lookup_service(std::type_index(typeid(I))));
+ }
+
+protected:
+ Host() = default;
+
+ // The id the kernel assigned to THIS extension; subscriptions are tagged
+ // with it for error isolation. Kernel-internal Hosts return
+ // kernel_extension_id.
+ [[nodiscard]] virtual auto extension_id() const -> ExtensionId = 0;
+
+ // Non-template service core (the templates above are thin type-key shims).
+ virtual auto register_service(std::type_index type, void* impl) -> bool = 0;
+ [[nodiscard]] virtual auto lookup_service(std::type_index type) -> void* = 0;
+
+ // Bind an extension-exported hook to the isolation registry (see adopt()).
+ virtual void adopt_hook(detail::HookBase& hook) = 0;
+
+ // The kernel-owned, session-wide surface->tree association store (shared by
+ // ALL extensions; the host_surface/scene_tree_for shims above route here).
+ [[nodiscard]] virtual auto surface_store() -> detail::PointerAssoc& = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/listener.hpp b/packages/kernel/include/unbox/kernel/listener.hpp
new file mode 100644
index 0000000..4115f66
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/listener.hpp
@@ -0,0 +1,72 @@
+#pragma once
+
+#include <unbox/kernel/wlr.hpp>
+
+#include <functional>
+#include <utility>
+
+// RAII wrapper over a wl_listener. An extension that does its own wlroots glue
+// (ext-xdg-shell binds xdg_shell signals; ext-layer-shell binds layer-shell
+// signals) MUST NOT hold a bare wl_listener across the boundary
+// (listener-lifetime.md, AGENTS.md): a wl_listener still linked into a signal
+// after its owner dies corrupts the signal's list on the next emit. This type
+// makes the link's lifetime equal to the wrapper's: connect() subscribes,
+// destruction (or disconnect()) unsubscribes. Hold it as a member; it dies
+// with you.
+//
+// Borrows received in a handler (the void* data, any wlroots pointer reached
+// through it) are valid ONLY for that call — never store them.
+//
+// A handler MAY destroy its own Listener — the destroy-event pattern: a
+// handler erases the entity that owns the Listener (and the Listener with it).
+// This is safe because the thunk touches NOTHING after handler_() returns. But
+// the handler itself must not touch its captures after triggering its own
+// destruction: make the erase/delete the handler's LAST action. (The bus's
+// Subscription formalizes this for hook callbacks; this type is for raw
+// wlroots signals an extension must bind directly.)
+//
+// Single wl_event_loop thread; no internal synchronization.
+
+namespace unbox::kernel {
+
+class Listener {
+public:
+ Listener() {
+ node_.self = this;
+ node_.listener.notify = &Listener::thunk;
+ wl_list_init(&node_.listener.link);
+ }
+ ~Listener() { disconnect(); }
+ Listener(const Listener&) = delete;
+ auto operator=(const Listener&) -> Listener& = delete;
+
+ // Subscribe to `signal`; `handler` receives the signal's data pointer
+ // (cast it to the documented event type). Re-connecting first disconnects.
+ void connect(wl_signal& signal, std::function<void(void*)> handler) {
+ disconnect();
+ handler_ = std::move(handler);
+ wl_signal_add(&signal, &node_.listener);
+ }
+
+ // Unsubscribe. Idempotent; called automatically on destruction.
+ void disconnect() {
+ wl_list_remove(&node_.listener.link);
+ wl_list_init(&node_.listener.link);
+ }
+
+private:
+ struct Node {
+ wl_listener listener; // MUST stay first: thunk casts wl_listener* -> Node*
+ Listener* self;
+ };
+
+ static void thunk(wl_listener* listener, void* data) {
+ auto* node = reinterpret_cast<Node*>(listener);
+ node->self->handler_(data);
+ }
+
+ Node node_{};
+ std::function<void(void*)> handler_;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 8389999..f8963e1 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -1,14 +1,19 @@
#pragma once
+#include <unbox/kernel/extension.hpp>
+
#include <memory>
#include <string>
-// The compositor core. Slice-2 shape: a faithful tinywl port (plus touch)
-// living wholly inside the kernel; slice 4 splits shell policy out into
-// extensions behind typed contracts.
+// The compositor core. Slice-4 shape: the kernel names NO concrete feature and
+// boots featureless. It owns the generic plumbing (compositor, subcompositor,
+// data-device, output/scene glue, cursor + seat, the kernel-internal ui
+// spike) and the extension host + typed bus. ALL shell policy (xdg-shell
+// toplevels, focus, cycling, interactive move/resize, keybindings) lives in
+// extensions installed via install() before run().
//
// Calling context: single wl_event_loop thread. run() blocks; terminate()
-// is safe to call from event handlers (e.g. a keybinding).
+// is safe to call from event handlers (e.g. a keybinding extension).
namespace unbox::kernel {
@@ -41,7 +46,25 @@ public:
// The WAYLAND_DISPLAY name clients connect with (e.g. "wayland-1").
[[nodiscard]] auto socket_name() const -> std::string;
- // Runs the event loop until terminate() (default binding: Alt+Escape).
+ // Install an extension (ownership transfer). Call after create(), before
+ // activate_extensions()/run(). Order of install() calls does NOT determine
+ // activation order — that is computed topologically from each Manifest's
+ // depends_on at activate_extensions() time. Installing two extensions with
+ // the same Manifest id throws std::runtime_error here (duplicate id).
+ void install(std::unique_ptr<Extension> extension);
+
+ // Activate every installed extension exactly once, in topological order by
+ // Manifest depends_on (ties broken by tier then install order). Throws
+ // std::runtime_error on a missing dependency, a dependency cycle, or a
+ // duplicate id; the offending ids are named in what(). An exception thrown
+ // by an extension's own activate() propagates out (activation failure is
+ // fatal — a core extension that cannot start is a broken session, not an
+ // isolated one). Idempotent: a second call is a no-op. run() calls this
+ // first if it was not called already.
+ void activate_extensions();
+
+ // Runs the event loop until terminate(). Calls activate_extensions() first
+ // if not already done.
void run();
// One event-loop turn (≤ timeout_ms); for tests and embedders.
diff --git a/packages/kernel/include/unbox/kernel/surface_registry.hpp b/packages/kernel/include/unbox/kernel/surface_registry.hpp
new file mode 100644
index 0000000..ac895d2
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/surface_registry.hpp
@@ -0,0 +1,134 @@
+#pragma once
+
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdint>
+#include <unordered_map>
+
+// Typed surface -> scene-tree association (the kernel-owned replacement for the
+// old untyped `wlr_surface.data` / `wlr_xdg_surface.data` cross-extension
+// convention). That `void*` agreement had zero compile/link enforcement and
+// violated "cross-extension coupling anchors to exported TYPED symbols"
+// (AGENTS.md); this is the typed contract.
+//
+// Ownership model: an extension that HOSTS a surface in a scene tree (xdg
+// toplevels in ext-xdg-shell, layer surfaces in ext-layer-shell, future
+// xwayland) registers the association via Host::host_surface() and keeps the
+// returned move-only RAII handle as a member. The MAP lives in the kernel; the
+// kernel stores an opaque association and names no feature — it does not know
+// what a toplevel or a layer surface is. Any other extension resolves a
+// surface to its host tree via Host::scene_tree_for() — e.g. ext-xdg-shell
+// resolving a popup's parent surface to the parent's scene tree.
+//
+// This does NOT forbid a unit using `.data` PRIVATELY within itself (e.g.
+// stashing its own per-node back-pointer) — only the CROSS-UNIT agreement
+// dies. Cross-unit, route through this typed contract.
+//
+// Single wl_event_loop thread throughout; no internal locking.
+
+namespace unbox::kernel {
+
+namespace detail {
+
+// Pure pointer-keyed association with token-defended RAII semantics. No
+// wlroots semantics — it stores pointer identities, which is exactly why it is
+// unit-testable with no compositor running. The kernel embeds ONE instance and
+// the typed SurfaceRegistration / Host methods are thin shims over it.
+//
+// Double-register of the same key REPLACES the value and bumps the key's token;
+// the previous holder's handle therefore becomes a no-op on destruction (its
+// token no longer matches), so it can never tear down the newer association.
+class PointerAssoc {
+public:
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ struct Slot {
+ void* value;
+ Token token;
+ };
+
+ // Returns the token now owning `key`. Replaces any existing mapping.
+ auto set(void* key, void* value) -> Token {
+ const Token token = ++next_token_;
+ map_[key] = Slot{value, token};
+ return token;
+ }
+
+ // Erase `key` ONLY if `token` is still the owning token (defends against a
+ // stale handle unregistering a newer registration of the same key).
+ void clear(void* key, Token token) noexcept {
+ auto it = map_.find(key);
+ if (it != map_.end() && it->second.token == token) {
+ map_.erase(it);
+ }
+ }
+
+ [[nodiscard]] auto get(void* key) const -> void* {
+ auto it = map_.find(key);
+ return it == map_.end() ? nullptr : it->second.value;
+ }
+
+ [[nodiscard]] auto size() const -> std::size_t { return map_.size(); }
+
+private:
+ std::unordered_map<void*, Slot> map_;
+ Token next_token_ = 0;
+};
+
+} // namespace detail
+
+// Move-only RAII handle for one surface->tree association. Destruction (or
+// reset()/move-out) unregisters — but only if this handle still owns the
+// surface's current mapping (re-hosting the same surface elsewhere supersedes
+// this handle, whose destruction then becomes a safe no-op). Hold it as a
+// member of the hosting entity so the association's lifetime equals the node's.
+class SurfaceRegistration {
+public:
+ SurfaceRegistration() = default;
+ SurfaceRegistration(detail::PointerAssoc* store, void* key, detail::PointerAssoc::Token token)
+ : store_(store), key_(key), token_(token) {}
+
+ SurfaceRegistration(SurfaceRegistration&& other) noexcept
+ : store_(other.store_), key_(other.key_), token_(other.token_) {
+ other.store_ = nullptr;
+ other.key_ = nullptr;
+ other.token_ = detail::PointerAssoc::invalid_token;
+ }
+ auto operator=(SurfaceRegistration&& other) noexcept -> SurfaceRegistration& {
+ if (this != &other) {
+ reset();
+ store_ = other.store_;
+ key_ = other.key_;
+ token_ = other.token_;
+ other.store_ = nullptr;
+ other.key_ = nullptr;
+ other.token_ = detail::PointerAssoc::invalid_token;
+ }
+ return *this;
+ }
+ SurfaceRegistration(const SurfaceRegistration&) = delete;
+ auto operator=(const SurfaceRegistration&) -> SurfaceRegistration& = delete;
+
+ ~SurfaceRegistration() { reset(); }
+
+ // Unregister early. Idempotent. No-op if this handle was superseded by a
+ // later registration of the same surface.
+ void reset() noexcept {
+ if (store_ != nullptr) {
+ store_->clear(key_, token_);
+ store_ = nullptr;
+ key_ = nullptr;
+ token_ = detail::PointerAssoc::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return store_ != nullptr; }
+
+private:
+ detail::PointerAssoc* store_ = nullptr;
+ void* key_ = nullptr;
+ detail::PointerAssoc::Token token_ = detail::PointerAssoc::invalid_token;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/wlr.hpp b/packages/kernel/include/unbox/kernel/wlr.hpp
index 51dd6fb..496d737 100644
--- a/packages/kernel/include/unbox/kernel/wlr.hpp
+++ b/packages/kernel/include/unbox/kernel/wlr.hpp
@@ -17,6 +17,15 @@ extern "C" {
// helpers become plain `inline`, which C++ ODR-merges safely. Keep the
// #define scoped to EXACTLY these includes.
#define static
+// wlr-layer-shell (and its generated protocol header) name a struct field /
+// request argument `namespace` — a valid C identifier but a C++ KEYWORD, which
+// `extern "C"` does NOT exempt (it changes linkage, not lexing). Rename it to
+// `_namespace` across the wlr includes, same scoped-macro discipline as
+// `static` above (the Hyprland-proven fix). CONSEQUENCE that leaks through this
+// public wrapper: code reaching wlr_layer_surface_v1::namespace must spell it
+// `->_namespace`. Re-audit for further C++-keyword identifiers when adding
+// protocol/wlr headers (only `namespace` collides in the current set).
+#define namespace _namespace
#include <wlr/backend.h>
#include <wlr/render/allocator.h>
// Slice-3 spike (RMLUi -> wlr_scene bridge): EGL/dmabuf, the GLES2 renderer's
@@ -37,6 +46,18 @@ extern "C" {
#include <wlr/types/wlr_data_device.h>
#include <wlr/types/wlr_input_device.h>
#include <wlr/types/wlr_keyboard.h>
+// wlr-layer-shell for ext-layer-shell. This header #includes the generated
+// "wlr-layer-shell-unstable-v1-protocol.h" — produced by the wayland-scanner
+// custom_target in packages/kernel/meson.build from the vendored
+// protocol/wlr-layer-shell-unstable-v1.xml and propagated (include path +
+// build order) through kernel_dep. Static-blanking re-audit: neither this
+// wlroots header nor the generated protocol header contains a `static` storage
+// keyword on a header-inline function with a function-local static (the
+// generated header has only extern interface declarations; no array-param
+// `[static N]` either), so the surrounding `#define static` is inert across
+// both. The scene helper wlr_scene_layer_surface_v1 lives in wlr_scene.h
+// (included below) — no second include needed.
+#include <wlr/types/wlr_layer_shell_v1.h>
#include <wlr/types/wlr_output.h>
#include <wlr/types/wlr_output_layout.h>
#include <wlr/types/wlr_pointer.h>
@@ -49,5 +70,6 @@ extern "C" {
#include <wlr/types/wlr_xdg_shell.h>
#include <wlr/util/log.h>
#include <wlr/version.h>
+#undef namespace
#undef static
}
diff --git a/packages/kernel/kernel.md b/packages/kernel/kernel.md
index 24a37c9..6ed9b5e 100644
--- a/packages/kernel/kernel.md
+++ b/packages/kernel/kernel.md
@@ -1,45 +1,84 @@
# kernel — package notes
-Slice-2 state: a working tinywl port (+ touch, which tinywl lacks) wholly
-inside the kernel: backend/output/scene glue, xdg-shell toplevels + popups,
-click/tap-to-focus, Alt-drag-free interactive move/resize (client-requested
-only), keyboard/pointer/touch via one wlr_cursor path. Slice-2 keybindings:
-Alt+Escape = terminate, Alt+F1 = cycle. Slice 4 splits shell policy out
-into extensions.
+Slice-4 state: the kernel **names no concrete feature** and boots
+featureless. It owns generic plumbing (compositor/subcompositor/data-device,
+output+scene glue, cursor + xcursor-mgr + seat, the kernel-private ui spike)
+plus the **extension host + typed bus**. ALL shell policy (xdg-shell
+toplevels/popups, focus, alt-cycle, terminate, interactive move/resize,
+keybindings) was EXTRACTED — `src/toplevel.cpp` is deleted; ext-xdg-shell /
+ext-layer-shell recreate it from the contract alone.
-Slice-3 state: THE SPIKE landed on **Plan A** (RMLUi -> dmabuf-backed
-wlr_buffer -> wlr_scene_buffer), with Plan B (FBO + glReadPixels into a
-data-ptr wlr_buffer) as a verified runtime fallback. All bridge state is
-private in `src/ui_spike.{hpp,cpp}` + the adapted GLES3 renderer
-`src/rmlui_renderer_gl3.{h,cpp}`. Public surface delta: `Options::ui_spike`
-+ `Server::ui_spike_frame_count()` (both TEMPORARY, replaced by the real ui
-substrate in slice 4+). Driven from the output frame handler; renders only
-when `ui_spike != nullptr`. Host-bin does NOT yet wire the Option.
+Public contract (the ABI): `hooks.hpp` (typed `Event<Args...>` /
+`Filter<T>` + RAII `Subscription`), `extension.hpp` (`Tier`, `Manifest`,
+`Extension`), `host.hpp` (`Host` facade: borrows + event catalogue + scene
+layers + services + typed surface→tree association), `listener.hpp` (the RAII
+`wl_listener` wrapper, now public), `surface_registry.hpp` (`SurfaceRegistration`
+RAII handle + the pure `detail::PointerAssoc` core), `server.hpp` (`install` +
+`activate_extensions`).
+
+Side-effect graph (who emits / who routes):
+- The kernel EMITS typed Events for its glue (output add/remove; pointer
+ motion/button/axis/frame; touch down/motion/up/cancel/frame) and applies
+ `key_filter` to every key. It moves the cursor, runs seat-capability and
+ seat-protocol glue (request_set_cursor/selection, focus_change default
+ cursor), and forwards a key to the focused client ONLY if no filter link
+ set `handled`. It routes NOTHING else to client surfaces and makes NO
+ focus decision — extensions do that via the bus + the seat borrow.
+- `Server::install()` transfers ownership; `activate_extensions()` (called
+ by `run()`, or earlier by host-bin/tests) topo-sorts by `Manifest
+ depends_on` (ties: tier then install order), then calls each `activate`.
+ Missing dep / cycle / duplicate id = `std::runtime_error` at startup. An
+ `activate()` throw is FATAL (propagates) — a core ext that can't start is
+ a broken session, not an isolated one. RUNTIME callback throws ARE
+ isolated (see below).
+- Scene z-bands live in `Impl::scene_layers[]` (SceneLayer order, created
+ over `scene->tree` background→overlay so stacking is correct). The ui
+ spike now sits in the `overlay` band. Extensions attach via
+ `Host::scene_layer()`.
Gotchas the headers can't express:
+- **Error isolation = deferred purge.** A hook callback that throws is
+ caught at the bus boundary; `Server::Impl` (a `detail::DisableSink`) marks
+ the owning extension disabled and `purge()`s its subscriptions from EVERY
+ registered hook (`all_hooks`). Purge during a live dispatch only
+ tombstones (`dead=true`); physical erase happens when that hook's dispatch
+ depth returns to 0 (`compact_if_idle`). So disabling an extension from
+ inside its own callback, and an ext subscribed to multiple hooks, are both
+ safe. Hooks are PINNED (Subscriptions hold a raw `HookBase*`): never move
+ an `Event`/`Filter`; hold them as stable members.
+- **Extensions are destroyed FIRST in `shutdown()`**, reverse of install
+ order, so their RAII members (Subscriptions, Listeners, scene nodes)
+ release while the wlr objects they borrow are still alive. Then the spike,
+ then clients, then server-level Listeners, then wlr objects.
- **`wlr.hpp` blanks `static` around the wlr includes.** wlroots headers
use C99 array-parameter syntax (`float color[static 4]`), invalid in
C++. With `static` blanked, `static inline` helpers become `inline`
- (ODR-merged, safe). Cost: a function-local `static` inside a header
- inline would silently lose persistence — none exist in our include set;
- re-audit when ADDING includes to the wrapper.
+ (ODR-merged, safe). Re-audit when ADDING includes to the wrapper.
- **RMLUi is kernel-private.** `rmlui_dep` is deliberately absent from
- `kernel_dep` propagation (see meson.build): extensions contribute RML
- documents + data bindings via the ui substrate, never RMLUi API calls.
- Do not "fix" a missing-RMLUi-header error downstream by propagating it.
-- **Shutdown order is load-bearing** (`Impl::shutdown()`): destroy clients
- → disconnect ALL server-level Listeners → scene/cursor/allocator/
- renderer/backend/display. A Listener outliving the wlr object owning its
+ `kernel_dep` propagation: extensions contribute RML documents + data
+ bindings via the (future) ui substrate, never RMLUi API calls. Do not
+ "fix" a missing-RMLUi-header error downstream by propagating it.
+- **Server-level Listener disconnect order is load-bearing**
+ (`Impl::shutdown()`): a Listener outliving the wlr object owning its
signal is a use-after-free (`wl_list_remove` touches neighbor links).
- Entity-level Listeners are exempt: their destroy events fire (and erase
- the entities) during `wl_display_destroy_clients` / backend destroy.
+ Entity-level Listeners (Output/Keyboard/TouchDevice) are exempt: their
+ destroy events fire during `wl_display_destroy_clients` / backend destroy.
- **A Listener handler may destroy its own Listener** (the destroy-event
- pattern) but the erase/delete must be the handler's LAST action — see
- listener.hpp. The slice-4 bus formalizes this.
-- **Touch points record their down-surface's layout origin** to derive
- surface-local motion coords; a surface moving mid-touch (interactive
- grab) skews them. Acceptable until slice 5's input routing.
+ pattern) but the erase/delete must be the handler's LAST action.
+- **No cross-unit `wlr_surface.data`.** The surface→scene-tree association is
+ a typed kernel contract (`Host::host_surface`/`scene_tree_for`, backed by
+ `Server::Impl::surface_assoc`). The map is kernel-owned but the VALUE tree is
+ an extension's; the returned tree is a borrow valid only while the hosting
+ extension's `SurfaceRegistration` lives. Re-hosting a surface supersedes the
+ old handle (token defense), so a stale handle never tears down the new
+ mapping. Private intra-unit `.data` use is still fine; cross-unit must route
+ through the contract.
+- **Pointer button & axis are NOT forwarded by the kernel** (it only moves the
+ cursor and emits `ev_pointer_button`/`ev_pointer_axis`). The pointer-routing
+ extension forwards them via `wlr_seat_pointer_notify_button/_axis`, same as
+ enter/motion/frame — not notifying during a grab is the suppression mechanism.
+ (The old "kernel forwards button/axis" doc comment was a verified lie; fixed.)
- Everything runs on the single `wl_event_loop` thread.
Slice-3 spike gotchas (EGL/dmabuf — read before touching `ui_spike.cpp`):
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index 2c7b971..0786a60 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -8,6 +8,45 @@ kernel_inc = include_directories('include')
egl_dep = dependency('egl')
glesv2_dep = dependency('glesv2')
+# ---- Wayland protocol codegen (the repo's FIRST; this is the template) -------
+#
+# wlroots' <wlr/types/wlr_layer_shell_v1.h> #includes the generated
+# "wlr-layer-shell-unstable-v1-protocol.h" (a wlr-protocols extra not shipped
+# by the system wayland-protocols package, so the XML is vendored read-only in
+# the repo root protocol/). Because wlr.hpp is a PUBLIC header that pulls that
+# wlroots header in, EVERY consumer of kernel_dep must compile against the
+# generated header AND must not build before codegen runs — see the
+# declare_dependency(sources: ...) propagation below, which carries both the
+# include path and the build-ordering edge.
+#
+# wayland-scanner is located via its own pkg-config variable (the canonical
+# pattern wlroots/sway/labwc use), run on the BUILD machine (native: true).
+wayland_scanner_dep = dependency('wayland-scanner', native: true)
+wayland_scanner = find_program(
+ wayland_scanner_dep.get_variable('wayland_scanner'),
+ native: true,
+)
+
+# Repo-root protocol/ holds the vendored XML (read-only; provisioned by the
+# orchestrator). meson.project_source_root() keeps this robust regardless of
+# this subdir's depth.
+wlr_layer_shell_xml = files(
+ meson.project_source_root() / 'protocol' / 'wlr-layer-shell-unstable-v1.xml',
+)
+
+# server-header only: the OUTPUT NAME must be exactly the string wlroots
+# #includes. The interface symbols (wlr_layer_shell_v1.h's deps) are already
+# exported by libwlroots, so the private-code glue is NOT needed here — proven
+# by a clean link below (no undefined wl_*_interface references). Add a
+# matching 'private-code' custom_target only if a future protocol's symbols are
+# not provided by a linked library.
+wlr_layer_shell_protocol_h = custom_target(
+ 'wlr-layer-shell-unstable-v1-protocol.h',
+ input: wlr_layer_shell_xml,
+ output: 'wlr-layer-shell-unstable-v1-protocol.h',
+ command: [wayland_scanner, 'server-header', '@INPUT@', '@OUTPUT@'],
+)
+
# UNBOX_RMLUI_GLES selects the native GLES 3.2 path in the adapted RmlUi GL3
# renderer (src/rmlui_renderer_gl3.cpp) without poisoning the TU with the
# __ANDROID__ builtin. Scoped to this library only.
@@ -15,10 +54,12 @@ kernel_lib = static_library(
'unbox-kernel',
'src/kernel.cpp',
'src/server.cpp',
- 'src/toplevel.cpp',
'src/input.cpp',
'src/ui_spike.cpp',
'src/rmlui_renderer_gl3.cpp',
+ # Listing the generated header as a source forces codegen before any kernel
+ # TU compiles and puts its build dir on this lib's include path.
+ wlr_layer_shell_protocol_h,
cpp_args: ['-DUNBOX_RMLUI_GLES'],
include_directories: kernel_inc,
dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep,
@@ -28,8 +69,13 @@ kernel_lib = static_library(
# What consumers get. wlroots/wayland propagate because wlr.hpp is a public
# header; RMLUi does NOT — it is kernel-private (the ui substrate owns it,
# extensions contribute RML documents + data bindings, never RMLUi calls).
+# The generated protocol header rides in `sources:` so every consumer of
+# kernel_dep gets BOTH the include path for it AND a build-ordering edge to the
+# codegen custom_target — a consumer can never compile wlr.hpp before the
+# header exists.
kernel_dep = declare_dependency(
link_with: kernel_lib,
+ sources: [wlr_layer_shell_protocol_h],
include_directories: kernel_inc,
dependencies: [wlroots_dep, wayland_server_dep],
)
diff --git a/packages/kernel/src/input.cpp b/packages/kernel/src/input.cpp
index 7095154..b6c4c0c 100644
--- a/packages/kernel/src/input.cpp
+++ b/packages/kernel/src/input.cpp
@@ -4,6 +4,14 @@
namespace unbox::kernel {
+// Slice 4: the kernel owns generic input PLUMBING only. It moves the cursor,
+// tracks seat capabilities, handles the seat's own protocol requests, runs the
+// key_filter (consume-or-pass), and EMITS typed events. It routes NOTHING to
+// client surfaces and makes NO focus decision — ext-xdg-shell (and others) do
+// that from the bus + borrows. The only client forward the kernel still does
+// is keyboard key passthrough AFTER the filter, because the seat already holds
+// the focus an extension set via wlr_seat_keyboard_notify_enter.
+
// ---- Device hotplug -----------------------------------------------------------
void Server::Impl::handle_new_input(wlr_input_device* device) {
@@ -44,7 +52,7 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
keyboard->keyboard = wlr_kb;
keyboards.push_back(std::move(owned));
- // Default XKB keymap (layout "us" etc.); unbox.toml takes over later.
+ // Default XKB keymap; per-config keymap is a later slice.
xkb_context* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
xkb_keymap* keymap = xkb_keymap_new_from_names(context, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
wlr_keyboard_set_keymap(wlr_kb, keymap);
@@ -53,7 +61,6 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
wlr_keyboard_set_repeat_info(wlr_kb, 25, 600);
keyboard->modifiers.connect(wlr_kb->events.modifiers, [this, keyboard](void*) {
- // The seat exposes one logical keyboard; swap the active device in.
wlr_seat_set_keyboard(seat, keyboard->keyboard);
wlr_seat_keyboard_notify_modifiers(seat, &keyboard->keyboard->modifiers);
});
@@ -65,16 +72,39 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
const xkb_keysym_t* syms = nullptr;
const int nsyms =
xkb_state_key_get_syms(keyboard->keyboard->xkb_state, keycode, &syms);
-
- bool handled = false;
const std::uint32_t modifiers = wlr_keyboard_get_modifiers(keyboard->keyboard);
- if ((modifiers & WLR_MODIFIER_ALT) != 0 &&
- event->state == WL_KEYBOARD_KEY_STATE_PRESSED) {
- for (int i = 0; i < nsyms; i++) {
- handled = handle_keybinding(syms[i]);
- }
- }
- if (!handled) {
+ const bool pressed = event->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+
+ // Thread each resolved keysym through the key_filter; a filter link may
+ // CONSUME the key (set handled=true) — that is how extensions implement
+ // compositor shortcuts. If any resolution is consumed, suppress the
+ // client forward for this key.
+ bool consumed = false;
+ for (int i = 0; i < nsyms; ++i) {
+ KeyEvent ke{};
+ ke.keysym = syms[i];
+ ke.keycode = event->keycode;
+ ke.modifiers = modifiers;
+ ke.pressed = pressed;
+ ke.time_msec = event->time_msec;
+ ke.handled = false;
+ ke = key_filter.apply(ke);
+ consumed = consumed || ke.handled;
+ }
+ // With no resolved syms (e.g. modifier-only) the filter still runs once
+ // so extensions can observe raw modifier keys if they wish.
+ if (nsyms == 0) {
+ KeyEvent ke{};
+ ke.keysym = XKB_KEY_NoSymbol;
+ ke.keycode = event->keycode;
+ ke.modifiers = modifiers;
+ ke.pressed = pressed;
+ ke.time_msec = event->time_msec;
+ ke = key_filter.apply(ke);
+ consumed = consumed || ke.handled;
+ }
+
+ if (!consumed) {
wlr_seat_set_keyboard(seat, keyboard->keyboard);
wlr_seat_keyboard_notify_key(seat, event->time_msec, event->keycode, event->state);
}
@@ -89,8 +119,8 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
}
void Server::Impl::new_pointer(wlr_input_device* device) {
- // All pointer handling is proxied through wlr_cursor; per-device
- // libinput config (acceleration, tap…) is a later slice.
+ // All pointer handling is proxied through wlr_cursor; per-device libinput
+ // config is a later slice.
wlr_cursor_attach_input_device(cursor, device);
}
@@ -107,225 +137,104 @@ void Server::Impl::new_touch(wlr_input_device* device) {
touch_devices.remove_if([touch](const auto& owned) { return owned.get() == touch; });
});
- // wlr_cursor aggregates touch devices too and emits layout-mapped
- // touch_* events (handled below).
wlr_cursor_attach_input_device(cursor, device);
}
-// ---- Compositor keybindings ------------------------------------------------------
-
-auto Server::Impl::handle_keybinding(std::uint32_t keysym) -> bool {
- // Slice-2 placeholder bindings (Alt held), replaced by the keybinding
- // filter chain in slice 5.
- switch (keysym) {
- case XKB_KEY_Escape:
- wl_display_terminate(display);
- return true;
- case XKB_KEY_F1:
- if (mapped_toplevels.size() >= 2) {
- focus_toplevel(mapped_toplevels.back());
- }
- return true;
- default:
- return false;
- }
-}
+// ---- Pointer (via wlr_cursor): move cursor + emit, route nothing ------------
-// ---- Pointer (via wlr_cursor) ------------------------------------------------------
-
-void Server::Impl::process_cursor_move() {
- wlr_scene_node_set_position(&grabbed_toplevel->scene_tree->node,
- static_cast<int>(cursor->x - grab_x),
- static_cast<int>(cursor->y - grab_y));
-}
-
-void Server::Impl::process_cursor_resize() {
- // Resizing moves the node when dragging top/left edges; the client is
- // asked for the new size (it commits a matching buffer later).
- Toplevel* toplevel = grabbed_toplevel;
- const double border_x = cursor->x - grab_x;
- const double border_y = cursor->y - grab_y;
- int new_left = grab_geobox.x;
- int new_right = grab_geobox.x + grab_geobox.width;
- int new_top = grab_geobox.y;
- int new_bottom = grab_geobox.y + grab_geobox.height;
-
- if ((resize_edges & WLR_EDGE_TOP) != 0) {
- new_top = static_cast<int>(border_y);
- if (new_top >= new_bottom) {
- new_top = new_bottom - 1;
- }
- } else if ((resize_edges & WLR_EDGE_BOTTOM) != 0) {
- new_bottom = static_cast<int>(border_y);
- if (new_bottom <= new_top) {
- new_bottom = new_top + 1;
- }
- }
- if ((resize_edges & WLR_EDGE_LEFT) != 0) {
- new_left = static_cast<int>(border_x);
- if (new_left >= new_right) {
- new_left = new_right - 1;
- }
- } else if ((resize_edges & WLR_EDGE_RIGHT) != 0) {
- new_right = static_cast<int>(border_x);
- if (new_right <= new_left) {
- new_right = new_left + 1;
- }
- }
-
- wlr_box* geo_box = &toplevel->xdg_toplevel->base->geometry;
- wlr_scene_node_set_position(&toplevel->scene_tree->node, new_left - geo_box->x,
- new_top - geo_box->y);
- wlr_xdg_toplevel_set_size(toplevel->xdg_toplevel, new_right - new_left,
- new_bottom - new_top);
-}
-
-void Server::Impl::process_cursor_motion(std::uint32_t time_msec) {
- if (cursor_mode == CursorMode::Move) {
- process_cursor_move();
- return;
- }
- if (cursor_mode == CursorMode::Resize) {
- process_cursor_resize();
- return;
- }
-
- // Slice-3 spike input proof (NOT the slice-5 routing contract): if the
- // cursor is over the spike node, forward surface-local coords to RmlUi so
- // the document's button reacts to hover. Crude and private.
+void Server::Impl::emit_pointer_motion(std::uint32_t time_msec) {
+ // Slice-3 spike input proof (kernel-internal; NOT a contract): forward
+ // surface-local coords over the spike node so its button hovers.
if (ui_spike != nullptr) {
if (wlr_scene_node* spike = ui_spike->node()) {
int nx = 0;
int ny = 0;
wlr_scene_node_coords(spike, &nx, &ny);
- const double sx = cursor->x - nx;
- const double sy = cursor->y - ny;
- ui_spike->on_pointer_motion(sx, sy);
+ ui_spike->on_pointer_motion(cursor->x - nx, cursor->y - ny);
}
}
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- Toplevel* toplevel = toplevel_at(cursor->x, cursor->y, &surface, &sx, &sy);
- if (toplevel == nullptr) {
- // Over no toplevel: the compositor draws its own default cursor.
- wlr_cursor_set_xcursor(cursor, cursor_mgr, "default");
- }
- if (surface != nullptr) {
- // Enter gives the surface pointer focus; wlroots dedupes repeats.
- wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
- wlr_seat_pointer_notify_motion(seat, time_msec, sx, sy);
- } else {
- wlr_seat_pointer_clear_focus(seat);
- }
+ const PointerMotionEvent ev{cursor->x, cursor->y, time_msec};
+ ev_pointer_motion.emit(ev);
}
void Server::Impl::attach_cursor_handlers() {
cursor_motion.connect(cursor->events.motion, [this](void* data) {
const auto* event = static_cast<wlr_pointer_motion_event*>(data);
wlr_cursor_move(cursor, &event->pointer->base, event->delta_x, event->delta_y);
- process_cursor_motion(event->time_msec);
+ emit_pointer_motion(event->time_msec);
});
cursor_motion_absolute.connect(cursor->events.motion_absolute, [this](void* data) {
const auto* event = static_cast<wlr_pointer_motion_absolute_event*>(data);
wlr_cursor_warp_absolute(cursor, &event->pointer->base, event->x, event->y);
- process_cursor_motion(event->time_msec);
+ emit_pointer_motion(event->time_msec);
});
cursor_button.connect(cursor->events.button, [this](void* data) {
const auto* event = static_cast<wlr_pointer_button_event*>(data);
- wlr_seat_pointer_notify_button(seat, event->time_msec, event->button, event->state);
+ const bool pressed = event->state == WL_POINTER_BUTTON_STATE_PRESSED;
- // Slice-3 spike input proof: forward clicks over the spike node to
- // RmlUi so its button reacts to press/release. Crude and private.
+ // Slice-3 spike input proof (kernel-internal): forward clicks over the
+ // spike node so its button reacts.
if (ui_spike != nullptr) {
if (wlr_scene_node* spike = ui_spike->node()) {
- int nx = 0;
- int ny = 0;
- wlr_scene_node_coords(spike, &nx, &ny);
if (wlr_scene_node_at(spike, cursor->x, cursor->y, nullptr, nullptr) != nullptr) {
- ui_spike->on_pointer_button(event->state ==
- WL_POINTER_BUTTON_STATE_PRESSED);
+ ui_spike->on_pointer_button(pressed);
}
}
}
- if (event->state == WL_POINTER_BUTTON_STATE_RELEASED) {
- reset_cursor_mode();
- } else {
- // Click-to-focus.
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- focus_toplevel(toplevel_at(cursor->x, cursor->y, &surface, &sx, &sy));
- }
+ const PointerButtonEvent ev{event->button, pressed, cursor->x, cursor->y,
+ event->time_msec};
+ ev_pointer_button.emit(ev);
});
cursor_axis.connect(cursor->events.axis, [this](void* data) {
const auto* event = static_cast<wlr_pointer_axis_event*>(data);
- wlr_seat_pointer_notify_axis(seat, event->time_msec, event->orientation, event->delta,
- event->delta_discrete, event->source,
- event->relative_direction);
- });
- cursor_frame.connect(cursor->events.frame, [this](void*) {
- wlr_seat_pointer_notify_frame(seat);
+ const PointerAxisEvent ev{event->orientation, event->delta, event->delta_discrete,
+ event->source, event->time_msec};
+ ev_pointer_axis.emit(ev);
});
+ cursor_frame.connect(cursor->events.frame, [this](void*) { ev_pointer_frame.emit(); });
- // ---- Touch (tinywl doesn't have this; the CF-AX3 does) ----
+ // ---- Touch (tinywl lacks this; the CF-AX3 has it). Convert to layout
+ // coords + emit; extensions route to surfaces. ----
cursor_touch_down.connect(cursor->events.touch_down, [this](void* data) {
const auto* event = static_cast<wlr_touch_down_event*>(data);
double lx = 0;
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- Toplevel* toplevel = toplevel_at(lx, ly, &surface, &sx, &sy);
- if (toplevel != nullptr) {
- focus_toplevel(toplevel); // tap raises + focuses
- }
- if (surface != nullptr) {
- touch_points.insert_or_assign(event->touch_id,
- TouchPoint{surface, lx - sx, ly - sy});
- wlr_seat_touch_notify_down(seat, surface, event->time_msec, event->touch_id, sx, sy);
- }
+ const TouchDownEvent ev{event->touch_id, lx, ly, event->time_msec};
+ ev_touch_down.emit(ev);
});
cursor_touch_motion.connect(cursor->events.touch_motion, [this](void* data) {
const auto* event = static_cast<wlr_touch_motion_event*>(data);
- auto it = touch_points.find(event->touch_id);
- if (it == touch_points.end()) {
- return; // down landed on no surface; nothing is grabbed
- }
double lx = 0;
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
- wlr_seat_touch_notify_motion(seat, event->time_msec, event->touch_id,
- lx - it->second.origin_x, ly - it->second.origin_y);
+ const TouchMotionEvent ev{event->touch_id, lx, ly, event->time_msec};
+ ev_touch_motion.emit(ev);
});
cursor_touch_up.connect(cursor->events.touch_up, [this](void* data) {
const auto* event = static_cast<wlr_touch_up_event*>(data);
- touch_points.erase(event->touch_id);
- wlr_seat_touch_notify_up(seat, event->time_msec, event->touch_id);
+ const TouchUpEvent ev{event->touch_id, event->time_msec};
+ ev_touch_up.emit(ev);
});
cursor_touch_cancel.connect(cursor->events.touch_cancel, [this](void* data) {
const auto* event = static_cast<wlr_touch_cancel_event*>(data);
- if (wlr_touch_point* point = wlr_seat_touch_get_point(seat, event->touch_id)) {
- wlr_seat_touch_notify_cancel(seat, point->client);
- }
- touch_points.erase(event->touch_id);
+ const TouchCancelEvent ev{event->touch_id};
+ ev_touch_cancel.emit(ev);
});
cursor_touch_frame.connect(cursor->events.touch_frame, [this](void*) {
- wlr_seat_touch_notify_frame(seat);
+ ev_touch_frame.emit();
});
}
-// ---- Seat requests -------------------------------------------------------------
+// ---- Seat requests (generic protocol glue) ----------------------------------
void Server::Impl::attach_seat_handlers() {
seat_request_cursor.connect(seat->events.request_set_cursor, [this](void* data) {
const auto* event = static_cast<wlr_seat_pointer_request_set_cursor_event*>(data);
- // Any client may send this; honor only the pointer-focused one.
if (seat->pointer_state.focused_client == event->seat_client) {
wlr_cursor_set_surface(cursor, event->surface, event->hotspot_x, event->hotspot_y);
}
diff --git a/packages/kernel/src/listener.hpp b/packages/kernel/src/listener.hpp
index 5b39f74..8b36b3f 100644
--- a/packages/kernel/src/listener.hpp
+++ b/packages/kernel/src/listener.hpp
@@ -1,56 +1,7 @@
#pragma once
-#include <unbox/kernel/wlr.hpp>
-
-#include <functional>
-#include <utility>
-
-namespace unbox::kernel {
-
-// RAII wl_listener: connect() subscribes, destruction/disconnect()
-// unsubscribes. PRIVATE slice-2 helper — the public typed subscription
-// handle arrives with the bus in slice 4 (.unbox/rules/listener-lifetime.md).
-//
-// A handler MAY destroy its own Listener (the destroy-event pattern: a
-// handler erases its owning entity from a container). This is safe because
-// thunk() touches nothing after handler_() returns — but the handler itself
-// must not touch captures after triggering its own destruction; make the
-// erase/delete its LAST action.
-class Listener {
-public:
- Listener() {
- node_.self = this;
- node_.listener.notify = &Listener::thunk;
- wl_list_init(&node_.listener.link);
- }
- ~Listener() { disconnect(); }
- Listener(const Listener&) = delete;
- auto operator=(const Listener&) -> Listener& = delete;
-
- void connect(wl_signal& signal, std::function<void(void*)> handler) {
- disconnect();
- handler_ = std::move(handler);
- wl_signal_add(&signal, &node_.listener);
- }
-
- void disconnect() {
- wl_list_remove(&node_.listener.link);
- wl_list_init(&node_.listener.link);
- }
-
-private:
- struct Node {
- wl_listener listener; // MUST stay first: thunk casts wl_listener* -> Node*
- Listener* self;
- };
-
- static void thunk(wl_listener* listener, void* data) {
- auto* node = reinterpret_cast<Node*>(listener);
- node->self->handler_(data);
- }
-
- Node node_{};
- std::function<void(void*)> handler_;
-};
-
-} // namespace unbox::kernel
+// The RAII wl_listener wrapper is now a PUBLIC contract type (slice 4):
+// extensions do their own wlroots glue and must never hold a bare wl_listener.
+// The kernel's own glue uses the very same type. This shim keeps the existing
+// `#include "listener.hpp"` sites in src/ pointing at the public definition.
+#include <unbox/kernel/listener.hpp>
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index df24f1f..e3308de 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -3,6 +3,8 @@
#include <ctime>
#include <stdexcept>
#include <unistd.h>
+#include <unordered_map>
+#include <utility>
namespace unbox::kernel {
@@ -42,7 +44,16 @@ auto Server::socket_name() const -> std::string {
return impl_->socket;
}
+void Server::install(std::unique_ptr<Extension> extension) {
+ impl_->install(std::move(extension));
+}
+
+void Server::activate_extensions() {
+ impl_->activate_extensions();
+}
+
void Server::run() {
+ impl_->activate_extensions();
wlr_log(WLR_INFO, "unbox running on WAYLAND_DISPLAY=%s", impl_->socket.c_str());
wl_display_run(impl_->display);
}
@@ -68,6 +79,27 @@ auto Server::ui_spike_orientation() const -> int {
// ---- Impl lifecycle --------------------------------------------------------
+void Server::Impl::register_hook(detail::HookBase& hook) {
+ hook.set_sink(this);
+ all_hooks.push_back(&hook);
+}
+
+void Server::Impl::disable(ExtensionId who) noexcept {
+ // Error isolation: a callback owned by `who` threw. Mark the extension dead
+ // and purge its subscriptions from every hook. Safe mid-dispatch — each
+ // hook tombstones now and compacts when its dispatch unwinds.
+ for (ExtensionSlot& slot : extensions) {
+ if (slot.id == who && !slot.disabled) {
+ slot.disabled = true;
+ wlr_log(WLR_ERROR, "extension '%s' disabled: a hook callback threw",
+ slot.extension->manifest().id.c_str());
+ }
+ }
+ for (detail::HookBase* hook : all_hooks) {
+ hook->purge(who);
+ }
+}
+
void Server::Impl::init() {
wlr_log_init(WLR_INFO, nullptr);
@@ -91,13 +123,12 @@ void Server::Impl::init() {
scene_layout = require(wlr_scene_attach_output_layout(scene, output_layout),
"wlr_scene_output_layout");
- xdg_shell = require(wlr_xdg_shell_create(display, 3), "wlr_xdg_shell");
- new_xdg_toplevel.connect(xdg_shell->events.new_toplevel, [this](void* data) {
- handle_new_toplevel(static_cast<wlr_xdg_toplevel*>(data));
- });
- new_xdg_popup.connect(xdg_shell->events.new_popup, [this](void* data) {
- handle_new_popup(static_cast<wlr_xdg_popup*>(data));
- });
+ // Ordered z-bands. wlr_scene_tree_create appends as the top child of its
+ // parent, so creating background -> overlay yields exactly that stacking
+ // order (background lowest, overlay highest).
+ for (auto& layer : scene_layers) {
+ layer = require(wlr_scene_tree_create(&scene->tree), "wlr_scene_tree (layer)");
+ }
cursor = require(wlr_cursor_create(), "wlr_cursor");
wlr_cursor_attach_output_layout(cursor, output_layout);
@@ -110,6 +141,24 @@ void Server::Impl::init() {
seat = require(wlr_seat_create(display, "seat0"), "wlr_seat");
attach_seat_handlers();
+ // Register every kernel-emitted hook with the isolation registry.
+ for (detail::HookBase* hook : {
+ static_cast<detail::HookBase*>(&ev_output_added),
+ static_cast<detail::HookBase*>(&ev_output_removed),
+ static_cast<detail::HookBase*>(&ev_pointer_motion),
+ static_cast<detail::HookBase*>(&ev_pointer_button),
+ static_cast<detail::HookBase*>(&ev_pointer_axis),
+ static_cast<detail::HookBase*>(&ev_pointer_frame),
+ static_cast<detail::HookBase*>(&ev_touch_down),
+ static_cast<detail::HookBase*>(&ev_touch_motion),
+ static_cast<detail::HookBase*>(&ev_touch_up),
+ static_cast<detail::HookBase*>(&ev_touch_cancel),
+ static_cast<detail::HookBase*>(&ev_touch_frame),
+ static_cast<detail::HookBase*>(&key_filter),
+ }) {
+ all_hooks.push_back(hook); // sink already set via {this} constructor
+ }
+
const char* socket_cstr = wl_display_add_socket_auto(display);
if (socket_cstr == nullptr) {
throw std::runtime_error("failed to add a Wayland socket");
@@ -135,11 +184,96 @@ void Server::Impl::init() {
}
}
+// ---- Extension host --------------------------------------------------------
+
+void Server::Impl::install(std::unique_ptr<Extension> extension) {
+ if (extensions_activated) {
+ throw std::runtime_error("Server::install called after activate_extensions");
+ }
+ const std::string& id = extension->manifest().id;
+ for (const ExtensionSlot& slot : extensions) {
+ if (slot.extension->manifest().id == id) {
+ throw std::runtime_error("duplicate extension id: " + id);
+ }
+ }
+ ExtensionSlot slot;
+ // id 0 is the kernel; extensions start at 1.
+ slot.id = static_cast<ExtensionId>(extensions.size() + 1);
+ slot.host = std::make_unique<HostImpl>(this, slot.id);
+ slot.extension = std::move(extension);
+ extensions.push_back(std::move(slot));
+}
+
+void Server::Impl::activate_extensions() {
+ if (extensions_activated) {
+ return;
+ }
+ extensions_activated = true;
+
+ // Topological sort by Manifest depends_on. Index extensions by id; ties
+ // (no dependency relation) are broken by tier (core before standard) then
+ // install order — deterministic activation.
+ const std::size_t n = extensions.size();
+ std::unordered_map<std::string, std::size_t> by_id;
+ for (std::size_t i = 0; i < n; ++i) {
+ by_id.emplace(extensions[i].extension->manifest().id, i);
+ }
+
+ // Build adjacency (dep -> dependents) and indegree; validate deps exist.
+ std::vector<std::vector<std::size_t>> dependents(n);
+ std::vector<int> indegree(n, 0);
+ for (std::size_t i = 0; i < n; ++i) {
+ const Manifest& m = extensions[i].extension->manifest();
+ for (const std::string& dep : m.depends_on) {
+ auto it = by_id.find(dep);
+ if (it == by_id.end()) {
+ throw std::runtime_error("extension '" + m.id +
+ "' depends on missing extension '" + dep + "'");
+ }
+ dependents[it->second].push_back(i);
+ ++indegree[i];
+ }
+ }
+
+ // Kahn's algorithm with a deterministic tie-break: among ready nodes pick
+ // the lowest (tier, install-index). A linear scan is fine for the handful
+ // of extensions a session installs.
+ auto rank = [&](std::size_t i) {
+ return std::pair<int, std::size_t>(
+ static_cast<int>(extensions[i].extension->manifest().tier), i);
+ };
+ std::vector<bool> done(n, false);
+ std::vector<std::size_t> order;
+ order.reserve(n);
+ for (std::size_t step = 0; step < n; ++step) {
+ std::size_t pick = n;
+ for (std::size_t i = 0; i < n; ++i) {
+ if (!done[i] && indegree[i] == 0) {
+ if (pick == n || rank(i) < rank(pick)) {
+ pick = i;
+ }
+ }
+ }
+ if (pick == n) {
+ throw std::runtime_error("extension dependency cycle detected");
+ }
+ done[pick] = true;
+ order.push_back(pick);
+ for (std::size_t d : dependents[pick]) {
+ --indegree[d];
+ }
+ }
+
+ // Activate in topological order. An activate() throw is FATAL (not
+ // isolated): a core extension that cannot start is a broken session.
+ for (std::size_t i : order) {
+ ExtensionSlot& slot = extensions[i];
+ slot.extension->activate(*slot.host);
+ slot.activated = true;
+ }
+}
+
void Server::Impl::start_ui_spike() {
- // The bridge needs the wlr renderer's EGLDisplay to build its sibling
- // GLES 3.2 context. Only the gles2 renderer exposes one; under the
- // pixman renderer (e.g. headless CI) there is no GL path, so the spike
- // stays disabled — slice-2 behaviour is preserved.
if (!wlr_renderer_is_gles2(renderer)) {
wlr_log(WLR_INFO, "ui-spike: renderer is not gles2; spike disabled");
return;
@@ -150,24 +284,34 @@ void Server::Impl::start_ui_spike() {
return;
}
EGLDisplay display_egl = wlr_egl_get_display(egl);
- ui_spike = UiSpike::create(&scene->tree, display_egl, allocator, renderer);
+ // The spike sits in the overlay band so it composites above everything.
+ ui_spike = UiSpike::create(scene_layers[static_cast<std::size_t>(SceneLayer::overlay)],
+ display_egl, allocator, renderer);
}
void Server::Impl::shutdown() {
- // Slice-3 spike: tear down before scene/renderer/allocator die (it owns
- // a scene node, GL objects on a sibling context, and borrows the others).
+ // Destroy extensions FIRST, in reverse activation order: their RAII members
+ // (Subscriptions, Listeners, scene nodes) release while the wlr objects
+ // they borrow are still alive. Reverse of `extensions` install order is a
+ // safe superset of reverse-topological (a dependent installed later than
+ // its dependency dies first; if installed earlier, it still only borrows).
+ for (auto it = extensions.rbegin(); it != extensions.rend(); ++it) {
+ it->extension.reset();
+ it->host.reset();
+ }
+ extensions.clear();
+
+ // Slice-3 spike: tear down before scene/renderer/allocator die.
ui_spike.reset();
if (display != nullptr) {
- wl_display_destroy_clients(display); // fires toplevel/popup destroy events
+ wl_display_destroy_clients(display);
}
- // Server-level listeners must detach BEFORE the wlr objects owning their
- // signals die; a wl_listener outliving its signal is a use-after-free.
+ // Server-level listeners detach BEFORE the wlr objects owning their signals
+ // die; a wl_listener outliving its signal is a use-after-free.
new_output.disconnect();
new_input.disconnect();
- new_xdg_toplevel.disconnect();
- new_xdg_popup.disconnect();
cursor_motion.disconnect();
cursor_motion_absolute.disconnect();
cursor_button.disconnect();
@@ -203,7 +347,7 @@ void Server::Impl::shutdown() {
renderer = nullptr;
}
if (backend != nullptr) {
- wlr_backend_destroy(backend); // fires output + input-device destroy events
+ wlr_backend_destroy(backend);
backend = nullptr;
}
if (display != nullptr) {
@@ -233,12 +377,9 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
outputs.push_back(std::move(owned));
output->frame.connect(wlr_output->events.frame, [this, output](void*) {
- // Slice-3 spike: render the RMLUi document if dirty, before commit so
- // its damage is picked up this frame. Cheap no-op when disabled.
if (ui_spike != nullptr) {
ui_spike->tick();
}
-
wlr_scene_output* scene_output = wlr_scene_get_scene_output(scene, output->output);
wlr_scene_output_commit(scene_output, nullptr);
@@ -251,6 +392,8 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
wlr_output_commit_state(output->output, event->state);
});
output->destroy.connect(wlr_output->events.destroy, [this, output](void*) {
+ const OutputEvent ev{output->output};
+ ev_output_removed.emit(ev);
// Last action: destroys `output` (and these listeners with it).
outputs.remove_if([output](const auto& owned) { return owned.get() == output; });
});
@@ -260,6 +403,8 @@ void Server::Impl::handle_new_output(wlr_output* 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);
}
} // namespace unbox::kernel
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index 2975537..c5615b2 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -1,28 +1,33 @@
#pragma once
+#include <unbox/kernel/host.hpp>
#include <unbox/kernel/server.hpp>
#include <unbox/kernel/wlr.hpp>
#include "listener.hpp"
#include "ui_spike.hpp"
+#include <array>
#include <cstdint>
#include <list>
#include <memory>
#include <string>
+#include <typeindex>
#include <unordered_map>
-
-// Private kernel state. Entity structs mirror tinywl's, with Listener
-// members replacing manual wl_list_remove bookkeeping (RAII unsubscribes).
-// Definitions are split: server.cpp (lifecycle + outputs), toplevel.cpp
-// (xdg-shell + focus + grabs), input.cpp (devices, cursor, touch, seat).
+#include <vector>
+
+// Private kernel state (slice 4). The kernel names no concrete feature: shell
+// policy (xdg-shell toplevels/popups, focus, cycling, interactive move/resize,
+// keybindings) was EXTRACTED to extensions. What remains is generic plumbing
+// plus the extension host + typed bus.
+//
+// Definitions split: server.cpp (lifecycle, outputs, host/bus/activation),
+// input.cpp (devices, cursor, touch, seat — now event-emitting, not routing).
+//
+// Everything runs on the single wl_event_loop thread.
namespace unbox::kernel {
-struct Toplevel;
-
-enum class CursorMode { Passthrough, Move, Resize };
-
struct Output {
Server::Impl* server = nullptr;
wlr_output* output = nullptr;
@@ -31,26 +36,6 @@ struct Output {
Listener destroy;
};
-struct Toplevel {
- Server::Impl* server = nullptr;
- wlr_xdg_toplevel* xdg_toplevel = nullptr;
- wlr_scene_tree* scene_tree = nullptr;
- Listener map;
- Listener unmap;
- Listener commit;
- Listener destroy;
- Listener request_move;
- Listener request_resize;
- Listener request_maximize;
- Listener request_fullscreen;
-};
-
-struct Popup {
- wlr_xdg_popup* xdg_popup = nullptr;
- Listener commit;
- Listener destroy;
-};
-
struct Keyboard {
Server::Impl* server = nullptr;
wlr_keyboard* keyboard = nullptr;
@@ -65,7 +50,20 @@ struct TouchDevice {
Listener destroy;
};
-struct Server::Impl {
+// ---- Extension host bookkeeping ---------------------------------------------
+
+class HostImpl; // per-extension Host facade (host.cpp)
+
+// One installed extension's kernel-side record.
+struct ExtensionSlot {
+ std::unique_ptr<Extension> extension;
+ std::unique_ptr<HostImpl> host;
+ ExtensionId id{};
+ bool activated = false;
+ bool disabled = false; // tripped when a callback threw (error isolation)
+};
+
+struct Server::Impl : detail::DisableSink {
Options options;
wl_display* display = nullptr;
@@ -75,55 +73,60 @@ struct Server::Impl {
wlr_scene* scene = nullptr;
wlr_scene_output_layout* scene_layout = nullptr;
wlr_output_layout* output_layout = nullptr;
- wlr_xdg_shell* xdg_shell = nullptr;
wlr_cursor* cursor = nullptr;
wlr_xcursor_manager* cursor_mgr = nullptr;
wlr_seat* seat = nullptr;
std::string socket;
- // Slice-3 spike: RMLUi -> wlr_scene bridge. Null unless options.ui_spike
- // and the bridge started; a started-but-disabled bridge is non-null but
- // reports Plan::Disabled. Owned here; torn down in shutdown() BEFORE the
- // scene/renderer/allocator die.
+ // Ordered scene-tree z-bands (SceneLayer order). Created once over
+ // scene->tree in stacking order so background < … < overlay. Extensions
+ // attach nodes via Host::scene_layer(); the kernel owns them.
+ std::array<wlr_scene_tree*, 5> scene_layers{};
+
+ // Slice-3 spike (kernel-internal; not a contract). Torn down in shutdown()
+ // BEFORE scene/renderer/allocator.
std::unique_ptr<UiSpike> ui_spike;
- // Ownership (RAII teardown); drained naturally during shutdown by the
- // destroy events wl_display_destroy_clients / backend destroy fire.
std::list<std::unique_ptr<Output>> outputs;
- std::unordered_map<wlr_xdg_toplevel*, std::unique_ptr<Toplevel>> toplevels;
- std::unordered_map<wlr_xdg_popup*, std::unique_ptr<Popup>> popups;
std::list<std::unique_ptr<Keyboard>> keyboards;
std::list<std::unique_ptr<TouchDevice>> touch_devices;
- // Focus order: front = focused. Contains MAPPED toplevels only.
- std::list<Toplevel*> mapped_toplevels;
-
- // Interactive move/resize grab state (one grab at a time).
- CursorMode cursor_mode = CursorMode::Passthrough;
- Toplevel* grabbed_toplevel = nullptr;
- double grab_x = 0.0;
- double grab_y = 0.0;
- wlr_box grab_geobox{};
- std::uint32_t resize_edges = 0;
-
- // Touch: Wayland implicitly grabs a touch point to the surface that
- // received down; we record the surface's layout origin at down time to
- // derive surface-local coords for motion. Assumes the surface doesn't
- // move mid-touch (true except during interactive grabs; slice 5 will
- // route input properly).
- struct TouchPoint {
- wlr_surface* surface = nullptr;
- double origin_x = 0.0;
- double origin_y = 0.0;
- };
- std::unordered_map<std::int32_t, TouchPoint> touch_points;
-
- // Server-level listeners (disconnected explicitly in shutdown() BEFORE
- // the wlr objects owning their signals are destroyed).
+ // ---- The typed bus: kernel-emitted hooks (host.hpp catalogue) ----
+ Event<const OutputEvent&> ev_output_added{this};
+ Event<const OutputEvent&> ev_output_removed{this};
+ Event<const PointerMotionEvent&> ev_pointer_motion{this};
+ Event<const PointerButtonEvent&> ev_pointer_button{this};
+ Event<const PointerAxisEvent&> ev_pointer_axis{this};
+ Event<> ev_pointer_frame{this};
+ Event<const TouchDownEvent&> ev_touch_down{this};
+ Event<const TouchMotionEvent&> ev_touch_motion{this};
+ Event<const TouchUpEvent&> ev_touch_up{this};
+ Event<const TouchCancelEvent&> ev_touch_cancel{this};
+ Event<> ev_touch_frame{this};
+ Filter<KeyEvent> key_filter{this};
+
+ // Every hook bound to the isolation registry (kernel hooks above + any
+ // extension-exported hooks adopted via Host::adopt). disable() purges an
+ // extension across ALL of them. Raw borrows; lifetimes are the hooks'.
+ std::vector<detail::HookBase*> all_hooks;
+
+ // Typed service registry (one provider per interface type; no strings).
+ std::unordered_map<std::type_index, void*> services;
+
+ // Kernel-owned surface -> scene-tree association (replaces the old untyped
+ // wlr_surface.data cross-extension convention). Shared by all extensions
+ // via Host::host_surface/scene_tree_for; the kernel just stores it.
+ detail::PointerAssoc surface_assoc;
+
+ // Installed extensions, in install order; activation order is computed
+ // topologically in activate_extensions().
+ std::vector<ExtensionSlot> extensions;
+ bool extensions_activated = false;
+
+ // Server-level listeners (disconnected in shutdown() BEFORE the wlr objects
+ // owning their signals die).
Listener new_output;
Listener new_input;
- Listener new_xdg_toplevel;
- Listener new_xdg_popup;
Listener cursor_motion;
Listener cursor_motion_absolute;
Listener cursor_button;
@@ -138,33 +141,96 @@ struct Server::Impl {
Listener seat_pointer_focus_change;
Listener seat_request_set_selection;
- // server.cpp
+ // detail::DisableSink
+ void disable(ExtensionId who) noexcept override;
+
+ // server.cpp — lifecycle + outputs
void init(); // throws std::runtime_error on any component failure
void shutdown();
void handle_new_output(wlr_output* output);
void start_ui_spike(); // slice-3 spike; never throws, may no-op
+ void register_hook(detail::HookBase& hook); // track for purge/disable
- // toplevel.cpp
- void handle_new_toplevel(wlr_xdg_toplevel* toplevel);
- void handle_new_popup(wlr_xdg_popup* popup);
- void focus_toplevel(Toplevel* toplevel);
- auto toplevel_at(double lx, double ly, wlr_surface** surface, double* sx, double* sy)
- -> Toplevel*;
- void begin_interactive(Toplevel* toplevel, CursorMode mode, std::uint32_t edges);
- void reset_cursor_mode();
+ // server.cpp — extension host
+ void install(std::unique_ptr<Extension> extension);
+ void activate_extensions();
- // input.cpp
+ // input.cpp — devices, cursor, touch, seat (event-emitting glue)
void handle_new_input(wlr_input_device* device);
void new_keyboard(wlr_input_device* device);
void new_pointer(wlr_input_device* device);
void new_touch(wlr_input_device* device);
void update_seat_capabilities();
- auto handle_keybinding(std::uint32_t keysym) -> bool;
void attach_cursor_handlers();
void attach_seat_handlers();
- void process_cursor_motion(std::uint32_t time_msec);
- void process_cursor_move();
- void process_cursor_resize();
+ void emit_pointer_motion(std::uint32_t time_msec);
+};
+
+// ---- Per-extension Host facade ----------------------------------------------
+
+class HostImpl final : public Host {
+public:
+ HostImpl(Server::Impl* server, ExtensionId id) : server_(server), id_(id) {}
+
+ auto display() -> wl_display* override { return server_->display; }
+ auto scene() -> wlr_scene* override { return server_->scene; }
+ auto seat() -> wlr_seat* override { return server_->seat; }
+ auto cursor() -> wlr_cursor* override { return server_->cursor; }
+ auto cursor_manager() -> wlr_xcursor_manager* override { return server_->cursor_mgr; }
+ auto output_layout() -> wlr_output_layout* override { return server_->output_layout; }
+ auto scene_layer(SceneLayer layer) -> wlr_scene_tree* override {
+ return server_->scene_layers[static_cast<std::size_t>(layer)];
+ }
+
+ auto on_output_added() -> Event<const OutputEvent&>& override {
+ return server_->ev_output_added;
+ }
+ auto on_output_removed() -> Event<const OutputEvent&>& override {
+ return server_->ev_output_removed;
+ }
+ auto on_pointer_motion() -> Event<const PointerMotionEvent&>& override {
+ return server_->ev_pointer_motion;
+ }
+ auto on_pointer_button() -> Event<const PointerButtonEvent&>& override {
+ return server_->ev_pointer_button;
+ }
+ auto on_pointer_axis() -> Event<const PointerAxisEvent&>& override {
+ return server_->ev_pointer_axis;
+ }
+ auto on_pointer_frame() -> Event<>& override { return server_->ev_pointer_frame; }
+ auto on_touch_down() -> Event<const TouchDownEvent&>& override {
+ return server_->ev_touch_down;
+ }
+ auto on_touch_motion() -> Event<const TouchMotionEvent&>& override {
+ return server_->ev_touch_motion;
+ }
+ auto on_touch_up() -> Event<const TouchUpEvent&>& override { return server_->ev_touch_up; }
+ auto on_touch_cancel() -> Event<const TouchCancelEvent&>& override {
+ return server_->ev_touch_cancel;
+ }
+ auto on_touch_frame() -> Event<>& override { return server_->ev_touch_frame; }
+ auto key_filter() -> Filter<KeyEvent>& override { return server_->key_filter; }
+
+protected:
+ auto extension_id() const -> ExtensionId override { return id_; }
+
+ auto register_service(std::type_index type, void* impl) -> bool override {
+ const bool fresh = server_->services.emplace(type, impl).second;
+ if (!fresh) {
+ server_->services[type] = impl; // replace
+ }
+ return fresh;
+ }
+ auto lookup_service(std::type_index type) -> void* override {
+ auto it = server_->services.find(type);
+ return it == server_->services.end() ? nullptr : it->second;
+ }
+ void adopt_hook(detail::HookBase& hook) override { server_->register_hook(hook); }
+ auto surface_store() -> detail::PointerAssoc& override { return server_->surface_assoc; }
+
+private:
+ Server::Impl* server_;
+ ExtensionId id_;
};
} // namespace unbox::kernel
diff --git a/packages/kernel/src/toplevel.cpp b/packages/kernel/src/toplevel.cpp
deleted file mode 100644
index 150870d..0000000
--- a/packages/kernel/src/toplevel.cpp
+++ /dev/null
@@ -1,190 +0,0 @@
-#include "server_impl.hpp"
-
-#include <algorithm>
-
-namespace unbox::kernel {
-
-// ---- Focus ------------------------------------------------------------------
-
-void Server::Impl::focus_toplevel(Toplevel* toplevel) {
- // Keyboard focus only (pointer focus follows the cursor).
- if (toplevel == nullptr) {
- return;
- }
- wlr_surface* surface = toplevel->xdg_toplevel->base->surface;
- wlr_surface* prev_surface = seat->keyboard_state.focused_surface;
- if (prev_surface == surface) {
- return;
- }
- if (prev_surface != nullptr) {
- // Deactivate the previously focused toplevel (client stops drawing
- // its focused decoration state, e.g. hides the caret).
- wlr_xdg_toplevel* prev = wlr_xdg_toplevel_try_from_wlr_surface(prev_surface);
- if (prev != nullptr) {
- wlr_xdg_toplevel_set_activated(prev, false);
- }
- }
-
- wlr_scene_node_raise_to_top(&toplevel->scene_tree->node);
- auto it = std::find(mapped_toplevels.begin(), mapped_toplevels.end(), toplevel);
- if (it != mapped_toplevels.end()) {
- mapped_toplevels.splice(mapped_toplevels.begin(), mapped_toplevels, it);
- }
- wlr_xdg_toplevel_set_activated(toplevel->xdg_toplevel, true);
-
- // The seat tracks the focused surface and routes key events to it.
- if (wlr_keyboard* keyboard = wlr_seat_get_keyboard(seat)) {
- wlr_seat_keyboard_notify_enter(seat, surface, keyboard->keycodes,
- keyboard->num_keycodes, &keyboard->modifiers);
- }
-}
-
-auto Server::Impl::toplevel_at(double lx, double ly, wlr_surface** surface, double* sx, double* sy)
- -> Toplevel* {
- // Topmost scene node at the given layout coords; we only care about
- // buffer nodes belonging to a surface tree rooted at a Toplevel.
- wlr_scene_node* node = wlr_scene_node_at(&scene->tree.node, lx, ly, sx, sy);
- if (node == nullptr || node->type != WLR_SCENE_NODE_BUFFER) {
- return nullptr;
- }
- wlr_scene_buffer* scene_buffer = wlr_scene_buffer_from_node(node);
- wlr_scene_surface* scene_surface = wlr_scene_surface_try_from_buffer(scene_buffer);
- if (scene_surface == nullptr) {
- return nullptr;
- }
- *surface = scene_surface->surface;
-
- // Walk up to the tree whose data field we set: the Toplevel root.
- wlr_scene_tree* tree = node->parent;
- while (tree != nullptr && tree->node.data == nullptr) {
- tree = tree->node.parent;
- }
- if (tree == nullptr) {
- return nullptr;
- }
- return static_cast<Toplevel*>(tree->node.data);
-}
-
-// ---- Interactive move/resize grabs -------------------------------------------
-
-void Server::Impl::reset_cursor_mode() {
- cursor_mode = CursorMode::Passthrough;
- grabbed_toplevel = nullptr;
-}
-
-void Server::Impl::begin_interactive(Toplevel* toplevel, CursorMode mode, std::uint32_t edges) {
- // The compositor consumes pointer events itself during a grab instead
- // of forwarding them. (tinywl note kept: a fuller compositor would
- // verify this against a recent button-press serial.)
- grabbed_toplevel = toplevel;
- cursor_mode = mode;
-
- if (mode == CursorMode::Move) {
- grab_x = cursor->x - toplevel->scene_tree->node.x;
- grab_y = cursor->y - toplevel->scene_tree->node.y;
- } else {
- wlr_box* geo_box = &toplevel->xdg_toplevel->base->geometry;
- const double border_x = (toplevel->scene_tree->node.x + geo_box->x) +
- ((edges & WLR_EDGE_RIGHT) != 0 ? geo_box->width : 0);
- const double border_y = (toplevel->scene_tree->node.y + geo_box->y) +
- ((edges & WLR_EDGE_BOTTOM) != 0 ? geo_box->height : 0);
- grab_x = cursor->x - border_x;
- grab_y = cursor->y - border_y;
-
- grab_geobox = *geo_box;
- grab_geobox.x += toplevel->scene_tree->node.x;
- grab_geobox.y += toplevel->scene_tree->node.y;
- resize_edges = edges;
- }
-}
-
-// ---- xdg-shell toplevels ------------------------------------------------------
-
-void Server::Impl::handle_new_toplevel(wlr_xdg_toplevel* xdg_toplevel) {
- auto owned = std::make_unique<Toplevel>();
- Toplevel* toplevel = owned.get();
- toplevel->server = this;
- toplevel->xdg_toplevel = xdg_toplevel;
- toplevel->scene_tree = wlr_scene_xdg_surface_create(&scene->tree, xdg_toplevel->base);
- toplevel->scene_tree->node.data = toplevel;
- // Popups look this up to find their parent's scene tree.
- xdg_toplevel->base->data = toplevel->scene_tree;
- toplevels.emplace(xdg_toplevel, std::move(owned));
-
- toplevel->map.connect(xdg_toplevel->base->surface->events.map, [this, toplevel](void*) {
- mapped_toplevels.push_front(toplevel);
- focus_toplevel(toplevel);
- wlr_log(WLR_INFO, "toplevel mapped: %s",
- toplevel->xdg_toplevel->title != nullptr ? toplevel->xdg_toplevel->title : "?");
- });
- toplevel->unmap.connect(xdg_toplevel->base->surface->events.unmap, [this, toplevel](void*) {
- if (toplevel == grabbed_toplevel) {
- reset_cursor_mode();
- }
- mapped_toplevels.remove(toplevel);
- });
- toplevel->commit.connect(xdg_toplevel->base->surface->events.commit, [toplevel](void*) {
- if (toplevel->xdg_toplevel->base->initial_commit) {
- // Reply to the initial commit with a 0x0 configure: the client
- // picks its own dimensions.
- wlr_xdg_toplevel_set_size(toplevel->xdg_toplevel, 0, 0);
- }
- });
- toplevel->destroy.connect(xdg_toplevel->events.destroy, [this, toplevel](void*) {
- // Last action: destroys `toplevel` (and these listeners with it).
- toplevels.erase(toplevel->xdg_toplevel);
- });
-
- toplevel->request_move.connect(xdg_toplevel->events.request_move, [this, toplevel](void*) {
- begin_interactive(toplevel, CursorMode::Move, 0);
- });
- toplevel->request_resize.connect(
- xdg_toplevel->events.request_resize, [this, toplevel](void* data) {
- const auto* event = static_cast<wlr_xdg_toplevel_resize_event*>(data);
- begin_interactive(toplevel, CursorMode::Resize, event->edges);
- });
- toplevel->request_maximize.connect(
- xdg_toplevel->events.request_maximize, [toplevel](void*) {
- // Unsupported, but xdg-shell demands a configure reply.
- if (toplevel->xdg_toplevel->base->initialized) {
- wlr_xdg_surface_schedule_configure(toplevel->xdg_toplevel->base);
- }
- });
- toplevel->request_fullscreen.connect(
- xdg_toplevel->events.request_fullscreen, [toplevel](void*) {
- if (toplevel->xdg_toplevel->base->initialized) {
- wlr_xdg_surface_schedule_configure(toplevel->xdg_toplevel->base);
- }
- });
-}
-
-// ---- xdg-shell popups ----------------------------------------------------------
-
-void Server::Impl::handle_new_popup(wlr_xdg_popup* xdg_popup) {
- auto owned = std::make_unique<Popup>();
- Popup* popup = owned.get();
- popup->xdg_popup = xdg_popup;
- popups.emplace(xdg_popup, std::move(owned));
-
- // Parent's scene tree was stashed in base->data when it was created
- // (toplevel or ancestor popup).
- wlr_xdg_surface* parent = wlr_xdg_surface_try_from_wlr_surface(xdg_popup->parent);
- if (parent != nullptr && parent->data != nullptr) {
- auto* parent_tree = static_cast<wlr_scene_tree*>(parent->data);
- xdg_popup->base->data = wlr_scene_xdg_surface_create(parent_tree, xdg_popup->base);
- }
-
- popup->commit.connect(xdg_popup->base->surface->events.commit, [popup](void*) {
- if (popup->xdg_popup->base->initial_commit) {
- // A fuller compositor would also unconstrain the popup to keep
- // it on-screen here.
- wlr_xdg_surface_schedule_configure(popup->xdg_popup->base);
- }
- });
- popup->destroy.connect(xdg_popup->events.destroy, [this, popup](void*) {
- // Last action: destroys `popup` (and these listeners with it).
- popups.erase(popup->xdg_popup);
- });
-}
-
-} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 6f8ce9b..e5b4a64 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -1,10 +1,18 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+#include <unbox/kernel/extension.hpp>
+#include <unbox/kernel/hooks.hpp>
+#include <unbox/kernel/host.hpp>
#include <unbox/kernel/kernel.hpp>
#include <unbox/kernel/server.hpp>
+#include <unbox/kernel/surface_registry.hpp>
#include <cstdlib>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <vector>
TEST_CASE("kernel compiles against and links wlroots + libwayland-server") {
CHECK(unbox::kernel::link_probe());
@@ -99,3 +107,378 @@ TEST_CASE("ui spike submits an upright (non-flipped) buffer") {
unsetenv("UNBOX_UI_SPIKE_FORCE_SHM");
}
+
+// ============================================================================
+// The typed bus — PURE CORE (strict; zero mocks of unbox modules, no wlroots
+// running). A test DisableSink stands in for the kernel's isolation registry.
+// ============================================================================
+
+namespace {
+
+using unbox::kernel::detail::DisableSink;
+using unbox::kernel::detail::HookBase;
+using unbox::kernel::Event;
+using unbox::kernel::ExtensionId;
+using unbox::kernel::Filter;
+using unbox::kernel::Subscription;
+
+// Mirrors Server::Impl's isolation behavior at pure-core scale: on disable(),
+// purge the offending extension from every registered hook. Records who got
+// disabled so tests can assert isolation hit the RIGHT extension.
+struct TestRegistry final : DisableSink {
+ std::vector<HookBase*> hooks;
+ std::vector<ExtensionId> disabled;
+
+ void track(HookBase& h) {
+ h.set_sink(this);
+ hooks.push_back(&h);
+ }
+ void disable(ExtensionId who) noexcept override {
+ disabled.push_back(who);
+ for (HookBase* h : hooks) {
+ h->purge(who);
+ }
+ }
+};
+
+constexpr ExtensionId ext_a{1};
+constexpr ExtensionId ext_b{2};
+constexpr ExtensionId ext_c{3};
+
+} // namespace
+
+TEST_CASE("Event fans out to all listeners in subscription order") {
+ Event<int> ev;
+ std::vector<int> log;
+ auto s1 = ev.subscribe(ext_a, [&](int v) { log.push_back(v + 10); });
+ auto s2 = ev.subscribe(ext_b, [&](int v) { log.push_back(v + 20); });
+ auto s3 = ev.subscribe(ext_c, [&](int v) { log.push_back(v + 30); });
+
+ ev.emit(1);
+ CHECK(log == std::vector<int>{11, 21, 31});
+}
+
+TEST_CASE("Subscription RAII unsubscribes on destruction") {
+ Event<int> ev;
+ int hits = 0;
+ auto outer = ev.subscribe(ext_a, [&](int) { ++hits; });
+ {
+ auto inner = ev.subscribe(ext_b, [&](int) { ++hits; });
+ ev.emit(0);
+ CHECK(hits == 2);
+ }
+ // inner dropped: only outer remains.
+ ev.emit(0);
+ CHECK(hits == 3);
+
+ // Explicit reset() also unsubscribes.
+ outer.reset();
+ CHECK(!outer.active());
+ ev.emit(0);
+ CHECK(hits == 3);
+}
+
+TEST_CASE("Subscription is move-only and the moved-from handle is inert") {
+ Event<int> ev;
+ int hits = 0;
+ Subscription s = ev.subscribe(ext_a, [&](int) { ++hits; });
+ Subscription moved = std::move(s);
+ CHECK(moved.active());
+ CHECK(!s.active());
+ ev.emit(0);
+ CHECK(hits == 1);
+ s.reset(); // no-op on moved-from
+ ev.emit(0);
+ CHECK(hits == 2);
+}
+
+TEST_CASE("a listener may unsubscribe ITSELF during dispatch (deferred removal)") {
+ Event<int> ev;
+ int a = 0;
+ int c = 0;
+ std::unique_ptr<Subscription> self;
+ auto sa = ev.subscribe(ext_a, [&](int) { ++a; });
+ auto sb = ev.subscribe(ext_b, [&](int) { self->reset(); }); // drop self mid-dispatch
+ auto sc = ev.subscribe(ext_c, [&](int) { ++c; });
+ self = std::make_unique<Subscription>(std::move(sb));
+
+ ev.emit(0);
+ // a and c still fired this round despite b removing itself.
+ CHECK(a == 1);
+ CHECK(c == 1);
+
+ ev.emit(0); // b gone now
+ CHECK(a == 2);
+ CHECK(c == 2);
+}
+
+TEST_CASE("re-entrant emit is safe") {
+ Event<int> ev;
+ int inner = 0;
+ bool reentered = false;
+ auto s = ev.subscribe(ext_a, [&](int v) {
+ if (!reentered && v == 1) {
+ reentered = true;
+ ev.emit(2); // re-enter
+ }
+ ++inner;
+ });
+ ev.emit(1);
+ CHECK(inner == 2); // outer (v=1) and inner (v=2)
+}
+
+TEST_CASE("Filter threads the value through links in order") {
+ Filter<int> flt;
+ auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; });
+ auto s2 = flt.subscribe(ext_b, [](int v) { return v * 10; });
+ // (((5)+1)*10) = 60
+ CHECK(flt.apply(5) == 60);
+}
+
+TEST_CASE("Filter with no links returns the value unchanged") {
+ Filter<int> flt;
+ CHECK(flt.apply(42) == 42);
+}
+
+TEST_CASE("error isolation: a throwing listener disables only its extension") {
+ TestRegistry reg;
+ Event<int> ev{&reg};
+ reg.track(ev);
+
+ std::vector<std::string> log;
+ auto sa = ev.subscribe(ext_a, [&](int) { log.emplace_back("a"); });
+ auto sb = ev.subscribe(ext_b, [&](int) {
+ log.emplace_back("b-throw");
+ throw std::runtime_error("boom");
+ });
+ auto sc = ev.subscribe(ext_c, [&](int) { log.emplace_back("c"); });
+
+ ev.emit(0);
+ // All three ran THIS emit (isolation doesn't abort the in-flight fan-out);
+ // b was disabled.
+ CHECK(log == std::vector<std::string>{"a", "b-throw", "c"});
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+
+ log.clear();
+ ev.emit(0);
+ // b's subscription was purged; a and c remain.
+ CHECK(log == std::vector<std::string>{"a", "c"});
+}
+
+TEST_CASE("error isolation: a throwing filter link is skipped and chain continues") {
+ TestRegistry reg;
+ Filter<int> flt{&reg};
+ reg.track(flt);
+
+ auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; });
+ auto s2 = flt.subscribe(ext_b, [](int) -> int { throw std::runtime_error("boom"); });
+ auto s3 = flt.subscribe(ext_c, [](int v) { return v * 10; });
+
+ // a: 0->1, b throws (skipped, value stays 1), c: 1->10.
+ CHECK(flt.apply(0) == 10);
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+
+ // b purged: a then c.
+ CHECK(flt.apply(0) == 10);
+}
+
+TEST_CASE("disabling an extension purges it across MULTIPLE hooks") {
+ TestRegistry reg;
+ Event<int> ev1{&reg};
+ Event<int> ev2{&reg};
+ reg.track(ev1);
+ reg.track(ev2);
+
+ int ev2_hits = 0;
+ // ext_b subscribes to BOTH hooks; throwing on ev1 must drop its ev2 sub too.
+ auto a1 = ev1.subscribe(ext_a, [](int) {});
+ auto b1 = ev1.subscribe(ext_b, [](int) { throw std::runtime_error("boom"); });
+ auto b2 = ev2.subscribe(ext_b, [&](int) { ++ev2_hits; });
+
+ ev1.emit(0); // disables ext_b everywhere
+ ev2.emit(0); // ext_b's ev2 listener must NOT fire
+ CHECK(ev2_hits == 0);
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+}
+
+// ============================================================================
+// Extension host: install + topological activation (no wlroots input needed).
+// ============================================================================
+
+namespace {
+
+// Records activation order into a shared log so tests can assert topo order.
+class RecordingExtension : public unbox::kernel::Extension {
+public:
+ RecordingExtension(unbox::kernel::Manifest m, std::vector<std::string>* log)
+ : manifest_(std::move(m)), log_(log) {}
+ auto manifest() const -> const unbox::kernel::Manifest& override { return manifest_; }
+ void activate(unbox::kernel::Host&) override { log_->push_back(manifest_.id); }
+
+private:
+ unbox::kernel::Manifest manifest_;
+ std::vector<std::string>* log_;
+};
+
+auto make_headless_server() -> std::unique_ptr<unbox::kernel::Server> {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+ return unbox::kernel::Server::create({});
+}
+
+using unbox::kernel::Manifest;
+using unbox::kernel::Tier;
+
+} // namespace
+
+TEST_CASE("activation respects depends_on topological order") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+
+ // Install in an order that does NOT match the dependency order.
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"taskbar", Tier::standard, {"xdg-shell"}}, &log));
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"xdg-shell", Tier::core, {}}, &log));
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"tiling", Tier::standard, {"xdg-shell", "taskbar"}}, &log));
+
+ server->activate_extensions();
+
+ // xdg-shell first (no deps, core tier), then taskbar, then tiling.
+ CHECK(log == std::vector<std::string>{"xdg-shell", "taskbar", "tiling"});
+}
+
+TEST_CASE("activate_extensions is idempotent") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"a", Tier::core, {}}, &log));
+ server->activate_extensions();
+ server->activate_extensions();
+ CHECK(log == std::vector<std::string>{"a"});
+}
+
+TEST_CASE("duplicate extension id is a startup error at install") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"dup", Tier::core, {}}, &log));
+ CHECK_THROWS_AS(server->install(std::make_unique<RecordingExtension>(
+ Manifest{"dup", Tier::standard, {}}, &log)),
+ std::runtime_error);
+}
+
+TEST_CASE("missing dependency is a startup error at activation") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"needs-missing", Tier::core, {"nope"}}, &log));
+ CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error);
+}
+
+TEST_CASE("dependency cycle is a startup error at activation") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"x", Tier::core, {"y"}}, &log));
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"y", Tier::core, {"x"}}, &log));
+ CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error);
+}
+
+TEST_CASE("featureless kernel: zero extensions boots, runs, shuts down clean") {
+ auto server = make_headless_server();
+ CHECK(!server->socket_name().empty());
+ server->activate_extensions(); // no-op with zero extensions
+ for (int i = 0; i < 3; ++i) {
+ CHECK(server->dispatch(10));
+ }
+}
+
+// ============================================================================
+// Typed surface->scene-tree association — PURE CORE (no wlroots). Keys/values
+// are pointer identities; dummy addresses stand in for wlr_surface*/scene_tree*.
+// ============================================================================
+
+namespace {
+
+using unbox::kernel::detail::PointerAssoc;
+using unbox::kernel::SurfaceRegistration;
+
+// Distinct, never-dereferenced sentinel addresses.
+int surf_a_obj = 0, surf_b_obj = 0, tree_1_obj = 0, tree_2_obj = 0;
+void* const surf_a = &surf_a_obj;
+void* const surf_b = &surf_b_obj;
+void* const tree_1 = &tree_1_obj;
+void* const tree_2 = &tree_2_obj;
+
+} // namespace
+
+TEST_CASE("surface assoc: register, lookup, unregister") {
+ PointerAssoc store;
+ CHECK(store.get(surf_a) == nullptr); // unregistered -> null
+
+ SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(reg.active());
+ CHECK(store.get(surf_a) == tree_1);
+ CHECK(store.get(surf_b) == nullptr); // independent key still null
+
+ reg.reset();
+ CHECK(!reg.active());
+ CHECK(store.get(surf_a) == nullptr); // unregistered on reset
+ CHECK(store.size() == 0);
+}
+
+TEST_CASE("surface assoc: RAII handle unregisters on destruction") {
+ PointerAssoc store;
+ {
+ SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(store.get(surf_a) == tree_1);
+ }
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: move transfers ownership; moved-from is inert") {
+ PointerAssoc store;
+ SurfaceRegistration a(&store, surf_a, store.set(surf_a, tree_1));
+ SurfaceRegistration b = std::move(a);
+ CHECK(b.active());
+ CHECK(!a.active());
+ a.reset(); // no-op
+ CHECK(store.get(surf_a) == tree_1); // still registered (b owns it)
+ b.reset();
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: double-register replaces value; stale handle is a no-op") {
+ PointerAssoc store;
+ // First registration of surf_a -> tree_1.
+ SurfaceRegistration first(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(store.get(surf_a) == tree_1);
+
+ // Re-host the SAME surface in tree_2: replaces the mapping, bumps token.
+ SurfaceRegistration second(&store, surf_a, store.set(surf_a, tree_2));
+ CHECK(store.get(surf_a) == tree_2);
+
+ // Destroying the SUPERSEDED first handle must NOT tear down the newer
+ // mapping (token defense).
+ first.reset();
+ CHECK(store.get(surf_a) == tree_2);
+
+ // The current owner still unregisters correctly.
+ second.reset();
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: distinct keys are independent") {
+ PointerAssoc store;
+ SurfaceRegistration ra(&store, surf_a, store.set(surf_a, tree_1));
+ SurfaceRegistration rb(&store, surf_b, store.set(surf_b, tree_2));
+ CHECK(store.get(surf_a) == tree_1);
+ CHECK(store.get(surf_b) == tree_2);
+ CHECK(store.size() == 2);
+ ra.reset();
+ CHECK(store.get(surf_a) == nullptr);
+ CHECK(store.get(surf_b) == tree_2); // unaffected
+}
diff --git a/protocol/wlr-layer-shell-unstable-v1.xml b/protocol/wlr-layer-shell-unstable-v1.xml
new file mode 100644
index 0000000..e9f27e4
--- /dev/null
+++ b/protocol/wlr-layer-shell-unstable-v1.xml
@@ -0,0 +1,407 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="wlr_layer_shell_unstable_v1">
+ <copyright>
+ Copyright © 2017 Drew DeVault
+
+ Permission to use, copy, modify, distribute, and sell this
+ software and its documentation for any purpose is hereby granted
+ without fee, provided that the above copyright notice appear in
+ all copies and that both that copyright notice and this permission
+ notice appear in supporting documentation, and that the name of
+ the copyright holders not be used in advertising or publicity
+ pertaining to distribution of the software without specific,
+ written prior permission. The copyright holders make no
+ representations about the suitability of this software for any
+ purpose. It is provided "as is" without express or implied
+ warranty.
+
+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+ THIS SOFTWARE.
+ </copyright>
+
+ <interface name="zwlr_layer_shell_v1" version="5">
+ <description summary="create surfaces that are layers of the desktop">
+ Clients can use this interface to assign the surface_layer role to
+ wl_surfaces. Such surfaces are assigned to a "layer" of the output and
+ rendered with a defined z-depth respective to each other. They may also be
+ anchored to the edges and corners of a screen and specify input handling
+ semantics. This interface should be suitable for the implementation of
+ many desktop shell components, and a broad number of other applications
+ that interact with the desktop.
+ </description>
+
+ <request name="get_layer_surface">
+ <description summary="create a layer_surface from a surface">
+ Create a layer surface for an existing surface. This assigns the role of
+ layer_surface, or raises a protocol error if another role is already
+ assigned.
+
+ Creating a layer surface from a wl_surface which has a buffer attached
+ or committed is a client error, and any attempts by a client to attach
+ or manipulate a buffer prior to the first layer_surface.configure call
+ must also be treated as errors.
+
+ After creating a layer_surface object and setting it up, the client
+ must perform an initial commit without any buffer attached.
+ The compositor will reply with a layer_surface.configure event.
+ The client must acknowledge it and is then allowed to attach a buffer
+ to map the surface.
+
+ You may pass NULL for output to allow the compositor to decide which
+ output to use. Generally this will be the one that the user most
+ recently interacted with.
+
+ Clients can specify a namespace that defines the purpose of the layer
+ surface.
+ </description>
+ <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
+ <arg name="surface" type="object" interface="wl_surface"/>
+ <arg name="output" type="object" interface="wl_output" allow-null="true"/>
+ <arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
+ <arg name="namespace" type="string" summary="namespace for the layer surface"/>
+ </request>
+
+ <enum name="error">
+ <entry name="role" value="0" summary="wl_surface has another role"/>
+ <entry name="invalid_layer" value="1" summary="layer value is invalid"/>
+ <entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
+ </enum>
+
+ <enum name="layer">
+ <description summary="available layers for surfaces">
+ These values indicate which layers a surface can be rendered in. They
+ are ordered by z depth, bottom-most first. Traditional shell surfaces
+ will typically be rendered between the bottom and top layers.
+ Fullscreen shell surfaces are typically rendered at the top layer.
+ Multiple surfaces can share a single layer, and ordering within a
+ single layer is undefined.
+ </description>
+
+ <entry name="background" value="0"/>
+ <entry name="bottom" value="1"/>
+ <entry name="top" value="2"/>
+ <entry name="overlay" value="3"/>
+ </enum>
+
+ <!-- Version 3 additions -->
+
+ <request name="destroy" type="destructor" since="3">
+ <description summary="destroy the layer_shell object">
+ This request indicates that the client will not use the layer_shell
+ object any more. Objects that have been created through this instance
+ are not affected.
+ </description>
+ </request>
+ </interface>
+
+ <interface name="zwlr_layer_surface_v1" version="5">
+ <description summary="layer metadata interface">
+ An interface that may be implemented by a wl_surface, for surfaces that
+ are designed to be rendered as a layer of a stacked desktop-like
+ environment.
+
+ Layer surface state (layer, size, anchor, exclusive zone,
+ margin, interactivity) is double-buffered, and will be applied at the
+ time wl_surface.commit of the corresponding wl_surface is called.
+
+ Attaching a null buffer to a layer surface unmaps it.
+
+ Unmapping a layer_surface means that the surface cannot be shown by the
+ compositor until it is explicitly mapped again. The layer_surface
+ returns to the state it had right after layer_shell.get_layer_surface.
+ The client can re-map the surface by performing a commit without any
+ buffer attached, waiting for a configure event and handling it as usual.
+ </description>
+
+ <request name="set_size">
+ <description summary="sets the size of the surface">
+ Sets the size of the surface in surface-local coordinates. The
+ compositor will display the surface centered with respect to its
+ anchors.
+
+ If you pass 0 for either value, the compositor will assign it and
+ inform you of the assignment in the configure event. You must set your
+ anchor to opposite edges in the dimensions you omit; not doing so is a
+ protocol error. Both values are 0 by default.
+
+ Size is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="width" type="uint"/>
+ <arg name="height" type="uint"/>
+ </request>
+
+ <request name="set_anchor">
+ <description summary="configures the anchor point of the surface">
+ Requests that the compositor anchor the surface to the specified edges
+ and corners. If two orthogonal edges are specified (e.g. 'top' and
+ 'left'), then the anchor point will be the intersection of the edges
+ (e.g. the top left corner of the output); otherwise the anchor point
+ will be centered on that edge, or in the center if none is specified.
+
+ Anchor is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="anchor" type="uint" enum="anchor"/>
+ </request>
+
+ <request name="set_exclusive_zone">
+ <description summary="configures the exclusive geometry of this surface">
+ Requests that the compositor avoids occluding an area with other
+ surfaces. The compositor's use of this information is
+ implementation-dependent - do not assume that this region will not
+ actually be occluded.
+
+ A positive value is only meaningful if the surface is anchored to one
+ edge or an edge and both perpendicular edges. If the surface is not
+ anchored, anchored to only two perpendicular edges (a corner), anchored
+ to only two parallel edges or anchored to all edges, a positive value
+ will be treated the same as zero.
+
+ A positive zone is the distance from the edge in surface-local
+ coordinates to consider exclusive.
+
+ Surfaces that do not wish to have an exclusive zone may instead specify
+ how they should interact with surfaces that do. If set to zero, the
+ surface indicates that it would like to be moved to avoid occluding
+ surfaces with a positive exclusive zone. If set to -1, the surface
+ indicates that it would not like to be moved to accommodate for other
+ surfaces, and the compositor should extend it all the way to the edges
+ it is anchored to.
+
+ For example, a panel might set its exclusive zone to 10, so that
+ maximized shell surfaces are not shown on top of it. A notification
+ might set its exclusive zone to 0, so that it is moved to avoid
+ occluding the panel, but shell surfaces are shown underneath it. A
+ wallpaper or lock screen might set their exclusive zone to -1, so that
+ they stretch below or over the panel.
+
+ The default value is 0.
+
+ Exclusive zone is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="zone" type="int"/>
+ </request>
+
+ <request name="set_margin">
+ <description summary="sets a margin from the anchor point">
+ Requests that the surface be placed some distance away from the anchor
+ point on the output, in surface-local coordinates. Setting this value
+ for edges you are not anchored to has no effect.
+
+ The exclusive zone includes the margin.
+
+ Margin is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="top" type="int"/>
+ <arg name="right" type="int"/>
+ <arg name="bottom" type="int"/>
+ <arg name="left" type="int"/>
+ </request>
+
+ <enum name="keyboard_interactivity">
+ <description summary="types of keyboard interaction possible for a layer shell surface">
+ Types of keyboard interaction possible for layer shell surfaces. The
+ rationale for this is twofold: (1) some applications are not interested
+ in keyboard events and not allowing them to be focused can improve the
+ desktop experience; (2) some applications will want to take exclusive
+ keyboard focus.
+ </description>
+
+ <entry name="none" value="0">
+ <description summary="no keyboard focus is possible">
+ This value indicates that this surface is not interested in keyboard
+ events and the compositor should never assign it the keyboard focus.
+
+ This is the default value, set for newly created layer shell surfaces.
+
+ This is useful for e.g. desktop widgets that display information or
+ only have interaction with non-keyboard input devices.
+ </description>
+ </entry>
+ <entry name="exclusive" value="1">
+ <description summary="request exclusive keyboard focus">
+ Request exclusive keyboard focus if this surface is above the shell surface layer.
+
+ For the top and overlay layers, the seat will always give
+ exclusive keyboard focus to the top-most layer which has keyboard
+ interactivity set to exclusive. If this layer contains multiple
+ surfaces with keyboard interactivity set to exclusive, the compositor
+ determines the one receiving keyboard events in an implementation-
+ defined manner. In this case, no guarantee is made when this surface
+ will receive keyboard focus (if ever).
+
+ For the bottom and background layers, the compositor is allowed to use
+ normal focus semantics.
+
+ This setting is mainly intended for applications that need to ensure
+ they receive all keyboard events, such as a lock screen or a password
+ prompt.
+ </description>
+ </entry>
+ <entry name="on_demand" value="2" since="4">
+ <description summary="request regular keyboard focus semantics">
+ This requests the compositor to allow this surface to be focused and
+ unfocused by the user in an implementation-defined manner. The user
+ should be able to unfocus this surface even regardless of the layer
+ it is on.
+
+ Typically, the compositor will want to use its normal mechanism to
+ manage keyboard focus between layer shell surfaces with this setting
+ and regular toplevels on the desktop layer (e.g. click to focus).
+ Nevertheless, it is possible for a compositor to require a special
+ interaction to focus or unfocus layer shell surfaces (e.g. requiring
+ a click even if focus follows the mouse normally, or providing a
+ keybinding to switch focus between layers).
+
+ This setting is mainly intended for desktop shell components (e.g.
+ panels) that allow keyboard interaction. Using this option can allow
+ implementing a desktop shell that can be fully usable without the
+ mouse.
+ </description>
+ </entry>
+ </enum>
+
+ <request name="set_keyboard_interactivity">
+ <description summary="requests keyboard events">
+ Set how keyboard events are delivered to this surface. By default,
+ layer shell surfaces do not receive keyboard events; this request can
+ be used to change this.
+
+ This setting is inherited by child surfaces set by the get_popup
+ request.
+
+ Layer surfaces receive pointer, touch, and tablet events normally. If
+ you do not want to receive them, set the input region on your surface
+ to an empty region.
+
+ Keyboard interactivity is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
+ </request>
+
+ <request name="get_popup">
+ <description summary="assign this layer_surface as an xdg_popup parent">
+ This assigns an xdg_popup's parent to this layer_surface. This popup
+ should have been created via xdg_surface::get_popup with the parent set
+ to NULL, and this request must be invoked before committing the popup's
+ initial state.
+
+ See the documentation of xdg_popup for more details about what an
+ xdg_popup is and how it is used.
+ </description>
+ <arg name="popup" type="object" interface="xdg_popup"/>
+ </request>
+
+ <request name="ack_configure">
+ <description summary="ack a configure event">
+ When a configure event is received, if a client commits the
+ surface in response to the configure event, then the client
+ must make an ack_configure request sometime before the commit
+ request, passing along the serial of the configure event.
+
+ If the client receives multiple configure events before it
+ can respond to one, it only has to ack the last configure event.
+
+ A client is not required to commit immediately after sending
+ an ack_configure request - it may even ack_configure several times
+ before its next surface commit.
+
+ A client may send multiple ack_configure requests before committing, but
+ only the last request sent before a commit indicates which configure
+ event the client really is responding to.
+ </description>
+ <arg name="serial" type="uint" summary="the serial from the configure event"/>
+ </request>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the layer_surface">
+ This request destroys the layer surface.
+ </description>
+ </request>
+
+ <event name="configure">
+ <description summary="suggest a surface change">
+ The configure event asks the client to resize its surface.
+
+ Clients should arrange their surface for the new states, and then send
+ an ack_configure request with the serial sent in this configure event at
+ some point before committing the new surface.
+
+ The client is free to dismiss all but the last configure event it
+ received.
+
+ The width and height arguments specify the size of the window in
+ surface-local coordinates.
+
+ The size is a hint, in the sense that the client is free to ignore it if
+ it doesn't resize, pick a smaller size (to satisfy aspect ratio or
+ resize in steps of NxM pixels). If the client picks a smaller size and
+ is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
+ surface will be centered on this axis.
+
+ If the width or height arguments are zero, it means the client should
+ decide its own window dimension.
+ </description>
+ <arg name="serial" type="uint"/>
+ <arg name="width" type="uint"/>
+ <arg name="height" type="uint"/>
+ </event>
+
+ <event name="closed">
+ <description summary="surface should be closed">
+ The closed event is sent by the compositor when the surface will no
+ longer be shown. The output may have been destroyed or the user may
+ have asked for it to be removed. Further changes to the surface will be
+ ignored. The client should destroy the resource after receiving this
+ event, and create a new surface if they so choose.
+ </description>
+ </event>
+
+ <enum name="error">
+ <entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
+ <entry name="invalid_size" value="1" summary="size is invalid"/>
+ <entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
+ <entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
+ <entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
+ </enum>
+
+ <enum name="anchor" bitfield="true">
+ <entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
+ <entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
+ <entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
+ <entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
+ </enum>
+
+ <!-- Version 2 additions -->
+
+ <request name="set_layer" since="2">
+ <description summary="change the layer of the surface">
+ Change the layer that the surface is rendered on.
+
+ Layer is double-buffered, see wl_surface.commit.
+ </description>
+ <arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
+ </request>
+
+ <!-- Version 5 additions -->
+
+ <request name="set_exclusive_edge" since="5">
+ <description summary="set the edge the exclusive zone will be applied to">
+ Requests an edge for the exclusive zone to apply. The exclusive
+ edge will be automatically deduced from anchor points when possible,
+ but when the surface is anchored to a corner, it will be necessary
+ to set it explicitly to disambiguate, as it is not possible to deduce
+ which one of the two corner edges should be used.
+
+ The edge must be one the surface is anchored to, otherwise the
+ invalid_exclusive_edge protocol error will be raised.
+ </description>
+ <arg name="edge" type="uint" enum="anchor"/>
+ </request>
+ </interface>
+</protocol>
diff --git a/tasks.md b/tasks.md
index d7c6f35..22d4953 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,10 +5,11 @@
## Now
-**Next action:** Slice 4 — extension host + contracts: bus, manifests,
-static registration; xdg-shell/layer-shell refactored OUT of the kernel
-into core extensions. Design input: what the spike learned (see slice-3
-row + packages/kernel/kernel.md gotchas).
+**Next action:** Slice 5 — input routing + ergonomics contract. Queued
+into it from slice 4: real ui-substrate contract (replaces ui_spike),
+layer-shell `on_demand` keyboard interactivity, window placement policy
+(new toplevels overlap at origin), factory-name alignment
+(`make_extension` vs `create`).
## Slices
@@ -18,7 +19,7 @@ row + packages/kernel/kernel.md gotchas).
| 1 | Bootstrap: toolchain, Meson skeleton, RMLUi subproject compiles, empty kernel links wlroots-0.20 from C++ via the extern-"C" wrapper | **DONE** 2026-06-12 | met: build green; tests 1/1; binary prints wlroots 0.20.1 + RmlUi 6.2, exits 0 |
| 2 | tinywl port: kernel skeleton runs nested under labwc | **DONE** 2026-06-12 | met: nested output WL-1, foot toplevel mapped+focused, GLES2 renderer; touch handlers added (tinywl lacks them); headless boot test green |
| 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 | pending | kernel names no feature; ext-xdg-shell + ext-layer-shell pass suite |
+| 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 | pending | same ui surface usable by mouse and finger |
| 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 |