diff --git a/Cotabby/Models/CompletionRenderMode.swift b/Cotabby/Models/CompletionRenderMode.swift index f7aabe68..d301a204 100644 --- a/Cotabby/Models/CompletionRenderMode.swift +++ b/Cotabby/Models/CompletionRenderMode.swift @@ -27,6 +27,13 @@ nonisolated enum CompletionRenderMode: Equatable, Sendable { /// drifts as the user types. case caretGeometryEstimated + /// Caret quality is `.layoutEstimated`: the host exposed no usable caret, but Cotabby's + /// hidden-TextKit layout repair produced a confident caret estimate. We deliberately route + /// these to the card rather than inline ghost text, but unlike `.caretGeometryEstimated` the + /// card anchors to that estimated caret (one line below it) instead of the whole field rect, + /// so the popup tracks the cursor the TextKit layout located. + case caretLayoutEstimated + /// The caret sits mid-line: real characters follow it before the next line break. Inline /// ghost text would draw on top of those trailing characters, so the suggestion is promoted /// to the card, which anchors to the caret rect (the geometry is trustworthy here) and sits diff --git a/Cotabby/Support/CompletionRenderModePolicy.swift b/Cotabby/Support/CompletionRenderModePolicy.swift index 6491937f..7abc4dbe 100644 --- a/Cotabby/Support/CompletionRenderModePolicy.swift +++ b/Cotabby/Support/CompletionRenderModePolicy.swift @@ -108,15 +108,25 @@ struct CompletionRenderModePolicy: Equatable, Sendable { return .mirror(reason: reason) case .auto: - // Only `.estimated` geometry triggers auto-mirror. `.derived` already lands close enough - // to the real caret to render inline ghost text confidently; promoting it would over-fire - // the card for hosts that work fine today (Gmail, Outlook, Discord text-marker path). - // `.layoutEstimated` deliberately falls through to inline as well: it only exists when - // the caret layout repair accepted a hidden-text-layout estimate, and rendering inline - // on that estimate is the entire point of the repair. - return geometry.caretQuality == .estimated - ? .mirror(reason: .caretGeometryEstimated) - : .inline + // `.derived` and `.exact` land close enough to the real caret to render inline ghost + // text confidently; promoting them would over-fire the card for hosts that work fine + // today (Gmail, Outlook, Discord text-marker path). + // + // Both estimate qualities go to the card, but with different anchors. `.estimated` has no + // usable caret at all, so the card anchors to the field rect (`.caretGeometryEstimated`). + // `.layoutEstimated` means the hidden-TextKit repair produced a confident caret estimate: + // we still prefer the card over inline ghost text (the estimate is good enough to place a + // popup, not to paint glyphs the eye will scrutinize against the host's own text), but we + // anchor that popup to the estimated caret (`.caretLayoutEstimated`) so it tracks the + // cursor TextKit located instead of floating below the whole field. + switch geometry.caretQuality { + case .estimated: + return .mirror(reason: .caretGeometryEstimated) + case .layoutEstimated: + return .mirror(reason: .caretLayoutEstimated) + case .exact, .derived: + return .inline + } } } } diff --git a/Cotabby/Support/MirrorOverlayLayout.swift b/Cotabby/Support/MirrorOverlayLayout.swift index 8e7a66c3..0c1d2e76 100644 --- a/Cotabby/Support/MirrorOverlayLayout.swift +++ b/Cotabby/Support/MirrorOverlayLayout.swift @@ -175,6 +175,21 @@ struct MirrorOverlayLayout: Equatable { // height as unreliable; the extra slack keeps the card from overlapping the typed line. return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset + case .caretLayoutEstimated: + // The hidden-TextKit repair located the caret, so anchor to that estimated caret rect + // rather than the whole field — the popup should track the cursor, not float below the + // document. The one-line offset (not the tight `anchorGap`) drops the card a full line + // beneath the estimated caret so it never overlaps the line being typed, which matters + // most here because the estimate's exact baseline is less certain than a real AX caret. + // Fall back to the field rect, then the tight caret offset, only if the estimate is empty. + if !geometry.caretRect.isEmpty { + return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset + } + if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty { + return inputFrame.minY - Metrics.anchorGap + } + return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset + case .userPreference, .perAppOverride, .caretMidLine: // 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 diff --git a/CotabbyTests/CompletionRenderModePolicyTests.swift b/CotabbyTests/CompletionRenderModePolicyTests.swift index a70a911c..380b67c2 100644 --- a/CotabbyTests/CompletionRenderModePolicyTests.swift +++ b/CotabbyTests/CompletionRenderModePolicyTests.swift @@ -230,21 +230,22 @@ final class CompletionRenderModePolicyTests: XCTestCase { // MARK: - Layout-estimated geometry (caret layout repair) - func test_auto_returnsInlineForLayoutEstimatedCaretGeometry() { - // `.layoutEstimated` only exists when the caret layout repair accepted a hidden-text-layout - // estimate; rendering inline on that estimate is the entire point of the repair. + func test_auto_returnsLayoutEstimatedMirrorForLayoutEstimatedCaretGeometry() { + // `.layoutEstimated` means the hidden-TextKit repair produced a confident caret estimate. We + // route it to the card (good enough to place a popup, not to paint inline glyphs the eye + // scrutinizes against host text) but anchor that popup to the estimated caret. let policy = CompletionRenderModePolicy(userPreference: .auto) let geometry = CotabbyTestFixtures.overlayGeometry(caretQuality: .layoutEstimated) XCTAssertEqual( policy.mode(for: geometry, bundleIdentifier: "com.microsoft.VSCode"), - .inline + .mirror(reason: .caretLayoutEstimated) ) } - func test_auto_midLineCaret_promotesLayoutEstimatedGeometryToMirror() { - // The repair fixes geometry, not the mid-line rule: characters after the caret still have - // no inline home, so the card promotion applies to repaired geometry too. + func test_auto_midLineCaret_keepsLayoutEstimatedReasonRatherThanOverwriting() { + // Layout-estimated geometry already routes to the card, so the mid-line promotion (which only + // upgrades inline results) never runs: the more specific caret-layout reason is retained. let policy = CompletionRenderModePolicy(userPreference: .auto) let geometry = CotabbyTestFixtures.overlayGeometry( caretQuality: .layoutEstimated, @@ -253,7 +254,7 @@ final class CompletionRenderModePolicyTests: XCTestCase { XCTAssertEqual( policy.mode(for: geometry, bundleIdentifier: "com.microsoft.VSCode"), - .mirror(reason: .caretMidLine) + .mirror(reason: .caretLayoutEstimated) ) } diff --git a/CotabbyTests/MirrorOverlayLayoutTests.swift b/CotabbyTests/MirrorOverlayLayoutTests.swift index aa1eb01b..81b01e00 100644 --- a/CotabbyTests/MirrorOverlayLayoutTests.swift +++ b/CotabbyTests/MirrorOverlayLayoutTests.swift @@ -139,6 +139,36 @@ final class MirrorOverlayLayoutTests: XCTestCase { ) } + func test_make_layoutEstimatedReasonAnchorsToCaretLine_notInputField() { + // Same geometry as the estimated test, but `.caretLayoutEstimated` means the hidden-TextKit + // repair located the caret, so the card must track that estimated caret (sit just below it) + // rather than dropping to the field's bottom edge ~100pt away. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 720, y: 500, width: 2, height: 18), + inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 200) + ) + + let layout = MirrorOverlayLayout.make( + suggestion: "hello", + geometry: geometry, + visibleFrame: screen, + showsAcceptanceHint: true, + reason: .caretLayoutEstimated + ) + + // Card sits one line below the estimated caret line, not at the field's bottom edge. + XCTAssertLessThan( + layout.panelFrame.maxY, + geometry.caretRect.minY, + "Layout-estimated popup should sit below the estimated caret line" + ) + XCTAssertGreaterThan( + layout.panelFrame.maxY, + geometry.inputFrameRect!.minY + 40, + "Layout-estimated popup should NOT drop down to the field's bottom edge" + ) + } + // MARK: - Fallback when input frame missing func test_make_fallsBackToCaretRectWhenInputFrameMissing() {