Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cotabby/Models/CompletionRenderMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 19 additions & 9 deletions Cotabby/Support/CompletionRenderModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
15 changes: 15 additions & 0 deletions Cotabby/Support/MirrorOverlayLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions CotabbyTests/CompletionRenderModePolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -253,7 +254,7 @@ final class CompletionRenderModePolicyTests: XCTestCase {

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: "com.microsoft.VSCode"),
.mirror(reason: .caretMidLine)
.mirror(reason: .caretLayoutEstimated)
)
}

Expand Down
30 changes: 30 additions & 0 deletions CotabbyTests/MirrorOverlayLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down