From 117ad49b2f9bfb6822e94df2706fd7cd0cf2121a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 14 Jun 2026 16:00:49 +0900 Subject: ext-stage-dock: C++-driven interruptible slide animation RmlUi only starts a transition on a class/definition change, never on the inline data-style-transform the dock uses for slide, so keyboard/minimize/restore open-close had stopped animating (snapped). Own the animation in C++ instead: a pure SlideAnimator that every path flows through -- keyboard/minimize/restore play it, a touch drag scrubs it (pause + set position from the finger), and release resumes easing from the current position to the snap target. Duration + easing are read from the #panel RCSS 'transition' via transition_timing(), so they stay hot-reloadable and any named RmlUi tween works. Drives slide per frame via request_frames; the surface now hides when the close animation completes (replaces the old transitionend path). Body=drag-handle / panel=transform split preserved. --- assets/ext-stage-dock/dock.rcss | 15 +- assets/ext-stage-dock/dock.rml | 2 +- packages/ext-stage-dock/src/anim.hpp | 116 ++++++++++ packages/ext-stage-dock/src/extension.cpp | 315 ++++++++++++++++++-------- packages/ext-stage-dock/src/gesture.hpp | 50 ++-- packages/ext-stage-dock/tests/test_policy.cpp | 143 +++++++++++- 6 files changed, 513 insertions(+), 128 deletions(-) create mode 100644 packages/ext-stage-dock/src/anim.hpp diff --git a/assets/ext-stage-dock/dock.rcss b/assets/ext-stage-dock/dock.rcss index 13bdfec..942a820 100644 --- a/assets/ext-stage-dock/dock.rcss +++ b/assets/ext-stage-dock/dock.rcss @@ -34,11 +34,16 @@ div.panel { overflow-x: hidden; overflow-y: auto; } -/* Ease only when NOT dragging: during a finger-follow drag (body.dock has the - gesture, div.panel gets data-class-dragging) there is no transition so each - `slide` update lands instantly under the finger; on release the Controller - clears dragging_, this rule re-applies, and the slide-to-target eases 0.36s. */ -div.panel:not(.dragging) { +/* DECLARATIVE EASING CARRIER for the C++ animator (d1 fix). This `transition` is + NOT animated by RmlUi: RmlUi only starts a CSS transition on a class/definition + change, and `slide` is driven through the inline data-style-transform above, + which RmlUi never animates. Instead the C++ SlideAnimator READS this transition + (duration + tween) via UiSurface::transition_timing("panel", "transform") and + drives slide_px_ itself, frame by frame — so editing the duration/tween here + hot-reloads the on-seat feel with no recompile. The element id="panel" is what + transition_timing looks the element up by. NOTE: RmlUi 6.2 has NO bare `linear` + keyword — use a named tween; `cubic-in-out` is the authored ease here. */ +#panel { transition: transform 0.36s cubic-in-out; } /* Hide both scrollbars (the rail scrolls, but no visible scrollbar). RmlUi diff --git a/assets/ext-stage-dock/dock.rml b/assets/ext-stage-dock/dock.rml index 4ddb47d..de91cba 100644 --- a/assets/ext-stage-dock/dock.rml +++ b/assets/ext-stage-dock/dock.rml @@ -3,7 +3,7 @@ -
+
diff --git a/packages/ext-stage-dock/src/anim.hpp b/packages/ext-stage-dock/src/anim.hpp new file mode 100644 index 0000000..8513815 --- /dev/null +++ b/packages/ext-stage-dock/src/anim.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +// Pure decision core 4 — the SLIDE ANIMATOR: a one-dimensional, interruptible +// easing animator that drives the dock's `slide_px` value over wall-clock time. +// No wlroots / GL / RMLUi — `start()` a run, `tick(dt)` it forward each frame, +// read `value()`; doctest-covered in tests/test_policy.cpp with nothing running. +// +// WHY this lives in C++ (the d1-fix design): RmlUi only starts a CSS transition +// on a class/definition change, NEVER on the inline `data-style-transform` the +// dock uses to render `slide`. So the keyboard / minimize / restore open/close +// paths "snapped" — there was no class flip for RmlUi to animate. We OWN the +// animation here instead: one interruptible animator that every non-finger path +// (keyboard, minimize, restore, drag-RELEASE) flows through, while the duration +// + easing are READ FROM RCSS (UiSurface::transition_timing) so they stay +// hot-reloadable. The finger-driven drag-MOVE bypasses the animator entirely +// (the finger IS the clock — see the glue's drag-move path). +// +// The animator is value-space agnostic: it interpolates `from`->`to` in whatever +// units the caller passes (the glue passes translateX px). The tween is RmlUi's +// own named-transition evaluator wrapped as a pure std::function +// (normalized progress [0,1] -> eased progress), so the on-seat feel matches the +// authored RCSS `transition` exactly. +// +// Single wl_event_loop thread throughout (no internal synchronization). + +namespace unbox::ext_stage_dock::anim { + +// The largest per-frame dt (seconds) the animator will honour. A frame gap (a +// stall, a tab-out, the first frame after the kernel starts scheduling) can hand +// us a huge dt; clamping it keeps a single tick from teleporting past the whole +// animation (it just advances at most one clamp-worth and the next frame +// continues), so the motion stays visible rather than snapping after a hitch. +inline constexpr double kMaxTickDt = 0.1; // 100 ms + +class SlideAnimator { +public: + // Begin (or REPLACE — interruptible) a run from `from` to `to` over + // `duration` seconds, eased by `ease` (normalized progress [0,1] -> eased + // progress in roughly [0,1]). A non-positive duration snaps immediately to + // `to` (done at once). A null `ease` falls back to linear (identity). The + // run is active until elapsed >= duration; interrupting mid-run (calling + // start() again, e.g. a drag-release reversing a keyboard open) simply + // re-anchors from the new `from` you pass (the glue passes the CURRENT + // value), so motion is continuous. + void start(double from, double to, double duration, std::function ease) { + from_ = from; + to_ = to; + duration_ = duration; + ease_ = std::move(ease); + elapsed_ = 0.0; + if (duration_ <= 0.0) { + // Degenerate: nothing to animate, land on the target immediately. + value_ = to_; + active_ = false; + return; + } + value_ = from_; + active_ = true; + } + + // Snap the current value to `value` with NO run (the drag-scrub: the finger + // sets the position directly). Cancels any active run and leaves the animator + // idle, so a subsequent tick() is inert until the next start(). + void set_immediate(double value) { + value_ = value; + active_ = false; + elapsed_ = 0.0; + duration_ = 0.0; + } + + // Advance the run by `dt` seconds (clamped to kMaxTickDt to survive frame + // gaps) and return the new value. When inactive, returns the held value + // unchanged. Marks the run DONE the instant elapsed >= duration, pinning the + // value EXACTLY to `to` (no float drift at the end) and clearing active(). + auto tick(double dt) -> double { + if (!active_) { + return value_; + } + elapsed_ += std::clamp(dt, 0.0, kMaxTickDt); + if (elapsed_ >= duration_) { + value_ = to_; + active_ = false; + return value_; + } + const double progress = elapsed_ / duration_; // (0,1) here (endpoints handled above) + const double eased = ease_ ? static_cast(ease_(static_cast(progress))) + : progress; // null ease == linear + value_ = from_ + (to_ - from_) * eased; + return value_; + } + + // The current value (same as the last tick()/start()/set_immediate result). + [[nodiscard]] auto value() const -> double { return value_; } + + // Whether a run is in progress (start()ed, not yet done, not set_immediate'd). + [[nodiscard]] auto active() const -> bool { return active_; } + + // The run's target (the value tick() converges to). Useful to the glue to + // know which DIRECTION it animated (open vs closed) when a run completes. + [[nodiscard]] auto target() const -> double { return to_; } + +private: + double from_ = 0.0; + double to_ = 0.0; + double duration_ = 0.0; + double elapsed_ = 0.0; + double value_ = 0.0; + std::function ease_; + bool active_ = false; +}; + +} // namespace unbox::ext_stage_dock::anim diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp index 35811c5..57e67ee 100644 --- a/packages/ext-stage-dock/src/extension.cpp +++ b/packages/ext-stage-dock/src/extension.cpp @@ -5,15 +5,20 @@ #include "probe.hpp" #include "reveal.hpp" +#include "anim.hpp" + #include +#include #include #include #include #include #include +#include #include #include +#include #include #include #include @@ -63,6 +68,18 @@ constexpr std::uint32_t kMinimizeMods = WLR_MODIFIER_LOGO; // Super/LOGO // the cards stay 224dp (RCSS) left-aligned, so the extra width is to their right. constexpr int kDockWidth = 288; +// d1-fix C++-driven slide animation. The dock open/close is animated in C++ +// (the SlideAnimator pure core), NOT by RmlUi: RmlUi only starts a CSS +// transition on a class/definition change, never on the inline +// data-style-transform we drive `slide` with, so the keyboard/minimize/restore +// paths used to snap. We read the duration + tween from the RCSS `transition` on +// #panel (UiSurface::transition_timing) so they stay hot-reloadable; these are +// the FALLBACKS used when the document has not rendered a frame yet (so computed +// values do not exist) or authors no transition. kFallbackDurS matches the +// authored 0.36s; the fallback ease is linear (RmlUi 6.2 has no `linear` +// keyword, so the RCSS carrier uses cubic-in-out — see dock.rcss). +constexpr double kFallbackDurS = 0.36; // seconds, matches dock.rcss #panel + // A minimized window's dock entry: the live Toplevel* borrow (valid until its // unmapped event), the frozen Preview (owns the imported texture; null when the // substrate has no GL path), and a copied title (the Toplevel's title() view is @@ -168,24 +185,27 @@ public: // on edge-slop / already-open itself (empty Outcome = ignored). touch_down_ = host.subscribe( host.on_touch_down(), [this](const kernel::TouchDownEvent& e) { - apply(controller_.touch_down(e.touch_id, e.lx, e.ly, e.time_msec)); + // Edge-swipe OPEN begins: a finger landed at the edge. This is a + // finger-DRIVEN scrub (the finger is the clock), so stop any + // running animation frame loop and scrub from here. + apply_scrub(controller_.touch_down(e.touch_id, e.lx, e.ly, e.time_msec), + /*on_drag_start=*/true); }); touch_motion_ = host.subscribe( host.on_touch_motion(), [this](const kernel::TouchMotionEvent& e) { - apply(controller_.touch_motion(e.touch_id, e.lx, e.ly, e.time_msec)); + apply_scrub(controller_.touch_motion(e.touch_id, e.lx, e.ly, e.time_msec), + /*on_drag_start=*/false); }); touch_up_ = host.subscribe( host.on_touch_up(), [this](const kernel::TouchUpEvent& e) { - // A release that commits CLOSE eases out; arm closing_ so the - // dock_settled transitionend hides the surface (gated on - // !controller_.open()). An open release leaves closing_ untouched. - closing_ = true; - apply(controller_.touch_up(e.touch_id, e.time_msec)); + // RELEASE = "resume": ease from the current finger value to the + // snap target the controller decided (open or closed). The + // animator's completion hides the surface on a CLOSE (on_frame). + apply_release(controller_.touch_up(e.touch_id, e.time_msec)); }); touch_cancel_ = host.subscribe( host.on_touch_cancel(), [this](const kernel::TouchCancelEvent& e) { - closing_ = true; - apply(controller_.touch_cancel(e.touch_id)); + apply_release(controller_.touch_cancel(e.touch_id)); }); // Create the dock surface up front, kept hidden until the first slot. It @@ -288,35 +308,144 @@ private: return; } if (controller_.open()) { - // Close: slide out. closing_ = true so the existing dock_settled path - // hides the surface once the slide-out transition finishes. - closing_ = true; - apply(controller_.close_now()); + // Close: animate the slide-out; on_frame hides the surface once the + // CLOSE animation completes (replaces the old dock_settled hide). + apply_animated(controller_.close_now()); } else { - // Open: make the surface visible (may already be from slots), then - // ease the body to translateX(0). open_now() sets make_visible. - closing_ = false; - apply(controller_.open_now()); + // Open: make the surface visible (open_now sets make_visible) then + // animate the slide-in. apply_animated honours make_visible FIRST. + apply_animated(controller_.open_now()); } } - // ---- e1 glue: apply a controller Outcome to the surface ---------------- - // The thin adapter the controller's contract calls for: make the surface - // visible, then dirty `dragging` BEFORE `slide` (so the restored RCSS - // transition eases the snap on release). No-op when the surface is null - // (no-GL backend); the controller state still advances for the model/probe. - void apply(const gesture::Outcome& o) { + // ---- d1-fix glue: C++-driven slide animation --------------------------- + // The animation is owned in C++ (anim::SlideAnimator), driven once per frame + // by a kernel FrameRequest, with the duration + tween READ FROM RCSS so they + // stay hot-reloadable. There are three routing paths a controller Outcome can + // take, distinguished by who is the "clock": + // * apply_animated — the keyboard/minimize/restore open/close paths: PLAY + // the animation from the current slide value to the controller's target. + // * apply_scrub — the finger-driven drag-MOVE (and the OPEN drag start): + // the finger IS the clock, so set slide directly, no animation. + // * apply_release — a drag/touch RELEASE: RESUME the animation from the + // current finger value to the snapped target the controller decided. + // All three no-op the visual when the surface is null (no-GL backend); the + // controller state still advances for the model/probe. + + // PLAY: animate the dock to the controller's freshly-set target slide value. + // make_visible FIRST (a hidden surface is not composited, so it cannot be + // seen sliding in), then start the run from the CURRENT slide_px_ (so an + // interrupted animation continues smoothly) to controller_.slide_px(). + void apply_animated(const gesture::Outcome& o) { if (dock_surface_ == nullptr) { return; } if (o.make_visible) { dock_surface_->set_visible(true); } - if (o.dirty_dragging) { - dock_surface_->dirty("dragging"); + animate_to(controller_.slide_px(), /*release_scale=*/false); + } + + // SCRUB: the finger drives slide directly (drag-move, and the edge-swipe OPEN + // start). set_immediate() cancels any in-flight run; on a drag START we also + // stop the frame loop (the brief: "reset any running FrameRequest on drag + // start") so a prior keyboard animation does not fight the finger. + void apply_scrub(const gesture::Outcome& o, bool on_drag_start) { + if (dock_surface_ == nullptr) { + return; + } + if (on_drag_start && frame_.active()) { + frame_.reset(); // stop the animation loop; the finger takes over + } + if (o.make_visible) { + dock_surface_->set_visible(true); } if (o.dirty_slide) { + slide_px_ = controller_.slide_px(); + anim_.set_immediate(slide_px_); + dock_surface_->dirty("slide"); + } + } + + // RESUME: a release eases from the current finger value to the snapped target + // the controller decided. Scaled duration (release_scale) so a near-complete + // drag finishes quickly. make_visible honoured (an OPEN-commit release). + void apply_release(const gesture::Outcome& o) { + if (dock_surface_ == nullptr) { + return; + } + if (o.make_visible) { + dock_surface_->set_visible(true); + } + animate_to(controller_.slide_px(), /*release_scale=*/true); + } + + // Start a run to `target` px. Reads the RCSS-authored duration + tween from + // #panel's `transition: transform` (hot-reloadable) with a sane fallback + // (kFallbackDurS, linear) when the document has not rendered yet / authors no + // transition. `release_scale` shrinks the duration by the fraction of the + // dock width still to travel, so a drag released near the snap target settles + // fast (a full-distance move keeps the full duration). Arms the per-frame + // FrameRequest if it is not already running. + void animate_to(double target, bool release_scale) { + const double from = slide_px_; + + double duration = kFallbackDurS; + std::function ease; // null == linear (the fallback) + if (auto t = dock_surface_->transition_timing("panel", "transform")) { + duration = t->duration > 0.0 ? t->duration : kFallbackDurS; + ease = std::move(t->ease); + } + + if (release_scale) { + // Scale by the remaining-distance fraction of the full dock width so + // a near-finished drag completes quickly. dock_width is the full + // travel; clamp to [0,1] (the recognizer can overshoot in theory). + const double width = static_cast(kDockWidth); + if (width > 0.0) { + const double frac = std::clamp(std::abs(target - from) / width, 0.0, 1.0); + duration *= frac; + } + } + + anim_.start(from, target, duration, std::move(ease)); + // A zero/degenerate-duration run already landed on the target (no frames + // needed); reflect it now and skip arming the loop. + if (!anim_.active()) { + slide_px_ = anim_.value(); dock_surface_->dirty("slide"); + on_animation_finished(); + return; + } + if (!frame_.active()) { + frame_ = host_->request_frames([this](double dt) { on_frame(dt); }); + } + } + + // The per-frame tick (runs BEFORE the surface renders each frame while the + // FrameRequest is held): advance the animator, push the value into the + // `slide` binding, and when the run completes stop the frame loop (don't hold + // frames at rest) + run the completion hook (the CLOSE-hide). + void on_frame(double dt) { + if (dock_surface_ == nullptr) { + frame_.reset(); + return; + } + slide_px_ = anim_.tick(dt); + dock_surface_->dirty("slide"); + if (!anim_.active()) { + on_animation_finished(); + frame_.reset(); // idle: stop scheduling frames + } + } + + // Run when a play/resume animation reaches its target. If we animated to the + // CLOSED state (dock not open), hide the surface NOW — this REPLACES the old + // dock_settled/transitionend hide. Guarded on !controller_.open() so a reveal + // that raced a conceal (reopened mid-animation) does not hide an open dock. + void on_animation_finished() { + if (dock_surface_ != nullptr && !controller_.open()) { + dock_surface_->set_visible(false); } } @@ -354,15 +483,17 @@ private: } // Re-render the dock list and ANIMATE the dock reveal. The dock is revealed - // iff there is at least one slot. The slide is value-driven (e1): both the - // gesture and these non-gesture call sites flow through the one Controller, - // which sets slide_px_ (the body's translateX) and clears dragging_ so the - // RCSS transition eases the move: + // iff there is at least one slot. The slide is value-driven: these non-gesture + // call sites flow through the one Controller (which sets the OPEN/CLOSED target + // px) and then the C++ SlideAnimator (apply_animated), which eases slide_px_ + // from its current value to the target over frames (the d1 fix — RmlUi never + // animated the inline transform): // empty -> non-empty: open_now() (make the surface visible FIRST — a hidden - // surface is not composited, so it can't animate — then ease the body in). - // non-empty -> empty: close_now() eases the body back out; we DEFER - // set_visible(false) until the slide-out finishes (on_dock_settled, fired - // by RmlUi's transitionend through the existing event binding). + // surface is not composited, so it can't be seen sliding in — then animate + // the slide-in via apply_animated). + // non-empty -> empty: close_now() animates the slide-out; on_frame hides the + // surface once the CLOSE animation completes (replaces the old transitionend + // dock_settled hide). // The surface is a fixed full-height rail; its size never changes with the // card count (the RCSS scrolls/centers the cards within it). Only visibility // toggles: shown when there is >= 1 slot, hidden (after the slide-out) when @@ -381,36 +512,14 @@ private: return; // reveal state unchanged (e.g. minimize a 2nd window) } if (want_open) { - // Reveal: composite before animating, then ease the body in. - closing_ = false; - apply(controller_.open_now()); + // Reveal: composite before animating, then animate the body in. + apply_animated(controller_.open_now()); } else { - // Conceal: ease the body out now, hide once the slide-out ends. - closing_ = true; - apply(controller_.close_now()); + // Conceal: animate the body out; on_frame hides it on completion. + apply_animated(controller_.close_now()); } } - // RmlUi fires `transitionend` on body.dock when the reveal-slide transition - // completes; the existing data-event binding routes it here (VERIFIED: the - // substrate's data-event controller binds any registered RmlUi event by - // name, and RmlUi dispatches transitionend from AdvanceAnimations() — no - // kernel change needed for this completion signal; see report). We only act - // on the CLOSE direction: once the slide-OUT has played, drop the surface - // from compositing. The open-direction transitionend is a no-op. Guarded so - // a stale end-event (e.g. a reveal that raced a conceal) cannot hide an - // again-open dock: we re-check open_. - void on_dock_settled() { - if (dock_surface_ != nullptr && closing_ && !controller_.open()) { - // Slide-out finished and the dock is empty: hide the full-height rail - // so it stops compositing AND stops capturing input over the left - // strip. The surface keeps its full height (no resize) for the next - // reveal; only visibility toggled. - dock_surface_->set_visible(false); - } - closing_ = false; - } - // Create the dock UiSurface (overlay, left edge) and register all data // bindings BEFORE the first frame. The surface is a FULL-HEIGHT left RAIL: // kDockWidth (240) wide x the full OUTPUT HEIGHT tall, at the output's @@ -471,41 +580,43 @@ private: dock_surface_->bind_list_event( "slots", "restore", [this](std::size_t i) { do_restore(i); }); - // e1 reveal bindings (registered before the first frame, same rule as the - // list bindings; capture only `this`, whose members outlive the surface). - // `slide` drives the body's data-style-transform translateX(px) — the - // value-driven reveal both the gesture AND the keyboard/minimize/restore - // paths feed through the one Controller. `dragging` drives - // data-class-dragging -> RCSS turns the transition OFF so a live drag - // follows the finger 1:1. `dock_settled` is body.dock's transitionend -> - // hide after the slide-out. `dock_drag` is the CLOSE path (see below). - dock_surface_->bind_double("slide", [this]() -> double { return controller_.slide_px(); }); - dock_surface_->bind_bool("dragging", [this]() -> bool { return controller_.dragging(); }); - dock_surface_->bind_event("dock_settled", [this]() { on_dock_settled(); }); - - // e1 CLOSE path — UiSurface::bind_drag. The OPEN dock is a visible ui + // Reveal binding (registered before the first frame, same rule as the + // list bindings; captures only `this`, whose members outlive the surface). + // `slide` drives the panel's data-style-transform translateX(px). It now + // reads the GLUE-OWNED slide_px_ (driven by the C++ SlideAnimator, or set + // directly during a finger scrub) rather than the controller's value: the + // animator interpolates between the controller's open/closed TARGETS over + // time. NOTE (d1 fix): the old `dragging` (data-class-dragging) + + // `dock_settled` (transitionend) bindings are GONE — RmlUi never animated + // the inline transform, so the class-toggle machinery was dead; the C++ + // animator's completion (on_frame) now does the close-hide instead. + dock_surface_->bind_double("slide", [this]() -> double { return slide_px_; }); + + // CLOSE/scrub path — UiSurface::bind_drag. The OPEN dock is a visible ui // surface, so the substrate captures its touches into our RMLUi document // (NOT the kernel bus); the body opts into dragging via RCSS `drag: drag;` // and authors data-event-dragstart/drag/dragend all naming "dock_drag". // x/y are surface-LOCAL document px — fed straight to the recognizer (no // layout-origin subtract). bind_drag carries no time, so we stamp a // monotonic ms clock. A tap still fires data-event-click -> restore(), so - // tap-to-restore coexists. + // tap-to-restore coexists. drag-START/-MOVE SCRUB slide_px_ directly (the + // finger is the clock); drag-END RESUMES the animator from the current + // finger value to the snapped target (apply_release). dock_surface_->bind_drag( "dock_drag", [this](kernel::UiSurface::DragPhase p, double x, double y) { switch (p) { case kernel::UiSurface::DragPhase::start: - closing_ = false; // not committed yet; armed by drag_end - apply(controller_.drag_start(x, y, now_ms())); + // Finger takes over: stop any running animation loop + scrub. + apply_scrub(controller_.drag_start(x, y, now_ms()), + /*on_drag_start=*/true); break; case kernel::UiSurface::DragPhase::move: - apply(controller_.drag_move(x, y, now_ms())); + apply_scrub(controller_.drag_move(x, y, now_ms()), + /*on_drag_start=*/false); break; case kernel::UiSurface::DragPhase::end: - // Like a touch_up: a CLOSE commit eases out, so arm closing_ - // for the dock_settled hide (gated on !controller_.open()). - closing_ = true; - apply(controller_.drag_end(now_ms())); + // Resume: ease from the finger value to the snapped target. + apply_release(controller_.drag_end(now_ms())); break; } }); @@ -513,10 +624,13 @@ private: // Seed the output geometry into the Controller and set the CLOSED target // so the very first render shows the dock off-screen (translateX( // -dock_width)) — matching spec.visible=false. close_now() leaves open_ - // false and slide_px_ at the f=0 offset; the dirties are harmless before - // the first frame (the getters are simply read once geometry is known). + // false and the closed offset as the target; seed slide_px_ to it directly + // (no animation at create) so the first frame renders the dock off-screen. controller_.set_metrics(m); - apply(controller_.close_now()); + controller_.close_now(); + slide_px_ = controller_.slide_px(); + anim_.set_immediate(slide_px_); + dock_surface_->dirty("slide"); } // Dock metrics from the first output's size (queried via output_layout). On @@ -568,23 +682,26 @@ private: std::vector slots_; // e1 gesture state. The Controller (src/gesture.hpp) is the pure decision - // core: it owns slide_px_/dragging_/open_ and the event->state transition for - // BOTH input sources (the kernel touch bus = OPEN, UiSurface::bind_drag = - // CLOSE). The glue is a thin adapter that feeds it events and applies the - // returned Outcome to the surface. Declared BEFORE dock_surface_ (like slots_) - // so it stays alive while the surface — whose `slide`/`dragging` getters read - // it — tears down. Constructed with the real dock width (kDockWidth) and - // default recognizer tunables (edge_slop / threshold / fling); set_metrics() - // seeds the output geometry once an output exists. + // core: it owns the recognizer + the event->state transition (open_, the snap + // commit, and the open/closed TARGET slide_px) for BOTH input sources (the + // kernel touch bus = OPEN, UiSurface::bind_drag = CLOSE). The glue feeds it + // events and routes the resulting target through the SlideAnimator. Declared + // BEFORE dock_surface_ (like slots_) so it stays alive while the surface tears + // down. Constructed with the real dock width (kDockWidth) + default recognizer + // tunables; set_metrics() seeds the output geometry once an output exists. gesture::Controller controller_{ reveal::RevealConfig{.dock_width = kDockWidth}, layout::DockMetrics{.dock_width = kDockWidth}}; - // closing_: are we mid slide-OUT, waiting on transitionend to set_visible - // (false)? Distinct from controller_.open() (the target state) because the - // hide must wait for the transition to finish. Set when a close starts, read - // (with !controller_.open()) by on_dock_settled. - bool closing_ = false; + // d1-fix C++ slide animation state. slide_px_ is the LIVE translateX px the + // `slide` binding reads — driven by anim_ (the SlideAnimator) for the + // keyboard/minimize/restore/drag-release paths, or set directly during a + // finger scrub. anim_ holds the current run; frame_ is the kernel per-frame + // tick that advances it (armed only while a run is live; reset at rest). + // slide_px_ + anim_ are declared BEFORE dock_surface_ so the `slide` getter + // reading slide_px_ stays valid through the surface's teardown. + double slide_px_ = 0.0; // live translateX px (closed = -dock_width, open = 0) + anim::SlideAnimator anim_; // the interruptible easing run // The dock ui surface. Destroyed before slots_ (declared after it) so any // getter invoked during its teardown still sees a live slots_; destroyed @@ -603,6 +720,12 @@ private: kernel::Subscription touch_motion_; kernel::Subscription touch_up_; kernel::Subscription touch_cancel_; + + // The per-frame animation tick. Declared LAST (with the subscriptions) so it + // is destroyed FIRST at teardown: its callback captures `this` and touches + // dock_surface_ / anim_ / slide_px_ / controller_, so it must stop before any + // of them are gone (same listener-lifetime rule as the Subscriptions). + kernel::FrameRequest frame_; }; } // namespace diff --git a/packages/ext-stage-dock/src/gesture.hpp b/packages/ext-stage-dock/src/gesture.hpp index 9904bb4..701d139 100644 --- a/packages/ext-stage-dock/src/gesture.hpp +++ b/packages/ext-stage-dock/src/gesture.hpp @@ -29,21 +29,26 @@ namespace unbox::ext_stage_dock::gesture { // What the glue must do AFTER a controller call (the side effects the pure core -// cannot perform itself). The glue applies these to the UiSurface: make it -// visible, dirty the `slide`/`dragging` bindings (dirty `dragging` BEFORE -// `slide` on release so the restored RCSS transition eases the snap), and — on a -// settling CLOSE — let the existing dock_settled() transitionend hide it. +// cannot perform itself). The glue makes the surface visible on make_visible and +// uses dirty_slide to know the slide value changed. NOTE (d1 fix): the close-hide +// is now driven by the C++ SlideAnimator's COMPLETION (the glue's on_frame), +// NOT an RmlUi transitionend; and dirty_dragging is VESTIGIAL — the +// data-class-dragging machinery was removed (RmlUi never animated the inline +// transform), so the glue ignores it. The field stays for the controller's own +// pure-core tests + a record of the gesture's "now scrubbing" intent. struct Outcome { bool make_visible = false; // show the surface (open begins compositing) - bool dirty_slide = false; // re-read the `slide` double getter next frame - bool dirty_dragging = false; // re-read the `dragging` bool getter next frame + bool dirty_slide = false; // the `slide` value changed -> re-render/animate + bool dirty_dragging = false; // vestigial (see note above); glue ignores it }; -// The live gesture state, read by the `slide`/`dragging` bound getters and by -// the open/close call sites. open == is the dock revealed (drives visibility + -// the dock_settled close-hide). slide_px == the translateX px the body binds -// (closed = -dock_width, open = 0). dragging == is a finger-follow drag active -// (RCSS turns the transition OFF so motion follows the finger 1:1). +// The live gesture state, read by the open/close call sites + the slide getter. +// open == is the dock revealed (the glue's animator-completion close-hide is +// gated on !open). slide_px == the open/closed TARGET translateX px (closed = +// -dock_width, open = 0) the glue's SlideAnimator eases toward (the body's +// live translateX is the glue-owned slide_px_ the animator drives, NOT this). +// dragging == is a finger-follow scrub active (the glue scrubs slide directly +// during it rather than animating). class Controller { public: Controller(reveal::RevealConfig reveal_config, layout::DockMetrics metrics) @@ -151,12 +156,12 @@ public: return snap(commit); } - // ---- non-gesture open/close call sites (unified onto slide_px_) -------- + // ---- non-gesture open/close call sites (set the open/closed TARGET) ---- // Super+M reveal, do_restore reveal, refresh_slots reveal, toggle_visible - // open: set the OPEN target. dragging_ = false so the RCSS transition is on - // and the body eases to translateX(0). The glue makes the surface visible + - // dirties dragging THEN slide. No-op shape if already open (still returns the - // outcome so the glue's set_visible(true) is idempotent / safe). + // open: set the OPEN target (slide_px == translateX 0). dragging_ = false. The + // glue makes the surface visible then ANIMATES slide_px_ to this target via + // the C++ SlideAnimator (d1 fix). No-op shape if already open (still returns + // the outcome so the glue's set_visible(true) is idempotent / safe). auto open_now() -> Outcome { open_ = true; dragging_ = false; @@ -165,8 +170,9 @@ public: } // refresh_slots conceal, toggle_visible close: set the CLOSED target. The - // body eases back out (transition on); the existing dock_settled() hides the - // surface once the slide-out transition ends. dirty dragging THEN slide. + // glue ANIMATES slide_px_ back out via the SlideAnimator; its completion + // (on_frame) hides the surface once the slide-out finishes (d1 fix — this + // replaced the old RmlUi transitionend dock_settled hide). auto close_now() -> Outcome { open_ = false; dragging_ = false; @@ -184,10 +190,10 @@ private: return static_cast(layout::dock_box(metrics_, fraction).x); } - // Shared release for both paths: stop dragging (transition back on), set the - // open/closed state + the snap target px. The glue dirties dragging THEN - // slide so the restored 0.36s cubic-in-out transition eases the snap; on a - // CLOSE the existing dock_settled() transitionend hides the surface. + // Shared release for both paths: stop dragging, set the open/closed state + + // the snap TARGET px. The glue RESUMES the C++ SlideAnimator from the current + // finger value to this target (apply_release), easing with the RCSS-read + // tween; on a CLOSE the animator's completion hides the surface. auto snap(reveal::RevealCommit commit) -> Outcome { dragging_ = false; if (commit == reveal::RevealCommit::open) { diff --git a/packages/ext-stage-dock/tests/test_policy.cpp b/packages/ext-stage-dock/tests/test_policy.cpp index 915b4d6..d0d1171 100644 --- a/packages/ext-stage-dock/tests/test_policy.cpp +++ b/packages/ext-stage-dock/tests/test_policy.cpp @@ -1,19 +1,23 @@ #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include +#include "anim.hpp" #include "dock_layout.hpp" #include "gesture.hpp" #include "reveal.hpp" // Pure-core tests — the heart of this b4 step. No kernel, no wlroots, no RMLUi. -// Three cores: the reveal recognizer (reversible edge swipe -> fraction + -// commit), the dock layout geometry (reveal fraction -> rects), and the e1 -// gesture Controller (touch/drag STREAM -> slide_px/dragging/open + the Outcome -// the glue applies). The Controller needs nothing running. +// Four cores: the reveal recognizer (reversible edge swipe -> fraction + +// commit), the dock layout geometry (reveal fraction -> rects), the e1 gesture +// Controller (touch/drag STREAM -> slide_px/dragging/open + the Outcome the glue +// applies), and the d1-fix SlideAnimator (the interruptible easing animator that +// drives slide_px over time for the keyboard/minimize/restore/drag-release +// paths). All four need nothing running. namespace rv = unbox::ext_stage_dock::reveal; namespace lay = unbox::ext_stage_dock::layout; namespace gst = unbox::ext_stage_dock::gesture; +namespace anm = unbox::ext_stage_dock::anim; using rv::RevealCommit; using rv::RevealConfig; @@ -389,3 +393,134 @@ TEST_CASE("Controller: set_metrics re-scales the slide offset for a new output") c.close_now(); CHECK(c.slide_px() == doctest::Approx(-100.0)); } + +// ============================================================================ +// SlideAnimator (d1 fix) — the interruptible easing animator over time +// ============================================================================ + +using anm::SlideAnimator; + +// A linear tween (identity) so lerped values are exact at any progress. +static auto linear() -> std::function { + return [](float t) { return t; }; +} + +TEST_CASE("SlideAnimator: starts inactive and reports its initial value") { + SlideAnimator a; + CHECK_FALSE(a.active()); + CHECK(a.value() == doctest::Approx(0.0)); + // tick while inactive is inert (returns the held value, does not advance). + CHECK(a.tick(0.016) == doctest::Approx(0.0)); + CHECK_FALSE(a.active()); +} + +TEST_CASE("SlideAnimator: linear run progresses monotonically and ends exactly at target") { + // A 0.5s run; ticks of 0.05s (well under the kMaxTickDt clamp) so the + // accumulated elapsed is exact and the loop terminates deterministically. + SlideAnimator a; + a.start(/*from=*/-100.0, /*to=*/0.0, /*duration=*/0.5, linear()); + CHECK(a.active()); + CHECK(a.value() == doctest::Approx(-100.0)); // at start: from + + // Advance in 0.05s steps; value must never go backward and tracks the lerp. + double prev = a.value(); + for (int i = 0; i < 9; ++i) { + double v = a.tick(0.05); + CHECK(v >= prev); // monotonic for an inward (increasing) run + prev = v; + CHECK(a.active()); // still mid-run before the final step (0.45s < 0.5s) + } + // 0.45s elapsed -> 90% of the way: -100 + 90 = -10. + CHECK(a.value() == doctest::Approx(-10.0)); + + // The step that crosses duration lands EXACTLY on `to` and goes inactive. A + // final dt of 0.1 (the clamp) clears the remaining ~0.05s unambiguously. + double end = a.tick(0.1); // elapsed > duration -> done + CHECK(end == doctest::Approx(0.0)); // exactly the target, no float drift + CHECK_FALSE(a.active()); + // Further ticks are inert and stay pinned at the target. + CHECK(a.tick(0.5) == doctest::Approx(0.0)); + CHECK(a.value() == doctest::Approx(0.0)); +} + +TEST_CASE("SlideAnimator: overshooting dt pins exactly to target and marks done") { + SlideAnimator a; + a.start(0.0, 50.0, 0.05, linear()); // 50ms run + // First tick's dt (0.1s) exceeds the run AND is exactly the clamp; elapsed + // becomes 0.1 >= 0.05 so it completes this frame, pinned to `to`. + CHECK(a.tick(0.1) == doctest::Approx(50.0)); + CHECK_FALSE(a.active()); +} + +TEST_CASE("SlideAnimator: dt is clamped (kMaxTickDt) so a huge frame gap doesn't teleport") { + SlideAnimator a; + a.start(0.0, 100.0, 1.0, linear()); + // A 5-second stall: dt is clamped to kMaxTickDt (0.1s), so the run advances + // only 10% this frame — the motion stays visible instead of snapping to 100. + double v = a.tick(5.0); + CHECK(v == doctest::Approx(10.0)); // 0.1s / 1.0s = 10% + CHECK(a.active()); +} + +TEST_CASE("SlideAnimator: a non-linear tween is honoured and sampled at t=0 and t=1") { + // An ease-out-ish tween: f(t) = t*(2-t). f(0)=0, f(1)=1, f(0.5)=0.75. + auto ease_out = [](float t) { return t * (2.0F - t); }; + + // Sample at t=0.5: 100 * 0.75 = 75 (ahead of a linear 50 — the ease-out + // front-loads progress). Use a short 0.5s run so a single dt=0.05 tick (well + // under the clamp) lands at exactly progress 0.5. + SlideAnimator half; + half.start(/*from=*/0.0, /*to=*/100.0, /*duration=*/0.1, ease_out); + CHECK(half.tick(0.05) == doctest::Approx(75.0)); // progress 0.5 -> eased 0.75 + + // Endpoints: t=0 maps to `from`, t=1 maps EXACTLY to `to` (the done-pin, + // independent of the tween). Re-run to sample the start endpoint cleanly. + SlideAnimator b; + b.start(0.0, 100.0, 0.1, ease_out); + CHECK(b.value() == doctest::Approx(0.0)); // t=0 -> from, regardless of ease + CHECK(b.tick(0.1) == doctest::Approx(100.0)); // t=1 -> to (exact, pinned) + CHECK_FALSE(b.active()); +} + +TEST_CASE("SlideAnimator: a null ease falls back to linear") { + SlideAnimator a; + a.start(0.0, 100.0, /*duration=*/0.1, /*ease=*/nullptr); + CHECK(a.tick(0.025) == doctest::Approx(25.0)); // progress 0.25, pure linear +} + +TEST_CASE("SlideAnimator: non-positive duration snaps immediately to target") { + SlideAnimator a; + a.start(-100.0, 0.0, /*duration=*/0.0, linear()); + CHECK_FALSE(a.active()); // nothing to animate + CHECK(a.value() == doctest::Approx(0.0)); // landed on the target at once + CHECK(a.tick(0.016) == doctest::Approx(0.0)); // inert thereafter +} + +TEST_CASE("SlideAnimator: set_immediate snaps the value and cancels any run") { + SlideAnimator a; + a.start(0.0, 100.0, 1.0, linear()); + a.tick(0.3); + CHECK(a.active()); + // The drag-scrub: snap the value to the finger position with no run. + a.set_immediate(42.0); + CHECK_FALSE(a.active()); + CHECK(a.value() == doctest::Approx(42.0)); + // tick is inert after set_immediate (the finger drives, not the clock). + CHECK(a.tick(0.1) == doctest::Approx(42.0)); + CHECK_FALSE(a.active()); +} + +TEST_CASE("SlideAnimator: interrupting a run re-anchors continuously from the new from") { + SlideAnimator a; + // Short run so single sub-clamp ticks land at clean fractions. + a.start(-100.0, 0.0, 0.1, linear()); + a.tick(0.04); // 40% of the way: -100 + 40 = -60 + CHECK(a.value() == doctest::Approx(-60.0)); + // Reverse mid-run (a drag-release back toward closed) from the CURRENT value: + // motion continues from -60, not from a stale anchor. + a.start(a.value(), -100.0, 0.1, linear()); + CHECK(a.value() == doctest::Approx(-60.0)); // continuous: no jump on restart + CHECK(a.target() == doctest::Approx(-100.0)); + a.tick(0.05); // halfway from -60 to -100 -> -80 + CHECK(a.value() == doctest::Approx(-80.0)); +} -- cgit v1.2.3