Skip to content

Add mirror overlay fallback for unreliable caret geometry#347

Merged
FuJacob merged 2 commits into
mainfrom
feat/mirror-overlay-fallback
May 28, 2026
Merged

Add mirror overlay fallback for unreliable caret geometry#347
FuJacob merged 2 commits into
mainfrom
feat/mirror-overlay-fallback

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

When AXTextGeometryResolver falls back to .estimated quality, the host didn't expose any of the trusted caret-position paths and inline ghost text drifts as the user types — the "marching ghost" failure mode in Electron canvases and some web editors. This PR routes those presentations through a new mirror render mode that draws the suggestion in a Cotabby-owned card anchored to the input field rect (much more stable than the caret rect in affected hosts). A pure CompletionRenderModePolicy picks the mode from geometry plus an optional user/per-app preference, defaulting to .auto, so hosts that report .exact or .derived stay on the byte-for-byte original inline path.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' build
# ** BUILD SUCCEEDED **  (no warnings)

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' build-for-testing
# ** TEST BUILD SUCCEEDED **

swiftlint lint --quiet
# exit 0

16 new pure-value tests added: 8 for the render-mode policy (auto/explicit/per-app overrides, nil bundle id), 8 for the mirror layout (field anchor, caret fallback, screen-edge clamping on all sides, whitespace collapse, RTL passthrough, keycap reservation). All call sites for the updated OverlayState.visible signature were migrated.

Local xcodebuild test runs hit a Team ID code-signing mismatch on this machine, which is the known local-dev case CLAUDE.md calls out; the tests compile and need CI signing or Xcode UI to execute.

Risk / rollout notes

  • OverlayState.visible gains a new mode associated value. ~8 internal/test call sites updated; no public API change beyond the SuggestionOverlayControlling protocol's state type.
  • Policy defaults to .auto; only .estimated caret quality triggers the new mode. Hosts that today resolve .exact or .derived (Gmail/Outlook text-marker path, most native AppKit fields, etc.) are unchanged.
  • Inline rendering is byte-for-byte the original behavior, just extracted into showInline. The panel, focus invariants, input pipeline, and acceptance hotkey are all untouched.
  • xcodegen generate was rerun; project.pbxproj diff is the auto-discovery of the 5 new source files.
  • Settings UI for the global preference and per-app overrides is intentionally out of scope here. The policy already accepts both inputs; wiring them through SuggestionSettingsModel lands in a follow-up phase.

Greptile Summary

This PR introduces a mirror render mode for hosts where AXTextGeometryResolver returns .estimated caret quality, routing those presentations through a MirrorOverlayView card anchored to the input field rect instead of the drifting caret rect. A new CompletionRenderModePolicy pure-value struct picks the mode from geometry quality and optional user/per-app preferences, defaulting to .auto so the existing inline path is completely unchanged for hosts that report .exact or .derived.

  • CompletionRenderMode + CompletionRenderModePolicy — new pure value types; policy is .auto in Phase 1 and already accepts Phase 2 per-app override maps.
  • MirrorOverlayLayout — pure layout math anchoring the card below the input field rect; 8 new tests cover clamping, fallback, whitespace collapse, and RTL passthrough.
  • OverlayController — splits hostingView into inlineHostingView/mirrorHostingView with an identity-checked panel.contentView swap for mode transitions; OverlayState.visible gains a mode associated value, migrating ~8 call sites.

Confidence Score: 4/5

Safe to merge for Phase 1 behaviour; one defect in the presenter deduplication guard will silently drop mode flips when Phase 2 per-app overrides are wired.

The deduplication guard in SuggestionOverlayPresenter.present returns nil without calling showSuggestion whenever text+geometry are unchanged — even when the render mode has changed. Its own inline comment says "we still need to invoke showSuggestion so the panel re-renders", and setCurrentBundleIdentifier's docstring promises per-app overrides take effect immediately. In Phase 1 the mode is derived purely from geometry quality, so this gap has no observable impact today. But the defect is already present in the code shipped by this PR and will activate as soon as Phase 2 wires the bundle-identifier updates through the presenter.

Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift — the early-return deduplication guard at the top of present.

Important Files Changed

