From 7c99ed8484772210b109d6029244f23cf33d19e1 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:48:58 -0700 Subject: [PATCH] feat(overlay): route TextKit-estimated carets to a popup anchored at 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. --- Cotabby/Models/CompletionRenderMode.swift | 7 +++++ .../Support/CompletionRenderModePolicy.swift | 28 +++++++++++------ Cotabby/Support/MirrorOverlayLayout.swift | 15 ++++++++++ .../CompletionRenderModePolicyTests.swift | 17 ++++++----- CotabbyTests/MirrorOverlayLayoutTests.swift | 30 +++++++++++++++++++ 5 files changed, 80 insertions(+), 17 deletions(-) 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() {