summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 19:19:03 +0900
committerAdam Malczewski <[email protected]>2026-06-13 19:19:03 +0900
commitfaf9d8c41ea87fe91f40af881ed1c920a82fac6f (patch)
tree18e280937e5e1d3f15e858057e4eb9a8d28570a9
parent2c4271aba1dddcb508f5dea92063d829ba2e97c9 (diff)
downloadunbox-faf9d8c41ea87fe91f40af881ed1c920a82fac6f.tar.gz
unbox-faf9d8c41ea87fe91f40af881ed1c920a82fac6f.zip
Slice 10 c2: stage dock end-to-end (minimize -> preview -> restore)
ext-stage-dock activate() now wires the full static pipeline: track toplevels via the ext-xdg-shell Service; Super+M (stopgap keybinding) minimizes the focused window -> snapshot a Preview from its scene_tree(), push a slot, hide() the live node, refocus another window; the dock is one overlay UiSurface rendering the slots via the b2 list bindings (data-for over {preview src, title}); tapping a slot fires bind_list_event -> show()+focus() the window and drop its slot/Preview. Storing the Toplevel* across minimize is safe because hide() keeps it mapped; slots are dropped on on_toplevel_unmapped. Teardown is reverse-declaration-order (subscriptions first, surface before slots), asan-clean. host-bin installs ext-stage-dock (standard, depends_on xdg-shell; hidden until it holds a minimized window). Headless glue test (real in-process xdg client) proves the model + scene-node enable bit true->false->true, slots 0->1->0. RML carries dock/slot classes as d1's RCSS animation hooks. Stopgaps: Super+M (to migrate into an ext-keybindings action + a stage-dock Service post-d1); favicon deferred (needs an XDG icon-theme dep — to surface to the user). 10/10 suites green on build + build-asan. Visual/tap path is real-seat (pending).
-rw-r--r--packages/ext-stage-dock/meson.build53
-rw-r--r--packages/ext-stage-dock/src/extension.cpp371
-rw-r--r--packages/ext-stage-dock/src/probe.hpp54
-rw-r--r--packages/ext-stage-dock/tests/test_glue.cpp318
-rw-r--r--packages/host-bin/meson.build2
-rw-r--r--packages/host-bin/src/main.cpp5
6 files changed, 783 insertions, 20 deletions
diff --git a/packages/ext-stage-dock/meson.build b/packages/ext-stage-dock/meson.build
index 527cab6..a31dccb 100644
--- a/packages/ext-stage-dock/meson.build
+++ b/packages/ext-stage-dock/meson.build
@@ -30,10 +30,10 @@ ext_stage_dock_dep = declare_dependency(
dependencies: [kernel_dep],
)
-# Tests: the pure decision cores doctest-hard (the reveal recognizer + the dock
-# layout geometry) with NO kernel/wlroots running. The TU compiles against the
-# header-only cores directly and needs `src` on the include path (a unit may read
-# its own src/). No glue/headless test this step — activate() is a no-op.
+# Tests, asymmetric (AGENTS.md: strict cores, lenient shell). The pure decision
+# cores are doctest-hard (the reveal recognizer + the dock layout geometry) with
+# NO kernel/wlroots running. The TU compiles against the header-only cores
+# directly and needs `src` on the include path (a unit may read its own src/).
ext_stage_dock_policy_test = executable(
'ext-stage-dock-policy-tests',
'tests/test_policy.cpp',
@@ -46,7 +46,52 @@ test(
suite: 'ext-stage-dock',
)
+# c2 GLUE test: install ext-xdg-shell + ext-stage-dock on the wlr headless
+# backend, map a REAL in-process client toplevel, then drive the minimize/restore
+# PIPELINE through the private test probe (src/probe.hpp) and assert the model +
+# the scene node's hide/show enable-bit. Lenient ext-tier glue: the substrate is
+# null on pixman (no GL), so this exercises the model + hide()/show(), not the
+# visual. Needs CLIENT-side xdg-shell bindings generated from the canonical
+# xdg-shell.xml exactly as ext-xdg-shell's test_minimize.cpp does (header +
+# private-code-as-header, #included once by the single C++ TU). `src` on the
+# include path so the test reaches the private probe factory (own src/ only).
+wayland_client_dep = dependency('wayland-client')
+sd_wayland_protocols_dir = dependency('wayland-protocols').get_variable('pkgdatadir')
+sd_xdg_shell_xml = sd_wayland_protocols_dir / 'stable' / 'xdg-shell' / 'xdg-shell.xml'
+
+sd_xdg_shell_client_header = custom_target(
+ 'sd-xdg-shell-client-header',
+ input: sd_xdg_shell_xml,
+ output: 'xdg-shell-client-protocol.h',
+ command: [wayland_scanner, 'client-header', '@INPUT@', '@OUTPUT@'],
+)
+# private-code emitted as a .h so the C++ TU #includes it exactly once.
+sd_xdg_shell_client_code = custom_target(
+ 'sd-xdg-shell-client-code-impl',
+ input: sd_xdg_shell_xml,
+ output: 'xdg-shell-client-protocol-code.h',
+ command: [wayland_scanner, 'private-code', '@INPUT@', '@OUTPUT@'],
+)
+
+ext_stage_dock_glue_test = executable(
+ 'ext-stage-dock-glue-tests',
+ 'tests/test_glue.cpp',
+ sd_xdg_shell_client_header,
+ sd_xdg_shell_client_code,
+ include_directories: [ext_stage_dock_inc, include_directories('src')],
+ dependencies: [ext_stage_dock_dep, ext_xdg_shell_dep, wayland_client_dep, doctest_dep],
+)
+test(
+ 'ext-stage-dock-glue',
+ ext_stage_dock_glue_test,
+ suite: 'ext-stage-dock',
+ # 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,
+)
+
# Aggregate alias the brief builds: `ninja -C build ext-stage-dock-tests`.
alias_target('ext-stage-dock-tests',
ext_stage_dock_policy_test,
+ ext_stage_dock_glue_test,
)
diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp
index b43dcad..8291c0b 100644
--- a/packages/ext-stage-dock/src/extension.cpp
+++ b/packages/ext-stage-dock/src/extension.cpp
@@ -1,47 +1,382 @@
#include <unbox/ext-stage-dock/ext_stage_dock.hpp>
#include "dock_layout.hpp"
+#include "probe.hpp"
#include "reveal.hpp"
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/ui.hpp>
#include <unbox/kernel/wlr.hpp>
+#include <cstddef>
#include <memory>
+#include <stdexcept>
+#include <string>
+#include <vector>
-// ext-stage-dock glue (b4 SKELETON). The decision cores live in src/reveal.hpp
-// (the reversible edge-swipe recognizer) and src/dock_layout.hpp (reveal ->
-// on-screen geometry) — both wlroots/GL/RMLUi-free and doctest-hard. THIS file
-// is the thin effectful edge: for now it is a deliberate no-op that just logs at
-// activate(), so the unit compiles and installs cleanly while the real wiring
-// (c2 static integration, d1 animation, e1 gesture) lands in later steps.
+// ext-stage-dock glue (c2 STATIC INTEGRATION). The decision cores live in
+// src/reveal.hpp (the reversible edge-swipe recognizer) and src/dock_layout.hpp
+// (reveal -> on-screen geometry) — both wlroots/GL/RMLUi-free and doctest-hard;
+// c2 uses dock_layout for the dock frame box only (no reveal animation yet, that
+// is d1; no gesture yet, that is e1).
//
-// Everything runs on the single wl_event_loop thread. Every future resource will
-// be a RAII member of StageDockExtension; teardown is reverse-declaration
-// destruction (no manual teardown lists — extension-agent.md).
+// This step wires the END-TO-END minimize pipeline with NO animation/gesture:
+// minimize key (Super+M, stopgap) -> snapshot the focused window into a
+// Preview, hide() its scene node, add a slot to the dock model, reveal the
+// dock; tap a slot -> show()+focus() that window, drop the slot (frees the
+// Preview texture), hide the dock when empty.
+//
+// Everything runs on the single wl_event_loop thread. Every resource is a RAII
+// member of StageDockExtension; teardown is reverse-declaration destruction (no
+// manual teardown lists — extension-agent.md). The Subscriptions release FIRST
+// (declared last), then the dock UiSurface (its bindings capture `this` and read
+// slots_, so it is destroyed BEFORE slots_), then the slots' Previews — all
+// before the Host borrow goes away.
namespace unbox::ext_stage_dock {
namespace {
using kernel::Host;
-class StageDockExtension final : public kernel::Extension {
+// ext-xdg-shell's Toplevel is the window handle. KEY INSIGHT (brief): minimize
+// is hide(), NOT unmap — a minimized window stays mapped, so its Toplevel*
+// borrow stays valid until on_toplevel_unmapped fires. We therefore MAY store a
+// minimized window's Toplevel* in a slot and deref it later to restore; we drop
+// the slot the moment on_toplevel_unmapped fires for it (never deref after).
+using Toplevel = ext_xdg_shell::Toplevel;
+
+// The stopgap minimize chord: Super(LOGO)+M. A STOPGAP per the brief — the
+// config-driven migration (a `minimize` action in ext-keybindings + a Service
+// we export) is a post-d1 step (change-request in the report).
+constexpr std::uint32_t kMinimizeKeysym = XKB_KEY_m; // 0x06d
+constexpr std::uint32_t kMinimizeMods = WLR_MODIFIER_LOGO; // Super/LOGO
+
+// The dock width (revealed) in px — matches the dock_layout default. For c2 the
+// dock sits fully revealed (no reveal animation); d1 animates dock_box(f).
+constexpr int kDockWidth = 240;
+
+// A minimized window's dock entry: the live Toplevel* borrow (valid until its
+// unmapped event), the frozen Preview (owns the imported texture; null when the
+// substrate has no GL path), and a copied title (the Toplevel's title() view is
+// call-only, so we copy at minimize time). app_id copied too for the deferred
+// favicon lookup. // TODO favicon: app_id -> icon file via the XDG icon theme
+// (needs an icon-lookup dependency the user must approve; deferred in c2).
+struct Slot {
+ Toplevel* tl = nullptr; // borrow, live until unmapped
+ std::unique_ptr<kernel::Preview> preview; // owns the snapshot texture
+ std::string title; // copied at minimize time
+ std::string app_id; // copied; for // TODO favicon
+};
+
+// Inline RML for the dock document. A vertical stack of preview cards, dark/
+// rounded like the Stage-Manager reference. Animation-ready (a `dock` class on
+// the body + a `slot` class per card) but STATIC now — d1 adds the RCSS
+// transitions. data-model "ui" (the substrate default); iterates the "slots"
+// list bound below. {{ row.preview }} is the Preview source_uri(); the
+// data-event-click delivers the row index to restore().
+constexpr const char* kDockRml = R"RML(<rml>
+<head>
+<style>
+body.dock {
+ background-color: #1c1c1ee6;
+ padding: 8dp;
+ font-family: sans-serif;
+}
+div.slot {
+ display: block;
+ margin-bottom: 8dp;
+ padding: 6dp;
+ background-color: #2e2e32ff;
+ border-radius: 10dp;
+}
+div.slot img.preview {
+ display: block;
+ width: 100%;
+ height: 84dp;
+ border-radius: 6dp;
+}
+div.slot span.title {
+ display: block;
+ margin-top: 4dp;
+ color: #e6e6e6ff;
+ font-size: 13dp;
+ text-align: center;
+}
+</style>
+</head>
+<body data-model="ui" class="dock">
+<div data-for="row : slots" class="slot" data-event-click="restore(it_index)">
+<img class="preview" src="{{ row.preview }}"/>
+<span class="title">{{ row.title }}</span>
+</div>
+</body>
+</rml>)RML";
+
+class StageDockExtension final : public kernel::Extension, public TestProbe {
public:
auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+ // ---- TestProbe (src/probe.hpp; glue-test only) ----
+ [[nodiscard]] auto activated() const -> bool override { return activated_; }
+ void minimize_focused() override { do_minimize_focused(); }
+ void restore(std::size_t i) override { do_restore(i); }
+ [[nodiscard]] auto slot_count() const -> std::size_t override { return slots_.size(); }
+
void activate(Host& host) override {
- // No-op this step: no hooks, no service, no RML document yet. The pure
- // cores are exercised by the doctest suite, not at runtime. Real wiring
- // arrives at c2 (consume ext-xdg-shell's Service) / d1 / e1.
- (void)host;
- wlr_log(WLR_INFO, "ext-stage-dock: activate (skeleton; no wiring yet)");
+ host_ = &host;
+
+ // Fatal: a missing ext-xdg-shell Service. The dock minimizes/restores
+ // its toplevels and snapshots their scene trees — meaningless without
+ // it. depends_on "xdg-shell" guarantees it activated first, so absence
+ // is a broken core session (extension.hpp: activation failure is fatal).
+ shell_ = host.service<ext_xdg_shell::Service>();
+ if (shell_ == nullptr) {
+ throw std::runtime_error(
+ "ext-stage-dock: ext-xdg-shell Service unavailable (depends_on "
+ "\"xdg-shell\" not satisfied)");
+ }
+
+ // Track mapped toplevels + the currently focused one. The Toplevel*
+ // borrow is valid from its mapped event until its unmapped event, so
+ // storing it across that window (here and in slots_) is the supported
+ // pattern; we never deref it after unmapped.
+ mapped_ = host.subscribe(
+ shell_->on_toplevel_mapped(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ mapped_toplevels_.push_back(e.toplevel);
+ focused_ = e.toplevel; // map-focus: a freshly mapped window is focused
+ });
+ focused_sub_ = host.subscribe(
+ shell_->on_toplevel_focused(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ focused_ = e.toplevel;
+ });
+ unmapped_ = host.subscribe(
+ shell_->on_toplevel_unmapped(), [this](const ext_xdg_shell::ToplevelEvent& e) {
+ on_unmapped(e.toplevel);
+ });
+
+ // Minimize trigger (STOPGAP keybinding — Super+M). Consume the chord and
+ // minimize the focused window. TODO: migrate to a config-driven
+ // `minimize` action in ext-keybindings + a Service we export (post-d1;
+ // change-request in the report). We do NOT trigger on Super alone (that
+ // is ext-keybindings' tap-launcher).
+ key_filter_ = host.subscribe(
+ host.key_filter(), [this](kernel::KeyEvent ev) {
+ if (ev.pressed && ev.keysym == kMinimizeKeysym &&
+ (ev.modifiers & kMinimizeMods) != 0) {
+ do_minimize_focused();
+ ev.handled = true; // consume
+ }
+ return ev;
+ });
+
+ // Create the dock surface up front, kept hidden until the first slot. It
+ // lives on the overlay layer at the left edge; geometry from dock_layout
+ // + the first output's size. The substrate is null on a no-GL backend
+ // (e.g. headless pixman) — create_surface returns nullptr; we degrade
+ // gracefully (model still tracked, hide/show still works, no visual).
+ create_dock_surface();
+
+ activated_ = true;
}
private:
+ // ---- minimize / restore (the c2 pipeline) -------------------------------
+
+ void do_minimize_focused() {
+ if (focused_ == nullptr) {
+ return;
+ }
+ Toplevel* tl = focused_;
+
+ Slot slot;
+ slot.tl = tl;
+ slot.title = std::string(tl->title()); // copy: title() is call-only
+ slot.app_id = std::string(tl->app_id()); // copy; for // TODO favicon
+ // Snapshot the window into a Preview if the substrate has a GL path.
+ // create_preview borrows scene_tree() only for the call; it NEVER
+ // throws and returns null on a no-GL backend (degrade: empty preview).
+ if (host_->ui().available()) {
+ slot.preview = host_->ui().create_preview(tl->scene_tree());
+ }
+ slots_.push_back(std::move(slot));
+
+ // Hide the live window (disable its scene node — NOT unmap; it stays
+ // mapped, its Toplevel* borrow stays valid for restore).
+ tl->hide();
+
+ // Move keyboard focus to another mapped, non-minimized window if any.
+ Toplevel* next = first_non_minimized_other(tl);
+ if (next != nullptr) {
+ next->focus(); // produces on_toplevel_focused -> updates focused_
+ } else {
+ focused_ = nullptr; // nothing else to focus
+ }
+
+ refresh_slots();
+ }
+
+ void do_restore(std::size_t i) {
+ // Guard the index — RmlUi delivers it from the document (it_index).
+ if (i >= slots_.size()) {
+ return;
+ }
+ Toplevel* tl = slots_[i].tl;
+ // Drop the slot FIRST (frees its Preview texture). Then show()+focus()
+ // the live toplevel (its borrow is still valid — it never unmapped).
+ slots_.erase(slots_.begin() + static_cast<std::ptrdiff_t>(i));
+ if (tl != nullptr) {
+ tl->show();
+ tl->focus(); // produces on_toplevel_focused -> updates focused_
+ }
+ refresh_slots();
+ }
+
+ // on_toplevel_unmapped for `tl`: if it has a slot, drop it (and its Preview)
+ // — NEVER refresh()/show() an unmapped toplevel (its scene tree is gone; UB
+ // per ui.hpp). Also forget it from the mapped set + focus tracking.
+ void on_unmapped(Toplevel* tl) {
+ std::erase_if(slots_, [tl](const Slot& s) { return s.tl == tl; });
+ std::erase(mapped_toplevels_, tl);
+ if (focused_ == tl) {
+ focused_ = nullptr;
+ }
+ refresh_slots();
+ }
+
+ // ---- helpers ------------------------------------------------------------
+
+ // The first mapped window that is neither `except` nor currently minimized
+ // (in a slot), for re-focusing after a minimize. Null if none.
+ [[nodiscard]] auto first_non_minimized_other(Toplevel* except) -> Toplevel* {
+ for (Toplevel* tl : mapped_toplevels_) {
+ if (tl == except || is_minimized(tl)) {
+ continue;
+ }
+ return tl;
+ }
+ return nullptr;
+ }
+
+ [[nodiscard]] auto is_minimized(Toplevel* tl) const -> bool {
+ for (const Slot& s : slots_) {
+ if (s.tl == tl) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Re-render the dock list and toggle dock visibility (it shows iff there is
+ // at least one slot). No-op on the visual when the surface is null (no-GL).
+ void refresh_slots() {
+ if (dock_surface_ != nullptr) {
+ dock_surface_->dirty("slots");
+ dock_surface_->set_visible(!slots_.empty());
+ }
+ }
+
+ // Create the dock UiSurface (overlay, left edge, full output height) and
+ // register all data bindings BEFORE the first frame. Null surface (no-GL
+ // backend) is fine — we just skip it and the model is still tracked.
+ void create_dock_surface() {
+ const layout::DockMetrics m = dock_metrics();
+ const layout::Box frame = layout::dock_box(m, 1.0); // fully revealed (c2)
+
+ kernel::UiSurfaceSpec spec;
+ spec.rml_inline = kDockRml;
+ spec.model = "ui";
+ spec.x = frame.x;
+ spec.y = frame.y;
+ spec.width = frame.w;
+ spec.height = frame.h;
+ spec.layer = kernel::SceneLayer::overlay;
+ spec.visible = false; // shown when slot count > 0
+
+ dock_surface_ = host_->ui().create_surface(spec);
+ if (dock_surface_ == nullptr) {
+ return; // no GL path: degrade gracefully (model only)
+ }
+
+ // List bindings (b2 list-binding family). All registered BEFORE the
+ // first frame, capturing only `this` (whose members outlive the
+ // surface, which is destroyed before them in reverse declaration order).
+ dock_surface_->bind_list(
+ "slots", [this]() -> std::size_t { return slots_.size(); });
+ dock_surface_->bind_list_string(
+ "slots", "preview", [this](std::size_t i) -> std::string {
+ if (i >= slots_.size()) {
+ return std::string{};
+ }
+ return slots_[i].preview ? slots_[i].preview->source_uri()
+ : std::string{};
+ });
+ dock_surface_->bind_list_string(
+ "slots", "title", [this](std::size_t i) -> std::string {
+ return i < slots_.size() ? slots_[i].title : std::string{};
+ });
+ dock_surface_->bind_list_event(
+ "slots", "restore", [this](std::size_t i) { do_restore(i); });
+ }
+
+ // Dock metrics from the first output's size (queried via output_layout). On
+ // a backend with no output yet, falls back to 0x0 (the dock is hidden until
+ // a slot exists anyway). The dock keeps its fixed width and full height.
+ [[nodiscard]] auto dock_metrics() const -> layout::DockMetrics {
+ int ow = 0;
+ int oh = 0;
+ wlr_output_layout* ol = host_->output_layout();
+ if (ol != nullptr) {
+ wlr_output_layout_output* lo = nullptr;
+ wl_list_for_each(lo, &ol->outputs, link) {
+ wlr_box box{};
+ wlr_output_layout_get_box(ol, lo->output, &box);
+ if (!wlr_box_empty(&box)) {
+ ow = box.width;
+ oh = box.height;
+ }
+ break; // first output (c2)
+ }
+ }
+ layout::DockMetrics m;
+ m.output_w = ow;
+ m.output_h = oh;
+ m.dock_width = kDockWidth;
+ return m;
+ }
+
const kernel::Manifest manifest_{
.id = "stage-dock",
.tier = kernel::Tier::standard,
.depends_on = {"xdg-shell"},
};
+
+ Host* host_ = nullptr;
+ ext_xdg_shell::Service* shell_ = nullptr; // borrow; fetched in activate()
+ bool activated_ = false; // TestProbe; set at end of activate()
+
+ // Window tracking. Toplevel* are borrows valid mapped..unmapped; we add on
+ // mapped, drop on unmapped, and only deref live ones.
+ std::vector<Toplevel*> mapped_toplevels_;
+ Toplevel* focused_ = nullptr;
+
+ // The dock model. Declared BEFORE dock_surface_ so the surface (whose
+ // bindings read slots_) is destroyed FIRST — slots_ (and its Previews) stay
+ // valid through the surface's teardown, then drop, all before host_'s borrow
+ // ends. Each Slot owns a Preview (frees its texture on erase/destruction).
+ std::vector<Slot> slots_;
+
+ // The dock ui surface. Destroyed before slots_ (declared after it) so any
+ // getter invoked during its teardown still sees a live slots_; destroyed
+ // before host_'s borrow ends (it is a member). Null on a no-GL backend.
+ std::unique_ptr<kernel::UiSurface> dock_surface_;
+
+ // RAII subscriptions — declared LAST so they release FIRST at teardown,
+ // before the dock surface + model their callbacks touch (listener-lifetime).
+ kernel::Subscription mapped_;
+ kernel::Subscription focused_sub_;
+ kernel::Subscription unmapped_;
+ kernel::Subscription key_filter_;
};
} // namespace
@@ -50,4 +385,10 @@ auto create() -> std::unique_ptr<kernel::Extension> {
return std::make_unique<StageDockExtension>();
}
+auto make_extension_with_probe() -> ExtensionWithProbe {
+ auto ext = std::make_unique<StageDockExtension>();
+ TestProbe* probe = ext.get();
+ return ExtensionWithProbe{.extension = std::move(ext), .probe = probe};
+}
+
} // namespace unbox::ext_stage_dock
diff --git a/packages/ext-stage-dock/src/probe.hpp b/packages/ext-stage-dock/src/probe.hpp
new file mode 100644
index 0000000..9ea49db
--- /dev/null
+++ b/packages/ext-stage-dock/src/probe.hpp
@@ -0,0 +1,54 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <cstddef>
+#include <memory>
+
+// Test-only probe surface (PRIVATE — src/, never part of the contract). The
+// headless glue test needs to drive the c2 minimize/restore PIPELINE and read
+// the dock MODEL without a synthetic keyboard device (the Super+M keystroke
+// decode is trivial; the pipeline it triggers is what matters). The public
+// create() hides the concrete extension behind kernel::Extension; this factory
+// hands back the same Extension plus a borrowed probe the test polls/drives.
+//
+// On a no-GL backend (headless pixman) the ui substrate is null, so the dock
+// UiSurface is null and previews are null — the probe still exercises the model
+// + hide()/show() (exactly what the brief says to test there). Glue/shell test
+// convenience only; never a contract claim.
+
+namespace unbox::ext_stage_dock {
+
+// A non-owning view onto the live extension for tests. Valid as long as the
+// returned unique_ptr (and thus the extension) is alive. All calls are on the
+// single event-loop thread, like the extension itself.
+class TestProbe {
+public:
+ virtual ~TestProbe() = default;
+
+ // True once activate() completed (Service fetched, hooks wired, dock surface
+ // creation attempted).
+ [[nodiscard]] virtual auto activated() const -> bool = 0;
+
+ // Drive the minimize pipeline on the currently focused window exactly as the
+ // Super+M key path does (snapshot -> hide -> slot -> reveal dock + re-focus).
+ // No-op if nothing is focused.
+ virtual void minimize_focused() = 0;
+
+ // Drive the restore pipeline for slot `i` (show + focus the window, drop the
+ // slot). Guards the index.
+ virtual void restore(std::size_t i) = 0;
+
+ // The current number of dock slots (minimized windows).
+ [[nodiscard]] virtual auto slot_count() const -> std::size_t = 0;
+};
+
+struct ExtensionWithProbe {
+ std::unique_ptr<unbox::kernel::Extension> extension; // install() this
+ TestProbe* probe = nullptr; // borrow into the above
+};
+
+// Same extension as create(), but also yields a probe borrow.
+[[nodiscard]] auto make_extension_with_probe() -> ExtensionWithProbe;
+
+} // namespace unbox::ext_stage_dock
diff --git a/packages/ext-stage-dock/tests/test_glue.cpp b/packages/ext-stage-dock/tests/test_glue.cpp
new file mode 100644
index 0000000..69985ff
--- /dev/null
+++ b/packages/ext-stage-dock/tests/test_glue.cpp
@@ -0,0 +1,318 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "probe.hpp" // PRIVATE: a unit may read its own src/ (drives the c2 pipeline)
+
+#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
+#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/server.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <wayland-client.h>
+
+// CLIENT-side xdg-shell bindings, generated from the canonical xdg-shell.xml the
+// same way ext-xdg-shell's test_minimize.cpp does (header + private-code-as-
+// header, #included once by this single C++ TU). Lets an in-process real client
+// map an xdg_toplevel so we drive the c2 minimize/restore PIPELINE end to end on
+// the wlr headless backend.
+#include "xdg-shell-client-protocol.h"
+#include "xdg-shell-client-protocol-code.h"
+
+#include <cstdint>
+#include <cstdlib>
+#include <cstring>
+#include <memory>
+#include <poll.h>
+#include <unistd.h>
+
+// ext-stage-dock GLUE test — lenient, headless (AGENTS.md: strict cores, lenient
+// shell). The decision cores (reveal recognizer + dock layout) are proven hard
+// in test_policy.cpp; here we verify the c2 STATIC INTEGRATION pipeline:
+// install ext-xdg-shell + ext-stage-dock, activate (fetch the Service — the
+// only fatal path), map a real toplevel, drive minimize via the private probe
+// and assert the window's scene node is DISABLED + a slot exists, then drive
+// restore and assert the node is ENABLED again + the slot is gone.
+// On the headless pixman backend the ui substrate has no GL path, so the dock
+// UiSurface + Previews are null — exactly the degrade the brief calls for: the
+// MODEL + hide()/show() still work; there is no visual to assert. A separate
+// OBSERVER extension (mirroring ext-xdg-shell/tests/test_minimize.cpp) captures
+// the mapped Toplevel borrow so the test can read its scene node's enable-bit —
+// the exact bit Toplevel::hide()/show() flips.
+
+namespace {
+
+using unbox::ext_stage_dock::ExtensionWithProbe;
+using unbox::ext_stage_dock::TestProbe;
+using unbox::ext_xdg_shell::Service;
+using unbox::ext_xdg_shell::Toplevel;
+using unbox::ext_xdg_shell::ToplevelEvent;
+
+// ---- observer extension: captures the live Toplevel borrow (test-only) ------
+//
+// Depends on xdg-shell (so its Service is registered first). Holds the mapped
+// Toplevel* — a borrow valid mapped..unmapped, which is exactly the window we
+// touch. Lets the test read scene_tree()->node.enabled to confirm hide()/show().
+class Observer final : public unbox::kernel::Extension {
+public:
+ [[nodiscard]] auto manifest() const -> const unbox::kernel::Manifest& override {
+ return manifest_;
+ }
+
+ void activate(unbox::kernel::Host& host) override {
+ Service* svc = host.service<Service>();
+ REQUIRE(svc != nullptr);
+ sub_mapped_ = host.subscribe(
+ svc->on_toplevel_mapped(),
+ [this](const ToplevelEvent& e) { mapped_ = e.toplevel; });
+ sub_unmapped_ = host.subscribe(
+ svc->on_toplevel_unmapped(), [this](const ToplevelEvent& e) {
+ if (e.toplevel == mapped_) {
+ mapped_ = nullptr;
+ }
+ });
+ }
+
+ [[nodiscard]] auto mapped() -> Toplevel* { return mapped_; }
+
+private:
+ unbox::kernel::Manifest manifest_{"test-observer", unbox::kernel::Tier::standard,
+ {"xdg-shell"}};
+ Toplevel* mapped_ = nullptr;
+ unbox::kernel::Subscription sub_mapped_;
+ unbox::kernel::Subscription sub_unmapped_;
+};
+
+// ---- minimal xdg-shell client (mirrors ext-xdg-shell/tests/test_minimize.cpp)
+
+struct Client {
+ wl_display* display = nullptr;
+ wl_registry* registry = nullptr;
+ wl_compositor* compositor = nullptr;
+ wl_shm* shm = nullptr;
+ xdg_wm_base* wm_base = nullptr;
+
+ wl_surface* surface = nullptr;
+ xdg_surface* xsurface = nullptr;
+ xdg_toplevel* toplevel = nullptr;
+
+ bool configured = false;
+};
+
+void wm_base_ping(void*, xdg_wm_base* wm, std::uint32_t serial) {
+ xdg_wm_base_pong(wm, serial);
+}
+const xdg_wm_base_listener kWmBaseListener{wm_base_ping};
+
+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_shm_interface.name) == 0) {
+ c->shm = static_cast<wl_shm*>(wl_registry_bind(reg, name, &wl_shm_interface, 1));
+ } else if (std::strcmp(iface, xdg_wm_base_interface.name) == 0) {
+ c->wm_base = static_cast<xdg_wm_base*>(wl_registry_bind(
+ reg, name, &xdg_wm_base_interface, version < 3 ? version : 3));
+ xdg_wm_base_add_listener(c->wm_base, &kWmBaseListener, c);
+ }
+}
+void registry_global_remove(void*, wl_registry*, std::uint32_t) {}
+const wl_registry_listener kRegistryListener{registry_global, registry_global_remove};
+
+void xsurface_configure(void* data, xdg_surface* xs, std::uint32_t serial) {
+ auto* c = static_cast<Client*>(data);
+ xdg_surface_ack_configure(xs, serial);
+ c->configured = true;
+}
+const xdg_surface_listener kXSurfaceListener{xsurface_configure};
+
+void toplevel_configure(void*, xdg_toplevel*, std::int32_t, std::int32_t, wl_array*) {}
+void toplevel_close(void*, xdg_toplevel*) {}
+const xdg_toplevel_listener kToplevelListener{toplevel_configure, toplevel_close, {}, {}};
+
+// Cooperative single-thread pump (the standard libwayland guarded-read dance).
+void pump(unbox::kernel::Server& server, wl_display* client) {
+ wl_display_flush(client);
+ server.dispatch(5);
+ 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);
+ return unbox::kernel::Server::create({});
+}
+
+// A 256x256 ARGB shm buffer so the surface actually maps (a toplevel maps on its
+// first buffer-bearing commit after the initial configure).
+auto make_buffer(Client& c, int w, int h) -> wl_buffer* {
+ const int stride = w * 4;
+ const int size = stride * h;
+ char name[] = "/tmp/unbox-stage-dock-test-XXXXXX";
+ int fd = mkstemp(name);
+ REQUIRE(fd >= 0);
+ unlink(name);
+ REQUIRE(ftruncate(fd, size) == 0);
+ REQUIRE(c.shm != nullptr);
+ wl_shm_pool* pool = wl_shm_create_pool(c.shm, fd, size);
+ wl_buffer* buffer =
+ wl_shm_pool_create_buffer(pool, 0, w, h, stride, WL_SHM_FORMAT_ARGB8888);
+ wl_shm_pool_destroy(pool);
+ close(fd);
+ return buffer;
+}
+
+} // namespace
+
+TEST_CASE("ext-stage-dock installs and activates atop ext-xdg-shell") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_xdg_shell::create());
+ // Topological activation runs xdg-shell first (stage-dock depends_on it), so
+ // activate() finds the Service. A missing-Service throw would propagate out.
+ ExtensionWithProbe ewp = unbox::ext_stage_dock::make_extension_with_probe();
+ TestProbe* probe = ewp.probe;
+ server->install(std::move(ewp.extension));
+ server->activate_extensions();
+ CHECK(!server->socket_name().empty());
+ CHECK(probe->activated());
+ CHECK(probe->slot_count() == 0);
+ // minimize_focused with nothing focused is a guarded no-op.
+ probe->minimize_focused();
+ CHECK(probe->slot_count() == 0);
+}
+
+TEST_CASE("ext-stage-dock c2: minimize hides the focused window + adds a slot; restore shows it + drops the slot") {
+ auto server = make_headless_server();
+ server->install(unbox::ext_xdg_shell::create());
+
+ auto observer_owned = std::make_unique<Observer>();
+ Observer* observer = observer_owned.get();
+ server->install(std::move(observer_owned));
+
+ ExtensionWithProbe ewp = unbox::ext_stage_dock::make_extension_with_probe();
+ TestProbe* probe = ewp.probe;
+ server->install(std::move(ewp.extension));
+
+ server->activate_extensions();
+ REQUIRE(!server->socket_name().empty());
+ REQUIRE(probe->activated());
+
+ 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);
+
+ for (int i = 0; i < 50 &&
+ (c.compositor == nullptr || c.wm_base == nullptr || c.shm == nullptr);
+ ++i) {
+ pump(*server, c.display);
+ }
+ REQUIRE(c.compositor != nullptr);
+ REQUIRE(c.wm_base != nullptr);
+ REQUIRE(c.shm != nullptr);
+
+ // Map a toplevel: surface -> xdg_surface -> xdg_toplevel, the mandatory empty
+ // initial commit, wait for the configure, then attach a buffer + commit to map.
+ c.surface = wl_compositor_create_surface(c.compositor);
+ c.xsurface = xdg_wm_base_get_xdg_surface(c.wm_base, c.surface);
+ xdg_surface_add_listener(c.xsurface, &kXSurfaceListener, &c);
+ c.toplevel = xdg_surface_get_toplevel(c.xsurface);
+ xdg_toplevel_add_listener(c.toplevel, &kToplevelListener, &c);
+ xdg_toplevel_set_title(c.toplevel, "unbox-dock-test");
+ wl_surface_commit(c.surface);
+
+ for (int i = 0; i < 200 && !c.configured; ++i) {
+ pump(*server, c.display);
+ }
+ REQUIRE(c.configured);
+
+ wl_buffer* buffer = make_buffer(c, 256, 256);
+ REQUIRE(buffer != nullptr);
+ wl_surface_attach(c.surface, buffer, 0, 0);
+ wl_surface_damage(c.surface, 0, 0, 256, 256);
+ wl_surface_commit(c.surface);
+
+ // Pump until the server-side map fires: ext-xdg-shell focuses the freshly
+ // mapped toplevel, the dock's on_toplevel_mapped records it as focused_, and
+ // the observer captures the live borrow.
+ for (int i = 0; i < 200 && observer->mapped() == nullptr; ++i) {
+ pump(*server, c.display);
+ }
+ Toplevel* tl = observer->mapped();
+ REQUIRE(tl != nullptr);
+ wlr_scene_tree* tree = tl->scene_tree();
+ REQUIRE(tree != nullptr);
+ CHECK(tree->node.enabled == true); // mapped + visible before minimize
+ REQUIRE(probe->slot_count() == 0);
+
+ // --- minimize the focused window (the Super+M path, driven via the probe) --
+ probe->minimize_focused();
+ CHECK(probe->slot_count() == 1); // a slot was added (model works w/o GL)
+ CHECK(tree->node.enabled == false); // hide() disabled the scene node
+ CHECK(observer->mapped() == tl); // NOT unmapped — minimize != unmap
+
+ // Pump a few turns: a hide must not unmap the client (no on_toplevel_unmapped).
+ for (int i = 0; i < 10; ++i) {
+ pump(*server, c.display);
+ }
+ CHECK(probe->slot_count() == 1);
+ CHECK(observer->mapped() == tl);
+
+ // --- restore slot 0 ---
+ probe->restore(0);
+ CHECK(probe->slot_count() == 0); // slot dropped (Preview, if any, freed)
+ CHECK(tree->node.enabled == true); // show() re-enabled the scene node
+
+ // A guarded out-of-range restore is a no-op.
+ probe->restore(5);
+ CHECK(probe->slot_count() == 0);
+
+ for (int i = 0; i < 10; ++i) {
+ pump(*server, c.display);
+ }
+
+ // Teardown: destroy the client toplevel; on_toplevel_unmapped fires and the
+ // dock forgets the window. Dropping the server destroys extensions in reverse
+ // activation order; within the dock, Subscriptions release first, then the
+ // dock UiSurface (null here), then the slots' Previews — all before the Host
+ // borrow ends (asan watches this).
+ wl_buffer_destroy(buffer);
+ xdg_toplevel_destroy(c.toplevel);
+ xdg_surface_destroy(c.xsurface);
+ wl_surface_destroy(c.surface);
+ c.toplevel = nullptr;
+ c.xsurface = nullptr;
+ c.surface = nullptr;
+ for (int i = 0; i < 50; ++i) {
+ pump(*server, c.display);
+ }
+
+ if (c.wm_base != nullptr) {
+ xdg_wm_base_destroy(c.wm_base);
+ }
+ if (c.shm != nullptr) {
+ wl_shm_destroy(c.shm);
+ }
+ if (c.compositor != nullptr) {
+ wl_compositor_destroy(c.compositor);
+ }
+ if (c.registry != nullptr) {
+ wl_registry_destroy(c.registry);
+ }
+ wl_display_flush(c.display);
+ pump(*server, c.display);
+ wl_display_disconnect(c.display);
+}
diff --git a/packages/host-bin/meson.build b/packages/host-bin/meson.build
index 71802a4..1c1b5a1 100644
--- a/packages/host-bin/meson.build
+++ b/packages/host-bin/meson.build
@@ -4,5 +4,5 @@
executable(
'unbox',
'src/main.cpp',
- dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep],
+ dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep, ext_stage_dock_dep],
)
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index 847bf89..7d33ac5 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -2,6 +2,7 @@
#include <unbox/ext-keybindings/ext_keybindings.hpp>
#include <unbox/ext-layer-shell/ext_layer_shell.hpp>
+#include <unbox/ext-stage-dock/ext_stage_dock.hpp>
#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
#include <unbox/kernel/kernel.hpp>
#include <unbox/kernel/server.hpp>
@@ -51,6 +52,10 @@ auto main(int argc, char* argv[]) -> int {
server->install(unbox::ext_xdg_shell::create());
server->install(unbox::ext_layer_shell::create());
server->install(unbox::ext_keybindings::create(config_path));
+ // The stage dock: Super+M minimizes the focused window into a left-edge
+ // dock of previews; tap a preview to restore. Standard tier, hidden until
+ // it holds a minimized window (depends_on xdg-shell, topo-activated).
+ server->install(unbox::ext_stage_dock::create());
if (ui_demo) {
server->install(unbox::host_bin::create_demo_ui());
}