Skip to content

Anchor user-forced popup card to the caret line, not the field rect#368

Merged
FuJacob merged 1 commit into
mainfrom
fix/popup-mode-caret-anchor
May 28, 2026
Merged

Anchor user-forced popup card to the caret line, not the field rect#368
FuJacob merged 1 commit into
mainfrom
fix/popup-mode-caret-anchor

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

Follow-up to #351. When the user pinned popup mode (or a per-app override forced it), the card was still anchored to the input field's bottom edge — the strategy that exists specifically to dodge unreliable caret geometry. With .exact or .derived caret quality that anchor wastes the precise signal and drops the popup far below where the eye is. This PR branches the anchor choice on the mirror reason: keep the field-rect anchor for caretGeometryEstimated, switch to a caret-rect anchor for userPreference and perAppOverride so the popup tracks the cursor like inline ghost text does.

Validation

swiftlint lint --quiet                             # exit 0
xcodebuild -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' build              # ** BUILD SUCCEEDED **
xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# Test Suite 'All tests' passed   (full suite, 0 failures)
# MirrorOverlayLayoutTests: 12 / 12 passed

Three new tests cover the fix:

  • test_make_userPreferenceAnchorsToCaretLine_notInputField — the bug case: card lands near the caret, not at the field's bottom edge.
  • test_make_perAppOverrideAnchorsToCaretLine — same behavior for per-app override.
  • test_make_estimatedReasonStillAnchorsToInputField — regression guard that the original .estimated path is unchanged.

Risk / rollout notes

  • Behavior unchanged for the .caretGeometryEstimated reason (the auto-mirror path users see by default). The fix only touches placement when popup mode is user-pinned or per-app forced, both of which require an explicit user action to reach today.
  • Caret-anchored fallback still routes through the field rect when the caret rect is empty, so degenerate geometry doesn't put the card off-screen.

Linked issues

Refs #351.

Greptile Summary

This PR fixes popup-card placement for user-pinned and per-app-forced mirror mode. Previously all mirror reasons anchored to the input field's bottom edge; now .userPreference and .perAppOverride anchor to the caret rect (with the field rect as a fallback), while .caretGeometryEstimated keeps the original field-first ordering.

  • MirrorOverlayLayout.computeAnchorTopY is refactored from a flat function into a switch over CompletionRenderMode.MirrorReason, routing each case to the appropriate anchor strategy.
  • Three new tests in MirrorOverlayLayoutTests verify the caret-anchored path, the per-app path, and a regression guard for the estimated path.

Confidence Score: 4/5

Safe to merge; the fix is narrowly scoped, only activates through an explicit user action, and all three expected behaviours are covered by the new tests.

The struct-level doc comment now directly contradicts the new caret-anchored behaviour for .userPreference/.perAppOverride, which will mislead future readers. The degenerate triple-fallback for those same branches produces a negative Y that relies entirely on the screen clamp to stay on-screen — a small logic gap compared to the .caretGeometryEstimated path. Both are quality concerns rather than functional breakage for end users today.

The struct-level docstring in MirrorOverlayLayout.swift needs to be updated to reflect the new anchor strategy for .userPreference and .perAppOverride.

Important Files Changed

Filename Overview
Cotabby/Support/MirrorOverlayLayout.swift Core layout logic refactored; struct-level doc comment now contradicts the new caret-anchored paths for .userPreference/.perAppOverride.
CotabbyTests/MirrorOverlayLayoutTests.swift Three new tests added; degenerate case (empty caret + nil inputFrame) for the new .userPreference/.perAppOverride paths is untested.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[computeAnchorTopY] --> B{reason}

    B -- .caretGeometryEstimated --> C{inputFrameRect\navailable & non-empty?}
    C -- yes --> D[inputFrame.minY − anchorGap]
    C -- no --> E[caretRect.minY − caretFallbackVerticalOffset]

    B -- .userPreference\n.perAppOverride --> F{caretRect\nnon-empty?}
    F -- yes --> G[caretRect.minY − anchorGap]
    F -- no --> H{inputFrameRect\navailable & non-empty?}
    H -- yes --> I[inputFrame.minY − anchorGap]
    H -- no --> J[caretRect.minY − caretFallbackVerticalOffset\n⚠️ caretRect is empty here — minY = 0\nclamped to screen minY]
Loading

Comments Outside Diff (2)

  1. Cotabby/Support/MirrorOverlayLayout.swift, line 7-10 (link)

    P2 The struct-level doc comment now contradicts the new behaviour introduced by this PR. It says "this helper does not anchor to the caret rect for positioning", but the .userPreference and .perAppOverride branches added here do exactly that. A reader skimming the type-level docs will get the wrong mental model for those paths.

    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/MirrorOverlayLayoutTests.swift, line 145-157 (link)

    P2 Missing degenerate-geometry test for new branches

    test_make_fallsBackToCaretRectWhenInputFrameMissing only exercises .caretGeometryEstimated. There is no test for what happens when both caretRect is empty and inputFrameRect is nil under .userPreference or .perAppOverride. The triple-fallback path in computeAnchorTopY now has different code for those reasons but no corresponding test to guard it against future regression.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Anchor user-forced popup card to the car..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

When the user pinned popup mode (or a per-app override forced it), the
card was still anchored to the input field's bottom edge — the strategy
that exists specifically to dodge unreliable caret geometry. With exact
or derived caret quality that anchor wastes the precise signal and
drops the popup far below where the eye is.

Branch the anchor choice on the mirror reason: keep the field-rect
anchor for caretGeometryEstimated (the caret rect really is unreliable
there), and switch to a caret-rect anchor for userPreference and
perAppOverride so the popup tracks the cursor like inline ghost text.
@FuJacob FuJacob merged commit c71e68b into main May 28, 2026
4 checks passed
Comment on lines +161 to +171
case .userPreference, .perAppOverride:
// Caret geometry is trustworthy in these cases. Sit just under the caret line so the
// popup tracks the cursor like the inline ghost does, instead of floating below the
// entire field.
if !geometry.caretRect.isEmpty {
return geometry.caretRect.minY - Metrics.anchorGap
}
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
return inputFrame.minY - Metrics.anchorGap
}
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When both caretRect is empty and inputFrameRect is nil/empty, this final fallback applies caretFallbackVerticalOffset to an already-empty caret rect whose minY is 0, producing -22. The screen-edge clamping rescues the card, but the intent — a "fallback vertical offset" — is meaningless here since there is no real caret position to offset from. Using Metrics.anchorGap instead makes the intent explicit and avoids relying on the clamp to fix a large negative offset.

Suggested change
case .userPreference, .perAppOverride:
// Caret geometry is trustworthy in these cases. Sit just under the caret line so the
// popup tracks the cursor like the inline ghost does, instead of floating below the
// entire field.
if !geometry.caretRect.isEmpty {
return geometry.caretRect.minY - Metrics.anchorGap
}
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
return inputFrame.minY - Metrics.anchorGap
}
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset
case .userPreference, .perAppOverride:
// Caret geometry is trustworthy in these cases. Sit just under the caret line so the
// popup tracks the cursor like the inline ghost does, instead of floating below the
// entire field.
if !geometry.caretRect.isEmpty {
return geometry.caretRect.minY - Metrics.anchorGap
}
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
return inputFrame.minY - Metrics.anchorGap
}
// Both rects are degenerate; the card will be clamped to the screen edge by the caller.
return Metrics.anchorGap

Fix in Codex Fix in Claude Code

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