Skip to content

Commit a134208

Browse files
committed
Tweak real-time suggestion trigger
1 parent 3bdbeeb commit a134208

File tree

3 files changed

+195
-66
lines changed

3 files changed

+195
-66
lines changed

Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ final class RealtimeSuggestionIndicatorController {
167167
private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) {
168168
xcode = app
169169
guard windowChangeObservationTask == nil else { return }
170-
windowChangeObservationTask = Task {
170+
windowChangeObservationTask = Task { [weak self] in
171171
let notifications = AXNotificationStream(
172172
app: app,
173173
notificationNames:
@@ -177,14 +177,15 @@ final class RealtimeSuggestionIndicatorController {
177177
kAXFocusedUIElementChangedNotification
178178
)
179179
for await notification in notifications {
180+
guard let self else { return }
180181
try Task.checkCancellation()
181-
updateIndicatorLocation()
182+
self.updateIndicatorLocation()
182183

183184
switch notification.name {
184185
case kAXFocusedUIElementChangedNotification, kAXFocusedWindowChangedNotification:
185-
editorObservationTask?.cancel()
186-
editorObservationTask = nil
187-
observeEditorChangeIfNeeded()
186+
self.editorObservationTask?.cancel()
187+
self.editorObservationTask = nil
188+
self.observeEditorChangeIfNeeded()
188189
default:
189190
continue
190191
}
@@ -226,8 +227,9 @@ final class RealtimeSuggestionIndicatorController {
226227
)
227228

228229
for await _ in merge(notificationsFromEditor, notificationsFromScrollBar) {
230+
guard let self else { return }
229231
try Task.checkCancellation()
230-
self?.updateIndicatorLocation()
232+
self.updateIndicatorLocation()
231233
}
232234
}
233235
}
Lines changed: 185 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import ActiveApplicationMonitor
22
import AppKit
3+
import AsyncAlgorithms
4+
import AXNotificationStream
35
import CGEventObserver
46
import Environment
57
import Foundation
68
import os.log
9+
import QuartzCore
710
import 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
}

Core/Sources/Service/XPCService.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,7 @@ public class XPCService: NSObject, XPCServiceProtocol {
132132
}
133133

134134
Task {
135-
if isRealtimeSuggestionRelatedCommand {
136-
await RealtimeSuggestionController.shared
137-
.cancelInFlightTasks(excluding: task)
138-
} else {
139-
await RealtimeSuggestionController.shared
140-
.cancelInFlightTasksAndIgnoreTriggerForAWhile(excluding: task)
141-
}
135+
await RealtimeSuggestionController.shared.cancelInFlightTasks(excluding: task)
142136
}
143137
return task
144138
}
@@ -229,7 +223,7 @@ public class XPCService: NSObject, XPCServiceProtocol {
229223
return
230224
}
231225
Task { @ServiceActor in
232-
await RealtimeSuggestionController.shared.cancelInFlightTasksAndIgnoreTriggerForAWhile()
226+
await RealtimeSuggestionController.shared.cancelInFlightTasks()
233227
UserDefaults.shared.set(
234228
!UserDefaults.shared.bool(forKey: SettingsKey.realtimeSuggestionToggle),
235229
forKey: SettingsKey.realtimeSuggestionToggle

0 commit comments

Comments
 (0)