summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 17:09:24 +0900
committerAdam Malczewski <[email protected]>2026-06-13 17:09:24 +0900
commit64c21337e7ccd3e158462771cd2e2886554256f0 (patch)
tree244d950d3adfcdbd2871f813154e1635a31bd177
parent2b99158e5f1be1a51c9e6bf351e058efa98e63c4 (diff)
downloadunbox-64c21337e7ccd3e158462771cd2e2886554256f0.tar.gz
unbox-64c21337e7ccd3e158462771cd2e2886554256f0.zip
Slice 10 a1: preview pipeline spike — wlr pixels -> RMLUi texture (Fork-B GO)
The keystone for the stage dock. Proves Fork B on the real target (Mesa crocus, HD 4400): a toplevel's pixels, rendered by the wlr GLES2 renderer into a LINEAR ARGB8888 dmabuf, import as an EGLImage -> sampled GL texture in the sibling RMLUi GLES 3.2 context (the slice-3 bridge run in reverse) and composite into an <img src="unbox-preview://N"> inside a ui surface — upright, color-correct. Public surface (ui.hpp): class Preview (source_uri/source_width/source_height/ refresh) + UiSubstrate::create_preview(wlr_scene_tree*) -> unique_ptr<Preview> (nullptr if no GL path; never throws). Kernel-suite probes: ui_preview_import_is_dmabuf, ui_pixel(x,y). Clean four-resource teardown (URI reg, GL texture, EGLImage, dmabuf); refresh-after-source-destruction is UB (consumer drops Preview on unmap). kernel 42 cases/150 assertions green on build + build-asan (asan clean, no new suppressions). Edits confined to packages/kernel/.
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp12
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp53
-rw-r--r--packages/kernel/src/rmlui_renderer_gl3.cpp33
-rw-r--r--packages/kernel/src/rmlui_renderer_gl3.h24
-rw-r--r--packages/kernel/src/server.cpp15
-rw-r--r--packages/kernel/src/server_impl.hpp1
-rw-r--r--packages/kernel/src/ui_substrate.cpp359
-rw-r--r--packages/kernel/src/ui_substrate.hpp44
-rw-r--r--packages/kernel/tests/test_kernel.cpp223
9 files changed, 763 insertions, 1 deletions
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 2948680..190d4d5 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -91,6 +91,18 @@ public:
// EGL_KHR_fence_sync) — i.e. no glFinish on the hot path (notes/plan.md §7).
[[nodiscard]] auto ui_fence_sync_active() const -> bool;
+ // True when the most recent create_preview imported the snapshot via the
+ // dmabuf -> EGLImage -> sampled-texture path (slice-10 preview spike, Fork
+ // B). False before any preview, or on a backend without the GL import path.
+ // The kernel suite asserts this is true on a gles2/dmabuf backend.
+ [[nodiscard]] auto ui_preview_import_is_dmabuf() const -> bool;
+
+ // Packed 0xRRGGBBAA of the first shm-path ui surface's submitted buffer at
+ // layout pixel (x,y) (row 0 = top). 0 if no shm surface / no frame / out of
+ // bounds. Position-aware readback so the preview-spike test can assert a
+ // known source color reached the expected spot inside an <img>.
+ [[nodiscard]] auto ui_pixel(int x, int y) const -> unsigned int;
+
// Pin the substrate's touch-mode for tests (none = automatic). Mirrors
// UiSubstrate::TouchModeOverride; lets the suite drive the state machine and
// its on_touch_mode_changed notification. Test instrumentation;
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index f7c1976..3a704cd 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -122,6 +122,42 @@ struct UiSurfaceSpec {
bool visible = true;
};
+// A FROZEN, graphically scalable image of a toplevel's pixels, snapshotted from
+// a wlr_scene_tree at create/refresh time and imported as a texture INTO the ui
+// substrate's RMLUi context. Show it by putting source_uri() into an RML
+// <img src="..."> in ANY ui surface this same substrate created (the preview's
+// texture lives in the shared sibling GLES context, so every surface can sample
+// it). Owned by the contributing extension via unique_ptr; destruction frees the
+// imported texture + EGLImage + snapshot dmabuf and unregisters the URI. NOT
+// live — it is a copy, so the source toplevel may later be hidden or destroyed
+// without affecting an existing Preview. Event-loop thread only.
+class Preview {
+public:
+ virtual ~Preview() = default;
+ Preview(const Preview&) = delete;
+ auto operator=(const Preview&) -> Preview& = delete;
+
+ // The <img src> value resolving to this preview's texture inside any ui
+ // surface of this substrate (e.g. "unbox-preview://7"). Stable for life.
+ [[nodiscard]] virtual auto source_uri() const -> std::string = 0;
+ // Natural pixel size of the snapshot (aspect ratio). The <img> may be sized
+ // to any box via RCSS; RML scales the texture to fit.
+ [[nodiscard]] virtual auto source_width() const -> int = 0;
+ [[nodiscard]] virtual auto source_height() const -> int = 0;
+
+ // Re-snapshot from the original source scene tree if it is still valid.
+ // The borrow validity of the source tree is the CALLER's concern: the
+ // substrate cannot detect a freed wlr_scene_tree, so calling refresh()
+ // after the source has been destroyed is UNDEFINED BEHAVIOUR — the caller
+ // MUST drop the Preview when its source toplevel unmaps/destroys. A
+ // refresh() that fails to render (e.g. substrate lost its GL path) is a
+ // no-op and leaves the previous snapshot intact. NEVER throws.
+ virtual void refresh() = 0;
+
+protected:
+ Preview() = default;
+};
+
// The kernel's ui substrate, reached via Host::ui(). Per-session, kernel-owned;
// the reference is a borrow valid for your extension's lifetime. Carries your
// extension identity for error isolation.
@@ -139,6 +175,23 @@ public:
[[nodiscard]] virtual auto create_surface(const UiSurfaceSpec& spec)
-> std::unique_ptr<UiSurface> = 0;
+ // Snapshot the pixels drawn under `source` (a scene subtree — typically a
+ // toplevel's scene tree) into an ARGB8888 dmabuf via the wlr renderer, then
+ // import that dmabuf into the RMLUi sibling context as a sampled GL texture
+ // and register it under an "unbox-preview://N" URI. Show the result by
+ // putting the returned Preview's source_uri() into an RML <img src="..."> in
+ // any ui surface this substrate created. Ownership transfers to you
+ // (unique_ptr). Returns nullptr if the substrate is unavailable (no GL path,
+ // e.g. headless pixman) or the snapshot/import failed. `source` is a borrow
+ // used only during this call (and on refresh()). NEVER throws.
+ //
+ // SCOPE (slice-10 spike): a single-surface toplevel is fully supported; the
+ // snapshot composites every WLR_SCENE_NODE_BUFFER under `source` at its tree
+ // offset, so simple subsurface stacks composite too, but complex
+ // transform/clip/opacity per node is NOT honoured yet (a follow-up).
+ [[nodiscard]] virtual auto create_preview(wlr_scene_tree* source)
+ -> std::unique_ptr<Preview> = 0;
+
// Whether the substrate has a working GL bridge on this backend. When
// false, create_surface returns nullptr (degrade gracefully).
[[nodiscard]] virtual auto available() const -> bool = 0;
diff --git a/packages/kernel/src/rmlui_renderer_gl3.cpp b/packages/kernel/src/rmlui_renderer_gl3.cpp
index 7e192e3..dfb455f 100644
--- a/packages/kernel/src/rmlui_renderer_gl3.cpp
+++ b/packages/kernel/src/rmlui_renderer_gl3.cpp
@@ -1210,8 +1210,37 @@ struct TGAHeader {
// Restore packing
#pragma pack()
+// unbox (slice-10 preview spike): map an externally-owned GL texture to a
+// source URI so LoadTexture() resolves it. See header.
+void RenderInterface_GL3::register_preview_texture(const Rml::String& source, unsigned int texture_id, Rml::Vector2i dimensions)
+{
+ preview_textures[source] = PreviewTexture{texture_id, dimensions};
+ preview_texture_ids.insert(texture_id);
+}
+
+void RenderInterface_GL3::unregister_preview_texture(const Rml::String& source)
+{
+ auto it = preview_textures.find(source);
+ if (it == preview_textures.end())
+ return;
+ preview_texture_ids.erase(it->second.texture_id);
+ preview_textures.erase(it);
+}
+
Rml::TextureHandle RenderInterface_GL3::LoadTexture(Rml::Vector2i& texture_dimensions, const Rml::String& source)
{
+ // unbox: a preview URI resolves to its registered, externally-owned texture
+ // (the substrate's imported dmabuf->EGLImage->texture). Returns the GL id as
+ // the handle; ReleaseTexture() will skip deleting it (caller owns it).
+ {
+ auto it = preview_textures.find(source);
+ if (it != preview_textures.end())
+ {
+ texture_dimensions = it->second.dimensions;
+ return (Rml::TextureHandle)it->second.texture_id;
+ }
+ }
+
Rml::FileInterface* file_interface = Rml::GetFileInterface();
Rml::FileHandle file_handle = file_interface->Open(source);
if (!file_handle)
@@ -1486,6 +1515,10 @@ void RenderInterface_GL3::RenderBlur(float sigma, const Gfx::FramebufferData& so
void RenderInterface_GL3::ReleaseTexture(Rml::TextureHandle texture_handle)
{
+ // unbox: never delete an externally-owned preview texture — its GL lifetime
+ // belongs to the substrate's Preview, which deletes it on its own teardown.
+ if (preview_texture_ids.count((unsigned int)texture_handle) != 0)
+ return;
glDeleteTextures(1, (GLuint*)&texture_handle);
}
diff --git a/packages/kernel/src/rmlui_renderer_gl3.h b/packages/kernel/src/rmlui_renderer_gl3.h
index 06cd152..73825ce 100644
--- a/packages/kernel/src/rmlui_renderer_gl3.h
+++ b/packages/kernel/src/rmlui_renderer_gl3.h
@@ -3,6 +3,8 @@
#include <RmlUi/Core/RenderInterface.h>
#include <RmlUi/Core/Types.h>
#include <bitset>
+#include <unordered_map>
+#include <unordered_set>
enum class ProgramId;
enum class UniformId;
@@ -88,6 +90,17 @@ public:
const Rml::Matrix4f& GetTransform() const;
void ResetProgram();
+ // unbox (slice-10 preview spike): register an externally-OWNED GL texture
+ // under a "source" string (the "unbox-preview://N" URI). LoadTexture()
+ // resolves that exact source to `texture_id` + `dimensions`, so an
+ // <img src="unbox-preview://N"> samples it. The texture's GL lifetime is the
+ // CALLER's (the substrate's Preview): ReleaseTexture() must NOT delete it
+ // when RmlUi drops its handle, so registered ids are tracked and skipped
+ // there. unregister_preview_texture() removes the mapping on Preview
+ // destruction (the caller deletes the GL texture itself, after).
+ void register_preview_texture(const Rml::String& source, unsigned int texture_id, Rml::Vector2i dimensions);
+ void unregister_preview_texture(const Rml::String& source);
+
private:
void UseProgram(ProgramId program_id);
int GetUniformLocation(UniformId uniform_id) const;
@@ -175,6 +188,17 @@ private:
RenderLayerStack render_layers;
+ // unbox (slice-10 preview spike): externally-owned preview textures, keyed
+ // by their "unbox-preview://N" source string. LoadTexture() resolves a hit
+ // here; ReleaseTexture() skips any id present in preview_texture_ids so the
+ // substrate's Preview keeps sole ownership of the GL texture's lifetime.
+ struct PreviewTexture {
+ unsigned int texture_id;
+ Rml::Vector2i dimensions;
+ };
+ std::unordered_map<Rml::String, PreviewTexture> preview_textures;
+ std::unordered_set<unsigned int> preview_texture_ids;
+
struct GLStateBackup {
bool enable_cull_face;
bool enable_blend;
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index a5f8365..b302c68 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -81,6 +81,14 @@ auto Server::ui_fence_sync_active() const -> bool {
return impl_->substrate != nullptr && impl_->substrate->fence_sync_active();
}
+auto Server::ui_preview_import_is_dmabuf() const -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->preview_import_is_dmabuf();
+}
+
+auto Server::ui_pixel(int x, int y) const -> unsigned int {
+ return impl_->substrate != nullptr ? impl_->substrate->surface_pixel(x, y) : 0U;
+}
+
void Server::ui_set_touch_override(UiTouchOverride ov) {
if (impl_->substrate == nullptr) {
return;
@@ -104,6 +112,13 @@ auto PerExtensionUi::create_surface(const UiSurfaceSpec& spec) -> std::unique_pt
return server_->substrate->create_surface(id_, parent, spec);
}
+auto PerExtensionUi::create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview> {
+ if (server_->substrate == nullptr) {
+ return nullptr;
+ }
+ return server_->substrate->create_preview(source);
+}
+
auto PerExtensionUi::available() const -> bool {
return server_->substrate != nullptr && server_->substrate->available();
}
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index ef4a479..bcdb693 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -184,6 +184,7 @@ public:
PerExtensionUi(Server::Impl* server, ExtensionId id) : server_(server), id_(id) {}
auto create_surface(const UiSurfaceSpec& spec) -> std::unique_ptr<UiSurface> override;
+ auto create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview> override;
auto available() const -> bool override;
auto touch_mode() const -> bool override;
void set_touch_mode_override(TouchModeOverride ov) override;
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 91029f4..9c53770 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -353,6 +353,32 @@ struct Surface {
int frame_count = 0;
};
+// ---- PreviewState -----------------------------------------------------------
+//
+// A frozen snapshot of a scene subtree, imported as a sampled GL texture in the
+// RMLUi sibling context and registered under an "unbox-preview://N" URI. The
+// snapshot is captured into an ARGB8888 LINEAR dmabuf by the wlr renderer
+// (wlr_renderer_begin_buffer_pass), then that dmabuf is imported into the
+// sibling context exactly like the surface path (EGLImage -> texture), but here
+// the texture is SAMPLED by RmlUi rather than used as an FBO color attachment.
+// This is the slice-3 bridge run in reverse (wlr pixels -> dmabuf -> EGLImage ->
+// RmlUi texture). Lives in Substrate::Impl::previews (stable addresses).
+struct PreviewState {
+ Substrate::Impl* owner = nullptr;
+ int id = 0;
+ std::string uri;
+
+ wlr_scene_tree* source = nullptr; // borrow; valid only per call (caller's concern)
+ int width = 0;
+ int height = 0;
+
+ // The snapshot dmabuf (held alive for the texture's life) + its import.
+ wlr_buffer* buffer = nullptr; // ARGB8888 LINEAR dmabuf (own_buffer)
+ EGLImageKHR image = EGL_NO_IMAGE_KHR;
+ GLuint tex = 0; // sampled by RmlUi via the URI registration
+ bool dmabuf = false; // true once a dmabuf import succeeded
+};
+
// ---- Substrate::Impl --------------------------------------------------------
struct Substrate::Impl {
@@ -365,6 +391,12 @@ struct Substrate::Impl {
std::list<Surface> surfaces; // stable addresses (handles borrow Surface*)
+ // Previews (slice-10 spike): stable addresses (PreviewHandle borrows a
+ // PreviewState*). `next_preview_id` numbers the "unbox-preview://N" URIs.
+ std::list<PreviewState> previews;
+ int next_preview_id = 0;
+ bool last_preview_dmabuf = false; // test probe: last import took the dmabuf path
+
// Pointer implicit grab: the consumer of the first button press owns the
// whole press..release stream (standard seat behavior). `pointer_grab`
// (pure) tracks owner + down-count; `pointer_grab_surface` is the ui surface
@@ -423,6 +455,15 @@ struct Substrate::Impl {
void render_surface(Surface& s); // caller holds context current
void destroy_surface(Surface* s);
+ // Preview snapshot + import (caller holds the sibling context current).
+ // snapshot_into_buffer composites every WLR_SCENE_NODE_BUFFER under `source`
+ // into `p.buffer` via the wlr renderer; import_snapshot (re)imports that
+ // dmabuf as the sampled GL texture and registers the URI. Both return false
+ // (and clean up) on any failure.
+ bool snapshot_into_buffer(PreviewState& p);
+ bool import_snapshot(PreviewState& p);
+ void destroy_preview(PreviewState* p);
+
// Forward a synthesized pointer event into a surface's Rml context. Returns
// whether RmlUi (or our hit-test) treats it as consumed.
void ctx_motion(Surface& s, double lx, double ly);
@@ -686,6 +727,218 @@ void Substrate::Impl::ctx_button(Surface& s, bool pressed) {
}
}
+// ---- Preview snapshot + import ----------------------------------------------
+//
+// The snapshot is captured by the wlr GLES2 renderer (NOT the sibling RMLUi
+// context) into a LINEAR ARGB8888 dmabuf, then that dmabuf is imported into the
+// sibling context as a sampled GL texture (slice-3 bridge in reverse). All wlr
+// renderer work happens on the WLR EGL context; all import/texture work after
+// the snapshot happens on the sibling context (the caller makes it current).
+
+namespace {
+
+// Recursively composite every enabled WLR_SCENE_NODE_BUFFER under `node` into
+// `pass`, offset by the accumulated (ox,oy) from the snapshot tree's origin.
+// Single-surface toplevels and simple subsurface stacks composite; per-node
+// transform/clip/opacity beyond position is a documented follow-up.
+void composite_buffers(wlr_scene_node* node, int ox, int oy, wlr_render_pass* pass,
+ wlr_renderer* renderer) {
+ if (!node->enabled) {
+ return;
+ }
+ const int x = ox + node->x;
+ const int y = oy + node->y;
+ if (node->type == WLR_SCENE_NODE_BUFFER) {
+ auto* sb = wlr_scene_buffer_from_node(node);
+ if (sb->buffer != nullptr) {
+ wlr_texture* tex = wlr_texture_from_buffer(renderer, sb->buffer);
+ if (tex != nullptr) {
+ const int w = sb->dst_width > 0 ? sb->dst_width : static_cast<int>(tex->width);
+ const int h = sb->dst_height > 0 ? sb->dst_height : static_cast<int>(tex->height);
+ wlr_render_texture_options opts{};
+ opts.texture = tex;
+ opts.dst_box = wlr_box{x, y, w, h};
+ opts.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED;
+ wlr_render_pass_add_texture(pass, &opts);
+ wlr_texture_destroy(tex);
+ }
+ }
+ } else if (node->type == WLR_SCENE_NODE_TREE) {
+ auto* tree = wlr_scene_tree_from_node(node);
+ wlr_scene_node* child = nullptr;
+ wl_list_for_each(child, &tree->children, link) {
+ composite_buffers(child, x, y, pass, renderer);
+ }
+ }
+}
+
+// Natural pixel extent (max right/bottom of buffer nodes) of the subtree, in
+// the tree's own coordinate space (origin 0,0). 0x0 if the tree has no buffers.
+void tree_extent(wlr_scene_node* node, int ox, int oy, int& max_w, int& max_h) {
+ if (!node->enabled) {
+ return;
+ }
+ const int x = ox + node->x;
+ const int y = oy + node->y;
+ if (node->type == WLR_SCENE_NODE_BUFFER) {
+ auto* sb = wlr_scene_buffer_from_node(node);
+ if (sb->buffer != nullptr) {
+ const int w = sb->dst_width > 0 ? sb->dst_width : sb->buffer->width;
+ const int h = sb->dst_height > 0 ? sb->dst_height : sb->buffer->height;
+ max_w = std::max(max_w, x + w);
+ max_h = std::max(max_h, y + h);
+ }
+ } else if (node->type == WLR_SCENE_NODE_TREE) {
+ auto* tree = wlr_scene_tree_from_node(node);
+ wlr_scene_node* child = nullptr;
+ wl_list_for_each(child, &tree->children, link) {
+ tree_extent(child, x, y, max_w, max_h);
+ }
+ }
+}
+
+} // namespace
+
+bool Substrate::Impl::snapshot_into_buffer(PreviewState& p) {
+ // Size the snapshot to the subtree's natural extent (relative to the tree
+ // origin: children offsets are relative to `source`, so start at 0,0).
+ int w = 0;
+ int h = 0;
+ tree_extent(&p.source->node, -p.source->node.x, -p.source->node.y, w, h);
+ if (w <= 0 || h <= 0) {
+ wlr_log(WLR_INFO, "ui-substrate: preview source has no pixels to snapshot");
+ return false;
+ }
+
+ // (Re)allocate the dmabuf if the extent changed (refresh of a resized
+ // toplevel). The buffer is LINEAR ARGB8888, same format the surface path
+ // uses, so the same EGL import preconditions apply.
+ if (p.buffer == nullptr || p.width != w || p.height != h) {
+ if (p.buffer != nullptr) {
+ wlr_buffer_drop(p.buffer);
+ p.buffer = nullptr;
+ }
+ wlr_drm_format fmt{};
+ fmt.format = kDrmFormatArgb8888;
+ std::uint64_t modifiers[] = {0 /* DRM_FORMAT_MOD_LINEAR */};
+ fmt.len = 1;
+ fmt.capacity = 1;
+ fmt.modifiers = modifiers;
+ p.buffer = wlr_allocator_create_buffer(allocator, w, h, &fmt);
+ if (p.buffer == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: preview dmabuf allocation failed");
+ return false;
+ }
+ p.width = w;
+ p.height = h;
+ }
+
+ // Composite the subtree's buffers into the dmabuf via the WLR renderer. This
+ // runs on the wlr renderer's own EGL context (the caller has the SIBLING
+ // context current for the import that follows; begin_buffer_pass switches to
+ // the wlr context internally and restores nothing — so we re-make-current
+ // the sibling context after submit, in import_snapshot's caller).
+ wlr_buffer_pass_options pass_opts{};
+ wlr_render_pass* pass = wlr_renderer_begin_buffer_pass(renderer, p.buffer, &pass_opts);
+ if (pass == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: preview begin_buffer_pass failed");
+ return false;
+ }
+ // Clear to transparent first (the toplevel may not cover the whole extent).
+ wlr_render_rect_options clear{};
+ clear.box = wlr_box{0, 0, w, h};
+ clear.color = wlr_render_color{0.f, 0.f, 0.f, 0.f};
+ clear.blend_mode = WLR_RENDER_BLEND_MODE_NONE;
+ wlr_render_pass_add_rect(pass, &clear);
+ composite_buffers(&p.source->node, -p.source->node.x, -p.source->node.y, pass, renderer);
+ if (!wlr_render_pass_submit(pass)) {
+ wlr_log(WLR_ERROR, "ui-substrate: preview render_pass_submit failed");
+ return false;
+ }
+ return true;
+}
+
+bool Substrate::Impl::import_snapshot(PreviewState& p) {
+ // The caller holds the sibling context current. Re-import the dmabuf as a
+ // sampled texture (EGLImage -> glEGLImageTargetTexture2DOES), then register
+ // it under the URI so RmlUi's LoadTexture resolves <img src="unbox-...">.
+ if (!gl.dmabuf_import_ok || gl.egl_create_image == nullptr ||
+ gl.gl_image_target_texture == nullptr) {
+ return false;
+ }
+ wlr_dmabuf_attributes attribs{};
+ if (!wlr_buffer_get_dmabuf(p.buffer, &attribs) || attribs.n_planes < 1) {
+ wlr_log(WLR_ERROR, "ui-substrate: preview buffer has no dmabuf");
+ return false;
+ }
+ EGLint ia[] = {
+ EGL_WIDTH, attribs.width,
+ EGL_HEIGHT, attribs.height,
+ EGL_LINUX_DRM_FOURCC_EXT, static_cast<EGLint>(attribs.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT, attribs.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT, static_cast<EGLint>(attribs.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT, static_cast<EGLint>(attribs.stride[0]),
+ EGL_NONE,
+ };
+ EGLImageKHR img =
+ gl.egl_create_image(gl.egl_display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img == EGL_NO_IMAGE_KHR) {
+ wlr_log(WLR_ERROR, "ui-substrate: preview eglCreateImageKHR failed (0x%x)", eglGetError());
+ return false;
+ }
+ GLuint tex = 0;
+ glGenTextures(1, &tex);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ gl.gl_image_target_texture(GL_TEXTURE_2D, static_cast<GLeglImageOES>(img));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glBindTexture(GL_TEXTURE_2D, 0);
+
+ // Replace any prior import (refresh): drop old registration + GL objects.
+ if (gl.render_iface) {
+ gl.render_iface->unregister_preview_texture(p.uri);
+ }
+ if (p.tex != 0) {
+ glDeleteTextures(1, &p.tex);
+ }
+ if (p.image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ gl.egl_destroy_image(gl.egl_display, p.image);
+ }
+ p.tex = tex;
+ p.image = img;
+ p.dmabuf = true;
+ if (gl.render_iface) {
+ gl.render_iface->register_preview_texture(p.uri, p.tex,
+ Rml::Vector2i(p.width, p.height));
+ }
+ return true;
+}
+
+void Substrate::Impl::destroy_preview(PreviewState* p) {
+ const bool cur = gl.make_current();
+ if (gl.render_iface) {
+ gl.render_iface->unregister_preview_texture(p->uri);
+ }
+ if (p->tex != 0) {
+ glDeleteTextures(1, &p->tex);
+ p->tex = 0;
+ }
+ if (p->image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ gl.egl_destroy_image(gl.egl_display, p->image);
+ p->image = EGL_NO_IMAGE_KHR;
+ }
+ if (cur) {
+ gl.restore_current();
+ }
+ if (p->buffer != nullptr) {
+ wlr_buffer_drop(p->buffer);
+ p->buffer = nullptr;
+ }
+ previews.remove_if([p](const PreviewState& e) { return &e == p; });
+}
+
// ---- Substrate (private surface) --------------------------------------------
auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_renderer* renderer,
@@ -701,7 +954,14 @@ auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_ren
Substrate::Substrate(std::unique_ptr<Impl> impl) : impl_(std::move(impl)) {}
Substrate::~Substrate() {
- // Destroy surfaces (GL + scene nodes) then the shared bridge.
+ // Destroy previews (imported texture+EGLImage+dmabuf+URI registration) and
+ // surfaces (GL + scene nodes) before the shared bridge. A surviving Preview
+ // handle would dangle after this, but the contract (ui.hpp) is that the
+ // substrate outlives every Preview an extension holds (it is kernel-owned
+ // and torn down after extensions in Server::Impl::shutdown).
+ while (!impl_->previews.empty()) {
+ impl_->destroy_preview(&impl_->previews.front());
+ }
while (!impl_->surfaces.empty()) {
impl_->destroy_surface(&impl_->surfaces.front());
}
@@ -771,6 +1031,47 @@ auto Substrate::create_surface(ExtensionId who, wlr_scene_tree* parent, const Ui
return std::make_unique<SurfaceHandle>(this, &s);
}
+auto Substrate::create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview> {
+ impl_->last_preview_dmabuf = false;
+ if (!impl_->available() || source == nullptr) {
+ return nullptr;
+ }
+
+ impl_->previews.emplace_back();
+ PreviewState& p = impl_->previews.back();
+ p.owner = impl_.get();
+ p.id = ++impl_->next_preview_id;
+ p.uri = "unbox-preview://" + std::to_string(p.id);
+ p.source = source;
+
+ // 1) Composite the subtree into our LINEAR ARGB8888 dmabuf via the WLR
+ // renderer (its own EGL context). NO sibling context current here:
+ // begin_buffer_pass drives the wlr renderer's GL.
+ if (!impl_->snapshot_into_buffer(p)) {
+ impl_->destroy_preview(&p);
+ return nullptr;
+ }
+
+ // 2) Import the dmabuf into the sibling RMLUi context as a sampled texture
+ // and register the URI. The sibling context must be current for the
+ // EGLImage/texture/RmlUi-registration work; save+restore the wlr context.
+ if (!impl_->gl.make_current()) {
+ impl_->destroy_preview(&p);
+ return nullptr;
+ }
+ const bool imported = impl_->import_snapshot(p);
+ impl_->gl.restore_current();
+ if (!imported) {
+ impl_->destroy_preview(&p);
+ return nullptr;
+ }
+
+ impl_->last_preview_dmabuf = p.dmabuf;
+ return std::make_unique<PreviewHandle>(this, &p);
+}
+
+auto Substrate::preview_import_is_dmabuf() const -> bool { return impl_->last_preview_dmabuf; }
+
void Substrate::tick_all() {
if (!impl_->available() || impl_->surfaces.empty()) {
return;
@@ -937,6 +1238,35 @@ auto Substrate::fence_sync_active() const -> bool {
return impl_->gl.fence_ok && impl_->gl.dmabuf_import_ok;
}
+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.
+ for (const Surface& s : impl_->surfaces) {
+ if (s.fbo == 0 || s.frame_count == 0) {
+ continue;
+ }
+ if (x < 0 || y < 0 || x >= s.width || y >= s.height) {
+ return 0;
+ }
+ 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);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (cur) {
+ impl_->gl.restore_current();
+ }
+ return (static_cast<std::uint32_t>(rgba[0]) << 24) |
+ (static_cast<std::uint32_t>(rgba[1]) << 16) |
+ (static_cast<std::uint32_t>(rgba[2]) << 8) | static_cast<std::uint32_t>(rgba[3]);
+ }
+ return 0;
+}
+
auto Substrate::orientation() const -> int {
for (const Surface& s : impl_->surfaces) {
if (s.dmabuf || s.shm == nullptr || s.frame_count == 0) {
@@ -1094,4 +1424,31 @@ void SurfaceHandle::dirty() {
}
}
+// ---- PreviewHandle (public Preview impl) ------------------------------------
+
+PreviewHandle::~PreviewHandle() { substrate_->impl_->destroy_preview(state_); }
+
+auto PreviewHandle::source_uri() const -> std::string { return state_->uri; }
+auto PreviewHandle::source_width() const -> int { return state_->width; }
+auto PreviewHandle::source_height() const -> int { return state_->height; }
+
+void PreviewHandle::refresh() {
+ // Re-snapshot from the original source IF it is still valid. Borrow validity
+ // is the caller's concern (ui.hpp: refresh after source destruction is UB);
+ // we guard only against an obviously unusable substrate. A failed re-snapshot
+ // or re-import leaves the previous frozen snapshot + URI registration intact.
+ Substrate::Impl& impl = *substrate_->impl_;
+ if (!impl.available() || state_->source == nullptr) {
+ return;
+ }
+ if (!impl.snapshot_into_buffer(*state_)) {
+ return;
+ }
+ if (!impl.gl.make_current()) {
+ return;
+ }
+ impl.import_snapshot(*state_);
+ impl.gl.restore_current();
+}
+
} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index 082417a..cdba997 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -49,6 +49,32 @@ class Substrate; // the concrete UiSubstrate, defined in ui_substrate.cpp
// list of them and the public SurfaceHandle can borrow one.
struct Surface;
+// One preview's private state (snapshot dmabuf + imported EGLImage/texture +
+// RmlUi URI registration). Defined in ui_substrate.cpp; declared here so the
+// public PreviewHandle can borrow one out of the Substrate's list.
+struct PreviewState;
+
+// Concrete Preview handed to an extension. A thin, owning handle over a
+// PreviewState that lives in the Substrate's list; destruction frees the GL
+// texture + EGLImage + dmabuf and unregisters the URI.
+class PreviewHandle final : public Preview {
+public:
+ PreviewHandle(Substrate* substrate, PreviewState* state)
+ : substrate_(substrate), state_(state) {}
+ ~PreviewHandle() override;
+ PreviewHandle(const PreviewHandle&) = delete;
+ auto operator=(const PreviewHandle&) -> PreviewHandle& = delete;
+
+ [[nodiscard]] auto source_uri() const -> std::string override;
+ [[nodiscard]] auto source_width() const -> int override;
+ [[nodiscard]] auto source_height() const -> int override;
+ void refresh() override;
+
+private:
+ Substrate* substrate_;
+ PreviewState* state_;
+};
+
// Concrete UiSurface handed to an extension. A thin, owning handle over a
// Surface that lives in the Substrate's list; destruction removes the Surface
// (document + scene node). Per-extension (carries no id itself — its Surface
@@ -103,6 +129,12 @@ public:
auto create_surface(ExtensionId who, wlr_scene_tree* parent, const UiSurfaceSpec& spec)
-> std::unique_ptr<UiSurface>;
+ // Snapshot the pixels under `source` into a dmabuf imported as a sampled GL
+ // texture in the RMLUi context, registered under an "unbox-preview://N"
+ // URI. Returns nullptr if unavailable or the snapshot/import failed. Never
+ // throws. (See ui.hpp UiSubstrate::create_preview for the public contract.)
+ auto create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview>;
+
// Render every dirty surface (called from the output frame handler).
void tick_all();
@@ -136,6 +168,17 @@ public:
// (no glFinish on the hot path) — lets the suite assert the production sync.
[[nodiscard]] auto fence_sync_active() const -> bool;
+ // ---- preview test instrumentation (kernel suite only) ----
+ // Whether the last create_preview imported the snapshot via the dmabuf ->
+ // EGLImage -> sampled texture path (Plan A) rather than failing. Lets the
+ // suite assert the spike's GO path engaged on hardware that supports it.
+ [[nodiscard]] auto preview_import_is_dmabuf() const -> bool;
+ // Packed 0xRRGGBBAA of the first shm-path surface's submitted buffer at
+ // layout pixel (x,y) (readback row 0 = top). 0 if no shm surface / no frame
+ // / 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;
+
struct Impl;
private:
@@ -143,6 +186,7 @@ private:
std::unique_ptr<Impl> impl_;
friend class SurfaceHandle;
+ friend class PreviewHandle;
};
} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 912d35e..20cd559 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -187,6 +187,145 @@ void pump(unbox::kernel::Server& s, int turns) {
}
}
+// ---- slice-10 / a1 preview spike: a known-color source buffer + an <img> ----
+//
+// A data-ptr wlr_buffer filled with one solid color, wrapped in a scene-buffer
+// node under a private tree. The preview snapshots THIS subtree; a ui surface's
+// <img src="unbox-preview://N"> samples it. Color is FourCC AR24 little-endian
+// {B,G,R,A} so the test color round-trips to RMLUi's RGBA after the snapshot.
+
+constexpr std::uint32_t kArgb8888 = 0x34325241; // 'AR24'
+
+struct TestSrcBuffer {
+ wlr_buffer base{};
+ std::vector<std::uint8_t> data;
+ std::size_t stride = 0;
+};
+
+void test_src_destroy(wlr_buffer* b) {
+ auto* buf = reinterpret_cast<TestSrcBuffer*>(b);
+ wlr_buffer_finish(&buf->base);
+ delete buf;
+}
+bool test_src_access(wlr_buffer* b, std::uint32_t, void** data, std::uint32_t* format,
+ std::size_t* stride) {
+ auto* buf = reinterpret_cast<TestSrcBuffer*>(b);
+ *data = buf->data.data();
+ *format = kArgb8888;
+ *stride = buf->stride;
+ return true;
+}
+void test_src_end(wlr_buffer*) {}
+
+const wlr_buffer_impl kTestSrcImpl = {
+ .destroy = test_src_destroy,
+ .get_dmabuf = nullptr,
+ .get_shm = nullptr,
+ .begin_data_ptr_access = test_src_access,
+ .end_data_ptr_access = test_src_end,
+};
+
+// Build a w*h buffer of solid (r,g,b) opaque pixels (premultiplied; opaque so
+// premultiply is identity). Stored {B,G,R,A} per AR24.
+auto make_solid_buffer(int w, int h, std::uint8_t r, std::uint8_t g, std::uint8_t b)
+ -> TestSrcBuffer* {
+ auto* buf = new TestSrcBuffer();
+ buf->stride = static_cast<std::size_t>(w) * 4;
+ buf->data.assign(buf->stride * static_cast<std::size_t>(h), 0);
+ for (std::size_t i = 0; i < static_cast<std::size_t>(w) * h; ++i) {
+ buf->data[i * 4 + 0] = b;
+ buf->data[i * 4 + 1] = g;
+ buf->data[i * 4 + 2] = r;
+ buf->data[i * 4 + 3] = 0xff;
+ }
+ wlr_buffer_init(&buf->base, &kTestSrcImpl, w, h);
+ return buf;
+}
+
+// A ui surface whose ONLY content is a full-bleed <img> of a preview. Sized to
+// the surface; the body has no margin so the image fills it. Distinct bg
+// (#101010) so a failed sample is obvious.
+const char* kPreviewRml = R"RML(<rml>
+<head>
+<style>
+body { background: #101010; width: 200px; height: 200px; }
+img { display: block; position: absolute; left: 0px; top: 0px;
+ width: 200px; height: 200px; }
+</style>
+</head>
+<body data-model="ui">
+<img src="PREVIEW_URI"/>
+</body>
+</rml>)RML";
+
+// Extension that builds a known-color source subtree, makes a Preview of it,
+// and shows it in a ui surface via <img>. Records whether each step succeeded.
+class PreviewTestExtension : public unbox::kernel::Extension {
+public:
+ auto manifest() const -> const Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ if (!host.ui().available()) {
+ return; // no GL path: degrade (test skips)
+ }
+ // Build the source: a 64x64 solid #ff2060 buffer in its own tree under
+ // the background layer (off to the side so it does not overlap the ui
+ // surface; the preview snapshots the TREE, not the screen).
+ src_tree_ = wlr_scene_tree_create(host.scene_layer(unbox::kernel::SceneLayer::background));
+ if (src_tree_ == nullptr) {
+ return;
+ }
+ src_buf_ = make_solid_buffer(64, 64, 0xff, 0x20, 0x60);
+ src_node_ = wlr_scene_buffer_create(src_tree_, &src_buf_->base);
+ wlr_buffer_drop(&src_buf_->base); // scene_buffer took its own lock
+
+ preview_ = host.ui().create_preview(src_tree_);
+ if (preview_ == nullptr) {
+ return;
+ }
+
+ std::string rml = kPreviewRml;
+ const std::string token = "PREVIEW_URI";
+ rml.replace(rml.find(token), token.size(), preview_->source_uri());
+
+ UiSurfaceSpec spec;
+ spec.rml_inline = rml;
+ 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);
+ }
+
+ void teardown() {
+ surface_.reset();
+ preview_.reset();
+ if (src_tree_ != nullptr) {
+ wlr_scene_node_destroy(&src_tree_->node);
+ src_tree_ = nullptr;
+ }
+ }
+
+ [[nodiscard]] auto has_preview() const -> bool { return preview_ != nullptr; }
+ [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
+ [[nodiscard]] auto preview_uri() const -> std::string {
+ return preview_ != nullptr ? preview_->source_uri() : std::string{};
+ }
+ [[nodiscard]] auto preview() -> unbox::kernel::Preview* { return preview_.get(); }
+ [[nodiscard]] auto preview_w() const -> int { return preview_ ? preview_->source_width() : 0; }
+ [[nodiscard]] auto preview_h() const -> int { return preview_ ? preview_->source_height() : 0; }
+
+private:
+ Manifest manifest_{"preview-test", Tier::standard, {}};
+ wlr_scene_tree* src_tree_ = nullptr;
+ TestSrcBuffer* src_buf_ = nullptr;
+ wlr_scene_buffer* src_node_ = nullptr;
+ std::unique_ptr<unbox::kernel::Preview> preview_;
+ std::unique_ptr<UiSurface> surface_;
+};
+
} // namespace
TEST_CASE("substrate: unavailable under pixman; create_surface degrades to null") {
@@ -352,6 +491,90 @@ TEST_CASE("substrate: a click over a ui surface is CONSUMED (no click-through)")
}
// ============================================================================
+// slice-10 / a1 PREVIEW SPIKE (Fork-B gate). A known-color source subtree is
+// snapshotted into a dmabuf, imported into the RMLUi context as a sampled
+// texture, and shown via <img src="unbox-preview://N"> in a ui surface. The
+// suite proves: (1) the dmabuf->EGLImage->texture import engaged on this GPU
+// (Plan A — the go/no-go unknown), and (2) the known source color actually
+// composited into the surface at the expected spot (position-aware readback).
+// ============================================================================
+
+TEST_CASE("preview: dmabuf import as a sampled RMLUi texture engages (Fork-B GO)") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // Plan A: dmabuf import path
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new PreviewTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+
+ if (!ext->has_preview()) {
+ // No GL path on this box: the spike is moot here (recorded NO-GO would
+ // be reported from real hardware, not skipped CI). Nothing to assert.
+ return;
+ }
+ // The preview reports the source's natural size and a stable URI.
+ CHECK(ext->preview_w() == 64);
+ CHECK(ext->preview_h() == 64);
+ CHECK(ext->preview_uri().rfind("unbox-preview://", 0) == 0);
+ // The GO criterion: the snapshot imported via dmabuf -> EGLImage -> texture.
+ CHECK(server->ui_preview_import_is_dmabuf());
+
+ pump(*server, 60); // let the <img> surface load + sample the preview texture
+ CHECK(ext->has_surface());
+ CHECK(server->ui_frame_count() > 0);
+
+ ext->teardown(); // drop preview + surface + source while the server lives
+}
+
+TEST_CASE("preview: known source color composites into an <img> (position-aware)") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ // Plan A throughout: the preview snapshots into a dmabuf and imports as a
+ // sampled texture; the surface composites it into its own (dmabuf) FBO. The
+ // ui_pixel probe reads that FBO back via glReadPixels (path-independent), so
+ // no FORCE_SHM is needed — this exercises the real Fork-B pipeline.
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new PreviewTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+
+ if (!ext->has_preview() || !ext->has_surface()) {
+ return; // no GL path: skip
+ }
+
+ for (int i = 0; i < 80; ++i) {
+ server->dispatch(10);
+ }
+ if (server->ui_frame_count() == 0) {
+ return; // no frame submitted on this box
+ }
+
+ // The <img> fills the 200x200 surface with the 64x64 #ff2060 source scaled
+ // up. Sample the center: it must be the source color, NOT the #101010 bg —
+ // proof the imported preview texture was sampled and composited upright.
+ const unsigned int px = server->ui_pixel(100, 100);
+ INFO("center pixel (RRGGBBAA) = ", px);
+ 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);
+ // Tolerant match for #ff2060 (bilinear edges + premultiply rounding).
+ CHECK(r > 180);
+ CHECK(g < 90);
+ CHECK(b > 60);
+ CHECK(b < 160);
+ // And definitely not the dark background (a missed sample would be ~#101010).
+ CHECK(r + g + b > 200);
+
+ ext->teardown();
+}
+
+// ============================================================================
// 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.