Skip to content

Commit e12d52a

Browse files
authored
Merge pull request #80 from IchenDEV/agent/focus-overlay-on-active-display
fix: place input overlay on focused display
2 parents 2fe7845 + 4c90782 commit e12d52a

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

‎Sources/App/VoicePipeline+Status.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ extension VoicePipeline {
66
func showOverlay() {
77
overlay.show(
88
appState: appState,
9+
targetApp: recordingTargetApp,
910
onCancel: { [weak self] in
1011
self?.cancel()
1112
},

‎Sources/UI/OverlayPanel.swift‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import AppKit
2+
import ApplicationServices
23
import SwiftUI
34

45
final class OverlayPanel {
56
private var window: NSPanel?
67
private var hostingView: NSHostingView<AnyView>?
8+
private var targetDisplayID: CGDirectDisplayID?
79

810
@MainActor
911
func show(
1012
appState: AppState,
13+
targetApp: NSRunningApplication?,
1114
onCancel: @escaping () -> Void,
1215
onConfirm: @escaping () -> Void
1316
) {
1417
let initialLayout = OverlayLayout(appState: appState)
18+
targetDisplayID = OverlayTargetScreenResolver.displayID(for: targetApp)
1519

1620
if window == nil {
1721
let panel = NSPanel(
@@ -61,6 +65,7 @@ final class OverlayPanel {
6165
window?.close()
6266
window = nil
6367
hostingView = nil
68+
targetDisplayID = nil
6469
}
6570

6671
@MainActor
@@ -78,15 +83,116 @@ final class OverlayPanel {
7883

7984
@MainActor
8085
private func frame(for size: CGSize, window: NSWindow) -> NSRect {
81-
let screen = window.screen ?? NSScreen.main
86+
let screen = OverlayTargetScreenResolver.screen(with: targetDisplayID)
87+
?? NSScreen.main
88+
?? window.screen
8289
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: size.width, height: size.height)
8390
return OverlayPanelPlacement.frame(for: size, in: visibleFrame)
8491
}
8592
}
8693

94+
@MainActor
95+
enum OverlayTargetScreenResolver {
96+
static func displayID(for app: NSRunningApplication?) -> CGDirectDisplayID? {
97+
guard let windowFrame = focusedWindowFrame(for: app) else {
98+
return NSScreen.main.flatMap(displayID(for:))
99+
}
100+
101+
let screens = NSScreen.screens
102+
// AX window geometry and CG display bounds share the same global coordinate space.
103+
let displayFrames = screens.map { screen in
104+
displayID(for: screen).map(CGDisplayBounds) ?? .null
105+
}
106+
guard let index = OverlayPanelPlacement.targetDisplayIndex(
107+
for: windowFrame,
108+
in: displayFrames
109+
) else {
110+
return NSScreen.main.flatMap(displayID(for:))
111+
}
112+
return displayID(for: screens[index])
113+
}
114+
115+
static func screen(with displayID: CGDirectDisplayID?) -> NSScreen? {
116+
guard let displayID else { return nil }
117+
return NSScreen.screens.first { self.displayID(for: $0) == displayID }
118+
}
119+
120+
private static func displayID(for screen: NSScreen) -> CGDirectDisplayID? {
121+
let key = NSDeviceDescriptionKey("NSScreenNumber")
122+
guard let number = screen.deviceDescription[key] as? NSNumber else { return nil }
123+
return CGDirectDisplayID(number.uint32Value)
124+
}
125+
126+
private static func focusedWindowFrame(for app: NSRunningApplication?) -> CGRect? {
127+
guard let app, AXIsProcessTrusted() else { return nil }
128+
let application = AXUIElementCreateApplication(app.processIdentifier)
129+
130+
for attribute in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] {
131+
guard let frame = windowFrame(from: application, attribute: attribute as CFString) else {
132+
continue
133+
}
134+
return frame
135+
}
136+
return nil
137+
}
138+
139+
private static func windowFrame(from application: AXUIElement, attribute: CFString) -> CGRect? {
140+
var windowValue: CFTypeRef?
141+
guard AXUIElementCopyAttributeValue(application, attribute, &windowValue) == .success,
142+
let rawWindow = windowValue,
143+
CFGetTypeID(rawWindow) == AXUIElementGetTypeID() else {
144+
return nil
145+
}
146+
147+
let window = rawWindow as! AXUIElement
148+
var positionValue: CFTypeRef?
149+
var sizeValue: CFTypeRef?
150+
guard AXUIElementCopyAttributeValue(
151+
window,
152+
kAXPositionAttribute as CFString,
153+
&positionValue
154+
) == .success,
155+
AXUIElementCopyAttributeValue(
156+
window,
157+
kAXSizeAttribute as CFString,
158+
&sizeValue
159+
) == .success,
160+
let rawPosition = positionValue,
161+
let rawSize = sizeValue,
162+
CFGetTypeID(rawPosition) == AXValueGetTypeID(),
163+
CFGetTypeID(rawSize) == AXValueGetTypeID() else {
164+
return nil
165+
}
166+
167+
var position = CGPoint.zero
168+
var size = CGSize.zero
169+
guard AXValueGetValue(rawPosition as! AXValue, .cgPoint, &position),
170+
AXValueGetValue(rawSize as! AXValue, .cgSize, &size),
171+
position.x.isFinite,
172+
position.y.isFinite,
173+
size.width.isFinite,
174+
size.height.isFinite,
175+
size.width > 0,
176+
size.height > 0 else {
177+
return nil
178+
}
179+
return CGRect(origin: position, size: size)
180+
}
181+
}
182+
87183
enum OverlayPanelPlacement {
88184
static let bottomMargin: CGFloat = 24
89185

186+
static func targetDisplayIndex(for windowFrame: CGRect, in displayFrames: [CGRect]) -> Int? {
187+
displayFrames.indices
188+
.map { index in
189+
(index: index, overlap: overlapArea(windowFrame, displayFrames[index]))
190+
}
191+
.filter { $0.overlap > 0 }
192+
.max { lhs, rhs in lhs.overlap < rhs.overlap }?
193+
.index
194+
}
195+
90196
static func frame(for size: CGSize, in visibleFrame: CGRect) -> CGRect {
91197
CGRect(
92198
x: visibleFrame.midX - size.width / 2,
@@ -95,4 +201,10 @@ enum OverlayPanelPlacement {
95201
height: size.height
96202
)
97203
}
204+
205+
private static func overlapArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat {
206+
let intersection = lhs.intersection(rhs)
207+
guard !intersection.isNull else { return 0 }
208+
return intersection.width * intersection.height
209+
}
98210
}

‎Tests/OpenTypeTests/OverlayLayoutTests.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,48 @@ final class OverlayLayoutTests: XCTestCase {
5959
XCTAssertEqual(frame.minY, visibleFrame.minY + 24)
6060
}
6161

62+
func testOverlayTargetsDisplayContainingFocusedWindow() {
63+
let displays = [
64+
CGRect(x: 0, y: 0, width: 1_512, height: 982),
65+
CGRect(x: -2_560, y: -323, width: 2_560, height: 1_440),
66+
]
67+
let focusedWindow = CGRect(x: -2_200, y: -200, width: 1_000, height: 800)
68+
69+
let index = OverlayPanelPlacement.targetDisplayIndex(
70+
for: focusedWindow,
71+
in: displays
72+
)
73+
74+
XCTAssertEqual(index, 1)
75+
}
76+
77+
func testOverlayTargetsDisplayWithMostOfFocusedWindow() {
78+
let displays = [
79+
CGRect(x: -1_920, y: 0, width: 1_920, height: 1_080),
80+
CGRect(x: 0, y: 0, width: 1_512, height: 982),
81+
]
82+
let focusedWindow = CGRect(x: -300, y: 100, width: 1_000, height: 700)
83+
84+
let index = OverlayPanelPlacement.targetDisplayIndex(
85+
for: focusedWindow,
86+
in: displays
87+
)
88+
89+
XCTAssertEqual(index, 1)
90+
}
91+
92+
func testOverlayHasNoTargetForWindowOutsideConnectedDisplays() {
93+
let displays = [CGRect(x: 0, y: 0, width: 1_512, height: 982)]
94+
let focusedWindow = CGRect(x: 4_000, y: 100, width: 800, height: 600)
95+
96+
let index = OverlayPanelPlacement.targetDisplayIndex(
97+
for: focusedWindow,
98+
in: displays
99+
)
100+
101+
XCTAssertNil(index)
102+
}
103+
62104
func testCancellingRecordingResetsStateWithoutProcessing() {
63105
let appState = AppState()
64106
appState.phase = .recording

0 commit comments

Comments
 (0)