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
69 changes: 52 additions & 17 deletions src/track/caret_rect.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,45 +139,80 @@ 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).
// The report currently being suppressed as a ghost. It is NOT confirmed by time any more: review
// 2026-10-09 #56 had a held report become the caret after 300 ms, but Discord's ghost is the caret
// after a TRAILING SPACE (Chromium draws it at the line's right end) and it stays reported for as
// long as the user pauses after that space (field 2026-11-06), so every pause jumped the view to
// the end of the box. The real caret then is one space past the last one followed, so the view
// staying put is right; the next key brings a real report.
bool holding = false;
int hLeft = 0, hTop = 0, hBottom = 0;
long long since = 0;
// A ghost PROVEN transient (the real caret came back after it) in this focus. Discord reports
// the same right-edge rect after many keystrokes, not only at a wrap (field 2026-11-06: 2921,1735
// 2x46 while typing at 2466..2879 in 2x49): a pause after one of them confirmed it as the caret
// and the view jumped to the end of the box, and the next ones sat too close to that new baseline
// to read as ghosts, so the view swung between the text and the box end. A known ghost is always
// skipped and never confirmed. Reset with the state on a focus change.
bool known = false;
int kLeft = 0, kTop = 0, kBottom = 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)

// A known ghost is matched by its x and height on ANY line: once the message wraps, Discord's box
// grows and the ghost moves to another line (field 2026-11-06: 2921,1735 2x46, then 2921,1686 2x45
// one line up while typing at y 1733 in 49 px lines). The real caret at that x keeps its own height.
inline bool IsKnownGhost(int left, int top, int bottom, const CaretGhostState& s) {
if (!s.known) return false;
const int dx = left - s.kLeft, dh = (bottom - top) - (s.kBottom - s.kTop);
// Height within 1 px: the ghost was 45-46 px, a real caret 48-49 px in the same lines.
return (dx < 0 ? -dx : dx) <= kGhostBoxPx && (dh < 0 ? -dh : dh) <= 1;
}

inline bool IsLineEndGhost(int left, int top, int bottom, const CaretGhostState& s) {
if (IsKnownGhost(left, top, bottom, s)) return true;
if (!s.have || s.h <= 0) return false;
const int h = bottom - top;
const int dTop = top - s.top, dH = h - s.h;
const bool sameLine = (dTop < 0 ? -dTop : dTop) < s.h / 2;
const bool otherBox = (dTop < 0 ? -dTop : dTop) >= kGhostBoxPx || (dH < 0 ? -dH : dH) >= kGhostBoxPx;
return sameLine && otherBox && left - s.left > s.h * kGhostJumpLines;
const int adTop = dTop < 0 ? -dTop : dTop, adH = dH < 0 ? -dH : dH;
const bool sameLine = adTop < s.h / 2;
const bool otherBox = adTop >= kGhostBoxPx || adH >= kGhostBoxPx;
// The line above or below (a wrapped message, see IsKnownGhost) counts only with a different
// HEIGHT: Up or Down to another line keeps the caret's own height.
const bool nextLine = !sameLine && adTop <= s.h * 3 / 2 && adH >= kGhostBoxPx;
return ((sameLine && otherBox) || nextLine) && left - s.left > s.h * kGhostJumpLines;
}

// A UIA caret whose height is off the line's (Discord's ghost is 45-46 px in 48-49 px lines, field
// 2026-11-06) is suspect: the caller asks MSAA for a second opinion before following it.
inline bool IsOffLineHeight(int top, int bottom, int lineH) {
const int d = (bottom - top) - lineH;
return lineH > 0 && (d < 0 ? -d : d) >= kGhostBoxPx;
}

// Remember a caret that was followed (never a ghost, so a held ghost stays a ghost).
// MSAA put the caret elsewhere than a suspect UIA report far to its right: that report is a ghost,
// remembered so it is skipped on any line even when MSAA has nothing to say next time.
inline void NoteKnownGhost(int left, int top, int bottom, CaretGhostState& s) {
s.known = true; s.kLeft = left; s.kTop = top; s.kBottom = bottom;
}

