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
14 changes: 14 additions & 0 deletions docs/TRACKING-FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,17 @@ highlighted option through UIA, so Wind follows only the terminal caret there.
cursor's visible body (opaque bounds around the hotspot, re-measured on cursor change).
- **Margin.** Edge mode has its own `mouseMarginPct` (default 0: the cursor reaches the view edge
before the view moves; Settings slider 0-30%). `trackMarginPct` (15%) stays the caret/focus one.

## Firefox in a zoomed iframe (issue #278, 2026-09-29)

- A Claude artifact (an iframe) at high Ctrl+ page zoom in Zen: Firefox reports the caret wrongly
from BOTH sources. The Win32 caret sits below and right of the input (input 2226,997 798x74,
caret 3097,1226 1x118); the UIA caret range and the element bounds point above the real text.
Not reproducible on a plain page at the same zoom. There is no correct source to fall back to.
- Fix: in Gecko windows (`MozillaWindowClass`, Firefox and all forks) a caret whose centre is outside
its own element is skipped, so the view stays put. Limited to Gecko so no app that tracked
correctly before can lose tracking. A UIA fallback was tried and field-rejected (it lands above).
- Leaving a text box moves focus to the whole page (3400x1912); centring on it dropped the view.
Focus rects covering half the monitor or more are containers and are skipped (all apps).
- Diagnostic tool from this hunt: a recorder logging the foreground process, Win32 caret, UIA caret
and focus bounds every 100 ms; the Win32/UIA disagreement is what located the bad source.
36 changes: 32 additions & 4 deletions src/focus_track.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "focus_track.h"
#include "logging.h"
#include "track_filter.h"
#include <windows.h>
#include <objbase.h>
#include <oleacc.h>
Expand Down Expand Up @@ -45,6 +46,12 @@ static bool IsOwnOrTooltip(HWND h) {
return wcscmp(cls, L"tooltips_class32") == 0 || wcscmp(cls, L"Xaml_WindowedPopupClass") == 0;
}

// A Gecko browser window (Firefox and every fork share this class).
static bool IsGeckoWindow(HWND h) {
wchar_t cls[64] = {}; GetClassNameW(h, cls, 64);
return wcscmp(cls, L"MozillaWindowClass") == 0;
}

