11import ActiveApplicationMonitor
22import AppKit
3+ import AsyncAlgorithms
4+ import AXNotificationStream
35import CGEventObserver
46import Environment
57import Foundation
68import os. log
9+ import QuartzCore
710import XPCShared
811
9- public actor RealtimeSuggestionController {
10- public static let shared = RealtimeSuggestionController ( )
12+ @ServiceActor
13+ public class RealtimeSuggestionController {
14+ public nonisolated static let shared = RealtimeSuggestionController ( )
1115 var eventObserver : CGEventObserverType = CGEventObserver ( eventsOfInterest: [
1216 . keyUp,
1317 . keyDown,
@@ -16,88 +20,142 @@ public actor RealtimeSuggestionController {
1620 ] )
1721 private var task : Task < Void , Error > ?
1822 private var inflightPrefetchTask : Task < Void , Error > ?
19- private var ignoreUntil = Date ( timeIntervalSince1970: 0 )
20- var realtimeSuggestionIndicatorController : RealtimeSuggestionIndicatorController {
21- GraphicalUserInterfaceController . shared. realtimeSuggestionIndicatorController
22- }
23+ private var windowChangeObservationTask : Task < Void , Error > ?
24+ private var activeApplicationMonitorTask : Task < Void , Error > ?
25+ private var editorObservationTask : Task < Void , Error > ?
26+ private var focusedUIElement : AXUIElement ?
27+
28+ private nonisolated init ( ) {
29+ Task { [ weak self] in
2330
24- private init ( ) {
25- Task {
26- for await _ in ActiveApplicationMonitor . createStream ( ) {
31+ if let app = ActiveApplicationMonitor . activeXcode {
32+ await self ? . handleXcodeChanged ( app)
33+ await startHIDObservation ( by: 1 )
34+ }
35+ var previousApp = ActiveApplicationMonitor . activeXcode
36+ for await app in ActiveApplicationMonitor . createStream ( ) {
37+ guard let self else { return }
2738 try Task . checkCancellation ( )
39+ defer { previousApp = app }
40+
41+ if let app = ActiveApplicationMonitor . activeXcode, app != previousApp {
42+ await self . handleXcodeChanged ( app)
43+ }
44+
45+ #warning("TOOD: Is it possible to get rid of hid event observation with only AXObserver?")
2846 if ActiveApplicationMonitor . activeXcode != nil {
29- await start ( by: 1 )
47+ await startHIDObservation ( by: 1 )
3048 } else {
31- await stop ( by: 1 )
49+ await stopHIDObservation ( by: 1 )
3250 }
3351 }
3452 }
3553 }
3654
37- private func start ( by listener: AnyHashable ) {
55+ private func startHIDObservation ( by listener: AnyHashable ) {
3856 os_log ( . info, " Add auto trigger listener: %@. " , listener as CVarArg )
3957
4058 if task == nil {
4159 task = Task { [ stream = eventObserver. stream] in
4260 for await event in stream {
43- await self . handleKeyboardEvent ( event: event)
61+ await self . handleHIDEvent ( event: event)
4462 }
4563 }
4664 }
4765 eventObserver. activateIfPossible ( )
4866 }
4967
50- private func stop ( by listener: AnyHashable ) {
68+ private func stopHIDObservation ( by listener: AnyHashable ) {
5169 os_log ( . info, " Remove auto trigger listener: %@. " , listener as CVarArg )
5270 os_log ( . info, " Auto trigger is stopped. " )
5371 task? . cancel ( )
5472 task = nil
5573 eventObserver. deactivate ( )
5674 }
5775
58- func handleKeyboardEvent( event: CGEvent ) async {
59- await cancelInFlightTasks ( )
60-
61- if Task . isCancelled { return }
62- guard await Environment . isXcodeActive ( ) else { return }
63-
64- let escape = 0x35
65- let arrowKeys = [ 0x7B , 0x7C , 0x7D , 0x7E ]
66- let isEditing = await Environment . frontmostXcodeWindowIsEditor ( )
76+ private func handleXcodeChanged( _ app: NSRunningApplication ) {
77+ windowChangeObservationTask? . cancel ( )
78+ windowChangeObservationTask = nil
79+ observeXcodeWindowChangeIfNeeded ( app)
80+ }
6781
68- // if Xcode suggestion panel is presenting, and we are not trying to close it
69- // ignore this event. (except present in window mode)
70- if !isEditing, event. getIntegerValueField ( . keyboardEventKeycode) != escape {
71- if UserDefaults . shared. integer ( forKey: SettingsKey . suggestionPresentationMode) != 1 {
72- return
82+ private func observeXcodeWindowChangeIfNeeded( _ app: NSRunningApplication ) {
83+ guard windowChangeObservationTask == nil else { return }
84+ handleFocusElementChange ( )
85+ windowChangeObservationTask = Task { [ weak self] in
86+ let notifications = AXNotificationStream (
87+ app: app,
88+ notificationNames: kAXFocusedUIElementChangedNotification,
89+ kAXMainWindowChangedNotification
90+ )
91+ for await _ in notifications {
92+ guard let self else { return }
93+ try Task . checkCancellation ( )
94+ self . handleFocusElementChange ( )
7395 }
7496 }
97+ }
7598
76- let shouldTrigger = {
77- let code = Int ( event. getIntegerValueField ( . keyboardEventKeycode) )
78- // closing auto-complete panel
79- if isEditing, code == escape {
80- return true
81- }
99+ private func handleFocusElementChange( ) {
100+ editorObservationTask? . cancel ( )
101+ editorObservationTask = nil
102+ guard let activeXcode = ActiveApplicationMonitor . activeXcode else { return }
103+ let application = AXUIElementCreateApplication ( activeXcode. processIdentifier)
104+ guard let focusElement = application. focusedElement else { return }
105+ let focusElementType = focusElement. description
106+ guard focusElementType == " Source Editor " else { return }
82107
83- // escape and arrows to cancel
108+ editorObservationTask = Task { [ weak self] in
109+ let notificationsFromEditor = AXNotificationStream (
110+ app: activeXcode,
111+ element: focusElement,
112+ notificationNames: kAXValueChangedNotification
113+ )
84114
85- if code == escape {
86- return false
87- }
115+ for await notification in notificationsFromEditor {
116+ guard let self else { return }
117+ try Task . checkCancellation ( )
118+ await cancelInFlightTasks ( )
88119
89- if arrowKeys. contains ( code) {
90- return false
120+ switch notification. name {
121+ case kAXValueChangedNotification:
122+ self . triggerPrefetchDebounced ( )
123+ default :
124+ continue
125+ }
91126 }
127+ }
128+ }
92129
93- // normally typing
130+ func handleHIDEvent( event: CGEvent ) async {
131+ guard await Environment . isXcodeActive ( ) else { return }
94132
95- return event. type == . keyUp
96- } ( )
133+ let keycode = Int ( event. getIntegerValueField ( . keyboardEventKeycode) )
97134
98- guard shouldTrigger else { return }
99- guard Date ( ) . timeIntervalSince ( ignoreUntil) > 0 else { return }
135+ let escape = 0x35
136+ let arrowKeys = [ 0x7B , 0x7C , 0x7D , 0x7E ]
137+
138+ // Mouse clicks should cancel in-flight tasks.
139+ if [ CGEventType . rightMouseDown, . leftMouseDown] . contains ( event. type) {
140+ await cancelInFlightTasks ( )
141+ return
142+ }
100143
144+ // Arror keys should cancel in-flight tasks.
145+ if arrowKeys. contains ( keycode) {
146+ await cancelInFlightTasks ( )
147+ return
148+ }
149+
150+ // Escape should cancel in-flight tasks.
151+ // Except that when the completion panel is presented, it should trigger prefetch instead.
152+ if keycode == escape {
153+ await cancelInFlightTasks ( )
154+ if isCompletionPanelPresenting ( ) { triggerPrefetchDebounced ( force: true ) }
155+ }
156+ }
157+
158+ func triggerPrefetchDebounced( force: Bool = false ) {
101159 inflightPrefetchTask = Task { @ServiceActor in
102160 try ? await Task . sleep ( nanoseconds: UInt64 ( (
103161 UserDefaults . shared
@@ -112,9 +170,14 @@ public actor RealtimeSuggestionController {
112170
113171 os_log ( . info, " Prefetch suggestions. " )
114172
115- await realtimeSuggestionIndicatorController. triggerPrefetchAnimation ( )
173+ if !force, isCompletionPanelPresenting ( ) {
174+ os_log ( . info, " Completion panel is open, blocked. " )
175+ return
176+ }
177+
116178 do {
117179 try await Environment . triggerAction ( " Prefetch Suggestions " )
180+
118181 } catch {
119182 os_log ( . info, " %@ " , error. localizedDescription)
120183 }
@@ -126,7 +189,7 @@ public actor RealtimeSuggestionController {
126189
127190 // cancel in-flight tasks
128191 await withTaskGroup ( of: Void . self) { group in
129- for (_, workspace) in await workspaces {
192+ for (_, workspace) in workspaces {
130193 group. addTask {
131194 await workspace. cancelInFlightRealtimeSuggestionRequests ( )
132195 }
@@ -146,10 +209,80 @@ public actor RealtimeSuggestionController {
146209 }
147210 }
148211
149- #warning("TODO: Find a better way to prevent that from happening!")
150- /// Prevent prefetch to be triggered by commands. Quick and dirty.
151- func cancelInFlightTasksAndIgnoreTriggerForAWhile( excluding: Task < Void , Never > ? = nil ) async {
152- ignoreUntil = Date ( timeIntervalSinceNow: 5 )
153- await cancelInFlightTasks ( excluding: excluding)
212+ func isCompletionPanelPresenting( ) -> Bool {
213+ guard let activeXcode = ActiveApplicationMonitor . activeXcode else { return false }
214+ let application = AXUIElementCreateApplication ( activeXcode. processIdentifier)
215+ if let completionPanel = application. child ( identifier: " _XC_COMPLETION_TABLE_ " ) ,
216+ completionPanel. window != nil
217+ {
218+ return true
219+ }
220+ return false
221+ }
222+ }
223+
224+ extension AXUIElement {
225+ var identifier : String {
226+ ( try ? copyValue ( key: kAXIdentifierAttribute) ) ?? " "
227+ }
228+
229+ var description : String {
230+ ( try ? copyValue ( key: kAXDescriptionAttribute) ) ?? " "
231+ }
232+
233+ var focusedElement : AXUIElement ? {
234+ try ? copyValue ( key: kAXFocusedUIElementAttribute)
235+ }
236+
237+ var sharedFocusElements : [ AXUIElement ] {
238+ ( try ? copyValue ( key: kAXChildrenAttribute) ) ?? [ ]
239+ }
240+
241+ var window : AXUIElement ? {
242+ try ? copyValue ( key: kAXWindowAttribute)
243+ }
244+
245+ var topLevelElement : AXUIElement ? {
246+ try ? copyValue ( key: kAXTopLevelUIElementAttribute)
247+ }
248+
249+ var rows : [ AXUIElement ] {
250+ ( try ? copyValue ( key: kAXRowsAttribute) ) ?? [ ]
251+ }
252+
253+ var parent : AXUIElement ? {
254+ try ? copyValue ( key: kAXParentAttribute)
255+ }
256+
257+ var children : [ AXUIElement ] {
258+ ( try ? copyValue ( key: kAXChildrenAttribute) ) ?? [ ]
259+ }
260+
261+ var visibleChildren : [ AXUIElement ] {
262+ ( try ? copyValue ( key: kAXVisibleChildrenAttribute) ) ?? [ ]
263+ }
264+
265+ var isFocused : Bool {
266+ ( try ? copyValue ( key: kAXFocusedAttribute) ) ?? false
267+ }
268+
269+ var isEnabled : Bool {
270+ ( try ? copyValue ( key: kAXEnabledAttribute) ) ?? false
271+ }
272+
273+ func child( identifier: String ) -> AXUIElement ? {
274+ for child in children {
275+ if child. identifier == identifier { return child }
276+ if let target = child. child ( identifier: identifier) { return target }
277+ }
278+ return nil
279+ }
280+
281+ func visibleChild( identifier: String ) -> AXUIElement ? {
282+ for child in visibleChildren {
283+ if child. identifier == identifier { return child }
284+ if let target = child. visibleChild ( identifier: identifier) { return target }
285+ }
286+ return nil
154287 }
155288}
0 commit comments