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
2 changes: 1 addition & 1 deletion build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ rem --- Test build (pure-logic sources only; no <windows.h>) -----------------
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"
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*` |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 5 additions & 7 deletions docs/architecture/06-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/architecture/11-build-test-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<module>.cpp`). A new pure `.cpp` also goes into the
Expand Down
29 changes: 29 additions & 0 deletions src/caret_rect.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
4 changes: 2 additions & 2 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 40 additions & 14 deletions src/focus_track.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <oleauto.h>
#include <UIAutomation.h>
#include <chrono>
#include <algorithm>
#include <condition_variable>
#include <memory>
#include <string>
Expand Down Expand Up @@ -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<State>()) {
Expand All @@ -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();
Expand All @@ -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; })) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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).
Expand All @@ -425,20 +442,21 @@ 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{};
edc.have = MsaaCaret(m);
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; }
Expand All @@ -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;
Expand All @@ -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;
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/focus_track.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class FocusTracker {
friend class FocusHandler;
std::thread th_;
std::atomic<unsigned long> tid_{0};
std::atomic<bool> stopRequested_{false};
std::atomic<bool> active_{false}, wantCaret_{false}, wantFocus_{false}, log_{false};
mutable std::mutex mu_;
TrackSnapshot snap_;
Expand Down
Loading
Loading