summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 21:00:21 +0900
committerAdam Malczewski <[email protected]>2026-06-13 21:00:21 +0900
commit494961f24861d2d771ced9927d0e897f0d95d03a (patch)
treee19bc49dc13d6bbbf628ac6ab97eaab18fb2282f
parentbe5f67f7c7cf2710b0e73df5d92be98c758c47a4 (diff)
downloadunbox-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.hpp14
-rw-r--r--packages/ext-stage-dock/src/extension.cpp109
-rw-r--r--packages/ext-stage-dock/src/probe.hpp6
-rw-r--r--packages/ext-stage-dock/tests/test_glue.cpp35
-rw-r--r--packages/ext-stage-dock/tests/test_policy.cpp50
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);