From cbb392bb41cff30d6a424a8b688c457e22f3f6de Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 10 Oct 2026 17:55:37 -0700 Subject: [PATCH] Keep update popover primary buttons legible when the popover is not key The update popover usually opens without becoming the key window, so its .borderedProminent actions render in the inactive control state: AppKit draws the gray inactive bezel under the prominent label and "Install and Relaunch" shows as an empty gray box. Force the key control state on the popover's primary actions (Install and Relaunch, Allow, Restart Now, Download Latest Version), as the Cloud enablement buttons already do. Fixes #18687 Co-Authored-By: Claude Opus 5.5 --- .../CmuxUpdaterUI/UpdateErrorView.swift | 2 +- .../CmuxUpdaterUI/UpdatePopoverView.swift | 8 +- .../View+UpdatePrimaryButton.swift | 14 ++++ .../UpdatePrimaryButtonStyleTests.swift | 74 +++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/View+UpdatePrimaryButton.swift create mode 100644 Packages/macOS/CmuxUpdaterUI/Tests/CmuxUpdaterUITests/UpdatePrimaryButtonStyleTests.swift diff --git a/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdateErrorView.swift b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdateErrorView.swift index 33db56062285..e0e0e19ff283 100644 --- a/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdateErrorView.swift +++ b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdateErrorView.swift @@ -51,7 +51,7 @@ struct UpdateErrorView: View { } .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .updatePrimaryButtonStyle() .controlSize(.small) } diff --git a/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift index 7b6ddf9bae6b..f878ba45f00f 100644 --- a/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift +++ b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift @@ -169,7 +169,7 @@ private struct DetectedBackgroundUpdateView: View { dismiss() } .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) + .updatePrimaryButtonStyle() .controlSize(.small) } } @@ -248,7 +248,7 @@ private struct PermissionRequestView: View { dismiss() } .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) + .updatePrimaryButtonStyle() } } .padding(16) @@ -323,7 +323,7 @@ private struct UpdateAvailableView: View { dismiss() } .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) + .updatePrimaryButtonStyle() .controlSize(.small) } } @@ -425,7 +425,7 @@ private struct InstallingView: View { dismiss() } .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) + .updatePrimaryButtonStyle() .controlSize(.small) } } diff --git a/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/View+UpdatePrimaryButton.swift b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/View+UpdatePrimaryButton.swift new file mode 100644 index 000000000000..a550968f9bea --- /dev/null +++ b/Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/View+UpdatePrimaryButton.swift @@ -0,0 +1,14 @@ +import SwiftUI + +extension View { + /// `.borderedProminent` that keeps its accent bezel and label when the popover's window is not key. + /// + /// The update popover usually opens without becoming the key window, so SwiftUI reports an + /// inactive control state. AppKit then draws the inactive gray bezel under the prominent + /// label and the primary action ("Install and Relaunch") shows as an empty gray box (#18687). + /// Same treatment as the Cloud enablement buttons (`cloudProminentButtonStyle`). + func updatePrimaryButtonStyle() -> some View { + buttonStyle(.borderedProminent) + .environment(\.controlActiveState, .key) + } +} diff --git a/Packages/macOS/CmuxUpdaterUI/Tests/CmuxUpdaterUITests/UpdatePrimaryButtonStyleTests.swift b/Packages/macOS/CmuxUpdaterUI/Tests/CmuxUpdaterUITests/UpdatePrimaryButtonStyleTests.swift new file mode 100644 index 000000000000..51e44d4fdf44 --- /dev/null +++ b/Packages/macOS/CmuxUpdaterUI/Tests/CmuxUpdaterUITests/UpdatePrimaryButtonStyleTests.swift @@ -0,0 +1,74 @@ +import AppKit +import SwiftUI +import Testing +@testable import CmuxUpdaterUI + +@MainActor +@Suite("Update primary button style", .serialized) +struct UpdatePrimaryButtonStyleTests { + @Test("Primary update actions draw as key while the popover window is inactive (#18687)") + func primaryButtonIgnoresInactiveWindowState() { + let recorder = ControlActiveStateRecorder() + let window = host( + ControlActiveStateProbe(recorder: recorder) + .updatePrimaryButtonStyle() + .environment(\.controlActiveState, .inactive) + ) + defer { window.orderOut(nil) } + pump { !recorder.states.isEmpty } + + #expect(!recorder.states.isEmpty, "the probe must render") + #expect(recorder.states.allSatisfy { $0 == .key }) + } + + @Test("The probe sees the inactive state without the primary style") + func probeObservesInactiveStateWithoutPrimaryStyle() { + let recorder = ControlActiveStateRecorder() + let window = host( + ControlActiveStateProbe(recorder: recorder) + .environment(\.controlActiveState, .inactive) + ) + defer { window.orderOut(nil) } + pump { !recorder.states.isEmpty } + + #expect(recorder.states.last == .inactive) + } + + private func host(_ view: some View) -> NSWindow { + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 120, height: 40) + let window = NSWindow( + contentRect: NSRect(x: -4000, y: -4000, width: 120, height: 40), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = hosting + window.orderBack(nil) + hosting.layoutSubtreeIfNeeded() + return window + } + + /// Pumps the main run loop until `condition` or a bounded deadline (test scaffolding only). + private func pump(until condition: () -> Bool) { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !condition(), ContinuousClock.now < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + } +} + +@MainActor +private final class ControlActiveStateRecorder { + var states: [ControlActiveState] = [] +} + +private struct ControlActiveStateProbe: View { + let recorder: ControlActiveStateRecorder + @Environment(\.controlActiveState) private var controlActiveState + + var body: some View { + recorder.states.append(controlActiveState) + return Color.clear.frame(width: 10, height: 10) + } +}