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
9 changes: 5 additions & 4 deletions Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 6 additions & 7 deletions Cotabby/Support/CompletionRenderModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions Cotabby/Support/MirrorOverlayLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
49 changes: 40 additions & 9 deletions CotabbyTests/MirrorOverlayLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"
)
}

Expand Down
Loading