summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 21:00:07 +0900
committerAdam Malczewski <[email protected]>2026-06-13 21:00:07 +0900
commitbe5f67f7c7cf2710b0e73df5d92be98c758c47a4 (patch)
tree857e0e9df72675d223b2a2f643c4d49ff01a5b50
parent4f5779cec17b0e9173d2b1de634c31c516069670 (diff)
downloadunbox-be5f67f7c7cf2710b0e73df5d92be98c758c47a4.tar.gz
unbox-be5f67f7c7cf2710b0e73df5d92be98c758c47a4.zip
kernel: ui surfaces composite with per-pixel alpha + set_size resizes the target
Two substrate capabilities the stage dock forced (both verified real-seat nested and on the gles2 headless path): 1. Per-pixel alpha. A ui surface composited opaque, so any overlay (the dock) occluded the toplevels beneath it. Root cause: a stray opaque render_iface->Clear() (glClearColor 0,0,0,1) in render_surface overrode the transparent BeginFrame clear, and EndFrame's premultiplied composite carried the opaque base to the buffer. Fix: drop the stray Clear(); clear the OUTPUT FBO to (0,0,0,0) once before BeginFrame. Blend was already correct premultiplied; the substrate never sets an opaque region (now guarded by a probe); ARGB8888 alpha survives end to end. A document whose <body> is transparent now shows the scene through its un-painted pixels. 2. set_size resizes the render target. Previously logical-only (the slice-5 documented change-request): set_size re-laid-out the RmlUi document but did NOT realloc the GL target, so a surface created small and grown rendered into its original buffer (the dock, created as a 1px placeholder and grown on minimize, was invisible). Fix: set_size now reallocs the FBO + dmabuf swapchain/shm + EGLImage + texture + scene buffer on an ACTUAL size change (no-op same-size, cheap; set_position still cheap). Grow and shrink both render fully; alpha/upright-flip/blend/fence-sync preserved. ui.hpp documents both. kernel 45 cases/182 assertions green on build + build-asan (no new suppressions). Edits confined to packages/kernel/.
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp13
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp23
-rw-r--r--packages/kernel/src/server.cpp8
-rw-r--r--packages/kernel/src/ui_substrate.cpp175
-rw-r--r--packages/kernel/src/ui_substrate.hpp10
-rw-r--r--packages/kernel/tests/test_kernel.cpp228
6 files changed, 417 insertions, 40 deletions
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 4cb5230..4af327a 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -103,6 +103,19 @@ public:
// known source color reached the expected spot inside an <img>.
[[nodiscard]] auto ui_pixel(int x, int y) const -> unsigned int;
+ // Whether the first ui surface's scene_buffer node carries a non-empty
+ // opaque region. The per-pixel-alpha contract requires FALSE: a forced
+ // opaque region would make wlr_scene skip alpha-blending the buffer, so the
+ // scene below would be occluded by un-painted pixels. Test instrumentation;
+ // single-thread only.
+ [[nodiscard]] auto ui_surface_has_opaque_region() const -> bool;
+
+ // Number of UiSurface::set_size GL-target reallocations performed so far.
+ // A same-size set_size is a no-op (does not bump this); a grow/shrink
+ // reallocates the FBO/swapchain/EGLImage/texture and bumps it. Lets the
+ // suite prove the only-on-change guard. Test instrumentation; single-thread.
+ [[nodiscard]] auto ui_resize_realloc_count() const -> int;
+
// Count elements with the given tag name in the first ui surface's loaded
// document. 0 if no surface / no document yet. Lets the suite assert that a
// data-for list rendered the expected number of rows (slice 10 / b2 list
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index 7f7609a..da5986c 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -47,6 +47,15 @@ namespace unbox::kernel {
// the document AND its scene node (so hold it as a member; it dies with you,
// in reverse declaration order, while the kernel's scene is still alive).
// All methods are event-loop-thread only.
+//
+// PER-PIXEL ALPHA (transparency). The surface composites with per-pixel alpha:
+// any pixel your document does NOT paint is fully transparent, and the scene
+// BELOW the surface composites through it (the substrate never marks the buffer
+// opaque). So a document with a transparent <body> that paints, say, only a
+// card in one corner shows the windows beneath it everywhere else. If you want
+// a solid panel, paint an opaque background in your RCSS (e.g. body {
+// background-color: #rrggbb; }) — a fully-opaque document is pixel-identical to
+// a fully-opaque surface and occludes whatever it covers, as before.
class UiSurface {
public:
virtual ~UiSurface() = default;
@@ -54,9 +63,19 @@ public:
auto operator=(const UiSurface&) -> UiSurface& = delete;
// ---- Geometry & visibility (layout coordinates) ----
- // Move/resize the surface. The document is laid out to w×h; the node sits
- // at (x,y) in layout space. Cheap; takes effect on the next frame.
+ // Move the surface: the node sits at (x,y) in layout space. Cheap (no
+ // realloc); takes effect on the next frame.
virtual void set_position(int x, int y) = 0;
+ // Resize the surface to w×h. This RESIZES THE RENDER TARGET — the document
+ // is laid out to w×h AND draws into a buffer of matching size, so the
+ // surface renders fully at the new size (grow AND shrink both work; the
+ // composited node and the input hit-test rect track the new size). It is
+ // HEAVIER than set_position: on an actual size change it reallocates GL
+ // resources (the offscreen FBO + dmabuf swapchain / shm buffer), so call it
+ // on size changes, not every frame. A no-op same-size call is cheap (no
+ // realloc). Non-positive w/h is rejected (the surface keeps its size). Takes
+ // effect on the next frame; resizing a hidden surface is fine (it still is
+ // not composited until shown).
virtual void set_size(int width, int height) = 0;
// Show/hide without destroying. Hidden surfaces are not composited and do
// not receive input. Default after create is the spec's `visible`.
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index 95631df..d97104e 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -89,6 +89,14 @@ auto Server::ui_pixel(int x, int y) const -> unsigned int {
return impl_->substrate != nullptr ? impl_->substrate->surface_pixel(x, y) : 0U;
}
+auto Server::ui_surface_has_opaque_region() const -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->surface_has_opaque_region();
+}
+
+auto Server::ui_resize_realloc_count() const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->resize_realloc_count() : 0;
+}
+
auto Server::ui_element_count(const char* tag) const -> int {
return impl_->substrate != nullptr ? impl_->substrate->element_count(tag) : 0;
}
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index bbe0600..d1fcd7a 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -549,6 +549,7 @@ struct Substrate::Impl {
std::list<PreviewState> previews;
int next_preview_id = 0;
bool last_preview_dmabuf = false; // test probe: last import took the dmabuf path
+ int resize_realloc_count = 0; // test probe: # of set_size GL-target reallocs
// Pointer implicit grab: the consumer of the first button press owns the
// whole press..release stream (standard seat behavior). `pointer_grab`
@@ -605,6 +606,17 @@ struct Substrate::Impl {
void refresh_bindings(Surface& s);
bool init_surface_gl(Surface& s);
+ // Free ONLY the GL render-target resources of `s` (FBO, swapchain + its
+ // cached EGLImages/textures, Plan-B texture/shm buffer, readback scratch) —
+ // leaves the context/document/scene_buffer/bindings intact. Caller holds the
+ // sibling context current. Shared by destroy_surface and the resize path.
+ void free_surface_gl(Surface& s);
+ // Reallocate `s`'s render target to w×h (FBO + swapchain/shm + EGLImage +
+ // texture). Updates s.width/s.height and the RmlUi context dimensions. Caller
+ // must guarantee w>0 && h>0. Returns false if the rebuild failed (the surface
+ // is then left with no GL target and will not render until a later resize
+ // succeeds). Caller holds the sibling context current.
+ bool resize_surface_gl(Surface& s, int w, int h);
void render_surface(Surface& s); // caller holds context current
void destroy_surface(Surface* s);
@@ -780,9 +792,24 @@ void Substrate::Impl::render_surface(Surface& s) {
// flip_y: GL renders bottom-left origin; the FBO is sampled/scanned-out
// top-first, so flip the final composite for an upright submitted buffer.
gl.render_iface->SetOutputFramebuffer(target_fbo, /*flip_y=*/true);
+ // PER-PIXEL ALPHA: the output FBO is the surface's ARGB8888 wlr_buffer. It
+ // must start FULLY TRANSPARENT (0,0,0,0) so a pixel the RML document never
+ // paints stays alpha=0 and wlr_scene composites the scene below through it
+ // (no opaque region is ever set on the scene_buffer node). EndFrame()
+ // composites the document over this with the premultiplied-alpha blend
+ // (GL_ONE, GL_ONE_MINUS_SRC_ALPHA), so an opaque-bodied document still
+ // fully overwrites to opaque — identical to before. Clearing here (output
+ // FBO bound) also wipes stale swapchain content each frame. NOTE: we do NOT
+ // call render_iface->Clear() (the vendored helper clears whatever FBO is
+ // currently bound — after BeginFrame that is the internal render layer, and
+ // it clears to OPAQUE black (0,0,0,1), which is exactly what made every
+ // un-painted pixel reach the screen opaque).
+ glBindFramebuffer(GL_FRAMEBUFFER, target_fbo);
+ glClearColor(0.f, 0.f, 0.f, 0.f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
s.context->Update();
gl.render_iface->BeginFrame();
- gl.render_iface->Clear();
s.context->Render();
gl.render_iface->EndFrame();
@@ -809,18 +836,13 @@ void Substrate::Impl::render_surface(Surface& s) {
s.frame_count += 1;
}
-void Substrate::Impl::destroy_surface(Surface* s) {
- const bool cur = gl.make_current();
- if (s->scene_buffer != nullptr) {
- wlr_scene_node_destroy(&s->scene_buffer->node);
- s->scene_buffer = nullptr;
- }
- if (s->context != nullptr) {
- Rml::RemoveContext(s->context->GetName());
- s->context = nullptr;
- s->document = nullptr;
- }
- for (auto& [buf, slot] : s->slot_gl) {
+void Substrate::Impl::free_surface_gl(Surface& s) {
+ // Caller holds the sibling context current. Free every GL render-target
+ // resource; the scene_buffer keeps its OWN lock on whatever buffer it was
+ // last given (wlr_scene_buffer_set_buffer locked it), so dropping our locks
+ // here does not pull the buffer out from under the scene before the next
+ // wlr_scene_buffer_set_buffer replaces it.
+ for (auto& [buf, slot] : s.slot_gl) {
if (slot.tex != 0) {
glDeleteTextures(1, &slot.tex);
}
@@ -828,23 +850,61 @@ void Substrate::Impl::destroy_surface(Surface* s) {
gl.egl_destroy_image(gl.egl_display, slot.image);
}
}
- s->slot_gl.clear();
- if (s->shm_tex != 0) {
- glDeleteTextures(1, &s->shm_tex);
- s->shm_tex = 0;
+ s.slot_gl.clear();
+ if (s.shm_tex != 0) {
+ glDeleteTextures(1, &s.shm_tex);
+ s.shm_tex = 0;
+ }
+ if (s.fbo != 0) {
+ glDeleteFramebuffers(1, &s.fbo);
+ s.fbo = 0;
}
- if (s->fbo != 0) {
- glDeleteFramebuffers(1, &s->fbo);
- s->fbo = 0;
+ if (s.swapchain != nullptr) {
+ wlr_swapchain_destroy(s.swapchain);
+ s.swapchain = nullptr;
}
- if (s->swapchain != nullptr) {
- wlr_swapchain_destroy(s->swapchain);
- s->swapchain = nullptr;
+ if (s.shm != nullptr) {
+ wlr_buffer_drop(&s.shm->base);
+ s.shm = nullptr;
+ }
+ s.readback.clear();
+ s.readback.shrink_to_fit();
+ s.dmabuf = false;
+}
+
+bool Substrate::Impl::resize_surface_gl(Surface& s, int w, int h) {
+ // Caller guarantees positive geometry + sibling context current. Tear the
+ // old GL target down and rebuild at the new size; init_surface_gl re-decides
+ // Plan A vs B (it sets s.dmabuf). The RmlUi context is laid out to w×h so the
+ // document draws into a matching-size target. Per-pixel-alpha transparent
+ // clear, upright (V-flip) composite, premultiplied blend, and the fence-sync
+ // submit all live in render_surface/EndFrame and are unaffected — the new
+ // target goes through the exact same render path.
+ free_surface_gl(s);
+ s.width = w;
+ s.height = h;
+ if (s.context != nullptr) {
+ s.context->SetDimensions(Rml::Vector2i(w, h));
+ }
+ if (!init_surface_gl(s)) {
+ wlr_log(WLR_ERROR, "ui-substrate: resize realloc failed (%dx%d)", w, h);
+ return false;
+ }
+ return true;
+}
+
+void Substrate::Impl::destroy_surface(Surface* s) {
+ const bool cur = gl.make_current();
+ if (s->scene_buffer != nullptr) {
+ wlr_scene_node_destroy(&s->scene_buffer->node);
+ s->scene_buffer = nullptr;
}
- if (s->shm != nullptr) {
- wlr_buffer_drop(&s->shm->base);
- s->shm = nullptr;
+ if (s->context != nullptr) {
+ Rml::RemoveContext(s->context->GetName());
+ s->context = nullptr;
+ s->document = nullptr;
}
+ free_surface_gl(*s);
if (cur) {
gl.restore_current();
}
@@ -1395,9 +1455,10 @@ auto Substrate::surface_pixel(int x, int y) const -> std::uint32_t {
// Read the first rendered surface's current FBO at (x,y) via glReadPixels on
// the sibling context — works for BOTH the shm and dmabuf paths (the FBO's
// color attachment is the surface's submitted texture in either case), so
- // this probe is independent of the per-surface path choice. row0 = GL
- // bottom; the buffer is top-first (the renderer V-flips on composite), so
- // map document-y -> GL-y = (h-1-y). R,G,B,A.
+ // this probe is independent of the per-surface path choice. The renderer
+ // V-flips on composite so the FBO is top-first (GL row 0 == document top,
+ // consistent with orientation()'s readback row0==top), hence document-y
+ // maps to GL row y DIRECTLY (a corner box at top:0 reads back at y≈0). R,G,B,A.
for (const Surface& s : impl_->surfaces) {
if (s.fbo == 0 || s.frame_count == 0) {
continue;
@@ -1408,7 +1469,7 @@ auto Substrate::surface_pixel(int x, int y) const -> std::uint32_t {
const bool cur = impl_->gl.make_current();
std::uint8_t rgba[4] = {0, 0, 0, 0};
glBindFramebuffer(GL_FRAMEBUFFER, s.fbo);
- glReadPixels(x, s.height - 1 - y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, rgba);
+ glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, rgba);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (cur) {
impl_->gl.restore_current();
@@ -1420,6 +1481,27 @@ auto Substrate::surface_pixel(int x, int y) const -> std::uint32_t {
return 0;
}
+auto Substrate::surface_has_opaque_region() const -> bool {
+ // A per-pixel-alpha surface must NOT carry a forced opaque region: if it
+ // did, wlr_scene would treat the buffer as opaque and skip blending the
+ // scene below through the transparent (un-painted) pixels. We never call
+ // wlr_scene_buffer_set_opaque_region, so this reads empty.
+ for (const Surface& s : impl_->surfaces) {
+ if (s.scene_buffer == nullptr) {
+ continue;
+ }
+ // Read the region's extents directly (header-only; avoids linking the
+ // pixman lib): an empty region has a degenerate extents box.
+ const pixman_box32_t& e = s.scene_buffer->opaque_region.extents;
+ return e.x2 > e.x1 && e.y2 > e.y1;
+ }
+ return false;
+}
+
+auto Substrate::resize_realloc_count() const -> int {
+ return impl_->resize_realloc_count;
+}
+
auto Substrate::element_count(const char* tag) const -> int {
for (const Surface& s : impl_->surfaces) {
if (s.document == nullptr) {
@@ -1512,15 +1594,32 @@ void SurfaceHandle::set_position(int x, int y) {
}
void SurfaceHandle::set_size(int width, int height) {
- // Geometry-only resize of an existing GL target is out of slice 5 (would
- // require re-allocating FBO/swapchain). Record logical size + resize the
- // Rml context; the rendered buffer keeps its allocated size. Documented in
- // ui.hpp as "takes effect on next frame"; full realloc is a slice-6 ask.
- surface_->width = width;
- surface_->height = height;
- if (surface_->context != nullptr) {
- surface_->context->SetDimensions(Rml::Vector2i(width, height));
+ Surface& s = *surface_;
+ // Reject non-positive geometry, same as create_surface — keep the old size.
+ if (width <= 0 || height <= 0) {
+ wlr_log(WLR_ERROR, "ui-substrate: surface needs positive geometry");
+ return;
+ }
+ // Only-on-change: a same-size set_size is a no-op (the dock calls set_size on
+ // every minimize/restore, often with the same height — never thrash the
+ // swapchain). A move (set_position) never reaches here, so it stays cheap.
+ if (width == s.width && height == s.height) {
+ return;
+ }
+ // Resize the actual render target so the surface renders at w×h (grow AND
+ // shrink): rebuild FBO + dmabuf swapchain (or shm buffer) + cached
+ // EGLImage/texture, lay the RmlUi context out to w×h, and re-set the scene
+ // node's buffer on the next render_surface tick. Heavier than set_position
+ // (reallocs GL resources); call on size changes, not every frame.
+ Substrate::Impl& impl = *substrate_->impl_;
+ const bool cur = impl.gl.make_current();
+ impl.resize_surface_gl(s, width, height); // updates s.width/s.height + ctx dims
+ ++impl.resize_realloc_count;
+ if (cur) {
+ impl.gl.restore_current();
}
+ // The scene-node composite size follows the new buffer (set on next render);
+ // the input hit-test rect uses s.width/s.height live, so it tracks too.
}
void SurfaceHandle::set_visible(bool visible) {
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index 33dfd4e..b82db21 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -189,6 +189,16 @@ public:
// / out of bounds. A position-aware probe (like orientation()) so the suite
// can assert a preview's known source color landed at the expected spot.
[[nodiscard]] auto surface_pixel(int x, int y) const -> std::uint32_t;
+ // Whether the first surface's scene_buffer node carries a non-empty opaque
+ // region. The per-pixel-alpha contract requires this to be FALSE: a forced
+ // opaque region would tell wlr_scene to skip alpha-blending the ARGB8888
+ // buffer (occluding the scene below). The substrate never sets one — this
+ // probe lets the suite assert that invariant. False if no surface exists.
+ [[nodiscard]] auto surface_has_opaque_region() const -> bool;
+ // Number of set_size GL-target reallocations performed so far (across all
+ // surfaces). A same-size set_size must NOT bump it (only-on-change guard);
+ // a grow/shrink does. Lets the suite prove the no-op-same-size guard.
+ [[nodiscard]] auto resize_realloc_count() const -> int;
// Count elements with `tag` in the first surface's loaded document (0 if no
// surface / not loaded yet). Proves a data-for list rendered N rows.
[[nodiscard]] auto element_count(const char* tag) const -> int;
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 2c9b2fd..89f65f3 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -705,6 +705,234 @@ TEST_CASE("substrate: data-for list renders N rows, re-renders on dirty, routes
}
// ============================================================================
+// slice-10 / ui-surface ALPHA (transparency). A ui surface composites with
+// per-pixel alpha: a pixel the document does NOT paint is transparent (the
+// scene below shows through), while a painted opaque box stays solid. The
+// substrate must (a) clear the surface's output buffer to transparent (0,0,0,0)
+// — NOT opaque black — and (b) never mark the scene_buffer opaque. This is the
+// substrate capability the stage dock needs (its un-painted strip becomes
+// see-through). Proven via the public Host::ui() path + the ui_pixel /
+// ui_surface_has_opaque_region probes.
+// ============================================================================
+
+namespace {
+
+// Transparent <body> with one small OPAQUE box in the top-left corner. The box
+// is #20c040 (an obvious, non-black color). Everything else is unpainted ⇒
+// must read back fully transparent. The box uses position:absolute so its
+// geometry is exact (a 40x40 square at 0,0).
+const char* kAlphaRml = R"RML(<rml>
+<head>
+<style>
+body { background-color: transparent; width: 200px; height: 200px; margin: 0px; }
+#box { display: block; width: 40px; height: 40px; background-color: #20c040; }
+</style>
+</head>
+<body data-model="ui">
+<div id="box"></div>
+</body>
+</rml>)RML";
+
+class AlphaTestExtension : public unbox::kernel::Extension {
+public:
+ auto manifest() const -> const Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ UiSurfaceSpec spec;
+ spec.rml_inline = kAlphaRml;
+ spec.x = 0;
+ spec.y = 0;
+ spec.width = 200;
+ spec.height = 200;
+ spec.layer = unbox::kernel::SceneLayer::overlay;
+ spec.visible = true;
+ surface_ = host.ui().create_surface(spec);
+ }
+
+ [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
+
+private:
+ Manifest manifest_{"alpha-test", Tier::standard, {}};
+ std::unique_ptr<UiSurface> surface_;
+};
+
+} // namespace
+
+TEST_CASE("substrate: un-painted pixels are transparent; painted box stays opaque") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1);
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new AlphaTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ pump(*server, 60); // load the document + render the surface
+
+ if (!ext->has_surface() || server->ui_frame_count() == 0) {
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path on this box: skip
+ return;
+ }
+
+ // (1) The scene buffer must NOT carry a forced opaque region — otherwise
+ // wlr_scene would skip alpha-blending and occlude the scene below.
+ CHECK(server->ui_surface_has_opaque_region() == false);
+
+ // (2) A pixel in the UN-painted area (center, far from the corner box) is
+ // FULLY TRANSPARENT: premultiplied (0,0,0,0) ⇒ packed 0xRRGGBBAA == 0. This
+ // is the failing-then-passing assertion: before the fix the output FBO was
+ // cleared to opaque black (0,0,0,1) so this read back 0x000000ff.
+ const unsigned int unpainted = server->ui_pixel(100, 100);
+ INFO("un-painted center pixel (RRGGBBAA) = ", unpainted);
+ CHECK((unpainted & 0xffu) == 0u); // alpha == 0
+ CHECK(unpainted == 0u); // fully transparent premultiplied (0,0,0,0)
+
+ // (3) A pixel inside the box reads the box color, fully opaque. The 40x40
+ // box is the first normal-flow block at the document top-left; sample well
+ // inside it (10,10) to avoid antialiased edges.
+ const unsigned int box = server->ui_pixel(10, 10);
+ INFO("box pixel (RRGGBBAA) = ", box);
+ const int br = static_cast<int>((box >> 24) & 0xff);
+ const int bg = static_cast<int>((box >> 16) & 0xff);
+ const int bb = static_cast<int>((box >> 8) & 0xff);
+ const int ba = static_cast<int>(box & 0xff);
+ CHECK(ba == 0xff); // opaque
+ CHECK(bg > br); // green dominates (#20c040)
+ CHECK(bg > bb);
+ CHECK(br < 90); // little red
+ CHECK(bg > 140); // strong green
+
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+}
+
+// ============================================================================
+// slice-10 / set_size RENDER-TARGET RESIZE. A surface created SMALL must grow
+// (and shrink) and render fully at the new size — set_size now reallocates the
+// FBO + dmabuf swapchain (or shm buffer) + EGLImage + texture, not just the
+// logical RmlUi layout. The dock creates a 1px placeholder and grows it; before
+// this fix the grown area rendered into the original tiny buffer (invisible).
+// Proven via the public Host::ui() path + the ui_pixel / ui_resize_realloc_count
+// probes. Full-body opaque color so a grown-area pixel reading the color proves
+// the new buffer was actually drawn into.
+// ============================================================================
+
+namespace {
+
+// A full-bleed opaque blue body (#2080e0), no margin, so EVERY pixel of the
+// surface (at whatever current size) is the painted color.
+const char* kResizeRml = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; }
+#fill { display: block; position: absolute; left: 0px; top: 0px;
+ width: 4000px; height: 4000px; background-color: #2080e0; }
+</style>
+</head>
+<body data-model="ui">
+<div id="fill"></div>
+</body>
+</rml>)RML";
+
+class ResizeTestExtension : public unbox::kernel::Extension {
+public:
+ auto manifest() const -> const Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ UiSurfaceSpec spec;
+ spec.rml_inline = kResizeRml;
+ spec.x = 0;
+ spec.y = 0;
+ spec.width = 40; // created SMALL (the dock starts at a tiny placeholder)
+ spec.height = 40;
+ spec.layer = unbox::kernel::SceneLayer::overlay;
+ spec.visible = true;
+ surface_ = host.ui().create_surface(spec);
+ }
+
+ [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
+ auto surface() -> UiSurface* { return surface_.get(); }
+
+private:
+ Manifest manifest_{"resize-test", Tier::standard, {}};
+ std::unique_ptr<UiSurface> surface_;
+};
+
+// True if (RRGGBBAA) is the painted blue #2080e0 (tolerant), opaque.
+auto is_painted_blue(unsigned int px) -> bool {
+ const int r = static_cast<int>((px >> 24) & 0xff);
+ const int g = static_cast<int>((px >> 16) & 0xff);
+ const int b = static_cast<int>((px >> 8) & 0xff);
+ const int a = static_cast<int>(px & 0xff);
+ return a == 0xff && b > 160 && b > r && b > g && r < 90;
+}
+
+} // namespace
+
+TEST_CASE("substrate: set_size resizes the render target (grow renders, shrink renders)") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // exercise the real Plan-A path the dock hits
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new ResizeTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ pump(*server, 30); // load + render at the small (40x40) size
+
+ if (!ext->has_surface() || server->ui_frame_count() == 0) {
+ return; // no GL path on this box: skip
+ }
+
+ // Small surface paints fully: its center reads the body color.
+ CHECK(is_painted_blue(server->ui_pixel(20, 20)));
+
+ // GROW to 200x200. Tick. A pixel deep in the GROWN region (150,150) — which
+ // does NOT exist in the original 40x40 buffer — must now read the painted
+ // color. Before the fix the document re-laid-out but rendered into the old
+ // 40x40 buffer, so (150,150) was unrendered (clamped/garbage/transparent).
+ const int before = server->ui_resize_realloc_count();
+ ext->surface()->set_size(200, 200);
+ CHECK(server->ui_resize_realloc_count() == before + 1); // grow reallocated
+ pump(*server, 10);
+ const unsigned int grown = server->ui_pixel(150, 150);
+ INFO("grown-region pixel (150,150) (RRGGBBAA) = ", grown);
+ CHECK(is_painted_blue(grown));
+ // The opaque-region invariant survives a resize (still per-pixel-alpha buffer).
+ CHECK(server->ui_surface_has_opaque_region() == false);
+ // The buffer is still upright after the realloc (no flip regression).
+ // (orientation() only inspects shm-path surfaces; this is the dmabuf path, so
+ // we assert upright indirectly: a top-left pixel and a bottom-right pixel of
+ // the full-bleed body both read the color, i.e. no garbled/empty rows.)
+ CHECK(is_painted_blue(server->ui_pixel(5, 5)));
+ CHECK(is_painted_blue(server->ui_pixel(195, 195)));
+
+ // SHRINK to 60x60. Tick. A pixel inside reads the color; out-of-bounds reads
+ // 0 (probe clamps), proving the buffer actually shrank.
+ ext->surface()->set_size(60, 60);
+ CHECK(server->ui_resize_realloc_count() == before + 2); // shrink reallocated
+ pump(*server, 10);
+ CHECK(is_painted_blue(server->ui_pixel(30, 30)));
+ CHECK(server->ui_pixel(150, 150) == 0u); // out of the new 60x60 bounds
+
+ // SAME-size set_size is a no-op realloc (only-on-change guard) and still
+ // renders correctly.
+ const int after_shrink = server->ui_resize_realloc_count();
+ ext->surface()->set_size(60, 60);
+ CHECK(server->ui_resize_realloc_count() == after_shrink); // no extra realloc
+ pump(*server, 5);
+ CHECK(is_painted_blue(server->ui_pixel(30, 30)));
+
+ // Non-positive set_size is rejected (keeps the 60x60 size; no realloc).
+ ext->surface()->set_size(0, 100);
+ ext->surface()->set_size(100, -1);
+ CHECK(server->ui_resize_realloc_count() == after_shrink);
+ pump(*server, 5);
+ CHECK(is_painted_blue(server->ui_pixel(30, 30)));
+}
+
+// ============================================================================
// VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The
// glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this
// helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12.