-
Notifications
You must be signed in to change notification settings - Fork 115
Expand file tree
/
Copy pathTooltipBubble.swift
More file actions
260 lines (225 loc) · 9.42 KB
/
Copy pathTooltipBubble.swift
File metadata and controls
260 lines (225 loc) · 9.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//
// TooltipBubble.swift
// RepoPrompt
//
// Updated 2025-12-17.
// All tooltip runtime state moved to reference container (TooltipRuntime) to prevent
// AppKit updateConstraints/layout recursion crashes when tooltips are used inside popovers.
//
import AppKit // ← access FontScalePreset.current
import Foundation
import SwiftUI
// ──────────────────────────────────
// MARK: - Bubble
/// ──────────────────────────────────
struct TooltipBubble: View {
let text: String
let preset: FontScalePreset // NEW
private var maxWidth: CGFloat {
320 * preset.scaleFactor
}
var body: some View {
Text(text)
.font(preset.captionFont) // was .caption
.multilineTextAlignment(.leading)
.padding(8 * preset.scaleFactor) // scale padding
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: maxWidth, alignment: .leading)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6 * preset.scaleFactor) // scale corner radius
.overlay(
RoundedRectangle(cornerRadius: 6 * preset.scaleFactor)
.stroke(Color.primary.opacity(0.15), lineWidth: 0.5)
)
.shadow(radius: 2 * preset.scaleFactor)
.allowsHitTesting(false)
}
}
// ──────────────────────────────────
// MARK: - Placement
/// ──────────────────────────────────
enum TooltipPlacement { case top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight }
enum HoverTooltipCoordinator {
static func dismissAll() {
NotificationCenter.default.post(name: .hoverTooltipsShouldDismiss, object: nil)
}
}
extension Notification.Name {
static let hoverTooltipsShouldDismiss = Notification.Name("RepoPromptHoverTooltipsShouldDismiss")
}
// ──────────────────────────────────
// MARK: - Modifier
/// ──────────────────────────────────
private struct HoverTooltipModifier: ViewModifier {
let text: String?
let placement: TooltipPlacement
private let showDelay: TimeInterval = 0.3 // 300 ms
/// IMPORTANT:
/// Keep *all* tooltip runtime mutable state in a reference container so that
/// geometry updates and event-driven dismissals do NOT invalidate SwiftUI layout.
/// This prevents AppKit updateConstraints/layout recursion crashes inside popovers.
final class TooltipRuntime {
var pendingWork: DispatchWorkItem?
var pendingReposition: DispatchWorkItem?
let pendingWorkGate = WorkItemGate()
let pendingRepositionGate = WorkItemGate()
var overlayController: TooltipOverlayController?
var globalMonitor: Any?
var localMonitor: Any?
var isCleaningUp: Bool = false
final class AnchorInfo {
var rect: NSRect = .zero
weak var window: NSWindow?
}
let anchorInfo = AnchorInfo()
}
@State private var runtime = TooltipRuntime()
/// Forces AnchorGeometryView to re-report when hover starts (fixes ScrollView offset staleness).
@State private var anchorRefreshID: UInt64 = 0
@ObservedObject private var globalSettings = GlobalSettingsStore.shared
private var showTooltips: Bool {
globalSettings.showTooltips()
}
private var preset: FontScalePreset {
.current
}
func body(content: Content) -> some View {
let rt = runtime
content
.background(
AnchorGeometryView(refreshID: anchorRefreshID) { rect, win in
// Update reference container without triggering SwiftUI updates
rt.anchorInfo.rect = rect
rt.anchorInfo.window = win
// If tooltip is visible, coalesce reposition calls
guard rt.overlayController != nil else { return }
// Avoid retain cycle: runtime -> pendingReposition -> closure -> runtime
rt.pendingReposition?.cancel()
rt.pendingRepositionGate.cancel()
rt.pendingReposition = rt.pendingRepositionGate.schedule { [weak rt] in
rt?.overlayController?.reposition(to: rect)
}
}
)
.onHover { inside in
guard let text else { return }
if inside, showTooltips {
// Key fix: scrolling doesn't relayout this view, so force an anchor re-measure now.
// This updates rt.anchorInfo.rect to the correct on-screen position before showing.
anchorRefreshID &+= 1
cancelPendingWork()
// Avoid retain cycle: runtime -> pendingWork -> closure -> runtime
rt.pendingWork = rt.pendingWorkGate.schedule(after: showDelay) { [placement, preset, weak rt] in
guard let rt else { return }
guard let hostWindow = rt.anchorInfo.window else { return }
defer { rt.pendingWork = nil }
hideOverlay()
let controller = TooltipOverlayController()
controller.show(
text: text,
anchorRect: rt.anchorInfo.rect,
owner: hostWindow,
placement: placement,
preset: preset
)
rt.overlayController = controller
installDismissMonitors()
}
} else {
cancelPendingWork()
hideOverlay()
}
}
.onDisappear {
cleanup(resetContext: true)
}
.onChange(of: showTooltips) { _, enabled in
if !enabled {
cleanup(resetContext: false)
}
}
.onReceive(NotificationCenter.default.publisher(for: .hoverTooltipsShouldDismiss)) { _ in
cleanup(resetContext: false)
}
}
@MainActor
private func cancelPendingWork() {
runtime.pendingWork?.cancel()
runtime.pendingWork = nil
runtime.pendingWorkGate.cancel()
}
@MainActor
private func cancelPendingReposition() {
runtime.pendingReposition?.cancel()
runtime.pendingReposition = nil
runtime.pendingRepositionGate.cancel()
}
@MainActor
private func hideOverlay() {
cancelPendingReposition()
runtime.overlayController?.hide()
runtime.overlayController = nil
removeDismissMonitors()
}
@MainActor
private func installDismissMonitors() {
guard runtime.globalMonitor == nil, runtime.localMonitor == nil else { return }
let masks: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel]
// Important: clean up SwiftUI state too (not just controller.hide()).
// Defer to next runloop to avoid doing too much during event processing.
runtime.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: masks) { [weak anchorInfo = runtime.anchorInfo] _ in
_ = anchorInfo // prevent capture warning
DispatchQueue.main.async {
Task { @MainActor in
hideOverlay()
}
}
}
runtime.localMonitor = NSEvent.addLocalMonitorForEvents(matching: masks.union(.keyDown)) { [weak anchorInfo = runtime.anchorInfo] event in
_ = anchorInfo // prevent capture warning
DispatchQueue.main.async {
Task { @MainActor in
hideOverlay()
}
}
return event
}
}
@MainActor
private func removeDismissMonitors() {
if let monitor = runtime.globalMonitor {
NSEvent.removeMonitor(monitor)
runtime.globalMonitor = nil
}
if let monitor = runtime.localMonitor {
NSEvent.removeMonitor(monitor)
runtime.localMonitor = nil
}
}
@MainActor
private func cleanup(resetContext: Bool = false) {
// Prevent re-entrant cleanup calls
guard !runtime.isCleaningUp else { return }
runtime.isCleaningUp = true
defer { runtime.isCleaningUp = false }
cancelPendingWork()
cancelPendingReposition()
hideOverlay()
if resetContext {
runtime.anchorInfo.rect = .zero
runtime.anchorInfo.window = nil
}
}
}
// ──────────────────────────────────
// MARK: - Public helper
/// ──────────────────────────────────
extension View {
/// Adds a pure-SwiftUI hover tooltip that adapts to font presets.
func hoverTooltip(
_ text: String?,
_ placement: TooltipPlacement = .top
) -> some View {
modifier(HoverTooltipModifier(text: text, placement: placement))
}
}