diff --git a/src/input_router.cpp b/src/input_router.cpp index 9ca8e5a..43c3464 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -234,6 +234,9 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { int vk = static_cast(ks->vkCode); bool down = (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN); bool up = (wParam == WM_KEYUP || wParam == WM_SYSKEYUP); + // 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()); // Only bound (non-forbidden) keys are tracked/swallowed; every other keystroke passes through // untouched. isBoundKey already range-checks vk and excludes IsForbiddenBindVk keys. if ((down || up) && g_router->isBoundKey(vk)) { diff --git a/src/input_router.h b/src/input_router.h index be1c3e6..5ab4549 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -179,6 +179,13 @@ class InputRouter { std::atomic kbZoomInMods_{0}, kbZoomInMods2_{0}, kbZoomOutMods_{0}, kbZoomOutMods2_{0}; // Recency stamps for the raw-UP reordering guards (see rawKeyUp/rawButtonUp). std::atomic kbLastHookDownMs_[256]{}; +public: + // Any key down, injected included (on-screen keyboards, voice typing), GetTickCount64 ms. 0 = none + // yet. Tracking uses it: only keyboard-driven caret/focus changes move the view (issue #289). + unsigned long long lastAnyKeyDownMs() const { return kbLastAnyDownMs_.load(std::memory_order_relaxed); } + void noteAnyKeyDown(unsigned long long ms) { kbLastAnyDownMs_.store(ms, std::memory_order_relaxed); } +private: + std::atomic kbLastAnyDownMs_{0}; std::atomic btnLastHookDownMs_[6]{}; // button ids 1..5 std::atomic kbHookActive_{false}; // true once the LL KEYBOARD hook is installed std::atomic kbHookReinstalls_{0}; // watchdog recoveries this session diff --git a/src/main.cpp b/src/main.cpp index 4b3e186..e2e8fbc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1585,6 +1585,10 @@ static void RunTick(TickState& t) { const unsigned long long nowMs = GetTickCount64(); if (vi.buttonDown) t.lastButtonMs = nowMs; 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. + const unsigned long long lastKey = g_input.lastAnyKeyDownMs(); + vi.msSinceKey = lastKey ? double(nowMs - lastKey) : (g_input.kbHookActive() ? 1e9 : 0.0); vi.dtMs = dt * 1000.0; vi.snap = g_track.snapshot(); const wind::ViewOwner was = t.viewOwner.owner; @@ -1598,7 +1602,9 @@ static void RunTick(TickState& t) { if (was == wind::ViewOwner::Mouse) { t.viewCx = r.centerX; t.viewCy = r.centerY; } // glide from where we are const double ptrX = cur.x - t.mon.x, ptrY = cur.y - t.mon.y; double tx = t.viewCx, ty = t.viewCy; - const wind::TrackRect rc{ vi.snap.l - t.mon.x, vi.snap.t - t.mon.y, vi.snap.r - t.mon.x, vi.snap.b - t.mon.y }; + // The LATCHED target: only caret/focus events that passed the gates move the view. + const wind::TrackSnapshot& tg = t.viewOwner.target; + const wind::TrackRect rc{ tg.l - t.mon.x, tg.t - t.mon.y, tg.r - t.mon.x, tg.b - t.mon.y }; double ox, oy; if (wind::TrackTargetCenter(rc, t.viewCx, t.viewCy, lvl, t.mon.w, t.mon.h, t.cfg.trackAlign, t.cfg.trackMarginPct, ox, oy)) { tx = ox; ty = oy; } @@ -2386,6 +2392,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { const RAWKEYBOARD& kb = ri->data.keyboard; if ((kb.Flags & RI_KEY_BREAK) && kb.VKey > 0 && kb.VKey < 256) g_input.rawKeyUp(static_cast(kb.VKey)); + // 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) + g_input.noteAnyKeyDown(GetTickCount64()); // downs and ups, like the hook } else if (ri->header.dwType == RIM_TYPEMOUSE) { const RAWMOUSE& m = ri->data.mouse; if ((m.usFlags & MOUSE_MOVE_ABSOLUTE) == 0) { diff --git a/src/version.h b/src/version.h index 4a7f817..945a7d3 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 13 -#define WIND_VER_PATCH 2 +#define WIND_VER_PATCH 3 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.13.2" +#define WIND_VERSION_STR "0.13.3" diff --git a/src/view_target.h b/src/view_target.h index 6043755..9fc391f 100644 --- a/src/view_target.h +++ b/src/view_target.h @@ -11,6 +11,9 @@ // field-reported wobble.) // - Caret/focus changes within kClickQuietMs of a mouse button are the click's own doing // (opening a page, clicking into a field): they are consumed without taking the view. +// - And they need a KEY: a caret/focus change takes the view only if a key went down or up within +// kKeyDrivenMs (issue #289; key-ups count so Alt+Tab released after a long look still counts). Scrolling a page moves a focused control's caret on screen with +// no key at all (field: the Settings page dragged the view while scrolling). #include namespace wind { enum class ViewOwner { Mouse, Caret, Focus }; @@ -22,6 +25,10 @@ struct ViewOwnerState { double moveAccum = 0; double moveWindowMs = 0; bool warpPointer = false; // set on the tick the mouse MOVED the view back; caller clears + // The caret/focus rect the view follows: LATCHED only from events that passed the gates. Reading + // the live snapshot instead let an owner that already followed one keystroke keep chasing every + // later caret move, a scroll included, and an app-driven focus change (review 2026-09-30). + TrackSnapshot target; }; struct ViewOwnerInputs { bool enabled = false; // zoomed && !game && !inspect && !locked @@ -29,11 +36,13 @@ struct ViewOwnerInputs { double mouseDx = 0, mouseDy = 0; // real pointer movement this tick, px bool buttonDown = false; double msSinceButton = 1e9; // time since a mouse button was last down + double msSinceKey = 0; // time since any key went down (0 when unknown: no gate) double dtMs = 0; TrackSnapshot snap; }; inline constexpr double kMouseTakeoverPx = 3.0, kMouseTakeoverWindowMs = 100.0; inline constexpr double kClickQuietMs = 1000.0; +inline constexpr double kKeyDrivenMs = 1000.0; inline ViewOwner StepViewOwner(ViewOwnerState& s, const ViewOwnerInputs& in) { s.warpPointer = false; @@ -65,9 +74,9 @@ inline ViewOwner StepViewOwner(ViewOwnerState& s, const ViewOwnerInputs& in) { // A new tracking event, unless a recent click caused it. if (in.snap.seq != s.lastSeq) { s.lastSeq = in.snap.seq; - if (in.msSinceButton >= kClickQuietMs) { - if (in.snap.kind == TrackKind::Caret && in.trackCaret) s.owner = ViewOwner::Caret; - else if (in.snap.kind == TrackKind::Focus && in.trackFocus) s.owner = ViewOwner::Focus; + if (in.msSinceButton >= kClickQuietMs && in.msSinceKey <= kKeyDrivenMs) { + if (in.snap.kind == TrackKind::Caret && in.trackCaret) { s.owner = ViewOwner::Caret; s.target = in.snap; } + else if (in.snap.kind == TrackKind::Focus && in.trackFocus) { s.owner = ViewOwner::Focus; s.target = in.snap; } } } return s.owner; diff --git a/tests/test_view_target.cpp b/tests/test_view_target.cpp index 0b166d5..9caa42f 100644 --- a/tests/test_view_target.cpp +++ b/tests/test_view_target.cpp @@ -69,3 +69,39 @@ TEST_CASE("tracking turned off mid-caret goes straight back to the mouse, no war CHECK(StepViewOwner(s, in) == ViewOwner::Mouse); CHECK_FALSE(s.warpPointer); } + +TEST_CASE("caret or focus changes need a recent key: scrolling moves a caret with no key (#289)") { + ViewOwnerState s; auto in = Base(); + in.msSinceKey = 5000; in.snap = Snap(TrackKind::Caret, 1); + CHECK(StepViewOwner(s, in) == ViewOwner::Mouse); // no key: consumed, not followed + in.msSinceKey = 5000; CHECK(StepViewOwner(s, in) == ViewOwner::Mouse); // and it never fires late + in.msSinceKey = 80; in.snap = Snap(TrackKind::Caret, 2); + CHECK(StepViewOwner(s, in) == ViewOwner::Caret); // typed: followed +} +TEST_CASE("once following the caret, a later caret move with no key does not drag the view (review)") { + ViewOwnerState s; auto in = Base(); + in.msSinceKey = 50; in.snap = Snap(TrackKind::Caret, 1); + CHECK(StepViewOwner(s, in) == ViewOwner::Caret); + CHECK(s.target.t == doctest::Approx(100)); + in.msSinceKey = 5000; in.snap = Snap(TrackKind::Caret, 2); in.snap.t = 900; in.snap.b = 920; // scrolled + CHECK(StepViewOwner(s, in) == ViewOwner::Caret); + CHECK(s.target.t == doctest::Approx(100)); // the view stays where the keystroke put it + in.msSinceKey = 30; in.snap = Snap(TrackKind::Caret, 3); in.snap.t = 400; in.snap.b = 420; // typed again + StepViewOwner(s, in); + CHECK(s.target.t == doctest::Approx(400)); +} +TEST_CASE("an app moving focus does not move a caret-owned view (review)") { + ViewOwnerState s; auto in = Base(); in.trackFocus = true; + in.msSinceKey = 50; in.snap = Snap(TrackKind::Caret, 1); + StepViewOwner(s, in); + in.msSinceKey = 5000; in.snap = Snap(TrackKind::Focus, 2); in.snap.l = 3000; + CHECK(StepViewOwner(s, in) == ViewOwner::Caret); + CHECK(s.target.l == doctest::Approx(100)); +} +TEST_CASE("focus by Tab follows; focus moved by the app on its own does not (#289)") { + ViewOwnerState s; auto in = Base(); + in.msSinceKey = 3000; in.snap = Snap(TrackKind::Focus, 1); + CHECK(StepViewOwner(s, in) == ViewOwner::Mouse); + in.msSinceKey = 40; in.snap = Snap(TrackKind::Focus, 2); + CHECK(StepViewOwner(s, in) == ViewOwner::Focus); +}