summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--packages/kernel/include/unbox/kernel/frames.hpp91
-rw-r--r--packages/kernel/include/unbox/kernel/host.hpp33
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp25
-rw-r--r--packages/kernel/meson.build1
-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
-rw-r--r--packages/kernel/tests/test_kernel.cpp107
11 files changed, 538 insertions, 0 deletions
diff --git a/packages/kernel/include/unbox/kernel/frames.hpp b/packages/kernel/include/unbox/kernel/frames.hpp
new file mode 100644
index 0000000..8c2ea2b
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/frames.hpp
@@ -0,0 +1,91 @@
+#pragma once
+
+#include <cstdint>
+
+// A typed, RAII per-frame animation primitive. While at least one FrameRequest
+// is alive the kernel SCHEDULES output frames continuously (so an animation
+// advances even when the scene is otherwise idle) and runs each request's
+// callback once per rendered output frame, BEFORE the ui substrate ticks and
+// the scene commits — so a callback that updates state + UiSurface::dirty() is
+// rendered THAT frame. When the last request dies the kernel stops requesting
+// frames (no busy render at rest). Each FrameRequest is a move-only handle,
+// mirroring FileWatch / Subscription / SurfaceRegistration; destroying (or
+// reset()/move-out) the handle stops its callback.
+//
+// See Host::request_frames() for the registration entry point + the full
+// per-frame / dt / error-isolation / driver-output semantics.
+//
+// Single wl_event_loop thread throughout; no internal locking.
+
+namespace unbox::kernel {
+
+namespace detail {
+
+// The registry a FrameRequest unregisters from. The kernel's FrameDriver
+// implements this; the handle holds a borrow + a token (like FileWatch /
+// PointerAssoc's token defense) so a stale handle can never tear down a reused
+// slot. Abstract so frames.hpp stays free of wlr/loop internals.
+class FrameRegistry {
+public:
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ virtual ~FrameRegistry() = default;
+ // Stop the per-frame callback identified by `token`. Idempotent / no-op if
+ // already gone.
+ virtual void remove_frame_request(Token token) noexcept = 0;
+
+protected:
+ FrameRegistry() = default;
+};
+
+} // namespace detail
+
+// A live per-frame animation callback. Move-only RAII: destruction / reset() /
+// move-out stops the callback (and, if it was the last one, the kernel stops
+// requesting frames). Hold it as a member of the entity that owns the animation
+// (e.g. an extension), so the callback's lifetime equals that entity's. A
+// default-constructed handle is inactive.
+class FrameRequest {
+public:
+ FrameRequest() = default;
+ FrameRequest(detail::FrameRegistry* registry, detail::FrameRegistry::Token token)
+ : registry_(registry), token_(token) {}
+
+ FrameRequest(FrameRequest&& other) noexcept
+ : registry_(other.registry_), token_(other.token_) {
+ other.registry_ = nullptr;
+ other.token_ = detail::FrameRegistry::invalid_token;
+ }
+ auto operator=(FrameRequest&& other) noexcept -> FrameRequest& {
+ if (this != &other) {
+ reset();
+ registry_ = other.registry_;
+ token_ = other.token_;
+ other.registry_ = nullptr;
+ other.token_ = detail::FrameRegistry::invalid_token;
+ }
+ return *this;
+ }
+ FrameRequest(const FrameRequest&) = delete;
+ auto operator=(const FrameRequest&) -> FrameRequest& = delete;
+
+ ~FrameRequest() { reset(); }
+
+ // Stop the per-frame callback early. Idempotent.
+ void reset() noexcept {
+ if (registry_ != nullptr) {
+ registry_->remove_frame_request(token_);
+ registry_ = nullptr;
+ token_ = detail::FrameRegistry::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return registry_ != nullptr; }
+
+private:
+ detail::FrameRegistry* registry_ = nullptr;
+ detail::FrameRegistry::Token token_ = detail::FrameRegistry::invalid_token;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/host.hpp b/packages/kernel/include/unbox/kernel/host.hpp
index f68875d..7be327a 100644
--- a/packages/kernel/include/unbox/kernel/host.hpp
+++ b/packages/kernel/include/unbox/kernel/host.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include <unbox/kernel/frames.hpp>
#include <unbox/kernel/hooks.hpp>
#include <unbox/kernel/surface_registry.hpp>
#include <unbox/kernel/watch.hpp>
@@ -220,6 +221,32 @@ public:
return register_file_watch(std::string(path), std::move(on_change));
}
+ // ---- Per-frame animation tick (C++-driven, RCSS-timed animations) ----
+ // Run `on_frame(dt_seconds)` once per rendered output frame for as long as
+ // the returned handle lives; reset/destroy the handle to stop. `dt_seconds`
+ // is the monotonic time since the PREVIOUS rendered frame (clamp large gaps
+ // yourself in the callback — e.g. after a stall the first dt can be big).
+ // While >=1 FrameRequest is alive the kernel SCHEDULES output frames
+ // continuously (wlr_output_schedule_frame) so animations advance even when
+ // the scene is otherwise idle; when the last handle dies it stops requesting
+ // frames (no busy render at rest). Callbacks run BEFORE the ui substrate
+ // ticks + the scene commits each frame, so a callback that updates state +
+ // UiSurface::dirty() is composited THAT frame. The set is drained
+ // RE-ENTRANCY-SAFE: a callback may add or remove requests, including its own
+ // (a request added during a drain first fires next frame; one removed during
+ // a drain does not fire again this drain). ERROR-ISOLATED to YOUR extension:
+ // a throw out of `on_frame` disables your extension (same boundary as
+ // watch_file / a hook), never the session. Hold the handle as a member.
+ //
+ // The kernel drives this from the PRIMARY output's frame event (the first
+ // output added); secondary outputs share the same dt and do not double-fire
+ // the callbacks. A backend with no event loop / no output yields an inactive
+ // handle (active() == false) — mirror of watch_file's no-loop behaviour.
+ [[nodiscard]] auto request_frames(std::function<void(double dt_seconds)> on_frame)
+ -> FrameRequest {
+ return register_frame_request(std::move(on_frame));
+ }
+
// ---- Kernel event catalogue ----
// Subscribe through these to react to kernel-owned input/output. Each
// returns an Event/Filter you subscribe to with YOUR extension id (the
@@ -337,6 +364,12 @@ protected:
std::function<void()> on_change)
-> FileWatch = 0;
+ // Non-template per-frame-callback core (the request_frames shim above
+ // injects YOUR id for error isolation). Registers on the kernel's frame
+ // driver; returns an inactive handle if there is no event loop / output.
+ [[nodiscard]] virtual auto register_frame_request(std::function<void(double)> on_frame)
+ -> FrameRequest = 0;
+
// The kernel-owned, session-wide surface->tree association store (shared by
// ALL extensions; the host_surface/scene_tree_for shims above route here).
[[nodiscard]] virtual auto surface_store() -> detail::PointerAssoc& = 0;
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index 0ab48b3..25ad406 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -5,6 +5,7 @@
#include <cstddef>
#include <functional>
#include <memory>
+#include <optional>
#include <string>
#include <string_view>
@@ -203,6 +204,30 @@ public:
virtual void dirty(std::string_view name) = 0;
virtual void dirty() = 0;
+ // ---- RCSS easing reader (timing authored in RCSS, driven from C++) ----
+ // The timing+easing authored on an element's `transition` for a given
+ // property, so a C++ animator can REUSE the RCSS-defined values (which stay
+ // hot-reloadable). The substrate looks the element up by id within THIS
+ // surface's document, reads its computed `transition`, and returns the entry
+ // whose target matches `property` — resolving the property NAME (e.g.
+ // "transform", "opacity") to RmlUi's internal property id, and honouring an
+ // `all` transition (which matches every property) as a fallback. `ease` is
+ // RmlUi's own tween evaluator wrapped as a pure function (normalized
+ // progress [0,1] -> eased value); NO RmlUi types cross this contract.
+ // Returns nullopt if the surface/element/transition is absent (the caller
+ // falls back to its own default). Cheap; call at animation START — and again
+ // after a hot-reload, because the reloaded element is re-parsed so a fresh
+ // call sees the new values. Document must have loaded (its first frame has
+ // rendered) for computed values to exist; before that, nullopt.
+ struct TransitionTiming {
+ double duration; // seconds
+ double delay; // seconds
+ std::function<float(float)> ease; // normalized progress [0,1] -> eased value
+ };
+ [[nodiscard]] virtual auto transition_timing(std::string_view element_id,
+ std::string_view property) const
+ -> std::optional<TransitionTiming> = 0;
+
protected:
UiSurface() = default;
};
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index 0a25088..9b4f81b 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -56,6 +56,7 @@ kernel_lib = static_library(
'src/server.cpp',
'src/input.cpp',
'src/file_watcher.cpp',
+ 'src/frame_driver.cpp',
'src/ui_substrate.cpp',
'src/rmlui_renderer_gl3.cpp',
# Listing the generated header as a source forces codegen before any kernel
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_;
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index ef50b9d..da2cbf0 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -2448,3 +2448,110 @@ TEST_CASE("grab: a fresh stream can flip owner (substrate then bus)") {
CHECK(g.press(false) == GrabOwner::bus);
CHECK(g.release() == GrabOwner::bus);
}
+
+// ============================================================================
+// RCSS easing reader: UiSurface::transition_timing parses an element's authored
+// `transition` (duration/delay + the RmlUi tween wrapped as a pure function),
+// resolves the property name to its id, and honours an `all` transition.
+// GL/seat note: transition_timing reads COMPUTED values, which only exist once
+// the document has loaded + a context update ran — so it needs the gles2 GL
+// bridge (headless+gles2). On a box with no GL path the surface is null and the
+// case degrades to a no-op (asserts nothing), exactly like the other substrate
+// cases. The frame-callback (request_frames) scheduling is real-seat / GL-frame
+// driven and is exercised only under a live output, not mocked here.
+// ============================================================================
+
+namespace {
+
+// transform => an exact `transform` transition; opacity => only via the `all`
+// fallback; the #plain element has no transition at all (nullopt).
+const char* kEaseRml = R"RML(<rml>
+<head>
+<style>
+body { width: 200px; height: 120px; }
+#anim { display: block; width: 100px; height: 20px;
+ transition: transform 0.2s cubic-in-out 0.05s; }
+#allel { display: block; width: 100px; height: 20px;
+ transition: all 0.3s; }
+#plain { display: block; width: 100px; height: 20px; }
+</style>
+</head>
+<body data-model="ui">
+<div id="anim"></div>
+<div id="allel"></div>
+<div id="plain"></div>
+</body>
+</rml>)RML";
+
+class EaseTestExtension : public unbox::kernel::Extension {
+public:
+ auto manifest() const -> const Manifest& override { return manifest_; }
+ void activate(Host& host) override {
+ UiSurfaceSpec spec;
+ spec.rml_inline = kEaseRml;
+ spec.x = 0;
+ spec.y = 0;
+ spec.width = 200;
+ spec.height = 120;
+ spec.visible = true;
+ surface_ = host.ui().create_surface(spec);
+ }
+ [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
+ [[nodiscard]] auto surface() -> UiSurface* { return surface_.get(); }
+
+private:
+ Manifest manifest_{"ease-test", Tier::standard, {}};
+ std::unique_ptr<UiSurface> surface_;
+};
+
+} // namespace
+
+TEST_CASE("ui: transition_timing reads RCSS duration/delay + tween, resolves property + all") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new EaseTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ pump(*server, 60); // load the document + run a context update => computed values
+
+ if (!ext->has_surface()) {
+ // No GL path on this box: surface is null, nothing computed. Graceful.
+ CHECK(true);
+ return;
+ }
+ UiSurface* s = ext->surface();
+
+ // (1) Exact property match: transform 0.2s cubic-in-out 0.05s.
+ const auto tt = s->transition_timing("anim", "transform");
+ REQUIRE(tt.has_value());
+ CHECK(tt->duration == doctest::Approx(0.2));
+ CHECK(tt->delay == doctest::Approx(0.05));
+ REQUIRE(static_cast<bool>(tt->ease));
+ // cubic-in-out: clamped endpoints 0 and 1, monotone, midpoint ~0.5.
+ CHECK(tt->ease(0.0F) == doctest::Approx(0.0F));
+ CHECK(tt->ease(1.0F) == doctest::Approx(1.0F));
+ CHECK(tt->ease(0.5F) == doctest::Approx(0.5F)); // symmetric in-out hits 0.5 at t=0.5
+ const float q = tt->ease(0.25F);
+ CHECK(q > 0.0F);
+ CHECK(q < 0.5F); // ease-in region rises slower than linear
+
+ // (2) `all` fallback: #allel has `all 0.3s` (no tween => RmlUi's default
+ // linear); a property with no exact entry resolves through the all
+ // transition (linear => ease(t) == t).
+ const auto allt = s->transition_timing("allel", "transform");
+ REQUIRE(allt.has_value());
+ CHECK(allt->duration == doctest::Approx(0.3));
+ CHECK(allt->delay == doctest::Approx(0.0));
+ REQUIRE(static_cast<bool>(allt->ease));
+ CHECK(allt->ease(0.5F) == doctest::Approx(0.5F)); // linear
+
+ // (3) No transition on the element => nullopt.
+ CHECK_FALSE(s->transition_timing("plain", "transform").has_value());
+ // (4) Unknown element id => nullopt.
+ CHECK_FALSE(s->transition_timing("nope", "transform").has_value());
+ // (5) Unparseable property name => nullopt (no exact match, no `all` here).
+ CHECK_FALSE(s->transition_timing("anim", "not-a-real-property").has_value());
+}