1+ import ActiveApplicationMonitor
12import AppKit
3+ import AXNotificationStream
24import DisplayLink
35import Environment
46import QuartzCore
57import SwiftUI
68import XPCShared
79
810/// Present a tiny dot next to mouse cursor if real-time suggestion is enabled.
11+ @MainActor
912final 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}
0 commit comments