diff options
| author | Adam Malczewski <[email protected]> | 2026-06-14 12:20:36 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-14 12:20:36 +0900 |
| commit | 18bcca4f820aca68c6f50c4d59b66d73f0d0970d (patch) | |
| tree | 4832214e3e5af8f50c3c0056b8897c134ac95029 | |
| parent | a044b44c936569db7e790546b81b4792c8e72058 (diff) | |
| download | unbox-18bcca4f820aca68c6f50c4d59b66d73f0d0970d.tar.gz unbox-18bcca4f820aca68c6f50c4d59b66d73f0d0970d.zip | |
ext-stage-dock: interactive touch edge-swipe to open/close the dock
Drag from the left edge to open, or drag the open dock back to close;
finger-following with a 50%-or-fling snap on release. New gesture::Controller
pure core converges both input paths onto one RevealRecognizer: OPEN via the
kernel touch bus (dock hidden at down), CLOSE via UiSurface::bind_drag (the
visible dock captures the touch). Slide is value-driven (data-style-transform)
and eased only when not dragging.
Two real-seat fixes found via per-frame logging:
- Flicker: RmlUi projects a drag event's coords into the DRAGGED element's
transformed frame. We had drag:drag on the same <body> we translate by
slide, so the reported x fed back into slide and ping-ponged every frame.
Fix: body is a stationary drag handle; an inner .panel carries the transform.
- Direction: drag_start now seeds the recognizer from the dock's current
fraction (1 + slide/width) instead of a hardcoded value, so a drag opens or
closes correctly from any state.
| -rw-r--r-- | assets/ext-stage-dock/dock.rcss | 41 | ||||
| -rw-r--r-- | assets/ext-stage-dock/dock.rml | 4 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/extension.cpp | 198 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/gesture.hpp | 211 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/reveal.hpp | 15 | ||||
| -rw-r--r-- | packages/ext-stage-dock/tests/test_policy.cpp | 211 |
6 files changed, 615 insertions, 65 deletions
diff --git a/assets/ext-stage-dock/dock.rcss b/assets/ext-stage-dock/dock.rcss index 4e1e95b..bbb23f3 100644 --- a/assets/ext-stage-dock/dock.rcss +++ b/assets/ext-stage-dock/dock.rcss @@ -1,12 +1,23 @@ +/* body.dock is the STATIONARY full-surface DRAG HANDLE — it carries `drag: drag` + (so RmlUi emits dragstart/drag/dragend that our bind_drag reads) but it is + NEVER transformed. This is load-bearing: RmlUi projects a drag event's + mouse_x/mouse_y into the DRAGGED element's transformed coordinate frame, so if + the dragged element itself were translated by `slide` (as it was), the reported + drag x would feed back into `slide` and ping-pong between the finger position + and ~dock_width every frame (the flicker). Keeping the drag handle at identity + transform makes the reported coordinate the raw finger position. The MOVING + visual is div.panel below — a child that is NOT the drag element, so its + transform never enters the drag projection. body itself paints nothing (the + gradient lives on the panel); it is just the input-capture + drag-origin layer. */ body.dock { - /* FULL-HEIGHT LEFT RAIL. The surface is the full output height (sized in - C++); height:100% fills it. #00000080 is the visible translucent panel - (the whole rail). It is a flex COLUMN scroll container: the cards stack - vertically, the .rail wrapper centers via margin:auto when they fit, and - overflow-y scrolls when they exceed the height. align-items:flex-start - pins the cards to the LEFT (the 224dp cards stay ~8dp from the screen edge - over the dark end of the gradient; the rail is wider (kDockWidth 288) so - the gradient fades to transparent over the empty space to their right). */ + width: 100%; + height: 100%; + drag: drag; +} +/* div.panel is the VALUE-DRIVEN moving visual: data-style-transform binds + translateX(slide px) (closed = -dock_width, open = 0). The gradient, padding, + flex layout and scroll live here, not on body. */ +div.panel { width: 100%; height: 100%; decorator: horizontal-gradient( #000000ff #00000000 ); @@ -17,19 +28,21 @@ body.dock { align-items: center; overflow-x: hidden; overflow-y: auto; - transform: translateX(-100%); - transition: transform 0.36s cubic-in-out; } -body.dock.open { - transform: translateX(0px); +/* 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) { + transition: transform 0.36s cubic-in-out; } /* Hide both scrollbars (the rail scrolls, but no visible scrollbar). RmlUi instances scrollbarvertical/scrollbarhorizontal elements; width/height 0 hides them (verified pattern: vendored Samples effects_style.rcss / invader.rcss). */ -body.dock scrollbarvertical { +div.panel scrollbarvertical { width: 0dp; } -body.dock scrollbarhorizontal { +div.panel scrollbarhorizontal { height: 0dp; } /* The slot WRAPPER. margin:auto on this single flex item vertically centers the diff --git a/assets/ext-stage-dock/dock.rml b/assets/ext-stage-dock/dock.rml index 7ba5cf0..4ddb47d 100644 --- a/assets/ext-stage-dock/dock.rml +++ b/assets/ext-stage-dock/dock.rml @@ -2,12 +2,14 @@ <head> <link type="text/rcss" href="dock.rcss"/> </head> -<body data-model="ui" class="dock" data-class-open="open" data-event-transitionend="dock_settled()"> +<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 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 )'"/> <span class="title">{{ row.title }}</span> </div> </div> +</div> </body> </rml> diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp index 685f6e4..35811c5 100644 --- a/packages/ext-stage-dock/src/extension.cpp +++ b/packages/ext-stage-dock/src/extension.cpp @@ -1,6 +1,7 @@ #include <unbox/ext-stage-dock/ext_stage_dock.hpp> #include "dock_layout.hpp" +#include "gesture.hpp" #include "probe.hpp" #include "reveal.hpp" @@ -10,7 +11,9 @@ #include <unbox/kernel/wlr.hpp> #include <algorithm> +#include <chrono> #include <cstddef> +#include <cstdint> #include <memory> #include <stdexcept> #include <string> @@ -157,6 +160,34 @@ public: return ev; }); + // e1 OPEN path — the kernel touch bus. The dock is HIDDEN when a finger + // lands at the left edge, so the implicit-grab contract (host.hpp:242-244) + // routes the WHOLE down->motion->up/cancel stream to these subscriptions + // even after we make the dock visible mid-drag. Feed each event to the + // Controller and apply the side-effects it returns. The Controller gates + // 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)); + }); + 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)); + }); + 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)); + }); + touch_cancel_ = host.subscribe( + host.on_touch_cancel(), [this](const kernel::TouchCancelEvent& e) { + closing_ = true; + apply(controller_.touch_cancel(e.touch_id)); + }); + // Create the dock surface up front, kept hidden until the first slot. It // lives on the overlay layer at the left edge; geometry from dock_layout // + the first output's size. The substrate is null on a no-GL backend @@ -256,23 +287,47 @@ private: if (dock_surface_ == nullptr) { return; } - if (open_) { - // Close: slide out. Keep the surface compositing so the animation - // plays; on_dock_settled won't hide it (closing_ is false), so a - // subsequent toggle re-opens instantly without delay. - open_ = false; - closing_ = false; - dock_surface_->dirty("open"); + 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()); } else { // Open: make the surface visible (may already be from slots), then - // slide in. If the dock was fully hidden (surface invisible), the - // set_visible(true) begins compositing; the dirty("open") - // transitions the body to translateX(0). - open_ = true; + // ease the body to translateX(0). open_now() sets make_visible. closing_ = false; + apply(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) { + if (dock_surface_ == nullptr) { + return; + } + if (o.make_visible) { dock_surface_->set_visible(true); - dock_surface_->dirty("open"); } + if (o.dirty_dragging) { + dock_surface_->dirty("dragging"); + } + if (o.dirty_slide) { + dock_surface_->dirty("slide"); + } + } + + // A monotonic millisecond clock for the CLOSE path: UiSurface::bind_drag + // carries no time_msec, but the recognizer needs a time base for its fling + // velocity. steady_clock keeps it consistent in shape with the bus path + // (which supplies time_msec) — only relative deltas matter to the recognizer. + [[nodiscard]] static auto now_ms() -> std::uint32_t { + const auto t = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast<std::uint32_t>( + std::chrono::duration_cast<std::chrono::milliseconds>(t).count()); } // ---- helpers ------------------------------------------------------------ @@ -298,17 +353,16 @@ private: return false; } - // Re-render the dock list and ANIMATE the dock reveal (d1). The dock is - // revealed iff there is at least one slot. Where c2 toggled set_visible() - // instantly, d1 slides the body via the `open` class (data-class-open -> - // transition on transform): - // empty -> non-empty: make the surface visible FIRST (a hidden surface is - // not composited, so it can't animate), then flip open_=true and dirty - // it -> the transition slides the body in from the left edge. - // non-empty -> empty: keep the surface visible, flip open_=false and dirty - // -> the body slides 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) so the close animation is seen. + // 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: + // 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). // 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 @@ -323,19 +377,17 @@ private: dock_surface_->dirty("slots"); const bool want_open = !slots_.empty(); - if (want_open == open_) { + if (want_open == controller_.open()) { return; // reveal state unchanged (e.g. minimize a 2nd window) } - open_ = want_open; - if (open_) { - // Reveal: composite before animating, then slide in. + if (want_open) { + // Reveal: composite before animating, then ease the body in. closing_ = false; - dock_surface_->set_visible(true); - dock_surface_->dirty("open"); + apply(controller_.open_now()); } else { - // Conceal: slide out now, hide once the slide-out transition ends. + // Conceal: ease the body out now, hide once the slide-out ends. closing_ = true; - dock_surface_->dirty("open"); + apply(controller_.close_now()); } } @@ -349,7 +401,7 @@ private: // 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_ && !open_) { + 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 @@ -419,14 +471,52 @@ private: dock_surface_->bind_list_event( "slots", "restore", [this](std::size_t i) { do_restore(i); }); - // d1 reveal-animation bindings (registered before the first frame, same - // rule as the list bindings; capture only `this`, whose members outlive - // the surface). `open` drives data-class-open on body.dock -> the slide - // transition; `dock_settled` is body.dock's transitionend -> hide after - // the slide-out. Initial open_ is false (the dock starts hidden, body - // un-`open` = translated off-screen), matching spec.visible=false. - dock_surface_->bind_bool("open", [this]() -> bool { return open_; }); + // 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 + // 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. + 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())); + break; + case kernel::UiSurface::DragPhase::move: + apply(controller_.drag_move(x, y, now_ms())); + 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())); + break; + } + }); + + // 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). + controller_.set_metrics(m); + apply(controller_.close_now()); } // Dock metrics from the first output's size (queried via output_layout). On @@ -477,13 +567,23 @@ private: // ends. Each Slot owns a Preview (frees its texture on erase/destruction). std::vector<Slot> slots_; - // d1 reveal-animation state, read by the `open` bool getter + the - // transitionend handler. Declared BEFORE dock_surface_ (like slots_) so they - // stay alive while the surface — whose binding reads open_ — tears down. - // open_: is the dock currently revealed (body has the `open` class)? Starts - // false (hidden + slid off-screen, matching spec.visible=false). closing_: - // are we mid slide-OUT, waiting on transitionend to set_visible(false)? - bool open_ = false; + // 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. + 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; // The dock ui surface. Destroyed before slots_ (declared after it) so any @@ -497,6 +597,12 @@ private: kernel::Subscription focused_sub_; kernel::Subscription unmapped_; kernel::Subscription key_filter_; + // e1 OPEN path: the kernel touch bus. Held as members so they unsubscribe on + // teardown before the controller they feed is gone. + kernel::Subscription touch_down_; + kernel::Subscription touch_motion_; + kernel::Subscription touch_up_; + kernel::Subscription touch_cancel_; }; } // namespace diff --git a/packages/ext-stage-dock/src/gesture.hpp b/packages/ext-stage-dock/src/gesture.hpp new file mode 100644 index 0000000..9904bb4 --- /dev/null +++ b/packages/ext-stage-dock/src/gesture.hpp @@ -0,0 +1,211 @@ +#pragma once + +#include "dock_layout.hpp" +#include "reveal.hpp" + +#include <cstdint> +#include <optional> + +// Pure decision core 3 — the GESTURE CONTROLLER: the event->state transition +// that turns a touch/drag STREAM into the dock's value-driven slide state +// (slide_px, dragging, open). No wlroots / GL / RMLUi — plain events in, plain +// state + a tiny "what the glue must do next" outcome out, doctest-covered in +// tests/test_policy.cpp with nothing running. +// +// WHY a pure core (extension-agent.md: pure decision core + thin glue): the +// e1 gesture has two input SOURCES that must drive ONE mechanism — +// * OPEN: the kernel touch bus (Host::on_touch_down/motion/up/cancel), valid +// because the dock is HIDDEN at touch-down so the implicit-grab contract +// routes the whole stream to our bus subscription (host.hpp:242-244). +// * CLOSE: UiSurface::bind_drag, because the OPEN dock is a visible ui surface +// so the substrate captures its touches into our RMLUi document, NOT the bus. +// Both feed the SAME RevealRecognizer + dock_box; this controller is where they +// converge so the glue is a thin adapter and the headless test can drive the +// full down->motion->up -> (slide_px, dragging, open) mapping WITHOUT a GL +// substrate or synthetic touch injection (which the pixman headless host lacks). +// +// Single wl_event_loop thread throughout (no internal synchronization). + +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. +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 +}; + +// 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). +class Controller { +public: + Controller(reveal::RevealConfig reveal_config, layout::DockMetrics metrics) + : recognizer_(reveal_config), metrics_(metrics) {} + + // ---- state the glue's bound getters read ---- + [[nodiscard]] auto slide_px() const -> double { return slide_px_; } + [[nodiscard]] auto dragging() const -> bool { return dragging_; } + [[nodiscard]] auto open() const -> bool { return open_; } + [[nodiscard]] auto gesturing() const -> bool { return active_touch_.has_value(); } + + // Refresh the output geometry (multi-output / resize). Only metrics_.output_h + // and dock_width feed dock_box(...).x, so this keeps the slide math correct + // after an output change. Does not touch live gesture state. + void set_metrics(layout::DockMetrics metrics) { metrics_ = metrics; } + + // ---- OPEN path (kernel touch bus) -------------------------------------- + // A touch went down at layout x/y, time t (ms), point `id`. Begins an OPEN + // reveal iff no gesture is active, the dock is currently closed, and the + // recognizer accepts the press as edge-started (x <= edge_slop). On accept: + // records the active touch_id, dragging_ = true, slide_px_ = the f=0 (fully + // hidden) offset, and asks the glue to make the surface visible + dirty both + // bindings. Otherwise returns an empty Outcome (ignored: not an edge swipe, + // or the dock is already open / mid-gesture). + auto touch_down(std::int32_t id, double lx, double ly, std::uint32_t t) -> Outcome { + if (active_touch_.has_value() || open_) { + return {}; + } + if (!recognizer_.begin(lx, ly, t, /*start_fraction=*/0.0)) { + return {}; // not an edge-started reveal + } + active_touch_ = id; + dragging_ = true; + slide_px_ = dock_box(0.0); + return Outcome{.make_visible = true, .dirty_slide = true, .dirty_dragging = true}; + } + + // A touch moved. If it is the active OPEN gesture's point, advance the + // recognizer and follow the finger (slide_px_ tracks the live fraction; the + // transition is off via dragging_). Otherwise a no-op. + auto touch_motion(std::int32_t id, double lx, double ly, std::uint32_t t) -> Outcome { + if (!is_active(id)) { + return {}; + } + const double frac = recognizer_.update(lx, ly, t); + slide_px_ = dock_box(frac); + return Outcome{.dirty_slide = true}; + } + + // A touch lifted. If it is the active OPEN gesture's point, release the + // recognizer and SNAP: commit decides open vs close (distance >= threshold OR + // a fast inward fling -> open; else close). See snap() for the shared release. + auto touch_up(std::int32_t id, std::uint32_t t) -> Outcome { + if (!is_active(id)) { + return {}; + } + const reveal::RevealCommit commit = recognizer_.end(t); + active_touch_.reset(); + return snap(commit); + } + + // A touch was cancelled (e.g. palm reject). Treat like a release that reverts + // to CLOSE (nothing committed), if it is the active gesture's point. + auto touch_cancel(std::int32_t id) -> Outcome { + if (!is_active(id)) { + return {}; + } + active_touch_.reset(); + return snap(reveal::RevealCommit::close); + } + + // ---- CLOSE path (UiSurface::bind_drag, surface-LOCAL coords) ----------- + // The open dock captured a drag. x/y are surface-local document px (origin + // top-left), good for the recognizer directly (no layout-origin subtract). + // We seed the SAME recognizer for a close: start_fraction 1.0, force_active + // (the finger lands anywhere on the dock, not at the edge), so dragging back + // toward the edge drops the fraction from 1.0 and end() can commit close. + // `t` is a caller-supplied monotonic ms (bind_drag has no time_msec). + auto drag_start(double x, double y, std::uint32_t t) -> Outcome { + // Seed the recognizer at the dock's CURRENT fraction, not a hardcoded + // value: the surface captures the touch (so we arrive here via bind_drag) + // whether the dock is open (drag to CLOSE, fraction ~1) OR closed-but- + // -visible (drag to OPEN, fraction ~0). fraction = 1 + slide_px_/width + // inverts dock_box(); begin() clamps it. force_active because the finger + // lands anywhere on the dock, not at the screen edge. + const double w = static_cast<double>(metrics_.dock_width); + const double cur = (w > 0.0) ? (1.0 + slide_px_ / w) : 1.0; + recognizer_.begin(x, y, t, /*start_fraction=*/cur, /*force_active=*/true); + dragging_ = true; + // Transition off (dragging_) so the body follows the finger 1:1; the first + // drag_move updates slide_px_ from the live fraction. + return Outcome{.dirty_dragging = true}; + } + + // A drag moved. Follow the finger back toward the edge (frac drops from 1.0). + auto drag_move(double x, double y, std::uint32_t t) -> Outcome { + const double frac = recognizer_.update(x, y, t); + slide_px_ = dock_box(frac); + return Outcome{.dirty_slide = true}; + } + + // A drag ended. Release + snap exactly like a touch_up (shared snap()). + auto drag_end(std::uint32_t t) -> Outcome { + const reveal::RevealCommit commit = recognizer_.end(t); + return snap(commit); + } + + // ---- non-gesture open/close call sites (unified onto slide_px_) -------- + // 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). + auto open_now() -> Outcome { + open_ = true; + dragging_ = false; + slide_px_ = dock_box(1.0); // == 0 + return Outcome{.make_visible = true, .dirty_slide = true, .dirty_dragging = true}; + } + + // 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. + auto close_now() -> Outcome { + open_ = false; + dragging_ = false; + slide_px_ = dock_box(0.0); // == -dock_width + return Outcome{.dirty_slide = true, .dirty_dragging = true}; + } + +private: + [[nodiscard]] auto is_active(std::int32_t id) const -> bool { + return active_touch_.has_value() && *active_touch_ == id; + } + + // The body translateX px for a reveal fraction (only .x matters here). + [[nodiscard]] auto dock_box(double fraction) const -> double { + 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. + auto snap(reveal::RevealCommit commit) -> Outcome { + dragging_ = false; + if (commit == reveal::RevealCommit::open) { + open_ = true; + slide_px_ = dock_box(1.0); // 0 + return Outcome{.make_visible = true, .dirty_slide = true, .dirty_dragging = true}; + } + open_ = false; + slide_px_ = dock_box(0.0); // -dock_width + return Outcome{.dirty_slide = true, .dirty_dragging = true}; + } + + reveal::RevealRecognizer recognizer_; + layout::DockMetrics metrics_; + std::optional<std::int32_t> active_touch_; + double slide_px_ = 0.0; // set to the closed offset by the glue at create + bool dragging_ = false; + bool open_ = false; +}; + +} // namespace unbox::ext_stage_dock::gesture diff --git a/packages/ext-stage-dock/src/reveal.hpp b/packages/ext-stage-dock/src/reveal.hpp index af220e2..e8dbef1 100644 --- a/packages/ext-stage-dock/src/reveal.hpp +++ b/packages/ext-stage-dock/src/reveal.hpp @@ -63,7 +63,18 @@ public: // commit close. For the normal OPEN-from-hidden gesture, leave it 0.0. The // anchor x (origin_x_) is the down x; the fraction tracks inward travel from // there, biased by start_fraction. - auto begin(double x, double y, std::uint32_t t, double start_fraction = 0.0) -> bool { + // + // force_active SKIPS the edge-slop gate (the `x <= edge_slop` test) and makes + // the gesture active regardless of where it began. The OPEN gesture leaves it + // false: it must start within edge_slop of the screen edge to count as a + // reveal. The CLOSE gesture (e1, fed by UiSurface::bind_drag) sets it true: + // the finger lands ANYWHERE on the already-open dock (surface-local x is in + // [0, dock_width], not near the edge), so the edge gate would wrongly reject + // it. The travel math (fraction = start_fraction + (x - origin_x)/width) is + // origin-relative, so a forced-active close still tracks the finger correctly + // from whatever x it began at. Velocity / fling are unaffected. + auto begin(double x, double y, std::uint32_t t, double start_fraction = 0.0, + bool force_active = false) -> bool { (void)y; origin_x_ = x; start_fraction_ = clamp_fraction(start_fraction); @@ -71,7 +82,7 @@ public: last_x_ = x; last_t_ = t; velocity_ = 0.0; - active_ = x <= static_cast<double>(config_.edge_slop); + active_ = force_active || x <= static_cast<double>(config_.edge_slop); return active_; } diff --git a/packages/ext-stage-dock/tests/test_policy.cpp b/packages/ext-stage-dock/tests/test_policy.cpp index b07739b..915b4d6 100644 --- a/packages/ext-stage-dock/tests/test_policy.cpp +++ b/packages/ext-stage-dock/tests/test_policy.cpp @@ -2,14 +2,18 @@ #include <doctest/doctest.h> #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. -// Two cores: the reveal recognizer (reversible edge swipe -> fraction + commit) -// and the dock layout geometry (reveal fraction + slot count -> rects). +// 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. namespace rv = unbox::ext_stage_dock::reveal; namespace lay = unbox::ext_stage_dock::layout; +namespace gst = unbox::ext_stage_dock::gesture; using rv::RevealCommit; using rv::RevealConfig; @@ -182,3 +186,206 @@ TEST_CASE("dock_box: revealed rail is dock_width x full output height, count-ind CHECK(rail.h == oh); // FULL output height, independent of any card count } } + +// ============================================================================ +// gesture Controller (e1) — the touch/drag STREAM -> state transition +// ============================================================================ + +using gst::Controller; + +// Matches the recognizer test config: 100px dock so fractions are exact, 0.5 +// threshold, 1.0 px/ms fling, 24px edge slop. metrics: 100px dock_width so +// dock_box(f).x = -100*(1-f) (closed = -100, open = 0). +static auto ctrl() -> Controller { + return Controller( + RevealConfig{.dock_width = 100, .open_threshold = 0.5, .fling_velocity = 1.0, + .edge_slop = 24}, + lay::DockMetrics{.output_w = 1920, .output_h = 1080, .dock_width = 100}); +} + +TEST_CASE("Controller: full OPEN stream past 50% ends open, flush, visible") { + Controller c = ctrl(); + CHECK(c.open() == false); + CHECK(c.dragging() == false); + + // touch_down at the very edge: begins the OPEN reveal. + auto down = c.touch_down(/*id=*/1, /*lx=*/0.0, /*ly=*/200.0, /*t=*/0); + CHECK(down.make_visible); + CHECK(down.dirty_slide); + CHECK(down.dirty_dragging); + CHECK(c.dragging()); + CHECK(c.gesturing()); + CHECK(c.slide_px() == doctest::Approx(-100.0)); // f=0 fully hidden + + // motion inward to 60px -> fraction 0.6 -> slide -40. + auto m1 = c.touch_motion(1, 60.0, 200.0, 1000); + CHECK(m1.dirty_slide); + CHECK_FALSE(m1.dirty_dragging); + CHECK(c.slide_px() == doctest::Approx(-40.0)); + + // release at 0.6 (>= 0.5, slow) -> OPEN, slide 0, surface visible. + auto up = c.touch_up(1, 1000); + CHECK(up.make_visible); + CHECK(up.dirty_slide); + CHECK(up.dirty_dragging); + CHECK(c.open()); + CHECK_FALSE(c.dragging()); + CHECK_FALSE(c.gesturing()); + CHECK(c.slide_px() == doctest::Approx(0.0)); +} + +TEST_CASE("Controller: slide_px advances monotonically with inward motion") { + Controller c = ctrl(); + REQUIRE(c.touch_down(1, 0.0, 0.0, 0).make_visible); + double prev = c.slide_px(); + for (double x : {10.0, 30.0, 55.0, 80.0, 100.0}) { + c.touch_motion(1, x, 0.0, static_cast<std::uint32_t>(x) + 100); + CHECK(c.slide_px() >= prev); + prev = c.slide_px(); + } + CHECK(c.slide_px() == doctest::Approx(0.0)); // f=1 flush +} + +TEST_CASE("Controller: OPEN release below 50% ends closed") { + Controller c = ctrl(); + REQUIRE(c.touch_down(1, 0.0, 0.0, 0).make_visible); + c.touch_motion(1, 40.0, 0.0, 1000); // fraction 0.4, slow + auto up = c.touch_up(1, 1000); + CHECK(up.dirty_slide); + CHECK(up.dirty_dragging); + CHECK_FALSE(up.make_visible); // close: no make_visible (dock_settled hides) + CHECK_FALSE(c.open()); + CHECK_FALSE(c.dragging()); + CHECK(c.slide_px() == doctest::Approx(-100.0)); // closed offset +} + +TEST_CASE("Controller: fast inward fling below 50% still opens") { + Controller c = ctrl(); + REQUIRE(c.touch_down(1, 0.0, 0.0, 0).make_visible); + c.touch_motion(1, 30.0, 0.0, 10); // 0.3 fraction but 3 px/ms >= fling + auto up = c.touch_up(1, 10); + CHECK(up.make_visible); + CHECK(c.open()); + CHECK(c.slide_px() == doctest::Approx(0.0)); +} + +TEST_CASE("Controller: edge-slop rejection — a press past the slop is ignored") { + Controller c = ctrl(); + auto down = c.touch_down(1, /*lx=*/25.0, 0.0, 0); // just past 24px slop + CHECK_FALSE(down.make_visible); + CHECK_FALSE(down.dirty_slide); + CHECK_FALSE(down.dirty_dragging); + CHECK_FALSE(c.gesturing()); + CHECK_FALSE(c.dragging()); + // Subsequent motion/up for that id are no-ops (no active gesture). + auto m = c.touch_motion(1, 80.0, 0.0, 100); + CHECK_FALSE(m.dirty_slide); + auto up = c.touch_up(1, 100); + CHECK_FALSE(up.dirty_slide); + CHECK_FALSE(c.open()); +} + +TEST_CASE("Controller: a touch_down while already open is ignored") { + Controller c = ctrl(); + c.open_now(); + REQUIRE(c.open()); + auto down = c.touch_down(1, 0.0, 0.0, 0); // edge press, but dock is open + CHECK_FALSE(down.make_visible); + CHECK_FALSE(down.dirty_slide); + CHECK_FALSE(c.gesturing()); + CHECK(c.open()); // unchanged +} + +TEST_CASE("Controller: motion/up for a non-active touch id are ignored") { + Controller c = ctrl(); + REQUIRE(c.touch_down(1, 0.0, 0.0, 0).make_visible); + auto m = c.touch_motion(/*other id=*/2, 80.0, 0.0, 100); + CHECK_FALSE(m.dirty_slide); + CHECK(c.slide_px() == doctest::Approx(-100.0)); // unchanged by the foreign id + auto up = c.touch_up(2, 100); + CHECK_FALSE(up.dirty_slide); + CHECK(c.gesturing()); // id 1 still active +} + +TEST_CASE("Controller: touch_cancel reverts the active OPEN gesture to closed") { + Controller c = ctrl(); + REQUIRE(c.touch_down(1, 0.0, 0.0, 0).make_visible); + c.touch_motion(1, 90.0, 0.0, 1000); // dragged well open (0.9) + auto cancel = c.touch_cancel(1); + CHECK(cancel.dirty_dragging); + CHECK(cancel.dirty_slide); + CHECK_FALSE(cancel.make_visible); + CHECK_FALSE(c.open()); + CHECK_FALSE(c.dragging()); + CHECK_FALSE(c.gesturing()); + CHECK(c.slide_px() == doctest::Approx(-100.0)); +} + +TEST_CASE("Controller: CLOSE drag toward the edge below 50% closes") { + Controller c = ctrl(); + c.open_now(); + REQUIRE(c.open()); + CHECK(c.slide_px() == doctest::Approx(0.0)); + + // drag_start force-active at fraction 1.0 (finger lands anywhere on the open + // dock). dragging on, no slide change yet. + auto start = c.drag_start(/*x=*/50.0, /*y=*/200.0, /*t=*/0); + CHECK(start.dirty_dragging); + CHECK(c.dragging()); + + // drag_move back toward the edge: from x=50 to x=-20 is -70px travel -> + // 1.0 + (-70/100) = 0.3 fraction -> slide -70. + auto move = c.drag_move(-20.0, 200.0, 1000); + CHECK(move.dirty_slide); + CHECK(c.slide_px() == doctest::Approx(-70.0)); + + // drag_end at 0.3 (< 0.5, slow) -> CLOSE. + auto end = c.drag_end(1000); + CHECK(end.dirty_dragging); + CHECK(end.dirty_slide); + CHECK_FALSE(end.make_visible); + CHECK_FALSE(c.open()); + CHECK_FALSE(c.dragging()); + CHECK(c.slide_px() == doctest::Approx(-100.0)); +} + +TEST_CASE("Controller: a CLOSE drag that barely travels stays open") { + Controller c = ctrl(); + c.open_now(); + c.drag_start(50.0, 0.0, 0); + c.drag_move(30.0, 0.0, 1000); // -20px -> 0.8, still past threshold, slow + auto end = c.drag_end(1000); + CHECK(c.open()); + CHECK_FALSE(c.dragging()); + CHECK(c.slide_px() == doctest::Approx(0.0)); // snapped back to open + CHECK(end.dirty_slide); +} + +TEST_CASE("Controller: open_now / close_now set the target + Outcome flags") { + Controller c = ctrl(); + auto o = c.open_now(); + CHECK(o.make_visible); + CHECK(o.dirty_slide); + CHECK(o.dirty_dragging); + CHECK(c.open()); + CHECK_FALSE(c.dragging()); + CHECK(c.slide_px() == doctest::Approx(0.0)); + + auto cl = c.close_now(); + CHECK_FALSE(cl.make_visible); + CHECK(cl.dirty_slide); + CHECK(cl.dirty_dragging); + CHECK_FALSE(c.open()); + CHECK(c.slide_px() == doctest::Approx(-100.0)); +} + +TEST_CASE("Controller: set_metrics re-scales the slide offset for a new output") { + Controller c = ctrl(); + c.set_metrics(lay::DockMetrics{.output_w = 1920, .output_h = 1080, .dock_width = 100}); + c.close_now(); + CHECK(c.slide_px() == doctest::Approx(-100.0)); + // dock_box only uses dock_width for .x, so changing only output_h keeps it. + c.set_metrics(lay::DockMetrics{.output_w = 2560, .output_h = 1440, .dock_width = 100}); + c.close_now(); + CHECK(c.slide_px() == doctest::Approx(-100.0)); +} |