Filename Overview
Cotabby/Models/CompletionRenderMode.swift New model file defining the CompletionRenderMode enum and MirrorReason; clean, well-documented value type with no issues.
Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift Deduplication guard ignores mode in the wildcard match but the inline comment says showSuggestion must still be called on mode flips — a contradiction that will silently drop per-app override changes in Phase 2.
Cotabby/Services/UI/OverlayController.swift Splits into showInline/showMirror; shared panel with identity-checked contentView swap is correct; screen selection for the mirror card still uses the unreliable caretRect (flagged separately).
Cotabby/Support/CompletionRenderModePolicy.swift Pure rule struct mapping geometry + preferences to CompletionRenderMode; logic is correct, per-app override reasoning in the alwaysMirror branch works as intended.
Cotabby/Support/MirrorOverlayLayout.swift Pure layout math for the mirror card; computeAnchorCenterX degenerate-caret guard has the x=0 edge case noted in a prior thread; clamping and fallback logic is otherwise sound.
Cotabby/Support/SuggestionSessionReconciler.swift Single call-site migration to three-label OverlayState.visible destructuring; mechanical and correct.
CotabbyTests/CompletionRenderModePolicyTests.swift Eight pure-value tests covering auto, alwaysInline, alwaysMirror, per-app overrides, and nil bundle ID paths; comprehensive coverage for the policy.
CotabbyTests/MirrorOverlayLayoutTests.swift Eight layout tests covering field anchor, caret fallback, all four screen-edge clamp directions, whitespace collapse, RTL passthrough, and keycap reservation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[present called with text + geometry] --> B{displayText empty?}
    B -- yes --> C[hide overlay]
    B -- no --> D{previousState.visible\nwith same text+geometry?}
    D -- yes --> E[return nil — no AppKit call\nMODE FLIP SILENTLY DROPPED]
    D -- no --> F[showSuggestion called]
    F --> G[renderModePolicy.mode\ngeometry + bundleIdentifier]
    G --> H{effectivePreference}
    H -- alwaysInline --> I[.inline]
    H -- alwaysMirror --> J[.mirror reason]
    H -- auto --> K{caretQuality == .estimated?}
    K -- yes --> L[.mirror caretGeometryEstimated]
    K -- no --> M[.inline]
    I & J & L & M --> N{switch mode}
    N -- .inline --> O[showInline GhostSuggestionView]
    N -- .mirror --> P[showMirror MirrorOverlayView]
    O & P --> Q[state = .visible text geometry mode]
    Q --> R[Diagnostic switch on previousState]
Loading

Fix All in Codex Fix All in Claude Code

Reviews (2): Last reviewed commit: "Reserve keycap width outside the min/max..." | Re-trigger Greptile

When AXTextGeometryResolver lands on .estimated quality the host did
not expose any of the trusted caret-position paths, so inline ghost
text drifts as the user types. Route those cases through a new mirror
render mode that draws the suggestion in a Cotabby-owned card anchored
to the input field rect rather than the unreliable caret rect.

A pure CompletionRenderModePolicy picks the mode from geometry + an
optional user/per-app preference, defaulting to .auto so existing hosts
that report .exact or .derived stay on the byte-for-byte inline path.
Settings UI for the preference and per-app overrides will follow in a
later phase; this PR is the architectural seam plus the auto trigger.
Comment on lines +148 to +150
if geometry.caretRect.width > 0 || geometry.caretRect.minX > 0 {
return geometry.caretRect.midX
}
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 The degenerate-caret guard has a gap: caretRect.minX > 0 excludes a zero-width cursor positioned at exactly x = 0 (left edge of a screen), causing the code to fall back to the field center when the caret is actually present and valid. A caret with a non-null rect is the right threshold here.

Suggested change
if geometry.caretRect.width > 0 || geometry.caretRect.minX > 0 {
return geometry.caretRect.midX
}
if !geometry.caretRect.isNull && geometry.caretRect != .zero {
return geometry.caretRect.midX
}

Fix in Codex Fix in Claude Code

Comment thread Cotabby/Services/UI/OverlayController.swift
Comment thread Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift
`MirrorOverlayLayout.make` folded the keycap reservation into
`max(minCardWidth, measuredTextWidth + keycapReservation)`, so for any
suggestion whose text width was below the 120pt floor the minimum
absorbed the reservation and the card came out the same width whether
the acceptance hint was on or off. That broke
`MirrorOverlayLayoutTests.test_make_widerCardWhenAcceptanceHintEnabled`
(140.0 vs. 140.0).

Compute the text portion against the floor on its own, then add the
keycap on top. `textBudget = maxCardWidth - keycapReservation` keeps
the final card from exceeding `maxCardWidth`.
@FuJacob FuJacob merged commit a715d2c into main May 28, 2026
4 checks passed
@FuJacob FuJacob deleted the feat/mirror-overlay-fallback branch May 28, 2026 09:10
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