diff --git a/build.bat b/build.bat index 82eb5b91..41f0883e 100644 --- a/build.bat +++ b/build.bat @@ -112,7 +112,7 @@ rem --- Test build (pure-logic sources only; no ) ----------------- rem /wd5285 silences a known doctest 2.4.11 header warning under MSVC /W4. cl /nologo /std:c++17 /EHsc /W4 /wd5285 /DWIND_TESTS /I third_party ^ tests\*.cpp ^ - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\profiles.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\cursor_lock.cpp src\mouse_ballistics.cpp src\crosshair.cpp src\config_ui\ini_edit.cpp src\logging.cpp src\tray_items.cpp src\hitch_record.cpp ^ + src\transform.cpp src\zoom_controller.cpp src\config.cpp src\profiles.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\cursor_lock.cpp src\crosshair.cpp src\config_ui\ini_edit.cpp src\logging.cpp src\tray_items.cpp src\hitch_record.cpp ^ /Fe:wind_tests.exe if errorlevel 1 exit /b 1 "%ROOT%wind_tests.exe" diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index ccc762e8..40a9314d 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -81,7 +81,7 @@ build. See [11](11-build-test-release.md). | Render engine | `render_engine.*`, `render_model.*`, `render_shaders.h`, `hdr_info.*`, `hdr_scale.h`, `band_window.h`, `png_dump.*` | | Transform engine | `transform_model.*`, `transform.*`, `mag_host.*`, `tx_warm.h`, `comp_pin.*`, `mpo_boot.h`, `native_cursor.h`, `zoom_ladder.h` | | Colour | `color_filter.*`, `color_matrix.h`, `cursor_tint.*` | -| Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `mouse_ballistics.*`, `keyboard_pan.h`, `typing_key.h` | +| Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `keyboard_pan.h`, `typing_key.h` | | Cursor and lock | `cursor_mapper.*`, `lock_detector.*`, `drag_follow.h`, `gain_learner.h`, `cursor_sprite.*`, `cursor_blanker.*`, `cursor_decode.*`, `sprite_layer.h`, `crosshair.*`, `cursor_lock.*`, `inspect_focus.h` | | Tracking | `focus_track.*`, `view_target.h`, `view_glide.h`, `detached_view.h`, `edge_pan.h`, `caret_rect.h`, `track_filter.h`, `java_bridge*` | | Zoom | `zoom_controller.*` | diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index fe7015ab..ef5910da 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -116,7 +116,7 @@ Engine-shaped keys (`model`) need a restart: they decide which models exist. |---|---|---| | Free (desktop) | The OS cursor | `GetCursorPos - lastSetVirtual`, times `cursorSensitivity` | | Locked (game holds the mouse) | Raw Input mickeys | `rawDx/rawDy * cursorSensitivity` | -| Inspect (cursor frozen) | Ballistics-cooked mickeys | `drainCooked` with a sub-pixel carry | +| Inspect (cursor frozen) | Raw mickeys x learned gain | `GainLearner::gainFor` with a sub-pixel carry | - **Free** reads the cursor's own movement since Wind last placed it, so Windows' pointer acceleration is already applied. diff --git a/docs/architecture/06-input.md b/docs/architecture/06-input.md index f582a64d..d1e91f3b 100644 --- a/docs/architecture/06-input.md +++ b/docs/architecture/06-input.md @@ -30,7 +30,8 @@ Both LL hooks are installed by `HookThreadProc`, a thread that only pumps messag - **A callback that misses `LowLevelHooksTimeout` gets the hook evicted**, so the thread runs at `THREAD_PRIORITY_TIME_CRITICAL`. That is safe because it does no other work. - Callbacks touch only atomics on `InputState`/`InputRouter`: no I/O, no allocation. Hooks and - `WM_INPUT` write; the tick drains (`drainRaw`, `drainCooked`, `keyPressed`, the held flags). + `WM_INPUT` write; the tick drains (`drainRaw`, `keyPressed`, the held flags). Inspect pans from raw mickeys + times the learned gain; the old per-packet ballistic cooking is gone. One mouse movement reaches both paths: the hook (latency-critical, stateless) and `WM_INPUT` (accumulators for the tick). In a free desktop session neither pans the view; the tick's @@ -136,9 +137,6 @@ fast double-click is not lost. The tick fires an absolute click at the look poin injected click carries `LLMHF_INJECTED` (the hook skips it) and its absolute move is ignored by the raw accumulator, so the look point does not move. In game-inspect the presses are discarded. -**Ballistics** (`src/mouse_ballistics.*`, pure). The frozen cursor makes the normal pan oracle read -zero, so the look point pans from raw mickeys run through Windows' pointer ballistics per -`WM_INPUT` packet: the pointer-speed multiplier plus, with "Enhance pointer precision", the -SmoothMouse curve, normalized so slow movement is 1:1 with the slider. The curve is blended at -`accelStrength` (default 0.3) because `WM_INPUT` can coalesce HID reports and over-accelerate. -`cookPacket` runs only while `inspectActive`; the tick drains with a sub-pixel carry. +**Ballistics.** The frozen cursor makes the normal pan oracle read zero, so the look point pans from +raw mickeys scaled by the measured desktop gain (`GainLearner::gainFor`, the same replay the locked +path uses; the old modelled `mouse_ballistics` code is gone). The tick keeps a sub-pixel carry. diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index e78d39ef..8ce6caee 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -30,7 +30,7 @@ headlessly, so the unit tests are the only verification loop that runs everywher - The `test` target compiles `tests\*.cpp` with `/DWIND_TESTS` against only the pure sources. The authoritative list is the `:test` target in `build.bat`; today it is `transform`, `zoom_controller`, `config`, `profiles`, `cursor_mapper`, `lock_detector`, `cursor_lock`, - `mouse_ballistics`, `crosshair`, `config_ui/ini_edit`, `logging`, `tray_items` and `hitch_record`. + `crosshair`, `config_ui/ini_edit`, `logging`, `tray_items` and `hitch_record`. - Header-only pure modules (`engine_pick.h`, `drag_follow.h`, `hdr_scale.h`, `keybind_rules.h`, `config_ui/wind_watchdog.h` and others) ride in through their test files. - One test file per module (`tests/test_.cpp`). A new pure `.cpp` also goes into the diff --git a/src/caret_rect.h b/src/caret_rect.h index a1243edb..065d7387 100644 --- a/src/caret_rect.h +++ b/src/caret_rect.h @@ -116,8 +116,16 @@ inline bool HoldMidScrollCaret(int left, int& top, int& bottom, int lineH, Caret struct CaretGhostState { bool have = false; int left = 0, top = 0, h = 0; // the last caret followed + // The report currently being suppressed as a ghost, and since when. A real ghost is transient + // (the next keystroke replaces it); one that just sits there is the caret, so the suppression + // must not be sticky (review 2026-10-09 #56). + bool holding = false; + int hLeft = 0, hTop = 0, hBottom = 0; + long long since = 0; }; +inline constexpr long long kGhostConfirmMs = 300; // a suppressed report this stable is a real caret + inline constexpr double kGhostJumpLines = 3.0; // further right than this many line heights in one report inline constexpr int kGhostBoxPx = 2; // the box changed by at least this much (top or height) @@ -133,6 +141,27 @@ inline bool IsLineEndGhost(int left, int top, int bottom, const CaretGhostState& // Remember a caret that was followed (never a ghost, so a held ghost stays a ghost). inline void NoteFollowedCaret(int left, int top, int bottom, CaretGhostState& s) { s.have = true; s.left = left; s.top = top; s.h = bottom - top; + s.holding = false; +} + +// A report was just suppressed as a ghost: start (or keep) timing it. +inline void NoteGhostHeld(int left, int top, int bottom, long long nowMs, CaretGhostState& s) { + if (s.holding && s.hLeft == left && s.hTop == top && s.hBottom == bottom) return; + s.holding = true; s.hLeft = left; s.hTop = top; s.hBottom = bottom; s.since = nowMs; +} + +// The same rect that was suppressed has now persisted long enough to be the real caret: follow it. +inline bool GhostHoldExpired(int left, int top, int bottom, long long nowMs, const CaretGhostState& s) { + return s.holding && s.hLeft == left && s.hTop == top && s.hBottom == bottom && + nowMs - s.since >= kGhostConfirmMs; +} + +// A UIA text range reports one rectangle per line (4 doubles each). A selection grown with +// Shift+arrow keeps its FIRST rectangle (the start) fixed, so following it never sees the growth +// (review 2026-10-09 #64): use the LAST rectangle, where the selection's moving end is. Returns the +// double offset of the rectangle to use for n array elements (0 for a single rect or fewer than 4). +inline int LastRectOffset(int n) { + return n >= 8 ? (n / 4 - 1) * 4 : 0; } } // namespace wind diff --git a/src/config.h b/src/config.h index a2886a64..39636c03 100644 --- a/src/config.h +++ b/src/config.h @@ -276,8 +276,8 @@ struct Config { // speed, LEARNED from the OS itself: free-cursor ticks record the raw-in -> cursor-out ratio // per speed (src/gain_learner.h) and locked sessions replay it - slider, acceleration curve, // polling rate and every undocumented constant included, nothing to tune. Modelling this - // pipeline (per-packet Windows-ballistics cooking, src/mouse_ballistics, which Inspect still - // uses) was tried twice and missed both ways, because WM_INPUT coalescing wrecks any + // pipeline (per-packet Windows-ballistics cooking, since removed; Inspect now replays the + // learned gain too) was tried twice and missed both ways, because WM_INPUT coalescing wrecks any // per-packet speed estimate. 0 = raw mickeys x cursorSensitivity (the historical behavior, // measurably slower than the desktop cursor). int lockedBallistics = 1; diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 9de5ac74..355433ca 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -82,6 +83,7 @@ class FocusLookup { IUIAutomationElement* result = nullptr; ULONGLONG resultAt = 0; // when the worker produced result HWND resultFg = nullptr; // the foreground window it was asked for + unsigned resultGen = 0; // the tracker's focus generation it was asked under }; public: explicit FocusLookup(IUIAutomation* uia) : st_(std::make_shared()) { @@ -97,7 +99,7 @@ class FocusLookup { } // The focused element (AddRef'd, caller releases), or null. timedOut: not answered in time, or // the previous lookup is still stuck. - IUIAutomationElement* get(unsigned deadlineMs, bool& timedOut) { + IUIAutomationElement* get(unsigned deadlineMs, bool& timedOut, unsigned gen) { timedOut = false; State& s = *st_; const HWND fg = GetForegroundWindow(); @@ -108,11 +110,14 @@ class FocusLookup { // A late answer (it missed the last deadline): still good if fresh and for this window. // Slow-but-alive apps (Edge answers in ~200 ms) then cost one skipped round, not every round. IUIAutomationElement* late = s.result; s.result = nullptr; - if (s.resultFg == fg && GetTickCount64() - s.resultAt < 250) return late; + // A focus event since the lookup started (a newer generation) makes it the previous + // control's answer, however fresh. + if (s.resultFg == fg && s.resultGen == gen && GetTickCount64() - s.resultAt < 250) return late; late->Release(); } s.busy = true; s.resultFg = fg; + s.resultGen = gen; const unsigned job = ++s.want; s.cv.notify_all(); if (!s.cv.wait_for(lk, std::chrono::milliseconds(deadlineMs), [&] { return s.done == job; })) { @@ -167,7 +172,8 @@ static bool Win32Caret(RECT& out) { if (rc.right <= rc.left && rc.bottom <= rc.top) return false; POINT a{ rc.left, rc.top }, b{ rc.right, rc.bottom }; if (!ClientToScreen(gi.hwndCaret, &a) || !ClientToScreen(gi.hwndCaret, &b)) return false; - out = { a.x, a.y, b.x, b.y }; + // A mirrored (RTL) window flips x in ClientToScreen: normalise so left <= right. + out = { (std::min)(a.x, b.x), (std::min)(a.y, b.y), (std::max)(a.x, b.x), (std::max)(a.y, b.y) }; return true; } @@ -246,8 +252,10 @@ static bool RangeRect(IUIAutomationTextRange* range, RECT& out) { double* d = nullptr; LONG n = sa->rgsabound[0].cElements; if (n >= 4 && SUCCEEDED(SafeArrayAccessData(sa, (void**)&d))) { - out = { (LONG)d[0], (LONG)d[1], (LONG)(d[0] + (d[2] > 1 ? d[2] : 1)), (LONG)(d[1] + d[3]) }; - ok = d[3] > 0; + // The LAST line's rect, so a selection grown with Shift+arrow moves what is followed. + const double* r = d + wind::LastRectOffset((int)n); + out = { (LONG)r[0], (LONG)r[1], (LONG)(r[0] + (r[2] > 1 ? r[2] : 1)), (LONG)(r[1] + r[3]) }; + ok = r[3] > 0; SafeArrayUnaccessData(sa); } else if (n == 0) { // An empty caret range has no rectangle: widen it by one character, then use its left edge. @@ -264,10 +272,14 @@ static bool RangeRect(IUIAutomationTextRange* range, RECT& out) { bool FocusTracker::start() { if (th_.joinable()) return true; g_self = this; + stopRequested_.store(false); th_ = std::thread([this] { run(); }); return true; } void FocusTracker::stop() { + // The flag covers a stop() that lands before run() has published its queue (no thread id yet, so + // no WM_QUIT could be posted and join() would wait forever): run() checks it right after. + stopRequested_.store(true); const unsigned long t = tid_.load(); if (t) PostThreadMessageW(t, WM_QUIT, 0, 0); if (th_.joinable()) th_.join(); @@ -291,8 +303,11 @@ void FocusTracker::publish(TrackKind k, double l, double t, double r, double b, } void FocusTracker::run() { - tid_ = GetCurrentThreadId(); + // Create the queue BEFORE publishing the thread id: a WM_QUIT posted to a thread without a + // queue is lost, and stop() would then join forever. MSG m; PeekMessageW(&m, nullptr, WM_USER, WM_USER, PM_NOREMOVE); // make the queue exist + tid_ = GetCurrentThreadId(); + if (stopRequested_.load()) { tid_ = 0; return; } CoInitializeEx(nullptr, COINIT_MULTITHREADED); IUIAutomation* uia = nullptr; CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, __uuidof(IUIAutomation), (void**)&uia); @@ -407,8 +422,10 @@ void FocusTracker::run() { unsigned gen = ~0u; HWND fg = nullptr; bool is = false; bool have = false; RECT msaa{}; ULONGLONG at = 0; } edc; - auto editContextCaret = [&](IUIAutomationElement* el, RECT& rc, const char*& src, bool fromPoll) { - if (!el) return; + // False only when this IS an EditContext element and MSAA gave no caret: the UIA rect is then + // the start of the line, which is wrong, so the caller reports not-found instead of it. + auto editContextCaret = [&](IUIAutomationElement* el, RECT& rc, const char*& src, bool fromPoll) -> bool { + if (!el) return true; // Keyed on the window too: a resolve can run before the next app's focus event arrives, // and a stale "is VS Code" must never send MSAA queries into another app (field: Notepad // tracking went silent for 2.5 s right after leaving VS Code). @@ -425,7 +442,7 @@ void FocusTracker::run() { SysFreeString(cls); } } - if (!edc.is) return; + if (!edc.is) return true; const ULONGLONG now = GetTickCount64(); if (!(fromPoll && edc.have && now - edc.at < 100)) { RECT m{}; @@ -433,12 +450,13 @@ void FocusTracker::run() { if (edc.have) edc.msaa = m; edc.at = now; } - if (!edc.have) return; + if (!edc.have) return false; const LONG lineH = rc.bottom - rc.top; const LONG dy = edc.msaa.top - rc.top; - if ((dy < 0 ? -dy : dy) > (lineH > 16 ? lineH / 2 : 8)) return; // another line: stale, keep UIA + if ((dy < 0 ? -dy : dy) > (lineH > 16 ? lineH / 2 : 8)) return true; // another line: stale, keep UIA rc = edc.msaa; src = "msaa-editcontext"; + return true; }; auto findCaret = [&](IUIAutomationElement* el, RECT& rc, const char*& src, bool fromPoll) -> bool { if (Win32Caret(rc)) { src = "win32"; return true; } @@ -457,7 +475,7 @@ void FocusTracker::run() { } tp2->Release(); } - if (ok) { editContextCaret(el, rc, src, fromPoll); return true; } + if (ok) return editContextCaret(el, rc, src, fromPoll); IUIAutomationTextPattern* tp = nullptr; if (SUCCEEDED(el->GetCurrentPatternAs(UIA_TextPatternId, __uuidof(IUIAutomationTextPattern), (void**)&tp)) && tp) { IUIAutomationTextRangeArray* sel = nullptr; @@ -476,7 +494,7 @@ void FocusTracker::run() { } tp->Release(); } - if (ok) editContextCaret(el, rc, src, fromPoll); + if (ok) ok = editContextCaret(el, rc, src, fromPoll); return ok; }; @@ -527,7 +545,7 @@ void FocusTracker::run() { if (win32Only) GetWindowRect(cgi.hwndCaret, &b); else if (focusLookup) { bool timedOut = false; - el = focusLookup->get(150, timedOut); + el = focusLookup->get(150, timedOut, focusGen); if (timedOut && !lookupStuckLogged && log_.load()) wind::Log(wind::LogLevel::Info, "track", "focused element lookup not answered in 150 ms: resolving without UIA"); lookupStuckLogged = timedOut; @@ -608,10 +626,18 @@ void FocusTracker::run() { // A line-end ghost (#387): the view stays on the text until the next real caret. if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret skipped (line-end ghost) via %s: %ld,%ld %ldx%ld", src, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top); + wind::NoteGhostHeld((int)rc.left, (int)rc.top, (int)rc.bottom, (long long)GetTickCount64(), caretGhost); } else { wind::NoteFollowedCaret((int)rc.left, (int)rc.top, (int)rc.bottom, caretGhost); publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); } + } else if (caretGhost.holding && + wind::GhostHoldExpired((int)rc.left, (int)rc.top, (int)rc.bottom, (long long)GetTickCount64(), caretGhost)) { + // The suppressed report never went away: a real ghost is replaced by the next key, + // so this is the caret, and holding it forever would strand the view. + if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "line-end ghost confirmed as the caret via %s: %ld,%ld", src, rc.left, rc.top); + wind::NoteFollowedCaret((int)rc.left, (int)rc.top, (int)rc.bottom, caretGhost); + publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); } } } diff --git a/src/focus_track.h b/src/focus_track.h index f83c7912..313b0b73 100644 --- a/src/focus_track.h +++ b/src/focus_track.h @@ -20,6 +20,7 @@ class FocusTracker { friend class FocusHandler; std::thread th_; std::atomic tid_{0}; + std::atomic stopRequested_{false}; std::atomic active_{false}, wantCaret_{false}, wantFocus_{false}, log_{false}; mutable std::mutex mu_; TrackSnapshot snap_; diff --git a/src/input_router.cpp b/src/input_router.cpp index e465a26b..1b8293c3 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -286,7 +286,8 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { g_maskedThisHold.store(false, std::memory_order_relaxed); // Any key activity, down OR up: tracking's keyboard gate (#289). Ups count so a focus change // committed by a release (Alt+Tab held for a while) is still keyboard-driven (review). - if (down || up) g_router->noteAnyKeyDown(GetTickCount64()); + // Wind's own mask keystroke (VK 0xE8) is not a user key: it must not stamp the clock. + if ((down || up) && vk != kMaskVk) g_router->noteAnyKeyDown(GetTickCount64()); // Typing (#328): only a fresh non-modifier down ends the click quiet period. if ((down || up) && g_typingKeys.note(vk, down)) g_router->noteTypingKeyDown(GetTickCount64()); // Only bound (non-forbidden) keys are tracked/swallowed; every other keystroke passes through @@ -310,6 +311,8 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { // Alt or Win held: mask it so its release is not a lone tap (Start / menu bar). if (NeedsMaskKey(held)) InjectMaskKey(); swallow = true; + } else { + g_kbSwallowedDown[vk].store(false); // a stale record must not eat this press's UP } } else { swallow = g_kbSwallowedDown[vk].load(); @@ -359,6 +362,9 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { return 1; } } + // Not swallowed: drop any record left by a DOWN whose UP never arrived, or this + // click's UP would be eaten on its strength. + g_swallowedDown[cb].store(false); } else if (cb && cbUp) { if (g_btnDir[cb].exchange(0, std::memory_order_relaxed) != 0) PublishButtonHeld(g_router->state()); // Balanced: swallow an up iff we swallowed its down, even if the modifiers were @@ -399,6 +405,7 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { pending.fetch_add(1, std::memory_order_relaxed); return 1; // eat the real DOWN; the tick fires the click at the look point } + if (cDown) g_commitDown[cDown].store(false, std::memory_order_relaxed); // stale record from a lost UP // Swallow an UP iff THIS button's DOWN was swallowed (per-button, so a chord never strands one). if (cUp && g_commitDown[cUp].exchange(false, std::memory_order_relaxed)) return 1; } @@ -487,6 +494,9 @@ static DWORD WINAPI HookThreadProc(LPVOID) { // rather than let a stale record eat an unrelated UP later (stuck key). No synthetic UP // is needed: we only ever swallow our OWN binds, so no other app saw the DOWN. for (int vk = 0; vk < 256; ++vk) { g_kbSwallowedDown[vk].store(false); g_kbPressed[vk].store(false); } + // The once-per-hold mask flag is stale across the gap too: the Alt/Win UP that clears it + // may have been missed, which would suppress every later mask keystroke. + g_maskedThisHold.store(false, std::memory_order_relaxed); if (want) g_kbHook = SetWindowsHookExW(WH_KEYBOARD_LL, KbProc, hmod, 0); if (g_router) g_router->onKbHookStateChanged(want && g_kbHook != nullptr); continue; @@ -630,22 +640,8 @@ void InputRouter::drainRaw(int& dx, int& dy) { dy = state_.rawDy.exchange(0); } -// Per-packet ballistic cooking for the Inspect-mode pan. Only needed while Inspect is on (the OS -// cursor is frozen then, so raw mickeys drive the look point); skipped otherwise so it costs nothing -// in the normal path. Windows accelerates per packet on each packet's magnitude, so cook here (one -// WM_INPUT = one packet) and accumulate the sub-pixel result; the tick drains it via drainCooked. -void InputRouter::cookPacket(int dx, int dy) { - if (!state_.inspectActive.load(std::memory_order_relaxed) && - !state_.cookActive.load(std::memory_order_relaxed)) return; - double cx, cy; - CookMickeyPacket(ballistics_, dx, dy, cx, cy); - cookedX_ += cx; - cookedY_ += cy; -} - void AccumulateRaw(InputRouter& r, int dx, int dy) { r.state().rawDx.fetch_add(dx); r.state().rawDy.fetch_add(dy); - r.cookPacket(dx, dy); // Inspect-mode speed match (no-op unless Inspect is active) } } diff --git a/src/input_router.h b/src/input_router.h index 4acdbff6..97380a93 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -1,7 +1,6 @@ #pragma once #include #include -#include "mouse_ballistics.h" // BallisticsConfig (pure; Inspect-mode speed match) namespace wind { // Holds input state shared between the hook/raw-input callbacks and the tick thread. struct InputState { @@ -21,7 +20,6 @@ struct InputState { // and hands the tick PER-BUTTON pending counts; the tick fires a clean click at the look point per // pending press (counts, not a single flag, so a fast second click before the tick drains isn't lost). std::atomic inspectActive{false}; // tick -> hook: Inspect on, swallow clicks - std::atomic cookActive{false}; // tick -> router: cook raw input (shell-panel freeze, #283) std::atomic commitLeft{0}; // hook -> tick: pending left clicks to fire at the look point std::atomic commitRight{0}; // hook -> tick: pending right clicks // --- Diagnostics for the intermittent stuck-side-button (issue #113). Each side-button @@ -46,15 +44,6 @@ class InputRouter { InputState& state() { return state_; } // Atomically read and zero the accumulated raw deltas. void drainRaw(int& dx, int& dy); - // Inspect-mode speed match: the OS cursor is frozen, so the look point pans from raw mickeys. - // Raw mickeys are pre-acceleration / pre-pointer-speed, so they feel slower than the desktop - // cursor. setBallistics() supplies the current Windows pointer-speed + acceleration settings; - // cookPacket() (called per WM_INPUT packet) converts that packet to cooked pixels and sums them - // while Inspect is active; drainCooked() reads and zeroes the accumulated cooked delta. All of - // this runs on the main thread (WM_INPUT, the tick, and the setters are all main-thread). - void setBallistics(const BallisticsConfig& c) { ballistics_ = c; } - void cookPacket(int dx, int dy); - void drainCooked(double& dx, double& dy) { dx = cookedX_; dy = cookedY_; cookedX_ = 0.0; cookedY_ = 0.0; } // Map an XBUTTON id (1 = XBUTTON1, 2 = XBUTTON2) to the in/out held state, using the // configured zoom buttons. Shared by the WH_MOUSE_LL hook and main's WM_INPUT path. void setButtonState(int xbuttonId, bool down); @@ -212,7 +201,12 @@ class InputRouter { // period (#328): releasing Ctrl/Shift after a Ctrl/Shift+click must not count as typing (review #349). unsigned long long lastTypingKeyDownMs() const { return kbLastTypingDownMs_.load(std::memory_order_relaxed); } void noteTypingKeyDown(unsigned long long ms) { kbLastTypingDownMs_.store(ms, std::memory_order_relaxed); } + // A left/right/middle button edge (down or up) from Raw Input, GetTickCount64 ms. Feeds the click quiet + // period without depending on the tick sampling GetAsyncKeyState while the button is held. + unsigned long long lastButtonEdgeMs() const { return btnLastEdgeMs_.load(std::memory_order_relaxed); } + void noteButtonEdge(unsigned long long ms) { btnLastEdgeMs_.store(ms, std::memory_order_relaxed); } private: + std::atomic btnLastEdgeMs_{0}; std::atomic kbLastAnyDownMs_{0}; std::atomic kbLastTypingDownMs_{0}; std::atomic btnLastHookDownMs_[6]{}; // button ids 1..5 @@ -220,10 +214,6 @@ class InputRouter { std::atomic kbHookReinstalls_{0}; // watchdog recoveries this session std::atomic kbHookWanted_{true}; // false while a fullscreen game is foreground std::atomic kbHookRecovering_{false}; // distinguishes watchdog recovery from a resume - // Inspect-mode cooked-pixel accumulator (main-thread only: WM_INPUT cooks, the tick drains). - BallisticsConfig ballistics_{}; - double cookedX_ = 0.0; - double cookedY_ = 0.0; }; // Called from main.cpp's WM_INPUT handler with decoded relative mouse deltas. diff --git a/src/java_bridge.cpp b/src/java_bridge.cpp index 11e5d465..02ff170f 100644 --- a/src/java_bridge.cpp +++ b/src/java_bridge.cpp @@ -149,9 +149,21 @@ static HMODULE LoadVerified(const std::wstring& path, bool log) { if (ok && GetFileAttributesW(d.c_str()) != INVALID_FILE_ATTRIBUTES) ok = hold(d) && SignedFile(d); } + // Any validly signed DLL passes the check above, and loading runs its DllMain. So look for the + // bridge's entry points in a copy mapped WITHOUT running DllMain or resolving imports first; only + // a DLL that exports them is loaded for real (review 2026-10-09 #58). + if (ok) { + HMODULE probe = LoadLibraryExW(path.c_str(), nullptr, DONT_RESOLVE_DLL_REFERENCES); + const bool exports = probe && GetProcAddress(probe, "Windows_run") && GetProcAddress(probe, "isJavaWindow"); + if (probe) FreeLibrary(probe); + if (!exports) { + ok = false; + wind::Log(wind::LogLevel::Warn, "track", "java: %ls does not export the Access Bridge entry points; skipped", path.c_str()); + } + } HMODULE m = nullptr; if (ok) m = LoadLibraryExW(path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); - else wind::Log(wind::LogLevel::Warn, "track", "java: refused unsigned bridge DLL (or dependency) at %ls", path.c_str()); + else wind::Log(wind::LogLevel::Warn, "track", "java: refused bridge DLL (unsigned or not a bridge) at %ls", path.c_str()); release(); (void)log; return m; diff --git a/src/main.cpp b/src/main.cpp index 95451e21..1b24e09f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -350,6 +350,8 @@ struct TickState { double inspectPanRemY = 0.0; double lockedPanRemX = 0.0; // sub-pixel carry for the locked pan double lockedPanRemY = 0.0; + bool renderStale = false; // the render overlay refused a retarget (multi-GPU) and still sits + // on an old monitor: sessions use the transform engine GainLearner gainLearner; // free ticks teach it the real in->out ratio; // locked ticks replay it (gain_learner.h) bool inspectGame = false; // game-inspect (issue #144): foreground stolen from a mouselook @@ -758,6 +760,9 @@ static void EndGameInspect(TickState& t) { SetForegroundWindow(t.inspectPrevFg); } t.inspectPrevFg = nullptr; + // The helper stays a topmost 1x1 at the monitor origin otherwise (invisible, but still a + // window sitting over the game). EnsureFocusStealer re-shows it on the next steal. + if (g_focusStealer && IsWindow(g_focusStealer)) ShowWindow(g_focusStealer, SW_HIDE); wind::Log(wind::LogLevel::Info, "inspect", "game-inspect ended (foreground returned)"); } @@ -775,20 +780,6 @@ static void RegisterHideCursorHotkey(HWND hwnd, int vk, int mods); // Same pattern for the quick-zoom hotkey (hotkey mode). Pass vk=0 to unregister. static void RegisterQuickZoomHotkey(HWND hwnd, int vk, int mods); -// Read the current Windows pointer-speed + acceleration settings into a BallisticsConfig so Inspect -// mode pans the look point at the same speed as the desktop cursor. Refreshed on each Inspect entry -// (these settings change rarely). SystemParametersInfo only: the SmoothMouse curve shape is the -// standard hardcoded default (rarely customized) and is normalized to the slider baseline in -// mouse_ballistics, so its absolute scale does not matter. -static BallisticsConfig ReadMouseBallistics() { - BallisticsConfig c; // xCurve/yCurve keep the standard Win10 "Enhance pointer precision" defaults - int speed = 10; - if (SystemParametersInfo(SPI_GETMOUSESPEED, 0, &speed, 0)) c.sliderMult = PointerSpeedMultiplier(speed); - int mp[3] = { 0, 0, 0 }; - if (SystemParametersInfo(SPI_GETMOUSE, 0, mp, 0)) c.accelEnabled = (mp[2] != 0); - return c; -} - // One magnifier tick: advance zoom, hot-reload config, then pan/draw via the render engine. // Pure of any pacing wait - the caller paces. Safe to call from the main loop or from a // WM_TIMER during a modal loop. @@ -1473,16 +1464,23 @@ static void RunTick(TickState& t) { // render model was retargeted, leaving the transform clamping against the old size. // A game that switches to a lower resolution therefore let the view pan off the real // desktop. Both engines are retargeted now, and the geometry is tracked either way. - if (zoomed) { + // Inspect at 1x also needs the session's monitor (the look point maps onto it). + if (zoomed || inspect) { MonitorTarget nt = t.cfg.multiMonitor ? MonitorUnderCursor() : PrimaryMonitor(); IMagnifierModel* rt = t.mRender ? t.mRender : t.model; bool ok = SameMonitor(nt, t.mon) ? false : rt->retarget(nt); + const bool renderOk = ok; + const bool hadTransformHalf = t.mTransform && t.mTransform != rt; // The transform half owns its own bounds and must follow even when the render // half refuses (retarget returns false across adapters, where the overlay cannot // move but the transform is still valid on the new geometry). if (!SameMonitor(nt, t.mon) && t.mTransform && t.mTransform != rt) { if (t.mTransform->retarget(nt)) ok = true; } + // Hybrid with a render half that refused while the transform half took it: the + // overlay is left on the old monitor, so this session must not pick render. + if (ok && hadTransformHalf && !renderOk) t.renderStale = true; + else if (ok) t.renderStale = false; if (ok) { t.mon = nt; int nhz = DetectRefreshHz(nt.device); // pace off the new monitor's refresh (#74) @@ -1517,7 +1515,7 @@ static void RunTick(TickState& t) { pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); pin.renderLost = RenderDeviceLost(t); FillCategoryInputs(t, pin); - IMagnifierModel* pick = ShouldPickTransform(pin) ? t.mTransform : t.mRender; + IMagnifierModel* pick = (ShouldPickTransform(pin) || t.renderStale) ? t.mTransform : t.mRender; if (pick && pick != t.model) t.model = pick; } t.vbounds = QueryVirtualBounds(); // refresh cached clip-detect bounds (topology may have changed) @@ -1586,10 +1584,9 @@ static void RunTick(TickState& t) { preClip.right, preClip.bottom); t.frozenCursor = pt; t.clickReleaseTicks = 0; // start frozen (clear any stale click-release window) - // Match the desktop cursor speed: snapshot the OS pointer-speed/accel and baseline the - // cooked accumulator + sub-pixel carry so the first tick after entry pans by zero. - g_input.setBallistics(ReadMouseBallistics()); - double cbx, cby; g_input.drainCooked(cbx, cby); (void)cbx; (void)cby; + // Zero the sub-pixel carry and this tick's raw motion so the first tick after entry + // pans by zero. + rawDx = 0; rawDy = 0; t.inspectPanRemX = 0.0; t.inspectPanRemY = 0.0; t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; @@ -1657,14 +1654,15 @@ static void RunTick(TickState& t) { int dx, dy; bool dragFollow = false; // set in the free render branch below; drives ex.suppressCursorSync if (inspect) { - // The OS cursor is frozen, so pan the look point from the COOKED mickeys - Windows' - // pointer-speed + acceleration applied per packet (see mouse_ballistics) - not raw - // counts, so the look point moves at the same speed/DPI as the desktop cursor. - // cursorSensitivity stays a user multiplier on top; carry the sub-pixel remainder so - // slow precise motion is not quantized away. - double cdx, cdy; g_input.drainCooked(cdx, cdy); - t.inspectPanRemX += cdx * t.cfg.cursorSensitivity; - t.inspectPanRemY += cdy * t.cfg.cursorSensitivity; + // The OS cursor is frozen, so pan the look point from raw mickeys scaled by the + // MEASURED desktop gain (GainLearner, the same replay as the locked path) so the look + // point moves at the same speed/DPI as the desktop cursor. cursorSensitivity stays a + // user multiplier on top; carry the sub-pixel remainder so slow precise motion is not + // quantized away. + const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); + const double g = t.gainLearner.gainFor(inC, dt * 1000.0); + t.inspectPanRemX += rawDx * g * t.cfg.cursorSensitivity; + t.inspectPanRemY += rawDy * g * t.cfg.cursorSensitivity; dx = (int)t.inspectPanRemX; t.inspectPanRemX -= dx; // truncate toward zero, carry the rest dy = (int)t.inspectPanRemY; t.inspectPanRemY -= dy; } else { @@ -1759,7 +1757,10 @@ static void RunTick(TickState& t) { // rawDx/rawDy went in, curDx/curDy came out - so teach the learner the REAL // ballistics at this speed. Gated on no confining clip: a clamped cursor // under-reports output and would teach a too-low gain. - if (!clipConfined && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts) { + // Also skip a pointer pinned against the clip bound: it cannot move, so the output + // under-reports exactly like a confined clip (modest downward bias otherwise). + if (!clipConfined && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts && + !wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) { const double dtMs_ = dt * 1000.0; const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); const double outC = std::sqrt((double)curDx * curDx + (double)curDy * curDy); @@ -1919,6 +1920,7 @@ static void RunTick(TickState& t) { (GetAsyncKeyState(VK_MBUTTON) & 0x8000); const unsigned long long nowMs = GetTickCount64(); if (vi.buttonDown) t.lastButtonMs = nowMs; + { const unsigned long long edge = g_input.lastButtonEdgeMs(); if (edge > t.lastButtonMs) t.lastButtonMs = edge; } vi.msSinceButton = t.lastButtonMs ? double(nowMs - t.lastButtonMs) : 1e9; // Keyboard-driven only (#289). The hook and Raw Input both stamp the clock, so a suspended // hook no longer switches the gate off. No stamp at all and no hook: no information, no gate. @@ -2145,7 +2147,7 @@ static void RunTick(TickState& t) { pin.inputTransformOk = tAvail && tAvail->inputTransformAvailable(); pin.renderLost = RenderDeviceLost(t); FillCategoryInputs(t, pin); - IMagnifierModel* want = ShouldPickTransform(pin) ? t.mTransform : t.mRender; + IMagnifierModel* want = (ShouldPickTransform(pin) || t.renderStale) ? t.mTransform : t.mRender; // STICKY (field: the engine flapped render<->transform inside one zoom session, and // each flip releases and rebuilds DWM's magnification context - a stall every time). // A real alt-tab still switches; a one-frame wobble in the foreground reads does not. @@ -2767,7 +2769,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { // Key activity (down and up) feeds only tracking's key clock (#289), never held state. Raw Input // keeps arriving while the hook is suspended (fullscreen game, noSwallowApps), so // the clock stays true there instead of the gate switching off (review #289). - if (kb.VKey > 0 && kb.VKey < 256) + if (kb.VKey > 0 && kb.VKey < 256 && kb.VKey != wind::kMaskVk) // Wind's own mask key is not a user key g_input.noteAnyKeyDown(GetTickCount64()); // downs and ups, like the hook // Typing stamp (#328), like the hook: fresh non-modifier downs only. static wind::TypingKeyFilter rawTyping; @@ -2793,6 +2795,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { // The UP's event time is the WM_INPUT's queue time, not now: the hook's reordering // guard compares event times (src/event_order.h), so a main-thread stall is harmless. const uint32_t rawTime = static_cast(GetMessageTime()); + // Click quiet period: stamp the edge here instead of relying on the tick to catch the + // button down in GetAsyncKeyState (a click shorter than a tick was missed). + if (bf & (RI_MOUSE_LEFT_BUTTON_DOWN | RI_MOUSE_LEFT_BUTTON_UP | RI_MOUSE_RIGHT_BUTTON_DOWN | + RI_MOUSE_RIGHT_BUTTON_UP | RI_MOUSE_MIDDLE_BUTTON_DOWN | RI_MOUSE_MIDDLE_BUTTON_UP)) + g_input.noteButtonEdge(GetTickCount64()); if (bf & RI_MOUSE_BUTTON_4_UP) g_input.rawButtonUp(1, rawTime); if (bf & RI_MOUSE_BUTTON_5_UP) g_input.rawButtonUp(2, rawTime); // Same net for left/right/middle click binds (#285); a no-op unless one holds a zoom. @@ -3104,10 +3111,6 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { model2.reset(); } - // Inspect's cooked-pan ballistics from the real system settings at startup (previously read - // only on Inspect entry). The LOCKED path no longer models ballistics at all - it replays the - // learned desktop gain instead (gain_learner.h). - g_input.setBallistics(ReadMouseBallistics()); // The tray icon and menu live in WindTray.exe (issue #291): a UIAccess process's menu stacks // above the cursor and the Snipping Tool overlay, an ordinary process's menu does not. g_trayBlock = wind::TrayHost::Start(exePath); diff --git a/src/mouse_ballistics.cpp b/src/mouse_ballistics.cpp deleted file mode 100644 index eb4a9820..00000000 --- a/src/mouse_ballistics.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "mouse_ballistics.h" -#include - -namespace wind { - -double PointerSpeedMultiplier(int s) { - // The well-known Windows pointer-speed slider table: positions 1..20, default 10 -> 1.0x. - static const double tbl[20] = { - 0.03125, 0.0625, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, - 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, - }; - if (s < 1) s = 1; - if (s > 20) s = 20; - return tbl[s - 1]; -} - -// Piecewise-linear interpolation of yCurve over xCurve at xin. Below the first point we use the -// first segment's slope (xCurve[0] is 0); above the last point we extrapolate with the last -// segment's slope (Windows likewise extends the final segment for very fast flicks). -static double interpCurve(const double* xc, const double* yc, double xin) { - for (int i = 0; i < 4; ++i) { - if (xin <= xc[i + 1]) { - double dx = xc[i + 1] - xc[i]; - if (dx <= 0.0) return yc[i]; - return yc[i] + (yc[i + 1] - yc[i]) * (xin - xc[i]) / dx; - } - } - double dx = xc[4] - xc[3]; - if (dx <= 0.0) return yc[4]; - return yc[4] + (yc[4] - yc[3]) * (xin - xc[4]) / dx; -} - -void CookMickeyPacket(const BallisticsConfig& c, int dx, int dy, double& outX, double& outY) { - if (dx == 0 && dy == 0) { outX = 0.0; outY = 0.0; return; } - const double counts = std::sqrt((double)dx * dx + (double)dy * dy); - double gain; - if (!c.accelEnabled || c.xCurve[1] <= 0.0 || c.yCurve[1] <= 0.0) { - // No acceleration: a flat multiplier (1 mickey -> sliderMult px), exactly like the desktop - // cursor with "Enhance pointer precision" off. - gain = c.sliderMult; - } else { - // Normalize so the limit gain as counts->0 equals sliderMult: with the first segment slope - // m = yCurve[1]/xCurve[1], the small-speed gain is m*(sliderMult/inputDiv)*K, so choosing - // K = inputDiv*xCurve[1]/yCurve[1] makes that exactly sliderMult. The curve then only adds - // acceleration above the slow baseline, and the unknown absolute DPI/refresh scale cancels. - const double K = c.inputDiv * c.xCurve[1] / c.yCurve[1]; - const double xin = counts * c.sliderMult / c.inputDiv; - const double yout = interpCurve(c.xCurve, c.yCurve, xin); - const double curveGain = yout * K / counts; - // Blend the curve's acceleration over the flat slider baseline by accelStrength (the curve - // only ever adds gain above the baseline, so strength 0 collapses to the flat slider). - gain = c.sliderMult + (curveGain - c.sliderMult) * c.accelStrength; - } - outX = dx * gain; - outY = dy * gain; -} - -} diff --git a/src/mouse_ballistics.h b/src/mouse_ballistics.h deleted file mode 100644 index 6cdb6f57..00000000 --- a/src/mouse_ballistics.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once -// Pure logic (NO ): convert a raw mouse packet (HID mickeys) into the cooked pixel -// delta Windows' pointer pipeline would produce, so Inspect mode (which reads raw mickeys because -// the OS cursor is frozen and cannot be used as the speed oracle) pans at the SAME speed as the -// normal desktop cursor. Two effects are modelled: -// 1. Pointer-speed slider (Control Panel -> Mouse -> pointer speed, 1..20): an exact multiplier. -// 2. "Enhance pointer precision" acceleration: the piecewise-linear SmoothMouseX/YCurve, but -// NORMALIZED so the low-speed gain equals the slider multiplier (slow precise movement is -// 1:1 with the desktop) and the curve only adds acceleration above that. Normalizing this way -// cancels the absolute DPI/refresh scaling constants (whose exact values are undocumented), -// so the match is robust without depending on those magic numbers. -// Issue: inspect-mode speed/DPI match (the look point pans at the desktop cursor's speed). -namespace wind { - -struct BallisticsConfig { - bool accelEnabled = false; // "Enhance pointer precision" on (SPI_GETMOUSE third element) - double sliderMult = 1.0; // pointer-speed slider 1..20 mapped to a multiplier (see PointerSpeedMultiplier) - double inputDiv = 6.0; // mickeys -> curve-x divisor. Windows' nominal constant is ~3.5, but - // WM_INPUT can coalesce HID reports (inflating per-packet magnitude), - // which over-accelerated fast moves; a larger divisor softens the - // high-speed gain to match the desktop. The slow-speed baseline is - // unaffected (it normalizes to sliderMult regardless of inputDiv). - double accelStrength = 0.3; // 0..1 blend of the acceleration curve over the flat slider baseline. - // 0 = flat (1 mickey -> sliderMult px, no acceleration); 1 = the full - // normalized curve. Because WM_INPUT coalescing makes the full curve - // over-accelerate, we only apply a fraction. The slow baseline is the - // same at any strength (the curve only adds gain above the baseline). - // Standard Windows 10 SmoothMouseXCurve / SmoothMouseYCurve, already divided by 65536 (the - // registry stores them as 16.16 fixed point). Hardcoded defaults: customizing these curves is - // rare, and the normalization above only relies on the curve SHAPE, not its absolute scale. - double xCurve[5] = { 0.0, 0.43, 1.25, 3.86, 40.0 }; // input speed (mickeys) - double yCurve[5] = { 0.0, 1.37, 5.30, 24.30, 568.0 }; // output (pre-normalization) -}; - -// Exact Windows pointer-speed slider multiplier table for slider positions 1..20 (10 = 1.0x). -// Out-of-range inputs are clamped to [1, 20]. -double PointerSpeedMultiplier(int slider1to20); - -// Convert one raw mouse packet (dx, dy in mickeys) to a cooked pixel delta (outX, outY), applying -// the slider multiplier and, when enabled, the normalized acceleration curve. Per-PACKET, matching -// Windows (its acceleration keys off each packet's magnitude, not elapsed time), so callers must -// feed it one WM_INPUT packet at a time and accumulate the fractional results. -void CookMickeyPacket(const BallisticsConfig& c, int dx, int dy, double& outX, double& outY); - -} diff --git a/src/version.h b/src/version.h index e143dcb7..955ce687 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 24 -#define WIND_VER_PATCH 13 +#define WIND_VER_PATCH 14 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.24.13" +#define WIND_VERSION_STR "0.24.14" diff --git a/tests/test_caret_rect.cpp b/tests/test_caret_rect.cpp index 868e105d..6b1d29cf 100644 --- a/tests/test_caret_rect.cpp +++ b/tests/test_caret_rect.cpp @@ -174,3 +174,24 @@ TEST_CASE("caret ghost: End and a click far right keep the caret's box, so they wind::CaretGhostState none; CHECK_FALSE(wind::IsLineEndGhost(3375, 1942, 1996, none)); // nothing followed yet } + +TEST_CASE("caret ghost: a suppressed report that persists is confirmed as the caret (review #56)") { + wind::CaretGhostState s; + wind::NoteFollowedCaret(1000, 1944, 1993, s); + CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 5000, s)); // nothing held yet + wind::NoteGhostHeld(3375, 1942, 1996, 1000, s); + CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 1100, s)); // too fresh: still a ghost + wind::NoteGhostHeld(3375, 1942, 1996, 1200, s); // same rect again keeps the first stamp + CHECK(wind::GhostHoldExpired(3375, 1942, 1996, 1000 + wind::kGhostConfirmMs, s)); + CHECK_FALSE(wind::GhostHoldExpired(3376, 1942, 1996, 9000, s)); // a different report is not the held one + wind::NoteFollowedCaret(3375, 1942, 1996, s); // followed: the hold is cleared + CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 9000, s)); +} + +TEST_CASE("selection rect: the last line's rectangle is used so Shift+arrow growth is followed (review #64)") { + CHECK(wind::LastRectOffset(0) == 0); + CHECK(wind::LastRectOffset(4) == 0); // one line + CHECK(wind::LastRectOffset(8) == 4); // two lines: the second + CHECK(wind::LastRectOffset(12) == 8); + CHECK(wind::LastRectOffset(10) == 4); // a ragged array still lands on a whole rectangle +} diff --git a/tests/test_mouse_ballistics.cpp b/tests/test_mouse_ballistics.cpp deleted file mode 100644 index 41d6eb9e..00000000 --- a/tests/test_mouse_ballistics.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "doctest.h" -#include "../src/mouse_ballistics.h" -#include - -using wind::BallisticsConfig; -using wind::CookMickeyPacket; -using wind::PointerSpeedMultiplier; - -TEST_CASE("PointerSpeedMultiplier matches the Windows slider table") { - CHECK(PointerSpeedMultiplier(10) == doctest::Approx(1.0)); // default - CHECK(PointerSpeedMultiplier(1) == doctest::Approx(0.03125)); - CHECK(PointerSpeedMultiplier(20) == doctest::Approx(3.5)); - CHECK(PointerSpeedMultiplier(14) == doctest::Approx(2.0)); - // clamps out of range - CHECK(PointerSpeedMultiplier(0) == doctest::Approx(0.03125)); - CHECK(PointerSpeedMultiplier(999) == doctest::Approx(3.5)); -} - -TEST_CASE("No acceleration: cooked = raw * slider, exactly (1:1 at default)") { - BallisticsConfig c; // accelEnabled defaults to false - c.sliderMult = 1.0; - double x, y; - CookMickeyPacket(c, 7, -3, x, y); - CHECK(x == doctest::Approx(7.0)); - CHECK(y == doctest::Approx(-3.0)); - - c.sliderMult = 1.5; - CookMickeyPacket(c, 4, 0, x, y); - CHECK(x == doctest::Approx(6.0)); - CHECK(y == doctest::Approx(0.0)); -} - -TEST_CASE("Zero packet yields zero") { - BallisticsConfig c; c.accelEnabled = true; c.sliderMult = 1.0; - double x = 9, y = 9; - CookMickeyPacket(c, 0, 0, x, y); - CHECK(x == doctest::Approx(0.0)); - CHECK(y == doctest::Approx(0.0)); -} - -TEST_CASE("Acceleration: slow movement is ~1:1 with the slider baseline") { - BallisticsConfig c; c.accelEnabled = true; c.sliderMult = 1.0; - // A single-mickey packet sits at the bottom of the curve: gain normalized to ~slider (1.0). - double x, y; - CookMickeyPacket(c, 1, 0, x, y); - CHECK(x == doctest::Approx(1.0).epsilon(0.05)); // within 5% of 1:1 -} - -TEST_CASE("Acceleration: fast movement gains more than slow (monotonic ramp)") { - BallisticsConfig c; c.accelEnabled = true; c.sliderMult = 1.0; - auto gain = [&](int counts) { - double x, y; CookMickeyPacket(c, counts, 0, x, y); - return x / counts; - }; - double gSlow = gain(1), gMed = gain(10), gFast = gain(30); - CHECK(gSlow < gMed); - CHECK(gMed < gFast); - CHECK(gSlow == doctest::Approx(1.0).epsilon(0.05)); - CHECK(gFast > gSlow); // a fast flick is accelerated above the slow baseline -} - -TEST_CASE("Slider scales the whole result up") { - BallisticsConfig c; c.accelEnabled = true; - auto slowGain = [&](double mult) { - c.sliderMult = mult; - double x, y; CookMickeyPacket(c, 1, 0, x, y); - return x; // gain at 1 mickey ~= slider baseline - }; - CHECK(slowGain(2.0) > slowGain(1.0)); - CHECK(slowGain(2.0) == doctest::Approx(2.0).epsilon(0.10)); -} - -TEST_CASE("Diagonal magnitude drives the curve (not per-axis)") { - BallisticsConfig c; c.accelEnabled = true; c.sliderMult = 1.0; - // Direction is preserved: equal dx,dy -> equal cooked components. - double x, y; - CookMickeyPacket(c, 10, 10, x, y); - CHECK(x == doctest::Approx(y)); - CHECK(x > 10.0); // accelerated (magnitude ~14) -}