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