diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 17:09:24 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 17:09:24 +0900 |
| commit | 64c21337e7ccd3e158462771cd2e2886554256f0 (patch) | |
| tree | 244d950d3adfcdbd2871f813154e1635a31bd177 | |
| parent | 2b99158e5f1be1a51c9e6bf351e058efa98e63c4 (diff) | |
| download | unbox-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.hpp | 12 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 53 | ||||
| -rw-r--r-- | packages/kernel/src/rmlui_renderer_gl3.cpp | 33 | ||||
| -rw-r--r-- | packages/kernel/src/rmlui_renderer_gl3.h | 24 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 15 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 1 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 359 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 44 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 223 |
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. |
