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 | |
| 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.
| -rw-r--r-- | packages/kernel/include/unbox/kernel/frames.hpp | 91 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/host.hpp | 33 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 25 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 1 | ||||
| -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 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 107 |
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()); +} |
