From f2059420a0eb5af044bbcdb1b29ce70856d2bd05 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 15:42:23 -0400 Subject: [PATCH 01/17] Simplify directory trees in AGENTS.md and README.md Show only directories instead of enumerating individual files. Add missing BroadwayTestHost and BroadwayTesting to README. Made-with: Cursor --- AGENTS.md | 58 ++++++++++++++----------------------------------------- README.md | 35 ++++++++++++++------------------- 2 files changed, 28 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 829d368..b9735b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,56 +10,26 @@ Broadway is a SwiftUI iOS + Mac Catalyst application managed by **Tuist**. The X ``` / -├── .githooks/pre-commit # Git pre-commit hook (SwiftFormat lint) -├── .mise.toml # mise tool versions (pins Tuist, SwiftFormat) -├── .swiftformat # SwiftFormat configuration -├── Tuist.swift # Tuist global configuration -├── Project.swift # Tuist project manifest (root level) +├── .githooks/ # Git hooks (pre-commit SwiftFormat lint) ├── BroadwayCatalog/ -│ ├── Sources/ # Catalog app source code (Swift / SwiftUI) -│ │ ├── BroadwayApp.swift # @main entry point -│ │ └── ContentView.swift # Root view -│ ├── Resources/ # Bundled resources (assets, localization, etc.) -│ └── Tests/ # Catalog app unit tests (Swift Testing) -│ └── BroadwayCatalogTests.swift +│ ├── Sources/ # Catalog app source code (Swift / SwiftUI) +│ ├── Resources/ # Bundled resources (assets, localization, etc.) +│ └── Tests/ # Catalog app unit tests ├── BroadwayUI/ -│ ├── Sources/ # UI framework source code -│ │ └── BRootViewController.swift # Root container VC (context + trait propagation) -│ └── Tests/ # UI framework unit tests (Swift Testing) -│ └── BRootViewControllerTests.swift +│ ├── Sources/ # UI framework source code +│ └── Tests/ # UI framework unit tests +├── BroadwayCore/ +│ ├── Sources/ # Core framework source code +│ └── Tests/ # Core framework unit tests ├── BroadwayTestHost/ -│ └── Sources/ # Minimal app used as test host for unit tests -│ └── TestHostApp.swift # @main entry point (empty window) +│ └── Sources/ # Minimal test host app ├── BroadwayTesting/ -│ └── Sources/ # Test utilities framework (depends on BroadwayCore) -│ └── BroadwayTesting.swift # Module entry point -├── BroadwayCore/ -│ ├── Sources/ # Core framework source code -│ │ ├── AnyEquatable.swift # Type-erased Equatable wrapper -│ │ ├── BAccessibility.swift # Accessibility snapshot + Observer -│ │ ├── BContext.swift # Root environment container -│ │ ├── BContext+UITraits.swift # UITraitDefinition bridge (#if canImport(UIKit)) -│ │ ├── BStylesheets.swift # Lazy cached stylesheet resolver -│ │ ├── BThemes.swift # Type-keyed theme container -│ │ ├── BTraits.swift # Type-keyed trait container -│ │ ├── CopyOnWrite.swift # COW property wrapper -│ │ └── TypeIdentifier.swift # Lightweight type-keyed identifier -│ └── Tests/ # Core framework unit tests (Swift Testing) -│ ├── AnyEquatableTests.swift -│ ├── BAccessibilityTests.swift -│ ├── BContextTests.swift -│ ├── BThemesTests.swift -│ ├── BTraitsTests.swift -│ ├── CopyOnWriteTests.swift -│ └── TypeIdentifierTests.swift -├── Plans/ # Archived implementation plans (see index below) -├── swiftformat # Run SwiftFormat (--lint to check only) -├── ide # Dev script (installs hooks, runs tuist generate) -├── LICENSE # Apache 2.0 -├── README.md # Project overview and setup instructions -└── AGENTS.md # This file +│ └── Sources/ # Shared test utilities +└── Plans/ # Archived implementation plans ``` +Key root files: `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), `.mise.toml` (tool versions), `.swiftformat` (style config), `ide` and `swiftformat` (dev scripts). + ## Build System - **Tuist 4+** is used to generate the Xcode project from `Project.swift`. diff --git a/README.md b/README.md index 1c6d556..3d1deaf 100644 --- a/README.md +++ b/README.md @@ -42,30 +42,21 @@ Or open the generated project in Xcode and run tests with **Cmd+U**. ``` Broadway/ -├── .mise.toml # mise tool versions (pins Tuist) -├── Tuist.swift # Tuist configuration -├── Project.swift # Tuist project manifest ├── BroadwayCatalog/ -│ ├── Sources/ # Catalog app source files -│ │ ├── BroadwayApp.swift # @main app entry point -│ │ └── ContentView.swift # Root SwiftUI view -│ ├── Resources/ # Asset catalogs, etc. -│ └── Tests/ # Catalog app unit tests -│ └── BroadwayCatalogTests.swift +│ ├── Sources/ # Catalog app source code +│ ├── Resources/ # Asset catalogs, etc. +│ └── Tests/ # Catalog app unit tests ├── BroadwayUI/ -│ ├── Sources/ # UI framework source files -│ │ └── BroadwayUI.swift -│ └── Tests/ # UI framework unit tests -│ └── BroadwayUITests.swift +│ ├── Sources/ # UI framework source code +│ └── Tests/ # UI framework unit tests ├── BroadwayCore/ -│ ├── Sources/ # Core framework source files -│ │ └── BroadwayCore.swift -│ └── Tests/ # Core framework unit tests -│ └── BroadwayCoreTests.swift -├── Plans/ # Archived implementation plans -├── ide # Dev script (generate project) -├── LICENSE # Apache 2.0 -└── README.md +│ ├── Sources/ # Core framework source code +│ └── Tests/ # Core framework unit tests +├── BroadwayTestHost/ +│ └── Sources/ # Minimal test host app +├── BroadwayTesting/ +│ └── Sources/ # Shared test utilities +└── Plans/ # Archived implementation plans ``` ## Targets @@ -78,6 +69,8 @@ Broadway/ | **BroadwayUITests** | Unit Tests | iOS, Mac Catalyst | | **BroadwayCore** | Framework | iOS, Mac Catalyst | | **BroadwayCoreTests** | Unit Tests | iOS, Mac Catalyst | +| **BroadwayTestHost** | App | iOS, Mac Catalyst | +| **BroadwayTesting** | Framework | iOS, Mac Catalyst | ## License From cbad878eeafce6e0ad728f94fdb78eb5f3bb6b80 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 15:50:39 -0400 Subject: [PATCH 02/17] Remove now un-nedded placeholder files --- BroadwayUI/Sources/BroadwayUI.swift | 3 --- BroadwayUI/Tests/BroadwayUITests.swift | 10 ---------- 2 files changed, 13 deletions(-) delete mode 100644 BroadwayUI/Sources/BroadwayUI.swift delete mode 100644 BroadwayUI/Tests/BroadwayUITests.swift diff --git a/BroadwayUI/Sources/BroadwayUI.swift b/BroadwayUI/Sources/BroadwayUI.swift deleted file mode 100644 index 0593951..0000000 --- a/BroadwayUI/Sources/BroadwayUI.swift +++ /dev/null @@ -1,3 +0,0 @@ -/// BroadwayUI is a SwiftUI component library providing reusable UI -/// building blocks for Broadway applications. -public enum BroadwayUI {} diff --git a/BroadwayUI/Tests/BroadwayUITests.swift b/BroadwayUI/Tests/BroadwayUITests.swift deleted file mode 100644 index 09fc92f..0000000 --- a/BroadwayUI/Tests/BroadwayUITests.swift +++ /dev/null @@ -1,10 +0,0 @@ -@testable import BroadwayUI -import Testing - -struct BroadwayUITests { - @Test("BroadwayUI module is importable") - func moduleExists() { - // Verifies the framework builds and is importable. - #expect(true) - } -} From 39e93ee1ec16ab60101794f98a8575ae3bdd8d24 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 16:09:54 -0400 Subject: [PATCH 03/17] BTraitsObserver: protocol-driven unified trait observation Add BTraitsValueObserver protocol and associated type to BTraitsValue so each trait declares its own observer. BTraitsObserver coordinates them behind a single start/stop interface, simplifying BRootViewController. Remove BAccessibility.observe(_:) factory in favor of direct Observer init. Made-with: Cursor --- BroadwayCore/Sources/BAccessibility.swift | 26 +------ BroadwayCore/Sources/BTraits.swift | 50 ++++++++++++- BroadwayCore/Sources/BTraitsObserver.swift | 74 +++++++++++++++++++ BroadwayCore/Tests/BAccessibilityTests.swift | 36 ++++----- BroadwayCore/Tests/BTraitsObserverTests.swift | 69 +++++++++++++++++ BroadwayUI/Sources/BRootViewController.swift | 24 +++--- 6 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 BroadwayCore/Sources/BTraitsObserver.swift create mode 100644 BroadwayCore/Tests/BTraitsObserverTests.swift diff --git a/BroadwayCore/Sources/BAccessibility.swift b/BroadwayCore/Sources/BAccessibility.swift index 437103a..d29fc28 100644 --- a/BroadwayCore/Sources/BAccessibility.swift +++ b/BroadwayCore/Sources/BAccessibility.swift @@ -175,31 +175,11 @@ extension BAccessibility { } extension BAccessibility { - /// Keeps a ``BContext``'s accessibility traits in sync with the device - /// by delivering change notifications as `(old, new)` pairs. - /// - /// Retain the returned ``Observer`` for the duration of observation. - /// Call ``Observer/start()`` to begin and ``Observer/stop()`` to pause. - /// - /// - Parameter onChange: Called with `(old, new)` values when a change is detected. - /// - Returns: An ``Observer`` that must be retained for the lifetime of observation. - @MainActor public static func observe( - on notificationCenter: NotificationCenter = .default, - with provider: any SettingsProvider = BAccessibility.systemSettings, - _ onChange: @MainActor @escaping @Sendable (BAccessibility, BAccessibility) -> Void, - ) -> Observer { - Observer( - notificationCenter: notificationCenter, - settingsProvider: provider, - onChange: onChange, - ) - } - /// Manages `NotificationCenter` registrations for system accessibility /// changes and reports diffs via a callback. /// Call ``start()`` and ``stop()`` to control the observation lifecycle. @MainActor - public final class Observer { + public final class Observer: BTraitsValueObserver { private let onChange: @MainActor @Sendable (BAccessibility, BAccessibility) -> Void private let notificationCenter: NotificationCenter @@ -210,8 +190,8 @@ extension BAccessibility { private var isObserving: Bool = false init( - notificationCenter: NotificationCenter, - settingsProvider: SettingsProvider, + notificationCenter: NotificationCenter = .default, + settingsProvider: any SettingsProvider = BAccessibility.systemSettings, onChange: @MainActor @escaping @Sendable (BAccessibility, BAccessibility) -> Void, ) { self.notificationCenter = notificationCenter diff --git a/BroadwayCore/Sources/BTraits.swift b/BroadwayCore/Sources/BTraits.swift index dbcee52..e3c3070 100644 --- a/BroadwayCore/Sources/BTraits.swift +++ b/BroadwayCore/Sources/BTraits.swift @@ -18,6 +18,16 @@ extension BAccessibility: BTraitsValue { public static var defaultValue: Self { .init() } + + @MainActor public static var initialValue: BAccessibility { + .current() + } + + @MainActor public static func makeObserver( + onChange: @MainActor @escaping @Sendable (BAccessibility) -> Void, + ) -> BAccessibility.Observer { + .init { _, new in onChange(new) } + } } /// A type-keyed container of ``BTraitsValue`` conforming values @@ -54,7 +64,45 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { /// A hashable value that can be stored in ``BTraits``. /// /// Conforming types provide a ``defaultValue`` that is returned when -/// the trait has not been explicitly set. +/// the trait has not been explicitly set. Types that need live system +/// observation override ``initialValue`` and ``makeObserver(onChange:)`` +/// to supply a snapshot and an observer. public protocol BTraitsValue: Hashable { + associatedtype Observer: BTraitsValueObserver = NeverObserver static var defaultValue: Self { get } + @MainActor static var initialValue: Self { get } + @MainActor static func makeObserver( + onChange: @MainActor @escaping @Sendable (Self) -> Void, + ) -> Observer +} + +extension BTraitsValue { + @MainActor public static var initialValue: Self { + defaultValue + } +} + +extension BTraitsValue where Observer == NeverObserver { + @MainActor public static func makeObserver( + onChange _: @MainActor @escaping @Sendable (Self) -> Void, + ) -> NeverObserver { + NeverObserver() + } +} + +// MARK: - BTraitsValueObserver + +/// The interface for an observer that monitors changes to +/// a single ``BTraitsValue`` type. Retained by ``BTraitsObserver``. +@MainActor public protocol BTraitsValueObserver: AnyObject { + func start() + func stop() +} + +/// A no-op observer used as the default ``BTraitsValue/Observer`` +/// for trait values that do not require live observation. +@MainActor public final class NeverObserver: BTraitsValueObserver { + public init() {} + public func start() {} + public func stop() {} } diff --git a/BroadwayCore/Sources/BTraitsObserver.swift b/BroadwayCore/Sources/BTraitsObserver.swift new file mode 100644 index 0000000..d2cbfd0 --- /dev/null +++ b/BroadwayCore/Sources/BTraitsObserver.swift @@ -0,0 +1,74 @@ +// +// BTraitsObserver.swift +// BroadwayCore +// + +import Foundation + +/// Observes all registered ``BTraitsValue`` types, maintaining a +/// live ``BTraits`` snapshot and notifying the caller when any +/// trait changes. +/// +/// Each ``BTraitsValue`` conformance declares its own +/// ``BTraitsValue/Observer`` and ``BTraitsValue/makeObserver(onChange:)``. +/// `BTraitsObserver` calls these generically and aggregates the +/// individual observers behind a single `start()` / `stop()` lifecycle. +@MainActor +public final class BTraitsObserver { + // MARK: Public + + /// The current aggregated traits. Updated automatically when + /// any observed trait value changes. + public private(set) var traits: BTraits + + // MARK: Initialization + + /// - Parameter onChange: Called with the updated ``BTraits`` + /// whenever any observed trait value changes. + public init( + onChange: @MainActor @escaping @Sendable (BTraits) -> Void, + ) { + traits = BTraits() + self.onChange = onChange + + observe(BAccessibility.self) + } + + // MARK: Lifecycle + + /// Starts all registered trait observers. Safe to call multiple times. + public func start() { + for observer in observers { + observer.start() + } + } + + /// Stops all registered trait observers. Safe to call multiple times. + public func stop() { + for observer in observers { + observer.stop() + } + } + + // MARK: Private + + private let onChange: @MainActor @Sendable (BTraits) -> Void + private var observers: [any BTraitsValueObserver] = [] + + private func observe(_: V.Type) { + traits[V.self] = V.initialValue + + let observer = V.makeObserver { [weak self] newValue in + guard let self else { return } + + let old = traits + traits[V.self] = newValue + + if traits != old { + onChange(traits) + } + } + + observers.append(observer) + } +} diff --git a/BroadwayCore/Tests/BAccessibilityTests.swift b/BroadwayCore/Tests/BAccessibilityTests.swift index 17489ab..a33aced 100644 --- a/BroadwayCore/Tests/BAccessibilityTests.swift +++ b/BroadwayCore/Tests/BAccessibilityTests.swift @@ -176,26 +176,18 @@ struct BAccessibilitySettingsProviderTests { // MARK: - Observer Tests @MainActor struct BAccessibilityObserverTests { - // MARK: - Factory - - @Test("observe returns an Observer") - func observeFactory() { - let observer = BAccessibility.observe { _, _ in } - _ = observer - } - // MARK: - Lifecycle @Test("start and stop complete without error") func startStop() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() } @Test("Calling start twice is safe") func doubleStart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.start() observer.stop() @@ -203,13 +195,13 @@ struct BAccessibilitySettingsProviderTests { @Test("Calling stop without start is safe") func stopWithoutStart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.stop() } @Test("Calling stop twice is safe") func doubleStop() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() observer.stop() @@ -217,7 +209,7 @@ struct BAccessibilitySettingsProviderTests { @Test("Can restart after stopping") func restart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() observer.start() @@ -226,7 +218,7 @@ struct BAccessibilitySettingsProviderTests { @Test("Deallocation after start does not crash") func deallocAfterStart() { - var observer: BAccessibility.Observer? = BAccessibility.observe { _, _ in } + var observer: BAccessibility.Observer? = .init { _, _ in } observer?.start() observer = nil _ = observer @@ -239,7 +231,7 @@ struct BAccessibilitySettingsProviderTests { let center = NotificationCenter() var callCount = 0 - let observer = BAccessibility.observe(on: center) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center) { _, _ in callCount += 1 } observer.start() @@ -260,7 +252,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -280,7 +272,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -299,7 +291,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var received: (old: BAccessibility, new: BAccessibility)? - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in received = (old, new) } observer.start() @@ -319,7 +311,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -337,7 +329,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var snapshots: [(old: BAccessibility, new: BAccessibility)] = [] - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in snapshots.append((old, new)) } observer.start() @@ -366,7 +358,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -384,7 +376,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var received: (old: BAccessibility, new: BAccessibility)? - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in received = (old, new) } diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift new file mode 100644 index 0000000..bc5ac3a --- /dev/null +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -0,0 +1,69 @@ +@testable import BroadwayCore +import Testing + +@MainActor struct BTraitsObserverTests { + // MARK: - Initial Value + + @Test("Initial traits contain the live accessibility snapshot") + func initialAccessibility() { + let observer = BTraitsObserver { _ in } + #expect(observer.traits.accessibility == BAccessibility.current()) + } + + @Test("Initial traits are not equal to a default BTraits()") + func initialTraitsNotDefault() { + let observer = BTraitsObserver { _ in } + let defaultTraits = BTraits() + + #expect(observer.traits.accessibility == BAccessibility.current()) + #expect(defaultTraits.accessibility == BAccessibility()) + } + + // MARK: - Lifecycle + + @Test("start and stop complete without error") + func startStop() { + let observer = BTraitsObserver { _ in } + observer.start() + observer.stop() + } + + @Test("Calling start twice is safe") + func doubleStart() { + let observer = BTraitsObserver { _ in } + observer.start() + observer.start() + observer.stop() + } + + @Test("Calling stop without start is safe") + func stopWithoutStart() { + let observer = BTraitsObserver { _ in } + observer.stop() + } + + @Test("Calling stop twice is safe") + func doubleStop() { + let observer = BTraitsObserver { _ in } + observer.start() + observer.stop() + observer.stop() + } + + @Test("Can restart after stopping") + func restart() { + let observer = BTraitsObserver { _ in } + observer.start() + observer.stop() + observer.start() + observer.stop() + } + + @Test("Deallocation after start does not crash") + func deallocAfterStart() { + var observer: BTraitsObserver? = BTraitsObserver { _ in } + observer?.start() + observer = nil + _ = observer + } +} diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index bd5fdc2..04b439b 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -10,9 +10,9 @@ import UIKit /// A container view controller that owns the root ``BContext`` and /// propagates it to all descendants via a custom `UITraitCollection` trait. /// -/// `BRootViewController` manages the ``BAccessibility/Observer``, -/// keeping ``BContext/traits`` in sync with system accessibility changes -/// and re-publishing the updated context through `traitOverrides`. +/// `BRootViewController` manages a ``BTraitsObserver``, keeping +/// ``BContext/traits`` in sync with system changes and re-publishing +/// the updated context through `traitOverrides`. /// /// Use the designated initializer to wrap an arbitrary child view controller, /// or the convenience initializer to embed SwiftUI content directly. @@ -26,8 +26,8 @@ import UIKit public final class BRootViewController: UIViewController { // MARK: Public - /// The current context. Updated automatically when accessibility - /// settings change. Read this to inspect the live state. + /// The current context. Updated automatically when observed + /// trait values change. Read this to inspect the live state. public private(set) var context: BContext { didSet { traitOverrides.bContext = context @@ -46,13 +46,11 @@ public final class BRootViewController: UIViewController { addChild(child) child.didMove(toParent: self) - context.traits.accessibility = .current() - traitOverrides.bContext = context - - accessibilityObserver = BAccessibility.observe { [weak self] _, new in - guard let self else { return } - context.traits.accessibility = new + traitsObserver = BTraitsObserver { [weak self] traits in + self?.context.traits = traits } + context.traits = traitsObserver!.traits + traitOverrides.bContext = context } /// Creates a root container that hosts SwiftUI content. @@ -75,7 +73,7 @@ public final class BRootViewController: UIViewController { child.view.frame = view.bounds view.addSubview(child.view) - accessibilityObserver?.start() + traitsObserver?.start() } override public func viewWillLayoutSubviews() { @@ -88,5 +86,5 @@ public final class BRootViewController: UIViewController { private let child: UIViewController - private var accessibilityObserver: BAccessibility.Observer? + private var traitsObserver: BTraitsObserver? } From c5ab2214c7555deadd88ba3cb94abe5860188a49 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 16:37:33 -0400 Subject: [PATCH 04/17] Move trait type registration into BTraits BTraits now owns the set of registered trait types via register() and a .system factory. BTraitsObserver reads registrations and the UIViewController hierarchy from the BTraits instance instead of hardcoding types. initialValue replaced with currentValue(from:) for future injectability. EquatableIgnored gains Hashable conformance. Made-with: Cursor --- BroadwayCore/Sources/BContext.swift | 6 +- BroadwayCore/Sources/BTraits.swift | 84 +++++++++++++++++-- BroadwayCore/Sources/BTraitsObserver.swift | 57 +++++++------ BroadwayCore/Sources/EquatableIgnored.swift | 4 + BroadwayCore/Tests/BTraitsObserverTests.swift | 23 +++-- BroadwayUI/Sources/BRootViewController.swift | 4 +- .../Tests/BRootViewControllerTests.swift | 5 +- 7 files changed, 137 insertions(+), 46 deletions(-) diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index fdfc47f..2dbd75a 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -12,12 +12,16 @@ import Foundation /// ``BStylesheets`` cache. Updating `traits` or `themes` replaces the /// stylesheet cache so subsequent lookups produce fresh instances. public struct BContext: Equatable, Sendable { - public init(traits: BTraits = .init(), themes: BThemes = .init()) { + public init(traits: BTraits, themes: BThemes = .init()) { self.traits = traits self.themes = themes stylesheets = BStylesheets(config: .init(traits: traits, themes: themes)) } + init() { + self.init(traits: BTraits()) + } + /// The current trait values (accessibility, size class, etc.). public var traits: BTraits { didSet { diff --git a/BroadwayCore/Sources/BTraits.swift b/BroadwayCore/Sources/BTraits.swift index e3c3070..3931ffe 100644 --- a/BroadwayCore/Sources/BTraits.swift +++ b/BroadwayCore/Sources/BTraits.swift @@ -6,6 +6,9 @@ // import Foundation +import UIKit + +// MARK: - BAccessibility + BTraitsValue extension BTraits { public var accessibility: BAccessibility { @@ -19,7 +22,9 @@ extension BAccessibility: BTraitsValue { .init() } - @MainActor public static var initialValue: BAccessibility { + @MainActor public static func currentValue( + from _: UIViewController, + ) -> BAccessibility { .current() } @@ -30,13 +35,26 @@ extension BAccessibility: BTraitsValue { } } +// MARK: - BTraits + /// A type-keyed container of ``BTraitsValue`` conforming values /// representing the current environment (accessibility, size classes, etc.). /// -/// Traits are always present; accessing a type that hasn't been explicitly -/// set returns its ``BTraitsValue/defaultValue``. +/// Create a traits container with ``system`` for the built-in set of +/// observed traits, or call ``register(_:)`` to add custom types. +/// Accessing a type that hasn't been explicitly set returns +/// its ``BTraitsValue/defaultValue``. public struct BTraits: Equatable, Hashable, @unchecked Sendable { - public init() {} + init() {} + + /// A traits container pre-registered with all built-in system trait types. + @MainActor public static var system: BTraits { + var traits = BTraits() + traits.register(BAccessibility.self) + return traits + } + + // MARK: Subscript /// Gets or sets the trait for the given type. Returns /// ``BTraitsValue/defaultValue`` if no value has been set. @@ -58,26 +76,76 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { } } + // MARK: Registration + + /// Registers a ``BTraitsValue`` type so that ``readCurrentValues(from:)`` + /// and ``BTraitsObserver`` know about it. + @MainActor public mutating func register(_: V.Type) { + registrations.append(Registration( + id: TypeIdentifier(V.self), + readCurrentValue: { vc in AnyHashable(V.currentValue(from: vc)) }, + createObserver: { onChange in + V.makeObserver { newValue in onChange(AnyHashable(newValue)) } + }, + )) + } + + /// Reads the live value for every registered trait type from the + /// given view controller hierarchy and stores it. + @MainActor public mutating func readCurrentValues( + from viewController: UIViewController, + ) { + for reg in registrations { + storage[reg.id] = reg.readCurrentValue(viewController) + } + } + + // MARK: Internal + + mutating func setValue(_ value: AnyHashable, for id: TypeIdentifier) { + storage[id] = value + } + + struct Registration: @unchecked Sendable { + let id: TypeIdentifier + let readCurrentValue: @MainActor @Sendable (UIViewController) -> AnyHashable + let createObserver: @MainActor @Sendable ( + @escaping @MainActor @Sendable (AnyHashable) -> Void + ) -> any BTraitsValueObserver + } + + // MARK: Private + @CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:] + @EquatableIgnored private(set) var registrations: [Registration] = [] } +// MARK: - BTraitsValue + /// A hashable value that can be stored in ``BTraits``. /// /// Conforming types provide a ``defaultValue`` that is returned when /// the trait has not been explicitly set. Types that need live system -/// observation override ``initialValue`` and ``makeObserver(onChange:)`` -/// to supply a snapshot and an observer. +/// observation override ``currentValue(from:)`` and +/// ``makeObserver(onChange:)`` to supply a snapshot and an observer. public protocol BTraitsValue: Hashable { associatedtype Observer: BTraitsValueObserver = NeverObserver + static var defaultValue: Self { get } - @MainActor static var initialValue: Self { get } + + /// Returns the current live value by reading from the view controller + /// hierarchy. Defaults to ``defaultValue``. + @MainActor static func currentValue(from viewController: UIViewController) -> Self + @MainActor static func makeObserver( onChange: @MainActor @escaping @Sendable (Self) -> Void, ) -> Observer } extension BTraitsValue { - @MainActor public static var initialValue: Self { + @MainActor public static func currentValue( + from _: UIViewController, + ) -> Self { defaultValue } } diff --git a/BroadwayCore/Sources/BTraitsObserver.swift b/BroadwayCore/Sources/BTraitsObserver.swift index d2cbfd0..e6a080c 100644 --- a/BroadwayCore/Sources/BTraitsObserver.swift +++ b/BroadwayCore/Sources/BTraitsObserver.swift @@ -4,15 +4,16 @@ // import Foundation +import UIKit -/// Observes all registered ``BTraitsValue`` types, maintaining a -/// live ``BTraits`` snapshot and notifying the caller when any +/// Observes all trait types registered in a ``BTraits`` instance, +/// maintaining a live snapshot and notifying the caller when any /// trait changes. /// -/// Each ``BTraitsValue`` conformance declares its own -/// ``BTraitsValue/Observer`` and ``BTraitsValue/makeObserver(onChange:)``. -/// `BTraitsObserver` calls these generically and aggregates the -/// individual observers behind a single `start()` / `stop()` lifecycle. +/// The observer reads its configuration (which types to observe) +/// from the ``BTraits`` value passed at init, reads the current +/// values from the view controller hierarchy, and creates +/// per-type observers via each type's ``BTraitsValue/makeObserver(onChange:)``. @MainActor public final class BTraitsObserver { // MARK: Public @@ -23,15 +24,38 @@ public final class BTraitsObserver { // MARK: Initialization + /// - Parameter traits: The ``BTraits`` container whose registrations + /// determine which types are observed. + /// - Parameter viewController: The view controller used to read + /// initial trait values via ``BTraitsValue/currentValue(from:)``. /// - Parameter onChange: Called with the updated ``BTraits`` /// whenever any observed trait value changes. public init( + traits: BTraits, + from viewController: UIViewController, onChange: @MainActor @escaping @Sendable (BTraits) -> Void, ) { - traits = BTraits() + self.traits = traits self.onChange = onChange - observe(BAccessibility.self) + self.traits.readCurrentValues(from: viewController) + + for reg in traits.registrations { + let regID = reg.id + + let observer = reg.createObserver { [weak self] newValue in + guard let self else { return } + + let old = self.traits + self.traits.setValue(newValue, for: regID) + + if self.traits != old { + onChange(self.traits) + } + } + + observers.append(observer) + } } // MARK: Lifecycle @@ -54,21 +78,4 @@ public final class BTraitsObserver { private let onChange: @MainActor @Sendable (BTraits) -> Void private var observers: [any BTraitsValueObserver] = [] - - private func observe(_: V.Type) { - traits[V.self] = V.initialValue - - let observer = V.makeObserver { [weak self] newValue in - guard let self else { return } - - let old = traits - traits[V.self] = newValue - - if traits != old { - onChange(traits) - } - } - - observers.append(observer) - } } diff --git a/BroadwayCore/Sources/EquatableIgnored.swift b/BroadwayCore/Sources/EquatableIgnored.swift index 4dd4654..008a73c 100644 --- a/BroadwayCore/Sources/EquatableIgnored.swift +++ b/BroadwayCore/Sources/EquatableIgnored.swift @@ -31,3 +31,7 @@ import Foundation true } } + +extension EquatableIgnored: Hashable { + public func hash(into _: inout Hasher) {} +} diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift index bc5ac3a..52d8dbe 100644 --- a/BroadwayCore/Tests/BTraitsObserverTests.swift +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -1,18 +1,25 @@ @testable import BroadwayCore import Testing +import UIKit @MainActor struct BTraitsObserverTests { + private func makeObserver( + onChange: @MainActor @escaping @Sendable (BTraits) -> Void = { _ in }, + ) -> BTraitsObserver { + BTraitsObserver(traits: .system, from: UIViewController(), onChange: onChange) + } + // MARK: - Initial Value @Test("Initial traits contain the live accessibility snapshot") func initialAccessibility() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() #expect(observer.traits.accessibility == BAccessibility.current()) } @Test("Initial traits are not equal to a default BTraits()") func initialTraitsNotDefault() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() let defaultTraits = BTraits() #expect(observer.traits.accessibility == BAccessibility.current()) @@ -23,14 +30,14 @@ import Testing @Test("start and stop complete without error") func startStop() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() observer.start() observer.stop() } @Test("Calling start twice is safe") func doubleStart() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() observer.start() observer.start() observer.stop() @@ -38,13 +45,13 @@ import Testing @Test("Calling stop without start is safe") func stopWithoutStart() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() observer.stop() } @Test("Calling stop twice is safe") func doubleStop() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() observer.start() observer.stop() observer.stop() @@ -52,7 +59,7 @@ import Testing @Test("Can restart after stopping") func restart() { - let observer = BTraitsObserver { _ in } + let observer = makeObserver() observer.start() observer.stop() observer.start() @@ -61,7 +68,7 @@ import Testing @Test("Deallocation after start does not crash") func deallocAfterStart() { - var observer: BTraitsObserver? = BTraitsObserver { _ in } + var observer: BTraitsObserver? = makeObserver() observer?.start() observer = nil _ = observer diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index 04b439b..2e1e867 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -38,7 +38,7 @@ public final class BRootViewController: UIViewController { /// Creates a root container that embeds the given child view controller. public init(child: UIViewController) { - context = BContext() + context = BContext(traits: .system) self.child = child super.init(nibName: nil, bundle: nil) @@ -46,7 +46,7 @@ public final class BRootViewController: UIViewController { addChild(child) child.didMove(toParent: self) - traitsObserver = BTraitsObserver { [weak self] traits in + traitsObserver = BTraitsObserver(traits: .system, from: self) { [weak self] traits in self?.context.traits = traits } context.traits = traitsObserver!.traits diff --git a/BroadwayUI/Tests/BRootViewControllerTests.swift b/BroadwayUI/Tests/BRootViewControllerTests.swift index 7d3407f..7e1e50e 100644 --- a/BroadwayUI/Tests/BRootViewControllerTests.swift +++ b/BroadwayUI/Tests/BRootViewControllerTests.swift @@ -49,10 +49,11 @@ import UIKit #expect(vc.traitOverrides.bContext == vc.context) } - @Test("Default bContext on UITraitCollection equals BContext()") + @Test("Default bContext on UITraitCollection has default traits") func defaultTraitCollectionContext() { let tc = UITraitCollection() - #expect(tc.bContext == BContext()) + #expect(tc.bContext.traits.accessibility == BAccessibility()) + #expect(tc.bContext.themes == BThemes()) } @Test("Child inherits bContext after layout") From f4b306ccf54cf41748d7692888f6708209e60c76 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 16:41:52 -0400 Subject: [PATCH 05/17] Move Hashable conformance to the main type to avoid spurious IDE error --- BroadwayCore/Sources/EquatableIgnored.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/BroadwayCore/Sources/EquatableIgnored.swift b/BroadwayCore/Sources/EquatableIgnored.swift index 008a73c..ee7857d 100644 --- a/BroadwayCore/Sources/EquatableIgnored.swift +++ b/BroadwayCore/Sources/EquatableIgnored.swift @@ -20,7 +20,7 @@ import Foundation /// } /// ``` /// In this example, `cache` will never affect the equality of two `Example` instances. -@propertyWrapper public struct EquatableIgnored: Equatable { +@propertyWrapper public struct EquatableIgnored: Equatable, Hashable { public var wrappedValue: Value public init(wrappedValue: Value) { @@ -30,8 +30,6 @@ import Foundation public static func == (_: Self, _: Self) -> Bool { true } -} -extension EquatableIgnored: Hashable { public func hash(into _: inout Hasher) {} } From 94d5f74b84ad56c14fa9591e61257d3083fe932c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 17:11:10 -0400 Subject: [PATCH 06/17] Lazy BRootViewController setup and Swift Testing show() helper Defer child VC creation, trait observation, and context setup until the controller enters a valid view hierarchy (viewIsAppearing). Replace the XCTestCase extension with standalone free functions compatible with Swift Testing. Made-with: Cursor --- .../Sources/XCTestCaseAdditions.swift | 171 ++++++++++-------- BroadwayUI/Sources/BRootViewController.swift | 72 +++++--- .../Tests/BRootViewControllerTests.swift | 73 ++++---- Project.swift | 1 - 4 files changed, 178 insertions(+), 139 deletions(-) diff --git a/BroadwayTesting/Sources/XCTestCaseAdditions.swift b/BroadwayTesting/Sources/XCTestCaseAdditions.swift index dbfaecf..bbcfdee 100644 --- a/BroadwayTesting/Sources/XCTestCaseAdditions.swift +++ b/BroadwayTesting/Sources/XCTestCaseAdditions.swift @@ -2,116 +2,129 @@ // XCTestCaseAdditions.swift // BroadwayTesting // -// -import XCTest - -extension XCTestCase { - /// - /// Call this method to show a view controller in the test host application - /// during a unit test. The view controller will be the size of host application's device. - /// - /// After the test runs, the view controller will be removed from the view hierarchy. - /// - /// A test failure will occur if the host application does not exist, or does not have a root view controller. - /// - public func show( - vc viewController: ViewController, - loadAndPlaceView: Bool = true, - test: (ViewController) throws -> Void, - ) rethrows { - guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else { - XCTFail("Cannot present a view controller in a test host that does not have a root window.") - return - } +import UIKit - rootVC.view.window?.layer.speed = 4 - rootVC.addChild(viewController) - viewController.didMove(toParent: rootVC) +struct ShowError: Error, CustomStringConvertible { + var description: String - if loadAndPlaceView { - viewController.view.frame = rootVC.view.bounds - viewController.view.layoutIfNeeded() + init(_ description: String) { + self.description = description + } +} - rootVC.beginAppearanceTransition(true, animated: false) - rootVC.view.addSubview(viewController.view) - rootVC.endAppearanceTransition() - } +/// Shows a view controller in the test host application's window for the +/// duration of `perform`. The view controller is added as a child of +/// the host's root view controller and its view is placed in the window, +/// triggering the full UIKit appearance lifecycle (`viewIsAppearing`, etc.). +/// +/// After `perform` returns (or throws), the view controller is removed +/// from the hierarchy automatically. +@MainActor +public func show( + _ viewController: ViewController, + loadAndPlaceView: Bool = true, + perform test: (ViewController) throws -> Void, +) throws { + guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else { + throw ShowError("No root view controller in test host.") + } - defer { - if loadAndPlaceView { - viewController.beginAppearanceTransition(false, animated: false) - viewController.view.removeFromSuperview() - viewController.endAppearanceTransition() - } + rootVC.view.window?.layer.speed = 100 + rootVC.addChild(viewController) + viewController.didMove(toParent: rootVC) - viewController.willMove(toParent: nil) - viewController.removeFromParent() - rootVC.view.window?.layer.speed = 1 - } + if loadAndPlaceView { + viewController.view.frame = rootVC.view.bounds + rootVC.view.addSubview(viewController.view) + viewController.view.layoutIfNeeded() + } - try autoreleasepool { - try test(viewController) + defer { + if loadAndPlaceView { + viewController.view.removeFromSuperview() } + viewController.willMove(toParent: nil) + viewController.removeFromParent() + rootVC.view.window?.layer.speed = 1 } - public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) { - let runloop = RunLoop.main - let timeout = Date(timeIntervalSinceNow: timeout) + try autoreleasepool { + try test(viewController) + } +} + +// MARK: - Run Loop Helpers - while Date() < timeout { - if predicate() { - return - } +@MainActor +public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) throws { + let runloop = RunLoop.main + let deadline = Date(timeIntervalSinceNow: timeout) - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) + while Date() < deadline { + if predicate() { + return } - XCTFail("waitUntil timed out waiting for a check to pass.") + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) } - public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) { - var isDone = false + throw WaitError("waitFor timed out waiting for a check to pass.") +} - waitFor(timeout: timeout, predicate: { - block { isDone = true } - return isDone - }) - } +@MainActor +public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) throws { + var isDone = false - public func waitForOneRunloop() { - let runloop = RunLoop.main - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) - } + try waitFor(timeout: timeout, predicate: { + block { isDone = true } + return isDone + }) +} - public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { - let start = Date() +@MainActor +public func waitForOneRunloop() { + let runloop = RunLoop.main + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) +} + +@MainActor +public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { + let start = Date() + + var iterations = 0 + var lastUpdateDate = Date() - var iterations = 0 + repeat { + block() - var lastUpdateDate = Date() + iterations += 1 - repeat { - block() + if Date().timeIntervalSince(lastUpdateDate) >= 1 { + lastUpdateDate = Date() + print("Continuing Test: \(iterations) Iterations...") + } - iterations += 1 + } while Date() < start + seconds - if Date().timeIntervalSince(lastUpdateDate) >= 1 { - lastUpdateDate = Date() - print("Continuing Test: \(iterations) Iterations...") - } + let end = Date() - } while Date() < start + seconds + let duration = end.timeIntervalSince(start) + let average = duration / TimeInterval(iterations) - let end = Date() + print("Iterations: \(iterations), Average Time: \(average)") +} - let duration = end.timeIntervalSince(start) - let average = duration / TimeInterval(iterations) +struct WaitError: Error, CustomStringConvertible { + var description: String - print("Iterations: \(iterations), Average Time: \(average)") + init(_ description: String) { + self.description = description } } +// MARK: - UIView Helpers + extension UIView { public var recursiveDescription: String { value(forKey: "recursiveDescription") as! String diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index 2e1e867..a89589c 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -14,6 +14,11 @@ import UIKit /// ``BContext/traits`` in sync with system changes and re-publishing /// the updated context through `traitOverrides`. /// +/// Child view controller creation, trait observation, and context setup +/// are deferred until the view controller enters a valid view hierarchy +/// (view loaded and in a window). This ensures traits can be read from +/// an actual hierarchy rather than from a detached controller. +/// /// Use the designated initializer to wrap an arbitrary child view controller, /// or the convenience initializer to embed SwiftUI content directly. /// @@ -26,38 +31,33 @@ import UIKit public final class BRootViewController: UIViewController { // MARK: Public - /// The current context. Updated automatically when observed - /// trait values change. Read this to inspect the live state. - public private(set) var context: BContext { + /// The current context, available after the view controller has been + /// added to a valid view hierarchy. `nil` before setup completes. + public private(set) var context: BContext? { didSet { - traitOverrides.bContext = context + if let context { + traitOverrides.bContext = context + } } } // MARK: Initialization - /// Creates a root container that embeds the given child view controller. - public init(child: UIViewController) { - context = BContext(traits: .system) - self.child = child + /// Creates a root container whose child view controller is produced + /// lazily by the given closure once the container enters a valid + /// view hierarchy. + public init(child: @escaping () -> UIViewController) { + makeChild = child super.init(nibName: nil, bundle: nil) - - addChild(child) - child.didMove(toParent: self) - - traitsObserver = BTraitsObserver(traits: .system, from: self) { [weak self] traits in - self?.context.traits = traits - } - context.traits = traitsObserver!.traits - traitOverrides.bContext = context } /// Creates a root container that hosts SwiftUI content. public convenience init( @ViewBuilder content: () -> some View, ) { - self.init(child: UIHostingController(rootView: content())) + let rootView = content() + self.init { UIHostingController(rootView: rootView) } } @available(*, unavailable) @@ -67,24 +67,44 @@ public final class BRootViewController: UIViewController { // MARK: Lifecycle - override public func viewDidLoad() { - super.viewDidLoad() + override public func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) - child.view.frame = view.bounds - view.addSubview(child.view) - - traitsObserver?.start() + setUpIfNeeded() } override public func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - child.view.frame = view.bounds + child?.view.frame = view.bounds } // MARK: Private - private let child: UIViewController + private let makeChild: () -> UIViewController + private var child: UIViewController? private var traitsObserver: BTraitsObserver? + + private func setUpIfNeeded() { + guard child == nil else { return } + + let child = makeChild() + self.child = child + + addChild(child) + child.didMove(toParent: self) + + child.view.frame = view.bounds + view.addSubview(child.view) + + let observer = BTraitsObserver(traits: .system, from: self) { [weak self] traits in + self?.context?.traits = traits + } + traitsObserver = observer + + context = BContext(traits: observer.traits) + + observer.start() + } } diff --git a/BroadwayUI/Tests/BRootViewControllerTests.swift b/BroadwayUI/Tests/BRootViewControllerTests.swift index 7e1e50e..0e4b59c 100644 --- a/BroadwayUI/Tests/BRootViewControllerTests.swift +++ b/BroadwayUI/Tests/BRootViewControllerTests.swift @@ -1,52 +1,60 @@ import BroadwayCore +import BroadwayTesting @testable import BroadwayUI import SwiftUI import Testing import UIKit @MainActor struct BRootViewControllerTests { - // MARK: - Initialization + // MARK: - Pre-Setup State + + @Test("Context is nil before entering a valid hierarchy") + func contextIsNilBeforeSetup() { + let vc = BRootViewController { UIViewController() } + #expect(vc.context == nil) + } + + // MARK: - Post-Setup State @Test("Context traits reflect the live accessibility snapshot") - func contextHasLiveAccessibility() { - let vc = BRootViewController(child: UIViewController()) - #expect(vc.context.traits.accessibility == BAccessibility.current()) + func contextHasLiveAccessibility() throws { + try show(BRootViewController { UIViewController() }) { vc in + #expect(vc.context?.traits.accessibility == BAccessibility.current()) + } } @Test("Convenience init wraps SwiftUI content") - func swiftUIConvenience() { - let vc = BRootViewController { - Text("Hello") + func swiftUIConvenience() throws { + try show(BRootViewController { Text("Hello") }) { vc in + #expect(vc.context?.traits.accessibility == BAccessibility.current()) } - #expect(vc.context.traits.accessibility == BAccessibility.current()) } // MARK: - Child Containment - @Test("Child is added to parent in init") - func childAddedInInit() { - let child = UIViewController() - let root = BRootViewController(child: child) - - #expect(child.parent === root) + @Test("Child is added after entering a valid hierarchy") + func childAddedAfterSetup() throws { + try show(BRootViewController { UIViewController() }) { root in + #expect(root.children.count == 1) + #expect(root.children.first?.parent === root) + } } - @Test("Child view is added as subview after loading") - func childViewEmbedded() { - let child = UIViewController() - let root = BRootViewController(child: child) - - root.loadViewIfNeeded() - - #expect(child.view.superview === root.view) + @Test("Child view is a subview after setup") + func childViewEmbedded() throws { + try show(BRootViewController { UIViewController() }) { root in + let child = root.children.first + #expect(child?.view.superview === root.view) + } } // MARK: - Trait Propagation - @Test("Context is written into traitOverrides at init") - func traitOverrideSet() { - let vc = BRootViewController(child: UIViewController()) - #expect(vc.traitOverrides.bContext == vc.context) + @Test("Context is written into traitOverrides after setup") + func traitOverrideSet() throws { + try show(BRootViewController { UIViewController() }) { vc in + #expect(vc.traitOverrides.bContext == vc.context) + } } @Test("Default bContext on UITraitCollection has default traits") @@ -57,12 +65,11 @@ import UIKit } @Test("Child inherits bContext after layout") - func childInheritsContext() { - let child = UIViewController() - let root = BRootViewController(child: child) - root.loadViewIfNeeded() - root.view.layoutIfNeeded() - - #expect(child.traitCollection.bContext == root.context) + func childInheritsContext() throws { + try show(BRootViewController { UIViewController() }) { root in + root.view.layoutIfNeeded() + let child = root.children.first + #expect(child?.traitCollection.bContext == root.context) + } } } diff --git a/Project.swift b/Project.swift index b41a557..f474689 100644 --- a/Project.swift +++ b/Project.swift @@ -88,7 +88,6 @@ let project = Project( sources: ["BroadwayTesting/Sources/**"], dependencies: [ .target(name: "BroadwayCore"), - .xctest, ], ), ] From 488fb2fdd3d812721aa67c9e4eb9dddafb8e0616 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 17:15:24 -0400 Subject: [PATCH 07/17] Minor updates after Cursor: Reorder BRootViewController creation, move BAccessibility trait conformance into the BAccessibility file. --- BroadwayCore/Sources/BAccessibility.swift | 18 ++++++++++++++++++ BroadwayCore/Sources/BTraits.swift | 18 ------------------ BroadwayTesting/Sources/BroadwayTesting.swift | 6 ------ BroadwayUI/Sources/BRootViewController.swift | 18 +++++++++--------- 4 files changed, 27 insertions(+), 33 deletions(-) delete mode 100644 BroadwayTesting/Sources/BroadwayTesting.swift diff --git a/BroadwayCore/Sources/BAccessibility.swift b/BroadwayCore/Sources/BAccessibility.swift index d29fc28..c547d43 100644 --- a/BroadwayCore/Sources/BAccessibility.swift +++ b/BroadwayCore/Sources/BAccessibility.swift @@ -119,6 +119,24 @@ public struct BAccessibility: Equatable, Hashable, Sendable { } } +extension BAccessibility: BTraitsValue { + public static var defaultValue: Self { + .init() + } + + @MainActor public static func currentValue( + from _: UIViewController, + ) -> BAccessibility { + .current() + } + + @MainActor public static func makeObserver( + onChange: @MainActor @escaping @Sendable (BAccessibility) -> Void, + ) -> BAccessibility.Observer { + .init { _, new in onChange(new) } + } +} + extension BAccessibility { /// A provider which returns the current accessibility settings on the device. /// diff --git a/BroadwayCore/Sources/BTraits.swift b/BroadwayCore/Sources/BTraits.swift index 3931ffe..4b6c906 100644 --- a/BroadwayCore/Sources/BTraits.swift +++ b/BroadwayCore/Sources/BTraits.swift @@ -17,24 +17,6 @@ extension BTraits { } } -extension BAccessibility: BTraitsValue { - public static var defaultValue: Self { - .init() - } - - @MainActor public static func currentValue( - from _: UIViewController, - ) -> BAccessibility { - .current() - } - - @MainActor public static func makeObserver( - onChange: @MainActor @escaping @Sendable (BAccessibility) -> Void, - ) -> BAccessibility.Observer { - .init { _, new in onChange(new) } - } -} - // MARK: - BTraits /// A type-keyed container of ``BTraitsValue`` conforming values diff --git a/BroadwayTesting/Sources/BroadwayTesting.swift b/BroadwayTesting/Sources/BroadwayTesting.swift deleted file mode 100644 index c38e2c5..0000000 --- a/BroadwayTesting/Sources/BroadwayTesting.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// BroadwayTesting.swift -// BroadwayTesting -// - -import BroadwayCore diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index a89589c..6e0a870 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -89,15 +89,6 @@ public final class BRootViewController: UIViewController { private func setUpIfNeeded() { guard child == nil else { return } - let child = makeChild() - self.child = child - - addChild(child) - child.didMove(toParent: self) - - child.view.frame = view.bounds - view.addSubview(child.view) - let observer = BTraitsObserver(traits: .system, from: self) { [weak self] traits in self?.context?.traits = traits } @@ -106,5 +97,14 @@ public final class BRootViewController: UIViewController { context = BContext(traits: observer.traits) observer.start() + + let child = makeChild() + self.child = child + + addChild(child) + child.didMove(toParent: self) + + child.view.frame = view.bounds + view.addSubview(child.view) } } From ef54e3dcaf99e37807a4276cb43af335480f7d13 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 21:13:40 -0400 Subject: [PATCH 08/17] Reorganize traits into Traits/ subdirectory and add BTraitOverridesViewController Move BTraits, BTraitsObserver, and BAccessibility into BroadwayCore/Sources/Traits/. Extract trait value conformances into Values/. Add BTraits+Overrides for override support and BTraitOverridesViewController for propagating trait overrides through the view hierarchy. Made-with: Cursor --- .../Tests/BroadwayCatalogTests.swift | 8 +-- BroadwayCore/Sources/BContext+UITraits.swift | 4 +- BroadwayCore/Sources/BContext.swift | 21 +++++- BroadwayCore/Sources/BStylesheets.swift | 6 +- .../Sources/Traits/BTraits+Overrides.swift | 46 +++++++++++++ .../Sources/{ => Traits}/BTraits.swift | 27 +++++--- .../{ => Traits}/BTraitsObserver.swift | 0 .../{ => Traits/Values}/BAccessibility.swift | 14 ++++ .../Traits/Values/BTraits+Values.swift | 64 ++++++++++++++++++ BroadwayCore/Tests/BContextTests.swift | 8 +-- BroadwayUI/Sources/BRootViewController.swift | 6 +- .../BTraitOverridesViewController.swift | 65 +++++++++++++++++++ 12 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 BroadwayCore/Sources/Traits/BTraits+Overrides.swift rename BroadwayCore/Sources/{ => Traits}/BTraits.swift (91%) rename BroadwayCore/Sources/{ => Traits}/BTraitsObserver.swift (100%) rename BroadwayCore/Sources/{ => Traits/Values}/BAccessibility.swift (97%) create mode 100644 BroadwayCore/Sources/Traits/Values/BTraits+Values.swift create mode 100644 BroadwayUI/Sources/BTraitOverridesViewController.swift diff --git a/BroadwayCatalog/Tests/BroadwayCatalogTests.swift b/BroadwayCatalog/Tests/BroadwayCatalogTests.swift index 92d4935..f7d8344 100644 --- a/BroadwayCatalog/Tests/BroadwayCatalogTests.swift +++ b/BroadwayCatalog/Tests/BroadwayCatalogTests.swift @@ -1,10 +1,4 @@ @testable import BroadwayCatalog import Testing -struct BroadwayCatalogTests { - @Test("App launches with ContentView") - func contentViewExists() { - let view = ContentView() - #expect(view != nil) - } -} +struct BroadwayCatalogTests {} diff --git a/BroadwayCore/Sources/BContext+UITraits.swift b/BroadwayCore/Sources/BContext+UITraits.swift index 4105eba..f862038 100644 --- a/BroadwayCore/Sources/BContext+UITraits.swift +++ b/BroadwayCore/Sources/BContext+UITraits.swift @@ -7,8 +7,8 @@ import UIKit /// Bridges ``BContext`` into UIKit's trait system so that it propagates /// automatically through the view-controller and view hierarchy. -struct BContextTrait: UITraitDefinition { - static let defaultValue = BContext() +public struct BContextTrait: UITraitDefinition { + public static let defaultValue = BContext() } extension UITraitCollection { diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index 2dbd75a..58adf45 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -12,8 +12,13 @@ import Foundation /// ``BStylesheets`` cache. Updating `traits` or `themes` replaces the /// stylesheet cache so subsequent lookups produce fresh instances. public struct BContext: Equatable, Sendable { - public init(traits: BTraits, themes: BThemes = .init()) { - self.traits = traits + public init( + traits: BTraits, + overrides: BTraits.Overrides = .init(), + themes: BThemes = .init(), + ) { + baseTraits = traits + traitOverrides = overrides self.themes = themes stylesheets = BStylesheets(config: .init(traits: traits, themes: themes)) } @@ -22,8 +27,18 @@ public struct BContext: Equatable, Sendable { self.init(traits: BTraits()) } - /// The current trait values (accessibility, size class, etc.). + public var traitOverrides: BTraits.Overrides { + didSet { + stylesheets.traits = traits + } + } + public var traits: BTraits { + baseTraits.merging(with: traitOverrides) + } + + /// The current trait values (accessibility, size class, etc.). + public var baseTraits: BTraits { didSet { stylesheets.traits = traits } diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index 37a493b..7d5d97c 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -47,7 +47,11 @@ public struct BStylesheets: Equatable, @unchecked Sendable { /// a dependency cycle, or ``StylesheetError/creationFailed(type:underlying:)`` /// if the stylesheet's initializer throws. public func get(_: Stylesheet.Type) throws(StylesheetError) -> Stylesheet { - let key = Key(stylesheet: .init(Stylesheet.self), traits: traits, themes: themes) + let key = Key( + stylesheet: .init(Stylesheet.self), + traits: traits, + themes: themes, + ) guard let value = cache[key], let value = value.base as? Stylesheet else { let id = TypeIdentifier(Stylesheet.self) diff --git a/BroadwayCore/Sources/Traits/BTraits+Overrides.swift b/BroadwayCore/Sources/Traits/BTraits+Overrides.swift new file mode 100644 index 0000000..91fc3b6 --- /dev/null +++ b/BroadwayCore/Sources/Traits/BTraits+Overrides.swift @@ -0,0 +1,46 @@ +// +// BTraits+Overrides.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/31/26. +// + +import Foundation + +extension BTraits { + /// Allows overriding traits with specific values. + public struct Overrides: Equatable, Hashable, @unchecked Sendable { + public init() {} + + // MARK: Subscript + + /// Gets or sets the trait for the given type. Returns `nil` if no value has been set. + public subscript(_: Value.Type) -> Value? { + get { + let id = TypeIdentifier(Value.self) + + guard let value = storage[id], let value = value.base as? Value else { + return nil + } + + return value + } + + set { + let id = TypeIdentifier(Value.self) + + if let newValue { + storage[id] = AnyHashable(newValue) + } else { + storage[id] = nil + } + } + } + + @CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:] + + var values: [TypeIdentifier: AnyHashable] { + storage + } + } +} diff --git a/BroadwayCore/Sources/BTraits.swift b/BroadwayCore/Sources/Traits/BTraits.swift similarity index 91% rename from BroadwayCore/Sources/BTraits.swift rename to BroadwayCore/Sources/Traits/BTraits.swift index 4b6c906..4e43c26 100644 --- a/BroadwayCore/Sources/BTraits.swift +++ b/BroadwayCore/Sources/Traits/BTraits.swift @@ -8,15 +8,6 @@ import Foundation import UIKit -// MARK: - BAccessibility + BTraitsValue - -extension BTraits { - public var accessibility: BAccessibility { - get { self[BAccessibility.self] } - set { self[BAccessibility.self] = newValue } - } -} - // MARK: - BTraits /// A type-keyed container of ``BTraitsValue`` conforming values @@ -32,7 +23,11 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { /// A traits container pre-registered with all built-in system trait types. @MainActor public static var system: BTraits { var traits = BTraits() + traits.register(BAccessibility.self) + traits.register(BMode.self) + traits.register(BContentSizeCategory.self) + return traits } @@ -58,6 +53,20 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { } } + // MARK: Applying Overrides + + public mutating func merge(with overrides: Overrides) { + for (id, value) in overrides.values { + storage[id] = value + } + } + + public func merging(with overrides: Overrides) -> BTraits { + var copy = self + copy.merge(with: overrides) + return copy + } + // MARK: Registration /// Registers a ``BTraitsValue`` type so that ``readCurrentValues(from:)`` diff --git a/BroadwayCore/Sources/BTraitsObserver.swift b/BroadwayCore/Sources/Traits/BTraitsObserver.swift similarity index 100% rename from BroadwayCore/Sources/BTraitsObserver.swift rename to BroadwayCore/Sources/Traits/BTraitsObserver.swift diff --git a/BroadwayCore/Sources/BAccessibility.swift b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift similarity index 97% rename from BroadwayCore/Sources/BAccessibility.swift rename to BroadwayCore/Sources/Traits/Values/BAccessibility.swift index c547d43..9505461 100644 --- a/BroadwayCore/Sources/BAccessibility.swift +++ b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift @@ -8,6 +8,20 @@ import Foundation import UIKit +extension BTraits { + public var accessibility: BAccessibility { + get { self[BAccessibility.self] } + set { self[BAccessibility.self] = newValue } + } +} + +extension BTraits.Overrides { + public var accessibility: BAccessibility? { + get { self[BAccessibility.self] } + set { self[BAccessibility.self] = newValue } + } +} + /// A snapshot of the device's current accessibility settings. /// /// Each property mirrors a corresponding `UIAccessibility` class property. diff --git a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift new file mode 100644 index 0000000..34c5c6d --- /dev/null +++ b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift @@ -0,0 +1,64 @@ +// +// BTraits+Values.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/31/26. +// + +import Foundation + +extension BTraits { + public var mode: BMode { + get { self[BMode.self] } + set { self[BMode.self] = newValue } + } + + public var contentSizeCategory: BContentSizeCategory { + get { self[BContentSizeCategory.self] } + set { self[BContentSizeCategory.self] = newValue } + } +} + +extension BTraits.Overrides { + public var mode: BMode? { + get { self[BMode.self] } + set { self[BMode.self] = newValue } + } + + public var contentSizeCategory: BContentSizeCategory? { + get { self[BContentSizeCategory.self] } + set { self[BContentSizeCategory.self] = newValue } + } +} + +public enum BMode: Equatable, Hashable { + case dark + case light +} + +extension BMode: BTraitsValue { + public static let defaultValue: BMode = .light +} + +public enum BContentSizeCategory: Equatable, Hashable, Comparable { + case extraSmall + case small + case medium + case large + case extraLarge + case extraExtraLarge + case extraExtraExtraLarge + case accessibilityMedium + case accessibilityLarge + case accessibilityExtraLarge + case accessibilityExtraExtraLarge + case accessibilityExtraExtraExtraLarge + + public var isAccessibilitySize: Bool { + self >= Self.accessibilityMedium + } +} + +extension BContentSizeCategory: BTraitsValue { + public static let defaultValue: BContentSizeCategory = .large +} diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index 91711c5..bcc0715 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -156,7 +156,7 @@ struct BContextStylesheetTests { let before = try context.stylesheets.get(TestStylesheet.self) - context.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + context.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) let after = try context.stylesheets.get(TestStylesheet.self) @@ -294,7 +294,7 @@ struct BContextStylesheetTests { _ = try context.stylesheets.get(CountingStylesheet.self) #expect(CountingStylesheet.initCount == 1) - context.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + context.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) _ = try context.stylesheets.get(CountingStylesheet.self) #expect(CountingStylesheet.initCount == 2) } @@ -311,7 +311,7 @@ struct BContextTests { @Test("Contexts with different traits are not equal") func traitInequality() { var a = BContext() - a.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + a.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) #expect(a != BContext()) } @@ -326,7 +326,7 @@ struct BContextTests { func traitPropagation() { var context = BContext() let accessibility = BAccessibility(isVoiceOverRunning: true) - context.traits.accessibility = accessibility + context.baseTraits.accessibility = accessibility #expect(context.stylesheets.traits.accessibility == accessibility) } diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index 6e0a870..3b64403 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -89,9 +89,13 @@ public final class BRootViewController: UIViewController { private func setUpIfNeeded() { guard child == nil else { return } + // TODO: Walk up the tree and don't make an observer if we're nested in another root + // further up the tree. + let observer = BTraitsObserver(traits: .system, from: self) { [weak self] traits in - self?.context?.traits = traits + self?.context?.baseTraits = traits } + traitsObserver = observer context = BContext(traits: observer.traits) diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift new file mode 100644 index 0000000..9fc4b5e --- /dev/null +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -0,0 +1,65 @@ +// +// BTraitOverridesViewController.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/31/26. +// + +import BroadwayCore +import Foundation +import UIKit + +public final class BTraitOverridesViewController: UIViewController { + public let content: Content + public let overrides: (Context, inout BTraits.Overrides) -> Void + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + public init( + _ content: () -> Content, + overrides: @escaping (Context, inout BTraits.Overrides) -> Void, + ) { + self.content = content() + self.overrides = overrides + + super.init() + + registerForTraitChanges( + [BContextTrait.self], + action: #selector(onTraitsDidChange(vc:previous:)), + ) + } + + public convenience init( + set keyPath: WritableKeyPath, + to: Value, + content: () -> Content, + ) { + self.init(content) { _, overrides in + overrides[keyPath: keyPath] = to + } + } + + public struct Context { + public let traits: BTraits + } + + @objc private func onTraitsDidChange( + vc _: UIViewController, + previous _: UITraitCollection, + ) { + let original = traitCollection.bContext.traitOverrides + var updated = original + + overrides(.init(traits: traitCollection.bContext.traits), &updated) + + if original != updated { + // TODO: Does this actually work? I hope so, since context is + // a struct, but not 100% sure. Hopefully each VC gets its own trait collection. + traitOverrides.bContext.traitOverrides = updated + } + } +} From aeb7c14944f4ef2598735f5391c3d6226142c418 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 31 Mar 2026 21:30:28 -0400 Subject: [PATCH 09/17] Simplify BStylesheets by removing Config wrapper and using EquatableIgnored for caches Flatten BStylesheets to store traits/themes directly instead of through a Config struct. Mark cache and creating arrays with @EquatableIgnored so equality is based solely on traits and themes. Add @unchecked Sendable to EquatableIgnored and mark stylesheets as @EquatableIgnored on BContext. Made-with: Cursor --- BroadwayCore/Sources/BContext.swift | 4 +-- BroadwayCore/Sources/BStylesheets.swift | 38 +++++---------------- BroadwayCore/Sources/EquatableIgnored.swift | 2 +- BroadwayCore/Tests/BContextTests.swift | 14 ++++---- 4 files changed, 18 insertions(+), 40 deletions(-) diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index 58adf45..8f2d53d 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -20,7 +20,7 @@ public struct BContext: Equatable, Sendable { baseTraits = traits traitOverrides = overrides self.themes = themes - stylesheets = BStylesheets(config: .init(traits: traits, themes: themes)) + stylesheets = BStylesheets(traits: traits, themes: themes) } init() { @@ -52,5 +52,5 @@ public struct BContext: Equatable, Sendable { } /// Lazily-populated stylesheet cache, scoped to the current traits and themes. - @CopyOnWrite public private(set) var stylesheets: BStylesheets + @EquatableIgnored @CopyOnWrite public private(set) var stylesheets: BStylesheets } diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index 7d5d97c..af74e68 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -12,32 +12,10 @@ import Foundation /// first access and cached for subsequent lookups with the same key. public struct BStylesheets: Equatable, @unchecked Sendable { /// The current trait values (accessibility, size class, etc.). - public var traits: BTraits { - get { config.traits } - set { config.traits = newValue } - } + public var traits: BTraits /// The current theme values. - public var themes: BThemes { - get { config.themes } - set { config.themes = newValue } - } - - /// The inputs that determine stylesheet identity. Two `BStylesheets` - /// values are equal when their configurations match, regardless of - /// how much of the cache has been lazily populated. - var config: Config - - struct Config: Equatable { - var traits: BTraits - var themes: BThemes - } - - // MARK: Equatable - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.config == rhs.config - } + public var themes: BThemes // MARK: Lookup @@ -56,21 +34,21 @@ public struct BStylesheets: Equatable, @unchecked Sendable { guard let value = cache[key], let value = value.base as? Stylesheet else { let id = TypeIdentifier(Stylesheet.self) - let creating = _creating._unsafeUnderlyingValue + let creating = _creating.wrappedValue._unsafeUnderlyingValue if let cycleStart = creating.firstIndex(of: id) { let path = creating[cycleStart...].map(\.debugDescription) + [id.debugDescription] throw .cyclicDependency(path: path) } - _creating._unsafeUnderlyingValue.append(id) - defer { _creating._unsafeUnderlyingValue.removeLast() } + _creating.wrappedValue._unsafeUnderlyingValue.append(id) + defer { _creating.wrappedValue._unsafeUnderlyingValue.removeLast() } let context = SlicingContext(themes: themes, stylesheets: self) do { let new = try Stylesheet(context: context) - _cache._unsafeUnderlyingValue[key] = AnyEquatable(new) + _cache.wrappedValue._unsafeUnderlyingValue[key] = AnyEquatable(new) return new } catch let error as StylesheetError { throw error @@ -92,9 +70,9 @@ public struct BStylesheets: Equatable, @unchecked Sendable { // MARK: Cache // TODO: Eventually we should clear this out on memory warnings for sheets not accessed within 10(?) min - @CopyOnWrite private var cache: [Key: AnyEquatable] = [:] + @EquatableIgnored @CopyOnWrite private var cache: [Key: AnyEquatable] = [:] - @CopyOnWrite private var creating: [TypeIdentifier] = [] + @EquatableIgnored @CopyOnWrite private var creating: [TypeIdentifier] = [] /// Cache key combining the stylesheet type with the traits and themes /// that were active at creation time. diff --git a/BroadwayCore/Sources/EquatableIgnored.swift b/BroadwayCore/Sources/EquatableIgnored.swift index ee7857d..40797c6 100644 --- a/BroadwayCore/Sources/EquatableIgnored.swift +++ b/BroadwayCore/Sources/EquatableIgnored.swift @@ -20,7 +20,7 @@ import Foundation /// } /// ``` /// In this example, `cache` will never affect the equality of two `Example` instances. -@propertyWrapper public struct EquatableIgnored: Equatable, Hashable { +@propertyWrapper public struct EquatableIgnored: Equatable, Hashable, @unchecked Sendable { public var wrappedValue: Value public init(wrappedValue: Value) { diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index bcc0715..42bedbc 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -206,7 +206,7 @@ struct BContextStylesheetTests { @Test("Circular stylesheet dependency throws cyclicDependency") func cycleThrows() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) #expect(throws: StylesheetError.self) { _ = try stylesheets.get(CycleA.self) @@ -215,7 +215,7 @@ struct BContextStylesheetTests { @Test("cyclicDependency error includes the dependency path") func cyclePath() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(CycleA.self) @@ -237,7 +237,7 @@ struct BContextStylesheetTests { @Test("Self-referencing stylesheet throws cyclicDependency") func selfCycle() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) #expect(throws: StylesheetError.self) { _ = try stylesheets.get(SelfCycle.self) @@ -246,7 +246,7 @@ struct BContextStylesheetTests { @Test("Three-node cycle throws cyclicDependency with full path") func threeNodeCycle() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(ThreeNodeCycleA.self) @@ -261,7 +261,7 @@ struct BContextStylesheetTests { @Test("Non-cycle error wraps in creationFailed") func nonCycleThrow() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(FailingStylesheet.self) @@ -276,10 +276,10 @@ struct BContextStylesheetTests { func setAndGet() throws { var themes = BThemes() themes[ColorTheme.self] = .dark - let source = BStylesheets(config: .init(traits: .init(), themes: themes)) + let source = BStylesheets(traits: .init(), themes: themes) let sheet = try source.get(TestStylesheet.self) - var target = BStylesheets(config: .init(traits: .init(), themes: themes)) + var target = BStylesheets(traits: .init(), themes: themes) target.set(sheet) let retrieved = try target.get(TestStylesheet.self) From 7f0a2f072a3229425972f4bc859fc5c1269be0a8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 2 Apr 2026 11:17:26 -0400 Subject: [PATCH 10/17] UIKit trait bridging, SwiftUI integration, and BTraitOverridesViewController fix Pass UIViewController through to BTraitsValue.makeObserver so VC-scoped traits can use registerForTraitChanges. Add UIViewControllerTraitObserver as a reusable observer for UIKit trait-backed values. Wire BMode and BContentSizeCategory to read from UITraitCollection and observe changes. Fix BTraitOverridesViewController with proper child containment and lazy setup. Bridge BContext into SwiftUI via UITraitBridgedEnvironmentKey and add bTraitOverrides/bMode/ bContentSizeCategory view modifiers. Made-with: Cursor --- BroadwayCore/Sources/Traits/BTraits.swift | 7 +- .../Sources/Traits/BTraitsObserver.swift | 2 +- .../UIViewControllerTraitObserver.swift | 45 +++++++++ .../Traits/Values/BAccessibility.swift | 1 + .../Traits/Values/BTraits+Values.swift | 54 +++++++++++ BroadwayUI/Sources/BContext+SwiftUI.swift | 36 ++++++++ .../Sources/BTraitOverrides+SwiftUI.swift | 30 ++++++ .../BTraitOverridesViewController.swift | 92 +++++++++++++------ 8 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift create mode 100644 BroadwayUI/Sources/BContext+SwiftUI.swift create mode 100644 BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift diff --git a/BroadwayCore/Sources/Traits/BTraits.swift b/BroadwayCore/Sources/Traits/BTraits.swift index 4e43c26..2f53d2f 100644 --- a/BroadwayCore/Sources/Traits/BTraits.swift +++ b/BroadwayCore/Sources/Traits/BTraits.swift @@ -75,8 +75,8 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { registrations.append(Registration( id: TypeIdentifier(V.self), readCurrentValue: { vc in AnyHashable(V.currentValue(from: vc)) }, - createObserver: { onChange in - V.makeObserver { newValue in onChange(AnyHashable(newValue)) } + createObserver: { vc, onChange in + V.makeObserver(with: vc) { newValue in onChange(AnyHashable(newValue)) } }, )) } @@ -101,6 +101,7 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { let id: TypeIdentifier let readCurrentValue: @MainActor @Sendable (UIViewController) -> AnyHashable let createObserver: @MainActor @Sendable ( + UIViewController, @escaping @MainActor @Sendable (AnyHashable) -> Void ) -> any BTraitsValueObserver } @@ -129,6 +130,7 @@ public protocol BTraitsValue: Hashable { @MainActor static func currentValue(from viewController: UIViewController) -> Self @MainActor static func makeObserver( + with viewController: UIViewController, onChange: @MainActor @escaping @Sendable (Self) -> Void, ) -> Observer } @@ -143,6 +145,7 @@ extension BTraitsValue { extension BTraitsValue where Observer == NeverObserver { @MainActor public static func makeObserver( + with _: UIViewController, onChange _: @MainActor @escaping @Sendable (Self) -> Void, ) -> NeverObserver { NeverObserver() diff --git a/BroadwayCore/Sources/Traits/BTraitsObserver.swift b/BroadwayCore/Sources/Traits/BTraitsObserver.swift index e6a080c..64cae76 100644 --- a/BroadwayCore/Sources/Traits/BTraitsObserver.swift +++ b/BroadwayCore/Sources/Traits/BTraitsObserver.swift @@ -43,7 +43,7 @@ public final class BTraitsObserver { for reg in traits.registrations { let regID = reg.id - let observer = reg.createObserver { [weak self] newValue in + let observer = reg.createObserver(viewController) { [weak self] newValue in guard let self else { return } let old = self.traits diff --git a/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift b/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift new file mode 100644 index 0000000..6ea39cf --- /dev/null +++ b/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift @@ -0,0 +1,45 @@ +// +// UIViewControllerTraitObserver.swift +// BroadwayCore +// + +import UIKit + +/// A reusable ``BTraitsValueObserver`` that monitors UIKit trait changes +/// on a specific view controller via `registerForTraitChanges`. +/// +/// Use this for ``BTraitsValue`` types whose values are derived from +/// `UITraitCollection` properties (e.g. user interface style, +/// preferred content size category). +@MainActor +public final class UIViewControllerTraitObserver: BTraitsValueObserver { + private weak var viewController: UIViewController? + private let uiTraits: [any UITraitDefinition.Type] + private let onChange: @MainActor @Sendable (UIViewController) -> Void + private var registration: (any UITraitChangeRegistration)? + + public init( + observing uiTraits: [any UITraitDefinition.Type], + on viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (UIViewController) -> Void, + ) { + self.viewController = viewController + self.uiTraits = uiTraits + self.onChange = onChange + } + + public func start() { + guard registration == nil, let viewController else { return } + + registration = viewController.registerForTraitChanges(uiTraits) { [weak self] (vc: UIViewController, _: UITraitCollection) in + self?.onChange(vc) + } + } + + public func stop() { + guard let registration, let viewController else { return } + + viewController.unregisterForTraitChanges(registration) + self.registration = nil + } +} diff --git a/BroadwayCore/Sources/Traits/Values/BAccessibility.swift b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift index 9505461..1315db8 100644 --- a/BroadwayCore/Sources/Traits/Values/BAccessibility.swift +++ b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift @@ -145,6 +145,7 @@ extension BAccessibility: BTraitsValue { } @MainActor public static func makeObserver( + with _: UIViewController, onChange: @MainActor @escaping @Sendable (BAccessibility) -> Void, ) -> BAccessibility.Observer { .init { _, new in onChange(new) } diff --git a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift index 34c5c6d..2ad4ce7 100644 --- a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift +++ b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit extension BTraits { public var mode: BMode { @@ -38,6 +39,25 @@ public enum BMode: Equatable, Hashable { extension BMode: BTraitsValue { public static let defaultValue: BMode = .light + + @MainActor public static func currentValue(from viewController: UIViewController) -> BMode { + switch viewController.traitCollection.userInterfaceStyle { + case .dark: .dark + default: .light + } + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BMode) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitUserInterfaceStyle.self], + on: viewController, + ) { vc in + onChange(currentValue(from: vc)) + } + } } public enum BContentSizeCategory: Equatable, Hashable, Comparable { @@ -61,4 +81,38 @@ public enum BContentSizeCategory: Equatable, Hashable, Comparable { extension BContentSizeCategory: BTraitsValue { public static let defaultValue: BContentSizeCategory = .large + + @MainActor public static func currentValue(from viewController: UIViewController) -> BContentSizeCategory { + .from(viewController.traitCollection.preferredContentSizeCategory) + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BContentSizeCategory) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitPreferredContentSizeCategory.self], + on: viewController, + ) { vc in + onChange(currentValue(from: vc)) + } + } + + public static func from(_ uiCategory: UIContentSizeCategory) -> BContentSizeCategory { + switch uiCategory { + case .extraSmall: .extraSmall + case .small: .small + case .medium: .medium + case .large: .large + case .extraLarge: .extraLarge + case .extraExtraLarge: .extraExtraLarge + case .extraExtraExtraLarge: .extraExtraExtraLarge + case .accessibilityMedium: .accessibilityMedium + case .accessibilityLarge: .accessibilityLarge + case .accessibilityExtraLarge: .accessibilityExtraLarge + case .accessibilityExtraExtraLarge: .accessibilityExtraExtraLarge + case .accessibilityExtraExtraExtraLarge: .accessibilityExtraExtraExtraLarge + default: .large + } + } } diff --git a/BroadwayUI/Sources/BContext+SwiftUI.swift b/BroadwayUI/Sources/BContext+SwiftUI.swift new file mode 100644 index 0000000..11110c5 --- /dev/null +++ b/BroadwayUI/Sources/BContext+SwiftUI.swift @@ -0,0 +1,36 @@ +// +// BContext+SwiftUI.swift +// BroadwayUI +// + +import BroadwayCore +import SwiftUI +import UIKit + +// MARK: - UIKit ↔ SwiftUI Bridging + +/// Bridges ``BContext`` between UIKit's trait system and SwiftUI's +/// environment so that `@Environment(\.bContext)` automatically reads +/// from `UITraitCollection` and writes back via `traitOverrides`. +struct BContextEnvironmentKey: UITraitBridgedEnvironmentKey { + static var defaultValue: BContext { + BContextTrait.defaultValue + } + + static func read(from traitCollection: UITraitCollection) -> BContext { + traitCollection.bContext + } + + static func write(to mutableTraits: inout UIMutableTraits, value: BContext) { + mutableTraits.bContext = value + } +} + +extension EnvironmentValues { + /// The Broadway environment container, automatically bridged from the + /// nearest UIKit ancestor's `UITraitCollection`. + public var bContext: BContext { + get { self[BContextEnvironmentKey.self] } + set { self[BContextEnvironmentKey.self] = newValue } + } +} diff --git a/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift b/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift new file mode 100644 index 0000000..c554aaa --- /dev/null +++ b/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift @@ -0,0 +1,30 @@ +// +// BTraitOverrides+SwiftUI.swift +// BroadwayUI +// + +import BroadwayCore +import SwiftUI + +extension View { + /// Applies trait overrides to the ``BContext`` flowing through this + /// view's environment. The closure receives the current resolved + /// traits and a mutable ``BTraits/Overrides`` to modify. + public func bTraitOverrides( + _ apply: @escaping (BTraits, inout BTraits.Overrides) -> Void, + ) -> some View { + transformEnvironment(\.bContext) { context in + apply(context.traits, &context.traitOverrides) + } + } + + /// Overrides the ``BMode`` (light/dark) for this view's subtree. + public func bMode(_ mode: BMode) -> some View { + bTraitOverrides { _, overrides in overrides.mode = mode } + } + + /// Overrides the ``BContentSizeCategory`` for this view's subtree. + public func bContentSizeCategory(_ category: BContentSizeCategory) -> some View { + bTraitOverrides { _, overrides in overrides.contentSizeCategory = category } + } +} diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift index 9fc4b5e..f827666 100644 --- a/BroadwayUI/Sources/BTraitOverridesViewController.swift +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -1,31 +1,37 @@ // // BTraitOverridesViewController.swift -// BroadwayCore -// -// Created by Kyle Van Essen on 3/31/26. +// BroadwayUI // import BroadwayCore -import Foundation import UIKit +/// A container view controller that applies ``BTraits/Overrides`` to the +/// inherited ``BContext`` before passing it to its child. +/// +/// Child creation and override application are deferred until the +/// view controller enters a valid view hierarchy (`viewIsAppearing`), +/// matching ``BRootViewController``'s lazy setup pattern. public final class BTraitOverridesViewController: UIViewController { - public let content: Content - public let overrides: (Context, inout BTraits.Overrides) -> Void + // MARK: Public - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError() + /// The child view controller, available after setup completes. + public private(set) var content: Content? + + public struct Context { + public let traits: BTraits } + // MARK: Initialization + public init( - _ content: () -> Content, + _ content: @escaping () -> Content, overrides: @escaping (Context, inout BTraits.Overrides) -> Void, ) { - self.content = content() + makeContent = content self.overrides = overrides - super.init() + super.init(nibName: nil, bundle: nil) registerForTraitChanges( [BContextTrait.self], @@ -35,31 +41,63 @@ public final class BTraitOverridesViewController: UIV public convenience init( set keyPath: WritableKeyPath, - to: Value, - content: () -> Content, + to value: Value, + content: @escaping () -> Content, ) { self.init(content) { _, overrides in - overrides[keyPath: keyPath] = to + overrides[keyPath: keyPath] = value } } - public struct Context { - public let traits: BTraits + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + // MARK: Lifecycle + + override public func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + setUpIfNeeded() + } + + override public func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + content?.view.frame = view.bounds + } + + // MARK: Private + + private let makeContent: () -> Content + private let overrides: (Context, inout BTraits.Overrides) -> Void + + private func setUpIfNeeded() { + guard content == nil else { return } + + applyOverrides() + + let child = makeContent() + content = child + + addChild(child) + child.didMove(toParent: self) + + child.view.frame = view.bounds + view.addSubview(child.view) + } + + private func applyOverrides() { + var current = traitCollection.bContext.traitOverrides + overrides(.init(traits: traitCollection.bContext.traits), ¤t) + traitOverrides.bContext.traitOverrides = current } @objc private func onTraitsDidChange( vc _: UIViewController, previous _: UITraitCollection, ) { - let original = traitCollection.bContext.traitOverrides - var updated = original - - overrides(.init(traits: traitCollection.bContext.traits), &updated) - - if original != updated { - // TODO: Does this actually work? I hope so, since context is - // a struct, but not 100% sure. Hopefully each VC gets its own trait collection. - traitOverrides.bContext.traitOverrides = updated - } + applyOverrides() } } From 16553c9b583868b6b57e59f3158ecd21dc2200dc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 4 Apr 2026 22:39:53 -0400 Subject: [PATCH 11/17] Fix context propagation, stylesheet init, and observer idempotency - Seed BStylesheets with merged traits when BContext init takes overrides. - Apply trait overrides from inherited parent bContext and republish full context. - Prefer parent traitCollection when resolving inherited BContext during viewIsAppearing. - Make BTraitsObserver.start/stop idempotent at the coordinator level. - Add BContext and show()-based BroadwayUI tests for the above. Made-with: Cursor --- BroadwayCore/Sources/BContext.swift | 5 +- .../Sources/Traits/BTraitsObserver.swift | 5 ++ BroadwayCore/Tests/BContextTests.swift | 25 ++++++++++ .../BTraitOverridesViewController.swift | 11 +++-- .../Tests/BRootViewControllerTests.swift | 47 +++++++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index 8f2d53d..41e2e4a 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -20,7 +20,10 @@ public struct BContext: Equatable, Sendable { baseTraits = traits traitOverrides = overrides self.themes = themes - stylesheets = BStylesheets(traits: traits, themes: themes) + stylesheets = BStylesheets( + traits: baseTraits.merging(with: traitOverrides), + themes: themes, + ) } init() { diff --git a/BroadwayCore/Sources/Traits/BTraitsObserver.swift b/BroadwayCore/Sources/Traits/BTraitsObserver.swift index 64cae76..7e31cfb 100644 --- a/BroadwayCore/Sources/Traits/BTraitsObserver.swift +++ b/BroadwayCore/Sources/Traits/BTraitsObserver.swift @@ -62,6 +62,8 @@ public final class BTraitsObserver { /// Starts all registered trait observers. Safe to call multiple times. public func start() { + guard !started else { return } + started = true for observer in observers { observer.start() } @@ -69,6 +71,8 @@ public final class BTraitsObserver { /// Stops all registered trait observers. Safe to call multiple times. public func stop() { + guard started else { return } + started = false for observer in observers { observer.stop() } @@ -78,4 +82,5 @@ public final class BTraitsObserver { private let onChange: @MainActor @Sendable (BTraits) -> Void private var observers: [any BTraitsValueObserver] = [] + private var started = false } diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index 42bedbc..915fbdc 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -52,6 +52,14 @@ private struct CountingStylesheet: BStylesheet, Equatable { } } +private struct MergedTraitsAccessibilityProbe: BStylesheet { + var isVoiceOverRunning: Bool + + init(context: SlicingContext) { + isVoiceOverRunning = context.stylesheets.traits.accessibility.isVoiceOverRunning + } +} + private struct CycleA: BStylesheet { init(context: SlicingContext) throws { _ = try context.stylesheets.get(CycleB.self) @@ -338,4 +346,21 @@ struct BContextTests { let theme: ColorTheme = context.stylesheets.themes[ColorTheme.self] #expect(theme == .dark) } + + @Test("Stylesheets use merged traits when initializer passes non-empty overrides") + func stylesheetsTraitsMatchMergedAtInit() throws { + var base = BTraits() + base.accessibility = BAccessibility(isVoiceOverRunning: false) + + var overrides = BTraits.Overrides() + overrides.accessibility = BAccessibility(isVoiceOverRunning: true) + + let context = BContext(traits: base, overrides: overrides) + + #expect(context.stylesheets.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + #expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + + let sheet = try context.stylesheets.get(MergedTraitsAccessibilityProbe.self) + #expect(sheet.isVoiceOverRunning == true) + } } diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift index f827666..0065839 100644 --- a/BroadwayUI/Sources/BTraitOverridesViewController.swift +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -89,9 +89,14 @@ public final class BTraitOverridesViewController: UIV } private func applyOverrides() { - var current = traitCollection.bContext.traitOverrides - overrides(.init(traits: traitCollection.bContext.traits), ¤t) - traitOverrides.bContext.traitOverrides = current + // Prefer the parent's resolved traits: `traitCollection` on `self` can lag + // behind the ancestor chain for custom `UITraitDefinition`s during + // `viewIsAppearing`, so read the merged collection from `parent` when embedded. + var context = parent?.traitCollection.bContext ?? traitCollection.bContext + var current = context.traitOverrides + overrides(.init(traits: context.traits), ¤t) + context.traitOverrides = current + traitOverrides.bContext = context } @objc private func onTraitsDidChange( diff --git a/BroadwayUI/Tests/BRootViewControllerTests.swift b/BroadwayUI/Tests/BRootViewControllerTests.swift index 0e4b59c..b43f12b 100644 --- a/BroadwayUI/Tests/BRootViewControllerTests.swift +++ b/BroadwayUI/Tests/BRootViewControllerTests.swift @@ -72,4 +72,51 @@ import UIKit #expect(child?.traitCollection.bContext == root.context) } } + + @Test("Trait override container preserves inherited base traits and themes on screen") + func traitOverridesPreserveInheritedContextAtLeaf() throws { + try show(BRootViewController { + BTraitOverridesViewController(set: \.accessibility, to: BAccessibility(isVoiceOverRunning: true)) { + UIViewController() + } + }) { root in + root.view.layoutIfNeeded() + guard + let overridesVC = root.children.first as? BTraitOverridesViewController, + let leaf = overridesVC.content, + let rootContext = root.context + else { + Issue.record("Expected override container, leaf, and root context") + return + } + + let leafContext = leaf.traitCollection.bContext + #expect(leafContext.baseTraits == rootContext.baseTraits) + #expect(leafContext.themes == rootContext.themes) + #expect(leafContext.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + } + } + + @Test("Trait override container publishes full inherited context to traitOverrides") + func traitOverridesPublishedContextPreservesBaseAndThemes() throws { + try show(BRootViewController { + BTraitOverridesViewController(set: \.accessibility, to: BAccessibility(isVoiceOverRunning: true)) { + UIViewController() + } + }) { root in + root.view.layoutIfNeeded() + guard + let overridesVC = root.children.first as? BTraitOverridesViewController, + let rootContext = root.context + else { + Issue.record("Expected override container and root context") + return + } + + let published = overridesVC.traitOverrides.bContext + #expect(published.baseTraits == rootContext.baseTraits) + #expect(published.themes == rootContext.themes) + #expect(published.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + } + } } From c0f198c7092864d8869902bf1773b07dbfa10c69 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 4 Apr 2026 23:06:01 -0400 Subject: [PATCH 12/17] Address PR review: encapsulation, tests, BMode.from, restore xctest - Align BMode.currentValue with BContentSizeCategory via BMode.from(_: UIUserInterfaceStyle). - Require explicit BTraitsValue.makeObserver (drop NeverObserver default extension); update ScaleFactor tests. - Make BStylesheets traits storage private; sync via updateTraits from BContext only; stop reading traits from tests. - BTraitsObserverTests: use show() with a retained anchor VC; assert coordinator double-start only calls underlying start once (CountingStartSpy). - BTraitOverridesViewController: apply overrides from didMove(toParent:) when embedded. - Restore BroadwayTesting dependency on .xctest (matches main). Made-with: Cursor --- BroadwayCore/Sources/BContext.swift | 4 +- BroadwayCore/Sources/BStylesheets.swift | 11 +- BroadwayCore/Sources/Traits/BTraits.swift | 11 +- .../Traits/Values/BTraits+Values.swift | 16 +- BroadwayCore/Tests/BContextTests.swift | 15 +- BroadwayCore/Tests/BTraitsObserverTests.swift | 145 +++++++++++++----- BroadwayCore/Tests/BTraitsTests.swift | 11 ++ .../BTraitOverridesViewController.swift | 8 + Project.swift | 1 + 9 files changed, 153 insertions(+), 69 deletions(-) diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index 41e2e4a..5fba8bd 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -32,7 +32,7 @@ public struct BContext: Equatable, Sendable { public var traitOverrides: BTraits.Overrides { didSet { - stylesheets.traits = traits + stylesheets.updateTraits(traits) } } @@ -43,7 +43,7 @@ public struct BContext: Equatable, Sendable { /// The current trait values (accessibility, size class, etc.). public var baseTraits: BTraits { didSet { - stylesheets.traits = traits + stylesheets.updateTraits(traits) } } diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index af74e68..b633256 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -12,11 +12,20 @@ import Foundation /// first access and cached for subsequent lookups with the same key. public struct BStylesheets: Equatable, @unchecked Sendable { /// The current trait values (accessibility, size class, etc.). - public var traits: BTraits + private var traits: BTraits /// The current theme values. public var themes: BThemes + init(traits: BTraits, themes: BThemes) { + self.traits = traits + self.themes = themes + } + + mutating func updateTraits(_ newTraits: BTraits) { + traits = newTraits + } + // MARK: Lookup /// Returns the cached stylesheet of the given type, creating it lazily if needed. diff --git a/BroadwayCore/Sources/Traits/BTraits.swift b/BroadwayCore/Sources/Traits/BTraits.swift index 2f53d2f..9575fb9 100644 --- a/BroadwayCore/Sources/Traits/BTraits.swift +++ b/BroadwayCore/Sources/Traits/BTraits.swift @@ -121,7 +121,7 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { /// observation override ``currentValue(from:)`` and /// ``makeObserver(onChange:)`` to supply a snapshot and an observer. public protocol BTraitsValue: Hashable { - associatedtype Observer: BTraitsValueObserver = NeverObserver + associatedtype Observer: BTraitsValueObserver static var defaultValue: Self { get } @@ -143,15 +143,6 @@ extension BTraitsValue { } } -extension BTraitsValue where Observer == NeverObserver { - @MainActor public static func makeObserver( - with _: UIViewController, - onChange _: @MainActor @escaping @Sendable (Self) -> Void, - ) -> NeverObserver { - NeverObserver() - } -} - // MARK: - BTraitsValueObserver /// The interface for an observer that monitors changes to diff --git a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift index 2ad4ce7..94f2925 100644 --- a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift +++ b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift @@ -35,16 +35,24 @@ extension BTraits.Overrides { public enum BMode: Equatable, Hashable { case dark case light + + public static func from(_ style: UIUserInterfaceStyle) -> BMode { + switch style { + case .dark: + .dark + case .light, .unspecified: + .light + @unknown default: + .light + } + } } extension BMode: BTraitsValue { public static let defaultValue: BMode = .light @MainActor public static func currentValue(from viewController: UIViewController) -> BMode { - switch viewController.traitCollection.userInterfaceStyle { - case .dark: .dark - default: .light - } + .from(viewController.traitCollection.userInterfaceStyle) } @MainActor public static func makeObserver( diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index 915fbdc..18cadaa 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -52,14 +52,6 @@ private struct CountingStylesheet: BStylesheet, Equatable { } } -private struct MergedTraitsAccessibilityProbe: BStylesheet { - var isVoiceOverRunning: Bool - - init(context: SlicingContext) { - isVoiceOverRunning = context.stylesheets.traits.accessibility.isVoiceOverRunning - } -} - private struct CycleA: BStylesheet { init(context: SlicingContext) throws { _ = try context.stylesheets.get(CycleB.self) @@ -335,7 +327,7 @@ struct BContextTests { var context = BContext() let accessibility = BAccessibility(isVoiceOverRunning: true) context.baseTraits.accessibility = accessibility - #expect(context.stylesheets.traits.accessibility == accessibility) + #expect(context.traits.accessibility == accessibility) } @Test("Theme mutation propagates to stylesheets config") @@ -357,10 +349,7 @@ struct BContextTests { let context = BContext(traits: base, overrides: overrides) - #expect(context.stylesheets.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) #expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) - - let sheet = try context.stylesheets.get(MergedTraitsAccessibilityProbe.self) - #expect(sheet.isVoiceOverRunning == true) + _ = try context.stylesheets.get(TestStylesheet.self) } } diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift index 52d8dbe..498ce23 100644 --- a/BroadwayCore/Tests/BTraitsObserverTests.swift +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -1,76 +1,143 @@ @testable import BroadwayCore +import BroadwayTesting import Testing import UIKit +@MainActor +private final class CountingStartSpy: BTraitsValueObserver { + weak static var lastInstance: CountingStartSpy? + + private(set) var startCount = 0 + + init() { + Self.lastInstance = self + } + + func start() { + startCount += 1 + } + + func stop() {} +} + +private struct StartCountingTrait: BTraitsValue, Hashable { + typealias Observer = CountingStartSpy + + @MainActor static var defaultValue: StartCountingTrait { + StartCountingTrait() + } + + @MainActor static func currentValue(from _: UIViewController) -> StartCountingTrait { + StartCountingTrait() + } + + @MainActor static func makeObserver( + with _: UIViewController, + onChange _: @MainActor @escaping @Sendable (StartCountingTrait) -> Void, + ) -> CountingStartSpy { + CountingStartSpy() + } +} + @MainActor struct BTraitsObserverTests { - private func makeObserver( + private func makeSystemObserver( + from viewController: UIViewController, onChange: @MainActor @escaping @Sendable (BTraits) -> Void = { _ in }, ) -> BTraitsObserver { - BTraitsObserver(traits: .system, from: UIViewController(), onChange: onChange) + BTraitsObserver(traits: .system, from: viewController, onChange: onChange) } // MARK: - Initial Value @Test("Initial traits contain the live accessibility snapshot") - func initialAccessibility() { - let observer = makeObserver() - #expect(observer.traits.accessibility == BAccessibility.current()) + func initialAccessibility() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + #expect(observer.traits.accessibility == BAccessibility.current()) + } } @Test("Initial traits are not equal to a default BTraits()") - func initialTraitsNotDefault() { - let observer = makeObserver() - let defaultTraits = BTraits() + func initialTraitsNotDefault() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + let defaultTraits = BTraits() - #expect(observer.traits.accessibility == BAccessibility.current()) - #expect(defaultTraits.accessibility == BAccessibility()) + #expect(observer.traits.accessibility == BAccessibility.current()) + #expect(defaultTraits.accessibility == BAccessibility()) + } } // MARK: - Lifecycle @Test("start and stop complete without error") - func startStop() { - let observer = makeObserver() - observer.start() - observer.stop() + func startStop() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + } } - @Test("Calling start twice is safe") - func doubleStart() { - let observer = makeObserver() - observer.start() - observer.start() - observer.stop() + @Test("Coordinator start is idempotent for underlying observers") + func doubleStartForwardsStartOnceToObservers() throws { + var traits = BTraits() + traits.register(StartCountingTrait.self) + + let anchor = UIViewController() + try show(anchor) { hosted in + CountingStartSpy.lastInstance = nil + let observer = BTraitsObserver(traits: traits, from: hosted, onChange: { _ in }) + observer.start() + observer.start() + #expect(CountingStartSpy.lastInstance?.startCount == 1) + observer.stop() + } } @Test("Calling stop without start is safe") - func stopWithoutStart() { - let observer = makeObserver() - observer.stop() + func stopWithoutStart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.stop() + } } @Test("Calling stop twice is safe") - func doubleStop() { - let observer = makeObserver() - observer.start() - observer.stop() - observer.stop() + func doubleStop() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + observer.stop() + } } @Test("Can restart after stopping") - func restart() { - let observer = makeObserver() - observer.start() - observer.stop() - observer.start() - observer.stop() + func restart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + observer.start() + observer.stop() + } } @Test("Deallocation after start does not crash") - func deallocAfterStart() { - var observer: BTraitsObserver? = makeObserver() - observer?.start() - observer = nil - _ = observer + func deallocAfterStart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + var observer: BTraitsObserver? = makeSystemObserver(from: hosted) + observer?.start() + observer = nil + _ = observer + } } } diff --git a/BroadwayCore/Tests/BTraitsTests.swift b/BroadwayCore/Tests/BTraitsTests.swift index 2bbb0e3..4ee4df7 100644 --- a/BroadwayCore/Tests/BTraitsTests.swift +++ b/BroadwayCore/Tests/BTraitsTests.swift @@ -1,11 +1,22 @@ @testable import BroadwayCore import Testing +import UIKit private struct ScaleFactor: BTraitsValue { + typealias Observer = NeverObserver + var value: Double + static var defaultValue: Self { ScaleFactor(value: 1.0) } + + @MainActor static func makeObserver( + with _: UIViewController, + onChange _: @MainActor @escaping @Sendable (ScaleFactor) -> Void, + ) -> NeverObserver { + NeverObserver() + } } struct BTraitsTests { diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift index 0065839..da7a272 100644 --- a/BroadwayUI/Sources/BTraitOverridesViewController.swift +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -56,6 +56,14 @@ public final class BTraitOverridesViewController: UIV // MARK: Lifecycle + override public func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + + if parent != nil { + applyOverrides() + } + } + override public func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) diff --git a/Project.swift b/Project.swift index f474689..b41a557 100644 --- a/Project.swift +++ b/Project.swift @@ -88,6 +88,7 @@ let project = Project( sources: ["BroadwayTesting/Sources/**"], dependencies: [ .target(name: "BroadwayCore"), + .xctest, ], ), ] From 2f557ecc10a3fca3c0b11cb91a49bee885f6abf0 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 4 Apr 2026 23:08:32 -0400 Subject: [PATCH 13/17] Hide BStylesheets.themes; sync via updateThemes from BContext - Make themes storage private; add updateThemes like updateTraits. - Theme propagation test resolves theme via lazy TestStylesheet get(). Made-with: Cursor --- BroadwayCore/Sources/BContext.swift | 2 +- BroadwayCore/Sources/BStylesheets.swift | 6 +++++- BroadwayCore/Tests/BContextTests.swift | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index 5fba8bd..4a4d7c8 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -50,7 +50,7 @@ public struct BContext: Equatable, Sendable { /// The current theme values. @CopyOnWrite public var themes: BThemes { didSet { - stylesheets.themes = themes + stylesheets.updateThemes(themes) } } diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index b633256..431efd1 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -15,7 +15,7 @@ public struct BStylesheets: Equatable, @unchecked Sendable { private var traits: BTraits /// The current theme values. - public var themes: BThemes + private var themes: BThemes init(traits: BTraits, themes: BThemes) { self.traits = traits @@ -26,6 +26,10 @@ public struct BStylesheets: Equatable, @unchecked Sendable { traits = newTraits } + mutating func updateThemes(_ newThemes: BThemes) { + themes = newThemes + } + // MARK: Lookup /// Returns the cached stylesheet of the given type, creating it lazily if needed. diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index 18cadaa..c44f866 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -331,12 +331,12 @@ struct BContextTests { } @Test("Theme mutation propagates to stylesheets config") - func themePropagation() { + func themePropagation() throws { var context = BContext() context.themes[ColorTheme.self] = .dark - let theme: ColorTheme = context.stylesheets.themes[ColorTheme.self] - #expect(theme == .dark) + let sheet = try context.stylesheets.get(TestStylesheet.self) + #expect(sheet.color == .dark) } @Test("Stylesheets use merged traits when initializer passes non-empty overrides") From 9d75433fae0aa7e8724e5f8ff8d468f8105f71d8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 4 Apr 2026 23:11:56 -0400 Subject: [PATCH 14/17] Use Swift package access for BStylesheets sync APIs (SE-0386) - Mark updateTraits/updateThemes as package; document Tuist -package-name. - Apply OTHER_SWIFT_FLAGS -package-name Broadway to all first-party targets so package symbols compile outside a SwiftPM Package.swift layout. Made-with: Cursor --- BroadwayCore/Sources/BStylesheets.swift | 7 +++++-- Project.swift | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index 431efd1..ac74993 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -22,11 +22,14 @@ public struct BStylesheets: Equatable, @unchecked Sendable { self.themes = themes } - mutating func updateTraits(_ newTraits: BTraits) { + /// Keeps the resolver’s trait key in sync with ``BContext/traits``. + /// `package` matches the monorepo `-package-name Broadway` set in Tuist (SE-0386). + package mutating func updateTraits(_ newTraits: BTraits) { traits = newTraits } - mutating func updateThemes(_ newThemes: BThemes) { + /// Keeps the resolver’s theme key in sync with ``BContext/themes``. + package mutating func updateThemes(_ newThemes: BThemes) { themes = newThemes } diff --git a/Project.swift b/Project.swift index b41a557..c2832bd 100644 --- a/Project.swift +++ b/Project.swift @@ -3,6 +3,13 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad, .macCatalyst] let deployment: DeploymentTargets = .iOS("26.0") +/// Enables Swift `package` access across all first-party targets (see SE-0386). +private let broadwayPackageAccessSettings = Settings.settings( + base: [ + "OTHER_SWIFT_FLAGS": .array(["$(inherited)", "-package-name", "Broadway"]), + ], +) + func framework( _ name: String, bundleIdSuffix: String, @@ -17,6 +24,7 @@ func framework( deploymentTargets: deployment, sources: ["\(name)/Sources/**"], dependencies: dependencies, + settings: broadwayPackageAccessSettings, ), .target( name: "\(name)Tests", @@ -30,6 +38,7 @@ func framework( .target(name: "BroadwayTesting"), .target(name: "BroadwayTestHost"), ], + settings: broadwayPackageAccessSettings, ), ] } @@ -54,6 +63,7 @@ let project = Project( sources: ["BroadwayCatalog/Sources/**"], resources: ["BroadwayCatalog/Resources/**"], dependencies: [.target(name: "BroadwayUI")], + settings: broadwayPackageAccessSettings, ), .target( name: "BroadwayCatalogTests", @@ -66,6 +76,7 @@ let project = Project( .target(name: "BroadwayCatalog"), .target(name: "BroadwayTesting"), ], + settings: broadwayPackageAccessSettings, ), .target( name: "BroadwayTestHost", @@ -78,6 +89,7 @@ let project = Project( ]), sources: ["BroadwayTestHost/Sources/**"], dependencies: [], + settings: broadwayPackageAccessSettings, ), .target( name: "BroadwayTesting", @@ -90,6 +102,7 @@ let project = Project( .target(name: "BroadwayCore"), .xctest, ], + settings: broadwayPackageAccessSettings, ), ] + framework("BroadwayUI", bundleIdSuffix: "ui", dependencies: [.target(name: "BroadwayCore")]) From 282404f32bf69bd871ba6d31128d1584b9b9db02 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 4 Apr 2026 23:23:11 -0400 Subject: [PATCH 15/17] Adopt root Package.swift; Tuist for apps and xctest bundles - Add Package.swift with BroadwayCore, BroadwayUI, BroadwayTesting (iOS 26 + Mac Catalyst); target paths use */Sources so Tests stay out of libraries. - Tuist Project.swift: local package at repo root; Catalog/TestHost/CoreTests/UITests/CatalogTests depend on package products; drop generated framework targets and -package-name flags. - Sendable: BMode/BContentSizeCategory; SettingsProvider + @unchecked Sendable system/mock providers for SPM concurrency checks. - Update AGENTS.md and README for hybrid SPM + Tuist workflows and scheme names. Made-with: Cursor --- AGENTS.md | 30 +++--- .../Traits/Values/BAccessibility.swift | 4 +- .../Traits/Values/BTraits+Values.swift | 4 +- BroadwayCore/Tests/BAccessibilityTests.swift | 2 +- Package.swift | 35 +++++++ Project.swift | 93 ++++++++----------- README.md | 18 ++-- 7 files changed, 107 insertions(+), 79 deletions(-) create mode 100644 Package.swift diff --git a/AGENTS.md b/AGENTS.md index a832cf4..fcd754e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Repository Shape -Broadway is a SwiftUI iOS + Mac Catalyst design system managed by **Tuist**. The Xcode project is not checked in — it is generated from `Project.swift`. +Broadway is a SwiftUI iOS + Mac Catalyst design system: **Swift Package Manager** supplies the shared libraries (`Package.swift`), and **Tuist** generates the Xcode workspace from `Project.swift` for the catalog app, test host, and unit test bundles that link those packages. ## Directory Layout @@ -13,21 +13,23 @@ Broadway is a SwiftUI iOS + Mac Catalyst design system managed by **Tuist**. The ├── BroadwayTestHost/ # Minimal test host app (Sources/) ├── BroadwayTesting/ # Shared test utilities (Sources/) ├── Plans/ # Archived implementation plans -├── Project.swift # Tuist project manifest +├── Package.swift # SPM: BroadwayCore, BroadwayUI, BroadwayTesting libraries +├── Project.swift # Tuist: apps, test host, and xctest targets using the local package ├── Tuist.swift # Tuist global configuration ├── ide # Dev script (installs hooks, runs tuist generate) ├── swiftformat # Run SwiftFormat (--lint to check only) └── sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md ``` -Key root files: `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), `.mise.toml` (tool versions), `.swiftformat` (style config), `ide` and `swiftformat` (dev scripts). +Key root files: `Package.swift` (library products), `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), `.mise.toml` (tool versions), `.swiftformat` (style config), `ide` and `swiftformat` (dev scripts). ## Build System -- **Tuist 4+** generates the Xcode project from `Project.swift`. The `.xcodeproj` and `Derived/` are git-ignored. +- **Swift Package Manager** (`Package.swift`) defines the **BroadwayCore**, **BroadwayUI**, and **BroadwayTesting** libraries (iOS 26 + Mac Catalyst). Library sources live under each module’s `Sources/` tree as today. +- **Tuist 4+** generates the Xcode workspace from `Project.swift`, wires a **local package** dependency (`.relativeToRoot(".")`), and defines **BroadwayCatalog**, **BroadwayTestHost**, **BroadwayCoreTests**, **BroadwayUITests**, and **BroadwayCatalogTests**. The `.xcodeproj` / `.xcworkspace` and `Derived/` are git-ignored. - Tuist and SwiftFormat are version-pinned via **mise** in `.mise.toml`. Run `mise install` to install them. - Run `./ide` to generate the project (or `./ide -i` to run `mise exec -- tuist install` first). -- Run `mise exec -- tuist test` to execute all tests, or `mise exec -- tuist test ` for a specific target. +- Run `mise exec -- tuist test` to execute all tests (scheme **Broadway-Workspace**), or `mise exec -- tuist test ` for a subset. Example schemes: **BroadwayCoreTests**, **BroadwayUITests**, **BroadwayCatalog**. There is no longer a generated **BroadwayCore** scheme (the core library is built as part of the Swift package). ## Formatting @@ -46,11 +48,16 @@ Key root files: `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), ## Dependency Graph ``` -BroadwayCatalog (app) --> BroadwayUI (framework) --> BroadwayCore (framework) -BroadwayTestHost (app) --> BroadwayUI --> BroadwayCore -BroadwayTesting (framework) --> BroadwayCore - -All framework test targets use BroadwayTestHost and depend on BroadwayTesting. +Package.swift: + BroadwayUI (library) --> BroadwayCore (library) + BroadwayTesting (library) --> BroadwayCore (library) + +Project.swift (Tuist): + BroadwayCatalog (app) --> package product BroadwayUI + BroadwayCatalogTests --> BroadwayCatalog, package BroadwayTesting + BroadwayCoreTests --> package BroadwayCore, package BroadwayTesting, BroadwayTestHost + BroadwayUITests --> package BroadwayUI, package BroadwayTesting, BroadwayTestHost + BroadwayTestHost (app) --> (no framework deps) ``` ## Key Conventions @@ -59,4 +66,5 @@ All framework test targets use BroadwayTestHost and depend on BroadwayTesting. - **Swift Testing** (`import Testing`) is used for unit tests, not XCTest. - Source files use `/Sources/**` globs; test files use `/Tests/**`. - Info.plist is auto-generated by Tuist via `infoPlist: .extendingDefault(with:)`. -- New targets or dependencies: edit `Project.swift`. +- New **library** code or package products: edit `Package.swift` (and folder layout under each module). +- New **app**, **test host**, or **xctest bundle** targets: edit `Project.swift`. diff --git a/BroadwayCore/Sources/Traits/Values/BAccessibility.swift b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift index 1315db8..57d24fb 100644 --- a/BroadwayCore/Sources/Traits/Values/BAccessibility.swift +++ b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift @@ -156,7 +156,7 @@ extension BAccessibility { /// A provider which returns the current accessibility settings on the device. /// /// Instead of accessing `UIAccessibility.{...}` directly, utilize `BAccessibility.systemSettings`. - public protocol SettingsProvider: AnyObject { + public protocol SettingsProvider: AnyObject, Sendable { // MARK: Assistive Technologies var isVoiceOverRunning: Bool { get } @@ -300,7 +300,7 @@ extension BAccessibility { } extension BAccessibility { - private final class SystemSettingsProvider: SettingsProvider { + private final class SystemSettingsProvider: SettingsProvider, @unchecked Sendable { var isVoiceOverRunning: Bool { UIAccessibility.isVoiceOverRunning } diff --git a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift index 94f2925..b528abb 100644 --- a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift +++ b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift @@ -32,7 +32,7 @@ extension BTraits.Overrides { } } -public enum BMode: Equatable, Hashable { +public enum BMode: Equatable, Hashable, Sendable { case dark case light @@ -68,7 +68,7 @@ extension BMode: BTraitsValue { } } -public enum BContentSizeCategory: Equatable, Hashable, Comparable { +public enum BContentSizeCategory: Equatable, Hashable, Comparable, Sendable { case extraSmall case small case medium diff --git a/BroadwayCore/Tests/BAccessibilityTests.swift b/BroadwayCore/Tests/BAccessibilityTests.swift index a33aced..b1cd110 100644 --- a/BroadwayCore/Tests/BAccessibilityTests.swift +++ b/BroadwayCore/Tests/BAccessibilityTests.swift @@ -120,7 +120,7 @@ struct BAccessibilityTests { // MARK: - Mock SettingsProvider -private final class MockSettingsProvider: BAccessibility.SettingsProvider { +private final class MockSettingsProvider: BAccessibility.SettingsProvider, @unchecked Sendable { var isVoiceOverRunning = false var isSwitchControlRunning = false var isAssistiveTouchRunning = false diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..bba3b35 --- /dev/null +++ b/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "Broadway", + platforms: [ + .iOS(.v26), + .macCatalyst(.v26), + ], + products: [ + .library(name: "BroadwayCore", targets: ["BroadwayCore"]), + .library(name: "BroadwayUI", targets: ["BroadwayUI"]), + .library(name: "BroadwayTesting", targets: ["BroadwayTesting"]), + ], + targets: [ + .target( + name: "BroadwayCore", + path: "BroadwayCore/Sources", + ), + .target( + name: "BroadwayUI", + dependencies: [ + .target(name: "BroadwayCore"), + ], + path: "BroadwayUI/Sources", + ), + .target( + name: "BroadwayTesting", + dependencies: [ + .target(name: "BroadwayCore"), + ], + path: "BroadwayTesting/Sources", + ), + ], +) diff --git a/Project.swift b/Project.swift index c2832bd..06b4a25 100644 --- a/Project.swift +++ b/Project.swift @@ -3,44 +3,28 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad, .macCatalyst] let deployment: DeploymentTargets = .iOS("26.0") -/// Enables Swift `package` access across all first-party targets (see SE-0386). -private let broadwayPackageAccessSettings = Settings.settings( - base: [ - "OTHER_SWIFT_FLAGS": .array(["$(inherited)", "-package-name", "Broadway"]), - ], -) +/// Local Swift package (see root `Package.swift`) for BroadwayCore, BroadwayUI, and BroadwayTesting. +private let broadwayPackage = Package.local(path: .relativeToRoot(".")) -func framework( - _ name: String, +func unitTests( + name: String, bundleIdSuffix: String, - dependencies: [TargetDependency] = [], -) -> [Target] { - [ - .target( - name: name, - destinations: destinations, - product: .framework, - bundleId: "com.broadway.\(bundleIdSuffix)", - deploymentTargets: deployment, - sources: ["\(name)/Sources/**"], - dependencies: dependencies, - settings: broadwayPackageAccessSettings, - ), - .target( - name: "\(name)Tests", - destinations: destinations, - product: .unitTests, - bundleId: "com.broadway.\(bundleIdSuffix).tests", - deploymentTargets: deployment, - sources: ["\(name)/Tests/**"], - dependencies: [ - .target(name: name), - .target(name: "BroadwayTesting"), - .target(name: "BroadwayTestHost"), - ], - settings: broadwayPackageAccessSettings, - ), - ] + productDependency: String, + sources: ProjectDescription.SourceFilesList, +) -> Target { + .target( + name: name, + destinations: destinations, + product: .unitTests, + bundleId: "com.broadway.\(bundleIdSuffix).tests", + deploymentTargets: deployment, + sources: sources, + dependencies: [ + .package(product: productDependency), + .package(product: "BroadwayTesting"), + .target(name: "BroadwayTestHost"), + ], + ) } let project = Project( @@ -49,6 +33,7 @@ let project = Project( defaultKnownRegions: ["en"], developmentRegion: "en", ), + packages: [broadwayPackage], targets: [ .target( name: "BroadwayCatalog", @@ -62,8 +47,9 @@ let project = Project( ]), sources: ["BroadwayCatalog/Sources/**"], resources: ["BroadwayCatalog/Resources/**"], - dependencies: [.target(name: "BroadwayUI")], - settings: broadwayPackageAccessSettings, + dependencies: [ + .package(product: "BroadwayUI"), + ], ), .target( name: "BroadwayCatalogTests", @@ -74,9 +60,8 @@ let project = Project( sources: ["BroadwayCatalog/Tests/**"], dependencies: [ .target(name: "BroadwayCatalog"), - .target(name: "BroadwayTesting"), + .package(product: "BroadwayTesting"), ], - settings: broadwayPackageAccessSettings, ), .target( name: "BroadwayTestHost", @@ -89,22 +74,18 @@ let project = Project( ]), sources: ["BroadwayTestHost/Sources/**"], dependencies: [], - settings: broadwayPackageAccessSettings, ), - .target( - name: "BroadwayTesting", - destinations: destinations, - product: .framework, - bundleId: "com.broadway.testing", - deploymentTargets: deployment, - sources: ["BroadwayTesting/Sources/**"], - dependencies: [ - .target(name: "BroadwayCore"), - .xctest, - ], - settings: broadwayPackageAccessSettings, + unitTests( + name: "BroadwayCoreTests", + bundleIdSuffix: "core", + productDependency: "BroadwayCore", + sources: ["BroadwayCore/Tests/**"], ), - ] - + framework("BroadwayUI", bundleIdSuffix: "ui", dependencies: [.target(name: "BroadwayCore")]) - + framework("BroadwayCore", bundleIdSuffix: "core"), + unitTests( + name: "BroadwayUITests", + bundleIdSuffix: "ui", + productDependency: "BroadwayUI", + sources: ["BroadwayUI/Tests/**"], + ), + ], ) diff --git a/README.md b/README.md index 27c997e..2fbb344 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ The `./ide` script also configures a Git pre-commit hook that automatically form tuist test ``` -Or open the generated project in Xcode and run tests with **Cmd+U**. +Use `tuist test BroadwayCoreTests` or `tuist test BroadwayUITests` to run a single bundle. Core/UI **libraries** are defined in `Package.swift` (there is no `BroadwayCore` Xcode scheme—libraries build via the embedded Swift package). + +Or open the generated workspace in Xcode and run tests with **Cmd+U**. ## Project Structure @@ -52,7 +54,8 @@ Or open the generated project in Xcode and run tests with **Cmd+U**. ├── BroadwayTestHost/ # Minimal test host app (Sources/) ├── BroadwayTesting/ # Shared test utilities (Sources/) ├── Plans/ # Archived implementation plans -├── Project.swift # Tuist project manifest +├── Package.swift # SPM libraries (Core, UI, Testing) +├── Project.swift # Tuist: apps, test host, xctest bundles ├── Tuist.swift # Tuist global configuration ├── ide # Dev setup script ├── swiftformat # Run SwiftFormat @@ -61,16 +64,17 @@ Or open the generated project in Xcode and run tests with **Cmd+U**. ## Targets +**Swift package** (`Package.swift`): **BroadwayCore**, **BroadwayUI**, and **BroadwayTesting** library products. + +**Tuist / Xcode** (`Project.swift`): + | Target | Product | Destinations | |---|---|---| | **BroadwayCatalog** | App | iOS, Mac Catalyst | | **BroadwayCatalogTests** | Unit Tests | iOS, Mac Catalyst | -| **BroadwayUI** | Framework | iOS, Mac Catalyst | -| **BroadwayUITests** | Unit Tests | iOS, Mac Catalyst | -| **BroadwayCore** | Framework | iOS, Mac Catalyst | | **BroadwayCoreTests** | Unit Tests | iOS, Mac Catalyst | -| **BroadwayTestHost** | App | iOS, Mac Catalyst | -| **BroadwayTesting** | Framework | iOS, Mac Catalyst | +| **BroadwayUITests** | Unit Tests | iOS, Mac Catalyst | +| **BroadwayTestHost** | App (test host) | iOS, Mac Catalyst | ## AI Agent Skills From d9ae4ffcc642bf114618648383ce792a1bf4d74b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Apr 2026 14:53:32 -0400 Subject: [PATCH 16/17] PR review: document SPM test deps, clarify trait observer docs, strengthen stop test - Remove redundant stylesheet get in BContext merged-traits test (kyleve) - Note why BroadwayTesting no longer links .xctest under Tuist+SPM - BTraitOverridesViewController: document didMove vs viewIsAppearing roles - NeverObserver doc: reflect explicit makeObserver requirement - BTraitsObserverTests: SeqTrait + NotificationCenter proves stop removes subscriptions; fix CountingStartSpy static storage; isolate NC per hosted VC Made-with: Cursor --- BroadwayCore/Sources/Traits/BTraits.swift | 4 +- BroadwayCore/Tests/BContextTests.swift | 3 +- BroadwayCore/Tests/BTraitsObserverTests.swift | 117 +++++++++++++++++- .../BTraitOverridesViewController.swift | 9 +- Project.swift | 3 + 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/BroadwayCore/Sources/Traits/BTraits.swift b/BroadwayCore/Sources/Traits/BTraits.swift index 9575fb9..df2502b 100644 --- a/BroadwayCore/Sources/Traits/BTraits.swift +++ b/BroadwayCore/Sources/Traits/BTraits.swift @@ -152,8 +152,8 @@ extension BTraitsValue { func stop() } -/// A no-op observer used as the default ``BTraitsValue/Observer`` -/// for trait values that do not require live observation. +/// A no-op observer type conformers can return from ``BTraitsValue/makeObserver`` +/// when a trait has no live system source to subscribe to. @MainActor public final class NeverObserver: BTraitsValueObserver { public init() {} public func start() {} diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index c44f866..0ba99c4 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -340,7 +340,7 @@ struct BContextTests { } @Test("Stylesheets use merged traits when initializer passes non-empty overrides") - func stylesheetsTraitsMatchMergedAtInit() throws { + func stylesheetsTraitsMatchMergedAtInit() { var base = BTraits() base.accessibility = BAccessibility(isVoiceOverRunning: false) @@ -350,6 +350,5 @@ struct BContextTests { let context = BContext(traits: base, overrides: overrides) #expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) - _ = try context.stylesheets.get(TestStylesheet.self) } } diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift index 498ce23..8f08cd2 100644 --- a/BroadwayCore/Tests/BTraitsObserverTests.swift +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -1,11 +1,12 @@ @testable import BroadwayCore import BroadwayTesting +import ObjectiveC import Testing import UIKit @MainActor private final class CountingStartSpy: BTraitsValueObserver { - weak static var lastInstance: CountingStartSpy? + static var lastInstance: CountingStartSpy? private(set) var startCount = 0 @@ -39,6 +40,94 @@ private struct StartCountingTrait: BTraitsValue, Hashable { } } +private enum SeqTraitTest { + static let notificationName = Notification.Name("BroadwayTests.SeqTrait") +} + +/// Per–view-controller notification center so parallel tests do not cross-post. +private enum SeqTraitAssoc { + private static var notificationCenterKey: UInt8 = 0 + + static func setNotificationCenter(_ viewController: UIViewController, _ center: NotificationCenter) { + objc_setAssociatedObject( + viewController, + ¬ificationCenterKey, + center, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + } + + static func notificationCenter(for viewController: UIViewController) -> NotificationCenter { + guard let center = objc_getAssociatedObject(viewController, ¬ificationCenterKey) as? NotificationCenter else { + preconditionFailure("SeqTrait tests must set a notification center on the hosted view controller.") + } + return center + } +} + +private struct SeqTrait: BTraitsValue, Hashable { + typealias Observer = SeqTraitObserver + + var generation: Int + + @MainActor static var defaultValue: SeqTrait { + SeqTrait(generation: 0) + } + + @MainActor static func currentValue(from _: UIViewController) -> SeqTrait { + SeqTrait(generation: 0) + } + + @MainActor static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (SeqTrait) -> Void, + ) -> SeqTraitObserver { + SeqTraitObserver(viewController: viewController, onChange: onChange) + } +} + +@MainActor +private final class SeqTraitObserver: BTraitsValueObserver { + private weak var viewController: UIViewController? + private var token: NSObjectProtocol? + private var centerUsedForObservation: NotificationCenter? + private var nextGeneration = 0 + private let onChange: @MainActor @Sendable (SeqTrait) -> Void + + init( + viewController: UIViewController, + onChange: @escaping @MainActor @Sendable (SeqTrait) -> Void, + ) { + self.viewController = viewController + self.onChange = onChange + } + + func start() { + guard token == nil, let viewController else { return } + let center = SeqTraitAssoc.notificationCenter(for: viewController) + centerUsedForObservation = center + token = center.addObserver( + forName: SeqTraitTest.notificationName, + object: nil, + queue: nil, + ) { [weak self] _ in + guard let self else { return } + MainActor.assumeIsolated { + self.nextGeneration += 1 + self.onChange(SeqTrait(generation: self.nextGeneration)) + } + } + } + + func stop() { + if let token, let center = centerUsedForObservation { + center.removeObserver(token) + } + token = nil + centerUsedForObservation = nil + } +} + @MainActor struct BTraitsObserverTests { private func makeSystemObserver( from viewController: UIViewController, @@ -118,6 +207,32 @@ private struct StartCountingTrait: BTraitsValue, Hashable { } } + @Test("stop tears down underlying subscriptions so notifications no longer update traits") + func stopRemovesUnderlyingObservers() throws { + var traits = BTraits() + traits.register(SeqTrait.self) + + let anchor = UIViewController() + let center = NotificationCenter() + try show(anchor) { hosted in + SeqTraitAssoc.setNotificationCenter(hosted, center) + + var aggregateChangeCount = 0 + let observer = BTraitsObserver(traits: traits, from: hosted) { _ in + aggregateChangeCount += 1 + } + observer.start() + + center.post(name: SeqTraitTest.notificationName, object: nil) + #expect(aggregateChangeCount == 1) + + observer.stop() + + center.post(name: SeqTraitTest.notificationName, object: nil) + #expect(aggregateChangeCount == 1) + } + } + @Test("Can restart after stopping") func restart() throws { let anchor = UIViewController() diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift index da7a272..0f474f3 100644 --- a/BroadwayUI/Sources/BTraitOverridesViewController.swift +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -9,9 +9,12 @@ import UIKit /// A container view controller that applies ``BTraits/Overrides`` to the /// inherited ``BContext`` before passing it to its child. /// -/// Child creation and override application are deferred until the -/// view controller enters a valid view hierarchy (`viewIsAppearing`), -/// matching ``BRootViewController``'s lazy setup pattern. +/// Override values are refreshed when embedded (`didMove(toParent:)`) and when +/// inherited traits change, so the subtree keeps the correct merged context +/// before the child is created. +/// +/// Child creation is deferred until the view controller enters a valid view +/// hierarchy (`viewIsAppearing`), matching ``BRootViewController``'s lazy setup pattern. public final class BTraitOverridesViewController: UIViewController { // MARK: Public diff --git a/Project.swift b/Project.swift index 06b4a25..5090814 100644 --- a/Project.swift +++ b/Project.swift @@ -4,6 +4,9 @@ let destinations: Destinations = [.iPhone, .iPad, .macCatalyst] let deployment: DeploymentTargets = .iOS("26.0") /// Local Swift package (see root `Package.swift`) for BroadwayCore, BroadwayUI, and BroadwayTesting. +/// +/// The former Tuist `BroadwayTesting` framework target linked `.xctest`; the package library only +/// needs UIKit (`show`, etc.) and does not link XCTest, so that dependency is not carried over. private let broadwayPackage = Package.local(path: .relativeToRoot(".")) func unitTests( From d596ab165f172930471f2fc8e684a82d6eb5bab2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Apr 2026 15:02:49 -0400 Subject: [PATCH 17/17] Simplify SeqTrait observer test harness (single NotificationCenter) Made-with: Cursor --- BroadwayCore/Tests/BTraitsObserverTests.swift | 52 ++++--------------- 1 file changed, 10 insertions(+), 42 deletions(-) diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift index 8f08cd2..ee43b44 100644 --- a/BroadwayCore/Tests/BTraitsObserverTests.swift +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -1,6 +1,5 @@ @testable import BroadwayCore import BroadwayTesting -import ObjectiveC import Testing import UIKit @@ -41,30 +40,10 @@ private struct StartCountingTrait: BTraitsValue, Hashable { } private enum SeqTraitTest { + static let notificationCenter = NotificationCenter() static let notificationName = Notification.Name("BroadwayTests.SeqTrait") } -/// Per–view-controller notification center so parallel tests do not cross-post. -private enum SeqTraitAssoc { - private static var notificationCenterKey: UInt8 = 0 - - static func setNotificationCenter(_ viewController: UIViewController, _ center: NotificationCenter) { - objc_setAssociatedObject( - viewController, - ¬ificationCenterKey, - center, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC, - ) - } - - static func notificationCenter(for viewController: UIViewController) -> NotificationCenter { - guard let center = objc_getAssociatedObject(viewController, ¬ificationCenterKey) as? NotificationCenter else { - preconditionFailure("SeqTrait tests must set a notification center on the hosted view controller.") - } - return center - } -} - private struct SeqTrait: BTraitsValue, Hashable { typealias Observer = SeqTraitObserver @@ -79,33 +58,26 @@ private struct SeqTrait: BTraitsValue, Hashable { } @MainActor static func makeObserver( - with viewController: UIViewController, + with _: UIViewController, onChange: @MainActor @escaping @Sendable (SeqTrait) -> Void, ) -> SeqTraitObserver { - SeqTraitObserver(viewController: viewController, onChange: onChange) + SeqTraitObserver(onChange: onChange) } } @MainActor private final class SeqTraitObserver: BTraitsValueObserver { - private weak var viewController: UIViewController? private var token: NSObjectProtocol? - private var centerUsedForObservation: NotificationCenter? private var nextGeneration = 0 private let onChange: @MainActor @Sendable (SeqTrait) -> Void - init( - viewController: UIViewController, - onChange: @escaping @MainActor @Sendable (SeqTrait) -> Void, - ) { - self.viewController = viewController + init(onChange: @escaping @MainActor @Sendable (SeqTrait) -> Void) { self.onChange = onChange } func start() { - guard token == nil, let viewController else { return } - let center = SeqTraitAssoc.notificationCenter(for: viewController) - centerUsedForObservation = center + guard token == nil else { return } + let center = SeqTraitTest.notificationCenter token = center.addObserver( forName: SeqTraitTest.notificationName, object: nil, @@ -120,11 +92,10 @@ private final class SeqTraitObserver: BTraitsValueObserver { } func stop() { - if let token, let center = centerUsedForObservation { - center.removeObserver(token) + if let token { + SeqTraitTest.notificationCenter.removeObserver(token) } token = nil - centerUsedForObservation = nil } } @@ -213,22 +184,19 @@ private final class SeqTraitObserver: BTraitsValueObserver { traits.register(SeqTrait.self) let anchor = UIViewController() - let center = NotificationCenter() try show(anchor) { hosted in - SeqTraitAssoc.setNotificationCenter(hosted, center) - var aggregateChangeCount = 0 let observer = BTraitsObserver(traits: traits, from: hosted) { _ in aggregateChangeCount += 1 } observer.start() - center.post(name: SeqTraitTest.notificationName, object: nil) + SeqTraitTest.notificationCenter.post(name: SeqTraitTest.notificationName, object: nil) #expect(aggregateChangeCount == 1) observer.stop() - center.post(name: SeqTraitTest.notificationName, object: nil) + SeqTraitTest.notificationCenter.post(name: SeqTraitTest.notificationName, object: nil) #expect(aggregateChangeCount == 1) } }