Skip to content

feat(overlay): route TextKit-estimated carets to a popup anchored at the caret#694

Merged
FuJacob merged 1 commit into
mainfrom
fix/estimated-force-popup-textkit
Jun 12, 2026
Merged

feat(overlay): route TextKit-estimated carets to a popup anchored at the caret#694
FuJacob merged 1 commit into
mainfrom
fix/estimated-force-popup-textkit

Conversation

@FuJacob

@FuJacob FuJacob commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

For .estimated hosts (no usable AX caret), Cotabby's hidden-TextKit layout repair produces a confident caret estimate, surfaced as .layoutEstimated quality. Previously that rendered inline ghost text. Inline glyphs drawn on an estimate get scrutinized against the host's own text and read as misplaced, so this routes .layoutEstimated to the popup card instead — but anchors the card to the estimated caret (one line below it) rather than the whole field rect, so the popup tracks the cursor TextKit located instead of floating at the bottom of the document.

Behavior by caret quality under Auto is now:

quality before after
.exact / .derived inline inline (unchanged)
.estimated (no caret) popup, field-anchored popup, field-anchored (unchanged)
.layoutEstimated (TextKit repair) inline popup, anchored 1 line below the estimated caret

A new .caretLayoutEstimated MirrorReason keeps this distinct from the no-usable-caret .caretGeometryEstimated case (which still anchors to the field rect, since its caret can't be trusted). The mid-line override is unaffected: .layoutEstimated is already a card, so the override (which only upgrades inline results) no longer relabels it.

Validation

xcodebuild ... build -derivedDataPath build/DerivedData            # ** BUILD SUCCEEDED **
xcodebuild ... test -only-testing:CotabbyTests/CompletionRenderModePolicyTests \
  -only-testing:CotabbyTests/MirrorOverlayLayoutTests \
  -only-testing:CotabbyTests/SuggestionCaretLayoutRepairTests \
  -only-testing:CotabbyTests/SuggestionOverlayStabilityGateTests \
  -only-testing:CotabbyTests/SuggestionCoordinatorPredictionTests \
  CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO                 # ** TEST SUCCEEDED **
swiftlint lint --quiet                                             # exit 0

Updated the two policy tests that pinned .layoutEstimated → inline, and added a MirrorOverlayLayout test asserting the new reason anchors below the estimated caret line (not the field bottom).

Linked issues

Risk / rollout notes

  • Behavior change for .estimated/TextKit-repaired hosts only (e.g. some Electron/web editors): they now show the popup card instead of inline ghost text. Hosts on .exact/.derived are untouched.
  • Pure render-mode/layout change; no settings, schema, or persistence impact. New MirrorReason case is String-backed so the debug label and OverlayState.detail pick it up with no other edits.

Greptile Summary

This PR routes .layoutEstimated-quality carets (produced by Cotabby's hidden-TextKit layout repair) to the popup card instead of inline ghost text, and anchors that popup one line below the estimated caret position rather than at the field rectangle used by the no-caret .estimated path.

  • CompletionRenderMode gains a new MirrorReason.caretLayoutEstimated case, keeping the TextKit-repair path distinct from the no-caret .caretGeometryEstimated path in diagnostics and layout.
  • CompletionRenderModePolicy replaces a ternary with an exhaustive switch on caretQuality; .layoutEstimated now returns .mirror(reason: .caretLayoutEstimated), and the mid-line promotion correctly no-ops for already-mirrored results.
  • MirrorOverlayLayout.computeAnchorTopY adds a caretLayoutEstimated branch that anchors to the estimated caret rect with caretFallbackVerticalOffset (22 pt) rather than the tight 8 pt gap used for fully trusted carets, with field-rect and last-resort fallbacks mirroring the existing patterns.

Confidence Score: 4/5

Safe to merge; the change only affects hosts whose caret quality resolves to .layoutEstimated (TextKit-repaired Electron/web editors) and is a pure render-mode routing change with no persistence or schema impact.

The logic is sound and the new tests cover the primary happy-path behavior. Two small gaps keep this from a clean score: the computeAnchorTopY doc comment does not document the new case, and there is no test pinning alwaysInline + .layoutEstimated quality — a combination that previously shared behavior with .auto and is now subtly distinct.

MirrorOverlayLayout.swift — the computeAnchorTopY header comment should be extended to describe the new case; CompletionRenderModePolicyTests.swift — worth adding a test for the alwaysInline + .layoutEstimated combination.

Important Files Changed

Filename Overview
Cotabby/Models/CompletionRenderMode.swift Adds caretLayoutEstimated to MirrorReason with a clear doc comment; no logic changes, clean addition.
Cotabby/Support/CompletionRenderModePolicy.swift Replaces ternary with an exhaustive switch on caretQuality; .layoutEstimated now returns .mirror(reason: .caretLayoutEstimated) instead of .inline. Logic is correct and the mid-line guard correctly skips already-mirror results.
Cotabby/Support/MirrorOverlayLayout.swift Adds caretLayoutEstimated branch to computeAnchorTopY; anchors below estimated caret with caretFallbackVerticalOffset. The doc comment for computeAnchorTopY does not document the new case, leaving an incomplete description of the anchor-selection logic.
CotabbyTests/CompletionRenderModePolicyTests.swift Updates two existing tests and adds tests for .layoutEstimated policy behavior; missing coverage for alwaysInline + .layoutEstimated quality combination.
CotabbyTests/MirrorOverlayLayoutTests.swift Adds test_make_layoutEstimatedReasonAnchorsToCaretLine_notInputField with correct AppKit-coordinate assertions confirming the popup tracks the estimated caret rather than the field bottom.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[caretQuality] --> B{switch}
    B -- .exact / .derived --> C[.inline]
    C --> D{isCaretAtEndOfLine?}
    D -- yes --> E[render inline]
    D -- no --> F[.mirror reason: .caretMidLine]
    B -- .estimated --> G[.mirror reason: .caretGeometryEstimated]
    G --> H[anchorTopY = inputFrame.minY - anchorGap]
    B -- .layoutEstimated --> I[.mirror reason: .caretLayoutEstimated]
    I --> J{caretRect.isEmpty?}
    J -- no --> K[anchorTopY = caretRect.minY - caretFallbackVerticalOffset]
    J -- yes --> L{inputFrame available?}
    L -- yes --> M[anchorTopY = inputFrame.minY - anchorGap]
    L -- no --> N[anchorTopY = caretRect.minY - caretFallbackVerticalOffset]
Loading

Comments Outside Diff (2)

  1. Cotabby/Support/MirrorOverlayLayout.swift, line 152-165 (link)

    P2 Doc comment doesn't cover .caretLayoutEstimated

    The computeAnchorTopY header documents only .caretGeometryEstimated and the trusted-caret reasons (.userPreference, .perAppOverride, .caretMidLine). A reader consulting just this comment to understand the anchor strategy gets no hint that .caretLayoutEstimated exists or that it anchors to the estimated caret instead of the field rect. Consider adding a third bullet to the existing - .caretGeometryEstimated / - .userPreference … list describing the new case and why it uses caretFallbackVerticalOffset rather than anchorGap.

    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!

    Fix in Codex Fix in Claude Code

  2. CotabbyTests/CompletionRenderModePolicyTests.swift, line 43-53 (link)

    P2 No test for alwaysInline + .layoutEstimated quality

    The existing test_alwaysInline_keepsInlineEvenForEstimatedGeometry covers .estimated caret quality, but not .layoutEstimated. Before this PR, .layoutEstimated fell through to .inline in .auto mode, making it share behavior with alwaysInline. Now that .auto routes .layoutEstimated to .mirror, it's worth pinning explicitly that alwaysInline still returns .inline for .layoutEstimated quality, so a future change to the alwaysInline branch can't silently regress it.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(overlay): route TextKit-estimated c..." | Re-trigger Greptile

…the caret

For `.estimated` hosts Cotabby's hidden-TextKit repair produces a confident
caret estimate (`.layoutEstimated`). Previously that rendered inline ghost text.
Inline glyphs on an estimate get scrutinized against the host's own text and
read as misplaced, so route `.layoutEstimated` to the popup card instead, but
anchor the card to the estimated caret (one line below it) rather than the whole
field rect, so the popup tracks the cursor TextKit located.

Adds a `.caretLayoutEstimated` MirrorReason to keep this distinct from the
no-usable-caret `.caretGeometryEstimated` case, which still anchors to the field.
@FuJacob FuJacob merged commit 94d0134 into main Jun 12, 2026
4 checks passed
@FuJacob FuJacob deleted the fix/estimated-force-popup-textkit branch June 12, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant