summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--assets/ext-window-field/field.rcss77
-rw-r--r--assets/ext-window-field/field.rml21
-rw-r--r--meson.build1
-rw-r--r--packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp36
-rw-r--r--packages/ext-window-field/meson.build102
-rw-r--r--packages/ext-window-field/src/extension.cpp378
-rw-r--r--packages/ext-window-field/src/probe.hpp64
-rw-r--r--packages/ext-window-field/tests/test_glue.cpp365
-rw-r--r--packages/ext-window-field/tests/test_window_field.cpp20
-rw-r--r--packages/host-bin/meson.build2
-rw-r--r--packages/host-bin/src/main.cpp21
11 files changed, 1085 insertions, 2 deletions
diff --git a/assets/ext-window-field/field.rcss b/assets/ext-window-field/field.rcss
new file mode 100644
index 0000000..da84358
--- /dev/null
+++ b/assets/ext-window-field/field.rcss
@@ -0,0 +1,77 @@
+/* The WINDOW FIELD layout (GLOSSARY: "window field"). RML compositing Wave 3.
+ LAYOUT + ANIMATION live ENTIRELY here (the user's contract decision): the C++
+ glue only feeds the bound "wins" list + the per-row `focused` flag; this RCSS
+ decides every window's on-screen box and animates changes. Real tiling /
+ stage-manager is a LATER refinement on this same field + list — Wave 3 keeps
+ it SIMPLE but clearly working.
+
+ THE LAYOUT (simple, N-window-safe with no per-index data): the field is a
+ flex row that wraps; every window is a flex tile sharing the space. The
+ FOCUSED window grows to dominate the field (a large flex-basis) and is
+ highlighted + lifted; the rest stay a row of smaller equal tiles. Because it
+ is pure flex keyed only on the `.focused` class, it works for any window
+ count and needs no geometry from C++. A `transition` on flex/transform/border
+ animates focus + map/unmap changes (RmlUi tweens the class flip). */
+
+/* The field fills its whole surface (sized to the output by the glue). It is a
+ wrapping flex row with a little gutter; transparent so an unpainted gap shows
+ the scene below (per-pixel alpha). */
+body.field {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: stretch;
+ justify-content: center;
+ padding: 6dp;
+ background-color: transparent;
+}
+
+/* A window tile. Default (unfocused): an equal, smaller share of the row. The
+ transition animates focus changes (flex-basis grow/shrink, the lift transform,
+ the highlight border) AND the layout reflow when a window maps/unmaps. RmlUi
+ 6.2 has no bare `linear` keyword, so the carrier ease is cubic-in-out. */
+div.win {
+ display: block;
+ position: relative;
+ flex: 1 1 30%;
+ min-width: 20%;
+ height: 46%;
+ margin: 6dp;
+ background-color: #1c1c20ff;
+ border-radius: 8dp;
+ border-width: 2dp;
+ border-color: #00000000;
+ overflow: hidden;
+ transform: scale(0.96);
+ transform-origin: 50% 50%;
+ transition: flex-basis 0.22s cubic-in-out,
+ width 0.22s cubic-in-out,
+ height 0.22s cubic-in-out,
+ transform 0.22s cubic-in-out,
+ border-color 0.22s cubic-in-out;
+}
+
+/* The FOCUSED window DOMINATES the field: a large flex-basis so it takes the
+ bulk of the row (the rest wrap below as the smaller tile row), full height, a
+ bright highlight border, and a subtle lift (scale 1.0) — the "raise". This is
+ the only state C++ drives (the `focused` bool); everything else is geometry
+ RCSS owns. */
+div.win.focused {
+ flex: 1 1 100%;
+ height: 70%;
+ border-color: #4da3ffff;
+ transform: scale(1.0);
+}
+
+/* The live window texture fills its tile 1:1 (the <img src=live_uri> of the
+ surface element). The tile's rounded overflow:hidden clips it to the card. */
+div.win img.live {
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+}
diff --git a/assets/ext-window-field/field.rml b/assets/ext-window-field/field.rml
new file mode 100644
index 0000000..15f031e
--- /dev/null
+++ b/assets/ext-window-field/field.rml
@@ -0,0 +1,21 @@
+<rml>
+<head>
+<link type="text/rcss" href="field.rcss"/>
+</head>
+<!-- The WINDOW FIELD document (GLOSSARY: "window field"). data-model "ui". The
+ C++ glue (ext-window-field) drives ONLY the data: the bound "wins" list (one
+ row per mapped toplevel, each a live SURFACE ELEMENT) + the per-row `focused`
+ flag. LAYOUT + ANIMATION are RCSS (field.rcss) — the user's contract
+ decision: C++ never computes a window's on-screen rectangle.
+
+ Each row renders one <img src="{{ w.live_uri }}"> = the window's live
+ texture (UiSubstrate::create_surface_element -> SurfaceElement::source_uri()).
+ The kernel manages the toplevel's whole subsurface/popup tree behind that
+ single <img>; the document addresses only the root. data-class-focused keys
+ the RCSS highlight/raise off the focused window. -->
+<body data-model="ui" class="field">
+<div data-for="w : wins" class="win" data-class-focused="w.focused">
+<img class="live" src="{{ w.live_uri }}"/>
+</div>
+</body>
+</rml>
diff --git a/meson.build b/meson.build
index 17668f5..102d8ab 100644
--- a/meson.build
+++ b/meson.build
@@ -62,4 +62,5 @@ subdir('packages/ext-xdg-shell')
subdir('packages/ext-layer-shell')
subdir('packages/ext-stage-dock')
subdir('packages/ext-keybindings')
+subdir('packages/ext-window-field')
subdir('packages/host-bin')
diff --git a/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp b/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp
new file mode 100644
index 0000000..732a7c2
--- /dev/null
+++ b/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <memory>
+
+// ext-window-field — RML compositing's window manager (GLOSSARY: "window
+// field"). The CORE extension that composites application toplevels as RCSS
+// SURFACE ELEMENTS inside ONE ui surface, rather than as wlr_scene nodes. It:
+// - subscribes to ext-xdg-shell's toplevel map/unmap/focus (typed Service),
+// - turns each toplevel's root wl_surface (Toplevel::wl_surface()) into a
+// kernel SurfaceElement (UiSubstrate::create_surface_element),
+// - lists the windows into its own ui surface's RML document (a data-bound
+// list of live source_uri()s) and lets RCSS lay them out + animate,
+// - drives focus policy (click-to-focus -> SurfaceElement::focus_keyboard() +
+// Toplevel::focus()), and takes each toplevel OUT of the wlr_scene composite
+// (Toplevel::hide()) while it owns the window field.
+//
+// Layout and animation live entirely in RCSS (the user's contract decision);
+// this extension only DRIVES the document (which windows exist + their data) and
+// focus — it never computes on-screen geometry imperatively.
+//
+// This header is the unit's WHOLE cross-extension contract: a factory only
+// (a leaf — exports no hooks/services yet; a future tiling/stage policy is RCSS
+// + bound data on the same field). host-bin installs it ONLY when RML
+// compositing is enabled. Manifest: { id "window-field", tier core,
+// depends_on "xdg-shell" }. Single wl_event_loop thread throughout.
+
+namespace unbox::ext_window_field {
+
+// Construct the extension. Cheap and side-effect free (per the Extension
+// contract); ALL wiring happens in activate(). Ownership transfers to the
+// caller (host-bin installs it into the Server).
+[[nodiscard]] auto create() -> std::unique_ptr<kernel::Extension>;
+
+} // namespace unbox::ext_window_field
diff --git a/packages/ext-window-field/meson.build b/packages/ext-window-field/meson.build
new file mode 100644
index 0000000..1b3766d
--- /dev/null
+++ b/packages/ext-window-field/meson.build
@@ -0,0 +1,102 @@
+# ext-window-field — RML compositing Phase 2, Wave 3 (GLOSSARY: "window field").
+# The core extension that composites application toplevels as RCSS SURFACE
+# ELEMENTS inside ONE ui surface (the "window field"), instead of as wlr_scene
+# nodes: it subscribes to ext-xdg-shell's toplevel lifecycle, turns each
+# toplevel's root wl_surface into a kernel SurfaceElement, lists them into its
+# RML document, and lets RCSS lay them out + animate. Layout/animation are RCSS
+# (the user's contract decision); this glue only DRIVES the document + focus.
+#
+# Installed by host-bin ONLY when RML compositing is enabled (the flag) — so the
+# classic wlr_scene path stays the default until this is proven on the real seat.
+# Tier: core; depends_on "xdg-shell".
+
+ext_window_field_inc = include_directories('include')
+
+# Glue library: needs the kernel ABI (ui substrate + SurfaceElement) and
+# ext-xdg-shell's public contract (Toplevel::wl_surface(), the Service events).
+ext_window_field_lib = static_library(
+ 'unbox-ext-window-field',
+ 'src/extension.cpp',
+ include_directories: ext_window_field_inc,
+ dependencies: [kernel_dep, ext_xdg_shell_dep],
+)
+
+# What host-bin links against (the factory). kernel_dep rides through for the
+# Extension ABI the factory returns; ext-xdg-shell stays a build-time dep of OUR
+# lib only.
+ext_window_field_dep = declare_dependency(
+ link_with: ext_window_field_lib,
+ include_directories: ext_window_field_inc,
+ dependencies: [kernel_dep],
+)
+
+# Tests, asymmetric (AGENTS.md: strict cores, lenient shell). This unit is PURE
+# GLUE — its only policy (window layout) lives in RCSS, not C++, so there is no
+# doctest-able decision core; the SMOKE test (factory/manifest) + a lenient
+# HEADLESS GLUE test (below) are the whole suite.
+
+# SMOKE test: the factory yields the window-field extension with the right
+# manifest. Cheap, no kernel/wlroots running.
+ext_window_field_smoke_test = executable(
+ 'ext-window-field-smoke-tests',
+ 'tests/test_window_field.cpp',
+ include_directories: [ext_window_field_inc, include_directories('src')],
+ dependencies: [ext_window_field_dep, doctest_dep],
+)
+test(
+ 'ext-window-field-smoke',
+ ext_window_field_smoke_test,
+ suite: 'ext-window-field',
+)
+
+# HEADLESS GLUE test: install ext-xdg-shell + ext-window-field on the wlr
+# headless backend, map REAL in-process client toplevels, then drive the
+# map/unmap/focus PIPELINE and assert the bound window MODEL via the private test
+# probe (src/probe.hpp) + that Toplevel::hide() was driven. Lenient ext-tier
+# glue: the substrate is null on pixman (no GL), so the field UiSurface +
+# SurfaceElements are null — this exercises the model + hide(), not the visual
+# (same posture as ext-stage-dock's test_glue.cpp). Needs CLIENT-side xdg-shell
+# bindings generated from the canonical xdg-shell.xml exactly as ext-stage-dock's
+# test_glue.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).
+wf_wayland_client_dep = dependency('wayland-client')
+wf_wayland_protocols_dir = dependency('wayland-protocols').get_variable('pkgdatadir')
+wf_xdg_shell_xml = wf_wayland_protocols_dir / 'stable' / 'xdg-shell' / 'xdg-shell.xml'
+
+wf_xdg_shell_client_header = custom_target(
+ 'wf-xdg-shell-client-header',
+ input: wf_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.
+wf_xdg_shell_client_code = custom_target(
+ 'wf-xdg-shell-client-code-impl',
+ input: wf_xdg_shell_xml,
+ output: 'xdg-shell-client-protocol-code.h',
+ command: [wayland_scanner, 'private-code', '@INPUT@', '@OUTPUT@'],
+)
+
+ext_window_field_glue_test = executable(
+ 'ext-window-field-glue-tests',
+ 'tests/test_glue.cpp',
+ wf_xdg_shell_client_header,
+ wf_xdg_shell_client_code,
+ include_directories: [ext_window_field_inc, include_directories('src')],
+ dependencies: [ext_window_field_dep, ext_xdg_shell_dep, wf_wayland_client_dep, doctest_dep],
+)
+test(
+ 'ext-window-field-glue',
+ ext_window_field_glue_test,
+ suite: 'ext-window-field',
+ # 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: `ninja -C build ext-window-field-tests`.
+alias_target('ext-window-field-tests',
+ ext_window_field_smoke_test,
+ ext_window_field_glue_test,
+)
diff --git a/packages/ext-window-field/src/extension.cpp b/packages/ext-window-field/src/extension.cpp
new file mode 100644
index 0000000..c1d297e
--- /dev/null
+++ b/packages/ext-window-field/src/extension.cpp
@@ -0,0 +1,378 @@
+#include <unbox/ext-window-field/ext_window_field.hpp>
+
+#include "probe.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 <algorithm>
+#include <cstddef>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <vector>
+
+// ext-window-field glue (RML compositing Phase 2, Wave 3 — GLOSSARY: "window
+// field"). This is the WHOLE unit: there is no pure decision core to doctest —
+// the "what windows exist + their per-row data" decision is trivial bookkeeping
+// over a vector, and the LAYOUT (the only real policy) lives entirely in RCSS
+// (assets/ext-window-field/field.{rml,rcss}) per the user's contract decision.
+// So this glue only DRIVES the document: it pushes the live window list + the
+// focused flag and lets RCSS lay them out and animate.
+//
+// The pipeline:
+// on_toplevel_mapped -> create_surface_element(wl_surface) (owns the live
+// import), Toplevel::hide() (take it OUT of wlr_scene
+// — the surface element is now the only compositor of
+// those pixels), append a Window row, dirty("wins").
+// on_toplevel_unmapped -> drop the Window (frees its SurfaceElement), dirty.
+// on_toplevel_focused -> mark that window focused, dirty (RCSS raises/
+// highlights the focused row).
+//
+// Keyboard focus itself is ext-xdg-shell::Toplevel::focus()'s job (the seat
+// keyboard enter); pointer/touch input-back to the client is automatic in the
+// kernel (a pick on a surface element routes to its client). So this extension
+// only tracks the focused flag for the RCSS highlight — it does NOT wire seat
+// calls. Click-to-focus a BACKGROUND window is a known gap (the kernel does not
+// yet notify the wm that a surface element was pressed); see the report's
+// change-request. For Wave 3, focus follows map + on_toplevel_focused (Alt+Tab
+// via ext-keybindings produces the latter).
+//
+// Everything runs on the single wl_event_loop thread. Every resource is a RAII
+// member of WindowField; teardown is reverse-declaration destruction (no manual
+// teardown lists — extension-agent.md). The Subscriptions release FIRST
+// (declared last), then the field UiSurface (its bindings capture `this` and
+// read windows_, so it is destroyed BEFORE windows_), then the windows_ vector
+// (each Window's SurfaceElement frees its import) — all before the Host borrow
+// goes away.
+
+namespace unbox::ext_window_field {
+namespace {
+
+using kernel::Host;
+using Toplevel = ext_xdg_shell::Toplevel;
+
+// One window in the field. The Toplevel* is a BORROW valid map..unmapped (per
+// the ext-xdg-shell contract), so we may key our own tracking on it and deref
+// it within that window; we drop the Window the instant on_toplevel_unmapped
+// fires for it (never deref after). `element` owns the live import + the
+// frame-callback duty; it is null on a no-GL backend (headless pixman) — the
+// model is still tracked there (lenient-glue degrade). title/app_id are copied
+// at map time (the Toplevel's title()/app_id() views are call-only).
+struct Window {
+ Toplevel* tl = nullptr; // borrow, live until unmapped
+ std::unique_ptr<kernel::SurfaceElement> element; // owns the live import (null = no GL)
+ std::string title; // copied at map time
+ std::string app_id; // copied at map time
+};
+
+// The window field document lives in EXTERNAL ASSET FILES (loaded via
+// UiSurfaceSpec::rml_path so layout changes need no recompile + dev hot-reload):
+// assets/ext-window-field/field.rml — the RML STRUCTURE: data-model "ui",
+// data-for="w : wins" emitting one .win per window with the live <img
+// src="{{ w.live_uri }}">, data-class-focused="w.focused" for the RCSS
+// highlight/raise. It links field.rcss via <link type="text/rcss" ...>.
+// assets/ext-window-field/field.rcss — ALL the layout: the focused window
+// fills the field, the rest are a row of smaller tiles along the bottom; a
+// `transition` animates focus/layout changes.
+// The C++ binding setup (bind_list*/bind_list_string/bool in create_field_surface)
+// is re-applied by the substrate across hot-reloads.
+
+class WindowFieldExtension final : public kernel::Extension, public TestProbe {
+public:
+ [[nodiscard]] auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+
+ // ---- TestProbe (src/probe.hpp; glue-test only) ----
+ [[nodiscard]] auto activated() const -> bool override { return activated_; }
+ [[nodiscard]] auto window_count() const -> std::size_t override { return windows_.size(); }
+ [[nodiscard]] auto live_uri(std::size_t i) const -> std::string override {
+ if (i >= windows_.size() || windows_[i].element == nullptr) {
+ return std::string{};
+ }
+ return windows_[i].element->source_uri();
+ }
+ [[nodiscard]] auto has_surface_element(std::size_t i) const -> bool override {
+ return i < windows_.size() && windows_[i].element != nullptr;
+ }
+ [[nodiscard]] auto focused_index() const -> std::ptrdiff_t override {
+ return index_of(focused_);
+ }
+ [[nodiscard]] auto hidden_count() const -> std::size_t override { return hidden_count_; }
+
+ void activate(Host& host) override {
+ host_ = &host;
+
+ // Fatal: a missing ext-xdg-shell Service. The field composites that
+ // extension's toplevels as surface elements — 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-window-field: ext-xdg-shell Service unavailable (depends_on "
+ "\"xdg-shell\" not satisfied)");
+ }
+
+ // Create the window field surface up front (sized to the primary output
+ // if one exists yet). Null on a no-GL backend (headless pixman) — we
+ // degrade gracefully: the model is still tracked, hide() still runs,
+ // there is just no composited surface.
+ create_field_surface();
+
+ // Re-size the field to a freshly-added output (Wave 3 tracks the primary
+ // output only — multi-output is a follow-up; see the report). If the
+ // field was created before any output existed (size 0), this gives it
+ // its first real size.
+ output_added_ = host.subscribe(
+ host.on_output_added(), [this](const kernel::OutputEvent&) { size_to_primary_output(); });
+
+ // Window lifecycle via the ext-xdg-shell Service. The Toplevel* borrow
+ // is valid mapped..unmapped, so we key windows_ on it and only deref a
+ // live one; we drop the Window on unmapped.
+ mapped_ = host.subscribe(
+ shell_->on_toplevel_mapped(),
+ [this](const ext_xdg_shell::ToplevelEvent& e) { on_mapped(e.toplevel); });
+ focused_sub_ = host.subscribe(
+ shell_->on_toplevel_focused(),
+ [this](const ext_xdg_shell::ToplevelEvent& e) { on_focused(e.toplevel); });
+ unmapped_ = host.subscribe(
+ shell_->on_toplevel_unmapped(),
+ [this](const ext_xdg_shell::ToplevelEvent& e) { on_unmapped(e.toplevel); });
+
+ activated_ = true;
+ }
+
+private:
+ // ---- window lifecycle ---------------------------------------------------
+
+ // A toplevel mapped: composite it as a surface element + take it out of
+ // wlr_scene. ext-xdg-shell focuses a freshly-mapped window, so it becomes
+ // the focused window of the field (on_toplevel_focused may also fire — both
+ // converge on the same value).
+ void on_mapped(Toplevel* tl) {
+ if (tl == nullptr) {
+ return;
+ }
+ Window w;
+ w.tl = tl;
+ w.title = std::string(tl->title()); // copy: title() is call-only
+ w.app_id = std::string(tl->app_id()); // copy: app_id() is call-only
+
+ // Turn the toplevel's ROOT wl_surface into a live surface element. The
+ // wl_surface is a borrow valid until unmapped (we drop the element then).
+ // create_surface_element returns null on a no-GL backend / failed import
+ // (graceful degrade); the model is still tracked. wl_surface() returns
+ // null only for an already-destroyed toplevel (never for a live mapped
+ // one) — guard anyway.
+ if (host_->ui().available()) {
+ if (wlr_surface* surface = tl->wl_surface(); surface != nullptr) {
+ w.element = host_->ui().create_surface_element(surface);
+ }
+ }
+
+ // Take the toplevel OUT of wlr_scene: the surface element is now the ONLY
+ // compositor of those pixels (the substrate drives the client's frame
+ // callbacks). hide() is NOT unmap — the client stays mapped, its Toplevel*
+ // borrow stays valid, and no on_toplevel_unmapped fires.
+ tl->hide();
+ ++hidden_count_;
+
+ windows_.push_back(std::move(w));
+ focused_ = tl; // map-focus: a freshly mapped window is focused
+ dirty_wins();
+ }
+
+ // A toplevel unmapping: drop its Window (frees the SurfaceElement — we must
+ // NOT sample a surface element after its wl_surface is gone; UB per ui.hpp)
+ // and clear focus tracking if it was the focused one. The Toplevel* borrow
+ // is dead after this call — never deref it again.
+ void on_unmapped(Toplevel* tl) {
+ std::erase_if(windows_, [tl](const Window& w) { return w.tl == tl; });
+ if (focused_ == tl) {
+ focused_ = nullptr;
+ }
+ dirty_wins();
+ }
+
+ // Keyboard focus moved to `tl` (map-focus, click/tap-to-focus on a surface
+ // element the kernel routed, or programmatic Toplevel::focus() — e.g.
+ // ext-keybindings' Alt+Tab). We only update the focused flag the RCSS keys
+ // its highlight/raise off; the seat keyboard enter itself is ext-xdg-shell's
+ // job, already done by the time this fires.
+ void on_focused(Toplevel* tl) {
+ focused_ = tl;
+ dirty_wins();
+ }
+
+ // ---- field surface ------------------------------------------------------
+
+ // Create the window field UiSurface at SceneLayer::normal (the toplevel
+ // band) sized to the primary output, and register all data bindings BEFORE
+ // the first frame. Null surface (no-GL backend) is fine — we skip the
+ // bindings and the model is still tracked.
+ void create_field_surface() {
+ wlr_box box = primary_output_box();
+
+ kernel::UiSurfaceSpec spec;
+ // External asset (RELATIVE to the asset root the orchestrator wires) so
+ // the field document is editable without recompiling + dev hot-reloads.
+ spec.rml_path = "ext-window-field/field.rml";
+ spec.model = "ui";
+ spec.x = box.x;
+ spec.y = box.y;
+ // The substrate rejects non-positive geometry; on a backend with no
+ // output yet box is 0x0, so clamp to >= 1 (size_to_primary_output()
+ // resizes to the real output on on_output_added).
+ spec.width = std::max(1, box.width);
+ spec.height = std::max(1, box.height);
+ spec.layer = kernel::SceneLayer::normal; // the application toplevel band
+ spec.visible = true;
+
+ field_surface_ = host_->ui().create_surface(spec);
+ if (field_surface_ == nullptr) {
+ return; // no GL path: degrade gracefully (model only)
+ }
+
+ // List bindings. All registered BEFORE the first frame, capturing only
+ // `this` (whose members outlive the surface, which is destroyed before
+ // them in reverse declaration order). The list is named "wins"; the RML
+ // iterates data-for="w : wins".
+ field_surface_->bind_list(
+ "wins", [this]() -> std::size_t { return windows_.size(); });
+ // live_uri: the SurfaceElement's <img src> (its live texture URI). Empty
+ // when the element is null (no-GL) — the RML still renders the row (RCSS
+ // shows the placeholder background); the substrate ignores an empty src.
+ field_surface_->bind_list_string(
+ "wins", "live_uri", [this](std::size_t i) -> std::string {
+ if (i >= windows_.size() || windows_[i].element == nullptr) {
+ return std::string{};
+ }
+ return windows_[i].element->source_uri();
+ });
+ // focused: the RCSS highlight/raise key (data-class-focused="w.focused").
+ field_surface_->bind_list_bool(
+ "wins", "focused", [this](std::size_t i) -> bool {
+ return i < windows_.size() && windows_[i].tl == focused_;
+ });
+ // title / app_id: optional per-row strings (a future label/RCSS hook).
+ field_surface_->bind_list_string(
+ "wins", "title", [this](std::size_t i) -> std::string {
+ return i < windows_.size() ? windows_[i].title : std::string{};
+ });
+ field_surface_->bind_list_string(
+ "wins", "app_id", [this](std::size_t i) -> std::string {
+ return i < windows_.size() ? windows_[i].app_id : std::string{};
+ });
+ }
+
+ // Re-read the bound list (count + every visible row field) and re-render the
+ // field on the next frame. No-op when the surface is null (no-GL backend) —
+ // the model is still tracked for the probe.
+ void dirty_wins() {
+ if (field_surface_ != nullptr) {
+ field_surface_->dirty("wins");
+ }
+ }
+
+ // Resize/reposition the field to the primary output's box. Called on
+ // on_output_added. No-op when the surface is null or the box is empty.
+ void size_to_primary_output() {
+ if (field_surface_ == nullptr) {
+ return;
+ }
+ wlr_box box = primary_output_box();
+ if (box.width <= 0 || box.height <= 0) {
+ return;
+ }
+ field_surface_->set_position(box.x, box.y);
+ field_surface_->set_size(box.width, box.height);
+ }
+
+ // The primary (first) output's box in layout coords, or an empty box (0x0)
+ // when no output exists yet. Wave 3 sizes the single field to the primary
+ // output; multi-output (one field per output) is a follow-up.
+ [[nodiscard]] auto primary_output_box() const -> wlr_box {
+ wlr_box box{};
+ wlr_output_layout* ol = host_->output_layout();
+ if (ol == nullptr) {
+ return box;
+ }
+ wlr_output_layout_output* lo = nullptr;
+ wl_list_for_each(lo, &ol->outputs, link) {
+ wlr_box b{};
+ wlr_output_layout_get_box(ol, lo->output, &b);
+ if (!wlr_box_empty(&b)) {
+ box = b;
+ }
+ break; // primary output only (Wave 3)
+ }
+ return box;
+ }
+
+ // ---- helpers ------------------------------------------------------------
+
+ // The list index of toplevel `tl`, or -1 if it is not tracked / is null.
+ [[nodiscard]] auto index_of(Toplevel* tl) const -> std::ptrdiff_t {
+ if (tl == nullptr) {
+ return -1;
+ }
+ for (std::size_t i = 0; i < windows_.size(); ++i) {
+ if (windows_[i].tl == tl) {
+ return static_cast<std::ptrdiff_t>(i);
+ }
+ }
+ return -1;
+ }
+
+ const kernel::Manifest manifest_{
+ .id = "window-field",
+ .tier = kernel::Tier::core,
+ .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()
+ std::size_t hidden_count_ = 0; // # windows taken out of wlr_scene
+
+ // The currently focused window (a borrow valid until its unmapped). Drives
+ // the per-row `focused` bool the RCSS highlights/raises. Cleared when its
+ // window unmaps.
+ Toplevel* focused_ = nullptr;
+
+ // The window model. Declared BEFORE field_surface_ so the surface (whose
+ // bindings read windows_) is destroyed FIRST — windows_ (and its
+ // SurfaceElements) stay valid through the surface's teardown, then drop, all
+ // before host_'s borrow ends. Each Window owns a SurfaceElement (frees its
+ // import on erase/destruction).
+ std::vector<Window> windows_;
+
+ // The window field ui surface. Destroyed before windows_ (declared after it)
+ // so any binding getter invoked during its teardown still sees a live
+ // windows_; destroyed before host_'s borrow ends (it is a member). Null on a
+ // no-GL backend.
+ std::unique_ptr<kernel::UiSurface> field_surface_;
+
+ // RAII subscriptions — declared LAST so they release FIRST at teardown,
+ // before the field surface + model their callbacks touch (listener-lifetime).
+ kernel::Subscription output_added_;
+ kernel::Subscription mapped_;
+ kernel::Subscription focused_sub_;
+ kernel::Subscription unmapped_;
+};
+
+} // namespace
+
+auto create() -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<WindowFieldExtension>();
+}
+
+auto make_extension_with_probe() -> ExtensionWithProbe {
+ auto ext = std::make_unique<WindowFieldExtension>();
+ TestProbe* probe = ext.get();
+ return ExtensionWithProbe{.extension = std::move(ext), .probe = probe};
+}
+
+} // namespace unbox::ext_window_field
diff --git a/packages/ext-window-field/src/probe.hpp b/packages/ext-window-field/src/probe.hpp
new file mode 100644
index 0000000..da2c953
--- /dev/null
+++ b/packages/ext-window-field/src/probe.hpp
@@ -0,0 +1,64 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <cstddef>
+#include <memory>
+#include <string>
+
+// Test-only probe surface (PRIVATE — src/, never part of the contract). The
+// headless glue test needs to read the window-field MODEL (the bound window
+// list) without a GL substrate: on the headless pixman backend the ui surface +
+// the SurfaceElements are null (no GL path), so there is nothing visual to
+// assert — only the list/model and that Toplevel::hide() was driven. This probe
+// exposes exactly that. The public create() hides the concrete extension behind
+// kernel::Extension; make_extension_with_probe() hands back the same Extension
+// plus a borrowed probe the test polls.
+//
+// Glue/shell test convenience only; never a contract claim. All calls are on
+// the single event-loop thread, like the extension itself.
+
+namespace unbox::ext_window_field {
+
+// 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 TestProbe {
+public:
+ virtual ~TestProbe() = default;
+
+ // True once activate() completed (Service fetched, hooks wired, the window
+ // field ui surface creation attempted).
+ [[nodiscard]] virtual auto activated() const -> bool = 0;
+
+ // The number of windows currently tracked in the window field (the bound
+ // "wins" list size).
+ [[nodiscard]] virtual auto window_count() const -> std::size_t = 0;
+
+ // The live_uri the list binds for window `i` (SurfaceElement::source_uri(),
+ // or "" when the surface element is null — no GL path on the headless
+ // pixman backend). Empty string for an out-of-range index.
+ [[nodiscard]] virtual auto live_uri(std::size_t i) const -> std::string = 0;
+
+ // Whether window `i` carries a non-null SurfaceElement (true only when the
+ // substrate has a GL path). False on the headless pixman backend.
+ [[nodiscard]] virtual auto has_surface_element(std::size_t i) const -> bool = 0;
+
+ // The index of the currently focused window in the list, or -1 if none /
+ // the focused window is not tracked. The RCSS keys its highlight/raise off
+ // the per-row `focused` bool this drives.
+ [[nodiscard]] virtual auto focused_index() const -> std::ptrdiff_t = 0;
+
+ // How many tracked windows have been hidden out of wlr_scene (had
+ // Toplevel::hide() driven on map). One per mapped window the field owns.
+ [[nodiscard]] virtual auto hidden_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_window_field
diff --git a/packages/ext-window-field/tests/test_glue.cpp b/packages/ext-window-field/tests/test_glue.cpp
new file mode 100644
index 0000000..d49d26f
--- /dev/null
+++ b/packages/ext-window-field/tests/test_glue.cpp
@@ -0,0 +1,365 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "probe.hpp" // PRIVATE: a unit may read its own src/ (drives the model)
+
+#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-stage-dock's test_glue.cpp does (header + private-code-as-header,
+// #included once by this single C++ TU). Lets in-process real clients map
+// xdg_toplevels so we drive the window-field map/unmap/focus 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-window-field GLUE test — lenient, headless (AGENTS.md: strict cores,
+// lenient shell). This unit has NO pure decision core (layout is RCSS, not C++),
+// so the whole verification is this integration pipeline:
+// install ext-xdg-shell + ext-window-field, activate (fetch the Service — the
+// only fatal path), map a REAL toplevel, and assert (via the private probe)
+// that it became a TRACKED window (count 1, hidden out of wlr_scene), mapping
+// a SECOND grows the list to 2, unmapping removes it, and on_toplevel_focused
+// flips the tracked focused index.
+//
+// On the headless pixman backend the ui substrate has no GL path, so the field
+// UiSurface + the SurfaceElements are null — exactly the degrade the brief calls
+// for: the MODEL/list + Toplevel::hide() still work; there is no live texture to
+// assert (live_uri is empty, has_surface_element() false). A separate OBSERVER
+// extension (mirroring ext-xdg-shell/tests/test_minimize.cpp) captures the mapped
+// Toplevel borrows so the test can drive on_toplevel_focused via the Service.
+
+namespace {
+
+using unbox::ext_window_field::ExtensionWithProbe;
+using unbox::ext_window_field::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 borrows (test-only) -----
+//
+// Depends on xdg-shell (so its Service is registered first). Holds the Service
+// (to call Toplevel::focus(), which produces on_toplevel_focused) and the most
+// recently mapped Toplevel*, plus the FIRST mapped one — both borrows valid
+// mapped..unmapped, so the test can re-focus the first window after the second
+// maps and assert the field's focused index follows.
+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 {
+ svc_ = host.service<Service>();
+ REQUIRE(svc_ != nullptr);
+ sub_mapped_ = host.subscribe(
+ svc_->on_toplevel_mapped(), [this](const ToplevelEvent& e) {
+ last_mapped_ = e.toplevel;
+ if (first_mapped_ == nullptr) {
+ first_mapped_ = e.toplevel;
+ }
+ });
+ sub_unmapped_ = host.subscribe(
+ svc_->on_toplevel_unmapped(), [this](const ToplevelEvent& e) {
+ if (e.toplevel == last_mapped_) {
+ last_mapped_ = nullptr;
+ }
+ if (e.toplevel == first_mapped_) {
+ first_mapped_ = nullptr;
+ }
+ });
+ }
+
+ [[nodiscard]] auto service() -> Service* { return svc_; }
+ [[nodiscard]] auto last_mapped() -> Toplevel* { return last_mapped_; }
+ [[nodiscard]] auto first_mapped() -> Toplevel* { return first_mapped_; }
+
+private:
+ unbox::kernel::Manifest manifest_{"test-observer", unbox::kernel::Tier::standard,
+ {"xdg-shell"}};
+ Service* svc_ = nullptr;
+ Toplevel* last_mapped_ = nullptr;
+ Toplevel* first_mapped_ = nullptr;
+ unbox::kernel::Subscription sub_mapped_;
+ unbox::kernel::Subscription sub_unmapped_;
+};
+
+// ---- minimal xdg-shell client (mirrors ext-stage-dock/tests/test_glue.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;
+};
+
+// One mapped toplevel's client objects (the field tracks multiple windows, so we
+// map several from the same connection).
+struct Window {
+ wl_surface* surface = nullptr;
+ xdg_surface* xsurface = nullptr;
+ xdg_toplevel* toplevel = nullptr;
+ wl_buffer* buffer = 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* w = static_cast<Window*>(data);
+ xdg_surface_ack_configure(xs, serial);
+ w->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 w*h 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-window-field-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;
+}
+
+// Map one toplevel from connection `c` and pump until it is mapped server-side.
+// `gate` is polled until non-null (the observer's most-recently-mapped borrow),
+// so the caller can confirm the new window mapped.
+void map_window(unbox::kernel::Server& server, Client& c, Window& w, const char* title) {
+ w.surface = wl_compositor_create_surface(c.compositor);
+ w.xsurface = xdg_wm_base_get_xdg_surface(c.wm_base, w.surface);
+ xdg_surface_add_listener(w.xsurface, &kXSurfaceListener, &w);
+ w.toplevel = xdg_surface_get_toplevel(w.xsurface);
+ xdg_toplevel_add_listener(w.toplevel, &kToplevelListener, &w);
+ xdg_toplevel_set_title(w.toplevel, title);
+ wl_surface_commit(w.surface); // mandatory empty initial commit
+
+ for (int i = 0; i < 200 && !w.configured; ++i) {
+ pump(server, c.display);
+ }
+ REQUIRE(w.configured);
+
+ w.buffer = make_buffer(c, 256, 256);
+ REQUIRE(w.buffer != nullptr);
+ wl_surface_attach(w.surface, w.buffer, 0, 0);
+ wl_surface_damage(w.surface, 0, 0, 256, 256);
+ wl_surface_commit(w.surface);
+}
+
+void destroy_window(Window& w) {
+ if (w.toplevel != nullptr) {
+ xdg_toplevel_destroy(w.toplevel);
+ }
+ if (w.xsurface != nullptr) {
+ xdg_surface_destroy(w.xsurface);
+ }
+ if (w.surface != nullptr) {
+ wl_surface_destroy(w.surface);
+ }
+ if (w.buffer != nullptr) {
+ wl_buffer_destroy(w.buffer);
+ }
+ w = Window{};
+}
+
+} // namespace
+
+TEST_CASE("ext-window-field 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 (window-field depends_on it),
+ // so activate() finds the Service. A missing-Service throw would propagate.
+ ExtensionWithProbe ewp = unbox::ext_window_field::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->window_count() == 0);
+ CHECK(probe->focused_index() == -1);
+ CHECK(probe->hidden_count() == 0);
+}
+
+TEST_CASE("ext-window-field: map tracks a window + hides it; a 2nd grows the list; "
+ "focus flips the flag; unmap removes it") {
+ 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_window_field::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 the FIRST toplevel -> becomes a tracked window, hidden from scene
+ Window w1;
+ map_window(*server, c, w1, "unbox-field-1");
+ for (int i = 0; i < 200 && observer->last_mapped() == nullptr; ++i) {
+ pump(*server, c.display);
+ }
+ Toplevel* tl1 = observer->last_mapped();
+ REQUIRE(tl1 != nullptr);
+ CHECK(probe->window_count() == 1);
+ // hide() took it OUT of wlr_scene (the surface element is now the compositor).
+ CHECK(probe->hidden_count() == 1);
+ // ext-xdg-shell focuses a freshly-mapped window, so the field tracks it as
+ // focused (map-focus; on_toplevel_focused may also have fired — same value).
+ CHECK(probe->focused_index() == 0);
+ // No GL path on headless pixman: the surface element is null, so live_uri is
+ // empty + has_surface_element() is false (lenient-glue degrade per the brief).
+ CHECK(probe->has_surface_element(0) == false);
+ CHECK(probe->live_uri(0).empty());
+
+ // --- map a SECOND toplevel -> the list grows to 2, the 2nd is now focused
+ Window w2;
+ map_window(*server, c, w2, "unbox-field-2");
+ for (int i = 0; i < 200 && observer->last_mapped() == tl1; ++i) {
+ pump(*server, c.display);
+ }
+ Toplevel* tl2 = observer->last_mapped();
+ REQUIRE(tl2 != nullptr);
+ CHECK(tl2 != tl1);
+ CHECK(probe->window_count() == 2);
+ CHECK(probe->hidden_count() == 2);
+ CHECK(probe->focused_index() == 1); // map-focus moved to the 2nd window
+
+ // --- on_toplevel_focused flips the focused flag: re-focus the FIRST window
+ // via Toplevel::focus() (exactly what ext-keybindings' Alt+Tab does). The
+ // field's focused index must follow back to window 0.
+ REQUIRE(observer->first_mapped() == tl1);
+ tl1->focus();
+ for (int i = 0; i < 50 && probe->focused_index() != 0; ++i) {
+ pump(*server, c.display);
+ }
+ CHECK(probe->focused_index() == 0);
+ CHECK(probe->window_count() == 2); // focus changes membership not at all
+
+ // --- unmap the SECOND window -> the list shrinks back to 1
+ destroy_window(w2);
+ for (int i = 0; i < 200 && probe->window_count() != 1; ++i) {
+ pump(*server, c.display);
+ }
+ CHECK(probe->window_count() == 1);
+ // The first window is still tracked + still the focused one.
+ CHECK(probe->focused_index() == 0);
+
+ // --- unmap the FIRST too -> empty + no focused window
+ destroy_window(w1);
+ for (int i = 0; i < 200 && probe->window_count() != 0; ++i) {
+ pump(*server, c.display);
+ }
+ CHECK(probe->window_count() == 0);
+ CHECK(probe->focused_index() == -1);
+
+ // Teardown: dropping the server destroys extensions in reverse activation
+ // order; within the field, Subscriptions release first, then the field
+ // UiSurface (null here), then the windows_ vector (empty now) — all before
+ // the Host borrow ends (asan watches this).
+ 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/ext-window-field/tests/test_window_field.cpp b/packages/ext-window-field/tests/test_window_field.cpp
new file mode 100644
index 0000000..f585247
--- /dev/null
+++ b/packages/ext-window-field/tests/test_window_field.cpp
@@ -0,0 +1,20 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include <unbox/ext-window-field/ext_window_field.hpp>
+#include <unbox/kernel/extension.hpp>
+
+// SCAFFOLD (Wave 3). Smoke test that the factory + manifest are wired. The
+// owner-agent replaces/extends this with the real headless glue test (install
+// ext-xdg-shell + ext-window-field on the wlr headless backend, map a real
+// in-process client toplevel, and assert it becomes a listed surface element +
+// that focus routes), mirroring ext-stage-dock's test_glue.cpp client-codegen.
+
+TEST_CASE("ext-window-field: factory yields the window-field extension") {
+ auto ext = unbox::ext_window_field::create();
+ REQUIRE(ext != nullptr);
+ CHECK(ext->manifest().id == "window-field");
+ CHECK(ext->manifest().tier == unbox::kernel::Tier::core);
+ REQUIRE(ext->manifest().depends_on.size() == 1);
+ CHECK(ext->manifest().depends_on[0] == "xdg-shell");
+}
diff --git a/packages/host-bin/meson.build b/packages/host-bin/meson.build
index 76f0e75..2ff8559 100644
--- a/packages/host-bin/meson.build
+++ b/packages/host-bin/meson.build
@@ -4,6 +4,6 @@
executable(
'unbox',
'src/main.cpp',
- dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep, ext_stage_dock_dep],
+ dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep, ext_stage_dock_dep, ext_window_field_dep],
install: true,
)
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index 7d33ac5..507efdc 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -3,11 +3,13 @@
#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-window-field/ext_window_field.hpp>
#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
#include <unbox/kernel/kernel.hpp>
#include <unbox/kernel/server.hpp>
#include <cstdio>
+#include <cstdlib>
#include <exception>
#include <optional>
#include <string>
@@ -16,7 +18,9 @@
namespace {
void print_usage(const char* argv0) {
- std::printf("usage: %s [-s <startup command>] [--config <path>] [--ui-demo]\n", argv0);
+ std::printf("usage: %s [-s <startup command>] [--config <path>] [--ui-demo] "
+ "[--rml-compositing]\n",
+ argv0);
}
} // namespace
@@ -24,6 +28,13 @@ void print_usage(const char* argv0) {
auto main(int argc, char* argv[]) -> int {
unbox::kernel::Server::Options options;
bool ui_demo = false;
+ // RML compositing (Phase 2): when on, install ext-window-field, which
+ // composites toplevels as RCSS surface elements instead of wlr_scene nodes.
+ // Default OFF so the proven wlr_scene path stays the default until RML
+ // compositing is signed off on the real seat. Enable via --rml-compositing
+ // or the UNBOX_RML_COMPOSITING env var (the flag is intentionally a runtime
+ // toggle so the two compositing paths can be A/B'd on hardware).
+ bool rml_compositing = std::getenv("UNBOX_RML_COMPOSITING") != nullptr;
std::optional<std::string> config_path;
for (int i = 1; i < argc; ++i) {
const std::string_view arg = argv[i];
@@ -37,6 +48,8 @@ auto main(int argc, char* argv[]) -> int {
// Slice-5 acceptance demo (temporary until the real UI extensions):
// a ui surface via the public substrate contract.
ui_demo = true;
+ } else if (arg == "--rml-compositing") {
+ rml_compositing = true;
} else {
print_usage(argv[0]);
return arg == "-h" ? 0 : 1;
@@ -56,6 +69,12 @@ auto main(int argc, char* argv[]) -> int {
// 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());
+ // RML compositing (Phase 2, opt-in): the window field composites toplevels
+ // as RCSS surface elements. depends_on "xdg-shell" (topologically
+ // activated). When off, toplevels keep compositing through wlr_scene.
+ if (rml_compositing) {
+ server->install(unbox::ext_window_field::create());
+ }
if (ui_demo) {
server->install(unbox::host_bin::create_demo_ui());
}