summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-14 16:00:49 +0900
committerAdam Malczewski <[email protected]>2026-06-14 16:00:49 +0900
commit117ad49b2f9bfb6822e94df2706fd7cd0cf2121a (patch)
tree06763e68b52816b4b4d0da36100a4fba6450bca4
parent41abc47bfb4a5098bff611e3e241d2b63788cbec (diff)
downloadunbox-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.
-rw-r--r--assets/ext-stage-dock/dock.rcss15
-rw-r--r--assets/ext-stage-dock/dock.rml2
-rw-r--r--packages/ext-stage-dock/src/anim.hpp116
-rw-r--r--packages/ext-stage-dock/src/extension.cpp315
-rw-r--r--packages/ext-stage-dock/src/gesture.hpp50
-rw-r--r--packages/ext-stage-dock/tests/test_policy.cpp143
6 files changed, 513 insertions, 128 deletions
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 @@
<link type="text/rcss" href="dock.rcss"/>
</head>
<body data-model="ui" class="dock" data-event-dragstart="dock_drag" data-event-drag="dock_drag" data-event-dragend="dock_drag">
-<div class="panel" data-class-dragging="dragging" data-style-transform="'translateX(' + slide + 'px)'" data-event-transitionend="dock_settled()">
+<div id="panel" class="panel" data-style-transform="'translateX(' + slide + 'px)'">
<div class="rail">
<div data-for="row : slots" class="slot" data-event-click="restore(it_index)">
<div class="thumb" data-style-decorator="'image( ' + row.preview + ' cover center center )'"/>
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 <algorithm>
+#include <functional>
+#include <utility>
+
+// 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<float(float)>
+// (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<float(float)> 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<double>(ease_(static_cast<float>(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<float(float)> 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 <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
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<double>(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 <doctest/doctest.h>
+#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<float(float)> {
+ 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));
+}