summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 00:17:33 +0900
committerAdam Malczewski <[email protected]>2026-06-13 00:17:33 +0900
commit803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9 (patch)
tree68d727df9c0f08a7a08c2c464f95d8c82fb8789e /packages/kernel/src
parentc102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (diff)
downloadunbox-803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9.tar.gz
unbox-803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9.zip
Slice 5: real ui substrate + unified input routing + touch-mode; spike retired
The ui substrate is now the extension-facing contract (unbox/kernel/ui.hpp): Host::ui() -> UiSubstrate::create_surface(spec) -> UiSurface with typed scalar bindings (int/double/bool/string getters), data-event callbacks (error-isolated per extension), dirty(), geometry/visibility — RMLUi and GL stay kernel-private. Production sync: glFinish replaced by EGL_KHR_fence_sync + 2-deep wlr_swapchain. ui_spike retired (orientation guard + dirty-cycle coverage live on as substrate tests). Input: ONE kernel routing path feeds pointer AND touch into ui surfaces with consume-or-pass semantics and implicit-grab ownership (the consumer of a press owns the matching release; per touch point too) — fixes drag-release-over-ui sticking. touch-mode: state machine + debounce + on_touch_mode_changed notification, NO visual scaling (user decision after hardware hands-on; dp-ratio stays 1.0; see plan §2). ext-xdg-shell: GrabMachine generalized to pointer-OR-touch interaction source (touch titlebar drag works; originating-point pinning); fixed the seat implicit-grab leak (suppressed release after forwarded press swallowed all later touch-downs — pointer/touch alternation doctested); factory renamed create(). ext-layer-shell: on_demand keyboard interactivity via scene hit resolution. host-bin: --ui-demo extension (temporary acceptance demo on the public contract, dies in slice 6). User hands-on verified: same surface by mouse and finger, tap counter, touch-mode neutrality, no click-through, drag alternation, fuzzel on_demand. 113 doctest cases green, ASan/UBSan clean (our code), idle RSS ≈78 MiB. Harness: UX-feel hands-on lesson (ORCHESTRATOR §2.6), nested-run pkill/setsid notes, touch-mode glossary redefinition.
Diffstat (limited to 'packages/kernel/src')
-rw-r--r--packages/kernel/src/input.cpp55
-rw-r--r--packages/kernel/src/server.cpp93
-rw-r--r--packages/kernel/src/server_impl.hpp37
-rw-r--r--packages/kernel/src/ui_core.hpp139
-rw-r--r--packages/kernel/src/ui_spike.cpp688
-rw-r--r--packages/kernel/src/ui_spike.hpp77
-rw-r--r--packages/kernel/src/ui_substrate.cpp1097
-rw-r--r--packages/kernel/src/ui_substrate.hpp148
8 files changed, 1520 insertions, 814 deletions
diff --git a/packages/kernel/src/input.cpp b/packages/kernel/src/input.cpp
index b6c4c0c..336947e 100644
--- a/packages/kernel/src/input.cpp
+++ b/packages/kernel/src/input.cpp
@@ -143,17 +143,13 @@ void Server::Impl::new_touch(wlr_input_device* device) {
// ---- Pointer (via wlr_cursor): move cursor + emit, route nothing ------------
void Server::Impl::emit_pointer_motion(std::uint32_t time_msec) {
- // Slice-3 spike input proof (kernel-internal; NOT a contract): forward
- // surface-local coords over the spike node so its button hovers.
- if (ui_spike != nullptr) {
- if (wlr_scene_node* spike = ui_spike->node()) {
- int nx = 0;
- int ny = 0;
- wlr_scene_node_coords(spike, &nx, &ny);
- ui_spike->on_pointer_motion(cursor->x - nx, cursor->y - ny);
- }
+ // Motion is ALWAYS observed by both the substrate (hover/leave on ui
+ // surfaces) and the bus (extensions hit-test the scene themselves). A ui-
+ // surface node is not a client surface, so a routing extension naturally
+ // finds "no client here" over a ui surface and clears stale client hover.
+ if (substrate != nullptr) {
+ substrate->route_pointer_motion(cursor->x, cursor->y, time_msec);
}
-
const PointerMotionEvent ev{cursor->x, cursor->y, time_msec};
ev_pointer_motion.emit(ev);
}
@@ -173,22 +169,23 @@ void Server::Impl::attach_cursor_handlers() {
const auto* event = static_cast<wlr_pointer_button_event*>(data);
const bool pressed = event->state == WL_POINTER_BUTTON_STATE_PRESSED;
- // Slice-3 spike input proof (kernel-internal): forward clicks over the
- // spike node so its button reacts.
- if (ui_spike != nullptr) {
- if (wlr_scene_node* spike = ui_spike->node()) {
- if (wlr_scene_node_at(spike, cursor->x, cursor->y, nullptr, nullptr) != nullptr) {
- ui_spike->on_pointer_button(pressed);
- }
- }
+ // Consumption order: the substrate gets first refusal. If the click is
+ // over a visible ui surface it consumes it (drives the document) and we
+ // do NOT emit on the bus — no click-through to clients beneath.
+ if (substrate != nullptr &&
+ substrate->route_pointer_button(cursor->x, cursor->y, pressed, event->time_msec)) {
+ return;
}
-
const PointerButtonEvent ev{event->button, pressed, cursor->x, cursor->y,
event->time_msec};
ev_pointer_button.emit(ev);
});
cursor_axis.connect(cursor->events.axis, [this](void* data) {
const auto* event = static_cast<wlr_pointer_axis_event*>(data);
+ if (substrate != nullptr &&
+ substrate->route_pointer_axis(cursor->x, cursor->y, event->delta, event->time_msec)) {
+ return; // consumed by a ui surface
+ }
const PointerAxisEvent ev{event->orientation, event->delta, event->delta_discrete,
event->source, event->time_msec};
ev_pointer_axis.emit(ev);
@@ -203,6 +200,12 @@ void Server::Impl::attach_cursor_handlers() {
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
+ // Substrate first refusal (consume-or-pass). A down over a ui surface
+ // is captured by the substrate (tap = click) and not emitted on the bus.
+ if (substrate != nullptr &&
+ substrate->route_touch_down(event->touch_id, lx, ly, event->time_msec)) {
+ return;
+ }
const TouchDownEvent ev{event->touch_id, lx, ly, event->time_msec};
ev_touch_down.emit(ev);
});
@@ -212,16 +215,30 @@ void Server::Impl::attach_cursor_handlers() {
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
+ // If this touch id was captured by a ui surface at down, the substrate
+ // keeps it (and consumes the motion); otherwise it passes to the bus.
+ if (substrate != nullptr &&
+ substrate->route_touch_motion(event->touch_id, lx, ly, event->time_msec)) {
+ return;
+ }
const TouchMotionEvent ev{event->touch_id, lx, ly, event->time_msec};
ev_touch_motion.emit(ev);
});
cursor_touch_up.connect(cursor->events.touch_up, [this](void* data) {
const auto* event = static_cast<wlr_touch_up_event*>(data);
+ if (substrate != nullptr && substrate->route_touch_up(event->touch_id, event->time_msec)) {
+ return; // a captured (ui-surface) touch ended
+ }
const TouchUpEvent ev{event->touch_id, event->time_msec};
ev_touch_up.emit(ev);
});
cursor_touch_cancel.connect(cursor->events.touch_cancel, [this](void* data) {
const auto* event = static_cast<wlr_touch_cancel_event*>(data);
+ // A cancel of a substrate-captured touch releases the RML button and is
+ // consumed; otherwise it passes to the bus.
+ if (substrate != nullptr && substrate->route_touch_up(event->touch_id, event->time_msec)) {
+ return;
+ }
const TouchCancelEvent ev{event->touch_id};
ev_touch_cancel.emit(ev);
});
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index e3308de..d02cf78 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -69,12 +69,53 @@ void Server::terminate() {
wl_display_terminate(impl_->display);
}
-auto Server::ui_spike_frame_count() const -> int {
- return impl_->ui_spike != nullptr ? impl_->ui_spike->frame_count() : 0;
+auto Server::ui_frame_count() const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->frame_count() : 0;
}
-auto Server::ui_spike_orientation() const -> int {
- return impl_->ui_spike != nullptr ? impl_->ui_spike->check_orientation() : 0;
+auto Server::ui_orientation() const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->orientation() : 0;
+}
+
+auto Server::ui_fence_sync_active() const -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->fence_sync_active();
+}
+
+void Server::ui_set_touch_override(UiTouchOverride ov) {
+ if (impl_->substrate == nullptr) {
+ return;
+ }
+ UiSubstrate::TouchModeOverride mapped = UiSubstrate::TouchModeOverride::automatic;
+ if (ov == UiTouchOverride::force_off) {
+ mapped = UiSubstrate::TouchModeOverride::force_off;
+ } else if (ov == UiTouchOverride::force_on) {
+ mapped = UiSubstrate::TouchModeOverride::force_on;
+ }
+ impl_->substrate->set_touch_mode_override(mapped);
+}
+
+// ---- PerExtensionUi (per-extension ui-substrate facade) --------------------
+
+auto PerExtensionUi::create_surface(const UiSurfaceSpec& spec) -> std::unique_ptr<UiSurface> {
+ if (server_->substrate == nullptr) {
+ return nullptr;
+ }
+ wlr_scene_tree* parent = server_->scene_layers[static_cast<std::size_t>(spec.layer)];
+ return server_->substrate->create_surface(id_, parent, spec);
+}
+
+auto PerExtensionUi::available() const -> bool {
+ return server_->substrate != nullptr && server_->substrate->available();
+}
+
+auto PerExtensionUi::touch_mode() const -> bool {
+ return server_->substrate != nullptr && server_->substrate->touch_mode();
+}
+
+void PerExtensionUi::set_touch_mode_override(TouchModeOverride ov) {
+ if (server_->substrate != nullptr) {
+ server_->substrate->set_touch_mode_override(ov);
+ }
}
// ---- Impl lifecycle --------------------------------------------------------
@@ -169,9 +210,10 @@ void Server::Impl::init() {
throw std::runtime_error("failed to start the wlr_backend");
}
- if (options.ui_spike) {
- start_ui_spike();
- }
+ // The ui substrate is always built; it reports available()==false on a
+ // backend with no GL path (headless pixman) and create_surface yields
+ // nullptr there, so extensions degrade gracefully. Never throws.
+ start_substrate();
if (!options.startup_cmd.empty()) {
if (fork() == 0) {
@@ -273,20 +315,22 @@ void Server::Impl::activate_extensions() {
}
}
-void Server::Impl::start_ui_spike() {
- if (!wlr_renderer_is_gles2(renderer)) {
- wlr_log(WLR_INFO, "ui-spike: renderer is not gles2; spike disabled");
- return;
- }
- wlr_egl* egl = wlr_gles2_renderer_get_egl(renderer);
- if (egl == nullptr) {
- wlr_log(WLR_ERROR, "ui-spike: gles2 renderer has no wlr_egl");
- return;
+void Server::Impl::start_substrate() {
+ // The substrate needs the wlr renderer's EGLDisplay for its sibling GLES
+ // 3.2 context. Only the gles2 renderer exposes one; under pixman (headless
+ // CI) there is no GL path, so the substrate builds but reports unavailable.
+ EGLDisplay display_egl = EGL_NO_DISPLAY;
+ if (wlr_renderer_is_gles2(renderer)) {
+ if (wlr_egl* egl = wlr_gles2_renderer_get_egl(renderer)) {
+ display_egl = wlr_egl_get_display(egl);
+ }
+ } else {
+ wlr_log(WLR_INFO, "ui-substrate: renderer is not gles2; substrate unavailable");
}
- EGLDisplay display_egl = wlr_egl_get_display(egl);
- // The spike sits in the overlay band so it composites above everything.
- ui_spike = UiSpike::create(scene_layers[static_cast<std::size_t>(SceneLayer::overlay)],
- display_egl, allocator, renderer);
+ // A data-event/getter throw disables the owning extension via the same
+ // isolation path the bus uses (Server::Impl is the DisableSink).
+ substrate = Substrate::create(display_egl, allocator, renderer,
+ [this](ExtensionId who) { disable(who); });
}
void Server::Impl::shutdown() {
@@ -301,8 +345,9 @@ void Server::Impl::shutdown() {
}
extensions.clear();
- // Slice-3 spike: tear down before scene/renderer/allocator die.
- ui_spike.reset();
+ // The ui substrate owns scene nodes + GL objects on a sibling context and
+ // borrows scene/renderer/allocator: tear it down before they die.
+ substrate.reset();
if (display != nullptr) {
wl_display_destroy_clients(display);
@@ -377,8 +422,8 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
outputs.push_back(std::move(owned));
output->frame.connect(wlr_output->events.frame, [this, output](void*) {
- if (ui_spike != nullptr) {
- ui_spike->tick();
+ if (substrate != nullptr) {
+ substrate->tick_all();
}
wlr_scene_output* scene_output = wlr_scene_get_scene_output(scene, output->output);
wlr_scene_output_commit(scene_output, nullptr);
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index c5615b2..3407883 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -2,10 +2,11 @@
#include <unbox/kernel/host.hpp>
#include <unbox/kernel/server.hpp>
+#include <unbox/kernel/ui.hpp>
#include <unbox/kernel/wlr.hpp>
#include "listener.hpp"
-#include "ui_spike.hpp"
+#include "ui_substrate.hpp"
#include <array>
#include <cstdint>
@@ -83,9 +84,10 @@ struct Server::Impl : detail::DisableSink {
// attach nodes via Host::scene_layer(); the kernel owns them.
std::array<wlr_scene_tree*, 5> scene_layers{};
- // Slice-3 spike (kernel-internal; not a contract). Torn down in shutdown()
- // BEFORE scene/renderer/allocator.
- std::unique_ptr<UiSpike> ui_spike;
+ // The ui substrate (the kernel's RMLUi subsystem behind <unbox/kernel/ui.hpp>).
+ // Kernel-owned; torn down in shutdown() BEFORE scene/renderer/allocator. Its
+ // per-extension facades (PerExtensionUi, one per HostImpl) borrow it.
+ std::unique_ptr<Substrate> substrate;
std::list<std::unique_ptr<Output>> outputs;
std::list<std::unique_ptr<Keyboard>> keyboards;
@@ -148,7 +150,7 @@ struct Server::Impl : detail::DisableSink {
void init(); // throws std::runtime_error on any component failure
void shutdown();
void handle_new_output(wlr_output* output);
- void start_ui_spike(); // slice-3 spike; never throws, may no-op
+ void start_substrate(); // builds the ui substrate; never throws, may be unavailable
void register_hook(detail::HookBase& hook); // track for purge/disable
// server.cpp — extension host
@@ -166,11 +168,32 @@ struct Server::Impl : detail::DisableSink {
void emit_pointer_motion(std::uint32_t time_msec);
};
+// ---- Per-extension ui-substrate facade --------------------------------------
+//
+// The public UiSubstrate an extension gets from Host::ui(). Injects the owning
+// extension id (for error isolation) and resolves a UiSurfaceSpec's SceneLayer
+// to the kernel's scene-layer tree, then delegates to the shared Substrate.
+// Owned by its HostImpl; borrows the kernel-owned Substrate.
+
+class PerExtensionUi final : public UiSubstrate {
+public:
+ PerExtensionUi(Server::Impl* server, ExtensionId id) : server_(server), id_(id) {}
+
+ auto create_surface(const UiSurfaceSpec& spec) -> std::unique_ptr<UiSurface> override;
+ auto available() const -> bool override;
+ auto touch_mode() const -> bool override;
+ void set_touch_mode_override(TouchModeOverride ov) override;
+
+private:
+ Server::Impl* server_;
+ ExtensionId id_;
+};
+
// ---- Per-extension Host facade ----------------------------------------------
class HostImpl final : public Host {
public:
- HostImpl(Server::Impl* server, ExtensionId id) : server_(server), id_(id) {}
+ HostImpl(Server::Impl* server, ExtensionId id) : server_(server), id_(id), ui_(server, id) {}
auto display() -> wl_display* override { return server_->display; }
auto scene() -> wlr_scene* override { return server_->scene; }
@@ -181,6 +204,7 @@ public:
auto scene_layer(SceneLayer layer) -> wlr_scene_tree* override {
return server_->scene_layers[static_cast<std::size_t>(layer)];
}
+ auto ui() -> UiSubstrate& override { return ui_; }
auto on_output_added() -> Event<const OutputEvent&>& override {
return server_->ev_output_added;
@@ -231,6 +255,7 @@ protected:
private:
Server::Impl* server_;
ExtensionId id_;
+ PerExtensionUi ui_;
};
} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_core.hpp b/packages/kernel/src/ui_core.hpp
new file mode 100644
index 0000000..e7c0723
--- /dev/null
+++ b/packages/kernel/src/ui_core.hpp
@@ -0,0 +1,139 @@
+#pragma once
+
+#include <cstdint>
+
+// Pure decision cores for the ui substrate — NO wlroots / GL / RMLUi types, so
+// they are doctest-able with nothing running (AGENTS.md: effects at the edges,
+// pure cores tested hard). The substrate glue (ui_substrate.cpp) injects the
+// effects around these.
+//
+// Everything runs on the single wl_event_loop thread; no synchronization here.
+
+namespace unbox::kernel {
+
+// touch-mode: the substrate-level theme state that scales hit targets for
+// finger input. It flips automatically by last-input kind — a touch event
+// turns it ON, pointer motion turns it OFF — but with debounce so a stray
+// pointer jitter during a touch interaction (palm, accidental trackpad brush)
+// does not flicker it. A manual override pins it for tests/config.
+//
+// Pure state machine: feed it input-kind events + a monotonic timestamp; it
+// returns whether the effective mode CHANGED so the caller can re-theme only
+// on a transition. The debounce rule: after a touch event, pointer motion is
+// ignored for `debounce_ms`; a touch event always wins immediately.
+class TouchModeTracker {
+public:
+ enum class Mode { pointer, touch };
+ enum class Override { none, force_pointer, force_touch };
+
+ explicit TouchModeTracker(std::uint32_t debounce_ms = 700) : debounce_ms_(debounce_ms) {}
+
+ // A touch event happened at time_msec. Returns true if the EFFECTIVE mode
+ // changed. Touch always wins immediately and arms the debounce window.
+ auto on_touch(std::uint32_t time_msec) -> bool {
+ last_touch_msec_ = time_msec;
+ have_touch_ = true;
+ return set_auto(Mode::touch);
+ }
+
+ // Pointer motion at time_msec. Ignored (does NOT flip to pointer) while
+ // within debounce_ms of the last touch — that suppresses palm/jitter.
+ // Returns true if the effective mode changed.
+ auto on_pointer_motion(std::uint32_t time_msec) -> bool {
+ if (have_touch_ && time_msec - last_touch_msec_ < debounce_ms_) {
+ return false; // inside the debounce shadow of a touch
+ }
+ return set_auto(Mode::pointer);
+ }
+
+ // Pin the mode regardless of input (tests/config). Override::none returns
+ // to automatic, adopting the current auto-derived mode. Returns true if the
+ // effective mode changed.
+ auto set_override(Override ov) -> bool {
+ const Mode before = effective();
+ override_ = ov;
+ return effective() != before;
+ }
+
+ [[nodiscard]] auto effective() const -> Mode {
+ switch (override_) {
+ case Override::force_pointer: return Mode::pointer;
+ case Override::force_touch: return Mode::touch;
+ case Override::none: break;
+ }
+ return auto_mode_;
+ }
+
+ [[nodiscard]] auto is_touch() const -> bool { return effective() == Mode::touch; }
+
+private:
+ auto set_auto(Mode m) -> bool {
+ const Mode before = effective();
+ auto_mode_ = m;
+ return effective() != before;
+ }
+
+ std::uint32_t debounce_ms_;
+ std::uint32_t last_touch_msec_ = 0;
+ bool have_touch_ = false;
+ Mode auto_mode_ = Mode::pointer;
+ Override override_ = Override::none;
+};
+
+// NOTE (user decision, slice-5 hands-on): touch-mode causes NO automatic visual
+// scaling. The dp-ratio knob is retired — the substrate leaves every context at
+// RmlUi's default 1.0 permanently, so `dp` behaves like `px` in practice. The
+// touch-mode STATE (auto-flip + debounce + on_touch_mode_changed notification)
+// stays meaningful for invisible affordances and later slices (OSK auto-show,
+// spacing); an extension that wants to adapt does so itself via the
+// notification. (Earlier slices applied a 1.0/1.25 ratio here.)
+
+// Who owns an in-flight pointer/touch grab — the consumer of the initiating
+// press/down owns the whole stream until it ends (standard seat implicit-grab
+// behavior). `none` = no grab active.
+enum class GrabOwner { none, substrate, bus };
+
+// Pure implicit-grab state for the pointer button stream. A grab begins on the
+// FIRST button press (when no button was down) and ends when the LAST button
+// is released; the owner is decided ONCE at grab start by whether the press
+// landed over a ui surface, and EVERY event until the grab ends routes to that
+// owner — regardless of what the cursor is over later. This is what makes a
+// release land on the same party as its press (the slice-5 stuck-drag bug:
+// press→extensions, release-over-ui-surface must still reach extensions).
+class PointerButtonGrab {
+public:
+ // A button press landed; `over_surface` is the hit-test AT PRESS TIME.
+ // Returns the owner this press (and the rest of the grab) routes to.
+ auto press(bool over_surface) -> GrabOwner {
+ if (down_count_ == 0) {
+ owner_ = over_surface ? GrabOwner::substrate : GrabOwner::bus;
+ }
+ ++down_count_;
+ return owner_;
+ }
+ // A button release. Returns the owner it routes to (the grab's owner). The
+ // grab ends (owner -> none) when the last button comes up.
+ auto release() -> GrabOwner {
+ const GrabOwner who = owner_;
+ if (down_count_ > 0 && --down_count_ == 0) {
+ owner_ = GrabOwner::none;
+ }
+ return who;
+ }
+ [[nodiscard]] auto owner() const -> GrabOwner { return owner_; }
+ [[nodiscard]] auto active() const -> bool { return down_count_ > 0; }
+
+private:
+ int down_count_ = 0;
+ GrabOwner owner_ = GrabOwner::none;
+};
+
+// Axis-aligned hit test in layout coordinates: is (lx,ly) inside the rect at
+// (x,y) of size w×h? Half-open on the far edges (matches scene node bounds).
+[[nodiscard]] constexpr auto point_in_rect(double lx, double ly, int x, int y, int w, int h)
+ -> bool {
+ return lx >= x && ly >= y && lx < static_cast<double>(x) + w &&
+ ly < static_cast<double>(y) + h;
+}
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_spike.cpp b/packages/kernel/src/ui_spike.cpp
deleted file mode 100644
index 5d18c07..0000000
--- a/packages/kernel/src/ui_spike.cpp
+++ /dev/null
@@ -1,688 +0,0 @@
-#include "ui_spike.hpp"
-
-#include "rmlui_renderer_gl3.h"
-
-#include <RmlUi/Core/Context.h>
-#include <RmlUi/Core/Core.h>
-#include <RmlUi/Core/DataModelHandle.h>
-#include <RmlUi/Core/ElementDocument.h>
-#include <RmlUi/Core/SystemInterface.h>
-
-// The kernel owns GL; system EGL/GLES headers are allowed here (brief).
-// wlr.hpp (via ui_spike.hpp) already pulled <EGL/egl.h>+<EGL/eglext.h>
-// through wlr/render/egl.h, and GLES through the adapted renderer; we add
-// the dmabuf import entrypoints explicitly.
-#include <EGL/egl.h>
-#include <EGL/eglext.h>
-#include <GLES3/gl32.h>
-#include <GLES2/gl2ext.h> // glEGLImageTargetTexture2DOES
-
-#include <cstdint>
-#include <cstdlib>
-#include <cstring>
-#include <ctime>
-#include <vector>
-
-namespace unbox::kernel {
-
-namespace {
-
-constexpr int kSpikeWidth = 320;
-constexpr int kSpikeHeight = 200;
-constexpr int kSpikeX = 40; // layout-space origin of the node
-constexpr int kSpikeY = 40;
-
-// DRM FourCC for the buffers we allocate / wrap. ARGB8888 is universally
-// render+sample-able and matches RMLUi's premultiplied RGBA8 output once
-// channel order is accounted for. (FourCC AR24 = little-endian B,G,R,A.)
-constexpr std::uint32_t kDrmFormatArgb8888 = 0x34325241; // 'AR24'
-
-// Distinctive solid bands at the document's top and bottom edges. They are
-// full-width, unique colors that appear NOWHERE else in the document, so the
-// orientation assertion can prove the submitted buffer is upright: the top
-// band must land in the TOP rows of the buffer, the bottom band in the
-// BOTTOM rows. (A vertical flip would swap them — the bug.)
-constexpr int kBandHeight = 12; // px, each band
-// Top band #18e0a0 (teal-green); bottom band #e09018 (amber). Stored as the
-// RGB byte triplets the Plan-B readback produces (R,G,B order).
-constexpr std::uint8_t kTopBandRGB[3] = {0x18, 0xe0, 0xa0};
-constexpr std::uint8_t kBottomBandRGB[3] = {0xe0, 0x90, 0x18};
-
-// In-memory hello-world document. Distinctive top/bottom bands (orientation
-// proof), a title, a live frame counter via data binding, and a button that
-// reacts to hover/click (input proof).
-const char* kHelloRml = R"RML(<rml>
-<head>
-<style>
-body { font-family: "Noto Sans"; background: #1e2230; color: #e8ecff;
- width: 320px; height: 200px; }
-#topband { display: block; width: 320px; height: 12px; background: #18e0a0; }
-#bottomband { display: block; width: 320px; height: 12px; background: #e09018;
- position: absolute; bottom: 0px; left: 0px; }
-h1 { font-size: 22px; margin: 16px; color: #9ecbff; }
-p { font-size: 15px; margin: 0 16px 12px 16px; }
-button { font-size: 15px; margin: 16px; padding: 8px 16px;
- background: #3a4670; color: #ffffff; border-radius: 6px; }
-button:hover { background: #5468b0; }
-button:active { background: #7e93e0; }
-</style>
-</head>
-<body data-model="spike">
-<div id="topband"></div>
-<h1>unbox ui spike</h1>
-<p>frame {{frame}}</p>
-<button>{{label}}</button>
-<div id="bottomband"></div>
-</body>
-</rml>)RML";
-
-// --- SystemInterface: elapsed time + route RmlUi logs to wlr_log ----------
-
-class SpikeSystemInterface final : public Rml::SystemInterface {
-public:
- auto GetElapsedTime() -> double override {
- timespec now{};
- clock_gettime(CLOCK_MONOTONIC, &now);
- if (start_ == 0.0) {
- start_ = static_cast<double>(now.tv_sec) + now.tv_nsec / 1e9;
- }
- return (static_cast<double>(now.tv_sec) + now.tv_nsec / 1e9) - start_;
- }
-
- auto LogMessage(Rml::Log::Type type, const Rml::String& message) -> bool override {
- const wlr_log_importance imp = (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT)
- ? WLR_ERROR
- : (type == Rml::Log::LT_WARNING ? WLR_INFO : WLR_DEBUG);
- wlr_log(imp, "[rmlui] %s", message.c_str());
- return true;
- }
-
-private:
- double start_ = 0.0;
-};
-
-// --- A data-ptr wlr_buffer wrapping heap memory (Plan B target) -----------
-//
-// The wlr GLES2 renderer can sample a WLR_BUFFER_CAP_DATA_PTR buffer (it
-// uploads via begin/end_data_ptr_access). Works on both the headless/pixman
-// and GPU/gles2 backends, which is why this is the robust spike landing.
-
-struct ShmBuffer {
- wlr_buffer base{};
- std::vector<std::uint8_t> data;
- std::uint32_t format = kDrmFormatArgb8888;
- std::size_t stride = 0;
- bool dropped = false;
-};
-
-void shm_buffer_destroy(wlr_buffer* wlr_buf) {
- auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
- wlr_buffer_finish(&buf->base);
- delete buf;
-}
-
-auto shm_buffer_begin_data_ptr_access(wlr_buffer* wlr_buf, std::uint32_t /*flags*/, void** data,
- std::uint32_t* format, std::size_t* stride) -> bool {
- auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
- *data = buf->data.data();
- *format = buf->format;
- *stride = buf->stride;
- return true;
-}
-
-void shm_buffer_end_data_ptr_access(wlr_buffer* /*wlr_buf*/) {}
-
-const wlr_buffer_impl kShmBufferImpl = {
- .destroy = shm_buffer_destroy,
- .get_dmabuf = nullptr,
- .get_shm = nullptr,
- .begin_data_ptr_access = shm_buffer_begin_data_ptr_access,
- .end_data_ptr_access = shm_buffer_end_data_ptr_access,
-};
-
-auto make_shm_buffer(int width, int height) -> ShmBuffer* {
- auto* buf = new ShmBuffer();
- buf->stride = static_cast<std::size_t>(width) * 4;
- buf->data.assign(buf->stride * static_cast<std::size_t>(height), 0);
- wlr_buffer_init(&buf->base, &kShmBufferImpl, width, height);
- return buf;
-}
-
-} // namespace
-
-// --- Impl -----------------------------------------------------------------
-
-struct UiSpike::Impl {
- EGLDisplay egl_display = EGL_NO_DISPLAY;
- EGLContext egl_context = EGL_NO_CONTEXT;
- EGLContext saved_context = EGL_NO_CONTEXT;
- EGLSurface saved_draw = EGL_NO_SURFACE;
- EGLSurface saved_read = EGL_NO_SURFACE;
-
- wlr_allocator* allocator = nullptr; // borrowed (server-owned)
- wlr_renderer* renderer = nullptr; // borrowed (server-owned)
-
- // Sibling-context GL objects.
- GLuint fbo = 0;
- GLuint color_tex = 0;
-
- // Plan A (dmabuf) state — populated only if A engages.
- wlr_buffer* dmabuf = nullptr; // the swapchain-acquired render target
- EGLImageKHR egl_image = EGL_NO_IMAGE_KHR;
-
- // Plan B (shm copy) state.
- ShmBuffer* shm = nullptr;
- std::vector<std::uint8_t> readback; // glReadPixels scratch
-
- // RMLUi.
- std::unique_ptr<SpikeSystemInterface> system;
- std::unique_ptr<RenderInterface_GL3> render_iface;
- Rml::Context* context = nullptr; // owned by Rml (RemoveContext)
- Rml::ElementDocument* document = nullptr;
- Rml::DataModelHandle model;
-
- // Data-bound document state.
- int frame = 0;
- Rml::String label = "hover me";
-
- // Scene.
- wlr_scene_buffer* scene_buffer = nullptr;
-
- Plan plan = Plan::Disabled;
- int frame_count = 0;
-
- // EGL extension entrypoints (loaded once).
- PFNEGLCREATEIMAGEKHRPROC egl_create_image = nullptr;
- PFNEGLDESTROYIMAGEKHRPROC egl_destroy_image = nullptr;
- PFNGLEGLIMAGETARGETTEXTURE2DOESPROC gl_image_target_texture = nullptr;
-
- bool make_current() {
- saved_context = eglGetCurrentContext();
- saved_draw = eglGetCurrentSurface(EGL_DRAW);
- saved_read = eglGetCurrentSurface(EGL_READ);
- return eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context) == EGL_TRUE;
- }
-
- void restore_current() {
- eglMakeCurrent(egl_display, saved_draw, saved_read, saved_context);
- }
-
- bool init(wlr_scene_tree* parent, EGLDisplay display, wlr_allocator* alloc,
- wlr_renderer* rend);
- bool try_plan_a();
- void setup_plan_b();
- void render_locked();
- void teardown();
-};
-
-bool UiSpike::Impl::init(wlr_scene_tree* parent, EGLDisplay display, wlr_allocator* alloc,
- wlr_renderer* rend) {
- egl_display = display;
- allocator = alloc;
- renderer = rend;
-
- // 1. Sibling GLES 3.2 context sharing the wlr EGLDisplay. No GL object
- // sharing — buffers cross via dmabuf/EGLImage or CPU copy only, so we
- // do NOT pass the wlr context as share_context.
- if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
- wlr_log(WLR_ERROR, "ui-spike: eglBindAPI(ES) failed");
- return false;
- }
- const EGLint config_attribs[] = {
- EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
- EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
- EGL_NONE,
- };
- EGLConfig config = nullptr;
- EGLint num_config = 0;
- if (eglChooseConfig(egl_display, config_attribs, &config, 1, &num_config) != EGL_TRUE ||
- num_config < 1) {
- wlr_log(WLR_ERROR, "ui-spike: eglChooseConfig found no ES3 config");
- return false;
- }
- const EGLint ctx_attribs[] = {
- EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 2, EGL_NONE,
- };
- egl_context = eglCreateContext(egl_display, config, EGL_NO_CONTEXT, ctx_attribs);
- if (egl_context == EGL_NO_CONTEXT) {
- wlr_log(WLR_ERROR, "ui-spike: eglCreateContext(ES 3.2) failed (0x%x)", eglGetError());
- return false;
- }
-
- if (!make_current()) {
- wlr_log(WLR_ERROR, "ui-spike: eglMakeCurrent (surfaceless) failed (0x%x)", eglGetError());
- restore_current();
- return false;
- }
-
- // Load EGLImage entrypoints for the Plan-A attempt.
- egl_create_image =
- reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(eglGetProcAddress("eglCreateImageKHR"));
- egl_destroy_image =
- reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(eglGetProcAddress("eglDestroyImageKHR"));
- gl_image_target_texture = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
- eglGetProcAddress("glEGLImageTargetTexture2DOES"));
-
- // 2. RMLUi render interface (our GLES3-adapted GL3 backend).
- Rml::String gl_msg;
- if (!RmlGL3::Initialize(&gl_msg)) {
- wlr_log(WLR_ERROR, "ui-spike: RmlGL3::Initialize failed");
- restore_current();
- return false;
- }
- wlr_log(WLR_INFO, "ui-spike: %s", gl_msg.c_str());
-
- render_iface = std::make_unique<RenderInterface_GL3>();
- if (!*render_iface) {
- wlr_log(WLR_ERROR, "ui-spike: RenderInterface_GL3 construction failed");
- restore_current();
- return false;
- }
- render_iface->SetViewport(kSpikeWidth, kSpikeHeight);
-
- // 3. Offscreen FBO + color target. Plan A first, Plan B on any failure.
- glGenFramebuffers(1, &fbo);
- if (!try_plan_a()) {
- setup_plan_b();
- }
- // flip_y: the FBO color attachment (dmabuf in Plan A, GL texture read
- // back in Plan B) is sampled/scanned-out row 0 = top, but GL renders with
- // a bottom-left origin. Flip the final composite so the submitted buffer
- // is upright; display then matches document coords, so pointer input is
- // forwarded unflipped (on-screen button == document button).
- render_iface->SetOutputFramebuffer(fbo, /*flip_y=*/true);
-
- // Verify the FBO is complete before committing to RMLUi init.
- glBindFramebuffer(GL_FRAMEBUFFER, fbo);
- const GLenum fb_status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
- glBindFramebuffer(GL_FRAMEBUFFER, 0);
- if (fb_status != GL_FRAMEBUFFER_COMPLETE) {
- wlr_log(WLR_ERROR, "ui-spike: output FBO incomplete (0x%x)", fb_status);
- restore_current();
- return false;
- }
-
- // 4. RMLUi core + font + context + document.
- system = std::make_unique<SpikeSystemInterface>();
- Rml::SetSystemInterface(system.get());
- Rml::SetRenderInterface(render_iface.get());
- if (!Rml::Initialise()) {
- wlr_log(WLR_ERROR, "ui-spike: Rml::Initialise failed");
- restore_current();
- return false;
- }
-
- if (!Rml::LoadFontFace("/usr/share/fonts/noto/NotoSans-Regular.ttf")) {
- wlr_log(WLR_INFO, "ui-spike: NotoSans not found; disabling spike gracefully");
- Rml::Shutdown();
- restore_current();
- return false;
- }
-
- context = Rml::CreateContext("spike", Rml::Vector2i(kSpikeWidth, kSpikeHeight));
- if (context == nullptr) {
- wlr_log(WLR_ERROR, "ui-spike: CreateContext failed");
- Rml::Shutdown();
- restore_current();
- return false;
- }
-
- if (Rml::DataModelConstructor ctor = context->CreateDataModel("spike")) {
- ctor.Bind("frame", &frame);
- ctor.Bind("label", &label);
- model = ctor.GetModelHandle();
- }
-
- document = context->LoadDocumentFromMemory(kHelloRml);
- if (document == nullptr) {
- wlr_log(WLR_ERROR, "ui-spike: LoadDocumentFromMemory failed");
- Rml::Shutdown();
- restore_current();
- return false;
- }
- document->Show();
-
- // 5. Scene node. Start with a transparent/empty buffer; tick() fills it.
- scene_buffer = wlr_scene_buffer_create(parent, nullptr);
- if (scene_buffer == nullptr) {
- wlr_log(WLR_ERROR, "ui-spike: wlr_scene_buffer_create failed");
- Rml::Shutdown();
- restore_current();
- return false;
- }
- wlr_scene_node_set_position(&scene_buffer->node, kSpikeX, kSpikeY);
-
- restore_current();
- wlr_log(WLR_INFO, "ui-spike: bridge up (plan %s, %dx%d)",
- plan == Plan::Dmabuf ? "A/dmabuf" : "B/shm-copy", kSpikeWidth, kSpikeHeight);
- return true;
-}
-
-// Plan A: allocate a dmabuf wlr_buffer via the server allocator, import it
-// into the sibling context as an EGLImage, bind that as the FBO color
-// attachment. Returns false (cleaning up) on any failure so init() falls to B.
-bool UiSpike::Impl::try_plan_a() {
- // Spike instrumentation: force the Plan-B fallback for testing the CPU
- // copy path even on hardware where Plan A works. Harmless in production.
- if (std::getenv("UNBOX_UI_SPIKE_FORCE_SHM") != nullptr) {
- wlr_log(WLR_INFO, "ui-spike: plan A skipped — UNBOX_UI_SPIKE_FORCE_SHM set");
- return false;
- }
- if ((allocator->buffer_caps & WLR_BUFFER_CAP_DMABUF) == 0) {
- wlr_log(WLR_INFO, "ui-spike: plan A skipped — allocator has no DMABUF cap");
- return false;
- }
- if (egl_create_image == nullptr || gl_image_target_texture == nullptr) {
- wlr_log(WLR_INFO, "ui-spike: plan A skipped — no EGLImage dmabuf-import entrypoints");
- return false;
- }
- const char* exts = eglQueryString(egl_display, EGL_EXTENSIONS);
- if (exts == nullptr || std::strstr(exts, "EGL_EXT_image_dma_buf_import") == nullptr) {
- wlr_log(WLR_INFO, "ui-spike: plan A skipped — no EGL_EXT_image_dma_buf_import");
- return false;
- }
-
- // Allocate one dmabuf via the allocator using a LINEAR/INVALID modifier
- // list (legacy-driver-safe; crocus is fine with linear).
- wlr_drm_format fmt{};
- fmt.format = kDrmFormatArgb8888;
- const std::uint64_t modifiers[] = {0 /* DRM_FORMAT_MOD_LINEAR */};
- fmt.len = 1;
- fmt.capacity = 1;
- fmt.modifiers = const_cast<std::uint64_t*>(modifiers);
-
- wlr_buffer* buf = wlr_allocator_create_buffer(allocator, kSpikeWidth, kSpikeHeight, &fmt);
- if (buf == nullptr) {
- wlr_log(WLR_INFO, "ui-spike: plan A — allocator could not create dmabuf");
- return false;
- }
-
- wlr_dmabuf_attributes attribs{};
- if (!wlr_buffer_get_dmabuf(buf, &attribs) || attribs.n_planes < 1) {
- wlr_log(WLR_INFO, "ui-spike: plan A — buffer has no dmabuf attrs");
- wlr_buffer_drop(buf);
- return false;
- }
-
- // Build the EGLImage from the dmabuf (single-plane fast path).
- EGLint img_attribs[] = {
- 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,
- };
- egl_image = egl_create_image(egl_display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT,
- static_cast<EGLClientBuffer>(nullptr), img_attribs);
- if (egl_image == EGL_NO_IMAGE_KHR) {
- wlr_log(WLR_INFO, "ui-spike: plan A — eglCreateImageKHR failed (0x%x)", eglGetError());
- wlr_buffer_drop(buf);
- return false;
- }
-
- glGenTextures(1, &color_tex);
- glBindTexture(GL_TEXTURE_2D, color_tex);
- gl_image_target_texture(GL_TEXTURE_2D, static_cast<GLeglImageOES>(egl_image));
- glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
- glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
-
- glBindFramebuffer(GL_FRAMEBUFFER, fbo);
- glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0);
- const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
- glBindFramebuffer(GL_FRAMEBUFFER, 0);
- if (status != GL_FRAMEBUFFER_COMPLETE) {
- wlr_log(WLR_INFO, "ui-spike: plan A — FBO from EGLImage incomplete (0x%x)", status);
- egl_destroy_image(egl_display, egl_image);
- egl_image = EGL_NO_IMAGE_KHR;
- glDeleteTextures(1, &color_tex);
- color_tex = 0;
- wlr_buffer_drop(buf);
- return false;
- }
-
- dmabuf = buf;
- plan = Plan::Dmabuf;
- wlr_log(WLR_INFO, "ui-spike: plan A engaged (dmabuf-backed FBO)");
- return true;
-}
-
-// Plan B: a plain GL texture color attachment; results read back to a
-// data-ptr wlr_buffer with glReadPixels each frame.
-void UiSpike::Impl::setup_plan_b() {
- glGenTextures(1, &color_tex);
- glBindTexture(GL_TEXTURE_2D, color_tex);
- glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kSpikeWidth, kSpikeHeight, 0, GL_RGBA,
- GL_UNSIGNED_BYTE, nullptr);
- glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
- glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
- glBindFramebuffer(GL_FRAMEBUFFER, fbo);
- glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0);
- glBindFramebuffer(GL_FRAMEBUFFER, 0);
-
- shm = make_shm_buffer(kSpikeWidth, kSpikeHeight);
- readback.assign(static_cast<std::size_t>(kSpikeWidth) * kSpikeHeight * 4, 0);
- plan = Plan::ShmCopy;
- wlr_log(WLR_INFO, "ui-spike: plan B engaged (FBO + glReadPixels -> shm)");
-}
-
-// Render one dirty frame. Caller holds the sibling context current.
-void UiSpike::Impl::render_locked() {
- // Tick the bound state; dirtying drives the repeated render proof.
- frame += 1;
- if (model) {
- model.DirtyVariable("frame");
- }
-
- context->Update();
- render_iface->BeginFrame();
- render_iface->Clear();
- context->Render();
- render_iface->EndFrame(); // composites into `fbo` (SetOutputFramebuffer)
-
- if (plan == Plan::Dmabuf) {
- // The wlr renderer will sample the dmabuf directly; ensure all GL
- // writes have landed before the compositor reads it. A spike uses
- // glFinish; the real substrate will use an EGL fence.
- glFinish();
- wlr_scene_buffer_set_buffer(scene_buffer, dmabuf);
- } else {
- // Plan B: read the FBO back into the data-ptr buffer.
- glBindFramebuffer(GL_FRAMEBUFFER, fbo);
- glReadPixels(0, 0, kSpikeWidth, kSpikeHeight, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
- glBindFramebuffer(GL_FRAMEBUFFER, 0);
-
- // RMLUi outputs premultiplied RGBA8 (R,G,B,A byte order). The shm
- // buffer is FourCC AR24 = little-endian {B,G,R,A}. Swap R<->B; the
- // result is already premultiplied which wlroots expects.
- const std::size_t px = static_cast<std::size_t>(kSpikeWidth) * kSpikeHeight;
- std::uint8_t* dst = shm->data.data();
- const std::uint8_t* src = readback.data();
- for (std::size_t i = 0; i < px; ++i) {
- dst[i * 4 + 0] = src[i * 4 + 2]; // B
- dst[i * 4 + 1] = src[i * 4 + 1]; // G
- dst[i * 4 + 2] = src[i * 4 + 0]; // R
- dst[i * 4 + 3] = src[i * 4 + 3]; // A
- }
- wlr_scene_buffer_set_buffer(scene_buffer, &shm->base);
- }
-
- frame_count += 1;
-}
-
-void UiSpike::Impl::teardown() {
- // RMLUi teardown needs the sibling context current (GL deletes).
- const bool ok = make_current();
-
- if (scene_buffer != nullptr) {
- wlr_scene_node_destroy(&scene_buffer->node);
- scene_buffer = nullptr;
- }
-
- if (context != nullptr) {
- // Document is owned by the context; Shutdown tears everything down.
- Rml::Shutdown();
- context = nullptr;
- document = nullptr;
- }
- render_iface.reset();
-
- if (color_tex != 0) {
- glDeleteTextures(1, &color_tex);
- color_tex = 0;
- }
- if (fbo != 0) {
- glDeleteFramebuffers(1, &fbo);
- fbo = 0;
- }
- if (egl_image != EGL_NO_IMAGE_KHR && egl_destroy_image != nullptr) {
- egl_destroy_image(egl_display, egl_image);
- egl_image = EGL_NO_IMAGE_KHR;
- }
- if (dmabuf != nullptr) {
- wlr_buffer_drop(dmabuf);
- dmabuf = nullptr;
- }
- if (shm != nullptr) {
- wlr_buffer_drop(&shm->base); // triggers shm_buffer_destroy -> delete
- shm = nullptr;
- }
-
- if (ok) {
- restore_current();
- }
- if (egl_context != EGL_NO_CONTEXT) {
- eglDestroyContext(egl_display, egl_context);
- egl_context = EGL_NO_CONTEXT;
- }
-}
-
-// --- UiSpike (public-ish private surface) ---------------------------------
-
-auto UiSpike::create(wlr_scene_tree* parent, EGLDisplay egl_display, wlr_allocator* allocator,
- wlr_renderer* renderer) -> std::unique_ptr<UiSpike> {
- auto impl = std::make_unique<Impl>();
- if (!impl->init(parent, egl_display, allocator, renderer)) {
- impl->teardown(); // safe to call after partial init
- // Hand back a Disabled bridge (never throws, never aborts the server).
- auto disabled = std::make_unique<Impl>();
- disabled->plan = Plan::Disabled;
- return std::unique_ptr<UiSpike>(new UiSpike(std::move(disabled)));
- }
- return std::unique_ptr<UiSpike>(new UiSpike(std::move(impl)));
-}
-
-UiSpike::UiSpike(std::unique_ptr<Impl> impl) : impl_(std::move(impl)) {}
-
-UiSpike::~UiSpike() {
- if (impl_->plan != Plan::Disabled) {
- impl_->teardown();
- }
-}
-
-void UiSpike::tick() {
- if (impl_->plan == Plan::Disabled) {
- return;
- }
- // Render every tick (spike fidelity: the frame counter dirties the doc
- // each call, so the context is always dirty — proving repeated cycles).
- if (!impl_->make_current()) {
- return;
- }
- impl_->render_locked();
- impl_->restore_current();
-}
-
-void UiSpike::on_pointer_motion(double sx, double sy) {
- if (impl_->plan == Plan::Disabled || impl_->context == nullptr) {
- return;
- }
- impl_->context->ProcessMouseMove(static_cast<int>(sx), static_cast<int>(sy), 0);
-}
-
-void UiSpike::on_pointer_button(bool pressed) {
- if (impl_->plan == Plan::Disabled || impl_->context == nullptr) {
- return;
- }
- if (pressed) {
- impl_->context->ProcessMouseButtonDown(0, 0);
- } else {
- impl_->context->ProcessMouseButtonUp(0, 0);
- }
-}
-
-auto UiSpike::node() const -> wlr_scene_node* {
- return impl_->scene_buffer != nullptr ? &impl_->scene_buffer->node : nullptr;
-}
-
-auto UiSpike::plan() const -> Plan {
- return impl_->plan;
-}
-
-auto UiSpike::frame_count() const -> int {
- return impl_->frame_count;
-}
-
-auto UiSpike::check_orientation() const -> int {
- // Only the shm path keeps a CPU readback to inspect, and only after a
- // frame has been submitted.
- if (impl_->plan != Plan::ShmCopy || impl_->frame_count == 0) {
- return 0;
- }
- const std::uint8_t* px = impl_->readback.data(); // R,G,B,A, row 0 = top
- const int w = kSpikeWidth;
- const int h = kSpikeHeight;
-
- auto matches = [](const std::uint8_t* p, const std::uint8_t (&c)[3]) {
- const int dr = static_cast<int>(p[0]) - c[0];
- const int dg = static_cast<int>(p[1]) - c[1];
- const int db = static_cast<int>(p[2]) - c[2];
- return dr * dr + dg * dg + db * db < 24 * 24; // tolerant of AA edges
- };
-
- // Count band pixels in the top kBandHeight rows vs the bottom kBandHeight
- // rows, sampling the full width. Upright => top band dominates the top
- // rows and bottom band the bottom rows; a flip swaps them.
- int top_band_in_top = 0;
- int top_band_in_bottom = 0;
- int bottom_band_in_top = 0;
- int bottom_band_in_bottom = 0;
- for (int row = 0; row < kBandHeight; ++row) {
- const int top_row = row;
- const int bot_row = h - 1 - row;
- for (int x = 0; x < w; ++x) {
- const std::uint8_t* pt = px + (static_cast<std::size_t>(top_row) * w + x) * 4;
- const std::uint8_t* pb = px + (static_cast<std::size_t>(bot_row) * w + x) * 4;
- if (matches(pt, kTopBandRGB)) {
- ++top_band_in_top;
- }
- if (matches(pb, kTopBandRGB)) {
- ++top_band_in_bottom;
- }
- if (matches(pt, kBottomBandRGB)) {
- ++bottom_band_in_top;
- }
- if (matches(pb, kBottomBandRGB)) {
- ++bottom_band_in_bottom;
- }
- }
- }
-
- // Need a clear, unambiguous signal in one orientation.
- const bool upright = top_band_in_top > 100 && bottom_band_in_bottom > 100 &&
- top_band_in_top > top_band_in_bottom &&
- bottom_band_in_bottom > bottom_band_in_top;
- const bool flipped = top_band_in_bottom > 100 && bottom_band_in_top > 100 &&
- top_band_in_bottom > top_band_in_top &&
- bottom_band_in_top > bottom_band_in_bottom;
- if (upright) {
- return 1;
- }
- if (flipped) {
- return -1;
- }
- return 0;
-}
-
-} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_spike.hpp b/packages/kernel/src/ui_spike.hpp
deleted file mode 100644
index 4fdca0b..0000000
--- a/packages/kernel/src/ui_spike.hpp
+++ /dev/null
@@ -1,77 +0,0 @@
-#pragma once
-
-#include <unbox/kernel/wlr.hpp>
-
-#include <memory>
-
-// Slice-3 spike: the RMLUi -> wlr_scene bridge (prompts/kernel.md, plan §4).
-// PRIVATE to the kernel; nothing here is a contract. Replaced wholesale by
-// the real ui-substrate contract in slice 4+.
-//
-// A UiSpike owns a sibling GLES 3.2 EGL context (sharing the wlr renderer's
-// EGLDisplay), an offscreen FBO into a wlr_buffer, an RMLUi context rendering
-// a hello-world document, and a wlr_scene_buffer node showing it. It renders
-// only when the RMLUi context is dirty, driven from an output frame handler.
-//
-// Everything runs on the single wl_event_loop thread.
-
-namespace unbox::kernel {
-
-class UiSpike {
-public:
- // Which compositing plan the bridge landed on (plan §4 / brief A->B->C).
- enum class Plan {
- Disabled, // could not start (no font / no GL); server runs as slice-2
- Dmabuf, // Plan A: dmabuf-backed wlr_buffer imported as EGLImage FBO
- ShmCopy, // Plan B: FBO + glReadPixels into a data-ptr wlr_buffer
- };
-
- // Builds the bridge and attaches a scene node under `parent`. `egl_display`
- // is the wlr renderer's EGLDisplay (wlr_egl_get_display); the sibling
- // context shares it. `allocator`/`renderer` are borrowed for the buffer
- // lifetime of the spike (owned by the server). Never throws: on any
- // failure it logs and yields a Disabled bridge (frame_count stays 0).
- static auto create(wlr_scene_tree* parent, EGLDisplay egl_display,
- wlr_allocator* allocator, wlr_renderer* renderer)
- -> std::unique_ptr<UiSpike>;
-
- ~UiSpike();
- UiSpike(const UiSpike&) = delete;
- auto operator=(const UiSpike&) -> UiSpike& = delete;
-
- // Advance + render one frame if the RMLUi context is dirty (ticks the
- // bound frame counter, which dirties the document every call at spike
- // fidelity). Submits to the scene with damage. No-op when Disabled.
- void tick();
-
- // Crude input proof (NOT the slice-5 routing contract). Coords are
- // surface-local pixels within the spike node. Forwarded straight to the
- // RMLUi context; a hover/click makes the document's button react.
- void on_pointer_motion(double sx, double sy);
- void on_pointer_button(bool pressed);
-
- // The scene node's position/size, so the server can hit-test pointer
- // events against it. Layout coords; node sits at a fixed origin.
- [[nodiscard]] auto node() const -> wlr_scene_node*;
-
- [[nodiscard]] auto plan() const -> Plan;
- [[nodiscard]] auto frame_count() const -> int;
-
- // Orientation self-check on the submitted buffer (Plan B / shm path only,
- // where the CPU readback exists). The document carries distinctive solid
- // bands at its top and bottom edges; this samples the buffer and returns:
- // +1 upright: top band is in the TOP rows, bottom band in the bottom
- // -1 flipped: bands are swapped (the bug this fix prevents)
- // 0 indeterminate: not the shm path, or no frame rendered yet, or the
- // bands were not found (e.g. spike disabled)
- // Lets a headless test assert orientation can never silently regress.
- [[nodiscard]] auto check_orientation() const -> int;
-
- struct Impl;
-
-private:
- explicit UiSpike(std::unique_ptr<Impl> impl);
- std::unique_ptr<Impl> impl_;
-};
-
-} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
new file mode 100644
index 0000000..91029f4
--- /dev/null
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -0,0 +1,1097 @@
+#include "ui_substrate.hpp"
+
+#include "rmlui_renderer_gl3.h"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/DataModelHandle.h>
+#include <RmlUi/Core/Element.h>
+#include <RmlUi/Core/ElementDocument.h>
+#include <RmlUi/Core/SystemInterface.h>
+
+// The kernel owns GL; system EGL/GLES headers are allowed here (same as the
+// retired spike). wlr.hpp already pulled <EGL/egl.h>+<EGL/eglext.h> via
+// wlr/render/egl.h and GLES via the adapted renderer.
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GLES2/gl2ext.h> // glEGLImageTargetTexture2DOES
+#include <GLES3/gl32.h>
+
+#include <cstdint>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <list>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace unbox::kernel {
+
+namespace {
+
+constexpr std::uint32_t kDrmFormatArgb8888 = 0x34325241; // 'AR24' = LE {B,G,R,A}
+
+// Orientation regression guard (kept from the spike): the test fixture document
+// carries full-width solid bands at top (#18e0a0) and bottom (#e09018). The
+// substrate's orientation() samples a shm-path surface's submitted buffer and
+// proves the top band lands in the TOP rows (upright) — GL's bottom-left FBO
+// origin vs wlr_buffer top-first convention makes a flip the default failure.
+constexpr int kBandHeight = 12;
+constexpr std::uint8_t kTopBandRGB[3] = {0x18, 0xe0, 0xa0};
+constexpr std::uint8_t kBottomBandRGB[3] = {0xe0, 0x90, 0x18};
+
+// --- SystemInterface: elapsed time + route RmlUi logs to wlr_log ----------
+class SubstrateSystemInterface final : public Rml::SystemInterface {
+public:
+ auto GetElapsedTime() -> double override {
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ const double t = static_cast<double>(now.tv_sec) + now.tv_nsec / 1e9;
+ if (start_ == 0.0) {
+ start_ = t;
+ }
+ return t - start_;
+ }
+ auto LogMessage(Rml::Log::Type type, const Rml::String& message) -> bool override {
+ const wlr_log_importance imp =
+ (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT) ? WLR_ERROR
+ : (type == Rml::Log::LT_WARNING ? WLR_INFO : WLR_DEBUG);
+ wlr_log(imp, "[rmlui] %s", message.c_str());
+ return true;
+ }
+
+private:
+ double start_ = 0.0;
+};
+
+// --- A data-ptr wlr_buffer wrapping heap memory (Plan B target) -----------
+struct ShmBuffer {
+ wlr_buffer base{};
+ std::vector<std::uint8_t> data;
+ std::uint32_t format = kDrmFormatArgb8888;
+ std::size_t stride = 0;
+};
+
+void shm_buffer_destroy(wlr_buffer* wlr_buf) {
+ auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
+ wlr_buffer_finish(&buf->base);
+ delete buf;
+}
+auto shm_buffer_begin_data_ptr_access(wlr_buffer* wlr_buf, std::uint32_t /*flags*/, void** data,
+ std::uint32_t* format, std::size_t* stride) -> bool {
+ auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
+ *data = buf->data.data();
+ *format = buf->format;
+ *stride = buf->stride;
+ return true;
+}
+void shm_buffer_end_data_ptr_access(wlr_buffer* /*wlr_buf*/) {}
+
+const wlr_buffer_impl kShmBufferImpl = {
+ .destroy = shm_buffer_destroy,
+ .get_dmabuf = nullptr,
+ .get_shm = nullptr,
+ .begin_data_ptr_access = shm_buffer_begin_data_ptr_access,
+ .end_data_ptr_access = shm_buffer_end_data_ptr_access,
+};
+
+auto make_shm_buffer(int width, int height) -> ShmBuffer* {
+ auto* buf = new ShmBuffer();
+ buf->stride = static_cast<std::size_t>(width) * 4;
+ buf->data.assign(buf->stride * static_cast<std::size_t>(height), 0);
+ wlr_buffer_init(&buf->base, &kShmBufferImpl, width, height);
+ return buf;
+}
+
+} // namespace
+
+// ---- GL bridge (shared sibling context) -------------------------------------
+//
+// One EGL context + Rml::Initialise + font shared by all surfaces. Owns the EGL
+// extension entrypoints (image import for Plan A, fence sync for production
+// submission) and the current-context save/restore around every GL section.
+
+struct GlBridge {
+ EGLDisplay egl_display = EGL_NO_DISPLAY;
+ EGLContext egl_context = EGL_NO_CONTEXT;
+ EGLConfig config = nullptr;
+
+ EGLContext saved_context = EGL_NO_CONTEXT;
+ EGLSurface saved_draw = EGL_NO_SURFACE;
+ EGLSurface saved_read = EGL_NO_SURFACE;
+
+ std::unique_ptr<SubstrateSystemInterface> system;
+ std::unique_ptr<RenderInterface_GL3> render_iface;
+ bool rml_initialised = false;
+ bool ok = false;
+
+ bool dmabuf_import_ok = false; // Plan A preconditions met
+ bool fence_ok = false; // EGL_KHR_fence_sync usable
+
+ PFNEGLCREATEIMAGEKHRPROC egl_create_image = nullptr;
+ PFNEGLDESTROYIMAGEKHRPROC egl_destroy_image = nullptr;
+ PFNGLEGLIMAGETARGETTEXTURE2DOESPROC gl_image_target_texture = nullptr;
+ PFNEGLCREATESYNCKHRPROC egl_create_sync = nullptr;
+ PFNEGLCLIENTWAITSYNCKHRPROC egl_client_wait_sync = nullptr;
+ PFNEGLDESTROYSYNCKHRPROC egl_destroy_sync = nullptr;
+
+ bool make_current() {
+ saved_context = eglGetCurrentContext();
+ saved_draw = eglGetCurrentSurface(EGL_DRAW);
+ saved_read = eglGetCurrentSurface(EGL_READ);
+ return eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context) == EGL_TRUE;
+ }
+ void restore_current() {
+ eglMakeCurrent(egl_display, saved_draw, saved_read, saved_context);
+ }
+
+ // Block until GL writes to the current target have completed, using an EGL
+ // fence (production sync; replaces the spike's glFinish on the hot path).
+ // Falls back to glFinish only if the fence extension is unusable.
+ void submit_sync() {
+ if (fence_ok) {
+ EGLSyncKHR sync = egl_create_sync(egl_display, EGL_SYNC_FENCE_KHR, nullptr);
+ if (sync != EGL_NO_SYNC_KHR) {
+ glFlush();
+ egl_client_wait_sync(egl_display, sync, 0, EGL_FOREVER_KHR);
+ egl_destroy_sync(egl_display, sync);
+ return;
+ }
+ }
+ glFinish();
+ }
+
+ bool init(EGLDisplay display);
+ void teardown();
+};
+
+bool GlBridge::init(EGLDisplay display) {
+ egl_display = display;
+ if (egl_display == EGL_NO_DISPLAY) {
+ return false;
+ }
+ if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
+ wlr_log(WLR_ERROR, "ui-substrate: eglBindAPI(ES) failed");
+ return false;
+ }
+ const EGLint config_attribs[] = {
+ EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
+ EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
+ EGL_NONE,
+ };
+ EGLint num_config = 0;
+ if (eglChooseConfig(egl_display, config_attribs, &config, 1, &num_config) != EGL_TRUE ||
+ num_config < 1) {
+ wlr_log(WLR_ERROR, "ui-substrate: eglChooseConfig found no ES3 config");
+ return false;
+ }
+ const EGLint ctx_attribs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 2,
+ EGL_NONE};
+ egl_context = eglCreateContext(egl_display, config, EGL_NO_CONTEXT, ctx_attribs);
+ if (egl_context == EGL_NO_CONTEXT) {
+ wlr_log(WLR_ERROR, "ui-substrate: eglCreateContext(ES 3.2) failed (0x%x)", eglGetError());
+ return false;
+ }
+ if (!make_current()) {
+ wlr_log(WLR_ERROR, "ui-substrate: surfaceless eglMakeCurrent failed (0x%x)", eglGetError());
+ restore_current();
+ return false;
+ }
+
+ egl_create_image =
+ reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(eglGetProcAddress("eglCreateImageKHR"));
+ egl_destroy_image =
+ reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(eglGetProcAddress("eglDestroyImageKHR"));
+ gl_image_target_texture = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
+ eglGetProcAddress("glEGLImageTargetTexture2DOES"));
+ const char* exts = eglQueryString(egl_display, EGL_EXTENSIONS);
+ const bool has_dmabuf_import =
+ exts != nullptr && std::strstr(exts, "EGL_EXT_image_dma_buf_import") != nullptr;
+ dmabuf_import_ok = has_dmabuf_import && egl_create_image != nullptr &&
+ gl_image_target_texture != nullptr &&
+ std::getenv("UNBOX_UI_SUBSTRATE_FORCE_SHM") == nullptr;
+
+ // EGL fence sync (production submission sync — notes/plan.md §7).
+ const bool has_fence =
+ exts != nullptr && std::strstr(exts, "EGL_KHR_fence_sync") != nullptr;
+ egl_create_sync =
+ reinterpret_cast<PFNEGLCREATESYNCKHRPROC>(eglGetProcAddress("eglCreateSyncKHR"));
+ egl_client_wait_sync =
+ reinterpret_cast<PFNEGLCLIENTWAITSYNCKHRPROC>(eglGetProcAddress("eglClientWaitSyncKHR"));
+ egl_destroy_sync =
+ reinterpret_cast<PFNEGLDESTROYSYNCKHRPROC>(eglGetProcAddress("eglDestroySyncKHR"));
+ fence_ok = has_fence && egl_create_sync != nullptr && egl_client_wait_sync != nullptr &&
+ egl_destroy_sync != nullptr;
+
+ Rml::String gl_msg;
+ if (!RmlGL3::Initialize(&gl_msg)) {
+ wlr_log(WLR_ERROR, "ui-substrate: RmlGL3::Initialize failed");
+ restore_current();
+ return false;
+ }
+ wlr_log(WLR_INFO, "ui-substrate: %s", gl_msg.c_str());
+
+ render_iface = std::make_unique<RenderInterface_GL3>();
+ if (!*render_iface) {
+ wlr_log(WLR_ERROR, "ui-substrate: RenderInterface_GL3 construction failed");
+ restore_current();
+ return false;
+ }
+
+ system = std::make_unique<SubstrateSystemInterface>();
+ Rml::SetSystemInterface(system.get());
+ Rml::SetRenderInterface(render_iface.get());
+ if (!Rml::Initialise()) {
+ wlr_log(WLR_ERROR, "ui-substrate: Rml::Initialise failed");
+ restore_current();
+ return false;
+ }
+ rml_initialised = true;
+
+ if (!Rml::LoadFontFace("/usr/share/fonts/noto/NotoSans-Regular.ttf")) {
+ wlr_log(WLR_INFO, "ui-substrate: NotoSans not found; substrate unavailable");
+ Rml::Shutdown();
+ rml_initialised = false;
+ restore_current();
+ return false;
+ }
+
+ restore_current();
+ ok = true;
+ wlr_log(WLR_INFO, "ui-substrate: up (dmabuf=%d fence=%d)", dmabuf_import_ok, fence_ok);
+ return true;
+}
+
+void GlBridge::teardown() {
+ const bool cur = (egl_context != EGL_NO_CONTEXT) && make_current();
+ if (rml_initialised) {
+ Rml::Shutdown();
+ rml_initialised = false;
+ }
+ render_iface.reset();
+ if (cur) {
+ restore_current();
+ }
+ if (egl_context != EGL_NO_CONTEXT) {
+ eglDestroyContext(egl_display, egl_context);
+ egl_context = EGL_NO_CONTEXT;
+ }
+}
+
+// ---- Surface ----------------------------------------------------------------
+
+struct Surface {
+ Substrate::Impl* owner = nullptr;
+ ExtensionId who{};
+
+ int width = 0;
+ int height = 0;
+ int x = 0;
+ int y = 0;
+ bool is_visible = true;
+
+ // Plan: dmabuf swapchain (A) or single shm buffer (B).
+ bool dmabuf = false;
+
+ // GL target.
+ GLuint fbo = 0;
+ GLuint shm_tex = 0; // Plan B color attachment
+
+ // Plan A: 2-deep swapchain + per-buffer cached EGLImage/texture.
+ wlr_swapchain* swapchain = nullptr;
+ struct SlotGl {
+ EGLImageKHR image = EGL_NO_IMAGE_KHR;
+ GLuint tex = 0;
+ };
+ std::unordered_map<wlr_buffer*, SlotGl> slot_gl;
+
+ // Plan B: one shm buffer + readback scratch.
+ ShmBuffer* shm = nullptr;
+ std::vector<std::uint8_t> readback;
+
+ // RMLUi.
+ Rml::Context* context = nullptr; // owned by Rml (RemoveContext)
+ Rml::ElementDocument* document = nullptr;
+ Rml::DataModelConstructor ctor; // open until the document loads (lazy)
+ Rml::DataModelHandle model;
+ std::string model_name;
+
+ // Deferred document source (loaded on first tick, after binds are set).
+ std::string rml_inline;
+ std::string rml_path;
+ bool doc_loaded = false;
+
+ // Data bindings. Each bound scalar pairs a getter with a stable slot the
+ // getter writes into; RmlUi binds to the slot's address. Bound BEFORE the
+ // document loads (RmlUi requires the model complete at parse time), so we
+ // use std::list for address stability across pushes.
+ template <typename T>
+ struct ScalarBinding {
+ std::function<T()> getter;
+ T slot{};
+ };
+ std::list<ScalarBinding<int>> int_bindings;
+ std::list<ScalarBinding<double>> double_bindings;
+ std::list<ScalarBinding<bool>> bool_bindings;
+ std::list<ScalarBinding<Rml::String>> string_bindings;
+ struct EventBinding {
+ std::function<void()> cb;
+ ExtensionId who;
+ Substrate::Impl* owner;
+ };
+ std::list<EventBinding> event_bindings;
+
+ // touch-mode-changed notification (one per surface; see ui.hpp). Fired on a
+ // transition, error-isolated to `who`. touch-mode does NO visual scaling
+ // (user decision) — this is purely an opt-in signal for extensions.
+ std::function<void(bool)> touch_mode_cb;
+
+ // Scene.
+ wlr_scene_buffer* scene_buffer = nullptr;
+
+ int frame_count = 0;
+};
+
+// ---- Substrate::Impl --------------------------------------------------------
+
+struct Substrate::Impl {
+ GlBridge gl;
+ wlr_allocator* allocator = nullptr;
+ wlr_renderer* renderer = nullptr;
+ SubstrateDisableFn disable;
+
+ TouchModeTracker touch_mode_tracker;
+
+ std::list<Surface> surfaces; // stable addresses (handles borrow Surface*)
+
+ // 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
+ // the substrate routes the grabbed stream to (null if a grabbed surface was
+ // destroyed mid-stream — then the substrate still CONSUMES the tail but
+ // delivers nothing, never leaking mid-grab events to the bus).
+ PointerButtonGrab pointer_grab;
+ Surface* pointer_grab_surface = nullptr;
+
+ // Touch routing: which surface a given touch id is captured by (down ->
+ // up/cancel). The down's consumer owns that point's motion/up/cancel; a
+ // down that fell through to the bus has NO entry (bus owns it). Cleared on
+ // up/cancel and on surface destruction.
+ std::unordered_map<std::int32_t, Surface*> touch_capture;
+
+ [[nodiscard]] auto available() const -> bool { return gl.ok; }
+
+ // Topmost visible surface containing (lx,ly). Surfaces are kept in
+ // creation order; later surfaces composite above earlier within a layer, so
+ // scan back-to-front. (Cross-layer correctness is the scene's job; for the
+ // input hit-test, last-created-wins matches the overlay-stacked default.)
+ auto surface_at(double lx, double ly) -> Surface* {
+ Surface* hit = nullptr;
+ for (Surface& s : surfaces) {
+ if (s.is_visible && point_in_rect(lx, ly, s.x, s.y, s.width, s.height)) {
+ hit = &s; // keep scanning: later = on top
+ }
+ }
+ return hit;
+ }
+
+ // Notify every surface that touch-mode flipped. touch-mode does NO visual
+ // scaling (user decision) — the substrate never touches the dp-ratio, so
+ // this is purely the opt-in signal. Called only on a real transition.
+ // Error-isolated per surface.
+ void notify_touch_mode_changed() {
+ const bool touch = touch_mode_tracker.is_touch();
+ for (Surface& s : surfaces) {
+ if (s.touch_mode_cb) {
+ try {
+ s.touch_mode_cb(touch);
+ } catch (...) {
+ if (disable) {
+ disable(s.who);
+ }
+ }
+ }
+ }
+ }
+
+ // Re-read every bound getter for `s` into its scratch slots + dirty the
+ // model. Getter exceptions isolate the owning extension.
+ void refresh_bindings(Surface& s);
+
+ bool init_surface_gl(Surface& s);
+ void render_surface(Surface& s); // caller holds context current
+ void destroy_surface(Surface* s);
+
+ // 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);
+ void ctx_button(Surface& s, bool pressed);
+};
+
+void Substrate::Impl::refresh_bindings(Surface& s) {
+ if (!s.model) {
+ return;
+ }
+ auto isolate = [&](auto&& fn) {
+ try {
+ fn();
+ } catch (...) {
+ if (disable) {
+ disable(s.who);
+ }
+ }
+ };
+ for (auto& b : s.int_bindings) {
+ if (b.getter) {
+ isolate([&] { b.slot = b.getter(); });
+ }
+ }
+ for (auto& b : s.double_bindings) {
+ if (b.getter) {
+ isolate([&] { b.slot = b.getter(); });
+ }
+ }
+ for (auto& b : s.bool_bindings) {
+ if (b.getter) {
+ isolate([&] { b.slot = b.getter(); });
+ }
+ }
+ for (auto& b : s.string_bindings) {
+ if (b.getter) {
+ isolate([&] { b.slot = b.getter(); });
+ }
+ }
+}
+
+bool Substrate::Impl::init_surface_gl(Surface& s) {
+ glGenFramebuffers(1, &s.fbo);
+
+ if (gl.dmabuf_import_ok && (allocator->buffer_caps & WLR_BUFFER_CAP_DMABUF) != 0) {
+ 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;
+ // 2-deep swapchain (production: double-buffer so the compositor can be
+ // sampling slot N while we render slot N+1). WLR_SWAPCHAIN_CAP caps it.
+ s.swapchain = wlr_swapchain_create(allocator, s.width, s.height, &fmt);
+ if (s.swapchain != nullptr) {
+ s.dmabuf = true;
+ }
+ }
+
+ if (!s.dmabuf) {
+ // Plan B: single GL texture color attachment, read back to a shm buffer.
+ glGenTextures(1, &s.shm_tex);
+ glBindTexture(GL_TEXTURE_2D, s.shm_tex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s.width, s.height, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glBindFramebuffer(GL_FRAMEBUFFER, s.fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, s.shm_tex, 0);
+ const GLenum st = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (st != GL_FRAMEBUFFER_COMPLETE) {
+ wlr_log(WLR_ERROR, "ui-substrate: Plan B FBO incomplete (0x%x)", st);
+ return false;
+ }
+ s.shm = make_shm_buffer(s.width, s.height);
+ s.readback.assign(static_cast<std::size_t>(s.width) * s.height * 4, 0);
+ }
+ return true;
+}
+
+void Substrate::Impl::render_surface(Surface& s) {
+ if (s.context == nullptr) {
+ return;
+ }
+ // Lazy document load on the first render: all bind_* calls have happened by
+ // now, so the data model is complete (RmlUi requires that at parse time).
+ if (!s.doc_loaded) {
+ s.doc_loaded = true;
+ s.model = s.ctor.GetModelHandle();
+ s.ctor = Rml::DataModelConstructor{}; // close the constructor
+ if (!s.rml_path.empty()) {
+ s.document = s.context->LoadDocument(s.rml_path);
+ } else {
+ s.document = s.context->LoadDocumentFromMemory(s.rml_inline);
+ }
+ if (s.document == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: failed to load document");
+ return;
+ }
+ s.document->Show();
+ }
+ refresh_bindings(s);
+ if (s.model) {
+ s.model.DirtyAllVariables();
+ }
+
+ GLuint target_fbo = s.fbo;
+ wlr_buffer* dmabuf_target = nullptr;
+
+ if (s.dmabuf) {
+ wlr_buffer* buf = wlr_swapchain_acquire(s.swapchain);
+ if (buf == nullptr) {
+ return;
+ }
+ dmabuf_target = buf;
+ // Cache an EGLImage+texture per swapchain buffer (re-import is costly).
+ auto it = s.slot_gl.find(buf);
+ if (it == s.slot_gl.end()) {
+ wlr_dmabuf_attributes attribs{};
+ if (!wlr_buffer_get_dmabuf(buf, &attribs) || attribs.n_planes < 1) {
+ wlr_buffer_unlock(buf);
+ return;
+ }
+ 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_buffer_unlock(buf);
+ return;
+ }
+ 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);
+ Surface::SlotGl slot{img, tex};
+ it = s.slot_gl.emplace(buf, slot).first;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, s.fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, it->second.tex,
+ 0);
+ if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ wlr_buffer_unlock(buf);
+ return;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ }
+
+ gl.render_iface->SetViewport(s.width, s.height);
+ // 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);
+ s.context->Update();
+ gl.render_iface->BeginFrame();
+ gl.render_iface->Clear();
+ s.context->Render();
+ gl.render_iface->EndFrame();
+
+ if (s.dmabuf) {
+ gl.submit_sync(); // EGL fence (production), not glFinish
+ wlr_scene_buffer_set_buffer(s.scene_buffer, dmabuf_target);
+ wlr_buffer_unlock(dmabuf_target); // scene_buffer took its own lock
+ } else {
+ glBindFramebuffer(GL_FRAMEBUFFER, s.fbo);
+ glReadPixels(0, 0, s.width, s.height, GL_RGBA, GL_UNSIGNED_BYTE, s.readback.data());
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ // RMLUi premultiplied RGBA8 -> FourCC AR24 {B,G,R,A}: swap R<->B.
+ const std::size_t px = static_cast<std::size_t>(s.width) * s.height;
+ std::uint8_t* dst = s.shm->data.data();
+ const std::uint8_t* src = s.readback.data();
+ for (std::size_t i = 0; i < px; ++i) {
+ dst[i * 4 + 0] = src[i * 4 + 2];
+ dst[i * 4 + 1] = src[i * 4 + 1];
+ dst[i * 4 + 2] = src[i * 4 + 0];
+ dst[i * 4 + 3] = src[i * 4 + 3];
+ }
+ wlr_scene_buffer_set_buffer(s.scene_buffer, &s.shm->base);
+ }
+ 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) {
+ if (slot.tex != 0) {
+ glDeleteTextures(1, &slot.tex);
+ }
+ if (slot.image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ 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;
+ }
+ 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->shm != nullptr) {
+ wlr_buffer_drop(&s->shm->base);
+ s->shm = nullptr;
+ }
+ if (cur) {
+ gl.restore_current();
+ }
+ // A surface dying mid-grab must not strand the input stream. Drop any
+ // capture pointing at it: the pointer grab keeps its OWNER (substrate) so
+ // the tail of the stream is still consumed (not leaked to the bus mid-grab)
+ // but routes to nothing; touch points captured by it are released — their
+ // remaining motion/up will find no capture and (correctly) reach the bus.
+ if (pointer_grab_surface == s) {
+ pointer_grab_surface = nullptr;
+ }
+ for (auto it = touch_capture.begin(); it != touch_capture.end();) {
+ it = (it->second == s) ? touch_capture.erase(it) : std::next(it);
+ }
+ // Erase from the owner list (Surface storage).
+ surfaces.remove_if([s](const Surface& e) { return &e == s; });
+}
+
+void Substrate::Impl::ctx_motion(Surface& s, double lx, double ly) {
+ if (s.context == nullptr) {
+ return;
+ }
+ s.context->ProcessMouseMove(static_cast<int>(lx - s.x), static_cast<int>(ly - s.y), 0);
+}
+void Substrate::Impl::ctx_button(Surface& s, bool pressed) {
+ if (s.context == nullptr) {
+ return;
+ }
+ if (pressed) {
+ s.context->ProcessMouseButtonDown(0, 0);
+ } else {
+ s.context->ProcessMouseButtonUp(0, 0);
+ }
+}
+
+// ---- Substrate (private surface) --------------------------------------------
+
+auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_renderer* renderer,
+ SubstrateDisableFn disable) -> std::unique_ptr<Substrate> {
+ auto impl = std::make_unique<Impl>();
+ impl->allocator = allocator;
+ impl->renderer = renderer;
+ impl->disable = std::move(disable);
+ impl->gl.init(egl_display); // sets gl.ok; failure => unavailable substrate
+ return std::unique_ptr<Substrate>(new Substrate(std::move(impl)));
+}
+
+Substrate::Substrate(std::unique_ptr<Impl> impl) : impl_(std::move(impl)) {}
+
+Substrate::~Substrate() {
+ // Destroy surfaces (GL + scene nodes) then the shared bridge.
+ while (!impl_->surfaces.empty()) {
+ impl_->destroy_surface(&impl_->surfaces.front());
+ }
+ impl_->gl.teardown();
+}
+
+auto Substrate::available() const -> bool { return impl_->available(); }
+
+auto Substrate::create_surface(ExtensionId who, wlr_scene_tree* parent, const UiSurfaceSpec& spec)
+ -> std::unique_ptr<UiSurface> {
+ if (!impl_->available() || parent == nullptr) {
+ return nullptr;
+ }
+ if (spec.width <= 0 || spec.height <= 0) {
+ wlr_log(WLR_ERROR, "ui-substrate: surface needs positive geometry");
+ return nullptr;
+ }
+ if (!impl_->gl.make_current()) {
+ return nullptr;
+ }
+
+ impl_->surfaces.emplace_back();
+ Surface& s = impl_->surfaces.back();
+ s.owner = impl_.get();
+ s.who = who;
+ s.width = spec.width;
+ s.height = spec.height;
+ s.x = spec.x;
+ s.y = spec.y;
+ s.is_visible = spec.visible;
+
+ bool ok = impl_->init_surface_gl(s);
+ if (ok) {
+ // Context name must be globally unique (RmlUi namespaces contexts by
+ // name); the data-model name is the document-authored spec.model.
+ static int counter = 0;
+ const std::string ctx_name = "ui_ctx_" + std::to_string(++counter);
+ s.model_name = spec.model.empty() ? std::string("ui") : spec.model;
+ s.context = Rml::CreateContext(ctx_name, Rml::Vector2i(s.width, s.height),
+ impl_->gl.render_iface.get());
+ ok = s.context != nullptr;
+ }
+ if (ok) {
+ // touch-mode does no visual scaling: leave the context at RmlUi's
+ // default dp-ratio (1.0) for the surface's whole life.
+ s.scene_buffer = wlr_scene_buffer_create(parent, nullptr);
+ ok = s.scene_buffer != nullptr;
+ }
+ if (!ok) {
+ impl_->destroy_surface(&s);
+ impl_->gl.restore_current();
+ return nullptr;
+ }
+
+ wlr_scene_node_set_position(&s.scene_buffer->node, s.x, s.y);
+ wlr_scene_node_set_enabled(&s.scene_buffer->node, s.is_visible);
+
+ // Open the data model constructor (model name == context name == the
+ // document's data-model). It stays open while the extension calls bind_*;
+ // the document loads lazily on the first render once binds are complete
+ // (RmlUi requires the data model fully built before it parses {{...}}).
+ s.ctor = s.context->CreateDataModel(s.model_name);
+ s.rml_inline = spec.rml_inline;
+ s.rml_path = spec.rml_path;
+
+ impl_->gl.restore_current();
+ return std::make_unique<SurfaceHandle>(this, &s);
+}
+
+void Substrate::tick_all() {
+ if (!impl_->available() || impl_->surfaces.empty()) {
+ return;
+ }
+ if (!impl_->gl.make_current()) {
+ return;
+ }
+ for (Surface& s : impl_->surfaces) {
+ if (s.is_visible) {
+ impl_->render_surface(s);
+ }
+ }
+ impl_->gl.restore_current();
+}
+
+// ---- Input routing ----------------------------------------------------------
+
+void Substrate::route_pointer_motion(double lx, double ly, std::uint32_t time_msec) {
+ if (!impl_->available()) {
+ return;
+ }
+ if (impl_->touch_mode_tracker.on_pointer_motion(time_msec)) {
+ impl_->notify_touch_mode_changed();
+ }
+ // During a substrate-owned button grab, the grabbed surface keeps receiving
+ // moves (RmlUi drag) even when the cursor leaves it; other surfaces get a
+ // leave. Otherwise, normal hover: the hit surface gets the move.
+ Surface* target = nullptr;
+ if (impl_->pointer_grab.owner() == GrabOwner::substrate) {
+ target = impl_->pointer_grab_surface; // may be null if destroyed mid-grab
+ } else {
+ target = impl_->surface_at(lx, ly);
+ }
+ for (Surface& s : impl_->surfaces) {
+ if (&s == target) {
+ impl_->ctx_motion(s, lx, ly);
+ } else if (s.context != nullptr) {
+ s.context->ProcessMouseLeave();
+ }
+ }
+}
+
+auto Substrate::route_pointer_button(double lx, double ly, bool pressed, std::uint32_t /*time*/)
+ -> bool {
+ if (!impl_->available()) {
+ return false;
+ }
+ if (pressed) {
+ // The press decides (or joins) the grab. Owner is fixed at the first
+ // press of the stream; this press routes to that owner.
+ Surface* hit = impl_->surface_at(lx, ly);
+ const GrabOwner owner = impl_->pointer_grab.press(hit != nullptr);
+ if (owner != GrabOwner::substrate) {
+ return false; // bus owns this grab — pass through
+ }
+ if (impl_->pointer_grab_surface == nullptr) {
+ impl_->pointer_grab_surface = hit; // first press of a substrate grab
+ }
+ if (impl_->pointer_grab_surface != nullptr) {
+ impl_->ctx_motion(*impl_->pointer_grab_surface, lx, ly);
+ impl_->ctx_button(*impl_->pointer_grab_surface, true);
+ }
+ return true; // consumed by the substrate
+ }
+ // Release: routes to the grab's owner regardless of what is under the cursor
+ // now (the press's consumer owns the release).
+ const GrabOwner owner = impl_->pointer_grab.release();
+ if (owner != GrabOwner::substrate) {
+ return false; // bus owned this grab — release reaches extensions
+ }
+ if (impl_->pointer_grab_surface != nullptr) {
+ impl_->ctx_motion(*impl_->pointer_grab_surface, lx, ly);
+ impl_->ctx_button(*impl_->pointer_grab_surface, false);
+ }
+ if (!impl_->pointer_grab.active()) {
+ impl_->pointer_grab_surface = nullptr; // grab ended
+ }
+ return true; // consumed (even if the surface vanished mid-grab)
+}
+
+auto Substrate::route_pointer_axis(double lx, double ly, double delta, std::uint32_t /*time*/)
+ -> bool {
+ if (!impl_->available()) {
+ return false;
+ }
+ Surface* hit = impl_->surface_at(lx, ly);
+ if (hit == nullptr || hit->context == nullptr) {
+ return false;
+ }
+ hit->context->ProcessMouseWheel(static_cast<float>(delta), 0);
+ return true;
+}
+
+auto Substrate::route_touch_down(std::int32_t id, double lx, double ly, std::uint32_t time_msec)
+ -> bool {
+ if (!impl_->available()) {
+ return false;
+ }
+ if (impl_->touch_mode_tracker.on_touch(time_msec)) {
+ impl_->notify_touch_mode_changed();
+ }
+ Surface* hit = impl_->surface_at(lx, ly);
+ if (hit == nullptr) {
+ return false;
+ }
+ // Synthesize a tap = mouse move-to + button down (RmlUi single-touch model).
+ impl_->touch_capture[id] = hit;
+ impl_->ctx_motion(*hit, lx, ly);
+ impl_->ctx_button(*hit, true);
+ return true;
+}
+
+auto Substrate::route_touch_motion(std::int32_t id, double lx, double ly, std::uint32_t time_msec)
+ -> bool {
+ if (!impl_->available()) {
+ return false;
+ }
+ auto it = impl_->touch_capture.find(id);
+ if (it == impl_->touch_capture.end()) {
+ return false; // down was not over a surface; not captured
+ }
+ impl_->touch_mode_tracker.on_touch(time_msec);
+ impl_->ctx_motion(*it->second, lx, ly);
+ return true;
+}
+
+auto Substrate::route_touch_up(std::int32_t id, std::uint32_t /*time*/) -> bool {
+ if (!impl_->available()) {
+ return false;
+ }
+ auto it = impl_->touch_capture.find(id);
+ if (it == impl_->touch_capture.end()) {
+ return false;
+ }
+ impl_->ctx_button(*it->second, false);
+ impl_->touch_capture.erase(it);
+ return true;
+}
+
+auto Substrate::touch_mode() const -> bool { return impl_->touch_mode_tracker.is_touch(); }
+
+void Substrate::set_touch_mode_override(UiSubstrate::TouchModeOverride ov) {
+ using TO = UiSubstrate::TouchModeOverride;
+ TouchModeTracker::Override mapped = TouchModeTracker::Override::none;
+ if (ov == TO::force_off) {
+ mapped = TouchModeTracker::Override::force_pointer;
+ } else if (ov == TO::force_on) {
+ mapped = TouchModeTracker::Override::force_touch;
+ }
+ if (impl_->touch_mode_tracker.set_override(mapped)) {
+ impl_->notify_touch_mode_changed();
+ }
+}
+
+auto Substrate::frame_count() const -> int {
+ int total = 0;
+ for (const Surface& s : impl_->surfaces) {
+ total += s.frame_count;
+ }
+ return total;
+}
+
+auto Substrate::fence_sync_active() const -> bool {
+ return impl_->gl.fence_ok && impl_->gl.dmabuf_import_ok;
+}
+
+auto Substrate::orientation() const -> int {
+ for (const Surface& s : impl_->surfaces) {
+ if (s.dmabuf || s.shm == nullptr || s.frame_count == 0) {
+ continue;
+ }
+ const std::uint8_t* base = s.readback.data(); // R,G,B,A, row0=top
+ const int w = s.width;
+ const int h = s.height;
+ auto matches = [](const std::uint8_t* p, const std::uint8_t (&c)[3]) {
+ const int dr = static_cast<int>(p[0]) - c[0];
+ const int dg = static_cast<int>(p[1]) - c[1];
+ const int db = static_cast<int>(p[2]) - c[2];
+ return dr * dr + dg * dg + db * db < 24 * 24;
+ };
+ int tt = 0;
+ int tb = 0;
+ int bt = 0;
+ int bb = 0;
+ for (int row = 0; row < kBandHeight; ++row) {
+ const int top_row = row;
+ const int bot_row = h - 1 - row;
+ for (int xx = 0; xx < w; ++xx) {
+ const std::uint8_t* pt = base + (static_cast<std::size_t>(top_row) * w + xx) * 4;
+ const std::uint8_t* pb = base + (static_cast<std::size_t>(bot_row) * w + xx) * 4;
+ if (matches(pt, kTopBandRGB)) {
+ ++tt;
+ }
+ if (matches(pb, kTopBandRGB)) {
+ ++tb;
+ }
+ if (matches(pt, kBottomBandRGB)) {
+ ++bt;
+ }
+ if (matches(pb, kBottomBandRGB)) {
+ ++bb;
+ }
+ }
+ }
+ if (tt > 100 && bb > 100 && tt > tb && bb > bt) {
+ return 1;
+ }
+ if (tb > 100 && bt > 100 && tb > tt && bt > bb) {
+ return -1;
+ }
+ return 0;
+ }
+ return 0;
+}
+
+// ---- SurfaceHandle (public UiSurface impl) ----------------------------------
+
+SurfaceHandle::~SurfaceHandle() {
+ substrate_->impl_->destroy_surface(surface_);
+}
+
+void SurfaceHandle::set_position(int x, int y) {
+ surface_->x = x;
+ surface_->y = y;
+ if (surface_->scene_buffer != nullptr) {
+ wlr_scene_node_set_position(&surface_->scene_buffer->node, x, 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));
+ }
+}
+
+void SurfaceHandle::set_visible(bool visible) {
+ surface_->is_visible = visible;
+ if (surface_->scene_buffer != nullptr) {
+ wlr_scene_node_set_enabled(&surface_->scene_buffer->node, visible);
+ }
+}
+
+auto SurfaceHandle::visible() const -> bool { return surface_->is_visible; }
+
+// All binds funnel through the surface's single open DataModelConstructor and
+// MUST happen before the document loads (first render). Binding after load is a
+// no-op (the constructor is closed) — documented in ui.hpp ("call before the
+// first frame"). The slot lives in a std::list for stable addresses.
+void SurfaceHandle::bind_int(std::string_view name, std::function<int()> getter) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.int_bindings.push_back({std::move(getter), 0});
+ s.ctor.Bind(std::string(name), &s.int_bindings.back().slot);
+}
+void SurfaceHandle::bind_double(std::string_view name, std::function<double()> getter) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.double_bindings.push_back({std::move(getter), 0.0});
+ s.ctor.Bind(std::string(name), &s.double_bindings.back().slot);
+}
+void SurfaceHandle::bind_bool(std::string_view name, std::function<bool()> getter) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.bool_bindings.push_back({std::move(getter), false});
+ s.ctor.Bind(std::string(name), &s.bool_bindings.back().slot);
+}
+void SurfaceHandle::bind_string(std::string_view name, std::function<std::string()> getter) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.string_bindings.push_back({std::move(getter), Rml::String{}});
+ s.ctor.Bind(std::string(name), &s.string_bindings.back().slot);
+}
+void SurfaceHandle::bind_event(std::string_view name, std::function<void()> callback) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.event_bindings.push_back({std::move(callback), s.who, s.owner});
+ Surface::EventBinding* binding = &s.event_bindings.back();
+ s.ctor.BindEventCallback(
+ std::string(name),
+ [binding](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) {
+ try {
+ if (binding->cb) {
+ binding->cb();
+ }
+ } catch (...) {
+ if (binding->owner->disable) {
+ binding->owner->disable(binding->who);
+ }
+ }
+ });
+}
+
+void SurfaceHandle::on_touch_mode_changed(std::function<void(bool)> callback) {
+ surface_->touch_mode_cb = std::move(callback);
+}
+
+void SurfaceHandle::dirty(std::string_view name) {
+ if (surface_->model) {
+ surface_->model.DirtyVariable(std::string(name));
+ }
+}
+void SurfaceHandle::dirty() {
+ if (surface_->model) {
+ surface_->model.DirtyAllVariables();
+ }
+}
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
new file mode 100644
index 0000000..082417a
--- /dev/null
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -0,0 +1,148 @@
+#pragma once
+
+#include <unbox/kernel/ui.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include "ui_core.hpp"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <vector>
+
+// The real ui substrate (slice 5) — the kernel's RMLUi subsystem behind the
+// typed <unbox/kernel/ui.hpp> facade. Replaces the slice-3 ui spike. PRIVATE
+// to the kernel; the only contract is ui.hpp.
+//
+// One sibling GLES 3.2 EGL context (shared wlr EGLDisplay), one Rml::Initialise
+// and one font atlas are shared across ALL ui surfaces (the 3.7 GiB budget:
+// one atlas). Each ui surface owns its own Rml::Context + offscreen FBO +
+// wlr_buffer + wlr_scene_buffer node, so per-surface damage is independent. The
+// proven slice-3 bridge mechanics (Plan A dmabuf-backed FBO, Plan B shm copy,
+// the SetOutputFramebuffer flip for upright buffers) are reused per surface;
+// the per-frame glFinish is replaced with an EGL fence (EGL_KHR_fence_sync).
+//
+// Everything runs on the single wl_event_loop thread. Surfaces created via the
+// public facade carry the OWNING extension id so a throwing data-event callback
+// disables that extension (the substrate calls back into the kernel's
+// DisableSink). The kernel drives rendering from the output frame handler
+// (tick_all) and routes input via the route_* methods (consume-or-pass).
+
+// The adapted RMLUi GL3 render interface lives in the GLOBAL namespace
+// (src/rmlui_renderer_gl3.h, mirroring upstream). Forward-declared here so the
+// substrate can hold a unique_ptr to it without the header pulling RMLUi in;
+// the full type is included only in ui_substrate.cpp.
+class RenderInterface_GL3;
+
+namespace unbox::kernel {
+
+// Callback the substrate invokes to disable an extension whose data-event
+// callback threw — injected by the kernel (Server::Impl). Mirrors the bus's
+// detail::DisableSink but scoped to the substrate so ui.hpp carries no kernel
+// internals.
+using SubstrateDisableFn = std::function<void(ExtensionId)>;
+
+class Substrate; // the concrete UiSubstrate, defined in ui_substrate.cpp
+
+// One ui surface's private state (Rml context + GL target + scene node +
+// bindings). Defined in ui_substrate.cpp; declared here so Substrate can own a
+// list of them and the public SurfaceHandle can borrow one.
+struct Surface;
+
+// 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
+// records the owning extension).
+class SurfaceHandle final : public UiSurface {
+public:
+ SurfaceHandle(Substrate* substrate, Surface* surface)
+ : substrate_(substrate), surface_(surface) {}
+ ~SurfaceHandle() override;
+ SurfaceHandle(const SurfaceHandle&) = delete;
+ auto operator=(const SurfaceHandle&) -> SurfaceHandle& = delete;
+
+ void set_position(int x, int y) override;
+ void set_size(int width, int height) override;
+ void set_visible(bool visible) override;
+ [[nodiscard]] auto visible() const -> bool override;
+
+ void bind_int(std::string_view name, std::function<int()> getter) override;
+ void bind_double(std::string_view name, std::function<double()> getter) override;
+ void bind_bool(std::string_view name, std::function<bool()> getter) override;
+ void bind_string(std::string_view name, std::function<std::string()> getter) override;
+ void bind_event(std::string_view name, std::function<void()> callback) override;
+ void on_touch_mode_changed(std::function<void(bool)> callback) override;
+ void dirty(std::string_view name) override;
+ void dirty() override;
+
+private:
+ Substrate* substrate_;
+ Surface* surface_;
+};
+
+// The substrate. Kernel-owned (one per Server). UiSubstrate is the per-
+// extension facade view; PerExtensionUi (in ui_substrate.cpp) injects the
+// owning id. The Substrate owns the GL/RMLUi state and every Surface.
+class Substrate {
+public:
+ // Build the substrate on the wlr renderer's EGLDisplay. `egl_display` may
+ // be EGL_NO_DISPLAY (no gles2 renderer) — then available() is false and
+ // create_surface yields nullptr. Never throws.
+ static auto create(EGLDisplay egl_display, wlr_allocator* allocator,
+ wlr_renderer* renderer, SubstrateDisableFn disable)
+ -> std::unique_ptr<Substrate>;
+
+ ~Substrate();
+ Substrate(const Substrate&) = delete;
+ auto operator=(const Substrate&) -> Substrate& = delete;
+
+ [[nodiscard]] auto available() const -> bool;
+
+ // Create a surface owned by `who`, parented under `parent` scene tree.
+ // Returns nullptr on any failure. Never throws.
+ auto create_surface(ExtensionId who, wlr_scene_tree* parent, const UiSurfaceSpec& spec)
+ -> std::unique_ptr<UiSurface>;
+
+ // Render every dirty surface (called from the output frame handler).
+ void tick_all();
+
+ // ---- Input routing (kernel calls these BEFORE emitting on the bus) ----
+ // Pointer motion is always observed (never consumes). Returns nothing.
+ void route_pointer_motion(double lx, double ly, std::uint32_t time_msec);
+ // Button / axis / touch: return true if a visible ui surface consumed the
+ // event (kernel must then NOT emit it on the bus). Coords are layout-space.
+ [[nodiscard]] auto route_pointer_button(double lx, double ly, bool pressed,
+ std::uint32_t time_msec) -> bool;
+ [[nodiscard]] auto route_pointer_axis(double lx, double ly, double delta,
+ std::uint32_t time_msec) -> bool;
+ [[nodiscard]] auto route_touch_down(std::int32_t id, double lx, double ly,
+ std::uint32_t time_msec) -> bool;
+ [[nodiscard]] auto route_touch_motion(std::int32_t id, double lx, double ly,
+ std::uint32_t time_msec) -> bool;
+ [[nodiscard]] auto route_touch_up(std::int32_t id, std::uint32_t time_msec) -> bool;
+
+ // ---- touch-mode ----
+ [[nodiscard]] auto touch_mode() const -> bool;
+ void set_touch_mode_override(UiSubstrate::TouchModeOverride ov);
+
+ // ---- test/inspection probes (kept from the spike's regression value) ----
+ // Total frames rendered+submitted across all surfaces.
+ [[nodiscard]] auto frame_count() const -> int;
+ // Orientation self-check of the first shm-path surface's submitted buffer:
+ // +1 upright, -1 flipped, 0 indeterminate. The orientation regression guard
+ // survives here (was Server::ui_spike_orientation).
+ [[nodiscard]] auto orientation() const -> int;
+ // True if the EGL fence-sync path is the active Plan-A submission sync
+ // (no glFinish on the hot path) — lets the suite assert the production sync.
+ [[nodiscard]] auto fence_sync_active() const -> bool;
+
+ struct Impl;
+
+private:
+ explicit Substrate(std::unique_ptr<Impl> impl);
+ std::unique_ptr<Impl> impl_;
+
+ friend class SurfaceHandle;
+};
+
+} // namespace unbox::kernel