// Remember a caret that was followed (never a ghost, so a held ghost stays a ghost). A real caret
// replacing a held ghost proves that ghost transient: it is remembered as a known ghost.
inline void NoteFollowedCaret(int left, int top, int bottom, CaretGhostState& s) {
if (s.holding && !(s.hLeft == left && s.hTop == top && s.hBottom == bottom)) {
s.known = true; s.kLeft = s.hLeft; s.kTop = s.hTop; s.kBottom = s.hBottom;
}
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) {
inline void NoteGhostHeld(int left, int top, int bottom, 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;
s.holding = true; s.hLeft = left; s.hTop = top; s.hBottom = bottom;
}

// 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;
}

// Terminal TUIs park the cursor (field: Wind for Mac, Claude Code in Terminal.app, 2026-11). Claude
// Code hides the terminal cursor and draws its own; while it redraws its prompt it leaves the real
Expand Down
31 changes: 23 additions & 8 deletions src/track/focus_track.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,28 @@ struct FocusRun {
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{}; caretGhost = wind::CaretGhostState{}; }
// Chromium's UIA selection puts the caret after a trailing space, or near the end of a
// line about to wrap, at the line's right end (#387 ghosts; Discord field 2026-11-06:
// for most letters in the last ~380 px of a line it reported only that ghost, so the
// view moved once per word). Its MSAA system caret had every letter right, with no
// ghost. So when UIA reports a ghost, ask MSAA and follow it instead; only when MSAA
// has nothing better is the ghost skipped below.
// A wrapped message moves the ghost up a line per line of text, and close to the box
// end it is too near to look like a jump, but its height is always off the line's.
if (!java && src[0] == 'u' &&
(wind::IsLineEndGhost((int)rc.left, (int)rc.top, (int)rc.bottom, caretGhost) ||
wind::IsOffLineHeight((int)rc.top, (int)rc.bottom, caretLine.lineH))) {
RECT m{};
if (MsaaCaret(m) && !EqualRect(&m, &rc) &&
!wind::IsLineEndGhost((int)m.left, (int)m.top, (int)m.bottom, caretGhost)) {
if (log_.load() && !EqualRect(&m, &lastCaret))
wind::Log(wind::LogLevel::Info, "track", "uia suspect %ld,%ld %ldx%ld: msaa caret %ld,%ld instead",
rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top, m.left, m.top);
if (rc.left - m.left > (m.bottom - m.top) * wind::kGhostJumpLines)
wind::NoteKnownGhost((int)rc.left, (int)rc.top, (int)rc.bottom, caretGhost);
rc = m; src = "msaa";
}
}
if (!java) {
const LONG rawTop = rc.top;
int top = (int)rc.top;
Expand Down Expand Up @@ -763,18 +785,11 @@ struct FocusRun {
// 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);
wind::NoteGhostHeld((int)rc.left, (int)rc.top, (int)rc.bottom, 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
63 changes: 53 additions & 10 deletions tests/test_caret_rect.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,60 @@ TEST_CASE("caret ghost: End and a click far right keep the caret's box, so they
CHECK_FALSE(wind::IsLineEndGhost(3375, 1942, 1996, none)); // nothing followed yet
}

TEST_CASE("caret ghost: a suppressed report that persists is confirmed as the caret (review #56)") {
TEST_CASE("caret ghost: a held ghost is never confirmed by time; a pause after a trailing space stays put (2026-11-06)") {
// Discord's ghost is the caret after a trailing space, reported for as long as the user pauses.
wind::CaretGhostState s;
wind::NoteFollowedCaret(1000, 1944, 1993, s);
CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 5000, s)); // nothing held yet
wind::NoteGhostHeld(3375, 1942, 1996, 1000, s);
CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 1100, s)); // too fresh: still a ghost
wind::NoteGhostHeld(3375, 1942, 1996, 1200, s); // same rect again keeps the first stamp
CHECK(wind::GhostHoldExpired(3375, 1942, 1996, 1000 + wind::kGhostConfirmMs, s));
CHECK_FALSE(wind::GhostHoldExpired(3376, 1942, 1996, 9000, s)); // a different report is not the held one
wind::NoteFollowedCaret(3375, 1942, 1996, s); // followed: the hold is cleared
CHECK_FALSE(wind::GhostHoldExpired(3375, 1942, 1996, 9000, s));
wind::NoteFollowedCaret(2543, 1733, 1782, s);
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s)); // first one in this focus
wind::NoteGhostHeld(2921, 1735, 1781, s);
wind::NoteGhostHeld(2921, 1735, 1781, s); // still reported seconds later
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s)); // still a ghost: no jump
CHECK_FALSE(s.known); // not proven yet
wind::NoteFollowedCaret(2405, 1733, 1782, s); // typing again: the real caret
CHECK(s.known);
}

TEST_CASE("caret ghost: a ghost the real caret replaced is always skipped, even close by (Discord 2026-11-06)") {
// Field trace: typing at 2466..2879 (2x49 at y 1733), Discord reported 2921,1735 2x46 after spaces.
wind::CaretGhostState s;
wind::NoteFollowedCaret(2533, 1733, 1782, s);
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s)); // 388 px right in a new box
wind::NoteGhostHeld(2921, 1735, 1781, s);
wind::NoteFollowedCaret(2573, 1733, 1782, s); // the next key: the real caret is back
CHECK(s.known);
wind::NoteFollowedCaret(2879, 1733, 1782, s); // typing close to the box end
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s)); // only 42 px right, still skipped
CHECK_FALSE(wind::IsLineEndGhost(2921, 1733, 1782, s)); // the real caret there keeps its own box
wind::NoteFollowedCaret(2921, 1735, 1781, s); // a following of the same rect never unlearns it
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s));
}

TEST_CASE("caret ghost: in a wrapped message the ghost sits a line up and is still skipped (Discord 2026-11-06)") {
wind::CaretGhostState s;
wind::NoteFollowedCaret(2562, 1733, 1782, s); // 2x49, typing on the second line
CHECK(wind::IsLineEndGhost(2921, 1686, 1731, s)); // 2x45 one line up, at the box end
CHECK_FALSE(wind::IsLineEndGhost(2600, 1684, 1733, s)); // Up to the line above keeps the height
CHECK_FALSE(wind::IsLineEndGhost(2921, 1684, 1733, s)); // End there too
wind::NoteGhostHeld(2921, 1686, 1731, s);
wind::NoteFollowedCaret(2668, 1733, 1782, s);
CHECK(s.known);
wind::NoteFollowedCaret(2900, 1733, 1782, s); // close to the box end now
CHECK(wind::IsLineEndGhost(2921, 1686, 1731, s)); // known: skipped on any line
CHECK(wind::IsLineEndGhost(2921, 1735, 1781, s)); // 1 px taller on the typing line, same ghost
CHECK_FALSE(wind::IsLineEndGhost(2921, 1733, 1782, s)); // a real caret there (49 px) is followed
}

TEST_CASE("caret ghost: an off-height UIA report is suspect; a ghost MSAA contradicts is skipped on any line (2026-11-06)") {
CHECK(wind::IsOffLineHeight(1636, 1682, 49)); // 46 px in 49 px lines
CHECK(wind::IsOffLineHeight(1686, 1731, 48)); // 45 px in 48 px lines
CHECK_FALSE(wind::IsOffLineHeight(1733, 1782, 48)); // 49 vs 48: the same line
CHECK_FALSE(wind::IsOffLineHeight(1733, 1782, 0)); // no line known yet
wind::CaretGhostState s;
wind::NoteFollowedCaret(2547, 1735, 1783, s);
wind::NoteKnownGhost(2921, 1636, 1682, s); // two lines up, MSAA said 2547
CHECK(wind::IsLineEndGhost(2921, 1636, 1682, s));
CHECK(wind::IsLineEndGhost(2921, 1686, 1731, s)); // a line lower, 1 px shorter: the same ghost
CHECK_FALSE(wind::IsLineEndGhost(2921, 1735, 1783, s)); // End: the caret's own box (MSAA arbitrates a UIA one)
}

TEST_CASE("selection rect: the last line's rectangle is used so Shift+arrow growth is followed (review #64)") {
Expand Down
Loading