From 31998a1d727cddd6a7d88486aa788ba49a3618cd Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 28 May 2026 02:57:53 -0700 Subject: [PATCH] Roll our own hover tooltip popover for macOS 26 beta The earlier AppKit fallback in #350 didn't actually work: the click-through overlay set NSView.toolTip but returned nil from hitTest, and NSToolTipManager only displays a tooltip for the view hit-tested under the cursor. So the tooltip was set but never queried, and every .cotabbyHelp call was effectively a no-op. Replace the NSToolTipManager dependency with a hand-rolled tracking + floating-panel implementation. The overlay still returns nil from hitTest so clicks reach the SwiftUI control beneath; an NSTrackingArea on the same view fires mouseEntered/mouseExited independent of hit testing. On enter, after a short delay, we order a borderless non-activating NSPanel into the floating layer next to the anchor. The panel sets ignoresMouseEvents = true, which avoids the chicken-and-egg cycle where the panel showing would have triggered mouseExited on the anchor and immediately closed itself. Also implements the native scrubbing behavior: a longer first-show delay, then near-instant for follow-up tooltips while the user keeps moving across help-equipped controls within ~500ms. --- Cotabby/UI/TooltipSupport.swift | 233 +++++++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 20 deletions(-) diff --git a/Cotabby/UI/TooltipSupport.swift b/Cotabby/UI/TooltipSupport.swift index 0c8c7ec..04dc5b8 100644 --- a/Cotabby/UI/TooltipSupport.swift +++ b/Cotabby/UI/TooltipSupport.swift @@ -2,18 +2,28 @@ import AppKit import SwiftUI /// File overview: -/// AppKit-backed tooltip support for the Settings window and the menu-bar panel. +/// Custom hover-tooltip support for the Settings window and the menu-bar panel. /// -/// SwiftUI's `.help(_:)` modifier silently stopped rendering tooltips on the macOS 26 beta in -/// menu-bar (LSUIElement) apps. Issue #313 wired up dozens of `.help(...)` calls that the user -/// can no longer see. Until SwiftUI's bridge is fixed we paint our own tooltip via an -/// `NSViewRepresentable` overlay that sets `NSView.toolTip` directly — AppKit's tooltip subsystem -/// still works fine in this environment. We also keep calling `.help(_:)` so accessibility help -/// stays wired up and the SwiftUI path "just starts working" again on a future macOS update. +/// SwiftUI's `.help(_:)` does not render visible tooltips for LSUIElement apps on the macOS 26 +/// beta, and an earlier AppKit attempt (#350) failed because the click-through overlay returned +/// `nil` from `hitTest(_:)` — which makes `NSToolTipManager` skip the view entirely, so the +/// `toolTip` property was set but never queried. This file replaces that with a hand-rolled +/// tracking + floating-panel implementation that does not depend on `NSToolTipManager` at all: +/// +/// - An overlay `NSView` reports `nil` from `hitTest(_:)` so clicks still reach the SwiftUI +/// control underneath. +/// - The same view installs an `NSTrackingArea` whose `mouseEntered:`/`mouseExited:` callbacks +/// do *not* require hit testing — they fire purely on the mouse position vs the tracked +/// rect, which is exactly the property the previous attempt mistakenly relied on. +/// - On enter, after a short delay, we order in a borderless floating `NSPanel` next to the +/// anchor. The panel ignores mouse events, so the chicken-and-egg (mouse enters panel → +/// exits anchor → panel closes) cycle never happens. +/// - `.help(_:)` is still applied alongside so VoiceOver accessibility-help text stays wired +/// up; when SwiftUI's tooltip bridge is fixed, the overlay becomes a harmless redundancy. extension View { - /// Drop-in replacement for `.help(_:)` that also installs an AppKit tooltip overlay, so the - /// tip is actually visible on macOS 26 beta where SwiftUI's tooltip bridge is broken. + /// Drop-in replacement for `.help(_:)` that also shows a visible tooltip via a floating panel. + /// Use everywhere `.help(_:)` is used in Settings and the menu bar — see issue #350. func cotabbyHelp(_ text: String) -> some View { modifier(CotabbyTooltipModifier(text: text)) } @@ -25,29 +35,212 @@ private struct CotabbyTooltipModifier: ViewModifier { func body(content: Content) -> some View { content .help(text) - .overlay(TooltipOverlayView(text: text)) + .overlay(TooltipOverlay(text: text).accessibilityHidden(true)) } } -/// Transparent NSView whose only job is to advertise a tooltip to AppKit's `NSToolTipManager`. -/// `hitTest` returns `nil` so the overlay never intercepts clicks meant for the underlying -/// SwiftUI control; tracking-area-based tooltip delivery is independent of hit testing. -private struct TooltipOverlayView: NSViewRepresentable { +private struct TooltipOverlay: NSViewRepresentable { let text: String func makeNSView(context: Context) -> NSView { - let view = ClickThroughTooltipView() - view.toolTip = text + let view = TooltipTrackingView() + view.text = text return view } func updateNSView(_ nsView: NSView, context: Context) { - nsView.toolTip = text + guard let view = nsView as? TooltipTrackingView else { return } + view.text = text + } + + static func dismantleNSView(_ nsView: NSView, coordinator: ()) { + (nsView as? TooltipTrackingView)?.dismantle() + } +} + +/// Tracking-only NSView. Hit testing is intentionally disabled so clicks pass through to the +/// SwiftUI control beneath. Tracking-area entered/exited events fire independently of hit testing, +/// which is what makes the click-through-and-still-hover trick work here. +private final class TooltipTrackingView: NSView { + var text: String = "" { + didSet { refreshPanelContentIfShowing() } + } + + private var trackingArea: NSTrackingArea? + private var showWorkItem: DispatchWorkItem? + private var panel: TooltipPanel? + private var hostingView: NSHostingView? + + /// macOS shows the first tooltip after a longer delay and subsequent ones immediately while + /// the user keeps scrubbing across help-equipped controls. Matching that behavior keeps the + /// tooltips feeling native rather than chatty. + private static var lastDismissedAt: Date = .distantPast + private static let standardDelay: TimeInterval = 0.6 + private static let scrubbingWindow: TimeInterval = 0.5 + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override var isFlipped: Bool { true } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + scheduleShow() + } + + override func mouseExited(with event: NSEvent) { + cancelShow() + hidePanelIfNeeded() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + if newWindow == nil { + cancelShow() + hidePanelIfNeeded() + } + } + + func dismantle() { + cancelShow() + panel?.orderOut(nil) + panel = nil + hostingView = nil + } + + private func scheduleShow() { + cancelShow() + guard !text.isEmpty else { return } + let delay = Date().timeIntervalSince(Self.lastDismissedAt) < Self.scrubbingWindow + ? 0 + : Self.standardDelay + let item = DispatchWorkItem { [weak self] in + self?.showPanelNow() + } + showWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + + private func cancelShow() { + showWorkItem?.cancel() + showWorkItem = nil + } + + private func showPanelNow() { + guard !text.isEmpty, + let window, + window.isVisible, + window.isKeyWindow || NSApp.isActive + else { return } + + let panel = ensurePanel() + hostingView?.rootView = TooltipBody(text: text) + hostingView?.layoutSubtreeIfNeeded() + let contentSize = hostingView?.fittingSize ?? CGSize(width: 200, height: 24) + panel.setContentSize(contentSize) + + // Position the panel just below the anchor view. AppKit windows are y-up, but our view is + // flipped (y-down) — convert both edges through the window/screen to land the panel where + // a native tooltip would sit. + let belowAnchorInView = NSPoint(x: 0, y: bounds.maxY + 4) + let belowAnchorInWindow = convert(belowAnchorInView, to: nil) + let onScreen = window.convertPoint(toScreen: belowAnchorInWindow) + // Flipped → on-screen y was the top edge of where we want the panel; subtract its height. + let origin = NSPoint(x: onScreen.x, y: onScreen.y - contentSize.height) + panel.setFrameOrigin(clampedOnScreen(origin: origin, size: contentSize)) + panel.orderFrontRegardless() + } + + private func ensurePanel() -> TooltipPanel { + if let panel { + return panel + } + let host = NSHostingView(rootView: TooltipBody(text: text)) + host.autoresizingMask = [.width, .height] + + let panel = TooltipPanel( + contentRect: CGRect(x: 0, y: 0, width: 200, height: 28), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.ignoresMouseEvents = true + panel.animationBehavior = .none + panel.collectionBehavior = [.transient, .ignoresCycle, .fullScreenAuxiliary] + panel.hidesOnDeactivate = true + panel.contentView = host + + self.panel = panel + self.hostingView = host + return panel } + + private func hidePanelIfNeeded() { + guard let panel, panel.isVisible else { return } + panel.orderOut(nil) + Self.lastDismissedAt = Date() + } + + private func refreshPanelContentIfShowing() { + guard let hostingView, let panel, panel.isVisible else { return } + hostingView.rootView = TooltipBody(text: text) + } + + /// Keep the tooltip inside the active screen so a control flush against the screen edge + /// doesn't push the panel into the abyss. + private func clampedOnScreen(origin: NSPoint, size: CGSize) -> NSPoint { + guard let screen = window?.screen ?? NSScreen.main else { return origin } + let visible = screen.visibleFrame + let clampedX = min(max(origin.x, visible.minX + 4), visible.maxX - size.width - 4) + let clampedY = min(max(origin.y, visible.minY + 4), visible.maxY - size.height - 4) + return NSPoint(x: clampedX, y: clampedY) + } +} + +private final class TooltipPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } } -private final class ClickThroughTooltipView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - nil +/// SwiftUI body of the tooltip. Sized with `fixedSize(vertical:)` so long help strings wrap up to +/// `maxWidth` instead of forcing a single line, which keeps the layout close to what AppKit's +/// native tooltips do. +private struct TooltipBody: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .frame(maxWidth: 280, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + ) } }