Skip to content

Roll our own hover tooltip popover so tooltips actually appear#354

Closed
FuJacob wants to merge 1 commit into
mainfrom
fix/tooltip-popover-panel
Closed

Roll our own hover tooltip popover so tooltips actually appear#354
FuJacob wants to merge 1 commit into
mainfrom
fix/tooltip-popover-panel

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

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.toolTip and returned nil from hitTest(_:), but NSToolTipManager only 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 NSToolTipManager dependency entirely. The overlay still returns nil from hitTest(_:) so clicks reach the SwiftUI control beneath, but instead of asking AppKit to show a tooltip on hover, we install an NSTrackingArea on the same view (whose mouseEntered:/mouseExited: callbacks fire independent of hit testing) and order a borderless non-activating NSPanel into the floating layer ourselves. The panel sets ignoresMouseEvents = true, which avoids the chicken-and-egg cycle where the panel appearing under the mouse would have triggered mouseExited on the anchor and immediately closed itself.

Two niceties on top of the basic show/hide:

  • Native scrubbing: longer first-show delay (~0.6s), then near-instant for follow-up tooltips while the user keeps moving across help-equipped controls within ~500ms.
  • Panel position clamps to the active screen so a control flush against the screen edge doesn't push the tooltip into the abyss.

.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 0
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build → ** BUILD SUCCEEDED **

UI verification needed on macOS 26 beta:

  • Hover any Settings toggle for ~1s → tooltip appears below the control.
  • Move directly to an adjacent help-equipped control → tooltip appears with no delay.
  • Move off → tooltip disappears.
  • Click the underlying toggle/button → still toggles (overlay is click-through).
  • Hover a control flush against the right edge of the screen → tooltip stays within the visible frame.

Linked issues

Fixes #350 (the prior attempt that did not work).

Risk / rollout notes

  • NSPanel floats above all windows including full-screen apps. The panel is closed on mouseExited: and on the parent SwiftUI view dismantle (`dismantleNSView`), so leaks are bounded to the lifetime of the host view.
  • Long help strings wrap up to 280pt before clipping. None of our current tooltips are near that length, but if one ever is, the body uses `.fixedSize(vertical:)` so it grows vertically.
  • The follow-up scrubbing delay uses a per-process timestamp; behaves correctly across windows but resets if you fully unhover for >500ms.

Greptile Summary

This PR replaces the non-functional NSToolTipManager-based overlay from #350 with a hand-rolled tracking-area + floating NSPanel approach that correctly fires mouseEntered/mouseExited independent of hit testing, making hover tooltips actually visible in LSUIElement apps on the macOS 26 beta.

  • TooltipTrackingView installs an NSTrackingArea and schedules/cancels a work item to show a borderless NSPanel (ignoresMouseEvents = true) after a configurable delay, with scrubbing heuristics that match native macOS tooltip timing.
  • Cleanup is handled in three layers — mouseExited, viewWillMove(toWindow:nil), and dismantleNSView — so the panel should never leak beyond the lifetime of the host view.
  • Screen-edge clamping keeps the panel within the visible frame, and .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

Filename Overview
Cotabby/UI/TooltipSupport.swift Replaces NSToolTipManager approach with a hand-rolled NSTrackingArea + floating NSPanel implementation; well-structured cleanup paths, minor edge cases around panel resizing on text update and lastDismissedAt not being recorded in dismantle()

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]
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Roll our own hover tooltip popover for m..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

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.
Comment on lines +202 to +205
private func refreshPanelContentIfShowing() {
guard let hostingView, let panel, panel.isVisible else { return }
hostingView.rootView = TooltipBody(text: text)
}
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

Comment on lines +117 to +122
func dismantle() {
cancelShow()
panel?.orderOut(nil)
panel = nil
hostingView = nil
}
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

@FuJacob
Copy link
Copy Markdown
Owner Author

FuJacob commented May 28, 2026

Abandoning the tooltip workaround entirely — see follow-up PR removing all of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant