diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 22:50:59 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 22:50:59 +0900 |
| commit | 6b67ae6ac1b8d0d272ddb50e6ef10d08f4fd6628 (patch) | |
| tree | 1e7233e046e2cb20555bf8d4a0fa22d4be4f9d68 /packages/kernel | |
| parent | f65b446cabe3da6f8afff34e127e247dd0d03e5c (diff) | |
| download | unbox-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')
| -rw-r--r-- | packages/kernel/include/unbox/kernel/server.hpp | 8 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 24 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 9 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 352 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 14 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 310 |
6 files changed, 703 insertions, 14 deletions
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp index 4af327a..25c2aef 100644 --- a/packages/kernel/include/unbox/kernel/server.hpp +++ b/packages/kernel/include/unbox/kernel/server.hpp @@ -129,6 +129,14 @@ public: // Test instrumentation; single-thread only. auto ui_click_element(const char* tag, int index) -> bool; + // Synchronously reload the first ui surface's document from its rml_path file + // — the same reload the dev hot-reload (UNBOX_DEV) inotify watcher drives, + // exposed so tests trigger it deterministically without racing real + // filesystem events. Returns true if a NEW document was installed (false if + // no file-backed surface or the new file failed to parse — the old document + // is kept). Test instrumentation; single-thread only. + auto ui_reload_surface() -> bool; + // Pin the substrate's touch-mode for tests (none = automatic). Mirrors // UiSubstrate::TouchModeOverride; lets the suite drive the state machine and // its on_touch_mode_changed notification. Test instrumentation; diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp index da5986c..e272b3b 100644 --- a/packages/kernel/include/unbox/kernel/ui.hpp +++ b/packages/kernel/include/unbox/kernel/ui.hpp @@ -177,6 +177,30 @@ protected: // `rml_inline` OR an asset path in `rml_path` (path wins if both set). Geometry // is layout-space; `layer` defaults to overlay (above toplevels). `visible` // is the initial visibility. +// +// rml_path RESOLUTION. An ABSOLUTE path is loaded as-is. A RELATIVE path +// resolves against the asset root: `$UNBOX_ASSET_DIR` if set, else the +// compile-time install data dir (`UNBOX_ASSET_DIR_DEFAULT`, falling back to the +// process working dir). So a unit passes e.g. `rml_path = +// "ext-stage-dock/dock.rml"` and the dev launch sets `UNBOX_ASSET_DIR=<repo>/ +// assets`. The document's own `<link>`/`<style>`/image srcs resolve RELATIVE TO +// THE DOCUMENT'S DIRECTORY, so a dock.rml that links a dock.rcss in the same dir +// just works. A missing/unreadable/malformed file yields a null surface from +// create_surface (graceful degrade, never throws) — same contract as no-GL. +// +// DEV HOT-RELOAD (zero overhead in production). When the process env sets +// `UNBOX_DEV` (or `UNBOX_HOT_RELOAD`), the substrate watches the directory of a +// file-backed surface and, on an editor save (handled via dir-watch so the usual +// temp-file+rename works), RELOADS the document live — no recompile, no restart. +// A reload PRESERVES the surface's RmlUi context, its data model, and every +// bind_*/bind_list*/bind_event getter/callback you registered (you set them once +// "before the first frame"; do NOT re-register on reload — the substrate keeps +// them and re-evaluates {{…}}/data-for/data-event against the new document), and +// PRESERVES the surface's geometry/visibility and any registered preview +// textures. RCSS edits re-parse (the stylesheet cache is dropped). A malformed +// save is ERROR-ISOLATED: the previous good document keeps rendering, one warning +// is logged, and the next good save recovers — a bad file never crashes or +// disables the session. Inline (`rml_inline`) surfaces are not watched. struct UiSurfaceSpec { std::string rml_inline{}; // inline RML document text std::string rml_path{}; // path to an .rml asset (assets/<unit>/…) 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; diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp index f5eed5e..22065b6 100644 --- a/packages/kernel/tests/test_kernel.cpp +++ b/packages/kernel/tests/test_kernel.cpp @@ -17,6 +17,8 @@ #include "../src/vt_core.hpp" #include <cstdlib> +#include <filesystem> +#include <fstream> #include <memory> #include <optional> #include <stdexcept> @@ -1268,6 +1270,314 @@ TEST_CASE("substrate: set_size resizes the render target (grow renders, shrink r } // ============================================================================ +// slice-10 / rml_path + dev HOT-RELOAD. A ui surface loads its document from a +// FILE (UiSurfaceSpec::rml_path), and a dev watcher reloads it live on a save — +// preserving the RmlUi context, data model, the extension's registered bindings, +// and the surface geometry/visibility. The reload is exercised deterministically +// via the Server::ui_reload_surface() test seam (no inotify race). Headless+ +// gles2, position-aware ui_pixel readback like the alpha/clip/resize tests. +// ============================================================================ + +namespace { + +// Write `contents` to `path` (truncating). Returns false on failure. +auto write_file(const std::filesystem::path& path, const std::string& contents) -> bool { + std::ofstream f(path, std::ios::out | std::ios::trunc | std::ios::binary); + if (!f) { + return false; + } + f << contents; + return static_cast<bool>(f); +} + +// A full-bleed body of one color. `color` is an RCSS hex like "#2080e0". +auto full_body_rml(const std::string& color) -> std::string { + return "<rml><head><style>" + "body { margin: 0px; }" + "#fill { display: block; position: absolute; left: 0px; top: 0px;" + " width: 4000px; height: 4000px; background-color: " + + color + "; }" + "</style></head><body data-model=\"ui\"><div id=\"fill\"></div></body></rml>"; +} + +// An extension that loads its surface from a file path (no inline RML). +class PathSurfaceExtension : public unbox::kernel::Extension { +public: + explicit PathSurfaceExtension(std::string path) : path_(std::move(path)) {} + auto manifest() const -> const Manifest& override { return manifest_; } + void activate(Host& host) override { + UiSurfaceSpec spec; + spec.rml_path = path_; // absolute path => loaded as-is + spec.x = 0; + spec.y = 0; + spec.width = 80; + spec.height = 80; + spec.layer = unbox::kernel::SceneLayer::overlay; + spec.visible = true; + surface_ = host.ui().create_surface(spec); + } + [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } + +private: + std::string path_; + Manifest manifest_{"path-surface-test", Tier::standard, {}}; + std::unique_ptr<UiSurface> surface_; +}; + +// True if px (RRGGBBAA) is ~green #20c040, opaque. +auto is_green(unsigned int px) -> bool { + const int r = static_cast<int>((px >> 24) & 0xff); + const int g = static_cast<int>((px >> 16) & 0xff); + const int b = static_cast<int>((px >> 8) & 0xff); + return (px & 0xffu) == 0xffu && g > 140 && g > r && g > b && r < 90; +} +// True if px (RRGGBBAA) is ~red #d03020, opaque. +auto is_red(unsigned int px) -> bool { + const int r = static_cast<int>((px >> 24) & 0xff); + const int g = static_cast<int>((px >> 16) & 0xff); + const int b = static_cast<int>((px >> 8) & 0xff); + return (px & 0xffu) == 0xffu && r > 150 && r > g && r > b && g < 90; +} + +// A unique temp path under the system temp dir for this test run. +auto temp_rml(const char* tag) -> std::filesystem::path { + auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + return dir / (std::string("hot-reload-") + tag + "-" + + std::to_string(::getpid()) + ".rml"); +} + +} // namespace + +TEST_CASE("substrate: load a surface document from rml_path (file), render its color") { + setenv("WLR_BACKENDS", "headless", 1); + setenv("WLR_RENDERER", "gles2", 1); + setenv("WLR_HEADLESS_OUTPUTS", "1", 1); + setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); + + const auto path = temp_rml("load"); + REQUIRE(write_file(path, full_body_rml("#20c040"))); // green + + auto server = unbox::kernel::Server::create({}); + auto* ext = new PathSurfaceExtension(path.string()); + server->install(std::unique_ptr<unbox::kernel::Extension>(ext)); + server->activate_extensions(); + pump(*server, 30); // lazy first-load happens on first render + + if (!ext->has_surface() || server->ui_frame_count() == 0) { + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); + std::error_code ec; + std::filesystem::remove(path, ec); + return; // no GL path: skip + } + + // The file's full-body green is composited (proves rml_path loaded a file). + CHECK(is_green(server->ui_pixel(40, 40))); + + std::error_code ec; + std::filesystem::remove(path, ec); + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); +} + +TEST_CASE("substrate: rml_path resolves a RELATIVE path against UNBOX_ASSET_DIR") { + setenv("WLR_BACKENDS", "headless", 1); + setenv("WLR_RENDERER", "gles2", 1); + setenv("WLR_HEADLESS_OUTPUTS", "1", 1); + setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); + + // Lay out <assetroot>/unit-x/doc.rml and load it via the RELATIVE path + // "unit-x/doc.rml" with UNBOX_ASSET_DIR pointing at <assetroot>. + const auto root = std::filesystem::temp_directory_path() / "unbox-kernel-tests" / + (std::string("assetroot-") + std::to_string(::getpid())); + const auto unit_dir = root / "unit-x"; + std::error_code ec; + std::filesystem::create_directories(unit_dir, ec); + REQUIRE(write_file(unit_dir / "doc.rml", full_body_rml("#20c040"))); // green + setenv("UNBOX_ASSET_DIR", root.string().c_str(), 1); + + auto server = unbox::kernel::Server::create({}); + auto* ext = new PathSurfaceExtension("unit-x/doc.rml"); // RELATIVE + server->install(std::unique_ptr<unbox::kernel::Extension>(ext)); + server->activate_extensions(); + pump(*server, 30); + + if (ext->has_surface() && server->ui_frame_count() > 0) { + CHECK(is_green(server->ui_pixel(40, 40))); // resolved + loaded + } + + unsetenv("UNBOX_ASSET_DIR"); + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); + std::filesystem::remove_all(root, ec); +} + +TEST_CASE("substrate: hot-reload re-parses RCSS (file change -> new color)") { + setenv("WLR_BACKENDS", "headless", 1); + setenv("WLR_RENDERER", "gles2", 1); + setenv("WLR_HEADLESS_OUTPUTS", "1", 1); + setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); + + const auto path = temp_rml("recolor"); + REQUIRE(write_file(path, full_body_rml("#20c040"))); // green first + + auto server = unbox::kernel::Server::create({}); + auto* ext = new PathSurfaceExtension(path.string()); + server->install(std::unique_ptr<unbox::kernel::Extension>(ext)); + server->activate_extensions(); + pump(*server, 30); + + if (!ext->has_surface() || server->ui_frame_count() == 0) { + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); + std::error_code ec; + std::filesystem::remove(path, ec); + return; + } + CHECK(is_green(server->ui_pixel(40, 40))); + + // Rewrite the file with a DIFFERENT body color, then trigger reload via the + // deterministic test seam. The new RCSS color must composite — proof that + // reload re-parses RCSS (ClearStyleSheetCache) and re-loads the document. + REQUIRE(write_file(path, full_body_rml("#d03020"))); // red now + CHECK(server->ui_reload_surface()); // a NEW doc installed + pump(*server, 10); + CHECK(is_red(server->ui_pixel(40, 40))); // failing-then-passing + CHECK_FALSE(is_green(server->ui_pixel(40, 40))); + + std::error_code ec; + std::filesystem::remove(path, ec); + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); +} + +namespace { +// A file-backed list document + an extension that binds a runtime-sized list. +const char* kListReloadRml = R"RML(<rml> +<head><style> +body { margin: 0px; background-color: #101010; } +p { display: block; } +</style></head> +<body data-model="ui"> +<p data-for="row : slots"><span>{{ row.title }}</span></p> +</body> +</rml>)RML"; + +class ListPathExtension : public unbox::kernel::Extension { +public: + explicit ListPathExtension(std::string path) : path_(std::move(path)) {} + auto manifest() const -> const Manifest& override { return manifest_; } + void activate(Host& host) override { + titles = {"a", "b", "c"}; + UiSurfaceSpec spec; + spec.rml_path = path_; + spec.width = 200; + spec.height = 200; + spec.visible = true; + surface_ = host.ui().create_surface(spec); + if (surface_ != nullptr) { + surface_->bind_list("slots", [this] { return titles.size(); }); + surface_->bind_list_string("slots", "title", + [this](std::size_t r) { return titles.at(r); }); + } + } + void set_rows(std::vector<std::string> rows) { + titles = std::move(rows); + if (surface_ != nullptr) { + surface_->dirty("slots"); + } + } + std::vector<std::string> titles; + [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } + +private: + std::string path_; + Manifest manifest_{"list-path-test", Tier::standard, {}}; + std::unique_ptr<UiSurface> surface_; +}; +} // namespace + +TEST_CASE("substrate: hot-reload preserves a list data binding") { + setenv("WLR_BACKENDS", "headless", 1); + setenv("WLR_RENDERER", "gles2", 1); + setenv("WLR_HEADLESS_OUTPUTS", "1", 1); + setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); + + const auto path = temp_rml("list"); + REQUIRE(write_file(path, kListReloadRml)); + + auto server = unbox::kernel::Server::create({}); + auto* ext = new ListPathExtension(path.string()); + server->install(std::unique_ptr<unbox::kernel::Extension>(ext)); + server->activate_extensions(); + pump(*server, 30); + + if (!ext->has_surface() || server->ui_frame_count() == 0) { + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); + std::error_code ec; + std::filesystem::remove(path, ec); + return; + } + // 3 bound rows render 3 <span> rows. + CHECK(server->ui_element_count("span") == 3); + + // Reload the SAME file (no re-registration by the extension). The bindings + // must survive: the list still renders its rows after reload. + REQUIRE(server->ui_reload_surface()); + pump(*server, 5); + CHECK(server->ui_element_count("span") == 3); // bindings preserved across reload + + // And mutating the vector + dirty still works AFTER reload (the getter the + // extension registered once, before first frame, is still live). + ext->set_rows({"one", "two", "three", "four", "five"}); + pump(*server, 5); + CHECK(server->ui_element_count("span") == 5); + + std::error_code ec; + std::filesystem::remove(path, ec); + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); +} + +TEST_CASE("substrate: a malformed hot-reload is isolated; old doc kept; recovers") { + setenv("WLR_BACKENDS", "headless", 1); + setenv("WLR_RENDERER", "gles2", 1); + setenv("WLR_HEADLESS_OUTPUTS", "1", 1); + setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); + + const auto path = temp_rml("malformed"); + REQUIRE(write_file(path, full_body_rml("#20c040"))); // good green first + + auto server = unbox::kernel::Server::create({}); + auto* ext = new PathSurfaceExtension(path.string()); + server->install(std::unique_ptr<unbox::kernel::Extension>(ext)); + server->activate_extensions(); + pump(*server, 30); + + if (!ext->has_surface() || server->ui_frame_count() == 0) { + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); + std::error_code ec; + std::filesystem::remove(path, ec); + return; + } + CHECK(is_green(server->ui_pixel(40, 40))); + + // Save a BROKEN file (not a valid RML document) and reload: no throw escapes, + // the previous GOOD document keeps rendering, and the session is alive. + REQUIRE(write_file(path, std::string("this is <not !! valid &&& rml at all"))); + CHECK_FALSE(server->ui_reload_surface()); // no new doc installed + pump(*server, 10); + CHECK(is_green(server->ui_pixel(40, 40))); // OLD good doc still rendering + CHECK(server->ui_frame_count() > 0); // session alive, still ticking + + // A subsequent GOOD save recovers (now red). + REQUIRE(write_file(path, full_body_rml("#d03020"))); + CHECK(server->ui_reload_surface()); + pump(*server, 10); + CHECK(is_red(server->ui_pixel(40, 40))); + + std::error_code ec; + std::filesystem::remove(path, ec); + unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); +} + +// ============================================================================ // VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The // glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this // helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12. |
