summaryrefslogtreecommitdiffhomepage
path: root/packages/ext-layer-shell/src/ext_layer_shell.cpp
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
committerAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
commitc102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (patch)
treef6dea2875b939c0f661292d8bfa0d79a96fe67d7 /packages/ext-layer-shell/src/ext_layer_shell.cpp
parent6949c3582ed1e480e70aabfcfa3a11b78007cc12 (diff)
downloadunbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.tar.gz
unbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.zip
Slice 4: extension host + typed bus; xdg-shell/layer-shell extracted to core extensions
The kernel now names NO concrete feature. It owns: the extension host (install/topological activate, missing-dep/cycle = startup error), the typed Event/Filter bus (error-isolated: a throwing extension is disabled, never the session; RAII Subscriptions), the Host API (per-extension facade: borrows, scene layers, event catalogue, typed services), the public RAII Listener, and a typed surface→scene-tree registry (Host::host_surface/scene_tree_for) that replaced the untyped wlr_surface.data convention both extensions flagged. - ext-xdg-shell (core): toplevel/popup lifecycle, focus-on-map, click/tap-to-focus, pointer/touch routing incl. button+axis (the kernel only moves the cursor and emits — a contract-doc lie caught by user hands-on), interactive move/resize via pure GrabMachine (fixes the request-arrives-after-release race: grab requires request ∧ button-down, release always ends it), Alt+F1 cycle, Ctrl+Alt+Backspace terminate (labwc's default A-Escape=Exit killed the dev session once — never again; see nested-run skill). - ext-layer-shell (core): wlr-layer-shell v1 (proto v5) for external clients; pure doctest-hard arrangement core; fuzzel verified visually nested (fix: seed outputs from output_layout at activate — events-only tracking missed pre-activation outputs; plus a scene-node double-free). - First protocol codegen: vendored wlr-layer-shell XML + wayland-scanner server-header propagated through kernel_dep; wlr.hpp grew a namespace→_namespace keyword fix for the generated header. - Glossary: 'scene layer' (user-approved). New rules earned: parallel-wave-builds, contract-docs. - User hands-on verified: typing, click-to-focus, drag-select, scroll, titlebar drag-move (slow + flick), Alt+F1, fuzzel + arrows, touch tap, Ctrl+Alt+Backspace. 68 doctest cases green, ASan/UBSan clean (our code), idle RSS ≈73 MiB.
Diffstat (limited to 'packages/ext-layer-shell/src/ext_layer_shell.cpp')
-rw-r--r--packages/ext-layer-shell/src/ext_layer_shell.cpp370
1 files changed, 370 insertions, 0 deletions
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