summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-14 16:00:49 +0900
committerAdam Malczewski <[email protected]>2026-06-14 16:00:49 +0900
commit41abc47bfb4a5098bff611e3e241d2b63788cbec (patch)
tree9df638f56c16c2cc6cf9dac0648cb50687ff0929 /packages/kernel/src
parent89c40575c353dfd3c9fcaf60d2bd45d3fa2b6792 (diff)
downloadunbox-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.cpp51
-rw-r--r--packages/kernel/src/frame_driver.hpp65
-rw-r--r--packages/kernel/src/server.cpp68
-rw-r--r--packages/kernel/src/server_impl.hpp33
-rw-r--r--packages/kernel/src/ui_substrate.cpp61
-rw-r--r--packages/kernel/src/ui_substrate.hpp3
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_;