summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-14 12:20:36 +0900
committerAdam Malczewski <[email protected]>2026-06-14 12:20:36 +0900
commit18bcca4f820aca68c6f50c4d59b66d73f0d0970d (patch)
tree4832214e3e5af8f50c3c0056b8897c134ac95029
parenta044b44c936569db7e790546b81b4792c8e72058 (diff)
downloadunbox-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.rcss41
-rw-r--r--assets/ext-stage-dock/dock.rml4
-rw-r--r--packages/ext-stage-dock/src/extension.cpp198
-rw-r--r--packages/ext-stage-dock/src/gesture.hpp211
-rw-r--r--packages/ext-stage-dock/src/reveal.hpp15
-rw-r--r--packages/ext-stage-dock/tests/test_policy.cpp211
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));
+}