summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 22:50:59 +0900
committerAdam Malczewski <[email protected]>2026-06-13 22:50:59 +0900
commit6b67ae6ac1b8d0d272ddb50e6ef10d08f4fd6628 (patch)
tree1e7233e046e2cb20555bf8d4a0fa22d4be4f9d68 /packages/kernel/src
parentf65b446cabe3da6f8afff34e127e247dd0d03e5c (diff)
downloadunbox-6b67ae6ac1b8d0d272ddb50e6ef10d08f4fd6628.tar.gz
unbox-6b67ae6ac1b8d0d272ddb50e6ef10d08f4fd6628.zip
kernel: load ui surfaces from RML asset files + dev hot-reload
Externalize UI documents so RML/RCSS design changes need no C++ recompile — and, in dev, no restart. - UiSurfaceSpec::rml_path now actually loads the document from a file (path wins over rml_inline, as documented). Resolution: absolute path as-is; relative path against $UNBOX_ASSET_DIR, else the compile-time UNBOX_ASSET_DIR_DEFAULT (the install data dir), else cwd. The document URL is set so its <link> RCSS / asset refs resolve relative to the doc's own dir. Missing/unreadable file -> nullptr (degrade, never throw). - Dev hot-reload (gated by $UNBOX_DEV): an inotify watcher integrated into the wl_event_loop (never blocks) watches the asset DIRS (dir-watch for IN_CLOSE_WRITE / IN_MOVED_TO, since editors save via temp+rename), coalesces events, and on a change to a surface's backing .rml/.rcss reloads the document IN PLACE: ClearStyleSheetCache + UnloadDocument + reload, preserving the surface's RmlUi context, data model and the extension's registered bind_*/bind_list* getters (the extension does NOT re-register), and its geometry/visibility; preview textures are kept. A malformed file on reload is ERROR-ISOLATED — the previous good document keeps rendering, one warning is logged, and a later good save recovers; the session never dies. - Test seam Server::ui_reload_surface() drives reload deterministically. ui.hpp documents rml_path + the dev hot-reload behavior. kernel 54 cases/232 assertions green on build + build-asan (incl. the UNBOX_DEV inotify path), no new suppressions. Edits confined to packages/kernel/.
Diffstat (limited to 'packages/kernel/src')
-rw-r--r--packages/kernel/src/server.cpp9
-rw-r--r--packages/kernel/src/ui_substrate.cpp352
-rw-r--r--packages/kernel/src/ui_substrate.hpp14
3 files changed, 361 insertions, 14 deletions
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index d97104e..5d78b70 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -105,6 +105,10 @@ auto Server::ui_click_element(const char* tag, int index) -> bool {
return impl_->substrate != nullptr && impl_->substrate->click_element(tag, index);
}
+auto Server::ui_reload_surface() -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->reload_first_surface();
+}
+
void Server::ui_set_touch_override(UiTouchOverride ov) {
if (impl_->substrate == nullptr) {
return;
@@ -371,8 +375,11 @@ void Server::Impl::start_substrate() {
wlr_log(WLR_INFO, "ui-substrate: renderer is not gles2; substrate unavailable");
}
// A data-event/getter throw disables the owning extension via the same
- // isolation path the bus uses (Server::Impl is the DisableSink).
+ // isolation path the bus uses (Server::Impl is the DisableSink). The
+ // wl_event_loop lets the substrate poll the dev hot-reload inotify fd
+ // (UNBOX_DEV-gated) without ever blocking the loop.
substrate = Substrate::create(display_egl, allocator, renderer,
+ wl_display_get_event_loop(display),
[this](ExtensionId who) { disable(who); });
}
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index d1fcd7a..0794536 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -8,6 +8,7 @@
#include <RmlUi/Core/DataVariable.h>
#include <RmlUi/Core/Element.h>
#include <RmlUi/Core/ElementDocument.h>
+#include <RmlUi/Core/Factory.h>
#include <RmlUi/Core/SystemInterface.h>
#include <RmlUi/Core/Variant.h>
@@ -19,21 +20,67 @@
#include <GLES2/gl2ext.h> // glEGLImageTargetTexture2DOES
#include <GLES3/gl32.h>
+// inotify is libc (not wlroots), so it does NOT go through wlr.hpp. Integrated
+// into the kernel's wl_event_loop via wl_event_loop_add_fd so the fd is polled,
+// never blocking the loop.
+#include <sys/inotify.h>
+#include <unistd.h>
+
+#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <ctime>
+#include <filesystem>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>
+// The installed asset root, resolved against for a RELATIVE UiSurfaceSpec::
+// rml_path when $UNBOX_ASSET_DIR is unset. The orchestrator adds
+// -DUNBOX_ASSET_DIR_DEFAULT="<installed data dir>" to the kernel build; until
+// then "." lets the kernel build + run from the source/working dir.
+#ifndef UNBOX_ASSET_DIR_DEFAULT
+#define UNBOX_ASSET_DIR_DEFAULT "."
+#endif
+
namespace unbox::kernel {
namespace {
constexpr std::uint32_t kDrmFormatArgb8888 = 0x34325241; // 'AR24' = LE {B,G,R,A}
+// Resolve UiSurfaceSpec::rml_path to an ABSOLUTE filesystem path so RmlUi can
+// load it and resolve the document's relative <link>/<style>/image refs against
+// its own directory. Absolute paths are used as-is; a relative path resolves
+// against $UNBOX_ASSET_DIR (dev) else UNBOX_ASSET_DIR_DEFAULT (installed data
+// dir, "." fallback). Pure string/path math — no I/O, no throw.
+auto resolve_asset_path(const std::string& rml_path) -> std::string {
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ fs::path p(rml_path);
+ if (p.is_absolute()) {
+ return p.lexically_normal().string();
+ }
+ const char* env = std::getenv("UNBOX_ASSET_DIR");
+ const fs::path root = (env != nullptr && env[0] != '\0') ? fs::path(env)
+ : fs::path(UNBOX_ASSET_DIR_DEFAULT);
+ fs::path joined = (root / p).lexically_normal();
+ // Make it absolute against the cwd if the root itself was relative (e.g. the
+ // "." fallback), so RmlUi's relative-ref resolution has a stable base.
+ if (!joined.is_absolute()) {
+ joined = (fs::current_path(ec) / joined).lexically_normal();
+ }
+ return joined.string();
+}
+
+// True if the dev hot-reload watcher should run for this process. Gated so a
+// production build does zero watching (no inotify fd, no overhead).
+auto hot_reload_enabled() -> bool {
+ return std::getenv("UNBOX_DEV") != nullptr || std::getenv("UNBOX_HOT_RELOAD") != nullptr;
+}
+
// 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
@@ -60,11 +107,24 @@ public:
(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());
+ // RmlUi's LoadDocument returns a (possibly empty) document even when the
+ // XML fails to parse — the failure is only LOGGED. So the hot-reload path
+ // counts parse errors here to decide whether a fresh load was actually
+ // good (see reload_surface): a "XML parse error" warning or any load-time
+ // ERROR during a reload means keep the previous document.
+ if (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT ||
+ (type == Rml::Log::LT_WARNING && message.find("parse error") != Rml::String::npos)) {
+ ++parse_errors_;
+ }
return true;
}
+ // Snapshot/read the parse-error counter (hot-reload validity check).
+ [[nodiscard]] auto parse_errors() const -> int { return parse_errors_; }
+
private:
double start_ = 0.0;
+ int parse_errors_ = 0;
};
// --- A data-ptr wlr_buffer wrapping heap memory (Plan B target) -----------
@@ -321,7 +381,8 @@ struct Surface {
// Deferred document source (loaded on first tick, after binds are set).
std::string rml_inline;
- std::string rml_path;
+ std::string rml_path; // the spec's path (as the extension passed it)
+ std::string resolved_path; // absolute path actually loaded (file-backed only)
bool doc_loaded = false;
// Data bindings. Each bound scalar pairs a getter with a stable slot the
@@ -544,6 +605,47 @@ struct Substrate::Impl {
std::list<Surface> surfaces; // stable addresses (handles borrow Surface*)
+ // ---- Dev hot-reload watcher (UNBOX_DEV-gated; see top-of-file helpers) ----
+ // ONE inotify fd integrated into the wl_event_loop. We watch DIRECTORIES (not
+ // inodes) because editors save via temp-file + rename — IN_CLOSE_WRITE /
+ // IN_MOVED_TO on the dir, matched by filename, catches that reliably. Each
+ // watched dir maps to the file basenames in it that back a surface. Reloads
+ // are coalesced and applied at the next tick_all (debounce within a frame).
+ wl_event_loop* loop = nullptr;
+ int inotify_fd = -1;
+ wl_event_source* inotify_source = nullptr;
+ // inotify watch descriptor (one per watched directory) -> directory path.
+ std::unordered_map<int, std::string> watch_dirs;
+ // directory path -> the surfaces whose document (or same-dir RCSS/assets)
+ // live there. A change to ANY file in the dir reloads them (covers the .rml
+ // AND a sibling .rcss the document <link>s, with no need to parse links).
+ std::unordered_map<std::string, std::vector<Surface*>> dir_surfaces;
+ // surfaces flagged dirty by a file event, drained (coalesced) at tick_all.
+ std::vector<Surface*> pending_reloads;
+
+ // Bring up the inotify fd + its wl_event_loop source (dev only). Idempotent;
+ // a failure leaves inotify_fd < 0 (watching simply disabled, no error).
+ void init_watcher(wl_event_loop* event_loop);
+ // Remove the event source + close the inotify fd (teardown / no surfaces).
+ void teardown_watcher();
+ // Watch the directory of `abs_file` and remember that `s` depends on it.
+ // No-op if hot-reload is disabled or the fd is unusable.
+ void watch_file_for(Surface* s, const std::string& abs_file);
+ // Stop tracking `s` across all watched dirs (on destroy / before re-watch).
+ void unwatch_surface(Surface* s);
+ // Drain the inotify fd; flag dependent surfaces for reload (coalesced).
+ void on_inotify_readable();
+ // Reload `s`'s document from its file, preserving context/model/bindings/
+ // geometry/visibility/previews; error-isolated (keeps the old doc on a bad
+ // parse). Returns true if a NEW document was installed. Caller holds nothing;
+ // this makes the GL context current itself.
+ bool reload_surface(Surface& s);
+ // Load `s`'s document for the FIRST time (file via resolved path, else
+ // inline), Show() it, and register the hot-reload watch if file-backed.
+ // Caller holds the sibling context current. Returns the loaded document or
+ // nullptr (logged, never throws).
+ Rml::ElementDocument* load_document_first(Surface& s);
+
// Previews (slice-10 spike): stable addresses (PreviewHandle borrows a
// PreviewState*). `next_preview_id` numbers the "unbox-preview://N" URIs.
std::list<PreviewState> previews;
@@ -710,6 +812,208 @@ bool Substrate::Impl::init_surface_gl(Surface& s) {
return true;
}
+// ---- Document load (first) + dev hot-reload --------------------------------
+
+namespace {
+// wl_event_loop fd callback: just drains inotify (never blocks the loop).
+auto inotify_dispatch(int /*fd*/, std::uint32_t /*mask*/, void* data) -> int {
+ static_cast<Substrate::Impl*>(data)->on_inotify_readable();
+ return 0;
+}
+} // namespace
+
+void Substrate::Impl::init_watcher(wl_event_loop* event_loop) {
+ loop = event_loop;
+ if (!hot_reload_enabled() || loop == nullptr || inotify_fd >= 0) {
+ return;
+ }
+ inotify_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
+ if (inotify_fd < 0) {
+ wlr_log(WLR_ERROR, "ui-substrate: inotify_init1 failed; hot-reload disabled");
+ return;
+ }
+ inotify_source = wl_event_loop_add_fd(loop, inotify_fd, WL_EVENT_READABLE,
+ inotify_dispatch, this);
+ if (inotify_source == nullptr) {
+ close(inotify_fd);
+ inotify_fd = -1;
+ wlr_log(WLR_ERROR, "ui-substrate: wl_event_loop_add_fd failed; hot-reload disabled");
+ return;
+ }
+ wlr_log(WLR_INFO, "ui-substrate: dev hot-reload ON (inotify watching asset dirs)");
+}
+
+void Substrate::Impl::teardown_watcher() {
+ if (inotify_source != nullptr) {
+ wl_event_source_remove(inotify_source);
+ inotify_source = nullptr;
+ }
+ if (inotify_fd >= 0) {
+ close(inotify_fd); // also drops all inotify watches
+ inotify_fd = -1;
+ }
+ watch_dirs.clear();
+ dir_surfaces.clear();
+ pending_reloads.clear();
+}
+
+Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
+ Rml::ElementDocument* doc = nullptr;
+ if (!s.rml_path.empty()) {
+ s.resolved_path = resolve_asset_path(s.rml_path);
+ doc = s.context->LoadDocument(s.resolved_path);
+ if (doc == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: failed to load document '%s' (resolved '%s')",
+ s.rml_path.c_str(), s.resolved_path.c_str());
+ return nullptr;
+ }
+ // Dev-only: watch the document's directory for editor saves.
+ if (hot_reload_enabled()) {
+ watch_file_for(&s, s.resolved_path);
+ }
+ } else {
+ doc = s.context->LoadDocumentFromMemory(s.rml_inline);
+ if (doc == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: failed to load inline document");
+ return nullptr;
+ }
+ }
+ doc->Show();
+ return doc;
+}
+
+void Substrate::Impl::watch_file_for(Surface* s, const std::string& abs_file) {
+ if (inotify_fd < 0) {
+ return;
+ }
+ std::error_code ec;
+ const std::string dir = std::filesystem::path(abs_file).parent_path().string();
+ if (dir.empty()) {
+ return;
+ }
+ auto& deps = dir_surfaces[dir];
+ if (std::find(deps.begin(), deps.end(), s) == deps.end()) {
+ deps.push_back(s);
+ }
+ // Add the dir watch once (inotify_add_watch on the same path returns the
+ // SAME wd, so this is idempotent — re-arming after a watched file is
+ // replaced is automatic since we watch the directory, not the inode).
+ const int wd = inotify_add_watch(inotify_fd, dir.c_str(),
+ IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE);
+ if (wd >= 0) {
+ watch_dirs[wd] = dir;
+ }
+}
+
+void Substrate::Impl::unwatch_surface(Surface* s) {
+ for (auto it = dir_surfaces.begin(); it != dir_surfaces.end();) {
+ auto& deps = it->second;
+ deps.erase(std::remove(deps.begin(), deps.end(), s), deps.end());
+ if (deps.empty()) {
+ it = dir_surfaces.erase(it);
+ } else {
+ ++it;
+ }
+ }
+ pending_reloads.erase(std::remove(pending_reloads.begin(), pending_reloads.end(), s),
+ pending_reloads.end());
+}
+
+void Substrate::Impl::on_inotify_readable() {
+ // Drain ALL queued inotify events (the fd is non-blocking; one readable
+ // notification may carry many). Flag every surface in a changed directory
+ // for reload — coalesced into pending_reloads (dedup) and applied once at
+ // the next tick_all, so a temp+rename burst causes a single reload.
+ alignas(struct inotify_event) char buf[4096];
+ for (;;) {
+ const ssize_t n = read(inotify_fd, buf, sizeof(buf));
+ if (n <= 0) {
+ break; // EAGAIN (drained) or closed
+ }
+ std::size_t off = 0;
+ while (off + sizeof(struct inotify_event) <= static_cast<std::size_t>(n)) {
+ auto* ev = reinterpret_cast<struct inotify_event*>(buf + off);
+ auto wd_it = watch_dirs.find(ev->wd);
+ if (wd_it != watch_dirs.end()) {
+ auto deps_it = dir_surfaces.find(wd_it->second);
+ if (deps_it != dir_surfaces.end()) {
+ for (Surface* s : deps_it->second) {
+ if (std::find(pending_reloads.begin(), pending_reloads.end(), s) ==
+ pending_reloads.end()) {
+ pending_reloads.push_back(s);
+ }
+ }
+ }
+ }
+ off += sizeof(struct inotify_event) + ev->len;
+ }
+ }
+}
+
+bool Substrate::Impl::reload_surface(Surface& s) {
+ // Only file-backed, already-loaded surfaces reload. The data model + the
+ // extension's registered bind_*/bind_list*/bind_event getters are CONTEXT-
+ // and substrate-owned, so they survive — we never touch s.ctor/s.*_bindings.
+ if (s.context == nullptr || s.resolved_path.empty() || !s.doc_loaded) {
+ return false;
+ }
+ const bool cur = gl.make_current();
+ bool installed = false;
+ try {
+ // RCSS edits only re-parse if the stylesheet cache is dropped first.
+ // Load the NEW document BEFORE unloading the old one, so a broken file
+ // leaves the previous good document rendering (error isolation). RmlUi
+ // returns a (possibly empty) document even on a parse failure — it only
+ // LOGS the error — so we bracket the load with the SystemInterface's
+ // parse-error counter and treat any increase as a failed reload.
+ Rml::Factory::ClearStyleSheetCache();
+ const int errors_before = gl.system ? gl.system->parse_errors() : 0;
+ Rml::ElementDocument* fresh = s.context->LoadDocument(s.resolved_path);
+ const bool parse_failed = gl.system && gl.system->parse_errors() != errors_before;
+ if (fresh != nullptr && parse_failed) {
+ // The "fresh" document is broken (empty/partial); discard it and keep
+ // the previous good one rendering.
+ s.context->UnloadDocument(fresh);
+ fresh = nullptr;
+ }
+ if (fresh == nullptr) {
+ wlr_log(WLR_ERROR, "ui-substrate: hot-reload parse failed for '%s'; keeping previous",
+ s.resolved_path.c_str());
+ } else {
+ if (s.document != nullptr) {
+ s.context->UnloadDocument(s.document);
+ }
+ s.document = fresh;
+ // Re-apply geometry/visibility (a reload must not move/resize/hide).
+ // The context was already laid out to s.width/s.height; visibility is
+ // the surface's current state, not the document default.
+ if (s.is_visible) {
+ s.document->Show();
+ } else {
+ s.document->Hide();
+ }
+ // The fresh document re-binds to the still-present data model; force
+ // every bound variable to re-read on the next frame.
+ if (s.model) {
+ s.model.DirtyAllVariables();
+ }
+ installed = true;
+ }
+ } catch (...) {
+ // A throwing reload is contained to the owning extension exactly like a
+ // throwing getter/hook — never the session.
+ wlr_log(WLR_ERROR, "ui-substrate: hot-reload threw for '%s'; keeping previous",
+ s.resolved_path.c_str());
+ if (disable) {
+ disable(s.who);
+ }
+ }
+ if (cur) {
+ gl.restore_current();
+ }
+ return installed;
+}
+
void Substrate::Impl::render_surface(Surface& s) {
if (s.context == nullptr) {
return;
@@ -720,16 +1024,10 @@ void Substrate::Impl::render_surface(Surface& s) {
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);
- }
+ s.document = load_document_first(s);
if (s.document == nullptr) {
- wlr_log(WLR_ERROR, "ui-substrate: failed to load document");
- return;
+ return; // logged inside; nothing to render this frame
}
- s.document->Show();
}
refresh_bindings(s);
if (s.model) {
@@ -894,6 +1192,10 @@ bool Substrate::Impl::resize_surface_gl(Surface& s, int w, int h) {
}
void Substrate::Impl::destroy_surface(Surface* s) {
+ // Drop the hot-reload watch tracking for this surface FIRST (so a queued
+ // file event can never reload a dying surface). The inotify dir watch itself
+ // is left armed (cheap) and is reaped at teardown_watcher.
+ unwatch_surface(s);
const bool cur = gl.make_current();
if (s->scene_buffer != nullptr) {
wlr_scene_node_destroy(&s->scene_buffer->node);
@@ -1155,12 +1457,14 @@ void Substrate::Impl::destroy_preview(PreviewState* p) {
// ---- Substrate (private surface) --------------------------------------------
auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_renderer* renderer,
- SubstrateDisableFn disable) -> std::unique_ptr<Substrate> {
+ wl_event_loop* loop, 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
+ impl->init_watcher(loop); // dev-only (UNBOX_DEV); no-op otherwise
return std::unique_ptr<Substrate>(new Substrate(std::move(impl)));
}
@@ -1178,6 +1482,7 @@ Substrate::~Substrate() {
while (!impl_->surfaces.empty()) {
impl_->destroy_surface(&impl_->surfaces.front());
}
+ impl_->teardown_watcher(); // remove the wl_event_loop source + close the fd
impl_->gl.teardown();
}
@@ -1289,6 +1594,26 @@ void Substrate::tick_all() {
if (!impl_->available() || impl_->surfaces.empty()) {
return;
}
+ // Apply any hot-reload requests coalesced from inotify since the last tick
+ // (dev only). reload_surface manages the GL context itself + is error-
+ // isolated, so a broken file here can't stop the frame.
+ if (!impl_->pending_reloads.empty()) {
+ std::vector<Surface*> due;
+ due.swap(impl_->pending_reloads);
+ for (Surface* s : due) {
+ // Still alive? (unwatch_surface scrubs destroyed ones, but be safe.)
+ bool live = false;
+ for (Surface& e : impl_->surfaces) {
+ if (&e == s) {
+ live = true;
+ break;
+ }
+ }
+ if (live) {
+ impl_->reload_surface(*s);
+ }
+ }
+ }
if (!impl_->gl.make_current()) {
return;
}
@@ -1530,6 +1855,13 @@ auto Substrate::click_element(const char* tag, int index) -> bool {
return false;
}
+auto Substrate::reload_first_surface() -> bool {
+ for (Surface& s : impl_->surfaces) {
+ return impl_->reload_surface(s);
+ }
+ return false;
+}
+
auto Substrate::orientation() const -> int {
for (const Surface& s : impl_->surfaces) {
if (s.dmabuf || s.shm == nullptr || s.frame_count == 0) {
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index b82db21..4ba477f 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -124,10 +124,12 @@ 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.
+ // create_surface yields nullptr. `loop` is the kernel's wl_event_loop, used
+ // (dev only, UNBOX_DEV-gated) to poll the hot-reload inotify fd without ever
+ // blocking; pass nullptr to disable watching. Never throws.
static auto create(EGLDisplay egl_display, wlr_allocator* allocator,
- wlr_renderer* renderer, SubstrateDisableFn disable)
- -> std::unique_ptr<Substrate>;
+ wlr_renderer* renderer, wl_event_loop* loop,
+ SubstrateDisableFn disable) -> std::unique_ptr<Substrate>;
~Substrate();
Substrate(const Substrate&) = delete;
@@ -205,6 +207,12 @@ public:
// Click the index-th `tag` element in the first surface's document (fires
// its data-event-click). False if no such element. Drives a row event.
auto click_element(const char* tag, int index) -> bool;
+ // Test seam: synchronously reload the first surface's document from its file
+ // (the same reload the dev inotify watcher drives), so tests trigger reload
+ // deterministically without racing real filesystem events. Returns true if a
+ // NEW document was installed (false if no file-backed surface / parse failed
+ // — old doc kept). Test instrumentation only.
+ auto reload_first_surface() -> bool;
struct Impl;