Skip to content

Commit bb89a64

Browse files
committed
Move RealtimeSuggestionIndicatorController to its own file
1 parent 879c6af commit bb89a64

File tree

2 files changed

+216
-217
lines changed

2 files changed

+216
-217
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import AppKit
2+
import DisplayLink
3+
import QuartzCore
4+
import SwiftUI
5+
import XPCShared
6+
7+
/// Present a tiny dot next to mouse cursor if real-time suggestion is enabled.
8+
final class RealtimeSuggestionIndicatorController {
9+
class IndicatorContentViewModel: ObservableObject {
10+
@Published var isPrefetching = false
11+
private var prefetchTask: Task<Void, Error>?
12+
13+
@MainActor
14+
func prefetch() {
15+
prefetchTask?.cancel()
16+
withAnimation(.easeIn(duration: 0.2)) {
17+
isPrefetching = true
18+
}
19+
prefetchTask = Task {
20+
try await Task.sleep(nanoseconds: 2 * 1_000_000_000)
21+
withAnimation(.easeOut(duration: 0.2)) {
22+
isPrefetching = false
23+
}
24+
}
25+
}
26+
}
27+
28+
struct IndicatorContentView: View {
29+
@ObservedObject var viewModel: IndicatorContentViewModel
30+
@State var progress: CGFloat = 1
31+
var opacityA: CGFloat { min(progress, 0.7) }
32+
var opacityB: CGFloat { 1 - progress }
33+
var scaleA: CGFloat { progress / 2 + 0.5 }
34+
var scaleB: CGFloat { max(1 - progress, 0.01) }
35+
36+
var body: some View {
37+
Circle()
38+
.fill(Color.accentColor.opacity(opacityA))
39+
.scaleEffect(.init(width: scaleA, height: scaleA))
40+
.frame(width: 8, height: 8)
41+
.background(
42+
Circle()
43+
.fill(Color.white.opacity(viewModel.isPrefetching ? opacityB : 0))
44+
.scaleEffect(.init(width: scaleB, height: scaleB))
45+
.frame(width: 8, height: 8)
46+
)
47+
.onAppear {
48+
Task {
49+
await Task.yield() // to avoid unwanted translations.
50+
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
51+
progress = 0
52+
}
53+
}
54+
}
55+
}
56+
}
57+
58+
class UserDefaultsObserver: NSObject {
59+
var onChange: (() -> Void)?
60+
61+
override func observeValue(
62+
forKeyPath keyPath: String?,
63+
of object: Any?,
64+
change: [NSKeyValueChangeKey: Any]?,
65+
context: UnsafeMutableRawPointer?
66+
) {
67+
onChange?()
68+
}
69+
}
70+
71+
private let viewModel = IndicatorContentViewModel()
72+
private var userDefaultsObserver = UserDefaultsObserver()
73+
var isObserving = false {
74+
didSet {
75+
Task {
76+
await updateIndicatorVisibility()
77+
}
78+
}
79+
}
80+
81+
private var displayLinkTask: Task<Void, Never>?
82+
83+
@MainActor
84+
lazy var window = {
85+
let it = NSWindow(
86+
contentRect: .zero,
87+
styleMask: .borderless,
88+
backing: .buffered,
89+
defer: false
90+
)
91+
it.isReleasedWhenClosed = false
92+
it.isOpaque = false
93+
it.backgroundColor = .white.withAlphaComponent(0)
94+
it.level = .statusBar
95+
it.contentView = NSHostingView(
96+
rootView: IndicatorContentView(viewModel: self.viewModel)
97+
.frame(minWidth: 10, minHeight: 10)
98+
)
99+
return it
100+
}()
101+
102+
init() {
103+
Task {
104+
let sequence = NSWorkspace.shared.notificationCenter
105+
.notifications(named: NSWorkspace.didActivateApplicationNotification)
106+
for await notification in sequence {
107+
guard let app = notification
108+
.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
109+
else { continue }
110+
guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue }
111+
await updateIndicatorVisibility()
112+
}
113+
}
114+
115+
Task {
116+
let sequence = NSWorkspace.shared.notificationCenter
117+
.notifications(named: NSWorkspace.didDeactivateApplicationNotification)
118+
for await notification in sequence {
119+
guard let app = notification
120+
.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
121+
else { continue }
122+
guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue }
123+
await updateIndicatorVisibility()
124+
}
125+
}
126+
127+
Task {
128+
userDefaultsObserver.onChange = { [weak self] in
129+
Task { [weak self] in
130+
await self?.updateIndicatorVisibility()
131+
}
132+
}
133+
UserDefaults.shared.addObserver(
134+
userDefaultsObserver,
135+
forKeyPath: SettingsKey.realtimeSuggestionToggle,
136+
options: .new,
137+
context: nil
138+
)
139+
}
140+
}
141+
142+
private func updateIndicatorVisibility() async {
143+
let isVisible = await {
144+
let isOn = UserDefaults.shared.bool(forKey: SettingsKey.realtimeSuggestionToggle)
145+
let isXcodeActive = await Environment.isXcodeActive()
146+
return isOn && isXcodeActive && isObserving
147+
}()
148+
149+
await { @MainActor in
150+
guard window.isVisible != isVisible else { return }
151+
if isVisible {
152+
if displayLinkTask == nil {
153+
displayLinkTask = Task {
154+
for await _ in DisplayLink.createStream() {
155+
self.updateIndicatorLocation()
156+
}
157+
}
158+
}
159+
} else {
160+
displayLinkTask?.cancel()
161+
displayLinkTask = nil
162+
}
163+
window.setIsVisible(isVisible)
164+
}()
165+
}
166+
167+
private func updateIndicatorLocation() {
168+
Task { @MainActor in
169+
if !window.isVisible {
170+
return
171+
}
172+
173+
if let activeXcode = NSRunningApplication
174+
.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode")
175+
.first(where: \.isActive)
176+
{
177+
let application = AXUIElementCreateApplication(activeXcode.processIdentifier)
178+
if let focusElement: AXUIElement = try? application
179+
.copyValue(key: kAXFocusedUIElementAttribute),
180+
let selectedRange: AXValue = try? focusElement
181+
.copyValue(key: kAXSelectedTextRangeAttribute),
182+
let rect: AXValue = try? focusElement.copyParameterizedValue(
183+
key: kAXBoundsForRangeParameterizedAttribute,
184+
parameters: selectedRange
185+
)
186+
{
187+
var frame: CGRect = .zero
188+
let found = AXValueGetValue(rect, .cgRect, &frame)
189+
let screen = NSScreen.screens.first
190+
if found, let screen {
191+
frame.origin = .init(
192+
x: frame.maxX + 2,
193+
y: screen.frame.height - frame.minY - 4
194+
)
195+
frame.size = .init(width: 10, height: 10)
196+
window.alphaValue = 1
197+
window.setFrame(frame, display: false, animate: true)
198+
return
199+
}
200+
}
201+
}
202+
203+
window.alphaValue = 0
204+
}
205+
}
206+
207+
func triggerPrefetchAnimation() {
208+
Task { @MainActor in
209+
viewModel.prefetch()
210+
}
211+
}
212+
}

0 commit comments

Comments
 (0)