Skip to content

Commit 93a67fc

Browse files
committed
Anchor user-forced popup card to the caret line, not the field rect
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.
1 parent 09d90c1 commit 93a67fc

2 files changed

Lines changed: 117 additions & 9 deletions

File tree

Cotabby/Support/MirrorOverlayLayout.swift

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ struct MirrorOverlayLayout: Equatable {
9191
let cardWidth = contentWidth + (Metrics.horizontalPadding * 2)
9292
let cardHeight = ceil(Metrics.fontSize * 1.6) + (Metrics.verticalPadding * 2)
9393

94-
let anchorTopY = computeAnchorTopY(geometry: geometry)
94+
let anchorTopY = computeAnchorTopY(geometry: geometry, reason: reason)
9595
let anchorCenterX = computeAnchorCenterX(geometry: geometry)
9696

9797
var originX = anchorCenterX - (cardWidth / 2)
@@ -133,15 +133,43 @@ struct MirrorOverlayLayout: Equatable {
133133
}
134134

135135
/// The Y coordinate the card sits *under*. In AppKit's bottom-up coordinate system this is the
136-
/// bottom edge of the anchor (field or caret rect) minus the gap.
137-
private static func computeAnchorTopY(geometry: SuggestionOverlayGeometry) -> CGFloat {
138-
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
139-
return inputFrame.minY - Metrics.anchorGap
136+
/// bottom edge of the anchor minus the gap.
137+
///
138+
/// The anchor choice depends on *why* mirror mode is active:
139+
///
140+
/// - `.caretGeometryEstimated` means the host did not expose any of the trusted caret paths, so
141+
/// the caret rect itself is unreliable. We anchor to the input field rect when available
142+
/// because the field rect stays stable even when the caret estimate drifts.
143+
/// - `.userPreference` and `.perAppOverride` mean the user pinned popup mode despite the caret
144+
/// geometry being trustworthy (`.exact` or `.derived`). Anchoring to the field rect in this
145+
/// case wastes the precise caret signal and lands the card far below where the eye is. We
146+
/// anchor to the caret rect instead, with the input field as a safety net only for the
147+
/// degenerate case where the caret rect is empty.
148+
private static func computeAnchorTopY(
149+
geometry: SuggestionOverlayGeometry,
150+
reason: CompletionRenderMode.MirrorReason
151+
) -> CGFloat {
152+
switch reason {
153+
case .caretGeometryEstimated:
154+
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
155+
return inputFrame.minY - Metrics.anchorGap
156+
}
157+
// Caret-rect fallback uses the larger offset because in `.estimated` we treat the caret
158+
// height as unreliable; the extra slack keeps the card from overlapping the typed line.
159+
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset
160+
161+
case .userPreference, .perAppOverride:
162+
// Caret geometry is trustworthy in these cases. Sit just under the caret line so the
163+
// popup tracks the cursor like the inline ghost does, instead of floating below the
164+
// entire field.
165+
if !geometry.caretRect.isEmpty {
166+
return geometry.caretRect.minY - Metrics.anchorGap
167+
}
168+
if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty {
169+
return inputFrame.minY - Metrics.anchorGap
170+
}
171+
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset
140172
}
141-
// No field rect — fall back to the caret rect with an explicit vertical offset so the card
142-
// doesn't overlap the caret line. The card lands slightly lower than ideal but at least
143-
// doesn't sit directly on top of typed text.
144-
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset
145173
}
146174

147175
/// Horizontal center the card aligns to. Prefer the caret's X because the user's eye is already

CotabbyTests/MirrorOverlayLayoutTests.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,86 @@ final class MirrorOverlayLayoutTests: XCTestCase {
5959
)
6060
}
6161

62+
// MARK: - Caret-anchored path (user-forced / per-app-forced popup)
63+
64+
func test_make_userPreferenceAnchorsToCaretLine_notInputField() {
65+
// The field's bottom edge (minY in AppKit coordinates) is at y=400. The caret line sits at
66+
// y=500, far above the field's bottom edge. Pre-fix behavior anchored to the field minY
67+
// even with .userPreference reason, dropping the popup ~100pt below where the eye is. The
68+
// fix uses caret.minY for user/perApp reasons so the popup tracks the cursor.
69+
let geometry = CotabbyTestFixtures.overlayGeometry(
70+
caretRect: CGRect(x: 720, y: 500, width: 2, height: 18),
71+
inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 200)
72+
)
73+
74+
let userPreferenceLayout = MirrorOverlayLayout.make(
75+
suggestion: "hello",
76+
geometry: geometry,
77+
visibleFrame: screen,
78+
showsAcceptanceHint: true,
79+
reason: .userPreference
80+
)
81+
82+
// Card must sit close to the caret line (within one card-height worth of slack), not at the
83+
// field's bottom edge.
84+
XCTAssertLessThan(
85+
userPreferenceLayout.panelFrame.maxY,
86+
geometry.caretRect.minY,
87+
"User-forced popup should sit below the caret line"
88+
)
89+
XCTAssertGreaterThan(
90+
userPreferenceLayout.panelFrame.maxY,
91+
geometry.inputFrameRect!.minY + 40,
92+
"User-forced popup should NOT drop down to the field's bottom edge"
93+
)
94+
}
95+
96+
func test_make_perAppOverrideAnchorsToCaretLine() {
97+
let geometry = CotabbyTestFixtures.overlayGeometry(
98+
caretRect: CGRect(x: 720, y: 500, width: 2, height: 18),
99+
inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 200)
100+
)
101+
102+
let layout = MirrorOverlayLayout.make(
103+
suggestion: "hello",
104+
geometry: geometry,
105+
visibleFrame: screen,
106+
showsAcceptanceHint: true,
107+
reason: .perAppOverride
108+
)
109+
110+
XCTAssertLessThan(
111+
layout.panelFrame.maxY,
112+
geometry.caretRect.minY,
113+
"Per-app forced popup should also sit below the caret line, not the field"
114+
)
115+
}
116+
117+
func test_make_estimatedReasonStillAnchorsToInputField() {
118+
// Same geometry as the user-preference test above, but with .caretGeometryEstimated. The
119+
// caret rect is exactly the kind of value that can't be trusted in this case, so the layout
120+
// must keep the field-rect anchor it had before the fix.
121+
let geometry = CotabbyTestFixtures.overlayGeometry(
122+
caretRect: CGRect(x: 720, y: 500, width: 2, height: 18),
123+
inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 200)
124+
)
125+
126+
let layout = MirrorOverlayLayout.make(
127+
suggestion: "hello",
128+
geometry: geometry,
129+
visibleFrame: screen,
130+
showsAcceptanceHint: true,
131+
reason: .caretGeometryEstimated
132+
)
133+
134+
// Card sits below the FIELD edge, well below the caret line.
135+
XCTAssertLessThan(
136+
layout.panelFrame.maxY,
137+
geometry.inputFrameRect!.minY,
138+
"Estimated-reason popup should still anchor below the input field rect"
139+
)
140+
}
141+
62142
// MARK: - Fallback when input frame missing
63143

64144
func test_make_fallsBackToCaretRectWhenInputFrameMissing() {

0 commit comments

Comments
 (0)