Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ add_executable(SimStore MACOSX_BUNDLE
NoSimulatorsView.swift
MissingXcodeView.swift
SimulatorStatusViews.swift
StableSizeView.swift
SimulatorCheckButton.swift
)

include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake")
Expand Down
10 changes: 2 additions & 8 deletions src/MissingXcodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,8 @@ struct MissingXcodeView: View {
HStack(spacing: 10) {
Button("Open App Store", systemImage: "bag", action: openAppStore)
.buttonStyle(.borderedProminent)
Button(
isChecking ? String(localized: "Checking…") : String(localized: "Check Again"),
systemImage: "arrow.clockwise"
) {
checkAgain()
}
.buttonStyle(.bordered)
.disabled(isChecking)
SimulatorCheckButton(isChecking: isChecking, action: checkAgain)
.buttonStyle(.bordered)
}
.controlSize(.large)
.padding(.top, 25)
Expand Down
10 changes: 2 additions & 8 deletions src/NoSimulatorsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,8 @@ struct NoSimulatorsView: View {
openDeviceManager()
}
.buttonStyle(.borderedProminent)
Button(
isChecking ? String(localized: "Checking…") : String(localized: "Check Again"),
systemImage: "arrow.clockwise"
) {
checkAgain()
}
.buttonStyle(.bordered)
.disabled(isChecking)
SimulatorCheckButton(isChecking: isChecking, action: checkAgain)
.buttonStyle(.bordered)
}
.controlSize(.large)
.padding(.top, 25)
Expand Down
26 changes: 26 additions & 0 deletions src/SimulatorCheckButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SwiftUI

@MainActor
struct SimulatorCheckButton: View {
let isChecking: Bool
var isRetry = false
let action: () -> Void

var body: some View {
Button(action: action) {
Label {
StableSizeView(isChecking, states: [false, true]) { checking in
Text(title(for: checking))
}
} icon: {
Image(systemName: "arrow.clockwise")
}
}
.disabled(isChecking)
}

private func title(for checking: Bool) -> LocalizedStringKey {
if checking { return "Checking…" }
return isRetry ? "Try Again" : "Check Again"
}
}
8 changes: 1 addition & 7 deletions src/SimulatorStatusViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,7 @@ struct SimulatorDiscoveryErrorView: View {
.frame(maxWidth: 390)
HStack(spacing: 10) {
Button("Open Xcode", systemImage: "hammer") { openXcode() }
Button(
isChecking ? String(localized: "Checking…") : String(localized: "Try Again"),
systemImage: "arrow.clockwise"
) {
checkAgain()
}
.disabled(isChecking)
SimulatorCheckButton(isChecking: isChecking, isRetry: true, action: checkAgain)
}
.controlSize(.large)
DisclosureGroup("Details") {
Expand Down
28 changes: 28 additions & 0 deletions src/StableSizeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI

@MainActor
struct StableSizeView<State: Hashable, Content: View>: View {
let selection: State
let states: [State]
let content: (State) -> Content

init(
_ selection: State, states: [State],
@ViewBuilder content: @escaping (State) -> Content
) {
self.selection = selection
self.states = states
self.content = content
}

var body: some View {
ZStack {
ForEach(states, id: \.self) { state in
content(state)
.opacity(state == selection ? 1 : 0)
.allowsHitTesting(state == selection)
.accessibilityHidden(state != selection)
}
}
}
}
6 changes: 6 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ target_compile_options(SimStoreLocalizationTest PRIVATE -parse-as-library)
add_test(NAME localization COMMAND SimStoreLocalizationTest "$<TARGET_BUNDLE_DIR:SimStore>")
set_tests_properties(localization PROPERTIES TIMEOUT 10)

add_executable(SimStoreStableSizeTest StableSizeViewTests.swift "${PROJECT_SOURCE_DIR}/src/StableSizeView.swift")
add_dependencies(SimStoreStableSizeTest SimStore)
target_compile_options(SimStoreStableSizeTest PRIVATE -parse-as-library)
add_test(NAME stable-button-size COMMAND SimStoreStableSizeTest "$<TARGET_BUNDLE_DIR:SimStore>")
set_tests_properties(stable-button-size PROPERTIES TIMEOUT 15 RUN_SERIAL TRUE)

execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version
OUTPUT_VARIABLE SDK_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
Expand Down
125 changes: 125 additions & 0 deletions tests/StableSizeViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import AppKit
import SwiftUI

private struct ButtonFrames: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]

static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}

