diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 22:44:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 22:44:16 +0900 |
| commit | c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (patch) | |
| tree | f6dea2875b939c0f661292d8bfa0d79a96fe67d7 | |
| parent | 6949c3582ed1e480e70aabfcfa3a11b78007cc12 (diff) | |
| download | unbox-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.
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.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.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{®}; + Event<int> ev2{®}; + 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> @@ -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 | |
