Skip to content
Open
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct MCPConnectionsCounter: View {

var body: some View {
Button {
HoverTooltipCoordinator.dismissAll()
NotificationCenter.default.post(
name: .showMCPStatusWindow,
object: nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -153,6 +163,9 @@ private struct HoverTooltipModifier: ViewModifier {
cleanup(resetContext: false)
}
}
.onReceive(NotificationCenter.default.publisher(for: .hoverTooltipsShouldDismiss)) { _ in
cleanup(resetContext: false)
}
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -43,6 +48,11 @@ final class TooltipOverlayController {
}

func reposition(to anchorRect: NSRect) {
guard Self.isValidAnchorRect(anchorRect) else {
hide()
return
}

guard
let win,
let owner,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading