diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index 04684349..00c8a17a 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -353,10 +353,11 @@ final class OverlayController: SuggestionOverlayControlling { return true } - /// Mirror-mode rendering. Draws the suggestion inside a Cotabby-owned card anchored to the - /// input field rectangle (not the caret rect) so unreliable caret geometry does not propagate - /// into the card position. The card is otherwise visually similar to inline ghost text plus a - /// backdrop that makes it read as a UI element rather than free-floating text. + /// Mirror-mode rendering. Draws the suggestion inside a Cotabby-owned card anchored beneath the + /// best available text line. Estimated geometry remains too weak for inline glyphs, but its + /// vertical line box keeps the popup beside the visible text instead of below the field chrome. + /// The card is otherwise visually similar to inline ghost text plus a backdrop that makes it + /// read as a UI element rather than free-floating text. private func showMirror( text: String, geometry: SuggestionOverlayGeometry, diff --git a/Cotabby/Support/CompletionRenderModePolicy.swift b/Cotabby/Support/CompletionRenderModePolicy.swift index 7abc4dbe..ff968978 100644 --- a/Cotabby/Support/CompletionRenderModePolicy.swift +++ b/Cotabby/Support/CompletionRenderModePolicy.swift @@ -112,13 +112,12 @@ struct CompletionRenderModePolicy: Equatable, Sendable { // 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. + // Both estimate qualities go to the card. `.estimated` is not precise enough to paint + // inline glyphs, but its vertical line box is useful for popup placement: the AXFrame + // fallback centers single-line text and bottom-aligns multiline text. `.layoutEstimated` + // means the hidden-TextKit repair produced a more confident caret estimate. It still uses + // a card because that estimate is good enough to place a popup, not to paint glyphs the + // eye will scrutinize against the host's own text. switch geometry.caretQuality { case .estimated: return .mirror(reason: .caretGeometryEstimated) diff --git a/Cotabby/Support/MirrorOverlayLayout.swift b/Cotabby/Support/MirrorOverlayLayout.swift index c0b7d273..28cb26ee 100644 --- a/Cotabby/Support/MirrorOverlayLayout.swift +++ b/Cotabby/Support/MirrorOverlayLayout.swift @@ -4,10 +4,11 @@ import Foundation /// Pure layout math for the mirror-overlay rendering mode. /// -/// Mirror mode is reached when the host's caret geometry is unreliable, so this helper does not -/// anchor to the caret rect for positioning — it anchors to the input field's frame and falls back -/// to the caret rect only when the field frame is missing. That ordering is the opposite of the -/// inline ghost layout, which trusts the caret rect first. +/// Mirror mode is reached when the host's caret geometry is not precise enough for inline text. +/// The card can still use the caret's vertical line box: estimated single-line fallbacks deliberately +/// center that box inside the field chrome, while multiline/full-frame fallbacks keep its bottom edge +/// aligned with the field. This lets the popup follow the visible text without claiming enough +/// precision to draw glyphs inline. /// /// Layout decisions live here as a pure value type so `OverlayController` can stay focused on /// AppKit window plumbing and the rules below stay easy to test without spinning up SwiftUI. @@ -73,9 +74,9 @@ struct MirrorOverlayLayout: Equatable { /// Computes the layout for one presentation. /// - /// `geometry.inputFrameRect` is the preferred anchor. When it is nil the card falls back to a - /// fixed offset below the caret rect — worse, but at least directionally correct. `visibleFrame` - /// is the target screen's visible region and is used to clamp the card on-screen. + /// The caret line is the preferred vertical anchor. AXFrame-only multiline and degenerate + /// fallbacks share the field's bottom edge, so they naturally retain the conservative field + /// anchor. `visibleFrame` is the target screen's visible region and keeps the card on-screen. static func make( suggestion: String, geometry: SuggestionOverlayGeometry, @@ -153,9 +154,9 @@ struct MirrorOverlayLayout: Equatable { /// /// The anchor choice depends on *why* mirror mode is active: /// - /// - `.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. + /// - `.caretGeometryEstimated` means the host did not expose a caret precise enough for inline + /// glyphs. Its vertical line box is still useful: AXFrame-only single-line estimates center it + /// inside the field, while multiline/full-frame estimates share the field's bottom edge. /// - `.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 @@ -167,6 +168,14 @@ struct MirrorOverlayLayout: Equatable { ) -> CGFloat { switch reason { case .caretGeometryEstimated: + // `AXTextGeometryResolver.estimatedCaretRect` gives single-line fields a centered line + // box instead of the full control height. Anchoring to that rect removes the extra + // chrome padding that previously dropped omnibox popups by roughly one text row. For + // multiline or unrefined AXFrame fallbacks, caret.minY equals inputFrame.minY, so this + // preserves the old conservative field-bottom placement. + if !geometry.caretRect.isEmpty { + return geometry.caretRect.minY - Metrics.anchorGap + } if let inputFrame = geometry.inputFrameRect?.standardized, !inputFrame.isEmpty { return inputFrame.minY - Metrics.anchorGap } diff --git a/CotabbyTests/MirrorOverlayLayoutTests.swift b/CotabbyTests/MirrorOverlayLayoutTests.swift index 9fa6bcb4..d3fd0faa 100644 --- a/CotabbyTests/MirrorOverlayLayoutTests.swift +++ b/CotabbyTests/MirrorOverlayLayoutTests.swift @@ -114,13 +114,13 @@ final class MirrorOverlayLayoutTests: XCTestCase { ) } - func test_make_estimatedReasonStillAnchorsToInputField() { - // Same geometry as the user-preference test above, but with .caretGeometryEstimated. The - // caret rect is exactly the kind of value that can't be trusted in this case, so the layout - // must keep the field-rect anchor it had before the fix. + func test_make_estimatedReasonAnchorsToCenteredCaretLine() { + // Browser omniboxes expose a tall field frame around a much shorter text line. The AXFrame + // fallback centers its estimated caret line inside that chrome; the popup must follow the + // line rather than adding the field's lower padding as an extra row of vertical offset. let geometry = CotabbyTestFixtures.overlayGeometry( - caretRect: CGRect(x: 720, y: 500, width: 2, height: 18), - inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 200) + caretRect: CGRect(x: 720, y: 418, width: 2, height: 18), + inputFrameRect: CGRect(x: 400, y: 400, width: 640, height: 54) ) let layout = MirrorOverlayLayout.make( @@ -131,11 +131,42 @@ final class MirrorOverlayLayoutTests: XCTestCase { reason: .caretGeometryEstimated ) - // Card sits below the FIELD edge, well below the caret line. - XCTAssertLessThan( + XCTAssertEqual( + layout.panelFrame.maxY, + geometry.caretRect.minY - 8, + accuracy: 0.001, + "Estimated popup should sit directly below the centered text line" + ) + XCTAssertGreaterThan( layout.panelFrame.maxY, geometry.inputFrameRect!.minY, - "Estimated-reason popup should still anchor below the input field rect" + "Field chrome padding should not push the popup down by another row" + ) + } + + func test_make_estimatedMultilineFallbackRetainsFieldBottomAnchor() { + // When AX only exposes a multiline field frame, the resolver deliberately bottom-aligns + // the estimated caret. The caret and field therefore share minY, preserving the old safe + // placement when the actual visible line cannot be inferred. + let frame = CGRect(x: 400, y: 400, width: 640, height: 200) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 720, y: frame.minY, width: 2, height: 18), + inputFrameRect: frame + ) + + let layout = MirrorOverlayLayout.make( + suggestion: "hello", + geometry: geometry, + visibleFrame: screen, + showsAcceptanceHint: true, + reason: .caretGeometryEstimated + ) + + XCTAssertEqual( + layout.panelFrame.maxY, + frame.minY - 8, + accuracy: 0.001, + "Multiline AXFrame fallback should remain below the field bottom" ) }