Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/input_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) {
int vk = static_cast<int>(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)) {
Expand Down
7 changes: 7 additions & 0 deletions src/input_router.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ class InputRouter {
std::atomic<int> kbZoomInMods_{0}, kbZoomInMods2_{0}, kbZoomOutMods_{0}, kbZoomOutMods2_{0};
// Recency stamps for the raw-UP reordering guards (see rawKeyUp/rawButtonUp).
std::atomic<unsigned long long> 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<unsigned long long> kbLastAnyDownMs_{0};
std::atomic<unsigned long long> btnLastHookDownMs_[6]{}; // button ids 1..5
std::atomic<bool> kbHookActive_{false}; // true once the LL KEYBOARD hook is installed
std::atomic<unsigned> kbHookReinstalls_{0}; // watchdog recoveries this session
Expand Down
13 changes: 12 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }
Expand Down Expand Up @@ -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<int>(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) {
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
15 changes: 12 additions & 3 deletions src/view_target.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmath>
namespace wind {
enum class ViewOwner { Mouse, Caret, Focus };
Expand All @@ -22,18 +25,24 @@ 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
bool trackCaret = false, trackFocus = false;
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;
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions tests/test_view_target.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading