From 0fa65cebe187622b20d9be73cb42fd5b2b6551a7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:56:05 +0200 Subject: [PATCH 1/5] fix(tracking): follow a caret/focus change only after a key press (#289) Scrolling moves a focused control's caret on screen with no key at all (field: the Settings page dragged the view while scrolling). A change now takes the view only if a key went down within 1 s (keyboard LL hook timestamp, injected keys included); without the hook there is no gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/input_router.cpp | 1 + src/input_router.h | 7 +++++++ src/main.cpp | 3 +++ src/view_target.h | 7 ++++++- tests/test_view_target.cpp | 16 ++++++++++++++++ 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/input_router.cpp b/src/input_router.cpp index 9ca8e5a..233146e 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -234,6 +234,7 @@ 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); + if (down) g_router->noteAnyKeyDown(GetTickCount64()); // any key: tracking's keyboard gate (#289) // 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..93a55d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1585,6 +1585,9 @@ 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). Without the keyboard hook there is no timestamp: no gate. + const unsigned long long lastKey = g_input.lastAnyKeyDownMs(); + vi.msSinceKey = !g_input.kbHookActive() ? 0.0 : (lastKey ? double(nowMs - lastKey) : 1e9); vi.dtMs = dt * 1000.0; vi.snap = g_track.snapshot(); const wind::ViewOwner was = t.viewOwner.owner; diff --git a/src/view_target.h b/src/view_target.h index 6043755..1055b01 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 within +// kKeyDrivenMs (issue #289). 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 }; @@ -29,11 +32,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,7 +70,7 @@ 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.msSinceButton >= kClickQuietMs && in.msSinceKey <= kKeyDrivenMs) { 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; } diff --git a/tests/test_view_target.cpp b/tests/test_view_target.cpp index 0b166d5..c68e5d4 100644 --- a/tests/test_view_target.cpp +++ b/tests/test_view_target.cpp @@ -69,3 +69,19 @@ 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("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); +} From fec213808f6c61c69dd4ddc7b05a6fea28908a72 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:12:28 +0200 Subject: [PATCH 2/5] fix(tracking): hold caret jumps no key explains; raw input keeps the key clock alive (#293, #289 review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/focus_track.cpp | 18 +++++++++++- src/input_router.cpp | 7 ++++- src/main.cpp | 14 +++++++-- src/track_filter.h | 57 +++++++++++++++++++++++++++++++++++++ tests/test_track_filter.cpp | 40 ++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 038dc37..4ee58ce 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -202,6 +202,7 @@ void FocusTracker::run() { // the 60 Hz backstop poll cannot publish the new field's caret in the 30 ms before that. unsigned focusGen = 0, caretGen = ~0u; RECT lastCaret{}; + CaretJumpGate jumpGate; // issue #293: far jumps no key explains are held // Java apps (issue #281): the bridge is asked only when something may have moved (a bridge caret // or focus callback, or any tracker wake), never by the 60 Hz poll, which reuses the last answer. @@ -291,6 +292,7 @@ void FocusTracker::run() { if (found) { if (caretGen != focusGen) { caretGen = focusGen; lastCaret = rc; // baseline, not followed + CaretGateBaseline(jumpGate, { rc.left, rc.top, rc.right, rc.bottom }); if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret baseline via %s: %ld,%ld", src, rc.left, rc.top); } else if (!EqualRect(&rc, &lastCaret)) { lastCaret = rc; @@ -304,7 +306,21 @@ void FocusTracker::run() { if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret skipped (outside its element %ld,%ld %ldx%ld) via %s: %ld,%ld", b.left, b.top, b.right - b.left, b.bottom - b.top, src, rc.left, rc.top); } else { - publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); + // The last key within a second, or none known at all (no hook, no raw + // input yet): only a known ordinary key can hold a jump. + const LastKey& lk = LastKeySlot(); + const unsigned long long km = lk.ms.load(std::memory_order_relaxed); + const bool jumpKey = km == 0 || GetTickCount64() - km > 1000 || + IsJumpKey(lk.vk.load(std::memory_order_relaxed), lk.chord.load(std::memory_order_relaxed)); + const bool wasPending = jumpGate.pending; + if (CaretGateStep(jumpGate, { rc.left, rc.top, rc.right, rc.bottom }, jumpKey)) { + if (wasPending && !jumpGate.pending && log_.load()) + wind::Log(wind::LogLevel::Info, "track", "caret jump confirmed or dropped via %s: %ld,%ld", src, rc.left, rc.top); + publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); + } else if (log_.load()) { + wind::Log(wind::LogLevel::Info, "track", "caret jump held (no navigation key, vk=0x%02x) via %s: %ld,%ld", + lk.vk.load(std::memory_order_relaxed), src, rc.left, rc.top); + } } } } diff --git a/src/input_router.cpp b/src/input_router.cpp index 233146e..3ef7d81 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -1,4 +1,5 @@ #include "input_router.h" +#include "track_filter.h" // NoteKeyDown (#293) #include "mag_thread.h" // the hook thread owns the Magnification runtime (issue #206) #include "hook_transform.h" // ...and writes the transform inline from MouseProc (#206 stage 2) #include "config.h" // IsForbiddenBindVk (keyboard-bind safety blocklist) @@ -234,7 +235,11 @@ 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); - if (down) g_router->noteAnyKeyDown(GetTickCount64()); // any key: tracking's keyboard gate (#289) + if (down) { // any key: tracking's keyboard gate (#289) and caret-jump rule (#293) + const unsigned long long now = GetTickCount64(); + g_router->noteAnyKeyDown(now); + wind::NoteKeyDown(vk, (GetAsyncKeyState(VK_CONTROL) & 0x8000) || (GetAsyncKeyState(VK_MENU) & 0x8000), now); + } // 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/main.cpp b/src/main.cpp index 93a55d6..2636205 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,7 @@ #include "detached_view.h" // tracking: a frame whose view is not centred on the pointer #include "edge_pan.h" // mouse edge mode (issue #276 phase 2) #include "cursor_decode.h" // edge mode measures the cursor body +#include "track_filter.h" // NoteKeyDown: the caret-jump rule's key clock (#293) #include "focus_track.h" // tracking: caret/focus watcher thread #include "tray_host.h" // WindTray.exe owns the icon and menu (#291) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed @@ -1585,9 +1586,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). Without the keyboard hook there is no timestamp: no gate. + // 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 = !g_input.kbHookActive() ? 0.0 : (lastKey ? double(nowMs - lastKey) : 1e9); + 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; @@ -2389,6 +2391,14 @@ 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 DOWN feeds only tracking's key clock (#289/#293), 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.Flags & RI_KEY_BREAK) && kb.VKey > 0 && kb.VKey < 256) { + const unsigned long long now = GetTickCount64(); + g_input.noteAnyKeyDown(now); + wind::NoteKeyDown(kb.VKey, (GetAsyncKeyState(VK_CONTROL) & 0x8000) || (GetAsyncKeyState(VK_MENU) & 0x8000), now); + } } else if (ri->header.dwType == RIM_TYPEMOUSE) { const RAWMOUSE& m = ri->data.mouse; if ((m.usFlags & MOUSE_MOVE_ABSOLUTE) == 0) { diff --git a/src/track_filter.h b/src/track_filter.h index 3fea2ee..90e4b13 100644 --- a/src/track_filter.h +++ b/src/track_filter.h @@ -1,5 +1,6 @@ #pragma once // Sanity filters for what the focus/caret watcher reports (issue #278). Pure; tests/test_track_filter.cpp. +#include namespace wind { struct TrackBox { long l, t, r, b; }; @@ -27,4 +28,60 @@ inline bool IsContainerFocus(const TrackBox& rc, long monL, long monT, long monR const double mon = (double)(monR - monL) * (double)(monB - monT); return mon > 0 && area >= 0.5 * mon; } + +// --- Caret jumps no key explains (issue #293) ------------------------------------------------ +// A terminal reports its TEXT cursor as the caret, and a TUI such as Claude Code moves that cursor +// around to redraw (hidden), sometimes leaving it lines above the input it draws itself. Field +// 2026-09-29, Prism Terminal: the caret read y=1737 (the input line), then y=567 while the user +// typed letters, and the view parked on the bogus row. Typing a character never moves a real +// caret several lines, so such a jump is HELD; it is followed once typing continues right next to +// it (a real move to that line), and dropped if the caret comes back. Jumps a key explains are +// followed at once: arrows, Page Up/Down, Home/End, Enter, Tab, F-keys, any Ctrl/Alt chord. + +// The last key that went down, written by the keyboard hook and by Raw Input (which keeps working +// while the hook is suspended), read by the tracker thread. +struct LastKey { std::atomic ms{0}; std::atomic vk{0}; std::atomic chord{false}; }; +inline LastKey& LastKeySlot() { static LastKey k; return k; } +inline void NoteKeyDown(int vk, bool ctrlOrAlt, unsigned long long ms) { + LastKey& k = LastKeySlot(); + k.vk.store(vk, std::memory_order_relaxed); + k.chord.store(ctrlOrAlt, std::memory_order_relaxed); + k.ms.store(ms, std::memory_order_relaxed); +} + +inline bool IsJumpKey(int vk, bool ctrlOrAlt) { + if (ctrlOrAlt) return true; // Ctrl+Home, Ctrl+Z, Alt+arrows, ... + if (vk >= 0x21 && vk <= 0x28) return true; // Page Up/Down, End, Home, arrows + if (vk == 0x0D || vk == 0x09) return true; // Enter, Tab + if (vk >= 0x70 && vk <= 0x87) return true; // F1..F24 (F3 = find next) + return false; +} + +inline long CaretLineH(const TrackBox& a, const TrackBox& b) { + long h = a.b - a.t; if (b.b - b.t > h) h = b.b - b.t; + return h < 8 ? 8 : h; +} +// More than three caret heights up or down. +inline bool IsFarCaretJump(const TrackBox& from, const TrackBox& to) { + const long dy = to.t > from.t ? to.t - from.t : from.t - to.t; + return dy > 3 * CaretLineH(from, to); +} +// The same row and within about two characters: what typing next to a caret produces. +inline bool NearOnSameRow(const TrackBox& a, const TrackBox& b) { + const long h = CaretLineH(a, b); + const long dy = a.t > b.t ? a.t - b.t : b.t - a.t, dx = a.l > b.l ? a.l - b.l : b.l - a.l; + return dy <= h / 2 && dx <= 2 * h; +} + +struct CaretJumpGate { bool haveRef = false; TrackBox ref{}; bool pending = false; TrackBox held{}; }; +// A focus change's first caret is the reference (the tracker's baseline); nothing is pending. +inline void CaretGateBaseline(CaretJumpGate& g, const TrackBox& c) { g.haveRef = true; g.ref = c; g.pending = false; } +// A caret move. True = follow it; false = hold it. jumpKey: the last key explains a jump (or there +// is no key information at all, which must never block tracking that worked before). +inline bool CaretGateStep(CaretJumpGate& g, const TrackBox& c, bool jumpKey) { + if (g.pending && NearOnSameRow(c, g.held)) { g.pending = false; g.ref = c; return true; } // confirmed + if (!g.haveRef || jumpKey || !IsFarCaretJump(g.ref, c)) { g.pending = false; g.haveRef = true; g.ref = c; return true; } + g.pending = true; g.held = c; + return false; +} } // namespace wind diff --git a/tests/test_track_filter.cpp b/tests/test_track_filter.cpp index 6c6e628..20c983d 100644 --- a/tests/test_track_filter.cpp +++ b/tests/test_track_filter.cpp @@ -18,3 +18,43 @@ TEST_CASE("page-sized focus is a container; controls and huge zoomed inputs part CHECK_FALSE(IsContainerFocus({ 902, 912, 6146, 2106 }, 0, 0, 3840, 2160)); // 5244x1194, 41% on screen CHECK_FALSE(IsContainerFocus({ 5000, 0, 6000, 100 }, 0, 0, 3840, 2160)); // off the monitor } + +// --- caret jumps no key explains (issue #293) ------------------------------------------- +TEST_CASE("the field case: typing in Claude Code, the terminal caret leaps to a row far above") { + CaretJumpGate g; + CaretGateBaseline(g, { 53, 1737, 77, 1782 }); + CHECK(CaretGateStep(g, { 98, 1737, 122, 1782 }, false)); // typing on the input line: followed + CHECK_FALSE(CaretGateStep(g, { 121, 567, 123, 612 }, false)); // space, then y=567: held + CHECK(g.pending); + CHECK(CaretGateStep(g, { 143, 1737, 167, 1782 }, false)); // back on the input line: followed + CHECK_FALSE(g.pending); +} +TEST_CASE("a held jump is followed once typing continues right next to it") { + CaretJumpGate g; + CaretGateBaseline(g, { 100, 1700, 102, 1745 }); + CHECK_FALSE(CaretGateStep(g, { 100, 300, 102, 345 }, false)); + CHECK(CaretGateStep(g, { 122, 300, 124, 345 }, false)); // one character further on that row + CHECK_FALSE(g.pending); + CHECK(CaretGateStep(g, { 144, 300, 146, 345 }, false)); // and it is the new reference +} +TEST_CASE("jumps a key explains are followed at once") { + CaretJumpGate g; + CaretGateBaseline(g, { 100, 1700, 102, 1745 }); + CHECK(CaretGateStep(g, { 100, 300, 102, 345 }, true)); // Ctrl+Home, Page Up, ... + CHECK(IsJumpKey(0x21, false)); CHECK(IsJumpKey(0x28, false)); CHECK(IsJumpKey(0x0D, false)); + CHECK(IsJumpKey(0x09, false)); CHECK(IsJumpKey(0x72, false)); CHECK(IsJumpKey('Z', true)); + CHECK_FALSE(IsJumpKey(0x20, false)); // space + CHECK_FALSE(IsJumpKey('A', false)); CHECK_FALSE(IsJumpKey(0x08, false)); +} +TEST_CASE("small moves are never held: new line, wrapped typing") { + CaretJumpGate g; + CaretGateBaseline(g, { 100, 1000, 102, 1045 }); + CHECK(CaretGateStep(g, { 10, 1045, 12, 1090 }, false)); // wrapped to the next line + CHECK(CaretGateStep(g, { 10, 1180, 12, 1225 }, false)); // three lines down: still near + CHECK_FALSE(IsFarCaretJump({ 0, 0, 2, 45 }, { 0, 135, 2, 180 })); + CHECK(IsFarCaretJump({ 0, 0, 2, 45 }, { 0, 136, 2, 181 })); +} +TEST_CASE("without a reference nothing is held") { + CaretJumpGate g; + CHECK(CaretGateStep(g, { 0, 2000, 2, 2045 }, false)); +} From 112d42c869fcb258fa89dc91576e7f809d0574c1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:39:33 +0200 Subject: [PATCH 3/5] revert(tracking): drop the caret-jump hold; the parked caret is fixed in Prism Terminal (#293) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/focus_track.cpp | 18 +----------- src/input_router.cpp | 7 +---- src/main.cpp | 10 ++----- src/track_filter.h | 57 ------------------------------------- tests/test_track_filter.cpp | 40 -------------------------- 5 files changed, 5 insertions(+), 127 deletions(-) diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 4ee58ce..038dc37 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -202,7 +202,6 @@ void FocusTracker::run() { // the 60 Hz backstop poll cannot publish the new field's caret in the 30 ms before that. unsigned focusGen = 0, caretGen = ~0u; RECT lastCaret{}; - CaretJumpGate jumpGate; // issue #293: far jumps no key explains are held // Java apps (issue #281): the bridge is asked only when something may have moved (a bridge caret // or focus callback, or any tracker wake), never by the 60 Hz poll, which reuses the last answer. @@ -292,7 +291,6 @@ void FocusTracker::run() { if (found) { if (caretGen != focusGen) { caretGen = focusGen; lastCaret = rc; // baseline, not followed - CaretGateBaseline(jumpGate, { rc.left, rc.top, rc.right, rc.bottom }); if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret baseline via %s: %ld,%ld", src, rc.left, rc.top); } else if (!EqualRect(&rc, &lastCaret)) { lastCaret = rc; @@ -306,21 +304,7 @@ void FocusTracker::run() { if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret skipped (outside its element %ld,%ld %ldx%ld) via %s: %ld,%ld", b.left, b.top, b.right - b.left, b.bottom - b.top, src, rc.left, rc.top); } else { - // The last key within a second, or none known at all (no hook, no raw - // input yet): only a known ordinary key can hold a jump. - const LastKey& lk = LastKeySlot(); - const unsigned long long km = lk.ms.load(std::memory_order_relaxed); - const bool jumpKey = km == 0 || GetTickCount64() - km > 1000 || - IsJumpKey(lk.vk.load(std::memory_order_relaxed), lk.chord.load(std::memory_order_relaxed)); - const bool wasPending = jumpGate.pending; - if (CaretGateStep(jumpGate, { rc.left, rc.top, rc.right, rc.bottom }, jumpKey)) { - if (wasPending && !jumpGate.pending && log_.load()) - wind::Log(wind::LogLevel::Info, "track", "caret jump confirmed or dropped via %s: %ld,%ld", src, rc.left, rc.top); - publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); - } else if (log_.load()) { - wind::Log(wind::LogLevel::Info, "track", "caret jump held (no navigation key, vk=0x%02x) via %s: %ld,%ld", - lk.vk.load(std::memory_order_relaxed), src, rc.left, rc.top); - } + publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src); } } } diff --git a/src/input_router.cpp b/src/input_router.cpp index 3ef7d81..233146e 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -1,5 +1,4 @@ #include "input_router.h" -#include "track_filter.h" // NoteKeyDown (#293) #include "mag_thread.h" // the hook thread owns the Magnification runtime (issue #206) #include "hook_transform.h" // ...and writes the transform inline from MouseProc (#206 stage 2) #include "config.h" // IsForbiddenBindVk (keyboard-bind safety blocklist) @@ -235,11 +234,7 @@ 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); - if (down) { // any key: tracking's keyboard gate (#289) and caret-jump rule (#293) - const unsigned long long now = GetTickCount64(); - g_router->noteAnyKeyDown(now); - wind::NoteKeyDown(vk, (GetAsyncKeyState(VK_CONTROL) & 0x8000) || (GetAsyncKeyState(VK_MENU) & 0x8000), now); - } + if (down) g_router->noteAnyKeyDown(GetTickCount64()); // any key: tracking's keyboard gate (#289) // 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/main.cpp b/src/main.cpp index 2636205..d749d91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,7 +36,6 @@ #include "detached_view.h" // tracking: a frame whose view is not centred on the pointer #include "edge_pan.h" // mouse edge mode (issue #276 phase 2) #include "cursor_decode.h" // edge mode measures the cursor body -#include "track_filter.h" // NoteKeyDown: the caret-jump rule's key clock (#293) #include "focus_track.h" // tracking: caret/focus watcher thread #include "tray_host.h" // WindTray.exe owns the icon and menu (#291) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed @@ -2391,14 +2390,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 DOWN feeds only tracking's key clock (#289/#293), never held state. Raw Input + // Key DOWN 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.Flags & RI_KEY_BREAK) && kb.VKey > 0 && kb.VKey < 256) { - const unsigned long long now = GetTickCount64(); - g_input.noteAnyKeyDown(now); - wind::NoteKeyDown(kb.VKey, (GetAsyncKeyState(VK_CONTROL) & 0x8000) || (GetAsyncKeyState(VK_MENU) & 0x8000), now); - } + if (!(kb.Flags & RI_KEY_BREAK) && kb.VKey > 0 && kb.VKey < 256) + g_input.noteAnyKeyDown(GetTickCount64()); } else if (ri->header.dwType == RIM_TYPEMOUSE) { const RAWMOUSE& m = ri->data.mouse; if ((m.usFlags & MOUSE_MOVE_ABSOLUTE) == 0) { diff --git a/src/track_filter.h b/src/track_filter.h index 90e4b13..3fea2ee 100644 --- a/src/track_filter.h +++ b/src/track_filter.h @@ -1,6 +1,5 @@ #pragma once // Sanity filters for what the focus/caret watcher reports (issue #278). Pure; tests/test_track_filter.cpp. -#include namespace wind { struct TrackBox { long l, t, r, b; }; @@ -28,60 +27,4 @@ inline bool IsContainerFocus(const TrackBox& rc, long monL, long monT, long monR const double mon = (double)(monR - monL) * (double)(monB - monT); return mon > 0 && area >= 0.5 * mon; } - -// --- Caret jumps no key explains (issue #293) ------------------------------------------------ -// A terminal reports its TEXT cursor as the caret, and a TUI such as Claude Code moves that cursor -// around to redraw (hidden), sometimes leaving it lines above the input it draws itself. Field -// 2026-09-29, Prism Terminal: the caret read y=1737 (the input line), then y=567 while the user -// typed letters, and the view parked on the bogus row. Typing a character never moves a real -// caret several lines, so such a jump is HELD; it is followed once typing continues right next to -// it (a real move to that line), and dropped if the caret comes back. Jumps a key explains are -// followed at once: arrows, Page Up/Down, Home/End, Enter, Tab, F-keys, any Ctrl/Alt chord. - -// The last key that went down, written by the keyboard hook and by Raw Input (which keeps working -// while the hook is suspended), read by the tracker thread. -struct LastKey { std::atomic ms{0}; std::atomic vk{0}; std::atomic chord{false}; }; -inline LastKey& LastKeySlot() { static LastKey k; return k; } -inline void NoteKeyDown(int vk, bool ctrlOrAlt, unsigned long long ms) { - LastKey& k = LastKeySlot(); - k.vk.store(vk, std::memory_order_relaxed); - k.chord.store(ctrlOrAlt, std::memory_order_relaxed); - k.ms.store(ms, std::memory_order_relaxed); -} - -inline bool IsJumpKey(int vk, bool ctrlOrAlt) { - if (ctrlOrAlt) return true; // Ctrl+Home, Ctrl+Z, Alt+arrows, ... - if (vk >= 0x21 && vk <= 0x28) return true; // Page Up/Down, End, Home, arrows - if (vk == 0x0D || vk == 0x09) return true; // Enter, Tab - if (vk >= 0x70 && vk <= 0x87) return true; // F1..F24 (F3 = find next) - return false; -} - -inline long CaretLineH(const TrackBox& a, const TrackBox& b) { - long h = a.b - a.t; if (b.b - b.t > h) h = b.b - b.t; - return h < 8 ? 8 : h; -} -// More than three caret heights up or down. -inline bool IsFarCaretJump(const TrackBox& from, const TrackBox& to) { - const long dy = to.t > from.t ? to.t - from.t : from.t - to.t; - return dy > 3 * CaretLineH(from, to); -} -// The same row and within about two characters: what typing next to a caret produces. -inline bool NearOnSameRow(const TrackBox& a, const TrackBox& b) { - const long h = CaretLineH(a, b); - const long dy = a.t > b.t ? a.t - b.t : b.t - a.t, dx = a.l > b.l ? a.l - b.l : b.l - a.l; - return dy <= h / 2 && dx <= 2 * h; -} - -struct CaretJumpGate { bool haveRef = false; TrackBox ref{}; bool pending = false; TrackBox held{}; }; -// A focus change's first caret is the reference (the tracker's baseline); nothing is pending. -inline void CaretGateBaseline(CaretJumpGate& g, const TrackBox& c) { g.haveRef = true; g.ref = c; g.pending = false; } -// A caret move. True = follow it; false = hold it. jumpKey: the last key explains a jump (or there -// is no key information at all, which must never block tracking that worked before). -inline bool CaretGateStep(CaretJumpGate& g, const TrackBox& c, bool jumpKey) { - if (g.pending && NearOnSameRow(c, g.held)) { g.pending = false; g.ref = c; return true; } // confirmed - if (!g.haveRef || jumpKey || !IsFarCaretJump(g.ref, c)) { g.pending = false; g.haveRef = true; g.ref = c; return true; } - g.pending = true; g.held = c; - return false; -} } // namespace wind diff --git a/tests/test_track_filter.cpp b/tests/test_track_filter.cpp index 20c983d..6c6e628 100644 --- a/tests/test_track_filter.cpp +++ b/tests/test_track_filter.cpp @@ -18,43 +18,3 @@ TEST_CASE("page-sized focus is a container; controls and huge zoomed inputs part CHECK_FALSE(IsContainerFocus({ 902, 912, 6146, 2106 }, 0, 0, 3840, 2160)); // 5244x1194, 41% on screen CHECK_FALSE(IsContainerFocus({ 5000, 0, 6000, 100 }, 0, 0, 3840, 2160)); // off the monitor } - -// --- caret jumps no key explains (issue #293) ------------------------------------------- -TEST_CASE("the field case: typing in Claude Code, the terminal caret leaps to a row far above") { - CaretJumpGate g; - CaretGateBaseline(g, { 53, 1737, 77, 1782 }); - CHECK(CaretGateStep(g, { 98, 1737, 122, 1782 }, false)); // typing on the input line: followed - CHECK_FALSE(CaretGateStep(g, { 121, 567, 123, 612 }, false)); // space, then y=567: held - CHECK(g.pending); - CHECK(CaretGateStep(g, { 143, 1737, 167, 1782 }, false)); // back on the input line: followed - CHECK_FALSE(g.pending); -} -TEST_CASE("a held jump is followed once typing continues right next to it") { - CaretJumpGate g; - CaretGateBaseline(g, { 100, 1700, 102, 1745 }); - CHECK_FALSE(CaretGateStep(g, { 100, 300, 102, 345 }, false)); - CHECK(CaretGateStep(g, { 122, 300, 124, 345 }, false)); // one character further on that row - CHECK_FALSE(g.pending); - CHECK(CaretGateStep(g, { 144, 300, 146, 345 }, false)); // and it is the new reference -} -TEST_CASE("jumps a key explains are followed at once") { - CaretJumpGate g; - CaretGateBaseline(g, { 100, 1700, 102, 1745 }); - CHECK(CaretGateStep(g, { 100, 300, 102, 345 }, true)); // Ctrl+Home, Page Up, ... - CHECK(IsJumpKey(0x21, false)); CHECK(IsJumpKey(0x28, false)); CHECK(IsJumpKey(0x0D, false)); - CHECK(IsJumpKey(0x09, false)); CHECK(IsJumpKey(0x72, false)); CHECK(IsJumpKey('Z', true)); - CHECK_FALSE(IsJumpKey(0x20, false)); // space - CHECK_FALSE(IsJumpKey('A', false)); CHECK_FALSE(IsJumpKey(0x08, false)); -} -TEST_CASE("small moves are never held: new line, wrapped typing") { - CaretJumpGate g; - CaretGateBaseline(g, { 100, 1000, 102, 1045 }); - CHECK(CaretGateStep(g, { 10, 1045, 12, 1090 }, false)); // wrapped to the next line - CHECK(CaretGateStep(g, { 10, 1180, 12, 1225 }, false)); // three lines down: still near - CHECK_FALSE(IsFarCaretJump({ 0, 0, 2, 45 }, { 0, 135, 2, 180 })); - CHECK(IsFarCaretJump({ 0, 0, 2, 45 }, { 0, 136, 2, 181 })); -} -TEST_CASE("without a reference nothing is held") { - CaretJumpGate g; - CHECK(CaretGateStep(g, { 0, 2000, 2, 2045 }, false)); -} From c42a30bfb4f28e5a07d601ee2b91090855594ae7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:37:01 +0200 Subject: [PATCH 4/5] fix(tracking): follow only gated caret/focus events (latched target); key-ups count as keyboard activity (#289 review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/input_router.cpp | 4 +++- src/main.cpp | 10 ++++++---- src/view_target.h | 12 ++++++++---- tests/test_view_target.cpp | 20 ++++++++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/input_router.cpp b/src/input_router.cpp index 233146e..43c3464 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -234,7 +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); - if (down) g_router->noteAnyKeyDown(GetTickCount64()); // any key: tracking's keyboard gate (#289) + // 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/main.cpp b/src/main.cpp index d749d91..e2e8fbc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1602,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; } @@ -2390,11 +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 DOWN feeds only tracking's key clock (#289), never held state. Raw Input + // 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.Flags & RI_KEY_BREAK) && kb.VKey > 0 && kb.VKey < 256) - g_input.noteAnyKeyDown(GetTickCount64()); + 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/view_target.h b/src/view_target.h index 1055b01..9fc391f 100644 --- a/src/view_target.h +++ b/src/view_target.h @@ -11,8 +11,8 @@ // 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 within -// kKeyDrivenMs (issue #289). Scrolling a page moves a focused control's caret on screen with +// - 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 { @@ -25,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 @@ -71,8 +75,8 @@ inline ViewOwner StepViewOwner(ViewOwnerState& s, const ViewOwnerInputs& in) { if (in.snap.seq != s.lastSeq) { s.lastSeq = in.snap.seq; if (in.msSinceButton >= kClickQuietMs && in.msSinceKey <= kKeyDrivenMs) { - 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.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 c68e5d4..9caa42f 100644 --- a/tests/test_view_target.cpp +++ b/tests/test_view_target.cpp @@ -78,6 +78,26 @@ TEST_CASE("caret or focus changes need a recent key: scrolling moves a caret wit 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); From 88a1b202f5423e2f7d9bc6a421e4a72ad68c320f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:05:04 +0200 Subject: [PATCH 5/5] chore(release): version 0.13.3 (#289) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/version.h b/src/version.h index e6b203e..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 1 +#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.1" +#define WIND_VERSION_STR "0.13.3"