Skip to content

Commit 538911d

Browse files
committed
Add OverlayWindowController
1 parent 118e7b3 commit 538911d

8 files changed

Lines changed: 301 additions & 2 deletions

File tree

Copilot for Xcode.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = "<group>"; };
236236
C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = "<group>"; };
237237
C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = "<group>"; };
238+
C8BE64912EB8964600EDB2D7 /* Window */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Window; sourceTree = SOURCE_ROOT; };
238239
C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = "<group>"; };
239240
C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = "<group>"; };
240241
C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = "<group>"; };
@@ -342,6 +343,7 @@
342343
C81458AE293A009800135263 /* Config.debug.xcconfig */,
343344
C8CD828229B88006008D044D /* TestPlan.xctestplan */,
344345
C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */,
346+
C8BE64912EB8964600EDB2D7 /* Window */,
345347
C84FD9D72CC671C600BE5093 /* ChatPlugins */,
346348
C81D181E2A1B509B006C1B70 /* Tool */,
347349
C8189B282938979000C9DCDA /* Core */,

Tool/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ let package = Package(
3434
.library(
3535
name: "SuggestionProvider",
3636
targets: ["SuggestionProvider", "GitHubCopilotService", "CodeiumService"]
37-
),
37+
),
3838
.library(
3939
name: "AppMonitoring",
4040
targets: [

Tool/Sources/XcodeInspector/AppInstanceInspector.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import AppKit
22
import Foundation
33

44
open class AppInstanceInspector: @unchecked Sendable {
5-
let runningApplication: NSRunningApplication
5+
public let runningApplication: NSRunningApplication
66
public let processIdentifier: pid_t
77
public let bundleURL: URL?
88
public let bundleIdentifier: String?

Window/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

Window/Package.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// swift-tools-version: 6.2
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "Window",
8+
platforms: [.macOS(.v12)],
9+
products: [
10+
.library(
11+
name: "Window",
12+
targets: ["Window"]
13+
),
14+
],
15+
dependencies: [
16+
.package(path: "../Tool"),
17+
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"),
18+
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.0"),
19+
],
20+
targets: [
21+
.target(
22+
name: "Window",
23+
dependencies: [
24+
.product(name: "AppMonitoring", package: "Tool"),
25+
.product(name: "Toast", package: "Tool"),
26+
.product(name: "Preferences", package: "Tool"),
27+
.product(name: "Logger", package: "Tool"),
28+
.product(name: "Perception", package: "swift-perception"),
29+
.product(name: "Dependencies", package: "swift-dependencies"),
30+
]
31+
),
32+
.testTarget(
33+
name: "WindowTests",
34+
dependencies: ["Window"]
35+
),
36+
]
37+
)
38+
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import AppKit
2+
import AXExtension
3+
import AXNotificationStream
4+
import Foundation
5+
import SwiftUI
6+
import XcodeInspector
7+
8+
public protocol IDEWorkspaceWindowOverlayWindowControllerContentProvider {
9+
associatedtype Content: View
10+
func createWindow() -> NSWindow?
11+
func createContent() -> Content
12+
13+
init(windowInspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication)
14+
}
15+
16+
extension IDEWorkspaceWindowOverlayWindowControllerContentProvider {
17+
var contentBody: AnyView {
18+
AnyView(createContent())
19+
}
20+
}
21+
22+
@MainActor
23+
final class IDEWorkspaceWindowOverlayWindowController {
24+
private var lastAccessDate: Date = .init()
25+
let application: NSRunningApplication
26+
let inspector: WorkspaceXcodeWindowInspector
27+
let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider]
28+
private var isDestroyed: Bool = false
29+
private let maskPanel: NSPanel
30+
private var axNotificationTask: Task<Void, Never>?
31+
32+
init(
33+
inspector: WorkspaceXcodeWindowInspector,
34+
application: NSRunningApplication,
35+
contentProviderFactory: (
36+
_ windowInspector: WorkspaceXcodeWindowInspector, _ application: NSRunningApplication
37+
) -> [any IDEWorkspaceWindowOverlayWindowControllerContentProvider]
38+
) {
39+
self.inspector = inspector
40+
self.application = application
41+
contentProviders = contentProviderFactory(inspector, application)
42+
43+
// Create the invisible panel
44+
let panel = NSPanel(
45+
contentRect: .zero,
46+
styleMask: [.borderless],
47+
backing: .buffered,
48+
defer: false
49+
)
50+
panel.isReleasedWhenClosed = false
51+
panel.isOpaque = false
52+
panel.backgroundColor = .clear
53+
panel.hasShadow = false
54+
panel.ignoresMouseEvents = true
55+
panel.alphaValue = 0
56+
panel.collectionBehavior = [.canJoinAllSpaces, .transient]
57+
panel.level = widgetLevel(0)
58+
panel.setIsVisible(true)
59+
maskPanel = panel
60+
61+
panel.contentView = NSHostingView(
62+
rootView: ZStack {
63+
ForEach(0..<contentProviders.count, id: \.self) { (index: Int) in
64+
self.contentProviders[index].contentBody
65+
}
66+
}
67+
.allowsHitTesting(false)
68+
)
69+
70+
for contentProvider in contentProviders {
71+
if let window = contentProvider.createWindow() {
72+
panel.addChildWindow(window, ordered: .above)
73+
}
74+
}
75+
76+
// Listen to AX notifications for window move/resize
77+
let windowElement = inspector.uiElement
78+
let stream = AXNotificationStream(
79+
app: application,
80+
element: windowElement,
81+
notificationNames: kAXMovedNotification, kAXResizedNotification
82+
)
83+
84+
axNotificationTask = Task { [weak self] in
85+
for await notification in stream {
86+
guard let self else { return }
87+
switch notification.name {
88+
case kAXMovedNotification, kAXResizedNotification:
89+
if let rect = windowElement.rect {
90+
self.maskPanel.setFrame(rect, display: false)
91+
}
92+
default: continue
93+
}
94+
}
95+
}
96+
97+
if let rect = windowElement.rect {
98+
maskPanel.setFrame(rect, display: false)
99+
}
100+
}
101+
102+
deinit {
103+
axNotificationTask?.cancel()
104+
_ = withExtendedLifetime(self) {
105+
Task { @MainActor in
106+
precondition(
107+
!self.isDestroyed,
108+
"IDEWorkspaceWindowOverlayWindowController should be destroyed before deinit"
109+
)
110+
}
111+
}
112+
}
113+
114+
/// Make the window the top most window and visible.
115+
func access() {
116+
lastAccessDate = Date()
117+
maskPanel.level = widgetLevel(0)
118+
maskPanel.setIsVisible(true)
119+
maskPanel.orderFrontRegardless()
120+
}
121+
122+
/// Stop keeping the window the top most window, do not change visibility.
123+
func dim() {
124+
maskPanel.level = .normal
125+
}
126+
127+
/// Hide the window.
128+
func hide() {
129+
maskPanel.setIsVisible(false)
130+
maskPanel.level = .normal
131+
}
132+
133+
/// Destroy the controller and clean up resources.
134+
func destroy() {
135+
axNotificationTask?.cancel()
136+
maskPanel.close()
137+
isDestroyed = true
138+
}
139+
}
140+
141+
func widgetLevel(_ addition: Int) -> NSWindow.Level {
142+
let minimumWidgetLevel: Int
143+
#if DEBUG
144+
minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1
145+
#else
146+
minimumWidgetLevel = NSWindow.Level.floating.rawValue
147+
#endif
148+
return .init(minimumWidgetLevel + addition)
149+
}
150+
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import AppKit
2+
import Foundation
3+
import Perception
4+
import XcodeInspector
5+
6+
@MainActor
7+
public final class OverlayWindowController {
8+
public typealias IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory = @Sendable (
9+
_ windowInspector: WorkspaceXcodeWindowInspector,
10+
_ application: NSRunningApplication
11+
) -> any IDEWorkspaceWindowOverlayWindowControllerContentProvider
12+
13+
var ideWindowOverlayWindowControllers: [URL: IDEWorkspaceWindowOverlayWindowController] = [:]
14+
var ideWindowOverlayWindowControllerContentProviderFactories:
15+
[IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = []
16+
17+
public init() {}
18+
19+
public func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory(
20+
_ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory
21+
) {
22+
ideWindowOverlayWindowControllerContentProviderFactories.append(factory)
23+
}
24+
}
25+
26+
extension OverlayWindowController {
27+
func observeEvents() {}
28+
}
29+
30+
private extension OverlayWindowController {
31+
func observeWindowChange() {
32+
withPerceptionTracking {
33+
_ = XcodeInspector.shared.focusedWindow
34+
_ = XcodeInspector.shared.activeXcode
35+
} onChange: { [weak self] in
36+
guard let self else { return }
37+
Task { @MainActor in
38+
defer { self.observeWindowChange() }
39+
40+
guard let app = XcodeInspector.shared.activeXcode else {
41+
for (_, controller) in self.ideWindowOverlayWindowControllers {
42+
controller.hide()
43+
}
44+
return
45+
}
46+
47+
let windowInspector = XcodeInspector.shared.focusedWindow
48+
if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector {
49+
let workspaceURL = ideWindowInspector.workspaceURL
50+
// Workspace window is active
51+
// Hide all controllers first
52+
for (url, controller) in self.ideWindowOverlayWindowControllers {
53+
if url != workspaceURL {
54+
controller.hide()
55+
}
56+
}
57+
if let controller = self.ideWindowOverlayWindowControllers[workspaceURL] {
58+
controller.access()
59+
} else {
60+
self.createNewIDEOverlayWindowController(
61+
for: workspaceURL,
62+
inspector: ideWindowInspector,
63+
application: app.runningApplication
64+
)
65+
}
66+
} else {
67+
// Not a workspace window, dim all controllers
68+
for (_, controller) in self.ideWindowOverlayWindowControllers {
69+
controller.dim()
70+
}
71+
}
72+
}
73+
}
74+
}
75+
76+
func createNewIDEOverlayWindowController(
77+
for workspaceURL: URL,
78+
inspector: WorkspaceXcodeWindowInspector,
79+
application: NSRunningApplication
80+
) {
81+
let newController = IDEWorkspaceWindowOverlayWindowController(
82+
inspector: inspector,
83+
application: application,
84+
contentProviderFactory: { [ideWindowOverlayWindowControllerContentProviderFactories]
85+
windowInspector, application in
86+
ideWindowOverlayWindowControllerContentProviderFactories.map {
87+
$0(windowInspector, application)
88+
}
89+
}
90+
)
91+
newController.access()
92+
ideWindowOverlayWindowControllers[workspaceURL] = newController
93+
}
94+
}
95+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Testing
2+
@testable import Window
3+
4+
@Test func example() async throws {
5+
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
6+
}

0 commit comments

Comments
 (0)