diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 26cf4ef..9fc841b 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -599,6 +599,7 @@ extension SuggestionCoordinator { caretRect: caretRect, inputFrameRect: context.inputFrameRect, caretQuality: context.caretQuality, + isCaretAtEndOfLine: context.isCaretAtEndOfLine, observedCharWidth: context.observedCharWidth, isRightToLeft: isRightToLeft, focusChangeSequence: context.focusChangeSequence, diff --git a/Cotabby/Models/CompletionRenderMode.swift b/Cotabby/Models/CompletionRenderMode.swift index f8a8c30..f7aabe6 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 + /// 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 + /// just under the cursor like an inline ghost would. This is also the surface fill-in-middle + /// completions render in, since a FIM result has no inline home. + case caretMidLine + /// User set their global preference to always use mirror mode. Phase 2 wiring. case userPreference diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index 3b77743..b5cc29c 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -532,6 +532,12 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { let caretRect: CGRect let inputFrameRect: CGRect? let caretQuality: CaretGeometryQuality + /// True when the caret is at the end of its line: only whitespace, if anything, precedes the + /// next line break. When false, real characters follow the caret on this line, so the + /// render-mode policy promotes the suggestion to the card: inline ghost text would otherwise + /// paint over those trailing characters. Carried from `FocusedInputContext.isCaretAtEndOfLine`. + /// Defaults to `true` so call sites that predate the mid-line rule keep the prior inline path. + let isCaretAtEndOfLine: Bool /// Average character width from AX child-frame sampling when available. Layout uses this as a /// cheap approximation for host-editor text width before falling back to local font metrics. let observedCharWidth: CGFloat? @@ -560,6 +566,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { caretRect: CGRect, inputFrameRect: CGRect?, caretQuality: CaretGeometryQuality, + isCaretAtEndOfLine: Bool = true, observedCharWidth: CGFloat?, isRightToLeft: Bool, focusChangeSequence: UInt64 = 0, @@ -570,6 +577,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { self.caretRect = caretRect self.inputFrameRect = inputFrameRect self.caretQuality = caretQuality + self.isCaretAtEndOfLine = isCaretAtEndOfLine self.observedCharWidth = observedCharWidth self.isRightToLeft = isRightToLeft self.focusChangeSequence = focusChangeSequence @@ -585,6 +593,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { caretRect: caretRect, inputFrameRect: inputFrameRect, caretQuality: caretQuality, + isCaretAtEndOfLine: isCaretAtEndOfLine, observedCharWidth: observedCharWidth, isRightToLeft: isRightToLeft, focusChangeSequence: focusChangeSequence, diff --git a/Cotabby/Support/CompletionRenderModePolicy.swift b/Cotabby/Support/CompletionRenderModePolicy.swift index 4b65793..6c58a4a 100644 --- a/Cotabby/Support/CompletionRenderModePolicy.swift +++ b/Cotabby/Support/CompletionRenderModePolicy.swift @@ -6,7 +6,9 @@ import Foundation /// to mirror mode. `alwaysInline` and `alwaysMirror` let power users pin a strategy when the /// auto rule misfires for their host mix. /// -/// Phase 1 hardcodes `.auto` everywhere; the global setting and per-app overrides land in Phase 2. +/// The global preference is live (Appearance settings Picker); per-app overrides are not wired yet. +/// Note that a mid-line caret promotes inline to the card regardless of this preference; see +/// `CompletionRenderModePolicy.mode(for:bundleIdentifier:)`. enum MirrorPreference: String, Codable, CaseIterable, Identifiable, Equatable, Sendable { case auto case alwaysInline @@ -59,6 +61,28 @@ struct CompletionRenderModePolicy: Equatable, Sendable { func mode( for geometry: SuggestionOverlayGeometry, bundleIdentifier: String? + ) -> CompletionRenderMode { + let baseMode = preferenceMode(for: geometry, bundleIdentifier: bundleIdentifier) + + // A caret parked mid-line (real characters follow it before the next line break) has no + // inline home: ghost text would paint over those trailing characters. Promote any inline + // result to the card, which anchors to the caret rect (the geometry is trustworthy here). This + // deliberately overrides an explicit `.alwaysInline` pin too, because inline cannot render + // mid-line at all, and the card is the surface fill-in-middle completions will use. The + // promotion only upgrades inline results; a presentation already routed to the card keeps its + // original, more specific reason (e.g. `.caretGeometryEstimated`). + if case .inline = baseMode, !geometry.isCaretAtEndOfLine { + return .mirror(reason: .caretMidLine) + } + return baseMode + } + + /// The render mode implied by the user (or per-app) preference and caret-geometry quality, before + /// the mid-line override in `mode(for:bundleIdentifier:)` is applied. Split out so that override + /// reads as a single, well-scoped rule rather than another branch threaded through the switch. + private func preferenceMode( + for geometry: SuggestionOverlayGeometry, + bundleIdentifier: String? ) -> CompletionRenderMode { let effectivePreference: MirrorPreference if let bundleIdentifier, let override = perAppOverrides[bundleIdentifier] { diff --git a/Cotabby/Support/MirrorOverlayLayout.swift b/Cotabby/Support/MirrorOverlayLayout.swift index e627515..bf9e142 100644 --- a/Cotabby/Support/MirrorOverlayLayout.swift +++ b/Cotabby/Support/MirrorOverlayLayout.swift @@ -151,11 +151,11 @@ struct MirrorOverlayLayout: Equatable { /// - `.caretGeometryEstimated` means the host did not expose any of the trusted caret paths, so /// the caret rect itself is unreliable. We anchor to the input field rect when available /// because the field rect stays stable even when the caret estimate drifts. - /// - `.userPreference` and `.perAppOverride` mean the user pinned popup mode despite the caret - /// geometry being trustworthy (`.exact` or `.derived`). Anchoring to the field rect in this - /// case wastes the precise caret signal and lands the card far below where the eye is. We - /// anchor to the caret rect instead, with the input field as a safety net only for the - /// degenerate case where the caret rect is empty. + /// - `.userPreference`, `.perAppOverride`, and `.caretMidLine` all mean the caret geometry is + /// trustworthy (`.exact` or `.derived`); the card is up because the user pinned popup mode or + /// the caret is mid-line. Anchoring to the field rect would waste the precise caret signal and + /// land the card far below where the eye is, so we anchor to the caret rect instead, with the + /// input field as a safety net only for the degenerate case where the caret rect is empty. private static func computeAnchorTopY( geometry: SuggestionOverlayGeometry, reason: CompletionRenderMode.MirrorReason @@ -169,7 +169,7 @@ struct MirrorOverlayLayout: Equatable { // height as unreliable; the extra slack keeps the card from overlapping the typed line. return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset - case .userPreference, .perAppOverride: + 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 // entire field. diff --git a/CotabbyTests/CompletionRenderModePolicyTests.swift b/CotabbyTests/CompletionRenderModePolicyTests.swift index a14fd4f..55fde62 100644 --- a/CotabbyTests/CompletionRenderModePolicyTests.swift +++ b/CotabbyTests/CompletionRenderModePolicyTests.swift @@ -123,4 +123,108 @@ final class CompletionRenderModePolicyTests: XCTestCase { .mirror(reason: .userPreference) ) } + + // MARK: - Mid-line caret promotion + + func test_auto_midLineCaret_promotesExactGeometryToMirror() { + // Exact geometry renders inline at end of line, but a caret with real characters after it has + // no inline home (the ghost would paint over the trailing text), so it promotes to the card. + let policy = CompletionRenderModePolicy(userPreference: .auto) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .exact, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: "com.apple.TextEdit"), + .mirror(reason: .caretMidLine) + ) + } + + func test_auto_midLineCaret_promotesDerivedGeometryToMirror() { + let policy = CompletionRenderModePolicy(userPreference: .auto) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .derived, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: "com.google.Chrome"), + .mirror(reason: .caretMidLine) + ) + } + + func test_auto_midLineCaret_keepsEstimatedReasonRatherThanOverwriting() { + // Estimated geometry already routes to the card; the promotion only upgrades inline results, + // so the more specific geometry reason is retained instead of being relabeled mid-line. + let policy = CompletionRenderModePolicy(userPreference: .auto) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .estimated, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: "com.microsoft.VSCode"), + .mirror(reason: .caretGeometryEstimated) + ) + } + + func test_alwaysInline_midLineCaret_isOverriddenToMirror() { + // Inline cannot render mid-line, so the mid-line rule overrides even an explicit inline pin. + // At the end of a line the pin is still honored (see the next test). + let policy = CompletionRenderModePolicy(userPreference: .alwaysInline) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .exact, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: nil), + .mirror(reason: .caretMidLine) + ) + } + + func test_alwaysInline_endOfLineCaret_staysInline() { + let policy = CompletionRenderModePolicy(userPreference: .alwaysInline) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .exact, + isCaretAtEndOfLine: true + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: nil), + .inline + ) + } + + func test_alwaysMirror_midLineCaret_keepsUserPreferenceReason() { + // Already a card; the promotion never runs, so the user-preference reason is preserved. + let policy = CompletionRenderModePolicy(userPreference: .alwaysMirror) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .exact, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: nil), + .mirror(reason: .userPreference) + ) + } + + func test_perAppInlineOverride_midLineCaret_isOverriddenToMirror() { + // A per-app inline override is still a request to render inline, which mid-line can't honor. + let policy = CompletionRenderModePolicy( + userPreference: .auto, + perAppOverrides: ["com.example.InlinePinned": .alwaysInline] + ) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretQuality: .exact, + isCaretAtEndOfLine: false + ) + + XCTAssertEqual( + policy.mode(for: geometry, bundleIdentifier: "com.example.InlinePinned"), + .mirror(reason: .caretMidLine) + ) + } } diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index d8ad18a..962089b 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -157,6 +157,7 @@ enum CotabbyTestFixtures { caretRect: CGRect = CGRect(x: 10, y: 20, width: 2, height: 18), inputFrameRect: CGRect? = CGRect(x: 0, y: 0, width: 240, height: 32), caretQuality: CaretGeometryQuality = .exact, + isCaretAtEndOfLine: Bool = true, observedCharWidth: CGFloat? = nil, isRightToLeft: Bool = false ) -> SuggestionOverlayGeometry { @@ -164,6 +165,7 @@ enum CotabbyTestFixtures { caretRect: caretRect, inputFrameRect: inputFrameRect, caretQuality: caretQuality, + isCaretAtEndOfLine: isCaretAtEndOfLine, observedCharWidth: observedCharWidth, isRightToLeft: isRightToLeft )