diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 21:00:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 21:00:21 +0900 |
| commit | 494961f24861d2d771ced9927d0e897f0d95d03a (patch) | |
| tree | e19bc49dc13d6bbbf628ac6ab97eaab18fb2282f | |
| parent | be5f67f7c7cf2710b0e73df5d92be98c758c47a4 (diff) | |
| download | unbox-494961f24861d2d771ced9927d0e897f0d95d03a.tar.gz unbox-494961f24861d2d771ced9927d0e897f0d95d03a.zip | |
ext-stage-dock: transparent strip, surface hugs cards, fix re-minimize after empty
Builds on the kernel per-pixel-alpha + set_size-resize capabilities.
- Transparent strip: body.dock background #1c1c1ee6 -> transparent, so the
windows beneath show through everywhere the cards don't cover; cards keep their
solid #2e2e32 panel. data-attr-src preview, Noto Sans, and the d1 slot-enter
animation are intact.
- Surface hugs the card stack: height = surface_height(count) =
max(1, 2*pad + count*card + (count-1)*gap) (0->1px hidden, 1->140, 2->272, …),
never the full output height, so the transparent area doesn't needlessly
capture input. The empty dock is a positive 1px hidden placeholder (the substrate
rejects 0 geometry); grows/shrinks via set_size on minimize/restore.
- Fix: re-minimize after the dock empties was a no-op. do_restore relied on
on_toplevel_focused re-firing, but a restored window was never seat-defocused,
so focus() is a seat no-op and the event never fires — leaving focused_ stale,
so the next Super+M's focused_ guard rejected it (a new toplevel mapping
unstuck it). Fix: set focused_ = tl directly in restore. No kernel change.
Tests: new policy cases (surface_height always positive; hug heights 0/140/272/536)
and a glue minimize->restore->minimize 1->0->1 cycle with has_focused() probe.
ext-stage-dock 2/2 green on build + build-asan (no sanitizer reports). Real-seat
verified: transparent strip, dock visible, cards float, re-minimize works.
Edits confined to packages/ext-stage-dock/.
| -rw-r--r-- | packages/ext-stage-dock/src/dock_layout.hpp | 14 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/extension.cpp | 109 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/probe.hpp | 6 | ||||
| -rw-r--r-- | packages/ext-stage-dock/tests/test_glue.cpp | 35 | ||||
| -rw-r--r-- | packages/ext-stage-dock/tests/test_policy.cpp | 50 |
5 files changed, 206 insertions, 8 deletions
diff --git a/packages/ext-stage-dock/src/dock_layout.hpp b/packages/ext-stage-dock/src/dock_layout.hpp index 43ee910..b02d819 100644 --- a/packages/ext-stage-dock/src/dock_layout.hpp +++ b/packages/ext-stage-dock/src/dock_layout.hpp @@ -86,6 +86,20 @@ struct DockMetrics { return 2 * m.pad + count * m.slot_height + (count - 1) * m.gap; } +// The POSITIVE surface height that hugs `count` cards: content_height(count) +// clamped to a strictly-positive minimum. The ui substrate REJECTS a surface +// with non-positive geometry (create_surface/set_size return nullptr + log an +// error), so the empty dock (count 0 -> content_height 0) must still be created +// / resized at a positive size and merely hidden (set_visible(false)), never at +// height 0. Returns max(content_height(count), 1): >= 1 for every count >= 0, +// equal to content_height once there is at least one card. (Width never hits 0 +// in practice — dock_width is a fixed positive constant — but callers should +// likewise guard it; this helper covers the height, which is the count-driven +// dimension.) +[[nodiscard]] inline auto surface_height(const DockMetrics& m, int count) -> int { + return std::max(1, content_height(m, count)); +} + // The on-screen rect of slot `i` (0-based) within the dock content, given the // current vertical `scroll` offset (px scrolled DOWN; 0 = top). The slot's // content-space top is pad + i*(slot_height+gap); subtracting `scroll` yields diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp index 82814e2..e5261e4 100644 --- a/packages/ext-stage-dock/src/extension.cpp +++ b/packages/ext-stage-dock/src/extension.cpp @@ -56,6 +56,21 @@ constexpr std::uint32_t kMinimizeMods = WLR_MODIFIER_LOGO; // Super/LOGO // dock sits fully revealed (no reveal animation); d1 animates dock_box(f). constexpr int kDockWidth = 240; +// Card-stack metrics, in px, that MIRROR the kDockRml RCSS so the surface rect +// can be sized (via dock_layout::content_height) to hug the rendered card stack +// rather than the full output height. dp == px (substrate dp-ratio is 1.0), so +// these are the RCSS dp values: +// kCardHeight — one div.slot's OUTER (border-box) height: padding 6dp*2 (=12) +// + img.preview 84dp + span.title (margin-top 4dp + ~one 13dp line). Rounded +// UP to 124 so RmlUi's exact line-box height never clips a card (over-sizing +// the transparent surface by a few px is harmless; clipping a card is not). +// kCardGap — the inter-card vertical space (div.slot margin-bottom: 8dp). +// kStripPad — the strip's inner top/bottom margin (body.dock padding: 8dp). +// content_height(count) = 2*kStripPad + count*kCardHeight + (count-1)*kCardGap. +constexpr int kCardHeight = 124; +constexpr int kCardGap = 8; +constexpr int kStripPad = 8; + // 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 @@ -73,7 +88,21 @@ struct Slot { // rounded like the Stage-Manager reference. d1 ADDS the RCSS animation on top of // c2's static document — without touching the data model (same "slots" list, // same per-row preview/title/restore bindings). data-model "ui" (the substrate -// default). The img src is the Preview source_uri(), bound via +// default). +// +// TRANSPARENT STRIP (per-pixel alpha). The substrate composites this surface +// with per-pixel alpha (ui.hpp UiSurface §PER-PIXEL ALPHA): any pixel body.dock +// does not paint is fully transparent and the windows BELOW show through. So +// `body.dock` paints NO background (`background-color: transparent`) — only the +// `div.slot` CARDS paint (their `#2e2e32ff` panel), reading as cards floating +// over the window with the empty strip see-through. NOTE the substrate still +// consumes pointer/touch over the whole surface RECT regardless of visual +// transparency (slice-5 consumption model), so the surface is sized to HUG the +// card stack (see content_height in create/refresh) — the rest of the screen +// stays interactive. A real input-transparent strip needs a deferred +// UiSurfaceSpec flag (report change-req). +// +// The img src is the Preview source_uri(), bound via // `data-attr-src="row.preview"` — RmlUi interpolates {{ }} only in TEXT, so an // element attribute must use the data-attr-<attr> attribute-binding form // (verified against vendored RmlUi 6.2: data_binding/options samples). The @@ -110,7 +139,7 @@ constexpr const char* kDockRml = R"RML(<rml> <head> <style> body.dock { - background-color: #1c1c1ee6; + background-color: transparent; padding: 8dp; font-family: Noto Sans; transform: translateX(-100%); @@ -171,6 +200,7 @@ public: 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(); } + [[nodiscard]] auto has_focused() const -> bool override { return focused_ != nullptr; } void activate(Host& host) override { host_ = &host; @@ -278,7 +308,20 @@ private: slots_.erase(slots_.begin() + static_cast<std::ptrdiff_t>(i)); if (tl != nullptr) { tl->show(); - tl->focus(); // produces on_toplevel_focused -> updates focused_ + tl->focus(); + // Re-establish focused_ DIRECTLY rather than relying on focus() + // re-emitting on_toplevel_focused. KEY: hide() never moved keyboard + // focus at the seat, so when we minimize the only window the seat + // STILL holds focus on the (now hidden) window; our local focused_ + // went nullptr only because first_non_minimized_other() found no + // other window. On restore, tl->focus() is then focusing the + // already-seat-focused window, which the kernel treats as a no-op and + // does NOT re-emit on_toplevel_focused — leaving focused_ stale at + // nullptr, so the next Super+M (guarded on focused_ != nullptr) was a + // no-op until a new map set focused_. We KNOW tl is the focused window + // now (we just restored + focused it), so set it here. If the event + // does also fire it just re-sets the same value (idempotent). + focused_ = tl; } refresh_slots(); } @@ -329,6 +372,14 @@ private: // -> the body slides back out; we DEFER set_visible(false) until the // slide-out finishes (on_dock_settled, fired by RmlUi's transitionend // through the existing event binding) so the close animation is seen. + // The surface rect tracks the slot count: whenever there is at least one + // slot we set_size the height to content_height(count) so the rect hugs the + // card stack (brief §3 — minimize the input-capturing area). We do NOT + // shrink to 0 the instant the dock empties: that would collapse the surface + // mid slide-OUT and the close animation would not be seen. Instead we keep + // the last non-empty height through the slide and shrink to 0 in + // on_dock_settled (with set_visible(false)). The width is fixed (kDockWidth). + // // No-op on the visual when the surface is null (no-GL backend); the model is // still tracked, and slot_count()/the c2 invariants are unchanged. void refresh_slots() { @@ -337,6 +388,13 @@ private: } dock_surface_->dirty("slots"); + // Resize the rect to hug the cards while non-empty (growing OR shrinking + // by one of several). When empty, defer the collapse to on_dock_settled. + if (!slots_.empty()) { + const int w = layout::dock_box(dock_metrics(), 1.0).w; + dock_surface_->set_size(w, surface_height_for(slots_.size())); + } + const bool want_open = !slots_.empty(); if (want_open == open_) { return; // reveal state unchanged (e.g. minimize a 2nd window) @@ -366,13 +424,27 @@ private: void on_dock_settled() { if (dock_surface_ != nullptr && closing_ && !open_) { dock_surface_->set_visible(false); + // Collapse the rect now that the slide-out is done and the surface is + // hidden: a hidden empty dock captures NO input. Deferred to here (not + // refresh_slots) so the card stack stays sized through the slide. The + // height is the positive 1px placeholder (count==0), never 0. + const int w = layout::dock_box(dock_metrics(), 1.0).w; + dock_surface_->set_size(w, surface_height_for(slots_.size())); // count==0 -> 1px } closing_ = false; } - // 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. + // Create the dock UiSurface (overlay, left edge) and register all data + // bindings BEFORE the first frame. The surface rect HUGS the card stack: its + // width is the dock_box width, its height is content_height(slot count) — + // NOT the full output height — because the substrate consumes pointer/touch + // over the whole rect regardless of visual transparency, so a transparent + // full-height strip would still eat clicks over the empty area (brief §3). + // Height tracks the slot count via set_size in refresh_slots(); at create + // the dock is empty so the height is a POSITIVE 1px placeholder (the + // substrate rejects non-positive geometry — see surface_height_for) and the + // surface is hidden (spec.visible=false) until the first slot. 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) @@ -383,7 +455,7 @@ private: spec.x = frame.x; spec.y = frame.y; spec.width = frame.w; - spec.height = frame.h; + spec.height = surface_height_for(slots_.size()); // hug the card stack spec.layer = kernel::SceneLayer::overlay; spec.visible = false; // shown when slot count > 0 @@ -424,7 +496,9 @@ private: // 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. + // a slot exists anyway). The dock keeps its fixed width; its HEIGHT now hugs + // the card stack (content_height), not the full output (input caveat, §3). + // The card-stack dims (slot_height/gap/pad) mirror the kDockRml RCSS. [[nodiscard]] auto dock_metrics() const -> layout::DockMetrics { int ow = 0; int oh = 0; @@ -445,9 +519,28 @@ private: m.output_w = ow; m.output_h = oh; m.dock_width = kDockWidth; + // Card-stack dims mirror the kDockRml RCSS so content_height() yields the + // surface height that hugs the rendered cards (see kCardHeight et al.). + m.slot_height = kCardHeight; + m.gap = kCardGap; + m.pad = kStripPad; return m; } + // The POSITIVE surface HEIGHT that hugs `count` cards: the strip's content + // height (2*pad + count*card + (count-1)*gap) per dock_layout, CLAMPED to a + // strictly-positive minimum (>= 1). The substrate REJECTS non-positive + // geometry (create_surface/set_size return nullptr + log + // "surface needs positive geometry"), so the EMPTY dock (count 0 -> + // content_height 0) must be created/resized at a positive size and merely + // hidden (set_visible(false)), never at height 0. The substrate consumes + // input over the whole rect, so sizing height to the card stack (not the + // full output) leaves the rest of the screen interactive (brief §3 caveat); + // the empty-dock 1px placeholder is hidden, so it captures nothing. + [[nodiscard]] auto surface_height_for(std::size_t count) const -> int { + return layout::surface_height(dock_metrics(), static_cast<int>(count)); + } + const kernel::Manifest manifest_{ .id = "stage-dock", .tier = kernel::Tier::standard, diff --git a/packages/ext-stage-dock/src/probe.hpp b/packages/ext-stage-dock/src/probe.hpp index 9ea49db..77d9a3b 100644 --- a/packages/ext-stage-dock/src/probe.hpp +++ b/packages/ext-stage-dock/src/probe.hpp @@ -41,6 +41,12 @@ public: // The current number of dock slots (minimized windows). [[nodiscard]] virtual auto slot_count() const -> std::size_t = 0; + + // Whether the dock currently has a focused window (focused_ != nullptr) — the + // exact guard do_minimize_focused()/the Super+M filter check before acting. + // After a restore the dock MUST report a focused window (restore re-focuses + // the shown window), or the next minimize would be a no-op. Glue-test only. + [[nodiscard]] virtual auto has_focused() const -> bool = 0; }; struct ExtensionWithProbe { diff --git a/packages/ext-stage-dock/tests/test_glue.cpp b/packages/ext-stage-dock/tests/test_glue.cpp index 69985ff..c72cd9d 100644 --- a/packages/ext-stage-dock/tests/test_glue.cpp +++ b/packages/ext-stage-dock/tests/test_glue.cpp @@ -275,6 +275,11 @@ TEST_CASE("ext-stage-dock c2: minimize hides the focused window + adds a slot; r 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 + // INVARIANT: restore must re-establish focused_ on the shown window, else the + // next minimize (guarded on focused_ != nullptr) is a no-op (the real-seat + // bug). With ONE window, minimize had set focused_=nullptr, so this is the + // re-establishment the restore path is responsible for. + CHECK(probe->has_focused() == true); // A guarded out-of-range restore is a no-op. probe->restore(5); @@ -284,6 +289,36 @@ TEST_CASE("ext-stage-dock c2: minimize hides the focused window + adds a slot; r pump(*server, c.display); } + // --- RE-MINIMIZE the same window after the dock fully emptied (1->0->1) ---- + // Real-seat regression: with ONE window, minimize sets focused_=nullptr (no + // OTHER window to focus), and restore must RE-ESTABLISH focused_ to the shown + // window. hide() never moves seat keyboard focus, so the restoring focus() is + // a no-op at the seat and on_toplevel_focused does NOT re-fire — so the glue + // must set focused_ itself on restore. If it does not, this second + // minimize_focused() (guarded on focused_ != nullptr) is a no-op and the slot + // count stays 0. The fix makes the SAME window minimizable again with NO new + // window mapped. This is the SAME tl (never unmapped); its borrow is still + // live (observer still holds it). + CHECK(observer->mapped() == tl); // still the only mapped window + CHECK(tree->node.enabled == true); // restored + visible before re-minimize + CHECK(probe->has_focused() == true); // dock still tracks a focused window + probe->minimize_focused(); + CHECK(probe->slot_count() == 1); // re-minimized into the dock (the bug fix) + CHECK(tree->node.enabled == false); // hide() disabled it again + CHECK(observer->mapped() == tl); // still NOT unmapped + + for (int i = 0; i < 10; ++i) { + pump(*server, c.display); + } + // Restore once more so we tear down from a clean (empty, shown) state. + probe->restore(0); + CHECK(probe->slot_count() == 0); + CHECK(tree->node.enabled == true); + + 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 diff --git a/packages/ext-stage-dock/tests/test_policy.cpp b/packages/ext-stage-dock/tests/test_policy.cpp index c2b057f..a62a5fa 100644 --- a/packages/ext-stage-dock/tests/test_policy.cpp +++ b/packages/ext-stage-dock/tests/test_policy.cpp @@ -187,6 +187,56 @@ TEST_CASE("content_height: 0/1/many slots") { CHECK(lay::content_height(m, 3) == 2 * 20 + 3 * 100 + 2 * 10); // 360 } +// The glue (src/extension.cpp) sizes the dock SURFACE rect to HUG the card stack +// via content_height(card-stack metrics, slot count) instead of the full output +// height — so the transparent strip captures input only over the cards (brief +// §3: the substrate consumes input over the whole rect regardless of visual +// transparency). These are the exact px values the surface height takes, with +// the card-stack metrics that mirror the kDockRml RCSS (kCardHeight=124 outer +// card height, kCardGap=8 inter-card margin, kStripPad=8 body padding). Keep in +// lockstep with src/extension.cpp's kCard*/kStripPad constants + dock_metrics(). +TEST_CASE("dock surface height hugs the card stack (content_height with RCSS card metrics)") { + // Mirror src/extension.cpp: kCardHeight=124, kCardGap=8, kStripPad=8. + lay::DockMetrics card{.output_w = 1920, .output_h = 1080, .dock_width = 240, + .slot_height = 124, .gap = 8, .pad = 8}; + // Empty dock -> 0 content (but the SURFACE is clamped positive, below). + CHECK(lay::content_height(card, 0) == 0); + // One card -> 2*pad + card (no trailing gap). + CHECK(lay::content_height(card, 1) == 2 * 8 + 124); // 140 + // Two cards -> +gap between them. + CHECK(lay::content_height(card, 2) == 2 * 8 + 2 * 124 + 1 * 8); // 272 + // Many cards grow linearly and stay FAR under the full output height, so the + // surface never spans the whole left edge (the hug-the-cards property). + CHECK(lay::content_height(card, 4) == 2 * 8 + 4 * 124 + 3 * 8); // 536 + CHECK(lay::content_height(card, 4) < card.output_h); // < 1080 +} + +// REGRESSION GUARD (0-geometry boot bug): the ui substrate REJECTS a surface +// with non-positive geometry ("surface needs positive geometry") and returns +// nullptr, so the EMPTY dock must be created/resized at a POSITIVE height, not 0. +// surface_height() — the helper the glue's surface_height_for() delegates to — +// clamps content_height to >= 1, so create_surface/set_size are never called +// with height 0. Cover EVERY count the glue can produce, especially the empty +// case the headless test cannot distinguish from the substrate-null path. +TEST_CASE("surface_height is ALWAYS positive (empty-dock 0-geometry guard)") { + lay::DockMetrics card{.output_w = 1920, .output_h = 1080, .dock_width = 240, + .slot_height = 124, .gap = 8, .pad = 8}; + // The empty dock: content_height is 0, but the surface height is clamped to 1. + CHECK(lay::content_height(card, 0) == 0); + CHECK(lay::surface_height(card, 0) == 1); // positive placeholder (hidden) + // Once there is at least one card, surface_height == content_height (>0). + CHECK(lay::surface_height(card, 1) == lay::content_height(card, 1)); + CHECK(lay::surface_height(card, 4) == lay::content_height(card, 4)); + // Never non-positive for any plausible count (incl. a negative/degenerate). + for (int n = -2; n <= 20; ++n) { + CHECK(lay::surface_height(card, n) >= 1); + } + // Even with degenerate (zeroed) metrics — defensive: still >= 1, never 0. + lay::DockMetrics zero{}; + CHECK(lay::surface_height(zero, 0) >= 1); + CHECK(lay::surface_height(zero, 3) >= 1); +} + TEST_CASE("slot_box: vertical stacking by stride, inset width") { auto m = metrics(); // pad 20, slot 100, gap 10, dock_width 300 auto s0 = lay::slot_box(m, 0, 0); |
