diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 21:00:07 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 21:00:07 +0900 |
| commit | be5f67f7c7cf2710b0e73df5d92be98c758c47a4 (patch) | |
| tree | 857e0e9df72675d223b2a2f643c4d49ff01a5b50 | |
| parent | 4f5779cec17b0e9173d2b1de634c31c516069670 (diff) | |
| download | unbox-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.hpp | 13 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 23 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 8 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 175 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 10 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 228 |
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. |
