Skip to content

Commit da14062

Browse files
committed
Merge branch 'feature/remove-display-link' into develop
2 parents 5a481e5 + e7edc73 commit da14062

4 files changed

Lines changed: 152 additions & 100 deletions

File tree

Lines changed: 129 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import ActiveApplicationMonitor
12
import AppKit
3+
import AXNotificationStream
24
import DisplayLink
35
import Environment
46
import QuartzCore
57
import SwiftUI
68
import XPCShared
79

810
/// Present a tiny dot next to mouse cursor if real-time suggestion is enabled.
11+
@MainActor
912
final class RealtimeSuggestionIndicatorController {
1013
class IndicatorContentViewModel: ObservableObject {
1114
@Published var isPrefetching = false
15+
@Published var progress: Double = 1
1216
private var prefetchTask: Task<Void, Error>?
1317

1418
@MainActor
@@ -18,33 +22,58 @@ final class RealtimeSuggestionIndicatorController {
1822
isPrefetching = true
1923
}
2024
prefetchTask = Task {
21-
try await Task.sleep(nanoseconds: 2 * 1_000_000_000)
22-
withAnimation(.easeOut(duration: 0.2)) {
23-
isPrefetching = false
25+
try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
26+
if isPrefetching {
27+
endPrefetch()
2428
}
2529
}
2630
}
31+
32+
@MainActor
33+
func endPrefetch() {
34+
withAnimation(.easeOut(duration: 0.2)) {
35+
isPrefetching = false
36+
}
37+
}
2738
}
2839

2940
struct IndicatorContentView: View {
3041
@ObservedObject var viewModel: IndicatorContentViewModel
31-
@State var progress: CGFloat = 1
32-
var opacityA: CGFloat { min(progress, 0.7) }
33-
var opacityB: CGFloat { 1 - progress }
34-
var scaleA: CGFloat { progress / 2 + 0.5 }
35-
var scaleB: CGFloat { max(1 - progress, 0.01) }
42+
var opacityA: CGFloat { min(viewModel.progress, 0.7) }
43+
var opacityB: CGFloat { 1 - viewModel.progress }
44+
var scaleA: CGFloat { viewModel.progress / 2 + 0.5 }
45+
var scaleB: CGFloat { max(1 - viewModel.progress, 0.01) }
3646

3747
var body: some View {
3848
Circle()
3949
.fill(Color.accentColor.opacity(opacityA))
4050
.scaleEffect(.init(width: scaleA, height: scaleA))
4151
.frame(width: 8, height: 8)
42-
.background(
43-
Circle()
44-
.fill(Color.white.opacity(viewModel.isPrefetching ? opacityB : 0))
45-
.scaleEffect(.init(width: scaleB, height: scaleB))
46-
.frame(width: 8, height: 8)
47-
)
52+
.overlay {
53+
if viewModel.isPrefetching {
54+
Circle()
55+
.fill(Color.white.opacity(opacityB))
56+
.scaleEffect(.init(width: scaleB, height: scaleB))
57+
.frame(width: 8, height: 8)
58+
.onAppear {
59+
Task {
60+
await Task.yield()
61+
withAnimation(
62+
.easeInOut(duration: 0.4)
63+
.repeatForever(
64+
autoreverses: true
65+
)
66+
) {
67+
viewModel.progress = 0
68+
}
69+
}
70+
}.onDisappear {
71+
withAnimation(.default) {
72+
viewModel.progress = 1
73+
}
74+
}
75+
}
76+
}
4877
}
4978
}
5079

@@ -63,6 +92,9 @@ final class RealtimeSuggestionIndicatorController {
6392

6493
private let viewModel = IndicatorContentViewModel()
6594
private var userDefaultsObserver = UserDefaultsObserver()
95+
private var windowChangeObservationTask: Task<Void, Error>?
96+
private var activeApplicationMonitorTask: Task<Void, Error>?
97+
private var xcode: NSRunningApplication?
6698
var isObserving = false {
6799
didSet {
68100
Task {
@@ -71,8 +103,6 @@ final class RealtimeSuggestionIndicatorController {
71103
}
72104
}
73105

74-
private var displayLinkTask: Task<Void, Never>?
75-
76106
@MainActor
77107
lazy var window = {
78108
let it = NSWindow(
@@ -92,35 +122,34 @@ final class RealtimeSuggestionIndicatorController {
92122
return it
93123
}()
94124

95-
init() {
96-
Task {
97-
let sequence = NSWorkspace.shared.notificationCenter
98-
.notifications(named: NSWorkspace.didActivateApplicationNotification)
99-
for await notification in sequence {
100-
guard let app = notification
101-
.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
102-
else { continue }
103-
guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue }
104-
await updateIndicatorVisibility()
105-
}
106-
}
107-
108-
Task {
109-
let sequence = NSWorkspace.shared.notificationCenter
110-
.notifications(named: NSWorkspace.didDeactivateApplicationNotification)
111-
for await notification in sequence {
112-
guard let app = notification
113-
.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
114-
else { continue }
115-
guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue }
116-
await updateIndicatorVisibility()
125+
nonisolated init() {
126+
Task { @MainActor in
127+
activeApplicationMonitorTask = Task { [weak self] in
128+
var previousApp: NSRunningApplication?
129+
for await app in ActiveApplicationMonitor.createStream() {
130+
guard let self else { return }
131+
try Task.checkCancellation()
132+
defer { previousApp = app }
133+
if let app = ActiveApplicationMonitor.activeXcode {
134+
if app != previousApp {
135+
windowChangeObservationTask?.cancel()
136+
windowChangeObservationTask = nil
137+
self.observeXcodeWindowChangeIfNeeded(app)
138+
}
139+
await self.updateIndicatorVisibility()
140+
self.updateIndicatorLocation()
141+
} else {
142+
await self.updateIndicatorVisibility()
143+
}
144+
}
117145
}
118146
}
119147

120-
Task {
148+
Task { @MainActor in
121149
userDefaultsObserver.onChange = { [weak self] in
122150
Task { [weak self] in
123151
await self?.updateIndicatorVisibility()
152+
self?.updateIndicatorLocation()
124153
}
125154
}
126155
UserDefaults.shared.addObserver(
@@ -132,74 +161,84 @@ final class RealtimeSuggestionIndicatorController {
132161
}
133162
}
134163

164+
private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) {
165+
xcode = app
166+
guard windowChangeObservationTask == nil else { return }
167+
windowChangeObservationTask = Task {
168+
let notifications = AXNotificationStream(
169+
app: app,
170+
notificationNames:
171+
kAXMovedNotification,
172+
kAXResizedNotification,
173+
kAXMainWindowChangedNotification,
174+
kAXFocusedWindowChangedNotification,
175+
kAXFocusedUIElementChangedNotification,
176+
kAXSelectedTextChangedNotification
177+
)
178+
for await _ in notifications {
179+
try Task.checkCancellation()
180+
updateIndicatorLocation()
181+
}
182+
}
183+
}
184+
135185
private func updateIndicatorVisibility() async {
136186
let isVisible = await {
137187
let isOn = UserDefaults.shared.bool(forKey: SettingsKey.realtimeSuggestionToggle)
138188
let isXcodeActive = await Environment.isXcodeActive()
139-
return isOn && isXcodeActive && isObserving
189+
return isOn && isXcodeActive
140190
}()
141191

142-
await { @MainActor in
143-
guard window.isVisible != isVisible else { return }
144-
if isVisible {
145-
if displayLinkTask == nil {
146-
displayLinkTask = Task {
147-
for await _ in DisplayLink.createStream() {
148-
self.updateIndicatorLocation()
149-
}
150-
}
151-
}
152-
} else {
153-
displayLinkTask?.cancel()
154-
displayLinkTask = nil
155-
}
156-
window.setIsVisible(isVisible)
157-
}()
192+
guard window.isVisible != isVisible else { return }
193+
window.setIsVisible(isVisible)
158194
}
159195

160196
private func updateIndicatorLocation() {
161-
Task { @MainActor in
162-
if !window.isVisible {
163-
return
164-
}
197+
if !window.isVisible {
198+
return
199+
}
165200

166-
if let activeXcode = NSRunningApplication
167-
.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode")
168-
.first(where: \.isActive)
201+
if let activeXcode = NSRunningApplication
202+
.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode")
203+
.first(where: \.isActive)
204+
{
205+
let application = AXUIElementCreateApplication(activeXcode.processIdentifier)
206+
if let focusElement: AXUIElement = try? application
207+
.copyValue(key: kAXFocusedUIElementAttribute),
208+
let focusElementType: String = try? focusElement
209+
.copyValue(key: kAXDescriptionAttribute),
210+
focusElementType == "Source Editor",
211+
let selectedRange: AXValue = try? focusElement
212+
.copyValue(key: kAXSelectedTextRangeAttribute),
213+
let rect: AXValue = try? focusElement.copyParameterizedValue(
214+
key: kAXBoundsForRangeParameterizedAttribute,
215+
parameters: selectedRange
216+
)
169217
{
170-
let application = AXUIElementCreateApplication(activeXcode.processIdentifier)
171-
if let focusElement: AXUIElement = try? application
172-
.copyValue(key: kAXFocusedUIElementAttribute),
173-
let selectedRange: AXValue = try? focusElement
174-
.copyValue(key: kAXSelectedTextRangeAttribute),
175-
let rect: AXValue = try? focusElement.copyParameterizedValue(
176-
key: kAXBoundsForRangeParameterizedAttribute,
177-
parameters: selectedRange
218+
var frame: CGRect = .zero
219+
let found = AXValueGetValue(rect, .cgRect, &frame)
220+
let screen = NSScreen.screens.first
221+
if found, let screen {
222+
frame.origin = .init(
223+
x: frame.maxX + 2,
224+
y: screen.frame.height - frame.minY - 4
178225
)
179-
{
180-
var frame: CGRect = .zero
181-
let found = AXValueGetValue(rect, .cgRect, &frame)
182-
let screen = NSScreen.screens.first
183-
if found, let screen {
184-
frame.origin = .init(
185-
x: frame.maxX + 2,
186-
y: screen.frame.height - frame.minY - 4
187-
)
188-
frame.size = .init(width: 10, height: 10)
189-
window.alphaValue = 1
190-
window.setFrame(frame, display: false, animate: true)
191-
return
192-
}
226+
frame.size = .init(width: 10, height: 10)
227+
window.alphaValue = 1
228+
window.setFrame(frame, display: false)
229+
return
193230
}
194231
}
195-
196-
window.alphaValue = 0
197232
}
233+
234+
window.alphaValue = 0
198235
}
199236

200237
func triggerPrefetchAnimation() {
201-
Task { @MainActor in
202-
viewModel.prefetch()
203-
}
238+
viewModel.prefetch()
239+
}
240+
241+
func endPrefetchAnimation() {
242+
viewModel.endPrefetch()
204243
}
205244
}

Core/Sources/Service/RealtimeSuggestionController.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ public actor RealtimeSuggestionController {
4444
}
4545
}
4646
}
47-
if eventObserver.activateIfPossible() {
48-
realtimeSuggestionIndicatorController.isObserving = true
49-
}
47+
eventObserver.activateIfPossible()
5048
}
5149

5250
private func stop(by listener: AnyHashable) {
@@ -55,7 +53,6 @@ public actor RealtimeSuggestionController {
5553
task?.cancel()
5654
task = nil
5755
eventObserver.deactivate()
58-
realtimeSuggestionIndicatorController.isObserving = false
5956
}
6057

6158
func handleKeyboardEvent(event: CGEvent) async {
@@ -107,14 +104,14 @@ public actor RealtimeSuggestionController {
107104
.value(forKey: SettingsKey.realtimeSuggestionDebounce) as? Double
108105
?? 0.7
109106
) * 1_000_000_000))
110-
107+
111108
guard UserDefaults.shared.bool(forKey: SettingsKey.realtimeSuggestionToggle)
112109
else { return }
113-
110+
114111
if Task.isCancelled { return }
115-
112+
116113
os_log(.info, "Prefetch suggestions.")
117-
114+
118115
await realtimeSuggestionIndicatorController.triggerPrefetchAnimation()
119116
do {
120117
try await Environment.triggerAction("Prefetch Suggestions")

Core/Sources/Service/SuggestionCommandHandler/CommentBaseCommandHandler.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ struct CommentBaseCommandHandler: SuggestionCommandHanlder {
119119
}
120120

121121
func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? {
122+
defer {
123+
Task {
124+
await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController
125+
.endPrefetchAnimation()
126+
}
127+
}
128+
122129
let fileURL = try await Environment.fetchCurrentFileURL()
123130
let (workspace, filespace) = try await Workspace
124131
.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL)
@@ -134,6 +141,7 @@ struct CommentBaseCommandHandler: SuggestionCommandHanlder {
134141
guard filespace.suggestionSourceSnapshot == snapshot else { return nil }
135142

136143
let presenter = PresentInCommentSuggestionPresenter()
144+
137145
return try await presenter.presentSuggestion(
138146
for: filespace,
139147
in: workspace,
@@ -173,7 +181,7 @@ struct CommentBaseCommandHandler: SuggestionCommandHanlder {
173181
// If there is a suggestion available, call another command to present it.
174182
guard !suggestions.isEmpty else { return nil }
175183
try await Environment.triggerAction("Real-time Suggestions")
176-
GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController
184+
await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController
177185
.triggerPrefetchAnimation()
178186

179187
return nil

Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ struct WindowBaseCommandHandler: SuggestionCommandHanlder {
158158
}
159159

160160
func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? {
161-
try await presentSuggestions(editor: editor)
161+
await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController
162+
.triggerPrefetchAnimation()
163+
defer {
164+
Task {
165+
await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController
166+
.endPrefetchAnimation()
167+
}
168+
}
169+
return try await presentSuggestions(editor: editor)
162170
}
163171
}

0 commit comments

Comments
 (0)