Keep ghost text perfectly still through word accepts (TextEdit jitter)#690
Conversation
Accepting a word in TextEdit sometimes jumped the remaining ghost left by the accepted word's width and snapped it back right one poll tick later. Root cause: the +30ms post-insertion refresh can read AX before the host publishes the synthetic insert. The session reconciler tolerates that lag, but presentation discarded the fact and ran the stability gate against the pre-insertion caret; a full word of drift blows past the 6pt tolerance, so the gate re-anchored the overlay onto the just-accepted text, and the post-publish poll bounced it back. Two changes, one per motion source: - The stability gate now holds geometry outright while the interaction state is awaiting the post-insertion AX sync (same field, same text). Stale pre-publish geometry is never worth re-anchoring to, and the hold lifts on the exact tick the publish lands. - The accept-time slide and the predicted-caret fallback now measure the caret's true travel with the field's own resolved font instead of the ghost render font (floored at 14pt) or a fixed system-14 approximation. Helvetica-12 hosts were overshooting 5pt per word and Menlo-11 hosts 11pt, accumulating past the gate tolerance and surfacing as a sideways nudge with no input in flight. With the travel measured in the host's font, the anchor matches the published caret within kerning noise and post-accept reconciles have nothing left to correct.
| guard width.isFinite, width > 0 else { | ||
| return nil |
There was a problem hiding this comment.
width > 0 guard is redundant given NSString.size advance-width semantics
NSString.size(withAttributes:) returns the typographic advance width of the string, which is positive for any non-empty string including pure whitespace (confirmed: a single space returns its glyph advance, not 0). The guard width > 0 cannot fire for any input that passed the !text.isEmpty check, so it is dead code in practice. Removing it would make the boundary condition explicit in the guard above (!text.isEmpty) and remove a subtle implication that whitespace might somehow return zero. Separately, the width.isFinite check is still useful for pathological custom fonts.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
Accepting a word in TextEdit sometimes jumped the remaining ghost text LEFT by roughly the accepted word's width, then snapped it back RIGHT a few dozen milliseconds later. A four-way investigation (full pipeline trace, overlay-layer audit, reconcile/prediction math with measured font widths, and structured-log mining) converged on two motion sources:
SuggestionOverlayStabilityGateagainst the stale caret: a full word of "drift" blows past the 6pt tolerance, so the gate re-anchored the ghost onto the just-accepted text (left), and the next 80ms poll re-anchored it onto the published caret (right). The gate never consultedisAwaitingPostInsertionSync, the precise signal that the geometry is suspect.The fix addresses each at its source:
SuggestionOverlayStabilityGate.shouldRePresentgainsisAwaitingPostInsertionSyncand holds geometry outright while the host has not published the insert (same field, same text). The hold lifts on the exact reconcile tick that observes the publish, because the interaction state clears the sentinel before presentation reads it. Field switches and text changes still re-anchor mid-window.InsertedTextAdvancemeasures the caret's true travel for inserted text using the field's own resolved font.advanceInlineuses it for trusted.exactgeometry (web/derived hosts keep the existing pixel-identical ghost-width slide and theirobservedCharWidthmachinery), andpredictedCaretRect's fallback prefers it over the system-14 approximation. With the anchor matching the published caret within kerning noise, post-accept reconciles have nothing left to correct: any residual few-point tail shift now happens at the accept keystroke itself, where the text is visibly changing anyway, and nothing moves afterward.Validation
New regression locks:
InsertedTextAdvance: host-face/size measurement, the documented 12pt-vs-14pt per-word delta (>3pt), leading-space travel, unknown-face fallback at the host's size, and unusable-input refusals.predictedCaretRectprefers the host font over the system-14 fallback.Not verified live in a TextEdit typing session (the logs show TextEdit has never run under
-cotabby-debugon this machine); the mechanism is pinned by code-level tracing with measured font arithmetic, and both motion sources are locked by differential tests that fail on the previous behavior.Linked issues
None filed; reported directly ("fragile... jitters left then jitters back to the right after accepting a word" in TextEdit).
Risk / rollout notes
.exactcaret quality, so Gmail/Outlook-class derived hosts keep today's slide behavior bit-for-bit.advanceInlinegained aninsertedTextparameter (protocol + default); the only behavioral callers are word-accept and type-through advancement.Greptile Summary
This PR eliminates two sources of ghost-text jitter that triggered when accepting a word in TextEdit (and similar apps): a timing-race where the +30ms post-insertion AX refresh reads the pre-insertion caret and re-anchors the overlay left, and a systematic font-size mismatch where the ghost render font (floored at 14pt) measured accepted text wider than the host's 12pt Helvetica, accumulating anchor error past the stability gate's tolerance.
SuggestionOverlayStabilityGate.shouldRePresentgains anisAwaitingPostInsertionSyncshort-circuit that holds geometry for the same field and text while the host has not yet published the synthetic insert; field switches and text changes still re-anchor immediately through checks that remain ahead of the new guard.InsertedTextAdvanceutility measures the host field's true caret travel using its AX-resolved font;advanceInlineuses it for.exact-quality geometry (the pixel-correct slide), andpredictedCaretRect's fallback prefers it over the previous system-14 approximation.observedCharWidthhosts are explicitly kept on their existing paths, so only the targeted TextEdit-class scenario changes behavior.Confidence Score: 4/5
Safe to merge; the changes are tightly scoped, well-tested, and fallback paths keep existing host types on their previous behavior.
Both motion sources are addressed at their actual root cause, test coverage is thorough across unit and integration levels, and the behavioral gating correctly limits the new code to the intended case. The only gaps are that the integration test exercises the ghost-font fallback rather than the new host-font slide, and a width > 0 guard is dead code — neither affects correctness.
CotabbyTests/SuggestionCoordinatorPredictionTests.swift — the integration test would be more complete with a resolvedFieldStyle-bearing overlay geometry so the host-font slide path is exercised end-to-end.
Important Files Changed
Sequence Diagram
sequenceDiagram participant U as User (Tab accept) participant SC as SuggestionCoordinator participant IS as InteractionState participant OC as OverlayController participant ITA as InsertedTextAdvance participant SOSG as StabilityGate participant AX as Accessibility (AX) U->>SC: acceptCurrentSuggestion() SC->>IS: "arm isAwaitingPostInsertionSync = true" SC->>OC: advanceInline(to: ' again', insertedText: ' world') OC->>ITA: width(of: ' world', style: resolvedFieldStyle) ITA-->>OC: host-font advance (e.g. 34pt for Helvetica 12) OC->>OC: "shift = hostAdvance (not ghost-font diff)" OC->>OC: slide panel right by shift, update state Note over SC,AX: +30ms post-insertion refresh (AX may not have published insert yet) SC->>AX: snapshot() AX-->>SC: pre-insertion caret (left of correct position) SC->>SOSG: shouldRePresent(..., isAwaitingPostInsertionSync: true) SOSG-->>SC: false (hold - same field, same text, sentinel armed) Note over OC: overlay stays perfectly still Note over SC,AX: Next poll - AX publishes the insert SC->>AX: snapshot() AX-->>SC: post-insertion caret (correct position) SC->>IS: reconcile clears isAwaitingPostInsertionSync SC->>SOSG: shouldRePresent(..., isAwaitingPostInsertionSync: false) SOSG-->>SC: "false if drift < 6pt, true only if genuine drift"Comments Outside Diff (1)
CotabbyTests/SuggestionCoordinatorPredictionTests.swift, line 475 (link)CotabbyTestFixtures.overlayGeometry()setscaretQuality: .exactbut leavesresolvedFieldStyle: nil(it has no parameter for it). InadvanceInline, the new host-font branch requires bothgeometry.caretQuality == .exactAND a non-nilInsertedTextAdvance.width, which itself requires a non-nilstyle. Withstyle == nil,InsertedTextAdvance.widthreturnsnil, so the slide falls back to the ghost-font diff path — the old behavior rather than the new fix.The test still correctly validates the stability-gate hold (no re-present during the sync window), but it doesn't exercise the
InsertedTextAdvance-driven slide that is the core of fix Explore contextual awareness for Use My Instructions mode #2. TheInsertedTextAdvanceTestscover the measurement path in isolation, andtest_predictedCaretRect_prefersTheHostFontOverTheSystemFallbackcovers the prediction fallback, so coverage is ultimately adequate — but anoverlayGeometry(resolvedFieldStyle: ...)variant here would lock the full new slide path end-to-end.Reviews (1): Last reviewed commit: "fix(overlay): keep ghost text perfectly ..." | Re-trigger Greptile