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
4 changes: 4 additions & 0 deletions tabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
E10000022F93000100DDD002 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */; };
E10000032F93000100DDD003 /* GhostSuggestionLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */; };
E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */; };
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */; };
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -60,6 +61,7 @@
E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = "<group>"; };
E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayoutTests.swift; sourceTree = "<group>"; };
E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = "<group>"; };
E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = "<group>"; };
F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextDirectionDetectorTests.swift; sourceTree = "<group>"; };
F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LlamaPromptRendererTests.swift; sourceTree = "<group>"; };
F9D35DB9E86506B9FAE1CFE9 /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -133,6 +135,7 @@
E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */,
E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */,
E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */,
E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */,
F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */,
);
path = tabbyTests;
Expand Down Expand Up @@ -271,6 +274,7 @@
E10000022F93000100DDD002 /* PermissionAndContextModelTests.swift in Sources */,
E10000032F93000100DDD003 /* GhostSuggestionLayoutTests.swift in Sources */,
E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */,
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */,
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
6 changes: 6 additions & 0 deletions tabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ final class OverlayController: SuggestionOverlayControlling {
}

private func targetScreenVisibleFrame(for caretRect: CGRect) -> CGRect {
let midpoint = CGPoint(x: caretRect.midX, y: caretRect.midY)

if let screen = NSScreen.screens.first(where: { $0.visibleFrame.contains(midpoint) }) {
return screen.visibleFrame
}

if let screen = NSScreen.screens.first(where: { $0.frame.intersects(caretRect) }) {
return screen.visibleFrame
}
Expand Down
102 changes: 56 additions & 46 deletions tabby/Support/AXHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,25 +287,23 @@

// MARK: - Coordinate Conversion

/// Converts raw Accessibility coordinates into global AppKit points via a simple Y-flip.
/// Converts raw Accessibility coordinates into global AppKit points via a per-display Y-flip.
/// Use this for element-level rects (AXFrame) that are reliably in Cocoa points.
/// For text-range rects (BoundsForRange, TextMarker), use `validatedCocoaTextRect` instead.
static func cocoaRect(fromAccessibilityRect rect: CGRect) -> CGRect {
guard !rect.isNull, rect != .zero else {
return rect
}

let desktopBounds = desktopUnionFrame()
guard !desktopBounds.isNull else {
return rect
let displays = displayGeometries()
if let converted = DisplayCoordinateConverter.appKitRect(
fromCoreGraphicsRect: rect,
displays: displays
) {
return converted
}

return CGRect(
x: rect.origin.x,
y: desktopBounds.maxY - rect.origin.y - rect.height,
width: rect.width,
height: rect.height
)
return legacyDesktopUnionFlip(rect)
}

/// Converts a text-range AX rect to Cocoa coordinates, using the element's AXFrame (already
Expand All @@ -322,18 +320,16 @@
return textRect
}

let desktopBounds = desktopUnionFrame()
guard !desktopBounds.isNull else {
let displays = displayGeometries()
guard !displays.isEmpty else {
return textRect
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Candidate A: plain Y-flip, assuming the AX rect is already in Cocoa points.
let flipped = CGRect(
x: textRect.origin.x,
y: desktopBounds.maxY - textRect.origin.y - textRect.height,
width: textRect.width,
height: textRect.height
)
let flipped = DisplayCoordinateConverter.appKitRect(
fromCoreGraphicsRect: textRect,
displays: displays
) ?? legacyDesktopUnionFlip(textRect)

guard let anchor = cocoaAnchorFrame, !anchor.isEmpty else {
// No anchor available — plain Y-flip is the safest default.
Expand All @@ -348,41 +344,55 @@
return flipped
}

// Candidate B: divide by backing scale factor first (Chromium pixel-space workaround),
// then Y-flip. Some apps return physical pixels for text ranges on Retina.
let fallbackScale = NSScreen.main?.backingScaleFactor ?? 2.0
let scale: CGFloat = NSScreen.screens.first(where: {
$0.frame.contains(CGPoint(
x: textRect.origin.x / fallbackScale,
y: $0.frame.maxY - (textRect.origin.y / fallbackScale)
))
})?.backingScaleFactor ?? fallbackScale

let scaled = CGRect(
x: textRect.origin.x / scale,
y: textRect.origin.y / scale,
width: textRect.width / scale,
height: textRect.height / scale
)
let scaledFlipped = CGRect(
x: scaled.origin.x,
y: desktopBounds.maxY - scaled.origin.y - scaled.height,
width: scaled.width,
height: scaled.height
)

if expandedAnchor.contains(CGPoint(x: scaledFlipped.midX, y: scaledFlipped.midY)) {
return scaledFlipped
// Candidate B: some apps report text-range bounds in physical pixels on Retina screens.
// Scale relative to the owning display's origin; dividing global coordinates directly
// breaks when an external monitor has a non-zero or negative origin.
for scaledFlipped in DisplayCoordinateConverter.appKitRectsFromPixelRect(
textRect,
displays: displays
) {
if expandedAnchor.contains(CGPoint(x: scaledFlipped.midX, y: scaledFlipped.midY)) {

Check warning on line 354 in tabby/Support/AXHelper.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

`where` clauses are preferred over a single `if` inside a `for` (for_where)
return scaledFlipped
}
}

// Neither candidate landed near the anchor. Return unscaled as best-effort.
return flipped
}

/// Union of all connected screen frames — used for AX top-left → Cocoa bottom-left conversion.
private static func desktopUnionFrame() -> CGRect {
NSScreen.screens
private static func displayGeometries() -> [DisplayGeometry] {
NSScreen.screens.compactMap { screen in
guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]
as? NSNumber
else {
return nil
}

let displayID = CGDirectDisplayID(number.uint32Value)
return DisplayGeometry(
appKitFrame: screen.frame,
visibleFrame: screen.visibleFrame,
coreGraphicsBounds: CGDisplayBounds(displayID),
backingScaleFactor: screen.backingScaleFactor
)
}
}

/// Last-resort fallback for unusual virtual displays where AppKit cannot expose a display ID.
private static func legacyDesktopUnionFlip(_ rect: CGRect) -> CGRect {
let desktopBounds = NSScreen.screens
.map(\.frame)
.reduce(into: CGRect.null) { $0 = $0.union($1) }

guard !desktopBounds.isNull else {
return rect
}

return CGRect(
x: rect.origin.x,
y: desktopBounds.maxY - rect.origin.y - rect.height,
width: rect.width,
height: rect.height
)
}
}
110 changes: 110 additions & 0 deletions tabby/Support/DisplayCoordinateConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import CoreGraphics

/// Describes one physical display in both coordinate systems Tabby has to bridge.
///
/// Accessibility and CoreGraphics APIs report rectangles in a top-left-origin display space.
/// AppKit windows use a bottom-left-origin screen space. Keeping both frames together lets the
/// conversion flip Y inside the display that actually owns the rectangle instead of using a
/// fragile union of every connected monitor.
struct DisplayGeometry: Equatable {
let appKitFrame: CGRect
let visibleFrame: CGRect
let coreGraphicsBounds: CGRect
let backingScaleFactor: CGFloat
}

/// Pure display-coordinate conversion shared by AX geometry and tests.
///
/// This type intentionally knows nothing about `NSScreen`; callers pass snapshots of display
/// geometry in. That keeps the math testable for external-monitor arrangements that are awkward
/// to reproduce in CI, such as a secondary display above the primary display.
enum DisplayCoordinateConverter {
static func appKitRect(
fromCoreGraphicsRect rect: CGRect,
displays: [DisplayGeometry]
) -> CGRect? {
guard let display = bestDisplay(
for: rect,
displays: displays,
keyPath: \.coreGraphicsBounds
) else {
return nil
}

return appKitRect(fromCoreGraphicsRect: rect, on: display)
}

static func appKitRectsFromPixelRect(
_ rect: CGRect,
displays: [DisplayGeometry]
) -> [CGRect] {
displays.compactMap { display in
guard display.backingScaleFactor > 0 else { return nil }

let pixelBounds = CGRect(
x: display.coreGraphicsBounds.minX * display.backingScaleFactor,
y: display.coreGraphicsBounds.minY * display.backingScaleFactor,
width: display.coreGraphicsBounds.width * display.backingScaleFactor,
height: display.coreGraphicsBounds.height * display.backingScaleFactor
)

let midpoint = CGPoint(x: rect.midX, y: rect.midY)
guard pixelBounds.intersects(rect) || pixelBounds.contains(midpoint) else {
return nil
}

let pointRect = CGRect(
x: display.coreGraphicsBounds.minX
+ (rect.minX - pixelBounds.minX) / display.backingScaleFactor,
y: display.coreGraphicsBounds.minY
+ (rect.minY - pixelBounds.minY) / display.backingScaleFactor,
width: rect.width / display.backingScaleFactor,
height: rect.height / display.backingScaleFactor
)

return appKitRect(fromCoreGraphicsRect: pointRect, on: display)
}
}

private static func appKitRect(
fromCoreGraphicsRect rect: CGRect,
on display: DisplayGeometry
) -> CGRect {
let localX = rect.minX - display.coreGraphicsBounds.minX
let localY = rect.minY - display.coreGraphicsBounds.minY

return CGRect(
x: display.appKitFrame.minX + localX,
y: display.appKitFrame.maxY - localY - rect.height,
width: rect.width,
height: rect.height
)
}

private static func bestDisplay(
for rect: CGRect,
displays: [DisplayGeometry],
keyPath: KeyPath<DisplayGeometry, CGRect>
) -> DisplayGeometry? {
let midpoint = CGPoint(x: rect.midX, y: rect.midY)

if let containingDisplay = displays.first(where: {
$0[keyPath: keyPath].contains(midpoint)
}) {
return containingDisplay
}

return displays
.filter { $0[keyPath: keyPath].intersects(rect) }
.max { lhs, rhs in
intersectionArea(lhs[keyPath: keyPath], rect)
< intersectionArea(rhs[keyPath: keyPath], rect)
}
}

private static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat {
let intersection = lhs.intersection(rhs)
guard !intersection.isNull else { return 0 }
return intersection.width * intersection.height
}
}
71 changes: 71 additions & 0 deletions tabbyTests/DisplayCoordinateConverterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import CoreGraphics
import XCTest
@testable import tabby

final class DisplayCoordinateConverterTests: XCTestCase {
func test_appKitRect_flipsWithinOwningDisplayAbovePrimary() {
let primary = DisplayGeometry(
appKitFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875),
coreGraphicsBounds: CGRect(x: 0, y: 0, width: 1440, height: 900),
backingScaleFactor: 2
)
let displayAbove = DisplayGeometry(
appKitFrame: CGRect(x: 0, y: 900, width: 1920, height: 1080),
visibleFrame: CGRect(x: 0, y: 900, width: 1920, height: 1055),
coreGraphicsBounds: CGRect(x: 0, y: -1080, width: 1920, height: 1080),
backingScaleFactor: 1
)

let rect = DisplayCoordinateConverter.appKitRect(
fromCoreGraphicsRect: CGRect(x: 120, y: -1000, width: 300, height: 20),
displays: [primary, displayAbove]
)

XCTAssertEqual(rect, CGRect(x: 120, y: 1880, width: 300, height: 20))
}

func test_appKitRect_preservesNegativeXWhenRectCrossesDisplayBoundary() {
let left = DisplayGeometry(
appKitFrame: CGRect(x: -1280, y: 0, width: 1280, height: 720),
visibleFrame: CGRect(x: -1280, y: 0, width: 1280, height: 700),
coreGraphicsBounds: CGRect(x: -1280, y: 0, width: 1280, height: 720),
backingScaleFactor: 1
)
let primary = DisplayGeometry(
appKitFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875),
coreGraphicsBounds: CGRect(x: 0, y: 0, width: 1440, height: 900),
backingScaleFactor: 2
)

let rect = DisplayCoordinateConverter.appKitRect(
fromCoreGraphicsRect: CGRect(x: -20, y: 100, width: 80, height: 20),
displays: [left, primary]
)

XCTAssertEqual(rect, CGRect(x: -20, y: 780, width: 80, height: 20))
}

func test_appKitRectsFromPixelRect_scalesRelativeToDisplayOrigin() {
let primary = DisplayGeometry(
appKitFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875),
coreGraphicsBounds: CGRect(x: 0, y: 0, width: 1440, height: 900),
backingScaleFactor: 2
)
let rightRetina = DisplayGeometry(
appKitFrame: CGRect(x: 1440, y: 0, width: 1512, height: 982),
visibleFrame: CGRect(x: 1440, y: 0, width: 1512, height: 957),
coreGraphicsBounds: CGRect(x: 1440, y: 0, width: 1512, height: 982),
backingScaleFactor: 2
)

let rects = DisplayCoordinateConverter.appKitRectsFromPixelRect(
CGRect(x: 1440 * 2 + 200, y: 120, width: 80, height: 40),
displays: [primary, rightRetina]
)

XCTAssertEqual(rects, [CGRect(x: 1540, y: 902, width: 40, height: 20)])
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
Loading