diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 17:31:27 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 17:31:27 +0900 |
| commit | 2c4271aba1dddcb508f5dea92063d829ba2e97c9 (patch) | |
| tree | 34afb22bd03738d1ecccbbb806c48308e7179468 | |
| parent | 55588c486d8b407130e76fc7ebbb32a0368931bc (diff) | |
| download | unbox-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.build | 1 | ||||
| -rw-r--r-- | packages/ext-stage-dock/ext-stage-dock.md | 40 | ||||
| -rw-r--r-- | packages/ext-stage-dock/include/unbox/ext-stage-dock/ext_stage_dock.hpp | 30 | ||||
| -rw-r--r-- | packages/ext-stage-dock/meson.build | 52 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/dock_layout.hpp | 101 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/extension.cpp | 53 | ||||
| -rw-r--r-- | packages/ext-stage-dock/src/reveal.hpp | 153 | ||||
| -rw-r--r-- | packages/ext-stage-dock/tests/test_policy.cpp | 210 |
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 +} |