// Classic Win32 caret of the foreground thread, in screen px. False when there is none.
static bool Win32Caret(RECT& out) {
HWND fg = GetForegroundWindow();
Expand Down Expand Up @@ -181,11 +188,21 @@ void FocusTracker::run() {
if (IsOwnOrTooltip(fg)) return;
IUIAutomationElement* el = nullptr;
if (uia) uia->GetFocusedElement(&el);
RECT b{}; // the focused element's bounds (empty = unknown)
if (el && FAILED(el->get_CurrentBoundingRectangle(&b))) b = RECT{};
// 1. A focus change: follow the focused control (if wanted). Its caret becomes the baseline.
if (focusChanged && wantFocus_.load() && el) {
RECT b{};
if (SUCCEEDED(el->get_CurrentBoundingRectangle(&b)) && b.right > b.left && b.bottom > b.top)
// A container-sized focus (the page after leaving a text box, a pane, the window) is not
// something the user moved to, so it is skipped (issue #278).
if (focusChanged && wantFocus_.load() && el && b.right > b.left && b.bottom > b.top) {
MONITORINFO mi{ sizeof(mi) };
const bool haveMon = GetMonitorInfoW(MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST), &mi) != 0;
if (haveMon && IsContainerFocus({ b.left, b.top, b.right, b.bottom },
mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom)) {
if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "focus skipped (container): %ld,%ld %ldx%ld",
b.left, b.top, b.right - b.left, b.bottom - b.top);
} else {
publish(TrackKind::Focus, b.left, b.top, b.right, b.bottom, "uia-focus");
}
}
// 2. The caret: published only when it moved within the same focus.
if (wantCaret_.load()) {
Expand All @@ -196,7 +213,18 @@ void FocusTracker::run() {
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;
publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src);
// In a Gecko browser (Firefox, Zen, LibreWolf...: one window class), a caret
// outside its own element is a bad report, not a place to look (a zoomed iframe,
// issue #278; its UIA caret was wrong too, so there is no source to fall back
// to): the view stays where it is. Other apps are unchanged, so no case that
// worked before can lose tracking to this rule.
if (IsGeckoWindow(fg) &&
!CaretInsideElement({ rc.left, rc.top, rc.right, rc.bottom }, { b.left, b.top, b.right, b.bottom })) {
if (log_.load()) wind::Log(wind::LogLevel::Info, "track", "caret skipped (outside its element %ld,%ld %ldx%ld) via %s: %ld,%ld",
b.left, b.top, b.right - b.left, b.bottom - b.top, src, rc.left, rc.top);
} else {
publish(TrackKind::Caret, rc.left, rc.top, rc.right, rc.bottom, src);
}
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions src/track_filter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#pragma once
// Sanity filters for what the focus/caret watcher reports (issue #278). Pure; tests/test_track_filter.cpp.
namespace wind {

struct TrackBox { long l, t, r, b; };

// Is the caret where its own focused element is? Firefox inside a zoomed iframe (a Claude artifact at
// high Ctrl+ page zoom, field 2026-09-29) reports the caret through BOTH GetGUIThreadInfo and UIA well
// outside the focused input (input 2226,997 798x74, caret 3097,1226 1x118), so following it showed
// empty page. A caret whose centre lies outside the element (plus a small slack for borders and
// padding) is not trusted. An empty element rect (unknown) trusts the caret.
inline bool CaretInsideElement(const TrackBox& caret, const TrackBox& elem, long slack = 8) {
if (elem.r <= elem.l || elem.b <= elem.t) return true;
const long cx = (caret.l + caret.r) / 2, cy = (caret.t + caret.b) / 2;
return cx >= elem.l - slack && cx <= elem.r + slack && cy >= elem.t - slack && cy <= elem.b + slack;
}

// Is a focus rect a CONTAINER (the page, a pane, the window) rather than a control someone moved to?
// Leaving a text area moves focus to the whole document, and centring on it dropped the view to the
// middle of the page (field 2026-09-29: 3400x1912 on a 3840x2160 monitor). Half the monitor's area,
// counting only the part on the monitor, is the line.
inline bool IsContainerFocus(const TrackBox& rc, long monL, long monT, long monR, long monB) {
const long l = rc.l > monL ? rc.l : monL, r = rc.r < monR ? rc.r : monR;
const long t = rc.t > monT ? rc.t : monT, b = rc.b < monB ? rc.b : monB;
if (r <= l || b <= t) return false;
const double area = (double)(r - l) * (double)(b - t);
const double mon = (double)(monR - monL) * (double)(monB - monT);
return mon > 0 && area >= 0.5 * mon;
}
} // namespace wind
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 10
#define WIND_VER_PATCH 3
#define WIND_VER_PATCH 4

// String form for logs/snapshot/UI. Keep in sync with the numeric parts above.
#define WIND_VERSION_STR "0.10.3"
#define WIND_VERSION_STR "0.10.4"
20 changes: 20 additions & 0 deletions tests/test_track_filter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#include "doctest.h"
#include "../src/track_filter.h"
using namespace wind;

TEST_CASE("caret inside its element is trusted; the field-recorded Firefox iframe caret is not") {
const TrackBox input{ 2226, 997, 3024, 1071 };
CHECK(CaretInsideElement({ 2453, 991, 2478, 1077 }, input)); // the good reading
CHECK_FALSE(CaretInsideElement({ 3097, 1226, 3098, 1344 }, input)); // below and right of it
CHECK(CaretInsideElement({ 3020, 1000, 3022, 1068 }, input)); // at the right end, inside
CHECK(CaretInsideElement({ 5, 5, 6, 20 }, { 0, 0, 0, 0 })); // unknown element: trust
}
TEST_CASE("terminal caret beside its cell stays trusted") {
CHECK(CaretInsideElement({ 332, 1692, 343, 1737 }, { 322, 1692, 346, 1737 }));
}
TEST_CASE("page-sized focus is a container; controls and huge zoomed inputs partly off-screen are not") {
CHECK(IsContainerFocus({ 422, 122, 3822, 2034 }, 0, 0, 3840, 2160)); // the document
CHECK_FALSE(IsContainerFocus({ 2226, 997, 3024, 1071 }, 0, 0, 3840, 2160));
CHECK_FALSE(IsContainerFocus({ 902, 912, 6146, 2106 }, 0, 0, 3840, 2160)); // 5244x1194, 41% on screen
CHECK_FALSE(IsContainerFocus({ 5000, 0, 6000, 100 }, 0, 0, 3840, 2160)); // off the monitor
}
Loading