summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 17:31:27 +0900
committerAdam Malczewski <[email protected]>2026-06-13 17:31:27 +0900
commit2c4271aba1dddcb508f5dea92063d829ba2e97c9 (patch)
tree34afb22bd03738d1ecccbbb806c48308e7179468
parent55588c486d8b407130e76fc7ebbb32a0368931bc (diff)
downloadunbox-2c4271aba1dddcb508f5dea92063d829ba2e97c9.tar.gz
unbox-2c4271aba1dddcb508f5dea92063d829ba2e97c9.zip
Slice 10 b4: ext-stage-dock new unit — skeleton + pure cores
New standard extension (id "stage-dock", depends_on xdg-shell). Ships the unit skeleton (factory-only public header, minimal no-op activate, meson + suite) and the two PURE DECISION CORES, doctest-hard with no kernel/wlroots: - src/reveal.hpp: reversible left-edge swipe recognizer (stream -> reveal fraction [0,1]; release -> open/close commit by threshold + fling velocity). - src/dock_layout.hpp: reveal-fraction -> on-screen dock box (slides -dock_width .. 0) + slot capacity / content-height / per-slot rect math. Real wiring (RML doc, snapshot, minimize/restore) lands in c2/d1/e1. Root meson.build gains the subdir; host-bin registration deferred to c2. ext-stage-dock suite green (17 cases / 74 assertions). Edits in packages/ext-stage-dock/ + root meson.build subdir.
-rw-r--r--meson.build1
-rw-r--r--packages/ext-stage-dock/ext-stage-dock.md40
-rw-r--r--packages/ext-stage-dock/include/unbox/ext-stage-dock/ext_stage_dock.hpp30
-rw-r--r--packages/ext-stage-dock/meson.build52
-rw-r--r--packages/ext-stage-dock/src/dock_layout.hpp101
-rw-r--r--packages/ext-stage-dock/src/extension.cpp53
-rw-r--r--packages/ext-stage-dock/src/reveal.hpp153
-rw-r--r--packages/ext-stage-dock/tests/test_policy.cpp210
8 files changed, 640 insertions, 0 deletions
diff --git a/meson.build b/meson.build
index 846b5e6..3d7fb59 100644
--- a/meson.build
+++ b/meson.build
@@ -51,4 +51,5 @@ subdir('packages/kernel')
subdir('packages/ext-xdg-shell')
subdir('packages/ext-layer-shell')
subdir('packages/ext-keybindings')
+subdir('packages/ext-stage-dock')
subdir('packages/host-bin')
diff --git a/packages/ext-stage-dock/ext-stage-dock.md b/packages/ext-stage-dock/ext-stage-dock.md
new file mode 100644
index 0000000..025066e
--- /dev/null
+++ b/packages/ext-stage-dock/ext-stage-dock.md
@@ -0,0 +1,40 @@
+# ext-stage-dock
+
+The **stage dock** (slice 10): the left-edge ui surface holding minimized-window
+**previews**, **revealed** by a left-edge **swipe** (GLOSSARY; notes/plan.md §2).
+A **standard**-tier extension (`id "stage-dock"`, `depends_on {"xdg-shell"}`). It
+is a LEAF consumer for now: its whole contract is the `create()` factory in
+`include/unbox/ext-stage-dock/ext_stage_dock.hpp` — no exported hooks/services
+yet.
+
+## Status (b4 — skeleton + pure cores)
+This step ships ONLY the unit skeleton and the two pure decision cores, doctest-
+hard with no kernel/wlroots running. `activate()` is a deliberate no-op (one log
+line). Runtime wiring comes later: c2 = static integration (consume
+ext-xdg-shell's Service, build the RML document), d1 = reveal animation, e1 =
+the edge-swipe gesture feeding the recognizer.
+
+## Pure cores
+- `src/reveal.hpp` — the **reveal** recognizer: a reversible, finger-following
+ recognizer for the left-edge swipe. `begin()` accepts only edge-started
+ presses (`x <= edge_slop`); `update()` returns the reveal **fraction** in
+ [0,1] (inward travel / `dock_width`, clamped, reversible); `end()` commits
+ open/close from final fraction + recent velocity (a fast fling overrides the
+ position threshold). A `start_fraction` arg lets the SAME recognizer drive the
+ symmetric CLOSE drag (seed 1.0, drag back toward the edge).
+- `src/dock_layout.hpp` — the dock **frame** + reveal **offset** + scroll/
+ capacity math. `dock_box(f)` slides the dock from `x = -dock_width` (f=0,
+ hidden) to `x = 0` (f=1, revealed), covering output height. `visible_slots`,
+ `content_height`, `slot_box(i, scroll)` give the slot stacking + scroll range.
+ Fork B: RML/RCSS does the in-dock slot FLOW; this core does the frame/offset.
+
+## Layout
+- `include/unbox/ext-stage-dock/ext_stage_dock.hpp` — the factory (contract).
+- `src/reveal.hpp` — reveal recognizer (pure; header-only).
+- `src/dock_layout.hpp` — dock geometry (pure; header-only).
+- `src/extension.cpp` — glue: minimal manifest + no-op `activate()` (skeleton).
+- `tests/test_policy.cpp` — both cores, doctest-hard.
+
+## Vocabulary
+All terms used (stage dock, reveal, swipe, preview, slot) are canonical in
+GLOSSARY.md — no new coinage.
diff --git a/packages/ext-stage-dock/include/unbox/ext-stage-dock/ext_stage_dock.hpp b/packages/ext-stage-dock/include/unbox/ext-stage-dock/ext_stage_dock.hpp
new file mode 100644
index 0000000..f56ed8c
--- /dev/null
+++ b/packages/ext-stage-dock/include/unbox/ext-stage-dock/ext_stage_dock.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <unbox/kernel/extension.hpp>
+
+#include <memory>
+
+// ext-stage-dock — the stage dock (slice 10): the left-edge ui surface holding
+// minimized-window PREVIEWS, REVEALED by a left-edge SWIPE (GLOSSARY / notes/
+// plan.md §2). Tier: standard. Manifest { id "stage-dock", depends_on
+// {"xdg-shell"} } — it will consume ext-xdg-shell's Service (toplevel lifecycle
+// + the future hide()/show() minimize mechanism) once wiring lands.
+//
+// THIS step (b4) ships only the skeleton + the two PURE DECISION CORES
+// (src/reveal.hpp the reversible edge-swipe recognizer, src/dock_layout.hpp the
+// reveal→on-screen-geometry math), doctest-hard with NO kernel/wlroots running.
+// activate() is a deliberate no-op for now; real wiring is later steps
+// (c2 = static integration, d1 = animation, e1 = gesture).
+//
+// This header is the unit's WHOLE cross-extension contract: a factory only
+// (mirroring ext-keybindings / ext-layer-shell). It is a LEAF for now — it
+// exports no hooks or services yet. Single wl_event_loop thread throughout.
+
+namespace unbox::ext_stage_dock {
+
+// Construct the extension (ownership transfer to the caller; host-bin installs
+// it via Server::install at c2). Construction is cheap and side-effect free per
+// the Extension contract; ALL wiring — once it exists — happens in activate().
+[[nodiscard]] auto create() -> std::unique_ptr<kernel::Extension>;
+
+} // namespace unbox::ext_stage_dock
diff --git a/packages/ext-stage-dock/meson.build b/packages/ext-stage-dock/meson.build
new file mode 100644
index 0000000..527cab6
--- /dev/null
+++ b/packages/ext-stage-dock/meson.build
@@ -0,0 +1,52 @@
+# ext-stage-dock — the stage dock (slice 10): the left-edge ui surface of
+# minimized-window PREVIEWS, REVEALED by a left-edge SWIPE (GLOSSARY). A
+# STANDARD-tier extension; public header (the contract): the create() factory in
+# include/unbox/ext-stage-dock/. A LEAF for now — exports no hooks/services yet.
+#
+# This b4 step is the skeleton + the two PURE DECISION CORES: src/reveal.hpp (the
+# reversible edge-swipe recognizer) and src/dock_layout.hpp (reveal -> on-screen
+# geometry), both wlroots/GL/RMLUi-free and doctest-hard. The glue
+# (src/extension.cpp) is a no-op activate() for now; real wiring lands later
+# (c2 static integration, d1 animation, e1 gesture).
+
+ext_stage_dock_inc = include_directories('include')
+
+# Glue library. Needs the kernel ABI and ext-xdg-shell's public contract (the
+# manifest depends_on "xdg-shell"; c2 will consume its Service through the glue).
+ext_stage_dock_lib = static_library(
+ 'unbox-ext-stage-dock',
+ 'src/extension.cpp',
+ include_directories: ext_stage_dock_inc,
+ dependencies: [kernel_dep, ext_xdg_shell_dep],
+)
+
+# What host-bin links against (registered at c2, orchestrator's step): the
+# factory. kernel_dep rides through for the Extension ABI the factory returns.
+# ext-xdg-shell stays a build-time dep of OUR lib only (factory consumers do not
+# need it).
+ext_stage_dock_dep = declare_dependency(
+ link_with: ext_stage_dock_lib,
+ include_directories: ext_stage_dock_inc,
+ dependencies: [kernel_dep],
+)
+
+# Tests: the pure decision cores doctest-hard (the reveal recognizer + the dock
+# layout geometry) with NO kernel/wlroots running. The TU compiles against the
+# header-only cores directly and needs `src` on the include path (a unit may read
+# its own src/). No glue/headless test this step — activate() is a no-op.
+ext_stage_dock_policy_test = executable(
+ 'ext-stage-dock-policy-tests',
+ 'tests/test_policy.cpp',
+ include_directories: [ext_stage_dock_inc, include_directories('src')],
+ dependencies: [doctest_dep],
+)
+test(
+ 'ext-stage-dock-policy',
+ ext_stage_dock_policy_test,
+ suite: 'ext-stage-dock',
+)
+
+# Aggregate alias the brief builds: `ninja -C build ext-stage-dock-tests`.
+alias_target('ext-stage-dock-tests',
+ ext_stage_dock_policy_test,
+)
diff --git a/packages/ext-stage-dock/src/dock_layout.hpp b/packages/ext-stage-dock/src/dock_layout.hpp
new file mode 100644
index 0000000..43ee910
--- /dev/null
+++ b/packages/ext-stage-dock/src/dock_layout.hpp
@@ -0,0 +1,101 @@
+#pragma once
+
+#include <algorithm>
+
+// Pure decision core 2 — DOCK LAYOUT: the geometry mapping a reveal fraction +
+// slot count to on-screen rects for the stage dock. No wlroots / GL / RMLUi —
+// plain ints in, plain rects out, doctest-covered in tests/test_policy.cpp.
+//
+// Fork B division of labour: the RML/RCSS inside the dock document does the
+// in-dock slot FLOW (wrapping, styling); this core owns the dock FRAME (its
+// on-screen rect as the reveal slides it in from the left), the reveal OFFSET,
+// and the SCROLL/CAPACITY math (how many slots fit, total content height, and a
+// per-slot rect for callers that want compositor-side placement). All glue (c2/
+// d1) consumes these; this file calls nothing back.
+//
+// Single wl_event_loop thread throughout.
+
+namespace unbox::ext_stage_dock::layout {
+
+// A pixel rectangle in output layout coords. (Matches the kernel's int box
+// conventions; the glue converts to wlr_box at the edge.)
+struct Box {
+ int x = 0;
+ int y = 0;
+ int w = 0;
+ int h = 0;
+
+ [[nodiscard]] auto operator==(const Box&) const -> bool = default;
+};
+
+// Static metrics of the dock on a given output. `output_w`/`output_h` are the
+// output's pixel size. `dock_width` is the revealed dock's on-screen width.
+// `slot_height` is one preview slot's height; `gap` the vertical space between
+// slots; `pad` the inner margin at the top (and conceptually all edges) of the
+// dock content. All in px.
+struct DockMetrics {
+ int output_w = 0;
+ int output_h = 0;
+ int dock_width = 320;
+ int slot_height = 96;
+ int gap = 8;
+ int pad = 8;
+};
+
+// The dock's on-screen rect at reveal fraction f in [0,1]. The dock slides
+// horizontally: at f=0 it is fully hidden off the left (x == -dock_width); at
+// f=1 it is fully revealed (x == 0). x is monotonic non-decreasing in f. y/h
+// cover the full output height; w is always dock_width (the dock keeps its
+// width and translates — it does not grow). f is clamped to [0,1].
+[[nodiscard]] inline auto dock_box(const DockMetrics& m, double fraction) -> Box {
+ const double f = std::clamp(fraction, 0.0, 1.0);
+ // x goes from -dock_width (f=0) to 0 (f=1): x = -dock_width * (1 - f).
+ const int x = -static_cast<int>(static_cast<double>(m.dock_width) * (1.0 - f));
+ return Box{.x = x, .y = 0, .w = m.dock_width, .h = m.output_h};
+}
+
+// One slot's full stride: its height plus the gap below it.
+[[nodiscard]] inline auto slot_stride(const DockMetrics& m) -> int {
+ return m.slot_height + m.gap;
+}
+
+// How many slots fit in the revealed dock WITHOUT scrolling. The usable height
+// is the output height minus the top+bottom pad; each slot occupies
+// slot_height and slots are separated by `gap` (no gap after the last). Returns
+// 0 if nothing fits. Independent of `count` — this is pure capacity.
+[[nodiscard]] inline auto visible_slots(const DockMetrics& m) -> int {
+ const int usable = m.output_h - 2 * m.pad;
+ if (usable < m.slot_height || m.slot_height <= 0) {
+ return 0;
+ }
+ // usable >= slot_height + k*(slot_height+gap) => k = (usable - slot_height) / stride
+ const int stride = slot_stride(m);
+ if (stride <= 0) {
+ return 1; // degenerate gap+height; at least the one that fit above
+ }
+ return 1 + (usable - m.slot_height) / stride;
+}
+
+// The total content height needed to stack `count` slots: top pad + count slots
+// with `gap` between them + bottom pad (no trailing gap). 0 slots -> 0 (an empty
+// dock has no content). Used to clamp the scroll range.
+[[nodiscard]] inline auto content_height(const DockMetrics& m, int count) -> int {
+ if (count <= 0) {
+ return 0;
+ }
+ return 2 * m.pad + count * m.slot_height + (count - 1) * m.gap;
+}
+
+// The on-screen rect of slot `i` (0-based) within the dock content, given the
+// current vertical `scroll` offset (px scrolled DOWN; 0 = top). The slot's
+// content-space top is pad + i*(slot_height+gap); subtracting `scroll` yields
+// its screen y. x is the inner pad; width is dock_width minus pad on both sides
+// (clamped to >= 0). A negative or off-screen y is returned as-is (the caller /
+// RCSS clips); this core does not cull.
+[[nodiscard]] inline auto slot_box(const DockMetrics& m, int i, int scroll) -> Box {
+ const int content_y = m.pad + i * slot_stride(m);
+ const int w = std::max(0, m.dock_width - 2 * m.pad);
+ return Box{.x = m.pad, .y = content_y - scroll, .w = w, .h = m.slot_height};
+}
+
+} // namespace unbox::ext_stage_dock::layout
diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp
new file mode 100644
index 0000000..b43dcad
--- /dev/null
+++ b/packages/ext-stage-dock/src/extension.cpp
@@ -0,0 +1,53 @@
+#include <unbox/ext-stage-dock/ext_stage_dock.hpp>
+
+#include "dock_layout.hpp"
+#include "reveal.hpp"
+
+#include <unbox/kernel/host.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <memory>
+
+// ext-stage-dock glue (b4 SKELETON). The decision cores live in src/reveal.hpp
+// (the reversible edge-swipe recognizer) and src/dock_layout.hpp (reveal ->
+// on-screen geometry) — both wlroots/GL/RMLUi-free and doctest-hard. THIS file
+// is the thin effectful edge: for now it is a deliberate no-op that just logs at
+// activate(), so the unit compiles and installs cleanly while the real wiring
+// (c2 static integration, d1 animation, e1 gesture) lands in later steps.
+//
+// Everything runs on the single wl_event_loop thread. Every future resource will
+// be a RAII member of StageDockExtension; teardown is reverse-declaration
+// destruction (no manual teardown lists — extension-agent.md).
+
+namespace unbox::ext_stage_dock {
+namespace {
+
+using kernel::Host;
+
+class StageDockExtension final : public kernel::Extension {
+public:
+ auto manifest() const -> const kernel::Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ // No-op this step: no hooks, no service, no RML document yet. The pure
+ // cores are exercised by the doctest suite, not at runtime. Real wiring
+ // arrives at c2 (consume ext-xdg-shell's Service) / d1 / e1.
+ (void)host;
+ wlr_log(WLR_INFO, "ext-stage-dock: activate (skeleton; no wiring yet)");
+ }
+
+private:
+ const kernel::Manifest manifest_{
+ .id = "stage-dock",
+ .tier = kernel::Tier::standard,
+ .depends_on = {"xdg-shell"},
+ };
+};
+
+} // namespace
+
+auto create() -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<StageDockExtension>();
+}
+
+} // namespace unbox::ext_stage_dock
diff --git a/packages/ext-stage-dock/src/reveal.hpp b/packages/ext-stage-dock/src/reveal.hpp
new file mode 100644
index 0000000..af220e2
--- /dev/null
+++ b/packages/ext-stage-dock/src/reveal.hpp
@@ -0,0 +1,153 @@
+#pragma once
+
+#include <algorithm>
+#include <cstdint>
+
+// Pure decision core 1 — the REVEAL recognizer (GLOSSARY "reveal"/"swipe"): the
+// reversible, finger-following recognizer for the left-edge swipe that shows the
+// stage dock. No wlroots / GL / RMLUi — input is a stream of drag samples (layout
+// x/y + time ms), output is a reveal FRACTION in [0,1] and, on release, a commit
+// decision (snap fully open or fully closed). Heavily doctest-covered in
+// tests/test_policy.cpp with nothing running; the glue (e1) feeds kernel touch/
+// pointer events into these calls and animates from the result.
+//
+// Reversibility: dragging inward (rightward, away from the left edge) GROWS the
+// fraction; dragging back toward the edge SHRINKS it. So the same recognizer
+// drives the close gesture too — see RevealRecognizer::begin's start_fraction.
+//
+// Single wl_event_loop thread throughout (no internal synchronization).
+
+namespace unbox::ext_stage_dock::reveal {
+
+// Tunables for the recognizer. `dock_width` is the inward travel (px) that maps
+// to a full reveal (fraction 1). `open_threshold` is the release fraction at or
+// above which a slow drag commits OPEN. `fling_velocity` (px/ms, inward
+// positive) is the release speed at or above which a fast flick commits OPEN
+// even below the threshold (and, symmetrically, an outward flick at or below
+// -fling_velocity commits CLOSE even above the threshold). `edge_slop` is how
+// far from the left edge (px) a press may start and still count as a reveal.
+struct RevealConfig {
+ int dock_width = 320;
+ double open_threshold = 0.5;
+ double fling_velocity = 1.0; // px/ms
+ int edge_slop = 24; // px from the left edge
+};
+
+// The release decision: snap the dock fully open or fully closed.
+enum class RevealCommit {
+ open,
+ close,
+};
+
+// A reversible finger-following recognizer for ONE drag. Construct with config,
+// begin() at the down sample, update() on each move (returns the live fraction),
+// end() at the up sample (returns the commit). Reusable across drags: a fresh
+// begin() resets all per-drag state.
+//
+// Geometry note: only the X axis matters (the dock slides horizontally); Y is
+// accepted for symmetry with the kernel touch/pointer payloads and ignored. All
+// fractions are clamped to [0,1].
+class RevealRecognizer {
+public:
+ explicit RevealRecognizer(RevealConfig config) : config_(config) {}
+
+ // Begin a drag at the down sample (layout x/y, time ms). Returns true iff
+ // this is a reveal gesture — i.e. it started within `edge_slop` of the left
+ // edge (x <= edge_slop). When false, the caller should ignore the gesture
+ // (it is not an edge swipe) and NOT feed update()/end().
+ //
+ // start_fraction seeds the fraction this drag begins from, so a CLOSE drag
+ // (starting from the already-open dock) reuses the SAME recognizer: pass
+ // start_fraction = 1.0 and a start x at the dock's right edge, then dragging
+ // back toward the screen edge shrinks the fraction toward 0 and end() can
+ // 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 {
+ (void)y;
+ origin_x_ = x;
+ start_fraction_ = clamp_fraction(start_fraction);
+ fraction_ = start_fraction_;
+ last_x_ = x;
+ last_t_ = t;
+ velocity_ = 0.0;
+ active_ = x <= static_cast<double>(config_.edge_slop);
+ return active_;
+ }
+
+ // Update with the current sample; returns the live reveal fraction in [0,1].
+ // The fraction is start_fraction + (inward travel from origin / dock_width),
+ // clamped. Inward = rightward (x increasing) for the open gesture; dragging
+ // back toward the edge decreases it (fully reversible). Also tracks a
+ // smoothed-enough instantaneous velocity (px/ms, inward positive) for the
+ // fling decision in end(). A non-active recognizer (begin returned false)
+ // returns its current fraction unchanged.
+ auto update(double x, double y, std::uint32_t t) -> double {
+ (void)y;
+ if (!active_) {
+ return fraction_;
+ }
+ const double inward = x - origin_x_;
+ const double width = config_.dock_width > 0 ? static_cast<double>(config_.dock_width) : 1.0;
+ fraction_ = clamp_fraction(start_fraction_ + inward / width);
+
+ // Instantaneous velocity from the last sample. dt==0 (same timestamp)
+ // keeps the previous velocity rather than dividing by zero.
+ const double dt = static_cast<double>(t) - static_cast<double>(last_t_);
+ if (dt > 0.0) {
+ velocity_ = (x - last_x_) / dt;
+ }
+ last_x_ = x;
+ last_t_ = t;
+ return fraction_;
+ }
+
+ // Release; decide open vs close from the final fraction + recent velocity:
+ // * a fast INWARD fling (velocity >= fling_velocity) commits OPEN even
+ // below the threshold;
+ // * a fast OUTWARD fling (velocity <= -fling_velocity) commits CLOSE even
+ // above the threshold;
+ // * otherwise a slow release commits by position: OPEN iff
+ // fraction >= open_threshold, else CLOSE.
+ // `t` is accepted for symmetry / a final velocity refresh but the decision
+ // uses the velocity accumulated across update()s. A non-active recognizer
+ // commits close (nothing was revealed).
+ auto end(std::uint32_t t) -> RevealCommit {
+ (void)t;
+ if (!active_) {
+ return RevealCommit::close;
+ }
+ if (velocity_ >= config_.fling_velocity) {
+ return RevealCommit::open;
+ }
+ if (velocity_ <= -config_.fling_velocity) {
+ return RevealCommit::close;
+ }
+ return fraction_ >= config_.open_threshold ? RevealCommit::open : RevealCommit::close;
+ }
+
+ // The live fraction (same value the last update() returned). Read-only.
+ [[nodiscard]] auto fraction() const -> double { return fraction_; }
+
+ // Whether begin() accepted this drag as an edge-started reveal.
+ [[nodiscard]] auto active() const -> bool { return active_; }
+
+ // The last measured instantaneous velocity (px/ms, inward positive).
+ [[nodiscard]] auto velocity() const -> double { return velocity_; }
+
+private:
+ [[nodiscard]] static auto clamp_fraction(double f) -> double {
+ return std::clamp(f, 0.0, 1.0);
+ }
+
+ RevealConfig config_;
+ double origin_x_ = 0.0;
+ double start_fraction_ = 0.0;
+ double fraction_ = 0.0;
+ double last_x_ = 0.0;
+ std::uint32_t last_t_ = 0;
+ double velocity_ = 0.0;
+ bool active_ = false;
+};
+
+} // namespace unbox::ext_stage_dock::reveal
diff --git a/packages/ext-stage-dock/tests/test_policy.cpp b/packages/ext-stage-dock/tests/test_policy.cpp
new file mode 100644
index 0000000..c2b057f
--- /dev/null
+++ b/packages/ext-stage-dock/tests/test_policy.cpp
@@ -0,0 +1,210 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "dock_layout.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).
+
+namespace rv = unbox::ext_stage_dock::reveal;
+namespace lay = unbox::ext_stage_dock::layout;
+
+using rv::RevealCommit;
+using rv::RevealConfig;
+using rv::RevealRecognizer;
+
+// ============================================================================
+// reveal recognizer
+// ============================================================================
+
+// A config with round numbers so fractions are exact: 100px dock, threshold
+// 0.5, fling 1.0 px/ms, 24px edge slop.
+static auto cfg() -> RevealConfig {
+ return RevealConfig{.dock_width = 100, .open_threshold = 0.5, .fling_velocity = 1.0, .edge_slop = 24};
+}
+
+TEST_CASE("begin: an edge-started press is a reveal; a non-edge press is not") {
+ RevealRecognizer r(cfg());
+ CHECK(r.begin(0.0, 200.0, 0)); // exactly the left edge
+ RevealRecognizer r2(cfg());
+ CHECK(r2.begin(24.0, 200.0, 0)); // exactly at edge_slop
+ RevealRecognizer r3(cfg());
+ CHECK_FALSE(r3.begin(25.0, 200.0, 0)); // just past the slop -> not a reveal
+ RevealRecognizer r4(cfg());
+ CHECK_FALSE(r4.begin(500.0, 200.0, 0)); // mid-screen -> not a reveal
+}
+
+TEST_CASE("fraction grows with inward drag and is clamped to [0,1]") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ CHECK(r.fraction() == doctest::Approx(0.0));
+ CHECK(r.update(50.0, 0.0, 100) == doctest::Approx(0.5)); // 50/100 dock_width
+ CHECK(r.update(100.0, 0.0, 200) == doctest::Approx(1.0)); // full
+ CHECK(r.update(180.0, 0.0, 300) == doctest::Approx(1.0)); // clamped at 1
+}
+
+TEST_CASE("fraction shrinks on reverse drag (reversible) and clamps at 0") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ CHECK(r.update(80.0, 0.0, 100) == doctest::Approx(0.8));
+ CHECK(r.update(30.0, 0.0, 200) == doctest::Approx(0.3)); // dragged back inward->edge
+ CHECK(r.update(0.0, 0.0, 300) == doctest::Approx(0.0)); // back to the edge
+ CHECK(r.update(-40.0, 0.0, 400) == doctest::Approx(0.0)); // past the edge -> clamp 0
+}
+
+TEST_CASE("slow drag past threshold commits open; below threshold commits close") {
+ RevealRecognizer open(cfg());
+ REQUIRE(open.begin(0.0, 0.0, 0));
+ open.update(60.0, 0.0, 1000); // fraction 0.6, 60px over 1000ms -> 0.06 px/ms (slow)
+ CHECK(open.end(1000) == RevealCommit::open);
+
+ RevealRecognizer close(cfg());
+ REQUIRE(close.begin(0.0, 0.0, 0));
+ close.update(40.0, 0.0, 1000); // fraction 0.4 (slow) -> below threshold
+ CHECK(close.end(1000) == RevealCommit::close);
+}
+
+TEST_CASE("at-threshold (==open_threshold) commits open") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ r.update(50.0, 0.0, 1000); // exactly 0.5
+ CHECK(r.end(1000) == RevealCommit::open);
+}
+
+TEST_CASE("a fast inward fling under threshold still commits open (velocity)") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ // Only 30px (fraction 0.3, below 0.5) but in 10ms -> 3 px/ms >= 1.0 fling.
+ r.update(30.0, 0.0, 10);
+ CHECK(r.fraction() == doctest::Approx(0.3));
+ CHECK(r.end(10) == RevealCommit::open);
+}
+
+TEST_CASE("a fast outward fling over threshold commits close (velocity)") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ r.update(90.0, 0.0, 1000); // fraction 0.9 (well past threshold), slow
+ r.update(60.0, 0.0, 1005); // yanked back 30px in 5ms -> -6 px/ms outward fling
+ CHECK(r.fraction() == doctest::Approx(0.6)); // still past threshold by position
+ CHECK(r.end(1005) == RevealCommit::close); // but the fling closes it
+}
+
+TEST_CASE("symmetric CLOSE drag: seed start_fraction=1.0, drag toward edge to close") {
+ // The already-open dock: begin at the dock's right edge with start_fraction
+ // 1.0, then drag back toward the screen edge. The SAME recognizer drives it.
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0, /*start_fraction=*/1.0));
+ CHECK(r.fraction() == doctest::Approx(1.0));
+ // Drag inward->edge by 70px slowly: 1.0 + (-70/100) = 0.3, below threshold.
+ r.update(-70.0, 0.0, 1000);
+ CHECK(r.fraction() == doctest::Approx(0.3));
+ CHECK(r.end(1000) == RevealCommit::close);
+}
+
+TEST_CASE("symmetric CLOSE drag that does not travel far stays open") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0, /*start_fraction=*/1.0));
+ r.update(-20.0, 0.0, 1000); // 1.0 - 0.2 = 0.8, still past threshold, slow
+ CHECK(r.end(1000) == RevealCommit::open);
+}
+
+TEST_CASE("dt==0 samples do not divide by zero and keep prior velocity") {
+ RevealRecognizer r(cfg());
+ REQUIRE(r.begin(0.0, 0.0, 0));
+ r.update(30.0, 0.0, 10); // 3 px/ms inward
+ const double v = r.velocity();
+ r.update(40.0, 0.0, 10); // same timestamp -> velocity unchanged
+ CHECK(r.velocity() == doctest::Approx(v));
+ CHECK(r.fraction() == doctest::Approx(0.4)); // fraction still tracks position
+}
+
+TEST_CASE("an inactive (non-edge) recognizer is inert") {
+ RevealRecognizer r(cfg());
+ CHECK_FALSE(r.begin(500.0, 0.0, 0));
+ CHECK_FALSE(r.active());
+ CHECK(r.update(600.0, 0.0, 100) == doctest::Approx(0.0)); // no movement
+ CHECK(r.end(100) == RevealCommit::close);
+}
+
+// ============================================================================
+// dock layout
+// ============================================================================
+
+static auto metrics() -> lay::DockMetrics {
+ return lay::DockMetrics{
+ .output_w = 1920, .output_h = 1080, .dock_width = 300,
+ .slot_height = 100, .gap = 10, .pad = 20};
+}
+
+TEST_CASE("dock_box: f=0 fully off-screen left, f=1 flush at x==0") {
+ auto m = metrics();
+ auto hidden = lay::dock_box(m, 0.0);
+ CHECK(hidden.x == -300); // -dock_width
+ CHECK(hidden.w == 300);
+ CHECK(hidden.y == 0);
+ CHECK(hidden.h == 1080); // covers the output
+
+ auto shown = lay::dock_box(m, 1.0);
+ CHECK(shown.x == 0);
+ CHECK(shown.w == 300);
+ CHECK(shown.h == 1080);
+}
+
+TEST_CASE("dock_box: x is monotonic non-decreasing in f and clamps outside [0,1]") {
+ auto m = metrics();
+ CHECK(lay::dock_box(m, 0.5).x == -150); // halfway
+ int prev = lay::dock_box(m, 0.0).x;
+ for (double f = 0.0; f <= 1.0; f += 0.1) {
+ int x = lay::dock_box(m, f).x;
+ CHECK(x >= prev);
+ prev = x;
+ }
+ CHECK(lay::dock_box(m, -1.0).x == -300); // clamped to f=0
+ CHECK(lay::dock_box(m, 2.0).x == 0); // clamped to f=1
+}
+
+TEST_CASE("visible_slots: 0/1/many capacity") {
+ // usable = 1080 - 40 = 1040; stride = 110; 1 + (1040-100)/110 = 1 + 8 = 9.
+ CHECK(lay::visible_slots(metrics()) == 9);
+
+ // Exactly one slot fits.
+ lay::DockMetrics one{.output_w = 0, .output_h = 140, .dock_width = 300,
+ .slot_height = 100, .gap = 10, .pad = 20};
+ CHECK(lay::visible_slots(one) == 1); // usable 100 == slot_height
+
+ // Nothing fits (usable < slot_height).
+ lay::DockMetrics none{.output_w = 0, .output_h = 100, .dock_width = 300,
+ .slot_height = 100, .gap = 10, .pad = 20};
+ CHECK(lay::visible_slots(none) == 0); // usable 60 < 100
+}
+
+TEST_CASE("content_height: 0/1/many slots") {
+ auto m = metrics(); // pad 20, slot 100, gap 10
+ CHECK(lay::content_height(m, 0) == 0);
+ CHECK(lay::content_height(m, 1) == 2 * 20 + 100); // 140, no gap
+ CHECK(lay::content_height(m, 3) == 2 * 20 + 3 * 100 + 2 * 10); // 360
+}
+
+TEST_CASE("slot_box: vertical stacking by stride, inset width") {
+ auto m = metrics(); // pad 20, slot 100, gap 10, dock_width 300
+ auto s0 = lay::slot_box(m, 0, 0);
+ CHECK(s0.x == 20); // inner pad
+ CHECK(s0.y == 20); // pad + 0*stride
+ CHECK(s0.w == 300 - 2 * 20); // dock_width minus pad both sides = 260
+ CHECK(s0.h == 100);
+
+ auto s1 = lay::slot_box(m, 1, 0);
+ CHECK(s1.y == 20 + 110); // pad + 1*stride(110) = 130
+ auto s2 = lay::slot_box(m, 2, 0);
+ CHECK(s2.y == 20 + 220); // 240
+}
+
+TEST_CASE("slot_box: scroll offset shifts slots up") {
+ auto m = metrics();
+ auto s2_unscrolled = lay::slot_box(m, 2, 0);
+ auto s2_scrolled = lay::slot_box(m, 2, 150);
+ CHECK(s2_scrolled.y == s2_unscrolled.y - 150);
+ CHECK(s2_scrolled.x == s2_unscrolled.x); // scroll is vertical only
+}