From 4c822a3faa0533daaf99b5278639a1f703be7ad9 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:56:24 +0200 Subject: [PATCH] fix(track): keep following the caret through repeat focus events; Java caret via getCaretLocation (#365) A focus event for the control that already has focus (same foreground window, focus window and element bounds) no longer re-baselines the caret, so typing right after an app switch is followed. Java carets come from the bridge's getCaretLocation scaled by the monitor DPI; the character bounds gave x=2 for every Swing caret. trackLog names the bridge call and caret index. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/architecture/07-cursor.md | 9 ++++++++- src/focus_identity.h | 26 ++++++++++++++++++++++++++ src/focus_track.cpp | 25 ++++++++++++++++++++++--- src/java_bridge.cpp | 27 +++++++++++++++++++++++++-- src/java_bridge.h | 5 +++++ src/version.h | 4 ++-- tests/test_focus_identity.cpp | 26 ++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 src/focus_identity.h create mode 100644 tests/test_focus_identity.cpp diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index dda7416..f5d9d97 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -205,6 +205,11 @@ default off). `FocusTracker` (`src/focus_track.*`) runs on its own thread with W - **Only keyboard-driven caret moves are followed.** The first caret after a focus change is a baseline. After a click there is a 1 s quiet period, ended early by a fresh non-modifier key down (`src/typing_key.h`), never by key-ups, auto-repeat or Ctrl/Shift. +- **A focus event for the same control is not a focus change** (`src/focus_identity.h`). Notepad + and VS Code fire them while typing, about three per app switch, and each used to re-baseline, so + the first second of typing after a switch was not followed. Same foreground window, same focus + window and same element bounds keep the caret followed (`focus repeat` in trackLog). Zoom-in + clears the key, so it still only baselines. - Caret rects are corrected in `src/caret_rect.h` (tall Chromium rects trimmed to the line, a whole-line rect recognised). - The glide is a critically damped spring (`SpringToward`, `src/view_glide.h`, `trackGlideMs`). @@ -212,7 +217,9 @@ default off). `FocusTracker` (`src/focus_track.*`) runs on its own thread with W (`src/java_bridge.*`). UIPI drops the JVM's handshake to a UIAccess process, so the bridge's hidden windows get a narrow `ChangeWindowMessageFilterEx` allowance. Never poll the bridge (each read runs on the Java app's UI thread); read only after bridge callbacks. Load only - Authenticode-signed bridge DLLs. + Authenticode-signed bridge DLLs. Read the caret with `getCaretLocation`, scaled by the monitor + DPI (Java answers in its user space, device px / scale). The older character bounds + (`getAccessibleTextRect`) gave x=2 for every Swing caret and stay only as a fallback. - **Mouse edge mode** (`mouseAlign=1`, free-pointer sessions only): the pointer is unwelded and `EdgePanCenter` (`src/edge_pan.h`) moves the view only when the cursor's visible body leaves the margin band (`mouseMarginPct`). Edge-pinned motion is hidden from the lock detector diff --git a/src/focus_identity.h b/src/focus_identity.h new file mode 100644 index 0000000..e76136c --- /dev/null +++ b/src/focus_identity.h @@ -0,0 +1,26 @@ +// src/focus_identity.h +// Which focus a caret baseline belongs to. Pure (no ): handles travel as void*. +// +// Apps fire focus events for a control that already has focus (Notepad and VS Code while typing, +// field data 2026-10-04: about three per app switch). Each one used to re-baseline the caret, so +// the moves around it were swallowed and the view did not follow the first second of typing. A +// focus event for the SAME control (same foreground window, same focus window, same bounds) is not +// a focus change: the caret keeps being followed. A real change (Tab, a click into another field, +// an app switch) still differs in at least one of the three. +#pragma once + +namespace wind { + +struct FocusKey { + const void* fg = nullptr; // foreground window + const void* focus = nullptr; // its thread's focus window (GUITHREADINFO.hwndFocus) + long l = 0, t = 0, r = 0, b = 0; // the focused UI element's bounds + bool valid = false; +}; + +inline bool SameFocus(const FocusKey& a, const FocusKey& b) { + return a.valid && b.valid && a.fg == b.fg && a.focus == b.focus && + a.l == b.l && a.t == b.t && a.r == b.r && a.b == b.b; +} + +} // namespace wind diff --git a/src/focus_track.cpp b/src/focus_track.cpp index 70a52ce..7680852 100644 --- a/src/focus_track.cpp +++ b/src/focus_track.cpp @@ -1,4 +1,5 @@ #include "focus_track.h" +#include "focus_identity.h" // repeat focus events keep the caret followed #include "logging.h" #include "track_filter.h" #include "caret_rect.h" // trim tall UIA caret rects (#337) @@ -261,6 +262,10 @@ 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{}; + // The control the current baseline belongs to. A focus event for that same control is a + // repeat, not a change (focus_identity.h); an activation (zoom-in) clears it, so a zoom-in + // still only baselines. + wind::FocusKey baseKey; wind::CaretLineState caretLine; // one-line caret height in the current focus (#337) wind::CaretHoldState caretHold; // last caret line, for mid-scroll Enter reports (#337) @@ -365,8 +370,9 @@ void FocusTracker::run() { javaHave = jab.caret(fg, javaCaret); javaDirty = false; if (!javaHave) javaRetryAt = now + 250; - if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "java read %s (poll=%d events=%u): %ld,%ld %ldx%ld", - javaHave ? "ok" : "none", (int)fromPoll, javaEvents, javaCaret.left, javaCaret.top, + if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "java read %s via %s index=%d (poll=%d events=%u): %ld,%ld %ldx%ld", + javaHave ? "ok" : "none", jab.lastSrc(), jab.lastIndex(), + (int)fromPoll, javaEvents, javaCaret.left, javaCaret.top, javaCaret.right - javaCaret.left, javaCaret.bottom - javaCaret.top); } } @@ -396,6 +402,18 @@ void FocusTracker::run() { // #337: a caret rect that also spans blank lines above (Chromium web editors, both its // UIA and Win32 carets) is trimmed to one line at its bottom, the real caret line. The // line height is per focus; Java carets come from the bridge and are left alone. + wind::FocusKey key; + { + GUITHREADINFO gi{}; gi.cbSize = sizeof(gi); + GetGUIThreadInfo(GetWindowThreadProcessId(fg, nullptr), &gi); + key.fg = fg; key.focus = gi.hwndFocus; + key.l = b.left; key.t = b.top; key.r = b.right; key.b = b.bottom; + key.valid = fg != nullptr; + } + if (caretGen != focusGen && wind::SameFocus(key, baseKey)) { + caretGen = focusGen; // same control: keep following (no new baseline) + if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "focus repeat (same control): caret still followed"); + } if (caretGen != focusGen) { caretLine = wind::CaretLineState{}; caretHold = wind::CaretHoldState{}; } if (!java) { const LONG rawTop = rc.top; @@ -411,7 +429,7 @@ void FocusTracker::run() { rc.top = top; rc.bottom = bot; } if (caretGen != focusGen) { - caretGen = focusGen; lastCaret = rc; // baseline, not followed + caretGen = focusGen; lastCaret = rc; baseKey = key; // baseline, not followed 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; @@ -442,6 +460,7 @@ void FocusTracker::run() { } else if (m.wParam == 0) { pendingCaret = true; ++focusGen; // (re)activation: the caret found now is a baseline too + baseKey = wind::FocusKey{}; // never a "repeat" of a focus from before the zoom-in } else if (m.wParam == JavaBridge::kJavaFocus) { pendingFocus = true; javaDirty = true; ++javaEvents; ++focusGen; // a Java focus change: its caret is a baseline, like any other diff --git a/src/java_bridge.cpp b/src/java_bridge.cpp index 3697c4a..0287ba8 100644 --- a/src/java_bridge.cpp +++ b/src/java_bridge.cpp @@ -1,6 +1,8 @@ #include "java_bridge.h" #include "java_bridge_util.h" #include "logging.h" +#include // GetDpiForMonitor: Java caret user space to device px +#pragma comment(lib, "shcore.lib") #include #include #pragma comment(lib, "wintrust.lib") @@ -25,6 +27,7 @@ static JabIsJavaWindow s_isJava = nullptr; static JabContextWithFocus s_withFocus = nullptr; static JabTextInfoFn s_textInfo = nullptr; static JabTextRectFn s_textRect = nullptr; +static JabTextRectFn s_caretLoc = nullptr; // getCaretLocation: the caret itself (#365) static JabRelease s_release = nullptr; static DWORD s_wakeTid = 0; static UINT s_wakeMsg = 0; @@ -189,6 +192,7 @@ bool JavaBridge::ensure(HWND javaWindow, DWORD wakeTid, UINT wakeMsg, bool log) s_withFocus = (JabContextWithFocus)GetProcAddress(mod_, "getAccessibleContextWithFocus"); s_textInfo = (JabTextInfoFn)GetProcAddress(mod_, "getAccessibleTextInfo"); s_textRect = (JabTextRectFn)GetProcAddress(mod_, "getAccessibleTextRect"); + s_caretLoc = (JabTextRectFn)GetProcAddress(mod_, "getCaretLocation"); s_release = (JabRelease)GetProcAddress(mod_, "releaseJavaObject"); auto setCaret = (JabSetEventFP)GetProcAddress(mod_, "setCaretUpdateFP"); auto setFocus = (JabSetEventFP)GetProcAddress(mod_, "setFocusGainedFP"); @@ -230,13 +234,30 @@ bool JavaBridge::caret(HWND javaWindow, RECT& out) { // not responding, skip it rather than block the tracker thread (and with it all other tracking). if (IsHungAppWindow(javaWindow) || !s_isJava(javaWindow)) return false; long vm = 0; JOBJECT64 ac = 0; - if (!s_withFocus(javaWindow, &vm, &ac) || !ac) return false; // ac 0 = no focus owner (inactive app) + if (!s_withFocus(javaWindow, &vm, &ac) || !ac) { lastSrc_ = "no-focus-owner"; return false; } // inactive app bool ok = false; + lastSrc_ = "no-text"; // until a read below answers JabTextInfo ti{ -1, -1, -1 }; if (s_textInfo(vm, ac, &ti, 0, 0) && ti.caretIndex >= 0 && ti.caretIndex <= ti.charCount) { JabTextRect r{ 0, 0, 0, 0 }; auto good = [&] { return r.height > 0 && r.height < 4096 && r.width >= 0; }; - if (s_textRect(vm, ac, &r, ti.caretIndex) && good()) { + // The caret's own location first (#365): the bounds of the character AT the caret came + // back wrong from Swing text areas (x pinned at 2, y at the top or bottom of the view, + // field 2026-10-04). Character bounds stay the fallback for bridges without it. + if (s_caretLoc && s_caretLoc(vm, ac, &r, ti.caretIndex) && good()) { + // Java answers in its user space: device px divided by the monitor scale (at 225%, + // x=59 for a caret at device x=134, field 2026-10-04). A DPI-unaware Java app is + // stretched by the same monitor scale, so the monitor DPI converts both. + UINT dx = 96, dy = 96; + GetDpiForMonitor(MonitorFromWindow(javaWindow, MONITOR_DEFAULTTONEAREST), MDT_EFFECTIVE_DPI, &dx, &dy); + const double s = dx / 96.0; + auto px = [s](int v) { return (LONG)(v * s + (v >= 0 ? 0.5 : -0.5)); }; + const LONG w = px(r.width) > 2 ? px(r.width) : 2; + out = { px(r.x), px(r.y), px(r.x) + w, px(r.y + r.height) }; + ok = true; + lastSrc_ = "caretloc"; + } else if (s_textRect(vm, ac, &r, ti.caretIndex) && good()) { + lastSrc_ = "charrect"; out = { r.x, r.y, r.x + (r.width > 1 ? r.width : 2), r.y + r.height }; ok = true; } else if (ti.caretIndex > 0 && s_textRect(vm, ac, &r, ti.caretIndex - 1) && good()) { @@ -244,7 +265,9 @@ bool JavaBridge::caret(HWND javaWindow, RECT& out) { // of the one before it. out = { r.x + r.width, r.y, r.x + r.width + 2, r.y + r.height }; ok = true; + lastSrc_ = "charrect-prev"; } + lastIndex_ = ti.caretIndex; } s_release(vm, ac); return ok; diff --git a/src/java_bridge.h b/src/java_bridge.h index 160332b..af9c473 100644 --- a/src/java_bridge.h +++ b/src/java_bridge.h @@ -22,7 +22,12 @@ class JavaBridge { // The caret of the focused Java text component, screen px. False when there is none. bool caret(HWND javaWindow, RECT& out); bool loaded() const { return mod_ != nullptr; } + // The last successful read: which bridge call answered and the caret index (trackLog). + const char* lastSrc() const { return lastSrc_; } + int lastIndex() const { return lastIndex_; } private: + const char* lastSrc_ = ""; + int lastIndex_ = -1; HMODULE mod_ = nullptr; std::set failedDirs_; // app folders with no usable bridge DLL (retried per folder) bool enabledDone_ = false; diff --git a/src/version.h b/src/version.h index 2c5cfd0..601e995 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 23 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.23.1" +#define WIND_VERSION_STR "0.23.2" diff --git a/tests/test_focus_identity.cpp b/tests/test_focus_identity.cpp new file mode 100644 index 0000000..d55e1f2 --- /dev/null +++ b/tests/test_focus_identity.cpp @@ -0,0 +1,26 @@ +// tests/test_focus_identity.cpp - repeat focus events for the same control keep caret following. +#include "doctest.h" +#include "../src/focus_identity.h" +using namespace wind; + +static FocusKey Key(int fg, int focus, long l, long t, long r, long b) { + FocusKey k; k.fg = (const void*)(long long)fg; k.focus = (const void*)(long long)focus; + k.l = l; k.t = t; k.r = r; k.b = b; k.valid = true; return k; +} + +TEST_CASE("SameFocus: a repeat event for the same control") { + CHECK(SameFocus(Key(1, 2, 0, 100, 3840, 2000), Key(1, 2, 0, 100, 3840, 2000))); +} + +TEST_CASE("SameFocus: any real change is a new focus") { + const FocusKey a = Key(1, 2, 0, 100, 3840, 2000); + CHECK_FALSE(SameFocus(a, Key(9, 2, 0, 100, 3840, 2000))); // app switch + CHECK_FALSE(SameFocus(a, Key(1, 3, 0, 100, 3840, 2000))); // another focus window + CHECK_FALSE(SameFocus(a, Key(1, 2, 0, 140, 3840, 2000))); // another field in the same window +} + +TEST_CASE("SameFocus: an unknown key never matches (zoom-in, start-up)") { + FocusKey none; + CHECK_FALSE(SameFocus(none, none)); + CHECK_FALSE(SameFocus(none, Key(1, 2, 0, 0, 1, 1))); +}