diff --git a/.bumper/RULES.md b/.bumper/RULES.md index ce91963d5..8ce84de00 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -92,6 +92,13 @@ This rule follows the typed-query pattern proven by TheButtonHeist's audited and retained: Where needs the same check plus explicit exceptions for its documented observation-task lifecycle. +## Reserved-region layout ownership + +`where.reserved_region_layout_ownership` permits `reservedRegions` calls only in `AdaptivePaneGeometry` within WhereUI. +Views use that adapter or `AdaptivePaneContainer`. Keep the system margins and physical coordinate mapping there. +The syntax guard checks member-call spelling; it does not resolve aliases or function references. +`WhereProjectRulesTests` covers direct member-call mutations. + ## Composition ownership `where.services_composition_ownership` keeps direct `WhereServices` diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 274ae722f..cf0cebee9 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -18,6 +18,7 @@ let whereProjectRules = RuleSet { owner: whereLoggingScope.union(nonWhereProductionScope), id: "where.logging_type_ownership", ) + reservedRegionLayoutOwnershipRule productionStoreOpeningRule checkedConcurrencyBoundaryRule gregorianCalendarRule @@ -305,3 +306,28 @@ private let previewCoverageRule = Rules.files( ) } } + +/// Keep system geometry mapping in one adapter, shared by production and injected fixtures. +private let reservedRegionLayoutOwnershipRule = Rules.files( + "where.reserved_region_layout_ownership", + severity: .error, + summary: "WhereUI reads system reserved regions through its shared geometry adapter.", + scope: .component(WhereComponent.whereUI), +) { file in + guard file.path.rawValue != "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift" + else { return [] } + return functionCalls() + .filter { match in + match.node.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text == "reservedRegions" + } + .matches(in: file) + .map { match in + match.failure( + message: "Reserved regions are read outside AdaptivePaneGeometry.", + evidence: ViolationEvidence( + observed: file.path.rawValue, + expectation: "use AdaptivePaneGeometry or AdaptivePaneContainer", + ), + ) + } +} diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index 4ab1edf3d..75d48d665 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -3,6 +3,23 @@ import BumperBowlingTestSupport import Testing struct WhereProjectRulesTests { + @Test + func `system reserved regions use one geometry adapter`() throws { + let source = "let regions = proxy.reservedRegions(kind: .division, options: .includeInactive, layoutDirectionBehavior: .fixed)" + let allowed = try evaluate( + path: "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift", + component: .whereUI, + source: source, + ) + let rejected = try evaluate( + path: "Where/WhereUI/Sources/Primary/LocationsView.swift", + component: .whereUI, + source: source, + ) + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.map(\.rule.id) == ["where.reserved_region_layout_ownership"]) + } + @Test func `production store opens at process composition roots`() throws { let allowed = try evaluate( diff --git a/Package.swift b/Package.swift index de4f5f2f8..94478f01f 100644 --- a/Package.swift +++ b/Package.swift @@ -145,6 +145,7 @@ let package = Package( .target( name: "SnapshotKit", path: "Shared/SnapshotKit/Sources", + resources: [.process("Resources")], ), .target( name: "SnapshotKitTesting", diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index e00675afa..e2a4a73cd 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -19,6 +19,12 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. - **Mirror the value into the UIKit trait system** so nested UIKit views receive it. - **If none is set, fall back to the UIKit trait-bridged value.** Then a `BRootViewController`-set context still reaches SwiftUI. +## Scroll restoration + +- Keep scroll restoration opt-in through an explicit stable scope. Key entries by view type and typed role; a context change replaces the entry, not its value in a historical cache. +- Resolve a mount again only when its scope, key, or context changes. Capture the resolved entry in native observable bindings. Outgoing views and obsolete contexts must not write into a replacement entry. Guard: `BScrollPositionTests`. +- Views own initial positioning and missing-target policy. The shared store must not import application types or synchronize panes. + ## Testing Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 500fa6e8d..affed60c1 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -36,6 +36,38 @@ Later explicit values update the context normally. `bHorizontalSizeClass(_:)` and `bVerticalSizeClass(_:)` scope Broadway layout policies to a subtree. These overrides affect stylesheets; native container traits remain under system control. +## Scroll restoration + +`BScrollPosition` exposes a native `ScrollPosition` binding. Add +`.bScrollRestorationScope()` to a stable ancestor to retain positions when its +descendants are replaced. A caller-owned `BScrollRestorationStore` can also be +supplied to the modifier. Without a scope, positions remain local to the view. + +Initialize the wrapper with the view type, a typed role, a context token, and the +scroll target ID type. Independent panes need independent roles or scopes. +Changing context (for example, region or year) discards the previous position; +returning to that context starts fresh. Old bindings retain their detached entry +and cannot overwrite its replacement. Stores belong to one scene/session. + +Views choose initial anchors, wait for content, and handle missing targets. +Call `_position.initialize(id: target, anchor: .top)` or `.initialize(edge: .top)` +from a view task after content is ready. The first call restores the retained target for this mount, +or applies the initial policy for a new context. Later calls in the same mount are no-ops. +A pending restoration protects the previous target from provisional layout updates. User movement supersedes that pending restoration. + +Use `_position.scrollTo(id: target, anchor: .bottom)` or `.scrollTo(edge: .top)` for +explicit commands. These retain alignment, which `ScrollPosition` does not expose +for reading. The native binding still supports immediate scrolling, but commands +through that binding cannot retain non-default alignment. User scrolling adopts +top alignment. The read-only `anchor` property supplies transition geometry. +The wrapper retains the concrete context type until the heterogeneous store boundary. +A context closure can read a live observable input when the owning view updates. +Reading an unmounted wrapper asserts in debug builds; release builds use an isolated +fallback that cannot access or replace a mounted scope entry. +The store neither interprets application data nor synchronizes separate panes. +Use identified content and alignment for restoration; pixel offsets are not a +cross-layout restoration contract. Guards: `BScrollPositionTests`. + ## Install Local SPM library declared in the root [`Package.swift`](../../../Package.swift) (depends on BroadwayCore): `.package(product: "BroadwayUI")`. diff --git a/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift new file mode 100644 index 000000000..1c3936e05 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift @@ -0,0 +1,176 @@ +import SwiftUI + +/// A native scroll binding retained by the nearest explicit restoration scope. +/// Use distinct roles for independent panes. Context changes discard the previous position. +@MainActor +@propertyWrapper +public struct BScrollPosition< + ID: Hashable & Sendable, + Context: Hashable, +>: @MainActor DynamicProperty { + @Environment(\.bScrollRestorationStore) private var sharedStore + @State private var localStore = BScrollRestorationStore() + private let key: BScrollRestorationStore.Key + private let context: () -> Context + @State private var mount = Mount() + private var resolvedAttachment: Attachment? + /// Unmounted access never joins a scope. Copies of this wrapper retain the same fallback. + private let unmountedAttachment = Attachment(entry: .init()) + + private struct Restoration { + let position: ScrollPosition + let anchor: UnitPoint? + } + + private final class Attachment { + let entry: BScrollRestorationStore.Entry + var pending: Restoration? + let userRevision: Int + + init(entry: BScrollRestorationStore.Entry) { + self.entry = entry + userRevision = entry.userRevision + pending = Restoration(position: entry.position, anchor: entry.anchor) + } + } + + @MainActor + private final class Mount { + private var store: BScrollRestorationStore? + private var context: Context? + private var key: BScrollRestorationStore.Key? + private var attachment: Attachment? + + func resolve( + store: BScrollRestorationStore, + key: BScrollRestorationStore.Key, + context: Context, + ) -> Attachment { + if self.store === store, self.key == key, self.context == context, + let attachment { return attachment } + let attachment = Attachment(entry: store.entry( + for: key, + context: AnyHashable(context), + idType: ID.self, + )) + self.store = store + self.key = key + self.context = context + self.attachment = attachment + return attachment + } + } + + public init( + _ view: (some View).Type, + role: some Hashable, + context: Context, + idType: ID.Type, + ) { + key = .init( + view: ObjectIdentifier(view), + role: AnyHashable(role), + content: ObjectIdentifier(idType), + ) + self.context = { context } + } + + /// Resolve a context supplied by an observable input when the owning view updates. + public init( + _ view: (some View).Type, + role: some Hashable, + context: @escaping @MainActor () -> Context, + idType: ID.Type, + ) { + key = .init( + view: ObjectIdentifier(view), + role: AnyHashable(role), + content: ObjectIdentifier(idType), + ) + self.context = context + } + + public mutating func update() { + // An outgoing view can still render while its replacement is mounted. + // Only a new mount, scope, or context may claim the store's current entry. + resolvedAttachment = mount.resolve( + store: sharedStore ?? localStore, + key: key, + context: context(), + ) + } + + private var attachment: Attachment { + guard let resolvedAttachment else { + assertionFailure("BScrollPosition must be read from a mounted view") + return unmountedAttachment + } + return resolvedAttachment + } + + private var entry: BScrollRestorationStore.Entry { + attachment.entry + } + + /// Alignment used for semantic restoration; native user scrolling adopts top alignment. + public var anchor: UnitPoint? { + entry.anchor + } + + public var wrappedValue: ScrollPosition { + get { entry.position } + nonmutating set { entry.position = newValue } + } + + public var projectedValue: Binding { + // Preserve SwiftUI's observable binding transaction, and capture this entry + // so an outgoing view cannot redirect a write into a replacement context. + @Bindable var entry = entry + return $entry.position + } + + /// Restore once per mount when content is ready, or apply this context's initial policy. + public func initialize(id: ID, anchor: UnitPoint) { + initialize(position: ScrollPosition(id: id, anchor: anchor), anchor: anchor) + } + + public func initialize(edge: Edge) { + initialize(position: ScrollPosition(idType: ID.self, edge: edge), anchor: nil) + } + + private func initialize(position: ScrollPosition, anchor: UnitPoint?) { + let attachment = attachment + guard let restoration = attachment.pending else { return } + attachment.pending = nil + guard attachment.userRevision == entry.userRevision else { return } + // Mutate the live position so repeated semantic commands notify its native binding. + if entry.isInitialized, let edge = restoration.position.edge { + entry.position.scrollTo(edge: edge) + entry.anchor = nil + } else if entry.isInitialized, + let target = restoration.position.viewID(type: ID.self) + { + entry.position.scrollTo(id: target, anchor: restoration.anchor) + entry.anchor = restoration.anchor + } else { + entry.position = position + entry.anchor = anchor + } + entry.isInitialized = true + } + + /// Use semantic commands to retain alignment, which native ScrollPosition does not expose. + public func scrollTo(id: ID, anchor: UnitPoint) { + attachment.pending = nil + entry.isInitialized = true + entry.anchor = anchor + entry.position.scrollTo(id: id, anchor: anchor) + } + + public func scrollTo(edge: Edge) { + attachment.pending = nil + entry.isInitialized = true + entry.anchor = nil + entry.position.scrollTo(edge: edge) + } +} diff --git a/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationScope.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationScope.swift new file mode 100644 index 000000000..e5fd1e31a --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationScope.swift @@ -0,0 +1,21 @@ +import SwiftUI + +private struct BScrollRestorationScope: ViewModifier { + @State private var store = BScrollRestorationStore() + + func body(content: Content) -> some View { + content.environment(\.bScrollRestorationStore, store) + } +} + +extension View { + /// Own a restoration store above content that can be replaced during layout changes. + public func bScrollRestorationScope() -> some View { + modifier(BScrollRestorationScope()) + } + + /// Share a caller-owned store across replacement hosts within the same scene/session. + public func bScrollRestorationScope(_ store: BScrollRestorationStore) -> some View { + environment(\.bScrollRestorationStore, store) + } +} diff --git a/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift new file mode 100644 index 000000000..58616c932 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift @@ -0,0 +1,56 @@ +import Observation +import SwiftUI + +/// Retains each scroll role's current context. Replacing a context detaches its old bindings. +@MainActor +public final class BScrollRestorationStore { + struct Key: Hashable { + let view: ObjectIdentifier + let role: AnyHashable + let content: ObjectIdentifier + } + + private struct Record { + let context: AnyHashable + let entry: AnyObject + } + + @Observable + final class Entry { + var position = ScrollPosition(idType: ID.self) { + didSet { + guard oldValue != position, position.isPositionedByUser else { return } + anchor = .top + isInitialized = true + userRevision += 1 + } + } + + private(set) var userRevision = 0 + var isInitialized = false + var anchor: UnitPoint? + } + + private var records: [Key: Record] = [:] + + public init() {} + + func entry( + for key: Key, + context: AnyHashable, + idType _: ID.Type, + ) -> Entry { + if let record = records[key], record.context == context, + let entry = record.entry as? Entry + { + return entry + } + let entry = Entry() + records[key] = Record(context: context, entry: entry) + return entry + } +} + +extension EnvironmentValues { + @Entry var bScrollRestorationStore: BScrollRestorationStore? +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift new file mode 100644 index 000000000..001dee755 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift @@ -0,0 +1,350 @@ +import BroadwayUI +import Observation +import SwiftUI +import TestHostSupport +import Testing + +@MainActor +@Suite(.serialized) +struct BScrollPositionTests { + @Test func nativeScrollViewRestoresItsVisibleTargetAfterReplacement() async throws { + let fixture = NativeScrollFixture() + fixture.initial = nil + let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { controller in + try await waitForAsync { fixture.position != nil && fixture.visible.contains(0) } + fixture.position?.wrappedValue.scrollTo(id: 12, anchor: .top) + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } + let before = fixture.position?.wrappedValue.viewID(type: Int.self) + fixture.mounted = false + try await waitForAsync { fixture.visible.isEmpty } + fixture.mounted = true + controller.view.layoutIfNeeded() + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } + #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == before) + } + } + + @Test func readinessReplacesProvisionalNativePositionOnlyOncePerContext() async throws { + let fixture = NativeScrollFixture() + fixture.initial = nil + let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { _ in + try await waitForAsync { fixture.visible.contains(0) } + fixture.initial = 12 + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } + fixture.initial = 20 + try await waitForAsync { fixture.observedInitial == 20 } + #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == 12) + fixture.context = 2 + try await waitForAsync { fixture.visible.contains(20) && !fixture.visible.contains(12) } + } + } + + @Test func delayedRemountRestoresBottomAlignmentBeforeUsingAnotherInitialTarget() async throws { + let fixture = NativeScrollFixture() + fixture.initial = 12 + fixture.initialAnchor = .bottom + let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { _ in + do { try await waitForAsync { + fixture.visible.contains(12) && fixture.frames[12] + .map { abs($0.maxY - (fixture.viewport.maxY)) < 1 } == + true + } + } catch { + throw TestHostError( + "Initial bottom: visible=\(fixture.visible), frame=\(String(describing: fixture.frames[12])), viewport=\(fixture.viewport), inset=\(fixture.bottomInset)", + ) + } + fixture.mounted = false + try await waitForAsync { fixture.visible.isEmpty } + fixture.initial = nil + fixture.mounted = true + try await waitForAsync { !fixture.visible.isEmpty && fixture.observedInitial == nil } + fixture.initial = 2 + do { try await waitForAsync { + fixture.observedInitial == 2 && fixture.visible.contains(12) + && fixture.frames[12] + .map { abs($0.maxY - (fixture.viewport.maxY)) < 1 } == + true + } + } catch { + throw TestHostError( + "Delayed bottom: visible=\(fixture.visible), frame=\(String(describing: fixture.frames[12])), viewport=\(fixture.viewport), target=\(String(describing: fixture.position?.wrappedValue.viewID(type: Int.self))), user=\(String(describing: fixture.position?.wrappedValue.isPositionedByUser))", + ) + } + #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == 12) + } + } + + @Test func replacementRetainsPositionAndContextChangeDiscardsIt() async throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { _ in + try await waitForAsync { fixture.bindings[.first] != nil } + let old = try #require(fixture.bindings[.first]) + let delayedInitialization = try #require(fixture.initializers[.first]) + old.wrappedValue.scrollTo(id: 7, anchor: .top) + fixture.mounted = false + try await waitForAsync { fixture.disappeared } + fixture.mounted = true + try await waitForAsync { + !fixture.disappeared && fixture.bindings[.first]?.wrappedValue + .viewID(type: Int.self) == 7 + } + fixture.context = 2 + try await waitForAsync { fixture.observedContext == 2 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + old.wrappedValue.scrollTo(id: 99, anchor: .bottom) + delayedInitialization(101) + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + fixture.context = 1 + try await waitForAsync { fixture.observedContext == 1 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + } + } + + @Test func aUserPositionUpdateSupersedesDelayedRestoration() async throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { _ in + try await waitForAsync { fixture.initializers[.first] != nil } + fixture.initializers[.first]?(12) + fixture.mounted = false + try await waitForAsync { fixture.disappeared } + fixture.mounted = true + try await waitForAsync { !fixture.disappeared } + var userPosition = ScrollPosition(id: 20, anchor: .top) + userPosition.isPositionedByUser = true + fixture.bindings[.first]?.wrappedValue = userPosition + fixture.initializers[.first]?(2) + #expect(fixture.bindings[.first]?.wrappedValue.isPositionedByUser == true) + } + } + + @Test func anOutgoingContextCannotReclaimTheReplacementEntryWhenItRedraws() async throws { + let outgoing = ScrollFixture() + let current = ScrollFixture() + current.context = 2 + current.mounted = false + let host = UIHostingController(rootView: VStack { + ScrollHarness(fixture: outgoing) + ScrollHarness(fixture: current) + }.bScrollRestorationScope()) + try await show(host) { _ in + try await waitForAsync { outgoing.bindings[.first] != nil } + outgoing.bindings[.first]?.wrappedValue.scrollTo(id: 7, anchor: .top) + current.mounted = true + try await waitForAsync { current.observedContext == 2 } + current.bindings[.first]?.wrappedValue.scrollTo(id: 12, anchor: .top) + outgoing.redraw += 1 + try await waitForAsync { outgoing.observedRedraw == 1 } + outgoing.bindings[.first]?.wrappedValue.scrollTo(id: 99, anchor: .top) + current.mounted = false + try await waitForAsync { current.disappeared } + current.mounted = true + try await waitForAsync { !current.disappeared } + #expect(current.bindings[.first]?.wrappedValue.viewID(type: Int.self) == 12) + } + } + + @Test func changingAMountedViewsRoleUsesTheNewEntry() async throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try await show(host) { _ in + try await waitForAsync { fixture.bindings[.first] != nil } + fixture.bindings[.first]?.wrappedValue.scrollTo(id: 7, anchor: .top) + fixture.primaryRole = .third + try await waitForAsync { fixture.bindings[.third] != nil } + #expect(fixture.bindings[.third]?.wrappedValue.viewID(type: Int.self) == nil) + fixture.bindings[.third]?.wrappedValue.scrollTo(id: 12, anchor: .top) + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == 7) + } + } + + @Test func rolesAndScopesRemainIndependent() async throws { + let first = ScrollFixture() + let second = ScrollFixture() + let host = UIHostingController(rootView: VStack { + ScrollHarness(fixture: first).bScrollRestorationScope() + ScrollHarness(fixture: second).bScrollRestorationScope() + }) + try await show(host) { _ in + try await waitForAsync { first.bindings.count == 2 && second.bindings.count == 2 } + first.bindings[.first]?.wrappedValue.scrollTo(id: 8, anchor: .top) + #expect(first.bindings[.second]?.wrappedValue.viewID(type: Int.self) == nil) + #expect(second.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + } + } + + @Test func missingScopeUsesViewLocalState() async throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture)) + try await show(host) { _ in + try await waitForAsync { fixture.bindings[.first] != nil } + fixture.bindings[.first]?.wrappedValue.scrollTo(id: 4, anchor: .top) + fixture.redraw += 1 + try await waitForAsync { fixture.observedRedraw == 1 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == 4) + fixture.mounted = false + try await waitForAsync { fixture.disappeared } + fixture.mounted = true + try await waitForAsync { !fixture.disappeared } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + } + } +} + +private enum ScrollRole: Hashable { case first, second, third } + +@MainActor @Observable +private final class ScrollFixture { + var primaryRole = ScrollRole.first + var mounted = true + var context = 1 + var redraw = 0 + @ObservationIgnored var observedContext: Int? + @ObservationIgnored var observedRedraw: Int? + @ObservationIgnored var disappeared = false + @ObservationIgnored var bindings: [ScrollRole: Binding] = [:] + @ObservationIgnored var initializers: [ScrollRole: @MainActor (Int) -> Void] = [:] +} + +private struct ScrollHarness: View { + let fixture: ScrollFixture + var body: some View { + if fixture.mounted { + ScrollProbe( + fixture: fixture, + role: fixture.primaryRole, + context: fixture.context, + redraw: fixture.redraw, + ) + ScrollProbe( + fixture: fixture, + role: .second, + context: fixture.context, + redraw: fixture.redraw, + ) + } + } +} + +private struct ScrollProbe: View { + let fixture: ScrollFixture + let role: ScrollRole + let context: Int + let redraw: Int + @BScrollPosition private var position: ScrollPosition + + init(fixture: ScrollFixture, role: ScrollRole, context: Int, redraw: Int) { + self.fixture = fixture + self.role = role + self.context = context + self.redraw = redraw + _position = BScrollPosition(Self.self, role: role, context: context, idType: Int.self) + } + + var body: some View { + Color.clear.frame(height: 10) + .onChange(of: context, initial: true) { + captureBinding() + if role == .first { fixture.observedContext = context } + } + .onChange(of: role) { captureBinding() } + .onChange(of: redraw, initial: true) { fixture.observedRedraw = redraw } + .onAppear { fixture.disappeared = false } + .onDisappear { fixture.disappeared = true } + } + + private func captureBinding() { + fixture.bindings[role] = $position + fixture.initializers[role] = { _position.initialize(id: $0, anchor: .bottom) } + } +} + +@MainActor @Observable +private final class NativeScrollFixture { + var mounted = true + var context = 1 + var initial: Int? = 0 + var initialAnchor = UnitPoint.top + @ObservationIgnored var frames: [Int: CGRect] = [:] + @ObservationIgnored var viewport = CGRect.zero + @ObservationIgnored var bottomInset: CGFloat = 0 + @ObservationIgnored var observedInitial: Int? + @ObservationIgnored var position: Binding? + @ObservationIgnored var visible: [Int] = [] +} + +private struct NativeScrollHarness: View { + let fixture: NativeScrollFixture + var body: some View { + if fixture.mounted { NativeScrollProbe(fixture: fixture) } + } +} + +private struct NativeScrollProbe: View { + let fixture: NativeScrollFixture + @BScrollPosition private var position: ScrollPosition + + init(fixture: NativeScrollFixture) { + self.fixture = fixture + _position = BScrollPosition( + Self.self, + role: ScrollRole.first, + context: { fixture.context }, + idType: Int.self, + ) + } + + var body: some View { + ScrollView { + LazyVStack { + ForEach(0 ..< 30, id: \.self) { index in + Text(index.formatted()).frame(height: 80).id(index) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { + fixture.frames[index] = $0 + } + } + } + .scrollTargetLayout() + } + .scrollPosition($position) + .onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.bottom } action: { _, value in + fixture.bottomInset = value + } + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { + fixture.viewport = $0 + } + .onScrollTargetVisibilityChange(idType: Int.self) { fixture.visible = $0 } + .onChange(of: fixture.context, initial: true) { + fixture.position = $position + } + .task(id: Readiness(context: fixture.context, initial: fixture.initial)) { initialize() } + .onDisappear { + fixture.visible = [] + fixture.frames = [:] + } + } + + private struct Readiness: Equatable { + let context: Int + let initial: Int? + } + + private func initialize() { + if let initial = fixture.initial { _position.initialize( + id: initial, + anchor: fixture.initialAnchor, + ) } + fixture.observedInitial = fixture.initial + } +} diff --git a/Shared/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index 4d872c557..3c9dfb4bf 100644 --- a/Shared/SnapshotKit/AGENTS.md +++ b/Shared/SnapshotKit/AGENTS.md @@ -52,6 +52,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. - **Keep scene retention explicit.** Use `Frame.scenePolicy = .retained` only for subjects that require native scene-backed chrome. Preserve truthful traits, configured insets, and full-content sizing. This policy keeps identifiers unchanged; name variants explicitly if both policies appear in one matrix. Preview rendering does not guarantee retained-scene fidelity. - **Source Duo frames from native measurements.** Re-measure the presets when the runtime pin changes and update the provenance in `README.md`. Synthetic frames do not change native pose or bar arrangement. - **Create a separate case and model for each new adaptive configuration.** Readiness hooks must target that configuration's model. +- **Share synthetic pose geometry through `SnapshotConfiguration.DuoPose`.** Keep localized pose labels here and production geometry adapters in consumers. Preserve the existing frame identifiers when adding fold-region fixtures. - **Use two-axis full-content frames only for spatial canvases.** Choose `.iPhoneFullContent2D`, `.iPadFullContent2D`, or an explicit `.fullContent2D` when the production subject intentionally scrolls in both dimensions. - **Keep ordinary screen presets fixed-width.** Guard: `SnapshotConfigurationTests`. - **Measurement readiness never replaces capture readiness.** diff --git a/Shared/SnapshotKit/README.md b/Shared/SnapshotKit/README.md index 72922717b..b536e5262 100644 --- a/Shared/SnapshotKit/README.md +++ b/Shared/SnapshotKit/README.md @@ -226,6 +226,12 @@ Re-measurement on the native 27.2 Duo runtime remains pending; see [native relea Synthetic captures on the current runtime verify layout against these recorded profiles, not current native geometry. All four profiles use 3× scale and the phone idiom. +`SnapshotConfiguration.DuoPose` composes these frames into seven synthetic poses for +previews, captures, and hosted tests. It supplies typed reserved regions and their +activation state. Tabletop and book activate the same divisions as their flat +counterparts without changing frame identifiers. It also owns the localized pose labels. Consumers adapt the generic regions to +their production geometry seam. + | Frame | Bounds (points) | Horizontal / vertical size class | Insets (top, left, bottom, right) | | --- | --- | --- | --- | | `duoOuterPortrait` | 466 × 678 | compact / regular | 0, 0, 34, 84 | diff --git a/Shared/SnapshotKit/Sources/Resources/Localizable.xcstrings b/Shared/SnapshotKit/Sources/Resources/Localizable.xcstrings new file mode 100644 index 000000000..c35c2b38e --- /dev/null +++ b/Shared/SnapshotKit/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,83 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "duo.book" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Book" + } + } + } + }, + "duo.innerLandscape" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open · Landscape" + } + } + } + }, + "duo.innerPortrait" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open · Portrait" + } + } + } + }, + "duo.narrow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Narrow window" + } + } + } + }, + "duo.outerLandscape" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed · Landscape" + } + } + } + }, + "duo.outerPortrait" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed · Portrait" + } + } + } + }, + "duo.tabletop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabletop" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Shared/SnapshotKit/Sources/SnapshotDuoPose.swift b/Shared/SnapshotKit/Sources/SnapshotDuoPose.swift new file mode 100644 index 000000000..35cebfd39 --- /dev/null +++ b/Shared/SnapshotKit/Sources/SnapshotDuoPose.swift @@ -0,0 +1,79 @@ +import CoreGraphics +import Foundation + +extension SnapshotConfiguration { + /// Synthetic container geometry for adaptive previews, captures, and hosted tests. + /// These profiles do not change the simulator's native folding pose. + public enum DuoPose: String, CaseIterable, Identifiable, Sendable { + case outerPortrait + case outerLandscape + case innerPortrait + case innerLandscape + case tabletop + case book + case narrow + + public var title: String { + switch self { + case .outerPortrait: String(localized: .duoOuterPortrait) + case .outerLandscape: String(localized: .duoOuterLandscape) + case .innerPortrait: String(localized: .duoInnerPortrait) + case .innerLandscape: String(localized: .duoInnerLandscape) + case .tabletop: String(localized: .duoTabletop) + case .book: String(localized: .duoBook) + case .narrow: String(localized: .duoNarrow) + } + } + + public var id: Self { + self + } + + public struct ReservedRegion: Hashable, Sendable { + public enum Kind: Hashable, Sendable { + case division + case occlusion + } + + public let kind: Kind + public let frame: CGRect + public let isActive: Bool + } + + public var frame: Frame { + switch self { + case .outerPortrait: .duoOuterPortrait + case .outerLandscape: .duoOuterLandscape + case .innerPortrait, .tabletop: .duoInnerPortrait + case .innerLandscape, .book: .duoInnerLandscape + case .narrow: .narrowWindow + } + } + + public var size: CGSize { + switch frame.size { + case let .fixed(size): size + case .intrinsic, .fullContent, .fullContent2D: + preconditionFailure("Synthetic poses require bounded viewports") + } + } + + public var reservedRegions: [ReservedRegion] { + switch self { + case .innerPortrait, .tabletop: + [.init( + kind: .division, + frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), + isActive: self == .tabletop, + )] + case .innerLandscape, .book: + [.init( + kind: .division, + frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), + isActive: self == .book, + )] + case .outerPortrait, .outerLandscape, .narrow: [] + } + } + } +} diff --git a/Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift b/Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift new file mode 100644 index 000000000..9bf861754 --- /dev/null +++ b/Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift @@ -0,0 +1,34 @@ +import SnapshotKit +import Testing + +struct SnapshotDuoPoseTests { + @Test func foldsReuseFramesWithoutChangingCaptureIdentity() { + #expect(SnapshotConfiguration(device: SnapshotConfiguration.DuoPose.tabletop.frame) + .identifier == "duoInnerPortrait") + #expect(SnapshotConfiguration(device: SnapshotConfiguration.DuoPose.book.frame) + .identifier == "duoInnerLandscape") + #expect(SnapshotConfiguration.DuoPose.tabletop.size == SnapshotConfiguration.DuoPose + .innerPortrait + .size) + #expect(SnapshotConfiguration.DuoPose.book.size == SnapshotConfiguration.DuoPose + .innerLandscape + .size) + } + + @Test func foldingActivatesTheSameReservedDivision() throws { + for pair in [ + [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop], + [.innerLandscape, .book], + ] { + let flat = try #require(pair[0].reservedRegions.first) + let folded = try #require(pair[1].reservedRegions.first) + #expect(flat.frame == folded.frame) + #expect(!flat.isActive) + #expect(folded.isActive) + #expect(folded.kind == .division) + } + #expect(SnapshotConfiguration.DuoPose.outerPortrait.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.DuoPose.outerLandscape.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.DuoPose.narrow.reservedRegions.isEmpty) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 2e878a22c..0d2c31daa 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -160,6 +160,10 @@ Layering, localization, preview, and testing conventions live in the feature reveal stays covered when headless promotion coalesces or the first hold is interrupted, while warm resumes never replay it. - Measure calendar month sections, day rows, and Timeline rows eagerly so full-content measurement and scroll restoration use their actual heights. Guards: `CalendarContentViewSnapshotTests`, `PresenceTimelineListSnapshotTests`. +- Keep navigation paths, search, sheets, and drafts in stable owning views above adaptive branches. Keep active drill-in stacks mounted while folding; defer tabletop composition until returning. +- Read system reserved regions only in `AdaptivePaneGeometry`. Keep authored pane limits in the stylesheet and measured geometry in the adapter. Guard: `where.reserved_region_layout_ownership`. +- Keep explicit reveal requests in the owning view, with typed request identities distinct from content identities. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move another pane. +- Keep synthetic-region injection under `#if DEBUG` and expose it through the Testing SPI. Transition tests retain one mounted container. - Keep itinerary persistence, forecast bounds, overlaps, and Home projections in WhereCore. `LocationForecastModel` mirrors one planning snapshot through store-change refreshes. Complete the latest started read when its requesting sheet disappears; keep older results from replacing it. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 08a20bbed..8f08ec61b 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -699,3 +699,19 @@ also avoids blank trailing space from lazy estimates in its bottom-anchored scro Developer Overlay snapshots retain their scene for native glass rendering. Detached windows produce material bands and clipping in the menu. The retained-scene cases keep their device traits, content clearance, and full-content sizing. + +## Adaptive pane foundations + +Stable destination views own navigation and presentation state. BroadwayUI retains +scroll positions for the current context only. Changing region or year discards +the old position. Explicit reveal requests belong to their consuming views; +ordinary scrolling does not select or move another pane. Synthetic pose geometry +and localized labels come from `SnapshotConfiguration.DuoPose`. + +`AdaptivePaneGeometry` maps safe areas and reserved regions into physical +container coordinates. `AdaptivePaneArrangement` applies the stylesheet's pane +limits. `AdaptivePaneContainer` hosts compact, columns, and tabletop arrangements. +The DEBUG Testing SPI supplies synthetic reserved regions to the same adapter. + +This foundation does not replace the current destination containers. The dependent +app integration installs view-owned state and presents these arrangements. diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column.png new file mode 100644 index 000000000..17f6bfe72 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b14a33668d9b3852ed39fc892c78b41bd2cdfa841ad13a9dc08683bc6a667817 +size 1023245 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency.png new file mode 100644 index 000000000..c94141bb6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5cfa63ed5c94309b1bd0dccb682716ce15750b4563300d29094b6ccd6889ad5 +size 998232 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency_dark.png new file mode 100644 index 000000000..b4fbfe9fb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d66d45c566683fca03472326dfc93bf89f4050e723a50285144bef901d0334 +size 1077895 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_accessibility.png new file mode 100644 index 000000000..9a61af000 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8360cc26d6a2e4625b1742d44820fd9546fac561f2a8fd3b574609378c2f4a15 +size 776965 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_dark.png new file mode 100644 index 000000000..6b1f6e4dd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94d34b6edbe4f8390baa61a861a0599deb1f139217ce03d8a0ff49dd65668bb9 +size 1105699 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_ax5.png new file mode 100644 index 000000000..77d37bc74 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23a93c5440fcf92115a37d8ee7ce0dcba9a732883b438125becb0361ce43043e +size 1857443 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_contrast.png new file mode 100644 index 000000000..b48372b76 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8977a5aecf81cb3229d6bc82fc6aec7db88d1aeb91148f916f83fcc5f08e3898 +size 1155540 diff --git a/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotDuoPose+WhereUI.swift b/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotDuoPose+WhereUI.swift new file mode 100644 index 000000000..b810026b8 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotDuoPose+WhereUI.swift @@ -0,0 +1,20 @@ +#if DEBUG + import SnapshotKit + import SwiftUI + + extension SnapshotConfiguration.DuoPose { + var regions: [AdaptivePaneReservedRegion] { + reservedRegions.map { region in + let kind: AdaptivePaneReservedRegion.Kind = switch region.kind { + case .division: .division + case .occlusion: .occlusion + } + return .init( + kind: kind, + frame: region.frame, + isActive: region.isActive, + ) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Model/PresenceTimeline.swift b/Where/WhereUI/Sources/Model/PresenceTimeline.swift index 7940c2969..db6314947 100644 --- a/Where/WhereUI/Sources/Model/PresenceTimeline.swift +++ b/Where/WhereUI/Sources/Model/PresenceTimeline.swift @@ -7,13 +7,18 @@ import WhereCore /// two regions ends one stint and starts the next, so adjacent stints can /// share an endpoint date. public struct RegionStint: Hashable, Sendable, Identifiable { + public struct ID: Hashable, Sendable { + public let region: Region + public let start: Date + } + public let region: Region public let start: Date public let end: Date public let dayCount: Int - public var id: String { - "\(region.rawValue)@\(start.timeIntervalSinceReferenceDate)" + public var id: ID { + ID(region: region, start: start) } public init(region: Region, start: Date, end: Date, dayCount: Int) { diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 180c4a554..4b4769db9 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -613,7 +613,12 @@ private struct ArcText: View { extension RegionSummaryCard: SnapshotProviding { static var snapshots: [SnapshotCase] { - for variant in [WhereStylesheet.CardStyle.Variant.regular, .compact, .elsewhere] { + for variant in [ + WhereStylesheet.CardStyle.Variant.regular, + .compact, + .column, + .elsewhere, + ] { themedSnapshot(variant: variant, reduceTransparency: false) themedSnapshot(variant: variant, reduceTransparency: true) } @@ -627,6 +632,7 @@ private struct ArcText: View { let name = switch variant { case .regular: "Regular" case .compact: "Compact" + case .column: "Column" case .elsewhere: "Elsewhere" } return whereThemeSnapshot( diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index f8a44eeba..b14b8aac2 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -6,6 +6,39 @@ }, "%lld" : { + }, + "adaptive.month.next" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next month" + } + } + } + }, + "adaptive.month.picker" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Month" + } + } + } + }, + "adaptive.month.previous" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous month" + } + } + } }, "appIcon.appearance.dark" : { "extractionState" : "manual", @@ -2820,6 +2853,50 @@ } } }, + "developer.duo.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synthetic layouts using measured simulator bounds. System bars and physical folding still require native validation." + } + } + } + }, + "developer.duo.pose" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display configuration" + } + } + } + }, + "developer.duo.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + } + } + }, + "developer.duo.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duo Layout Lab" + } + } + } + }, "developer.enterInspectorNextLaunch" : { "comment" : "Button that schedules the standalone Inspector runtime for the next app launch.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift new file mode 100644 index 000000000..69f2ec5ad --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift @@ -0,0 +1,115 @@ +import CoreGraphics + +/// Chooses a layout from available space and active reserved regions, without device or angle +/// checks. +enum AdaptivePaneArrangement: Equatable { + struct Panes: Equatable { + var detail: CGRect + var controls: CGRect + } + + case compact + case columns + case tabletop(Panes) + + static func resolve( + geometry: AdaptivePaneGeometry, + style: WhereStylesheet.AdaptivePaneStyle, + ) -> Self { + guard style.panes == .multiple else { return .compact } + let content = geometry.contentBounds + guard content.width >= style.minimumDetailWidth, + content.height >= style.minimumPaneHeight + else { return .compact } + + let divisions = geometry.regions.filter { + $0.isActive && $0.kind == .division && $0.frame.intersects(content) + } + guard divisions.count <= 1 else { return .compact } + if let division = divisions.first(where: { $0.frame.width > $0.frame.height }) { + let gap = division.frame.intersection(content) + let detail = avoidingOcclusions( + CGRect( + x: content.minX, + y: content.minY, + width: content.width, + height: max(0, gap.minY - content.minY), + ), + geometry: geometry, + ) + let controls = avoidingOcclusions( + CGRect( + x: content.minX, + y: gap.maxY, + width: content.width, + height: max(0, content.maxY - gap.maxY), + ), + geometry: geometry, + ) + guard detail.height >= style.minimumPaneHeight, + controls.height >= style.minimumPaneHeight, + detail.width >= style.minimumDetailWidth, + controls.width >= style.minimumControlsWidth + else { return .compact } + return .tabletop(Panes(detail: detail, controls: controls)) + } + + if let division = divisions.first { + let gap = division.frame.intersection(content) + let controlsWidth = geometry.layoutDirection == .leftToRight + ? gap.minX - content.minX : content.maxX - gap.maxX + let detailWidth = geometry.layoutDirection == .leftToRight + ? content.maxX - gap.maxX : gap.minX - content.minX + guard controlsWidth >= style.minimumControlsWidth, + detailWidth >= style.minimumDetailWidth + else { return .compact } + } else { + guard content.width >= style.minimumControlsWidth + style.minimumDetailWidth + else { return .compact } + } + return .columns + } + + /// A rectangular pane uses the largest clear rectangle around an occlusion. + /// Edge occlusions already outside the content safe area do not reduce it again. + private static func avoidingOcclusions( + _ pane: CGRect, + geometry: AdaptivePaneGeometry, + ) -> CGRect { + geometry.regions.reduce(pane) { available, region in + guard region.isActive, region.kind == .occlusion, + available.intersects(region.frame) + else { return available } + let blocked = available.intersection(region.frame) + let above = CGRect( + x: available.minX, + y: available.minY, + width: available.width, + height: max(0, blocked.minY - available.minY), + ) + return [ + CGRect( + x: available.minX, + y: blocked.maxY, + width: available.width, + height: max(0, available.maxY - blocked.maxY), + ), + CGRect( + x: available.minX, + y: available.minY, + width: max(0, blocked.minX - available.minX), + height: available.height, + ), + CGRect( + x: blocked.maxX, + y: available.minY, + width: max(0, available.maxX - blocked.maxX), + height: available.height, + ), + ].reduce(above) { largest, candidate in + candidate.width * candidate.height > largest.width * largest.height + ? candidate : largest + } + } + } +} diff --git a/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift new file mode 100644 index 000000000..5b8aa1adf --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift @@ -0,0 +1,144 @@ +import SwiftUI + +/// Measures one bounded destination. Navigation and presentation owners stay outside it. +struct AdaptivePaneContainer: View { + @Environment(\.stylesheet) private var stylesheet + @ViewBuilder var content: (AdaptivePaneArrangement) -> Content + + var body: some View { + AdaptivePaneGeometryReader { geometry in + content(AdaptivePaneArrangement.resolve( + geometry: geometry, + style: stylesheet.adaptivePane, + )) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(.background) + } +} + +/// Shares physical container geometry with pane layout, the welcome overlay, and developer +/// overlays. +struct AdaptivePaneGeometryReader: View { + // Physical reserved-region coordinates require the live direction at this container. + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.adaptivePaneRegions) private var injectedRegions + @ViewBuilder var content: (AdaptivePaneGeometry) -> Content + + var body: some View { + GeometryReader { proxy in + content(geometry(in: proxy)) + } + } + + private func geometry(in proxy: GeometryProxy) -> AdaptivePaneGeometry { + var geometry = AdaptivePaneGeometry(proxy: proxy, layoutDirection: layoutDirection) + if let injectedRegions { + let origin = proxy.frame(in: .named(injectedRegions.coordinateSpace)).origin + geometry.regions = injectedRegions.regions.map { region in + AdaptivePaneGeometry.Region( + kind: region.kind, + frame: region.frame.offsetBy(dx: -origin.x, dy: -origin.y), + isActive: region.isActive, + ) + } + } + return geometry + } +} + +/// Detail precedes controls in both source and accessibility order. Frames are physical, +/// so RTL changes content direction without mirroring the hinge or camera clearance. +struct TabletopPaneLayout: View { + let panes: AdaptivePaneArrangement.Panes + @ViewBuilder var detail: () -> Detail + @ViewBuilder var controls: () -> Controls + + var body: some View { + ZStack(alignment: .topLeading) { + detail() + .frame(width: panes.detail.width, height: panes.detail.height) + .clipped() + .position(x: panes.detail.midX, y: panes.detail.midY) + controls() + .frame(width: panes.controls.width, height: panes.controls.height) + .clipped() + .position(x: panes.controls.midX, y: panes.controls.midY) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct AdaptivePaneRegions { + let regions: [AdaptivePaneGeometry.Region] + let coordinateSpace: Namespace.ID +} + +extension EnvironmentValues { + @Entry fileprivate var adaptivePaneRegions: AdaptivePaneRegions? +} + +#if DEBUG + /// Synthetic reserved regions for the adaptive lab and hosted rendering tests. + /// Bounds, safe areas, and traits still come from the mounted container. + @_spi(Testing) public struct AdaptivePaneReservedRegion { + public enum Kind { + case division + case occlusion + } + + let kind: Kind + let frame: CGRect + let isActive: Bool + + public init(kind: Kind, frame: CGRect, isActive: Bool) { + self.kind = kind + self.frame = frame + self.isActive = isActive + } + } + + private struct AdaptivePaneRegionsModifier: ViewModifier { + let regions: [AdaptivePaneReservedRegion] + @Namespace private var coordinateSpace + + func body(content: Content) -> some View { + content + .environment(\.adaptivePaneRegions, AdaptivePaneRegions( + regions: regions.map { region in + AdaptivePaneGeometry.Region( + kind: region.kind == .division ? .division : .occlusion, + frame: region.frame, + isActive: region.isActive, + ) + }, + coordinateSpace: coordinateSpace, + )) + .coordinateSpace(name: coordinateSpace) + } + } + + extension View { + /// Regions use coordinates local to the modified root. This only replaces system + /// region delivery; production layout policy, container bounds, and traits are shared. + @_spi(Testing) public func adaptivePaneReservedRegions( + _ regions: [AdaptivePaneReservedRegion], + ) -> some View { + modifier(AdaptivePaneRegionsModifier(regions: regions)) + } + } +#endif + +#if DEBUG + #Preview { + AdaptivePaneContainer { arrangement in + switch arrangement { + case .compact: Color.blue + case .columns: HStack { Color.blue; Color.green } + case let .tabletop(panes): + TabletopPaneLayout(panes: panes) { Color.blue } controls: { Color.green } + } + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift new file mode 100644 index 000000000..9df70f602 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift @@ -0,0 +1,127 @@ +import CoreGraphics +import SwiftUI + +/// Container geometry in one coordinate space. Insets belong to independent physical edges. +/// Reserved-region frames already include the system's margins. +struct AdaptivePaneGeometry: Equatable { + struct Insets: Equatable { + var top: CGFloat + var left: CGFloat + var bottom: CGFloat + var right: CGFloat + + static let zero = Self(top: 0, left: 0, bottom: 0, right: 0) + } + + struct Region: Equatable { + enum Kind: Equatable { + case division + case occlusion + } + + var kind: Kind + var frame: CGRect + var isActive: Bool + } + + var bounds: CGRect + var insets: Insets + var regions: [Region] + var layoutDirection: LayoutDirection + + var contentBounds: CGRect { + CGRect( + x: bounds.minX + insets.left, + y: bounds.minY + insets.top, + width: max(0, bounds.width - insets.left - insets.right), + height: max(0, bounds.height - insets.top - insets.bottom), + ) + } + + /// A custom floating surface occupies one clear rectangle nearest its preferred point. + /// Division margins and camera regions are removed before choosing that rectangle. + func unobstructedBounds(near point: CGPoint) -> CGRect { + var candidates = [contentBounds] + for region in regions where region.isActive { + candidates = candidates.flatMap { area -> [CGRect] in + guard area.intersects(region.frame) else { return [area] } + let gap = area.intersection(region.frame) + return [ + CGRect( + x: area.minX, + y: area.minY, + width: area.width, + height: gap.minY - area.minY, + ), + CGRect( + x: area.minX, + y: gap.maxY, + width: area.width, + height: area.maxY - gap.maxY, + ), + CGRect( + x: area.minX, + y: area.minY, + width: gap.minX - area.minX, + height: area.height, + ), + CGRect( + x: gap.maxX, + y: area.minY, + width: area.maxX - gap.maxX, + height: area.height, + ), + ].filter { $0.width > 0 && $0.height > 0 } + } + } + func distance(to area: CGRect) -> CGFloat { + let x = max(area.minX - point.x, 0, point.x - area.maxX) + let y = max(area.minY - point.y, 0, point.y - area.maxY) + return x * x + y * y + } + return candidates.min { left, right in + let leftDistance = distance(to: left) + let rightDistance = distance(to: right) + if leftDistance != rightDistance { return leftDistance < rightDistance } + return left.width * left.height > right.width * right.height + } ?? .zero + } + + /// GeometryReader proposes the safe content area. Expand it to the container's + /// bounds without subtracting its safe-area insets a second time. + init(proxy: GeometryProxy, layoutDirection: LayoutDirection) { + self.layoutDirection = layoutDirection + let safeArea = proxy.safeAreaInsets + insets = Insets( + top: safeArea.top, + left: layoutDirection == .leftToRight ? safeArea.leading : safeArea.trailing, + bottom: safeArea.bottom, + right: layoutDirection == .leftToRight ? safeArea.trailing : safeArea.leading, + ) + bounds = CGRect( + x: -insets.left, + y: -insets.top, + width: proxy.size.width + insets.left + insets.right, + height: proxy.size.height + insets.top + insets.bottom, + ) + regions = proxy.reservedRegions( + kind: .division, + options: .includeInactive, + layoutDirectionBehavior: .fixed, + ) + .map { Region(kind: .division, frame: $0.frame, isActive: $0.isActive) } + regions += proxy.reservedRegions( + kind: .occlusion, + options: .includeInactive, + layoutDirectionBehavior: .fixed, + ) + .map { Region(kind: .occlusion, frame: $0.frame, isActive: $0.isActive) } + } + + init(bounds: CGRect, insets: Insets, regions: [Region], layoutDirection: LayoutDirection) { + self.bounds = bounds + self.insets = insets + self.regions = regions + self.layoutDirection = layoutDirection + } +} diff --git a/Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift new file mode 100644 index 000000000..bbc14b29e --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift @@ -0,0 +1,27 @@ +import CoreGraphics +import SwiftUI + +extension WhereStylesheet { + /// Authored limits for two readable panes. Runtime geometry resolves the arrangement. + struct AdaptivePaneStyle: Equatable { + enum Panes: Equatable { + case single + case multiple + } + + var panes = Panes.single + /// Point widths needed for the month grid and location/settings controls. + var minimumDetailWidth: CGFloat = 320 + var minimumControlsWidth: CGFloat = 280 + /// Point height needed for independently scrollable detail and controls. + var minimumPaneHeight: CGFloat = 220 + + var selection = Selection() + + struct Selection: Equatable { + var cornerRadius: CGFloat = 8 + var borderWidth: CGFloat = 2 + var border = Color.accentColor + } + } +} diff --git a/Where/WhereUI/Sources/Shared/PlanningPresentation.swift b/Where/WhereUI/Sources/Shared/PlanningPresentation.swift new file mode 100644 index 000000000..ffc91d389 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PlanningPresentation.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Attach to a stable owner, above any container that changes during resizing. +struct PlanningPresentation: ViewModifier { + let report: YearReportModel + @Binding var destination: PlannedStaysDestination? + var isEnabled = true + + func body(content: Content) -> some View { + if isEnabled { + content.sheet(item: $destination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) + } + } else { + content + } + } +} diff --git a/Where/WhereUI/Sources/Shared/PresenceSelection.swift b/Where/WhereUI/Sources/Shared/PresenceSelection.swift new file mode 100644 index 000000000..512280632 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PresenceSelection.swift @@ -0,0 +1,26 @@ +import SwiftUI + +private struct PresenceSelection: ViewModifier { + let isSelected: Bool + let cornerRadius: CGFloat? + @Environment(\.stylesheet) private var stylesheet + + func body(content: Content) -> some View { + content + .overlay { + if isSelected { + let style = stylesheet.adaptivePane.selection + RoundedRectangle(cornerRadius: cornerRadius ?? style.cornerRadius) + .strokeBorder(style.border, lineWidth: style.borderWidth) + .allowsHitTesting(false) + } + } + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} + +extension View { + func presenceSelection(_ isSelected: Bool, cornerRadius: CGFloat? = nil) -> some View { + modifier(PresenceSelection(isSelected: isSelected, cornerRadius: cornerRadius)) + } +} diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 6f36e19cc..81f55b249 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -36,6 +36,7 @@ struct WhereStylesheet: BStylesheet { var themePicker = ThemePickerStyle() var yearModePicker = YearModePickerStyle() var loggedDay = LoggedDayStyle() + var adaptivePane = AdaptivePaneStyle() var settings = SettingsStyle.standard var featureDiscovery = FeatureDiscoveryStyle.standard var passportSeal = PassportSealStyle.standard @@ -53,6 +54,13 @@ struct WhereStylesheet: BStylesheet { let traits = context.traits theme = context.themes[WhereTheme.self] + if traits.horizontalSizeClass == .regular, + traits.verticalSizeClass == .regular, + !traits.contentSizeCategory.isAccessibilitySize + { + adaptivePane.panes = .multiple + } + developerOverlay.launcher.diameter = BScaledDimension.value( developerOverlay.launcher.diameter, relativeTo: .title2, @@ -783,6 +791,7 @@ extension WhereStylesheet { case regular case compact case elsewhere + case column } var cornerRadius: CGFloat @@ -1286,6 +1295,7 @@ extension WhereStylesheet { switch variant { case .regular: return regular case .compact: return compact + case .column: return column case .elsewhere: var card = compact card.paper = elsewherePaper @@ -1297,6 +1307,20 @@ extension WhereStylesheet { } } + /// Retains the Locations artwork while fitting a browse column. + private var column: CardStyle { + var style = regular + style.padding = compact.padding + style.contentSpacing = compact.contentSpacing + style.progressBarHeight = compact.progressBarHeight + style.entryStamp = compact.entryStamp + style.regionNameTypography = compact.regionNameTypography + style.regionNameTracking = compact.regionNameTracking + style.heroNumberTypography = compact.heroNumberTypography + style.dayUnitTypography = compact.dayUnitTypography + return style + } + /// The fixed card geometry, migrated from the former split /// `Shadow`/`Size` tokens and the card's inline `compact ? … : …` values. static let standard = CardStyles( @@ -1484,6 +1508,12 @@ extension WhereStylesheet { /// Style for one month section: the header/grid/footer stack, its /// current-month highlight, and the tally footer. struct MonthStyle: Equatable { + /// A tabletop month puts its totals beside the grid when both fit. + var minimumGridWidth: CGFloat = 320 + var summaryWidth: CGFloat = 180 + var summarySpacing: CGFloat = 16 + /// Leaves six weeks readable above a horizontal division without shrinking dates. + var tabletopGridSpacing: CGFloat = 4 /// Spacing between the month header, day grid, and footer. var sectionSpacing: CGFloat /// Spacing between day cells in the grid (both axes). diff --git a/Where/WhereUI/Tests/AdaptivePaneArrangementTests.swift b/Where/WhereUI/Tests/AdaptivePaneArrangementTests.swift new file mode 100644 index 000000000..0b62ee4ca --- /dev/null +++ b/Where/WhereUI/Tests/AdaptivePaneArrangementTests.swift @@ -0,0 +1,94 @@ +import CoreGraphics +import Testing +@testable import WhereUI + +struct AdaptivePaneArrangementTests { + private let style = AdaptivePaneTestSupport.multiplePaneStyle + + @Test func anActiveHorizontalDivisionSeparatesDetailAndControls() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.regions = [AdaptivePaneTestSupport.horizontalDivision] + let arrangement = AdaptivePaneArrangement.resolve(geometry: geometry, style: style) + guard case let .tabletop(panes) = arrangement else { + Issue.record("A usable horizontal division must choose tabletop.") + return + } + #expect(panes.detail == CGRect(x: 0, y: 82, width: 669, height: 373.5)) + #expect(panes.controls == CGRect(x: 0, y: 495.5, width: 669, height: 421.5)) + #expect(panes.controls.minY - panes.detail.maxY == 40) + } + + @Test func aFlatDisplayIgnoresItsInactiveDivision() { + var geometry = AdaptivePaneTestSupport.portrait + var division = AdaptivePaneTestSupport.horizontalDivision + division.isActive = false + geometry.regions = [division] + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .columns) + } + + @Test func verticalDivisionsRetainBrowseAndDetail() { + let geometry = AdaptivePaneGeometry( + bounds: CGRect(x: 0, y: 0, width: 951, height: 669), + insets: .init(top: 0, left: 0, bottom: 34, right: 84), + regions: [.init( + kind: .division, + frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), + isActive: true, + )], + layoutDirection: .leftToRight, + ) + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .columns) + } + + @Test func insufficientSpaceAndSinglePanePolicyCollapse() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.bounds.size.width = style.minimumControlsWidth + style.minimumDetailWidth - 1 + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .compact) + geometry = AdaptivePaneTestSupport.portrait + geometry.regions = [AdaptivePaneTestSupport.horizontalDivision] + var single = style + single.panes = .single + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: single) == .compact) + geometry.regions[0].frame.origin.y = 200 + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test func sideBarsParticipateInTheFitDecision() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.insets.left = 40 + geometry.insets.right = 84 + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test func rightToLeftPlacesTheDetailWidthOnTheOppositeSide() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.bounds.size.width = 780 + geometry.regions = [.init( + kind: .division, + frame: CGRect(x: 300, y: 0, width: 40, height: 951), + isActive: true, + )] + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .columns) + geometry.layoutDirection = .rightToLeft + #expect(AdaptivePaneArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test(arguments: [true, false]) + func tabletopAvoidsOnlyActiveOcclusions(isActive: Bool) { + var geometry = AdaptivePaneTestSupport.portrait + let camera = CGRect(x: 21, y: 210, width: 37, height: 58) + geometry.regions = [ + AdaptivePaneTestSupport.horizontalDivision, + .init(kind: .occlusion, frame: camera, isActive: isActive), + ] + guard case let .tabletop(panes) = AdaptivePaneArrangement.resolve( + geometry: geometry, + style: style, + ) else { + Issue.record("The camera leaves sufficient space for tabletop.") + return + } + #expect(panes.detail.intersection(camera).isEmpty == isActive) + #expect(panes.controls.maxY == geometry.contentBounds.maxY) + } +} diff --git a/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift new file mode 100644 index 000000000..2989c6606 --- /dev/null +++ b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift @@ -0,0 +1,49 @@ +import SnapshotKit +import SwiftUI +import TestHostSupport +import Testing +@_spi(Testing) @testable import WhereUI + +@MainActor +struct AdaptivePaneContainerTests { + @Test func oneMountedContainerRespondsToBoundsTraitsAndReservedRegions() async throws { + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + AdaptivePaneContainer { arrangement in + Color.clear.onChange(of: arrangement, initial: true) { _, value in + state.arrangement = value + } + } + }) + host.safeAreaRegions = [] + try await show(host) { controller in + for _ in 0 ..< 2 { + for pose in [ + SnapshotConfiguration.DuoPose.outerPortrait, + .innerPortrait, + .tabletop, + .book, + .narrow, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + let expected: AdaptivePaneArrangement = switch pose { + case .outerPortrait, .outerLandscape, .narrow: .compact + case .innerPortrait, .innerLandscape, .book: .columns + case .tabletop: .tabletop(.init( + detail: CGRect(x: 0, y: 0, width: 669, height: 455.5), + controls: CGRect(x: 0, y: 495.5, width: 669, height: 455.5), + )) + } + try await waitForAsync { + state.arrangement == expected + } + if case let .tabletop(panes) = state.arrangement { + #expect(panes.detail.maxY == 455.5) + #expect(panes.controls.minY == 495.5) + } + } + } + } + } +} diff --git a/Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift b/Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift new file mode 100644 index 000000000..ed3fad98b --- /dev/null +++ b/Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift @@ -0,0 +1,52 @@ +import CoreGraphics +import Testing +@testable import WhereUI + +struct AdaptivePaneGeometryTests { + @Test func independentInsetsRetainTheContainersCoordinateSpace() { + let geometry = AdaptivePaneGeometry( + bounds: CGRect(x: 20, y: 30, width: 800, height: 600), + insets: .init(top: 20, left: 50, bottom: 30, right: 80), + regions: [], + layoutDirection: .rightToLeft, + ) + #expect(geometry.contentBounds == CGRect(x: 70, y: 50, width: 670, height: 550)) + } + + @Test func insetsNeverProduceNegativePaneDimensions() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.bounds.size = CGSize(width: 20, height: 20) + geometry.insets.left = 30 + #expect(geometry.contentBounds.width == 0) + #expect(geometry.contentBounds.height == 0) + } + + @Test func floatingControlsChooseTheirSideOfAnActiveHinge() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.regions = [AdaptivePaneTestSupport.horizontalDivision] + let lower = geometry.unobstructedBounds(near: CGPoint(x: 669, y: 917)) + #expect(lower.minY == 495.5) + #expect(lower.maxY == 917) + #expect(!lower.intersects(geometry.regions[0].frame)) + let upper = geometry.unobstructedBounds(near: CGPoint(x: 0, y: 82)) + #expect(upper.minY == 82) + #expect(upper.maxY == 455.5) + } + + @Test func floatingControlsAvoidAnActiveCameraWithoutMirroringRTL() { + var geometry = AdaptivePaneTestSupport.portrait + geometry.layoutDirection = .rightToLeft + let camera = AdaptivePaneGeometry.Region( + kind: .occlusion, + frame: CGRect(x: 600, y: 820, width: 69, height: 97), + isActive: true, + ) + geometry.regions = [camera] + let clear = geometry.unobstructedBounds(near: CGPoint(x: 669, y: 917)) + #expect(!clear.intersects(camera.frame)) + #expect(geometry.contentBounds.contains(clear)) + geometry.regions[0].isActive = false + #expect(geometry.unobstructedBounds(near: CGPoint(x: 669, y: 917)) == geometry + .contentBounds) + } +} diff --git a/Where/WhereUI/Tests/AdaptivePaneStyleTests.swift b/Where/WhereUI/Tests/AdaptivePaneStyleTests.swift new file mode 100644 index 000000000..dd46a8760 --- /dev/null +++ b/Where/WhereUI/Tests/AdaptivePaneStyleTests.swift @@ -0,0 +1,16 @@ +import SwiftUI +import Testing +@testable import WhereUI + +struct AdaptivePaneStyleTests { + @Test func defaultsRequireEnoughRoomForReadableDetailAndControls() { + let style = WhereStylesheet.default.adaptivePane + #expect(style.panes == .single) + #expect(style.minimumDetailWidth == 320) + #expect(style.minimumControlsWidth == 280) + #expect(style.minimumPaneHeight == 220) + #expect(style.selection.cornerRadius == 8) + #expect(style.selection.borderWidth == 2) + #expect(style.selection.border == Color.accentColor) + } +} diff --git a/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift new file mode 100644 index 000000000..5e58b1dc9 --- /dev/null +++ b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift @@ -0,0 +1,95 @@ +import CoreGraphics +import Observation +import SnapshotKit +import SwiftUI +import TestHostSupport +@_spi(Testing) @testable import WhereUI + +enum AdaptivePaneTestSupport { + /// Native inner-portrait measurements; frames include reserved-region margins. + static var portrait: AdaptivePaneGeometry { + AdaptivePaneGeometry( + bounds: CGRect(x: 0, y: 0, width: 669, height: 951), + insets: .init(top: 82, left: 0, bottom: 34, right: 0), + regions: [], + layoutDirection: .leftToRight, + ) + } + + static var horizontalDivision: AdaptivePaneGeometry.Region { + .init( + kind: .division, + frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), + isActive: true, + ) + } + + static var multiplePaneStyle: WhereStylesheet.AdaptivePaneStyle { + var style = WhereStylesheet.AdaptivePaneStyle() + style.panes = .multiple + return style + } +} + +@MainActor @Observable +final class AdaptivePaneHostState { + var pose = SnapshotConfiguration.DuoPose.outerPortrait + var renderedSize: CGSize = .zero + var panes = WhereStylesheet.AdaptivePaneStyle.Panes.single + var arrangement: AdaptivePaneArrangement? +} + +struct AdaptivePaneHostedSurface: View { + let state: AdaptivePaneHostState + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .adaptivePaneReservedRegions(state.pose.regions) + .overlay { AdaptivePanePolicyProbe(state: state) } + .whereBroadwayRoot() + .onGeometryChange(for: CGSize.self) { $0.size } action: { state.renderedSize = $0 } + } +} + +private struct AdaptivePanePolicyProbe: View { + let state: AdaptivePaneHostState + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + Color.clear + .allowsHitTesting(false) + .onChange(of: stylesheet.adaptivePane.panes, initial: true) { _, panes in + state.panes = panes + } + } +} + +extension AdaptivePaneTestSupport { + @MainActor static func resize( + _ controller: UIViewController, + state: AdaptivePaneHostState, + to pose: SnapshotConfiguration.DuoPose, + ) async throws { + state.pose = pose + controller.view.frame = CGRect(origin: .zero, size: pose.size) + let isRegular = [.innerPortrait, .innerLandscape, .tabletop, .book].contains(pose) + controller.traitOverrides.horizontalSizeClass = isRegular ? .regular : .compact + controller.traitOverrides.verticalSizeClass = pose == .outerLandscape ? .compact : .regular + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + try await waitForAsync { + state.renderedSize == pose.size && state.panes == (isRegular ? .multiple : .single) + } + } + + @MainActor static func presentedController(from root: UIViewController) -> UIViewController? { + if let presented = root.presentedViewController { return presented } + return root.children.lazy.compactMap { presentedController(from: $0) }.first + } + + @MainActor static func descendants(of view: UIView, matching _: T.Type) -> [T] { + let matches = (view as? T).map { [$0] } ?? [] + return matches + view.subviews.flatMap { descendants(of: $0, matching: T.self) } + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 53b43d248..1f928fbee 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -31,10 +31,52 @@ struct WhereStylesheetTests { private let style = WhereStylesheet.default + @Test func browseColumnCardsRetainTheirArtworkWithCompactTypography() { + let column = style.card[.column] + #expect(column.regionShape == style.card.regular.regionShape) + #expect(column.sheen == style.card.regular.sheen) + #expect(column.regionNameTypography == style.card.compact.regionNameTypography) + #expect(column.entryStamp == style.card.compact.entryStamp) + } + @Test func yearModePickerDefaultsToInline() { #expect(style.yearModePicker.layout == .inline) } + @MainActor + @Test( + arguments: [UIUserInterfaceSizeClass.unspecified, .compact, .regular], + [UIUserInterfaceSizeClass.unspecified, .compact, .regular], + ) + func historyPanePolicyFollowsSystemSizeClasses( + horizontal: UIUserInterfaceSizeClass, + vertical: UIUserInterfaceSizeClass, + ) throws { + var context = BContext(traits: .system) + context.traitOverrides.horizontalSizeClass = BHorizontalSizeClass(horizontal) + context.traitOverrides.verticalSizeClass = BVerticalSizeClass(vertical) + context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.large) + let sheet = try context.stylesheets.get(WhereStylesheet.self) + let expected: WhereStylesheet.AdaptivePaneStyle.Panes = + horizontal == .regular && vertical == .regular ? .multiple : .single + #expect(sheet.adaptivePane.panes == expected) + } + + @MainActor + @Test func accessibilityCollapsesHistoryWithoutChangingItsMeasuredLimits() throws { + var context = BContext(traits: .system) + context.traitOverrides.horizontalSizeClass = .regular + context.traitOverrides.verticalSizeClass = .regular + context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.large) + let regular = try context.stylesheets.get(WhereStylesheet.self).adaptivePane + #expect(regular.panes == .multiple) + context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.accessibility1) + let accessible = try context.stylesheets.get(WhereStylesheet.self).adaptivePane + #expect(accessible.panes == .single) + #expect(accessible.minimumDetailWidth == regular.minimumDetailWidth) + #expect(accessible.minimumControlsWidth == regular.minimumControlsWidth) + } + @MainActor @Test(arguments: DynamicTypeSize.allCases) func resolvesComponentLayouts(size: DynamicTypeSize) throws { @@ -527,6 +569,10 @@ struct WhereStylesheetTests { let month = calendar.month #expect(month.sectionSpacing == 8) + #expect(month.minimumGridWidth == 320) + #expect(month.summaryWidth == 180) + #expect(month.summarySpacing == 16) + #expect(month.tabletopGridSpacing == 4) #expect(month.gridSpacing == 6) #expect(month.weekdayFontSize == 11) #expect(month.stacksFooter == false)