@MainActor
private final class Measurements {
var frames: [String: CGRect] = [:]
}

@main
struct StableSizeViewTests {
@MainActor
static func main() async {
do {
guard CommandLine.arguments.count == 2 else { throw failure("Expected the app bundle path.") }
let resources = URL(fileURLWithPath: CommandLine.arguments[1])
.appendingPathComponent("Contents/Resources")
let app = NSApplication.shared
app.setActivationPolicy(.regular)
let window = NSWindow(
contentRect: NSRect(x: 200, y: 200, width: 640, height: 180),
styleMask: [.titled], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
var cases = 0
for language in ["en", "zh-Hans"] {
guard let bundle = Bundle(url: resources.appendingPathComponent("\(language).lproj")) else {
throw failure("Missing \(language) translations.")
}
let checking = bundle.localizedString(forKey: "Checking…", value: nil, table: nil)
for key in ["Check Again", "Try Again"] {
let idle = bundle.localizedString(forKey: key, value: nil, table: nil)
for controlSize: ControlSize in [.regular, .large] {
for fontSize: CGFloat in [14, 24] {
let labels = [idle, checking]
let before = try await measure(
window, checking: false, labels: labels, controlSize: controlSize,
fontSize: fontSize)
let during = try await measure(
window, checking: true, labels: labels, controlSize: controlSize, fontSize: fontSize
)
for name in ["check", "neighbor"] {
guard let first = before[name], let second = during[name],
abs(first.minX - second.minX) < 0.5,
abs(first.minY - second.minY) < 0.5,
abs(first.width - second.width) < 0.5,
abs(first.height - second.height) < 0.5
else {
throw failure(
"\(language), \(key), \(controlSize), \(fontSize): \(name) moved or resized.")
}
}
cases += 1
}
}
}
}
print(
"PASS: stable button bounds and adjacent position in \(cases) translated control/font combinations."
)
} catch {
FileHandle.standardError.write(
Data("Stable sizing test failed: \(error.localizedDescription)\n".utf8))
exit(EXIT_FAILURE)
}
}

@MainActor
private static func measure(
_ window: NSWindow, checking: Bool, labels: [String], controlSize: ControlSize,
fontSize: CGFloat
) async throws -> [String: CGRect] {
let measurements = Measurements()
let content = HStack(spacing: 10) {
Button("Neighbor") {}.buttonStyle(.borderedProminent)
.background(frame("neighbor"))
Button {
} label: {
Label {
StableSizeView(checking, states: [false, true]) { state in
Text(verbatim: labels[state ? 1 : 0])
}
} icon: {
Image(systemName: "arrow.clockwise")
}
}
.buttonStyle(.bordered)
.disabled(checking)
.background(frame("check"))
}
.controlSize(controlSize)
.font(.system(size: fontSize))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onPreferenceChange(ButtonFrames.self) { frames in
Task { @MainActor in measurements.frames = frames }
}
let host = NSHostingView(rootView: content)
host.frame = NSRect(x: 0, y: 0, width: 640, height: 180)
window.contentView = host
window.orderFront(nil)
for _ in 0..<30 {
host.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(20))
if measurements.frames.count == 2 { return measurements.frames }
}
throw failure("Native button geometry was not produced.")
}

private static func frame(_ name: String) -> some View {
GeometryReader { geometry in
Color.clear.preference(key: ButtonFrames.self, value: [name: geometry.frame(in: .global)])
}
}

private static func failure(_ message: String) -> NSError {
NSError(domain: "StableSizeViewTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
}