From fd81725e5b5df6ae4d3cfb5324359d6cfed2aaaf Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:53:15 +0200 Subject: [PATCH 1/4] refactor(track): move FocusTracker::run's state and steps into FocusRun (#477) Move-only. run()'s locals become FocusRun members (same declarations, same order, same comments), its five lambdas become member functions with unchanged bodies, and its body is split into setUp (COM, UIA timeouts, focus lookup, focus handler, WinEvent hooks, first retune), messageLoop and tearDown. run() keeps the queue/thread-id prologue and the final tid_ = 0. The tracker members the steps use are aliased so the moved code is byte-identical. Everything still runs on the tracker thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/focus_track.cpp | 109 +++++++++++++++++++++++++++++--------------- src/focus_track.h | 1 + 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 634816a6..cc66a27b 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -302,47 +302,40 @@ void FocusTracker::publish(TrackKind k, double l, double t, double r, double b, k == TrackKind::Caret ? "caret" : "focus", src, l, t, r - l, b - t); } -void FocusTracker::run() { - // 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); +// FocusTracker::run's state and steps, all on the tracker thread (#477: split out of run()). The +// tracker members the steps use are aliased, so the moved code reads as it did inside run(). +struct FocusRun { + explicit FocusRun(FocusTracker& tracker) + : tracker(tracker), tid_(tracker.tid_), active_(tracker.active_), wantCaret_(tracker.wantCaret_), + wantFocus_(tracker.wantFocus_), log_(tracker.log_) {} + FocusTracker& tracker; + std::atomic& tid_; + std::atomic& active_; + std::atomic& wantCaret_; + std::atomic& wantFocus_; + std::atomic& log_; + void publish(TrackKind k, double l, double t, double r, double b, const char* src) { tracker.publish(k, l, t, r, b, src); } + + void setUp(); // COM, UIA and its timeouts, the focus lookup, the event hooks + void messageLoop(MSG& m); // wakes, coalescing and the backstop poll until WM_QUIT + void tearDown(); // undoes setUp + IUIAutomation* uia = nullptr; - CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, __uuidof(IUIAutomation), (void**)&uia); - // Every UIA call is a round trip into the target app, and one app that does not answer stalls - // ALL tracking for the default timeouts (field 2026-10-04: GetFocusedElement blocked 3016 ms - // right after an app switch, and the next app's typing went unfollowed). Cap both waits: a slow - // app now costs at most half a second, and the 16 ms poll simply asks again. - if (uia) { - IUIAutomation2* uia2 = nullptr; - if (SUCCEEDED(uia->QueryInterface(__uuidof(IUIAutomation2), (void**)&uia2)) && uia2) { - uia2->put_ConnectionTimeout(500); - uia2->put_TransactionTimeout(500); - uia2->Release(); - } - } std::unique_ptr focusLookup; - if (uia) focusLookup = std::make_unique(uia); bool lookupStuckLogged = false; FocusHandler* fh = nullptr; - if (uia) { fh = new FocusHandler(); if (FAILED(uia->AddFocusChangedEventHandler(nullptr, fh))) { fh->Release(); fh = nullptr; } } - HWINEVENTHOOK h1 = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - HWINEVENTHOOK h2 = SetWinEventHook(EVENT_SYSTEM_MENUPOPUPSTART, EVENT_SYSTEM_MENUPOPUPSTART, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - HWINEVENTHOOK h3 = SetWinEventHook(EVENT_OBJECT_FOCUS, EVENT_OBJECT_FOCUS, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + HWINEVENTHOOK h1 = nullptr, h2 = nullptr, h3 = nullptr; // LOCATIONCHANGE fires for every moving window and caret system-wide: installed only while the // tracker is active (zoomed), never at 1x (#71). The 16 ms caret backstop likewise. HWINEVENTHOOK h4 = nullptr; UINT_PTR pollTimer = 0; - auto retune = [&](bool on) { + void retune(bool on) { if (on && !h4) h4 = SetWinEventHook(EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_LOCATIONCHANGE, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); if (!on && h4) { UnhookWinEvent(h4); h4 = nullptr; } if (pollTimer) KillTimer(nullptr, pollTimer); pollTimer = on ? SetTimer(nullptr, 0, 16, nullptr) : 0; - }; - retune(active_.load()); // an activation that raced thread start-up is picked up here + } bool pendingFocus = false, pendingCaret = false; int focusRetries = 0; // re-resolves of a focus change whose lookup went unanswered (M5) UINT_PTR coalesce = 0; @@ -391,7 +384,7 @@ void FocusTracker::run() { // strip) says nothing about where the caret is. The character at the caret, when the editor exposes // it, gives the real x; else Chromium's system caret object for screen magnifiers (OBJID_CARET); else // no caret rather than a guess. Only for a collapsed range: a real selection keeps its own rect. - auto fixLineWide = [&](IUIAutomationTextRange* range, RECT& rc, const char*& src, bool fromPoll) -> bool { + bool fixLineWide(IUIAutomationTextRange* range, RECT& rc, const char*& src, bool fromPoll) { const ULONGLONG now = GetTickCount64(); if (lineWide.gen == focusGen && EqualRect(&lineWide.line, &rc) && fromPoll && now < lineWide.retryAt) { if (lineWide.ok) { rc = lineWide.rc; src = lineWide.src; } @@ -408,7 +401,7 @@ void FocusTracker::run() { lineWide.retryAt = now + 250; if (ok) { rc = out; src = outSrc; } return ok; - }; + } // Caret, fastest source first. Releases everything it acquires. // VS Code's editor (and Monaco elsewhere) is an EditContext element, "native-edit-context", @@ -427,7 +420,7 @@ void FocusTracker::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). - auto focusClass = [&](IUIAutomationElement* el) { + void focusClass(IUIAutomationElement* el) { const HWND fgNow = GetForegroundWindow(); if (edc.gen == focusGen && edc.fg == fgNow) return; edc = EditContextCache{}; edc.gen = focusGen; edc.fg = fgNow; @@ -441,10 +434,10 @@ void FocusTracker::run() { edc.xterm = wind::IsXtermInput(cls); SysFreeString(cls); } - }; + } // 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 { + bool editContextCaret(IUIAutomationElement* el, RECT& rc, const char*& src, bool fromPoll) { if (!el) return true; focusClass(el); if (!edc.is) return true; @@ -462,9 +455,9 @@ void FocusTracker::run() { rc = edc.msaa; src = "msaa-editcontext"; return true; - }; + } // elB: the focused element's bounds (empty = unknown). - auto findCaret = [&](IUIAutomationElement* el, const RECT& elB, RECT& rc, const char*& src, bool fromPoll) -> bool { + bool findCaret(IUIAutomationElement* el, const RECT& elB, RECT& rc, const char*& src, bool fromPoll) { // An xterm.js terminal (Prism, VS Code's terminal...): its input textarea is the cursor cell, // moved on every cursor move, while Chromium's text range rect for it goes stale for seconds // (field 2026-10-12, Prism: 60 moves of the textarea, 6 changes of its selection rect). @@ -518,13 +511,13 @@ void FocusTracker::run() { } if (ok) ok = editContextCaret(el, rc, src, fromPoll); return ok; - }; + } // Returns true when a focus change could not be answered (the focused-element lookup timed out // or came back empty), so the caller keeps it pending and tries again (review 2026-10-09 M5: // in slow UIA apps a Tab was otherwise lost for good, because the backstop poll never // re-resolves a focus change). - auto resolve = [&](bool focusChanged, bool fromPoll) -> bool { + bool resolve(bool focusChanged, bool fromPoll) { if (!active_.load()) return false; HWND fg = GetForegroundWindow(); if (IsOwnOrTooltip(fg)) return false; @@ -666,8 +659,33 @@ void FocusTracker::run() { } if (el) el->Release(); return focusUnanswered; - }; + } +}; +void FocusRun::setUp() { + CoInitializeEx(nullptr, COINIT_MULTITHREADED); + CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, __uuidof(IUIAutomation), (void**)&uia); + // Every UIA call is a round trip into the target app, and one app that does not answer stalls + // ALL tracking for the default timeouts (field 2026-10-04: GetFocusedElement blocked 3016 ms + // right after an app switch, and the next app's typing went unfollowed). Cap both waits: a slow + // app now costs at most half a second, and the 16 ms poll simply asks again. + if (uia) { + IUIAutomation2* uia2 = nullptr; + if (SUCCEEDED(uia->QueryInterface(__uuidof(IUIAutomation2), (void**)&uia2)) && uia2) { + uia2->put_ConnectionTimeout(500); + uia2->put_TransactionTimeout(500); + uia2->Release(); + } + } + if (uia) focusLookup = std::make_unique(uia); + if (uia) { fh = new FocusHandler(); if (FAILED(uia->AddFocusChangedEventHandler(nullptr, fh))) { fh->Release(); fh = nullptr; } } + h1 = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + h2 = SetWinEventHook(EVENT_SYSTEM_MENUPOPUPSTART, EVENT_SYSTEM_MENUPOPUPSTART, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + h3 = SetWinEventHook(EVENT_OBJECT_FOCUS, EVENT_OBJECT_FOCUS, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + retune(active_.load()); // an activation that raced thread start-up is picked up here +} + +void FocusRun::messageLoop(MSG& m) { while (GetMessageW(&m, nullptr, 0, 0) > 0) { if (m.message == kActiveMsg) { retune(m.wParam != 0); continue; } if (m.message == kWakeMsg) { @@ -704,6 +722,9 @@ void FocusTracker::run() { } TranslateMessage(&m); DispatchMessageW(&m); } +} + +void FocusRun::tearDown() { if (pollTimer) KillTimer(nullptr, pollTimer); for (HWINEVENTHOOK h : { h1, h2, h3, h4 }) if (h) UnhookWinEvent(h); if (uia && fh) uia->RemoveFocusChangedEventHandler(fh); @@ -711,6 +732,18 @@ void FocusTracker::run() { focusLookup.reset(); if (uia) uia->Release(); CoUninitialize(); +} + +void FocusTracker::run() { + // 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; } + FocusRun r(*this); + r.setUp(); + r.messageLoop(m); + r.tearDown(); tid_ = 0; } } // namespace wind diff --git a/src/focus_track.h b/src/focus_track.h index 313b0b73..754a2557 100644 --- a/src/focus_track.h +++ b/src/focus_track.h @@ -18,6 +18,7 @@ class FocusTracker { void run(); friend struct FocusTrackImpl; friend class FocusHandler; + friend struct FocusRun; // run()'s state and steps (focus_track.cpp) std::thread th_; std::atomic tid_{0}; std::atomic stopRequested_{false}; From 71d17e2b37386aaed5f63acd2d5dd8c62e19fddb Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:54:05 +0200 Subject: [PATCH 2/4] refactor(track): extract the Java bridge read from resolve into readJavaCaret (#477) Move-only: the java decision and the event-driven bridge read move verbatim; resolve calls it at the same point and keeps the result in the same const. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/focus_track.cpp | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/focus_track.cpp b/src/focus_track.cpp index cc66a27b..999ea81a 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -513,15 +513,9 @@ struct FocusRun { return ok; } - // Returns true when a focus change could not be answered (the focused-element lookup timed out - // or came back empty), so the caller keeps it pending and tries again (review 2026-10-09 M5: - // in slow UIA apps a Tab was otherwise lost for good, because the backstop poll never - // re-resolves a focus change). - bool resolve(bool focusChanged, bool fromPoll) { - if (!active_.load()) return false; - HWND fg = GetForegroundWindow(); - if (IsOwnOrTooltip(fg)) return false; - // Java app: the bridge is the only caret source (its Win32/UIA views have no caret). + // Java app: the bridge is the only caret source (its Win32/UIA views have no caret). True when the + // foreground window is a Java window with a bridge; the read lands in javaHave/javaCaret. + bool readJavaCaret(HWND fg, bool fromPoll) { const bool java = wantCaret_.load() && IsJavaWindow(fg) && jab.ensure(fg, tid_.load(), kWakeMsg, log_.load()); if (java) { if (fg != javaWnd) { javaWnd = fg; javaDirty = true; } @@ -545,6 +539,17 @@ struct FocusRun { javaCaret.right - javaCaret.left, javaCaret.bottom - javaCaret.top); } } + return java; + } + // Returns true when a focus change could not be answered (the focused-element lookup timed out + // or came back empty), so the caller keeps it pending and tries again (review 2026-10-09 M5: + // in slow UIA apps a Tab was otherwise lost for good, because the backstop poll never + // re-resolves a focus change). + bool resolve(bool focusChanged, bool fromPoll) { + if (!active_.load()) return false; + HWND fg = GetForegroundWindow(); + if (IsOwnOrTooltip(fg)) return false; + const bool java = readJavaCaret(fg, fromPoll); // Slow-resolve log (#365 diagnosis): a cross-process call that blocks stalls ALL tracking. const ULONGLONG rs0 = GetTickCount64(); IUIAutomationElement* el = nullptr; From 48ffb9184adf9657b30e46a2f5215820abfb70cc Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:54:35 +0200 Subject: [PATCH 3/4] refactor(track): extract the caret follow step from resolve into followCaret (#477) Move-only: step 2 of resolve (find the caret, slow-resolve log, trim/hold, baseline or publish) moves verbatim; resolve calls it at the same point, before releasing the focused element. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/focus_track.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 999ea81a..e00f7187 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -588,6 +588,13 @@ struct FocusRun { publish(TrackKind::Focus, b.left, b.top, b.right, b.bottom, "uia-focus"); } } + followCaret(el, b, fg, java, fromPoll, rs0, rs1); + if (el) el->Release(); + return focusUnanswered; + } + // el, b: the focused element and its bounds as resolve found them; rs0/rs1 time the lookup. + void followCaret(IUIAutomationElement* el, const RECT& b, HWND fg, bool java, bool fromPoll, + ULONGLONG rs0, ULONGLONG rs1) { // 2. The caret: published only when it moved within the same focus. if (wantCaret_.load()) { RECT rc{}; const char* src = ""; @@ -662,8 +669,6 @@ struct FocusRun { } } } - if (el) el->Release(); - return focusUnanswered; } }; From 3d8e56ea7d328508a3d1046b2f055199bb589916 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:54:50 +0200 Subject: [PATCH 4/4] docs(cursor): name the FocusRun steps behind FocusTracker::run (#477) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 1d3a2a49..05b6d497 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -320,6 +320,10 @@ the mouse hook only swallows clicks. The view can follow the text caret (`trackCaret`, default on) and keyboard focus (`trackFocus`, default off). `FocusTracker` (`src/focus_track.*`) runs on its own thread with WinEvents, `GetGUIThreadInfo` and UI Automation, and publishes a snapshot; the tick never calls UIA. +`FocusTracker::run` hands that thread to `FocusRun` (`focus_track.cpp`): `setUp` (COM, UIA timeouts, +`FocusLookup`, WinEvent hooks), `messageLoop` (wakes, 30 ms coalescing, the 16 ms backstop poll) and +`tearDown`. A wake calls `resolve`, which reads Java carets in `readJavaCaret`, follows focus, and +leaves the caret to `followCaret` (sources in `findCaret`: xterm input, Win32, UIA, MSAA fixes). - **Tracking never moves the pointer.** While caret or focus owns the view, the view is detached (`t.viewDetached`, weld off): `DetachedMap` (`src/detached_view.h`) maps the glided centre while