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 @@ -51,7 +51,7 @@ struct UpdateErrorView: View {
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.updatePrimaryButtonStyle()
.controlSize(.small)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ private struct DetectedBackgroundUpdateView: View {
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.updatePrimaryButtonStyle()
.controlSize(.small)
}
}
Expand Down Expand Up @@ -248,7 +248,7 @@ private struct PermissionRequestView: View {
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.updatePrimaryButtonStyle()
}
}
.padding(16)
Expand Down Expand Up @@ -323,7 +323,7 @@ private struct UpdateAvailableView: View {
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.updatePrimaryButtonStyle()
.controlSize(.small)
}
}
Expand Down Expand Up @@ -425,7 +425,7 @@ private struct InstallingView: View {
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.updatePrimaryButtonStyle()
.controlSize(.small)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading