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 | 117ad49b2f9bfb6822e94df2706fd7cd0cf2121a (patch) | |
| tree | 06763e68b52816b4b4d0da36100a4fba6450bca4 /packages/ext-stage-dock/src/extension.cpp | |
| parent | 41abc47bfb4a5098bff611e3e241d2b63788cbec (diff) | |
| download | unbox-117ad49b2f9bfb6822e94df2706fd7cd0cf2121a.tar.gz unbox-117ad49b2f9bfb6822e94df2706fd7cd0cf2121a.zip | |
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.
Diffstat (limited to 'packages/ext-stage-dock/src/extension.cpp')
| -rw-r--r-- | packages/ext-stage-dock/src/extension.cpp | 315 |
1 files changed, 219 insertions, 96 deletions
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 <unbox/ext-xdg-shell/ext_xdg_shell.hpp> +#include <unbox/kernel/frames.hpp> #include <unbox/kernel/host.hpp> #include <unbox/kernel/ui.hpp> #include <unbox/kernel/wlr.hpp> #include <algorithm> #include <chrono> +#include <cmath> #include <cstddef> #include <cstdint> +#include <functional> #include <memory> #include <stdexcept> #include <string> @@ -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<float(float)> 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<double>(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<Slot> 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 |
