diff options
| author | Adam Malczewski <[email protected]> | 2026-06-14 16:00:49 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-14 16:00:49 +0900 |
| commit | 41abc47bfb4a5098bff611e3e241d2b63788cbec (patch) | |
| tree | 9df638f56c16c2cc6cf9dac0648cb50687ff0929 /packages/kernel/src | |
| parent | 89c40575c353dfd3c9fcaf60d2bd45d3fa2b6792 (diff) | |
| download | unbox-41abc47bfb4a5098bff611e3e241d2b63788cbec.tar.gz unbox-41abc47bfb4a5098bff611e3e241d2b63788cbec.zip | |
kernel: add request_frames() frame callback + UiSurface::transition_timing()
Two additive primitives for C++-driven, RCSS-tunable animation:
- Host::request_frames(cb) -> FrameRequest: a per-frame callback (RAII handle)
run before tick_all each frame; the kernel schedules frames continuously while
>=1 request is alive and stops at rest. Fills the missing animation timer.
- UiSurface::transition_timing(element_id, property): reads the RCSS-authored
transition duration + easing, returning RmlUi's tween wrapped as a pure
std::function (no RmlUi types cross the contract) so an extension can drive its
own animation with hot-reloadable, designer-tunable timing/easing.
Diffstat (limited to 'packages/kernel/src')
| -rw-r--r-- | packages/kernel/src/frame_driver.cpp | 51 | ||||
| -rw-r--r-- | packages/kernel/src/frame_driver.hpp | 65 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 68 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 33 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 61 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 3 |
6 files changed, 281 insertions, 0 deletions
diff --git a/packages/kernel/src/frame_driver.cpp b/packages/kernel/src/frame_driver.cpp new file mode 100644 index 0000000..eb89ee1 --- /dev/null +++ b/packages/kernel/src/frame_driver.cpp @@ -0,0 +1,51 @@ +#include "frame_driver.hpp" + +namespace unbox::kernel { + +FrameDriver::FrameDriver(std::function<void(ExtensionId)> disable) : disable_(std::move(disable)) {} + +auto FrameDriver::add(std::function<void(double)> on_frame, ExtensionId who) -> FrameRequest { + const Token token = ++next_token_; + entries_.emplace(token, Entry{std::move(on_frame), who}); + return FrameRequest(this, token); +} + +void FrameDriver::remove_frame_request(Token token) noexcept { entries_.erase(token); } + +void FrameDriver::drain(double dt_seconds) { + if (entries_.empty()) { + return; + } + // Snapshot the live tokens so a callback that adds/removes requests (its own + // or another's) mid-drain cannot invalidate iteration: a token added during + // the drain is not in this snapshot (fires next frame), a token removed + // during the drain is skipped via the re-lookup below (does not fire again). + std::vector<Token> to_fire; + to_fire.reserve(entries_.size()); + for (const auto& [token, e] : entries_) { + to_fire.push_back(token); + } + for (const Token token : to_fire) { + auto it = entries_.find(token); + if (it == entries_.end()) { + continue; // removed by an earlier callback this drain + } + // Copy what we need before invoking: the callback may remove this entry. + std::function<void(double)> cb = it->second.on_frame; + const ExtensionId who = it->second.who; + if (!cb) { + continue; + } + try { + cb(dt_seconds); + } catch (...) { + // Same isolation boundary as a throwing hook/getter/file-watch: + // disable the owning extension, never take down the loop/session. + if (disable_) { + disable_(who); + } + } + } +} + +} // namespace unbox::kernel diff --git a/packages/kernel/src/frame_driver.hpp b/packages/kernel/src/frame_driver.hpp new file mode 100644 index 0000000..fdf806a --- /dev/null +++ b/packages/kernel/src/frame_driver.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include <unbox/kernel/frames.hpp> +#include <unbox/kernel/hooks.hpp> // ExtensionId + +#include <functional> +#include <unordered_map> +#include <vector> + +// The kernel's per-frame animation driver. Holds the set of live per-frame +// callbacks registered via Host::request_frames; the kernel's output frame +// handler drains them (in order, reentrancy-safe) BEFORE the ui substrate ticks +// and the scene commits, and — while the set is non-empty — keeps requesting +// output frames so animations advance even when the scene is idle. +// +// Error-isolated: a throwing callback is caught at the boundary and the owning +// extension is disabled via the injected sink (same contract as hooks / file +// watches), never the session. +// +// Single wl_event_loop thread throughout; no internal locking. + +namespace unbox::kernel { + +class FrameDriver final : public detail::FrameRegistry { +public: + using Token = detail::FrameRegistry::Token; + + // `disable` disables the owning extension when its callback throws (injected + // by the kernel, same as the bus/substrate/file-watch isolation sink). + explicit FrameDriver(std::function<void(ExtensionId)> disable); + ~FrameDriver() override = default; + FrameDriver(const FrameDriver&) = delete; + auto operator=(const FrameDriver&) -> FrameDriver& = delete; + + // Register `on_frame` (fired each frame with dt seconds, error-isolated to + // `who`). Returns a FrameRequest RAII handle; destroying it removes the + // callback. Always active (the driver itself has no loop dependency — the + // caller decides whether a handle is inert when there is no output). + [[nodiscard]] auto add(std::function<void(double)> on_frame, ExtensionId who) -> FrameRequest; + + // True if at least one callback is live (the kernel keeps scheduling frames + // while this holds). + [[nodiscard]] auto has_requests() const noexcept -> bool { return !entries_.empty(); } + + // Fire every live callback once with `dt_seconds`. Reentrancy-safe: a + // callback may add or remove requests (including its own) mid-drain — a + // request added during the drain first fires NEXT frame, one removed during + // the drain does not fire again this drain. + void drain(double dt_seconds); + + // detail::FrameRegistry — stop the callback with this token (handle dtor). + void remove_frame_request(Token token) noexcept override; + +private: + struct Entry { + std::function<void(double)> on_frame; + ExtensionId who{}; + }; + + std::function<void(ExtensionId)> disable_; + Token next_token_ = 0; + std::unordered_map<Token, Entry> entries_; +}; + +} // namespace unbox::kernel diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp index 66f2cc3..15158a9 100644 --- a/packages/kernel/src/server.cpp +++ b/packages/kernel/src/server.cpp @@ -399,6 +399,26 @@ auto Server::Impl::file_watcher() -> FileWatcher* { return watcher.get(); } +auto Server::Impl::frame_driver() -> FrameDriver* { + // Lazily create the per-frame animation driver on first use, carrying the + // kernel's disable sink for error isolation. No loop/wlr resource of its own + // (the frame handler drives it), so it is always creatable. + if (frames == nullptr) { + frames = std::make_unique<FrameDriver>([this](ExtensionId who) { disable(who); }); + } + return frames.get(); +} + +void Server::Impl::schedule_driver_frame() { + // Pick / keep the primary driving output: the first one still present. + if (frame_driver_output == nullptr && !outputs.empty()) { + frame_driver_output = outputs.front()->output; + } + if (frame_driver_output != nullptr) { + wlr_output_schedule_frame(frame_driver_output); + } +} + void Server::Impl::shutdown() { // Destroy extensions FIRST, in reverse activation order: their RAII members // (Subscriptions, Listeners, scene nodes) release while the wlr objects @@ -531,6 +551,33 @@ 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*) { + // Per-frame animation callbacks (Host::request_frames) run on the PRIMARY + // output's frame only — the first output added is the frame driver, so a + // multi-output session gets ONE shared dt and the callbacks fire once per + // displayed frame rather than once per output. They run BEFORE + // substrate->tick_all()/commit so a callback that updates state + + // UiSurface::dirty() is composited THIS frame. + if (frame_driver_output == nullptr) { + frame_driver_output = output->output; // promote a survivor / first output + } + if (output->output == frame_driver_output && frames != nullptr && + frames->has_requests()) { + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + const double t = static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9; + const double dt = (last_frame_time < 0.0) ? 0.0 : (t - last_frame_time); + last_frame_time = t; + frames->drain(dt); // error-isolated; may add/remove requests (incl. its own) + // Keep the frames coming while any request is still alive after the + // drain (a callback may have removed the last one — then we stop, + // returning to idle: no busy render at rest). + if (frames->has_requests()) { + wlr_output_schedule_frame(frame_driver_output); + } else { + last_frame_time = -1.0; // reset the dt base for the next animation + } + } + if (substrate != nullptr) { substrate->tick_all(); } @@ -548,6 +595,27 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) { output->destroy.connect(wlr_output->events.destroy, [this, output](void*) { const OutputEvent ev{output->output}; ev_output_removed.emit(ev); + // If the frame DRIVER output is going away, re-point it (and reset the dt + // base) so live animations keep advancing on a surviving output. This + // MUST all happen BEFORE `outputs.remove_if` below: that call destroys + // `output` together with THIS very listener's std::function storage, so + // it has to be the LAST action — nothing may touch the lambda or its + // captures afterwards. + if (frame_driver_output == output->output) { + frame_driver_output = nullptr; + last_frame_time = -1.0; + // Promote a SURVIVING output (skip the one being destroyed) and, if + // any frame request is alive, schedule its next frame. + for (const auto& owned : outputs) { + if (owned.get() != output) { + frame_driver_output = owned->output; + break; + } + } + if (frame_driver_output != nullptr && frames != nullptr && frames->has_requests()) { + wlr_output_schedule_frame(frame_driver_output); + } + } // Last action: destroys `output` (and these listeners with it). outputs.remove_if([output](const auto& owned) { return owned.get() == output; }); }); diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp index 61c073f..f99fe16 100644 --- a/packages/kernel/src/server_impl.hpp +++ b/packages/kernel/src/server_impl.hpp @@ -6,6 +6,7 @@ #include <unbox/kernel/wlr.hpp> #include "file_watcher.hpp" +#include "frame_driver.hpp" #include "listener.hpp" #include "ui_substrate.hpp" @@ -104,6 +105,23 @@ struct Server::Impl : detail::DisableSink { // no wl_event_loop (never in practice — the display always has one). auto file_watcher() -> FileWatcher*; + // The per-frame animation driver (Host::request_frames). Holds the live + // per-frame callbacks; drained from the PRIMARY output's frame handler + // (frame_driver_output) BEFORE substrate->tick_all()/commit. Created lazily + // on the first request; outlives the extensions (their FrameRequest handles + // unregister into it on destruction), so it is a plain Impl member torn down + // after the extension slots in shutdown(). Has no loop/wlr resource itself. + std::unique_ptr<FrameDriver> frames; + auto frame_driver() -> FrameDriver*; + // The output whose frame event drives request_frames: the FIRST output + // added (the primary). One shared dt; secondary outputs do not drive the + // callbacks. Cleared when that output is removed (a survivor is promoted in + // the next frame handler that runs). Borrow; the kernel owns the output. + wlr_output* frame_driver_output = nullptr; + // Monotonic timestamp (seconds) of the previous driving frame; used to + // compute dt. Reset (< 0) until the first driving frame establishes a base. + double last_frame_time = -1.0; + std::list<std::unique_ptr<Output>> outputs; std::list<std::unique_ptr<Keyboard>> keyboards; std::list<std::unique_ptr<TouchDevice>> touch_devices; @@ -167,6 +185,9 @@ struct Server::Impl : detail::DisableSink { void handle_new_output(wlr_output* output); void start_substrate(); // builds the ui substrate; never throws, may be unavailable void register_hook(detail::HookBase& hook); // track for purge/disable + // Schedule a frame on the driving output (if any) so the continuous-frame + // loop keeps advancing while >=1 FrameRequest is alive. No-op if no output. + void schedule_driver_frame(); // server.cpp — extension host void install(std::unique_ptr<Extension> extension); @@ -275,6 +296,18 @@ protected: } return w->add(path, std::move(on_change), id_); } + auto register_frame_request(std::function<void(double)> on_frame) -> FrameRequest override { + FrameDriver* d = server_->frame_driver(); + if (d == nullptr) { + return FrameRequest{}; // no event loop: inert handle (mirror watch_file) + } + FrameRequest req = d->add(std::move(on_frame), id_); + // Kick the driving output so the continuous-frame loop starts THIS turn + // even if the scene was idle (the frame handler re-arms each frame while + // requests live). Safe before any output exists (no-op then). + server_->schedule_driver_frame(); + return req; + } private: Server::Impl* server_; diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp index 4c9efe8..9b9593d 100644 --- a/packages/kernel/src/ui_substrate.cpp +++ b/packages/kernel/src/ui_substrate.cpp @@ -3,6 +3,7 @@ #include "file_watcher.hpp" #include "rmlui_renderer_gl3.h" +#include <RmlUi/Core/Animation.h> // Transition / TransitionList / Tween #include <RmlUi/Core/Context.h> #include <RmlUi/Core/Core.h> #include <RmlUi/Core/DataModelHandle.h> @@ -12,7 +13,10 @@ #include <RmlUi/Core/Event.h> #include <RmlUi/Core/Factory.h> #include <RmlUi/Core/ID.h> +#include <RmlUi/Core/Property.h> +#include <RmlUi/Core/StyleSheetSpecification.h> // GetPropertyId #include <RmlUi/Core/SystemInterface.h> +#include <RmlUi/Core/Tween.h> #include <RmlUi/Core/Variant.h> // The kernel owns GL; system EGL/GLES headers are allowed here (same as the @@ -2150,6 +2154,63 @@ void SurfaceHandle::dirty() { } } +auto SurfaceHandle::transition_timing(std::string_view element_id, std::string_view property) const + -> std::optional<TransitionTiming> { + const Surface& s = *surface_; + if (s.document == nullptr) { + return std::nullopt; // not loaded yet => no computed values + } + Rml::Element* el = s.document->GetElementById(Rml::String(element_id)); + if (el == nullptr) { + return std::nullopt; + } + // The computed `transition` property: a TransitionList (none/all + entries). + const Rml::Property* prop = el->GetProperty(Rml::PropertyId::Transition); + if (prop == nullptr) { + return std::nullopt; + } + const Rml::TransitionList list = prop->Get<Rml::TransitionList>(); + if (list.none) { + return std::nullopt; + } + + // Resolve the requested property name (e.g. "transform") to RmlUi's id, then + // find the matching per-property transition. An `all` transition applies to + // every property and serves as the fallback if no exact entry exists; an + // exact entry wins over `all`. + const Rml::PropertyId want = Rml::StyleSheetSpecification::GetPropertyId(Rml::String(property)); + const Rml::Transition* match = nullptr; + if (want != Rml::PropertyId::Invalid) { + for (const Rml::Transition& t : list.transitions) { + if (t.id == want) { + match = &t; + break; + } + } + } + Rml::Tween tween; + double duration = 0.0; + double delay = 0.0; + if (match != nullptr) { + tween = match->tween; + duration = static_cast<double>(match->duration); + delay = static_cast<double>(match->delay); + } else if (list.all && !list.transitions.empty()) { + // `all foo 0.2s ease` parses to a single entry flagged all=true; reuse + // its timing/tween for the requested property. + const Rml::Transition& t = list.transitions.front(); + tween = t.tween; + duration = static_cast<double>(t.duration); + delay = static_cast<double>(t.delay); + } else { + return std::nullopt; + } + + // Wrap RmlUi's Tween BY VALUE — Tween::operator()(float) is the evaluator; + // capturing it keeps RmlUi types out of the contract entirely. + return TransitionTiming{duration, delay, [tween](float t) { return tween(t); }}; +} + // ---- PreviewHandle (public Preview impl) ------------------------------------ PreviewHandle::~PreviewHandle() { substrate_->impl_->destroy_preview(state_); } diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp index d08c7de..1b58d13 100644 --- a/packages/kernel/src/ui_substrate.hpp +++ b/packages/kernel/src/ui_substrate.hpp @@ -118,6 +118,9 @@ public: void on_touch_mode_changed(std::function<void(bool)> callback) override; void dirty(std::string_view name) override; void dirty() override; + [[nodiscard]] auto transition_timing(std::string_view element_id, + std::string_view property) const + -> std::optional<TransitionTiming> override; private: Substrate* substrate_; |
