Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 213 additions & 20 deletions Cotabby/UI/TooltipSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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<TooltipBody>?

/// 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
}
Comment on lines +117 to +122
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 dismantle() orders the panel out but does not record the dismissal time in lastDismissedAt. If the tooltip is visible when the host view tears down (e.g., the settings sheet closes), the next hover within 500 ms will skip the 0.6 s initial delay and appear instantly, as if the user had been scrubbing. Calling hidePanelIfNeeded() instead of an open-coded orderOut would keep the timestamp consistent.

Suggested change
func dismantle() {
cancelShow()
panel?.orderOut(nil)
panel = nil
hostingView = nil
}
func dismantle() {
cancelShow()
hidePanelIfNeeded()
panel = nil
hostingView = nil
}

Fix in Codex Fix in Claude Code


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)
}
Comment on lines +202 to +205
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 refreshPanelContentIfShowing updates the tooltip body text but leaves the panel at its previously computed size. If the new text is longer or shorter, the NSPanel frame stays stale, causing text to clip or leaving a gap. The resize logic from showPanelNow (layout pass → fittingSizesetContentSize) should be applied here too.

Suggested change
private func refreshPanelContentIfShowing() {
guard let hostingView, let panel, panel.isVisible else { return }
hostingView.rootView = TooltipBody(text: text)
}
private func refreshPanelContentIfShowing() {
guard let hostingView, let panel, panel.isVisible else { return }
hostingView.rootView = TooltipBody(text: text)
hostingView.layoutSubtreeIfNeeded()
let contentSize = hostingView.fittingSize
guard contentSize != panel.frame.size else { return }
panel.setContentSize(contentSize)
}

Fix in Codex Fix in Claude Code


/// 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)
)
}
}