From 786148cc505191845b2e706c18f836694995fbb8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 19:25:23 -0700 Subject: [PATCH 1/8] test: cover grouped profile popover opening animation --- .../Popover/ArrowlessPopoverAnchor.swift | 2 +- cmuxTests/CmuxPopoverGroupTests.swift | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 053a659ba62d..8e043017e363 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -187,7 +187,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } } - private func makePopover() -> NSPopover { + func makePopover() -> NSPopover { let popover = NSPopover() popover.behavior = group == nil ? .semitransient : .applicationDefined popover.animates = group == nil diff --git a/cmuxTests/CmuxPopoverGroupTests.swift b/cmuxTests/CmuxPopoverGroupTests.swift index 1ddccdbc0e5f..ca5854343e77 100644 --- a/cmuxTests/CmuxPopoverGroupTests.swift +++ b/cmuxTests/CmuxPopoverGroupTests.swift @@ -1,10 +1,21 @@ import AppKit +import SwiftUI import Testing @testable import CmuxAppKitSupportUI @MainActor @Suite struct CmuxPopoverGroupTests { + @Test func groupedRootPopoverUsesNativeOpeningHint() { + let coordinator = ArrowlessPopoverAnchor.Coordinator( + isPresented: .constant(true), group: CmuxPopoverGroup() + ) + let popover = coordinator.makePopover() + #expect(popover.behavior == .applicationDefined) + #expect(popover.animates) + coordinator.dismiss() + } + @Test func clicksInsideEitherMenuKeepBothOpen() { let group = CmuxPopoverGroup() let parent = UUID() From a098feff3c4e6f7087d8b82c1b23e6f342d8d9ef Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 19:56:07 -0700 Subject: [PATCH 2/8] fix: restore native profile menu opening animation --- .../Popover/ArrowlessPopoverAnchor.swift | 21 +++++++++++++-- .../Popover/CmuxPopoverGroup.swift | 13 ++++++++-- cmuxTests/CmuxPopoverGroupTests.swift | 26 ++++++++++++------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 8e043017e363..0e79956924c7 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -97,6 +97,14 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable _isPresented = binding } + /// Returns whether this coordinator should use AppKit's presentation transition. + /// + /// Root popovers retain the native transition; nested popovers open immediately + /// so the grouped hover path does not animate an independent child window. + static func shouldAnimatePresentation(reduceMotion: Bool, isSubmenu: Bool) -> Bool { + !reduceMotion && !isSubmenu + } + func updateRootView(_ rootView: AnyView) { CmuxPopoverMutation.performWithoutImplicitAnimation { hostingController.rootView = AnyView(rootView.fixedSize()) @@ -165,11 +173,13 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable func dismiss() { cancelDeferredRootViewUpdate() unregisterFromGroup() + if group != nil { popover?.animates = false } popover?.performClose(nil) popover = nil } public func popoverWillClose(_ notification: Notification) { + guard let closing = notification.object as? NSPopover, closing === popover else { return } unregisterFromGroup() } @@ -180,6 +190,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } public func popoverDidClose(_ notification: Notification) { + guard let closing = notification.object as? NSPopover, closing === popover else { return } cancelDeferredRootViewUpdate() popover = nil if isPresented { @@ -187,10 +198,16 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } } - func makePopover() -> NSPopover { + private func makePopover() -> NSPopover { let popover = NSPopover() popover.behavior = group == nil ? .semitransient : .applicationDefined - popover.animates = group == nil + // Grouping owns dismissal, not the root menu's native opening transition. + // Hover submenus still open immediately, and Reduce Motion always wins. + let isSubmenu = anchorView.flatMap { group?.parentID(for: $0) } != nil + popover.animates = Coordinator.shouldAnimatePresentation( + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + isSubmenu: isSubmenu + ) popover.setValue(true, forKeyPath: "shouldHideAnchor") popover.contentViewController = hostingController popover.delegate = self diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift index ba5ea6595039..d3022333f8c5 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift @@ -31,9 +31,14 @@ public final class CmuxPopoverGroup { /// Creates an independent dismissal group for one menu presentation. public init() {} + func parentID(for anchor: NSView) -> UUID? { + guard let window = anchor.window else { return nil } + return members.last { windows[$0.id]?() === window }?.id + } + func register(popover: NSPopover, anchor: NSView) -> UUID { let id = UUID() - let parent = members.last { windows[$0.id]?() === anchor.window }?.id + let parent = parentID(for: anchor) let contains: (Int?, CGPoint) -> Bool = { [weak popover, weak anchor] windowNumber, point in if let window = popover?.contentViewController?.view.window, popover?.isShown == true, @@ -58,7 +63,11 @@ public final class CmuxPopoverGroup { parent: parent, contains: contains, containsPointer: containsPointer, - close: { [weak popover] in popover?.close() } + close: { [weak popover] in + // Finish each child's close before its parent tears down its window. + popover?.animates = false + popover?.close() + } ) windows[id] = { [weak popover] in popover?.contentViewController?.view.window } enableMouseTracking(in: anchor.window, for: id) diff --git a/cmuxTests/CmuxPopoverGroupTests.swift b/cmuxTests/CmuxPopoverGroupTests.swift index ca5854343e77..3e2f0ca39da0 100644 --- a/cmuxTests/CmuxPopoverGroupTests.swift +++ b/cmuxTests/CmuxPopoverGroupTests.swift @@ -1,19 +1,27 @@ import AppKit -import SwiftUI import Testing @testable import CmuxAppKitSupportUI @MainActor @Suite struct CmuxPopoverGroupTests { - @Test func groupedRootPopoverUsesNativeOpeningHint() { - let coordinator = ArrowlessPopoverAnchor.Coordinator( - isPresented: .constant(true), group: CmuxPopoverGroup() - ) - let popover = coordinator.makePopover() - #expect(popover.behavior == .applicationDefined) - #expect(popover.animates) - coordinator.dismiss() + @Test func groupedRootUsesNativeOpeningAnimation() { + #expect(ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + reduceMotion: false, isSubmenu: false + )) + } + + @Test func groupedSubmenuRemainsImmediate() { + #expect(!ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + reduceMotion: false, isSubmenu: true + )) + } + + @Test(arguments: [false, true]) + func reduceMotionDisablesOpeningAnimationForRootAndSubmenu(isSubmenu: Bool) { + #expect(!ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + reduceMotion: true, isSubmenu: isSubmenu + )) } @Test func clicksInsideEitherMenuKeepBothOpen() { From f46d0add61a86885753e126e08a85708778b82ba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:15:17 -0700 Subject: [PATCH 3/8] fix: keep popover animation policy on coordinator --- .../Popover/ArrowlessPopoverAnchor.swift | 4 ++-- cmuxTests/CmuxPopoverGroupTests.swift | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 0e79956924c7..2043b03e7556 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -101,7 +101,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable /// /// Root popovers retain the native transition; nested popovers open immediately /// so the grouped hover path does not animate an independent child window. - static func shouldAnimatePresentation(reduceMotion: Bool, isSubmenu: Bool) -> Bool { + func shouldAnimatePresentation(reduceMotion: Bool, isSubmenu: Bool) -> Bool { !reduceMotion && !isSubmenu } @@ -204,7 +204,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable // Grouping owns dismissal, not the root menu's native opening transition. // Hover submenus still open immediately, and Reduce Motion always wins. let isSubmenu = anchorView.flatMap { group?.parentID(for: $0) } != nil - popover.animates = Coordinator.shouldAnimatePresentation( + popover.animates = shouldAnimatePresentation( reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, isSubmenu: isSubmenu ) diff --git a/cmuxTests/CmuxPopoverGroupTests.swift b/cmuxTests/CmuxPopoverGroupTests.swift index 3e2f0ca39da0..738105703719 100644 --- a/cmuxTests/CmuxPopoverGroupTests.swift +++ b/cmuxTests/CmuxPopoverGroupTests.swift @@ -6,20 +6,29 @@ import Testing @Suite struct CmuxPopoverGroupTests { @Test func groupedRootUsesNativeOpeningAnimation() { - #expect(ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + let coordinator = ArrowlessPopoverAnchor.Coordinator( + isPresented: .constant(true), group: CmuxPopoverGroup() + ) + #expect(coordinator.shouldAnimatePresentation( reduceMotion: false, isSubmenu: false )) } @Test func groupedSubmenuRemainsImmediate() { - #expect(!ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + let coordinator = ArrowlessPopoverAnchor.Coordinator( + isPresented: .constant(true), group: CmuxPopoverGroup() + ) + #expect(!coordinator.shouldAnimatePresentation( reduceMotion: false, isSubmenu: true )) } @Test(arguments: [false, true]) func reduceMotionDisablesOpeningAnimationForRootAndSubmenu(isSubmenu: Bool) { - #expect(!ArrowlessPopoverAnchor.Coordinator.shouldAnimatePresentation( + let coordinator = ArrowlessPopoverAnchor.Coordinator( + isPresented: .constant(true), group: CmuxPopoverGroup() + ) + #expect(!coordinator.shouldAnimatePresentation( reduceMotion: true, isSubmenu: isSubmenu )) } From 87ee48eaaa7b7aa04d8ee0151b9e0b7a5f2a424f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:39:09 -0700 Subject: [PATCH 4/8] fix: restore SwiftUI test import --- cmuxTests/CmuxPopoverGroupTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/CmuxPopoverGroupTests.swift b/cmuxTests/CmuxPopoverGroupTests.swift index 738105703719..9fbcc7fc91b9 100644 --- a/cmuxTests/CmuxPopoverGroupTests.swift +++ b/cmuxTests/CmuxPopoverGroupTests.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import Testing @testable import CmuxAppKitSupportUI From 29444a0eea04d5e3ca2e2bf438d5505f9f1a87d6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:03:39 -0700 Subject: [PATCH 5/8] fix: retain closing popover until callback --- .../Popover/ArrowlessPopoverAnchor.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 2043b03e7556..8ce12e0436db 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -83,6 +83,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) private let visibleUpdateScheduler = CmuxPopoverVisibleUpdateScheduler() private var popover: NSPopover? + private var closingPopover: NSPopover? private var pendingVisibleRootView: AnyView? private let group: CmuxPopoverGroup? private var groupMemberID: UUID? @@ -173,14 +174,23 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable func dismiss() { cancelDeferredRootViewUpdate() unregisterFromGroup() - if group != nil { popover?.animates = false } - popover?.performClose(nil) + guard let popover else { + isPresented = false + return + } + closingPopover = popover + if group != nil { popover.animates = false } + popover.performClose(nil) popover = nil + isPresented = false } public func popoverWillClose(_ notification: Notification) { - guard let closing = notification.object as? NSPopover, closing === popover else { return } - unregisterFromGroup() + guard let closing = notification.object as? NSPopover, + closing === popover || closing === closingPopover else { return } + if closing === popover { + unregisterFromGroup() + } } private func unregisterFromGroup() { @@ -190,7 +200,12 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } public func popoverDidClose(_ notification: Notification) { - guard let closing = notification.object as? NSPopover, closing === popover else { return } + guard let closing = notification.object as? NSPopover else { return } + if closing === closingPopover { + closingPopover = nil + return + } + guard closing === popover else { return } cancelDeferredRootViewUpdate() popover = nil if isPresented { From 9d5052c8f2f16d4e58fcab55de63966e48cfd674 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:25:58 -0700 Subject: [PATCH 6/8] fix: assign coordinator popover property explicitly --- .../CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 8ce12e0436db..43a878a6682f 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -174,14 +174,14 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable func dismiss() { cancelDeferredRootViewUpdate() unregisterFromGroup() - guard let popover else { + guard let popover = self.popover else { isPresented = false return } closingPopover = popover if group != nil { popover.animates = false } popover.performClose(nil) - popover = nil + self.popover = nil isPresented = false } From e80089f69cb0259c6223de6fab2137e6d9dbcc9f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:54:45 -0700 Subject: [PATCH 7/8] fix: avoid binding writes during popover teardown --- .../Popover/ArrowlessPopoverAnchor.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 43a878a6682f..8f99a9685a06 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -71,7 +71,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } public static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.dismiss() + coordinator.dismiss(resetPresentation: false) } /// Bridges popover lifecycle between AppKit's `NSPopover` and the SwiftUI binding. @@ -171,18 +171,18 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } } - func dismiss() { + func dismiss(resetPresentation: Bool = true) { cancelDeferredRootViewUpdate() unregisterFromGroup() guard let popover = self.popover else { - isPresented = false + if resetPresentation { isPresented = false } return } closingPopover = popover if group != nil { popover.animates = false } popover.performClose(nil) self.popover = nil - isPresented = false + if resetPresentation { isPresented = false } } public func popoverWillClose(_ notification: Notification) { From f10ca3664e241ad2c43e516942563edb4ccb2fa4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 22:35:55 -0700 Subject: [PATCH 8/8] fix: retain overlapping closing popovers --- .../Popover/ArrowlessPopoverAnchor.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index 8f99a9685a06..f1b14c077651 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -83,7 +83,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) private let visibleUpdateScheduler = CmuxPopoverVisibleUpdateScheduler() private var popover: NSPopover? - private var closingPopover: NSPopover? + private var closingPopovers: [ObjectIdentifier: NSPopover] = [:] private var pendingVisibleRootView: AnyView? private let group: CmuxPopoverGroup? private var groupMemberID: UUID? @@ -178,7 +178,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable if resetPresentation { isPresented = false } return } - closingPopover = popover + closingPopovers[ObjectIdentifier(popover)] = popover if group != nil { popover.animates = false } popover.performClose(nil) self.popover = nil @@ -187,7 +187,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable public func popoverWillClose(_ notification: Notification) { guard let closing = notification.object as? NSPopover, - closing === popover || closing === closingPopover else { return } + closing === popover || closingPopovers[ObjectIdentifier(closing)] != nil else { return } if closing === popover { unregisterFromGroup() } @@ -201,8 +201,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable public func popoverDidClose(_ notification: Notification) { guard let closing = notification.object as? NSPopover else { return } - if closing === closingPopover { - closingPopover = nil + if closingPopovers.removeValue(forKey: ObjectIdentifier(closing)) != nil { return } guard closing === popover else { return }