diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPConnectionsCounter.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPConnectionsCounter.swift index 3e1f229a8..8ae0220c0 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPConnectionsCounter.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPConnectionsCounter.swift @@ -29,6 +29,7 @@ struct MCPConnectionsCounter: View { var body: some View { Button { + HoverTooltipCoordinator.dismissAll() NotificationCenter.default.post( name: .showMCPStatusWindow, object: nil, diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift index 9978ad2e8..e89b7b4ea 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift @@ -95,7 +95,10 @@ struct MCPServerToggleView: View { } var body: some View { - Button(action: { showPopover.toggle() }) { + Button(action: { + HoverTooltipCoordinator.dismissAll() + showPopover.toggle() + }) { HStack(spacing: 6) { Image(systemName: "server.rack") .imageScale(.medium) diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/TooltipBubble.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/TooltipBubble.swift index 23a3a94bc..9874a43ca 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/TooltipBubble.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/TooltipBubble.swift @@ -46,6 +46,16 @@ struct TooltipBubble: View { /// ────────────────────────────────── 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 @@ -153,6 +163,9 @@ private struct HoverTooltipModifier: ViewModifier { cleanup(resetContext: false) } } + .onReceive(NotificationCenter.default.publisher(for: .hoverTooltipsShouldDismiss)) { _ in + cleanup(resetContext: false) + } } @MainActor diff --git a/Sources/RepoPrompt/Infrastructure/UI/Tooltip/TooltipOverlayController.swift b/Sources/RepoPrompt/Infrastructure/UI/Tooltip/TooltipOverlayController.swift index a60e6769f..52c175733 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Tooltip/TooltipOverlayController.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Tooltip/TooltipOverlayController.swift @@ -19,6 +19,11 @@ final class TooltipOverlayController { placement: TooltipPlacement, preset: FontScalePreset ) { + guard Self.isValidAnchorRect(anchorRect) else { + hide() + return + } + prepareWindowIfNeeded(owner: owner, preset: preset) let bubbleSize = bubbleSize(for: text, preset: preset) guard let win else { return } @@ -43,6 +48,11 @@ final class TooltipOverlayController { } func reposition(to anchorRect: NSRect) { + guard Self.isValidAnchorRect(anchorRect) else { + hide() + return + } + guard let win, let owner, @@ -137,11 +147,7 @@ final class TooltipOverlayController { owner.removeChildWindow(w) } - // Remove notification observer - if let token = ownerWillCloseObserver { - NotificationCenter.default.removeObserver(token) - ownerWillCloseObserver = nil - } + removeObservers() win = nil owner = nil @@ -159,6 +165,7 @@ final class TooltipOverlayController { private var cachedPreset: FontScalePreset? private var ownerWillCloseObserver: NSObjectProtocol? + private var hoverDismissObserver: NSObjectProtocol? /// Distance (in points) between the tooltip bubble and its anchor view. /// Kept small to avoid a large visual gap; still multiplied by @@ -178,16 +185,57 @@ final class TooltipOverlayController { // This avoids hierarchy issues with menus win = tooltipWindow - ownerWillCloseObserver = NotificationCenter.default.addObserver( + installObservers(for: owner) + } + + private func installObservers(for owner: NSWindow) { + removeObservers() + + let center = NotificationCenter.default + ownerWillCloseObserver = center.addObserver( forName: NSWindow.willCloseNotification, object: owner, queue: .main - ) // ensure main thread - { [weak self] _ in - Task { @MainActor [weak self] in - self?.hide() - } + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.hide() + } + } + + hoverDismissObserver = center.addObserver( + forName: .hoverTooltipsShouldDismiss, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.hide() + } + } + } + + private func removeObservers() { + if let ownerWillCloseObserver { + NotificationCenter.default.removeObserver(ownerWillCloseObserver) + self.ownerWillCloseObserver = nil + } + if let hoverDismissObserver { + NotificationCenter.default.removeObserver(hoverDismissObserver) + self.hoverDismissObserver = nil + } + } + + #if DEBUG + var isVisibleForTesting: Bool { + win?.isVisible == true } + #endif + + private static func isValidAnchorRect(_ rect: NSRect) -> Bool { + !rect.isEmpty + && rect.origin.x.isFinite + && rect.origin.y.isFinite + && rect.size.width.isFinite + && rect.size.height.isFinite } private func bubbleSize(for text: String, preset: FontScalePreset) -> CGSize { diff --git a/Tests/RepoPromptTests/Infrastructure/UI/TooltipOverlayControllerTests.swift b/Tests/RepoPromptTests/Infrastructure/UI/TooltipOverlayControllerTests.swift new file mode 100644 index 000000000..595905dcd --- /dev/null +++ b/Tests/RepoPromptTests/Infrastructure/UI/TooltipOverlayControllerTests.swift @@ -0,0 +1,79 @@ +import AppKit +@testable import RepoPrompt +import XCTest + +@MainActor +final class TooltipOverlayControllerTests: XCTestCase { + func testTooltipHidesWhenDismissAllIsRequested() async { + let owner = makeVisibleOwnerWindow() + let controller = showTooltip(owner: owner) + defer { + controller.hide() + owner.orderOut(nil) + } + + XCTAssertTrue(controller.isVisibleForTesting) + + HoverTooltipCoordinator.dismissAll() + await Task.yield() + + XCTAssertFalse(controller.isVisibleForTesting) + } + + func testTooltipHidesInsteadOfMovingToWindowOriginWhenRepositionedToInvalidAnchor() { + let owner = makeVisibleOwnerWindow() + let controller = showTooltip(owner: owner) + defer { + controller.hide() + owner.orderOut(nil) + } + + XCTAssertTrue(controller.isVisibleForTesting) + + controller.reposition(to: .zero) + + XCTAssertFalse(controller.isVisibleForTesting) + } + + func testTooltipDoesNotShowWithInvalidInitialAnchor() { + let owner = makeVisibleOwnerWindow() + let controller = TooltipOverlayController() + defer { + controller.hide() + owner.orderOut(nil) + } + + controller.show( + text: "1 connection - View status", + anchorRect: .zero, + owner: owner, + placement: .top, + preset: .current + ) + + XCTAssertFalse(controller.isVisibleForTesting) + } + + private func makeVisibleOwnerWindow() -> NSWindow { + let owner = NSWindow( + contentRect: NSRect(x: 80, y: 120, width: 320, height: 240), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + owner.orderFront(nil) + return owner + } + + private func showTooltip(owner: NSWindow) -> TooltipOverlayController { + let controller = TooltipOverlayController() + controller.show( + text: "1 connection - View status", + anchorRect: NSRect(x: 8, y: 8, width: 80, height: 20), + owner: owner, + placement: .top, + preset: .current + ) + return controller + } +}