diff --git a/src/track/caret_rect.h b/src/track/caret_rect.h index c960a165..1fc3c37d 100644 --- a/src/track/caret_rect.h +++ b/src/track/caret_rect.h @@ -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 diff --git a/src/track/focus_track.cpp b/src/track/focus_track.cpp index 0da9cfed..ce1addd8 100644 --- a/src/track/focus_track.cpp +++ b/src/track/focus_track.cpp @@ -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; @@ -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); } } } diff --git a/tests/test_caret_rect.cpp b/tests/test_caret_rect.cpp index dd21c6d3..4a0dab97 100644 --- a/tests/test_caret_rect.cpp +++ b/tests/test_caret_rect.cpp @@ -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)") {