diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 13:47:24 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 13:47:24 +0900 |
| commit | ed1e3d60fe209af06911c640a145936f71adc238 (patch) | |
| tree | 70402344391eaddbb0a5360bd2a613e7fe5ca12f | |
| parent | 91312ec3e164b04216ca10cd36bf77a6b72be533 (diff) | |
| download | unbox-ed1e3d60fe209af06911c640a145936f71adc238.tar.gz unbox-ed1e3d60fe209af06911c640a145936f71adc238.zip | |
ext-window-field(rml-compositing W3): toplevels as RCSS surface elements
NEW core unit (id "window-field", depends_on "xdg-shell"), installed by host-bin
ONLY under --rml-compositing / UNBOX_RML_COMPOSITING (default off; the classic
wlr_scene path stays the default until RML compositing is signed off on hardware).
It composites application toplevels as RCSS surface elements inside ONE ui
surface (the window field) instead of as wlr_scene nodes:
- activate(): fetch ext-xdg-shell Service (typed), create the window-field
UiSurface at SceneLayer::normal sized to the primary output, subscribe to
on_output_added + on_toplevel_mapped/unmapped/focused.
- on map: Toplevel::wl_surface() -> UiSubstrate::create_surface_element(); the
toplevel is Toplevel::hide()'d out of wlr_scene (the surface element is now the
sole compositor of its pixels; the substrate drives its frame callbacks);
appended to bind_list("wins") (live_uri = source_uri(), focused, title, app_id).
- on unmap: drop the SurfaceElement + row. on focus: flip the focused flag.
- Layout/animation are RCSS (assets/ext-window-field/field.{rml,rcss}): a wrapping
flex field where the focused window dominates and the rest tile below, animated
on a transition. No geometry from C++ (the user's contract decision).
- Keyboard focus stays ext-xdg-shell's job; pointer/touch input-back is automatic
in the kernel; this unit wires no seat calls.
Wiring (orchestrator): root meson subdir, host-bin install behind the flag +
ext_window_field_dep. Headless glue test (40 assertions): map tracks the window +
drives hide(), 2nd map, focus flip via the Alt+Tab path, unmap removes it; no-GL
degrade asserted. build + build-asan green; host-bin links; no regressions.
Known gaps (change-requests): click-to-focus a BACKGROUND window needs a kernel
"surface-element pressed -> notify owner" hook (next); wallpaper needs
ext-layer-shell surface exposure (Wave 3b).
| -rw-r--r-- | assets/ext-window-field/field.rcss | 77 | ||||
| -rw-r--r-- | assets/ext-window-field/field.rml | 21 | ||||
| -rw-r--r-- | meson.build | 1 | ||||
| -rw-r--r-- | packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp | 36 | ||||
| -rw-r--r-- | packages/ext-window-field/meson.build | 102 | ||||
| -rw-r--r-- | packages/ext-window-field/src/extension.cpp | 378 | ||||
| -rw-r--r-- | packages/ext-window-field/src/probe.hpp | 64 | ||||
| -rw-r--r-- | packages/ext-window-field/tests/test_glue.cpp | 365 | ||||
| -rw-r--r-- | packages/ext-window-field/tests/test_window_field.cpp | 20 | ||||
| -rw-r--r-- | packages/host-bin/meson.build | 2 | ||||
| -rw-r--r-- | packages/host-bin/src/main.cpp | 21 |
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()); } |
