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
9 changes: 8 additions & 1 deletion docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,21 @@ 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`).
- **Java apps** (IntelliJ, PyCharm) expose the caret only through the Java Access Bridge
(`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
Expand Down
26 changes: 26 additions & 0 deletions src/focus_identity.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// src/focus_identity.h
// Which focus a caret baseline belongs to. Pure (no <windows.h>): 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
25 changes: 22 additions & 3 deletions src/focus_track.cpp
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions src/java_bridge.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#include "java_bridge.h"
#include "java_bridge_util.h"
#include "logging.h"
#include <shellscalingapi.h> // GetDpiForMonitor: Java caret user space to device px
#pragma comment(lib, "shcore.lib")
#include <softpub.h>
#include <wintrust.h>
#pragma comment(lib, "wintrust.lib")
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -230,21 +234,40 @@ 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()) {
// At the very end of the text there is no character under the caret: use the right edge
// 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;
Expand Down
5 changes: 5 additions & 0 deletions src/java_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::wstring> failedDirs_; // app folders with no usable bridge DLL (retried per folder)
bool enabledDone_ = false;
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 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"
26 changes: 26 additions & 0 deletions tests/test_focus_identity.cpp
Original file line number Diff line number Diff line change
@@ -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)));
}
Loading