diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 00:17:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 00:17:33 +0900 |
| commit | 803fd2687a5f6ead0644f9c952bed6e3e4ef7ed9 (patch) | |
| tree | 68d727df9c0f08a7a08c2c464f95d8c82fb8789e /packages/kernel/src | |
| parent | c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (diff) | |
| download | unbox-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.cpp | 55 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 93 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 37 | ||||
| -rw-r--r-- | packages/kernel/src/ui_core.hpp | 139 | ||||
| -rw-r--r-- | packages/kernel/src/ui_spike.cpp | 688 | ||||
| -rw-r--r-- | packages/kernel/src/ui_spike.hpp | 77 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 1097 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 148 |
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 |
