Roll our own hover tooltip popover so tooltips actually appear#354
Roll our own hover tooltip popover so tooltips actually appear#354FuJacob wants to merge 1 commit into
Conversation
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.
| private func refreshPanelContentIfShowing() { | ||
| guard let hostingView, let panel, panel.isVisible else { return } | ||
| hostingView.rootView = TooltipBody(text: text) | ||
| } |
There was a problem hiding this comment.
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 → fittingSize → setContentSize) should be applied here too.
| 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) | |
| } |
| func dismantle() { | ||
| cancelShow() | ||
| panel?.orderOut(nil) | ||
| panel = nil | ||
| hostingView = nil | ||
| } |
There was a problem hiding this comment.
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.
| func dismantle() { | |
| cancelShow() | |
| panel?.orderOut(nil) | |
| panel = nil | |
| hostingView = nil | |
| } | |
| func dismantle() { | |
| cancelShow() | |
| hidePanelIfNeeded() | |
| panel = nil | |
| hostingView = nil | |
| } |
|
Abandoning the tooltip workaround entirely — see follow-up PR removing all of it. |
Summary
Follow-up to #350 — that PR was supposed to bring tooltips back on the macOS 26 beta, but the workaround it shipped didn't actually do anything: the click-through overlay set
NSView.toolTipand returnednilfromhitTest(_:), butNSToolTipManageronly displays a tooltip for the view it hit-tests under the cursor. So the tooltip data was wired up correctly and then never queried, and every.cotabbyHelp(...)call was effectively a no-op.This PR replaces the
NSToolTipManagerdependency entirely. The overlay still returnsnilfromhitTest(_:)so clicks reach the SwiftUI control beneath, but instead of asking AppKit to show a tooltip on hover, we install anNSTrackingAreaon the same view (whosemouseEntered:/mouseExited:callbacks fire independent of hit testing) and order a borderless non-activatingNSPanelinto the floating layer ourselves. The panel setsignoresMouseEvents = true, which avoids the chicken-and-egg cycle where the panel appearing under the mouse would have triggeredmouseExitedon the anchor and immediately closed itself.Two niceties on top of the basic show/hide:
.help(_:)is still applied alongside so VoiceOver accessibility-help text stays wired up; the overlay becomes a harmless redundancy on a future macOS update that fixes SwiftUI's tooltip bridge.Validation
swiftlint lint --quiet→ exit 0xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build→ ** BUILD SUCCEEDED **UI verification needed on macOS 26 beta:
Linked issues
Fixes #350 (the prior attempt that did not work).
Risk / rollout notes
NSPanelfloats above all windows including full-screen apps. The panel is closed onmouseExited:and on the parent SwiftUI view dismantle (`dismantleNSView`), so leaks are bounded to the lifetime of the host view.Greptile Summary
This PR replaces the non-functional
NSToolTipManager-based overlay from #350 with a hand-rolled tracking-area + floatingNSPanelapproach that correctly firesmouseEntered/mouseExitedindependent of hit testing, making hover tooltips actually visible in LSUIElement apps on the macOS 26 beta.TooltipTrackingViewinstalls anNSTrackingAreaand schedules/cancels a work item to show a borderlessNSPanel(ignoresMouseEvents = true) after a configurable delay, with scrubbing heuristics that match native macOS tooltip timing.mouseExited,viewWillMove(toWindow:nil), anddismantleNSView— so the panel should never leak beyond the lifetime of the host view..help(_:)is preserved alongside the overlay so VoiceOver accessibility remains wired up.Confidence Score: 4/5
Safe to merge; panels are properly torn down on all exit paths and the new approach correctly bypasses the hit-test shortcoming that broke #350.
The core mechanism is sound and cleanup paths cover the expected lifecycle. Two minor edge cases exist: panel size isn't recalculated when tooltip text updates while the panel is visible, and dismantle() skips recording the dismissal timestamp, which can briefly corrupt the scrubbing heuristic after a view teardown.
Cotabby/UI/TooltipSupport.swift — specifically the refreshPanelContentIfShowing and dismantle methods.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Mouse enters tracking area\nmouseEntered] --> B{lastDismissedAt\n< 500ms ago?} B -- Yes --> C[delay = 0] B -- No --> D[delay = 0.6s] C --> E[DispatchWorkItem scheduled] D --> E E --> F{Mouse exited\nbefore delay?} F -- Yes --> G[cancelShow\nwork item cancelled] F -- No --> H{window visible &\nkey or app active?} H -- No --> I[return / no-op] H -- Yes --> J[ensurePanel\ncreate if needed] J --> K[Update hostingView content\nlayoutSubtreeIfNeeded\nfittingSize] K --> L[Compute position\nflipped → screen coords\nclampedOnScreen] L --> M[orderFrontRegardless\nPanel visible] M --> N[Mouse exits\nmouseExited] N --> O[cancelShow\nhidePanelIfNeeded] O --> P[lastDismissedAt = now] P --> B Q[dismantleNSView] --> R[dismantle\ncancelShow + orderOut\npanel = nil] S[viewWillMove toWindow=nil] --> T[cancelShow\nhidePanelIfNeeded]Reviews (1): Last reviewed commit: "Roll our own hover tooltip popover for m..." | Re-trigger Greptile