From e74d426e0d2a91f3eb68e43775badf738bff20ac Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 16 Oct 2026 07:05:30 -0700 Subject: [PATCH 01/11] Add adaptive history navigation and layout foundations --- .bumper/RULES.md | 14 ++ .bumper/Sources/WhereProjectRules.swift | 35 ++++ .bumper/Tests/WhereProjectRulesTests.swift | 43 +++++ Where/WhereUI/AGENTS.md | 5 + Where/WhereUI/README.md | 15 ++ .../AdaptiveHistory/AdaptiveHistoryPose.swift | 80 +++++++++ .../Model/CalendarNavigationModel.swift | 75 +++++++++ .../Model/HistoryNavigationModel.swift | 62 +++++++ .../Model/PlanningPresentationModel.swift | 7 + .../Sources/Model/PresenceTimeline.swift | 9 +- .../Sources/Model/SceneNavigationModel.swift | 100 ++++++++++++ .../Model/TimelineNavigationModel.swift | 118 ++++++++++++++ .../Sources/Resources/Localizable.xcstrings | 154 ++++++++++++++++++ .../Shared/AdaptiveHistoryArrangement.swift | 115 +++++++++++++ .../Shared/AdaptiveHistoryContainer.swift | 143 ++++++++++++++++ .../Shared/AdaptiveHistoryGeometry.swift | 127 +++++++++++++++ .../Sources/Shared/AdaptiveHistoryStyle.swift | 27 +++ .../Sources/Shared/HistorySelection.swift | 26 +++ .../Sources/Shared/PlanningPresentation.swift | 18 ++ .../Sources/Shared/WhereStylesheet.swift | 30 ++++ .../AdaptiveHistoryArrangementTests.swift | 94 +++++++++++ .../Tests/AdaptiveHistoryContainerTests.swift | 48 ++++++ .../Tests/AdaptiveHistoryGeometryTests.swift | 52 ++++++ .../Tests/AdaptiveHistoryStyleTests.swift | 16 ++ .../Tests/AdaptiveHistoryTestSupport.swift | 94 +++++++++++ .../Tests/CalendarNavigationModelTests.swift | 60 +++++++ .../Tests/HistoryNavigationModelTests.swift | 73 +++++++++ .../Tests/HistoryNavigationTestSupport.swift | 41 +++++ .../Tests/SceneNavigationModelTests.swift | 40 +++++ .../Tests/TimelineNavigationModelTests.swift | 145 +++++++++++++++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 46 ++++++ 31 files changed, 1910 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift create mode 100644 Where/WhereUI/Sources/Model/CalendarNavigationModel.swift create mode 100644 Where/WhereUI/Sources/Model/HistoryNavigationModel.swift create mode 100644 Where/WhereUI/Sources/Model/PlanningPresentationModel.swift create mode 100644 Where/WhereUI/Sources/Model/SceneNavigationModel.swift create mode 100644 Where/WhereUI/Sources/Model/TimelineNavigationModel.swift create mode 100644 Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift create mode 100644 Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift create mode 100644 Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift create mode 100644 Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift create mode 100644 Where/WhereUI/Sources/Shared/HistorySelection.swift create mode 100644 Where/WhereUI/Sources/Shared/PlanningPresentation.swift create mode 100644 Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift create mode 100644 Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift create mode 100644 Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift create mode 100644 Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift create mode 100644 Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift create mode 100644 Where/WhereUI/Tests/CalendarNavigationModelTests.swift create mode 100644 Where/WhereUI/Tests/HistoryNavigationModelTests.swift create mode 100644 Where/WhereUI/Tests/HistoryNavigationTestSupport.swift create mode 100644 Where/WhereUI/Tests/SceneNavigationModelTests.swift create mode 100644 Where/WhereUI/Tests/TimelineNavigationModelTests.swift diff --git a/.bumper/RULES.md b/.bumper/RULES.md index ce91963d5..f5af2c596 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -92,6 +92,20 @@ 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. +## Scene navigation ownership + +`where.scene_navigation_ownership` permits direct `SceneNavigationModel` construction in `MainTabs` and the DEBUG-only `AdaptiveHistoryLabModel`. +The root retains this model across layout changes and replaces it when the session changes. +Destination views receive focused child models. Isolated previews may construct child models. +Repair a violation by injecting the existing scene model or its child. +The syntax rule does not resolve type aliases, factories, or contextually inferred `.init()` calls. +`WhereProjectRulesTests` covers direct constructor mutations. The lab exception is path-scoped; release compilation must continue excluding that file's implementation. + +`where.reserved_region_layout_ownership` permits `reservedRegions` calls only in `AdaptiveHistoryGeometry` within WhereUI. +Views use that adapter or `AdaptiveHistoryContainer`. 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..cbc5cfacb 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -2,6 +2,15 @@ import BumperBowlingCore import SwiftSyntax let whereProjectRules = RuleSet { + Rules.constructionOwnership( + "SceneNavigationModel", + allowed: RuleScope.files([ + "Where/WhereUI/Sources/MainTabs.swift", + "Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryLabModel.swift", + ]) + .union(nonWhereProductionScope), + id: "where.scene_navigation_ownership", + ) Rules.constructionOwnership( "WhereServices", allowed: whereServicesConstructionScope.union(nonWhereProductionScope), @@ -18,6 +27,7 @@ let whereProjectRules = RuleSet { owner: whereLoggingScope.union(nonWhereProductionScope), id: "where.logging_type_ownership", ) + reservedRegionLayoutOwnershipRule productionStoreOpeningRule checkedConcurrencyBoundaryRule gregorianCalendarRule @@ -305,3 +315,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/AdaptiveHistoryGeometry.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 AdaptiveHistoryGeometry.", + evidence: ViolationEvidence( + observed: file.path.rawValue, + expectation: "use AdaptiveHistoryGeometry or AdaptiveHistoryContainer", + ), + ) + } +} diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index 4ab1edf3d..1eec1eb88 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -3,6 +3,49 @@ import BumperBowlingTestSupport import Testing struct WhereProjectRulesTests { + @Test + func `only the stable tab root constructs scene navigation`() throws { + let allowed = try evaluate( + path: "Where/WhereUI/Sources/MainTabs.swift", + component: .whereUI, + source: "let navigation = SceneNavigationModel()", + ) + let rejected = try evaluate( + path: "Where/WhereUI/Sources/Primary/LocationsView.swift", + component: .whereUI, + source: "let navigation = SceneNavigationModel()", + ) + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.map(\.rule.id) == ["where.scene_navigation_ownership"]) + } + + @Test + func `the DEBUG layout lab owns an independent demo scene`() throws { + let allowed = try evaluate( + path: "Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryLabModel.swift", + component: .whereUI, + source: "#if DEBUG\nlet navigation = SceneNavigationModel()\n#endif", + ) + #expect(allowed.violations.isEmpty) + } + + @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/AdaptiveHistoryGeometry.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/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 2e878a22c..96f9ea821 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -160,6 +160,11 @@ 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`. +- Construct `SceneNavigationModel` only at `MainTabs` or the DEBUG adaptive lab root. Destination views receive focused child state. Guard: `where.scene_navigation_ownership`. +- Read system reserved regions only in `AdaptiveHistoryGeometry`. Keep authored pane limits in the stylesheet and measured geometry in the adapter. Guard: `where.reserved_region_layout_ownership`. +- Link explicit month and stay selections through `HistoryNavigationModel`. Keep domain identities typed. Scroll observations retain local anchors without moving a companion pane. +- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `TimelineNavigationModelTests`. +- 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..3305eeb96 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -699,3 +699,18 @@ 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 history foundations + +`SceneNavigationModel` groups navigation by destination. `HistoryNavigationModel` +connects explicit month and stay selections. Calendar and timeline models retain +typed scroll anchors without coupling their scroll offsets. A consumed month reveal +keeps its request identity, so repeated selections remain observable. + +`AdaptiveHistoryGeometry` maps safe areas and reserved regions into physical +container coordinates. `AdaptiveHistoryArrangement` applies the stylesheet's pane +limits. `AdaptiveHistoryContainer` 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 scene ownership and presents these arrangements. diff --git a/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift b/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift new file mode 100644 index 000000000..fb135a284 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift @@ -0,0 +1,80 @@ +#if DEBUG + import SnapshotKit + import SwiftUI + + /// Named synthetic configurations from SnapshotKit's measured Duo profiles. + enum AdaptiveHistoryPose: String, CaseIterable, Identifiable { + case outerPortrait + case outerLandscape + case innerPortrait + case innerLandscape + case tabletop + case book + case narrow + + var id: Self { + self + } + + var title: String { + switch self { + case .outerPortrait: String(localized: .developerDuoOuterPortrait) + case .outerLandscape: String(localized: .developerDuoOuterLandscape) + case .innerPortrait: String(localized: .developerDuoInnerPortrait) + case .innerLandscape: String(localized: .developerDuoInnerLandscape) + case .tabletop: String(localized: .developerDuoTabletop) + case .book: String(localized: .developerDuoBook) + case .narrow: String(localized: .developerDuoNarrow) + } + } + + var frame: SnapshotConfiguration.Frame { + switch self { + case .outerPortrait: .duoOuterPortrait + case .outerLandscape: .duoOuterLandscape + case .innerPortrait, .tabletop: .duoInnerPortrait + case .innerLandscape, .book: .duoInnerLandscape + case .narrow: .narrowWindow + } + } + + var size: CGSize { + switch frame.size { + case let .fixed(size): size + case .intrinsic, .fullContent, .fullContent2D: + preconditionFailure("Adaptive lab profiles must be bounded viewports") + } + } + + var regions: [AdaptiveHistoryReservedRegion] { + switch self { + case .tabletop: + [.init( + kind: .division, + frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), + isActive: true, + )] + case .book: + [.init( + kind: .division, + frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), + isActive: true, + )] + case .innerPortrait: + [.init( + kind: .division, + frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), + isActive: false, + )] + case .innerLandscape: + [.init( + kind: .division, + frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), + isActive: false, + )] + case .outerPortrait, .outerLandscape, .narrow: + [] + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Model/CalendarNavigationModel.swift b/Where/WhereUI/Sources/Model/CalendarNavigationModel.swift new file mode 100644 index 000000000..caa1fdc8f --- /dev/null +++ b/Where/WhereUI/Sources/Model/CalendarNavigationModel.swift @@ -0,0 +1,75 @@ +import Observation +import SwiftUI +import WhereCore + +/// Retains the calendar's scroll anchor and requested planning presentation across remounts. +@MainActor @Observable +final class CalendarNavigationModel { + private enum Anchor { + case pending + case initial(CalendarDay) + case browsed(CalendarDay) + + var month: CalendarDay? { + switch self { + case .pending: nil + case let .initial(month), let .browsed(month): month + } + } + } + + var scrollPosition = ScrollPosition(idType: CalendarDay.self) + let planning: PlanningPresentationModel + var planningDestination: PlannedStaysDestination? { + get { planning.destination } + set { planning.destination = newValue } + } + + private var anchor: Anchor = .pending + + init(planning: PlanningPresentationModel? = nil) { + self.planning = planning ?? PlanningPresentationModel() + } + + var displayedMonth: CalendarDay? { + anchor.month + } + + func reveal(month day: CalendarDay) { + let month = CalendarDay(year: day.year, month: day.month, day: 1) + anchor = .browsed(month) + scrollPosition.scrollTo(id: month, anchor: .top) + } + + var initiallyPositionedYear: Int? { + anchor.month?.year + } + + var visibleMonth: CalendarDay? { + switch anchor { + case .pending, .initial: nil + case let .browsed(month): month + } + } + + /// A restored pane uses its last visible month. Only a new year uses the initial anchor. + func restorePosition(in year: Int, initialMonth: CalendarDay?) { + if initiallyPositionedYear == year { + if let month = anchor.month, month.year == year { + scrollPosition.scrollTo(id: month, anchor: .top) + } + return + } + guard let initialMonth, initialMonth.year == year else { return } + // The section can include a forecast after its month. Align the start so + // that extra content cannot move the requested month above the viewport. + scrollPosition.scrollTo(id: initialMonth, anchor: .top) + anchor = .initial(initialMonth) + } + + /// Observing scrolling does not select an interval or move another pane. + func recordVisibleMonths(_ months: [CalendarDay]) { + guard let month = months.min(), month.year == initiallyPositionedYear else { return } + anchor = .browsed(month) + } +} diff --git a/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift b/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift new file mode 100644 index 000000000..693459258 --- /dev/null +++ b/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Explicit selections connect history panes. Scroll observations never write this selection. +@MainActor @Observable +final class HistoryNavigationModel { + struct Selection: Equatable { + let interval: ClosedRange + let region: Region? + } + + let planning = PlanningPresentationModel() + let calendar: CalendarNavigationModel + let timeline = TimelineNavigationModel() + private(set) var selection: Selection? + + init() { + calendar = CalendarNavigationModel(planning: planning) + } + + func select(month: CalendarDay, dateCalendar: Calendar) { + let start = CalendarDay(year: month.year, month: month.month, day: 1) + guard let interval = dateCalendar.dateInterval( + of: .month, + for: start.startOfDay(in: dateCalendar), + ) + else { return } + let end = CalendarDay(from: interval.end, in: dateCalendar).adding(days: -1) + selection = Selection(interval: start ... end, region: nil) + calendar.reveal(month: start) + timeline.reveal(month: start) + } + + func select(stint: RegionStint, dateCalendar: Calendar) { + let start = CalendarDay(from: stint.start, in: dateCalendar) + let end = CalendarDay(from: stint.end, in: dateCalendar) + selection = Selection(interval: start ... end, region: stint.region) + calendar.reveal(month: start) + timeline.reveal(.recorded(stint.id)) + } + + func select(plannedItem: PlanningTimelineItem) { + selection = Selection( + interval: plannedItem.start ... plannedItem.end, + region: plannedItem.region, + ) + calendar.reveal(month: plannedItem.start) + timeline.reveal(.planned(plannedItem.id)) + } + + func selectedInterval(in year: Int) -> ClosedRange? { + guard let selection, + selection.interval.lowerBound.year <= year, + selection.interval.upperBound.year >= year + else { return nil } + let start = max(selection.interval.lowerBound, CalendarDay(year: year, month: 1, day: 1)) + let end = min(selection.interval.upperBound, .lastDay(ofYear: year)) + return start ... end + } +} diff --git a/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift b/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift new file mode 100644 index 000000000..fd92154a3 --- /dev/null +++ b/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift @@ -0,0 +1,7 @@ +import Observation + +/// One presentation owner for the calendar and timeline, retained above adaptive containers. +@MainActor @Observable +final class PlanningPresentationModel { + var destination: PlannedStaysDestination? +} 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/Model/SceneNavigationModel.swift b/Where/WhereUI/Sources/Model/SceneNavigationModel.swift new file mode 100644 index 000000000..9b3a27134 --- /dev/null +++ b/Where/WhereUI/Sources/Model/SceneNavigationModel.swift @@ -0,0 +1,100 @@ +import Observation +import RegionKit +import SwiftUI +import WhereCore + +/// Session-scoped presentation state. Resizing changes containers, never this owner or its +/// children. +@MainActor @Observable +final class SceneNavigationModel { + enum Tab: Hashable { + case locations + case year + case settings + } + + var selectedTab: Tab + let locations = Locations() + let year = Year() + let settings = Settings() + + init(selectedTab: Tab = .locations) { + self.selectedTab = selectedTab + } + + @MainActor @Observable + final class Locations { + enum Destination: Hashable { + case elsewhere + case flight(GPSCorrectionReview) + } + + var selectedRegion: Region? + var compactColumn = NavigationSplitViewColumn.sidebar + var browsePath = NavigationPath() + var detailPath = NavigationPath() + var scrollPosition = ScrollPosition(idType: Region.self) + var visibleRegion: Region? + let history = HistoryNavigationModel() + var calendar: CalendarNavigationModel { + history.calendar + } + + var showingResolution = false + var planningDestination: PlannedStaysDestination? { + get { history.planning.destination } + set { history.planning.destination = newValue } + } + + func select(region: Region) { + guard selectedRegion != region || compactColumn != .detail else { return } + if selectedRegion != region { detailPath = NavigationPath() } + selectedRegion = region + compactColumn = .detail + } + + func open(_ destination: Destination) { + browsePath.append(destination) + compactColumn = .sidebar + } + + func restoreBrowsePosition() { + guard let visibleRegion else { return } + scrollPosition.scrollTo(id: visibleRegion, anchor: .top) + } + } + + @MainActor @Observable + final class Year { + var mode: YearMode + let history = HistoryNavigationModel() + var calendar: CalendarNavigationModel { + history.calendar + } + + init(mode: YearMode = .calendar) { + self.mode = mode + } + } + + @MainActor @Observable + final class Settings { + // Settings, evidence, and feature-gallery routes share the same stack. + // NavigationPath retains each value's concrete Hashable identity. + var path = NavigationPath() + private var route: SettingsRoute? + var compactColumn = NavigationSplitViewColumn.sidebar + var selectedRoute: SettingsRoute? { + get { route } + set { + guard route != newValue else { return } + route = newValue + path = NavigationPath() + if newValue != nil { compactColumn = .detail } + } + } + + var searchText = "" + var showRegions = false + } +} diff --git a/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift b/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift new file mode 100644 index 000000000..9f6e20f8a --- /dev/null +++ b/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift @@ -0,0 +1,118 @@ +import Foundation +import Observation +import SwiftUI +import WhereCore + +/// Scroll observations retain a local anchor. Only explicit selection requests reveal a row. +@MainActor @Observable +final class TimelineNavigationModel { + /// Each explicit selection has its own identity. Consuming it keeps that + /// identity stable, so the view does not observe its own response as a request. + struct MonthReveal: Identifiable { + struct ID: Hashable { + private let value = UUID() + } + + enum State { + case pending(CalendarDay) + case consumed + } + + let id = ID() + var state: State + } + + enum Item: Hashable { + case recorded(RegionStint.ID) + case flight(FlightAssessment.ID) + case planned(PlanningTimelineItem.ID) + + init(_ item: PresenceJourneyItem) { + self = switch item { + case let .stay(stint): .recorded(stint.id) + case let .flight(flight): .flight(flight.id) + } + } + } + + var scrollPosition = ScrollPosition(idType: Item.self) + private(set) var visibleItem: Item? + private var year: Int? + private var monthReveal: MonthReveal? + + var monthRevealID: MonthReveal.ID? { + monthReveal?.id + } + + var requestedMonth: CalendarDay? { + guard case let .pending(month) = monthReveal?.state else { return nil } + return month + } + + func restorePosition(in year: Int, initialItem: Item? = nil) { + if self.year != year { + let isChangingYear = self.year != nil + self.year = year + if isChangingYear { visibleItem = nil } + if requestedMonth?.year != year { monthReveal = nil } + if let visibleItem { + scrollPosition.scrollTo(id: visibleItem, anchor: .center) + } else if let initialItem { + visibleItem = initialItem + scrollPosition.scrollTo(id: initialItem, anchor: .center) + } else { + scrollPosition.scrollTo(edge: .bottom) + } + } else if let visibleItem { + scrollPosition.scrollTo(id: visibleItem, anchor: .top) + } + } + + func recordVisibleItems(_ items: [Item], in year: Int) { + guard self.year == year, let first = items.first else { return } + visibleItem = first + } + + func reveal(_ item: Item) { + monthReveal = nil + revealPosition(item) + } + + private func revealPosition(_ item: Item) { + visibleItem = item + // Leading-edge alignment keeps the selected row visible after resizing. + scrollPosition.scrollTo(id: item, anchor: .top) + } + + func reveal(month: CalendarDay) { + monthReveal = MonthReveal(state: .pending(month)) + } + + /// A month selected while data is loading is resolved when its rows arrive. + func reconcile( + recordedItems: [PresenceJourneyItem], + plannedItems: [PlanningTimelineItem], + calendar: Calendar, + ) { + guard let month = requestedMonth else { return } + let start = CalendarDay(year: month.year, month: month.month, day: 1) + guard let end = calendar.dateInterval(of: .month, for: start.startOfDay(in: calendar)) + .map({ CalendarDay(from: $0.end, in: calendar).adding(days: -1) }) + else { return } + if let item = recordedItems.first(where: { item in + switch item { + case let .stay(stint): + CalendarDay(from: stint.start, in: calendar) <= end + && CalendarDay(from: stint.end, in: calendar) >= start + case let .flight(flight): + flight.completedDays(in: calendar).contains { $0 >= start && $0 <= end } + } + }) { + monthReveal?.state = .consumed + revealPosition(Item(item)) + } else if let item = plannedItems.first(where: { $0.start <= end && $0.end >= start }) { + monthReveal?.state = .consumed + revealPosition(.planned(item.id)) + } + } +} diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index f8a44eeba..5b9e31ab8 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,127 @@ } } }, + "developer.duo.book" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Book" + } + } + } + }, + "developer.duo.innerLandscape" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open · Landscape" + } + } + } + }, + "developer.duo.innerPortrait" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open · Portrait" + } + } + } + }, + "developer.duo.narrow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Narrow window" + } + } + } + }, + "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.outerLandscape" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed · Landscape" + } + } + } + }, + "developer.duo.outerPortrait" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed · Portrait" + } + } + } + }, + "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.tabletop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabletop" + } + } + } + }, + "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/AdaptiveHistoryArrangement.swift b/Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift new file mode 100644 index 000000000..18c08c511 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift @@ -0,0 +1,115 @@ +import CoreGraphics + +/// Chooses a layout from available space and active reserved regions, without device or angle +/// checks. +enum AdaptiveHistoryArrangement: Equatable { + struct Panes: Equatable { + var history: CGRect + var controls: CGRect + } + + case compact + case columns + case tabletop(Panes) + + static func resolve( + geometry: AdaptiveHistoryGeometry, + style: WhereStylesheet.AdaptiveHistoryStyle, + ) -> Self { + guard style.panes == .multiple else { return .compact } + let content = geometry.contentBounds + guard content.width >= style.minimumHistoryWidth, + 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 history = 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 history.height >= style.minimumPaneHeight, + controls.height >= style.minimumPaneHeight, + history.width >= style.minimumHistoryWidth, + controls.width >= style.minimumControlsWidth + else { return .compact } + return .tabletop(Panes(history: history, 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 historyWidth = geometry.layoutDirection == .leftToRight + ? content.maxX - gap.maxX : gap.minX - content.minX + guard controlsWidth >= style.minimumControlsWidth, + historyWidth >= style.minimumHistoryWidth + else { return .compact } + } else { + guard content.width >= style.minimumControlsWidth + style.minimumHistoryWidth + 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: AdaptiveHistoryGeometry, + ) -> 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/AdaptiveHistoryContainer.swift b/Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift new file mode 100644 index 000000000..9731d2980 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// Measures one bounded destination. Navigation and presentation owners stay outside it. +struct AdaptiveHistoryContainer: View { + @Environment(\.stylesheet) private var stylesheet + @ViewBuilder var content: (AdaptiveHistoryArrangement) -> Content + + var body: some View { + AdaptiveHistoryGeometryReader { geometry in + content(AdaptiveHistoryArrangement.resolve( + geometry: geometry, + style: stylesheet.adaptiveHistory, + )) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(.background) + } +} + +/// Shares the native/injected coordinate mapping with custom chrome above the destinations. +struct AdaptiveHistoryGeometryReader: View { + // Physical reserved-region coordinates require the live direction at this container. + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.adaptiveHistoryRegions) private var injectedRegions + @ViewBuilder var content: (AdaptiveHistoryGeometry) -> Content + + var body: some View { + GeometryReader { proxy in + content(geometry(in: proxy)) + } + } + + private func geometry(in proxy: GeometryProxy) -> AdaptiveHistoryGeometry { + var geometry = AdaptiveHistoryGeometry(proxy: proxy, layoutDirection: layoutDirection) + if let injectedRegions { + let origin = proxy.frame(in: .named(injectedRegions.coordinateSpace)).origin + geometry.regions = injectedRegions.regions.map { region in + AdaptiveHistoryGeometry.Region( + kind: region.kind, + frame: region.frame.offsetBy(dx: -origin.x, dy: -origin.y), + isActive: region.isActive, + ) + } + } + return geometry + } +} + +/// History precedes controls in both source and accessibility order. Frames are physical, +/// so RTL changes content direction without mirroring the hinge or camera clearance. +struct TabletopHistoryLayout: View { + let panes: AdaptiveHistoryArrangement.Panes + @ViewBuilder var history: () -> History + @ViewBuilder var controls: () -> Controls + + var body: some View { + ZStack(alignment: .topLeading) { + history() + .frame(width: panes.history.width, height: panes.history.height) + .clipped() + .position(x: panes.history.midX, y: panes.history.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 AdaptiveHistoryRegions { + let regions: [AdaptiveHistoryGeometry.Region] + let coordinateSpace: Namespace.ID +} + +extension EnvironmentValues { + @Entry fileprivate var adaptiveHistoryRegions: AdaptiveHistoryRegions? +} + +#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 AdaptiveHistoryReservedRegion { + 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 AdaptiveHistoryRegionsModifier: ViewModifier { + let regions: [AdaptiveHistoryReservedRegion] + @Namespace private var coordinateSpace + + func body(content: Content) -> some View { + content + .environment(\.adaptiveHistoryRegions, AdaptiveHistoryRegions( + regions: regions.map { region in + AdaptiveHistoryGeometry.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 adaptiveHistoryReservedRegions( + _ regions: [AdaptiveHistoryReservedRegion], + ) -> some View { + modifier(AdaptiveHistoryRegionsModifier(regions: regions)) + } + } +#endif + +#if DEBUG + #Preview { + AdaptiveHistoryContainer { arrangement in + switch arrangement { + case .compact: Color.blue + case .columns: HStack { Color.blue; Color.green } + case let .tabletop(panes): + TabletopHistoryLayout(panes: panes) { Color.blue } controls: { Color.green } + } + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift b/Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift new file mode 100644 index 000000000..a3b67f4a5 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.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 AdaptiveHistoryGeometry: 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/AdaptiveHistoryStyle.swift b/Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift new file mode 100644 index 000000000..1f6b05698 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift @@ -0,0 +1,27 @@ +import CoreGraphics +import SwiftUI + +extension WhereStylesheet { + /// Authored limits for two readable panes. Runtime geometry resolves the arrangement. + struct AdaptiveHistoryStyle: Equatable { + enum Panes: Equatable { + case single + case multiple + } + + var panes = Panes.single + /// Point widths needed for the month grid and location/settings controls. + var minimumHistoryWidth: CGFloat = 320 + var minimumControlsWidth: CGFloat = 280 + /// Point height needed for independently scrollable history 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/HistorySelection.swift b/Where/WhereUI/Sources/Shared/HistorySelection.swift new file mode 100644 index 000000000..bdf2a7046 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/HistorySelection.swift @@ -0,0 +1,26 @@ +import SwiftUI + +private struct HistorySelection: 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.adaptiveHistory.selection + RoundedRectangle(cornerRadius: cornerRadius ?? style.cornerRadius) + .strokeBorder(style.border, lineWidth: style.borderWidth) + .allowsHitTesting(false) + } + } + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} + +extension View { + func historySelection(_ isSelected: Bool, cornerRadius: CGFloat? = nil) -> some View { + modifier(HistorySelection(isSelected: isSelected, cornerRadius: cornerRadius)) + } +} diff --git a/Where/WhereUI/Sources/Shared/PlanningPresentation.swift b/Where/WhereUI/Sources/Shared/PlanningPresentation.swift new file mode 100644 index 000000000..42f6a5260 --- /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 + @Bindable var navigation: PlanningPresentationModel + var isEnabled = true + + func body(content: Content) -> some View { + if isEnabled { + content.sheet(item: $navigation.destination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) + } + } else { + content + } + } +} diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 6f36e19cc..609865cb9 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 adaptiveHistory = AdaptiveHistoryStyle() 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 + { + adaptiveHistory.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/AdaptiveHistoryArrangementTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift new file mode 100644 index 000000000..bd42dc86d --- /dev/null +++ b/Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift @@ -0,0 +1,94 @@ +import CoreGraphics +import Testing +@testable import WhereUI + +struct AdaptiveHistoryArrangementTests { + private let style = AdaptiveHistoryTestSupport.multiplePaneStyle + + @Test func anActiveHorizontalDivisionSeparatesHistoryAndControls() { + var geometry = AdaptiveHistoryTestSupport.portrait + geometry.regions = [AdaptiveHistoryTestSupport.horizontalDivision] + let arrangement = AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) + guard case let .tabletop(panes) = arrangement else { + Issue.record("A usable horizontal division must choose tabletop.") + return + } + #expect(panes.history == 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.history.maxY == 40) + } + + @Test func aFlatDisplayIgnoresItsInactiveDivision() { + var geometry = AdaptiveHistoryTestSupport.portrait + var division = AdaptiveHistoryTestSupport.horizontalDivision + division.isActive = false + geometry.regions = [division] + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) + } + + @Test func verticalDivisionsRetainBrowseAndDetail() { + let geometry = AdaptiveHistoryGeometry( + 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(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) + } + + @Test func insufficientSpaceAndSinglePanePolicyCollapse() { + var geometry = AdaptiveHistoryTestSupport.portrait + geometry.bounds.size.width = style.minimumControlsWidth + style.minimumHistoryWidth - 1 + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) + geometry = AdaptiveHistoryTestSupport.portrait + geometry.regions = [AdaptiveHistoryTestSupport.horizontalDivision] + var single = style + single.panes = .single + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: single) == .compact) + geometry.regions[0].frame.origin.y = 200 + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test func sideBarsParticipateInTheFitDecision() { + var geometry = AdaptiveHistoryTestSupport.portrait + geometry.insets.left = 40 + geometry.insets.right = 84 + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test func rightToLeftPlacesTheHistoryWidthOnTheOppositeSide() { + var geometry = AdaptiveHistoryTestSupport.portrait + geometry.bounds.size.width = 780 + geometry.regions = [.init( + kind: .division, + frame: CGRect(x: 300, y: 0, width: 40, height: 951), + isActive: true, + )] + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) + geometry.layoutDirection = .rightToLeft + #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) + } + + @Test(arguments: [true, false]) + func tabletopAvoidsOnlyActiveOcclusions(isActive: Bool) { + var geometry = AdaptiveHistoryTestSupport.portrait + let camera = CGRect(x: 21, y: 210, width: 37, height: 58) + geometry.regions = [ + AdaptiveHistoryTestSupport.horizontalDivision, + .init(kind: .occlusion, frame: camera, isActive: isActive), + ] + guard case let .tabletop(panes) = AdaptiveHistoryArrangement.resolve( + geometry: geometry, + style: style, + ) else { + Issue.record("The camera leaves sufficient space for tabletop.") + return + } + #expect(panes.history.intersection(camera).isEmpty == isActive) + #expect(panes.controls.maxY == geometry.contentBounds.maxY) + } +} diff --git a/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift new file mode 100644 index 000000000..bb0f1eb44 --- /dev/null +++ b/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import TestHostSupport +import Testing +@_spi(Testing) @testable import WhereUI + +@MainActor +struct AdaptiveHistoryContainerTests { + @Test func oneMountedContainerRespondsToBoundsTraitsAndReservedRegions() async throws { + let state = AdaptiveHistoryHostState() + let host = UIHostingController(rootView: AdaptiveHistoryHostedSurface(state: state) { + AdaptiveHistoryContainer { 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 [ + AdaptiveHistoryPose.outerPortrait, + .innerPortrait, + .tabletop, + .book, + .narrow, + .outerPortrait, + ] { + try await AdaptiveHistoryTestSupport.resize(controller, state: state, to: pose) + let expected: AdaptiveHistoryArrangement = switch pose { + case .outerPortrait, .outerLandscape, .narrow: .compact + case .innerPortrait, .innerLandscape, .book: .columns + case .tabletop: .tabletop(.init( + history: 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.history.maxY == 455.5) + #expect(panes.controls.minY == 495.5) + } + } + } + } + } +} diff --git a/Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift new file mode 100644 index 000000000..fb92859fa --- /dev/null +++ b/Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift @@ -0,0 +1,52 @@ +import CoreGraphics +import Testing +@testable import WhereUI + +struct AdaptiveHistoryGeometryTests { + @Test func independentInsetsRetainTheContainersCoordinateSpace() { + let geometry = AdaptiveHistoryGeometry( + 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 = AdaptiveHistoryTestSupport.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 = AdaptiveHistoryTestSupport.portrait + geometry.regions = [AdaptiveHistoryTestSupport.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 = AdaptiveHistoryTestSupport.portrait + geometry.layoutDirection = .rightToLeft + let camera = AdaptiveHistoryGeometry.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/AdaptiveHistoryStyleTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift new file mode 100644 index 000000000..d6d05eb83 --- /dev/null +++ b/Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift @@ -0,0 +1,16 @@ +import SwiftUI +import Testing +@testable import WhereUI + +struct AdaptiveHistoryStyleTests { + @Test func defaultsRequireEnoughRoomForReadableHistoryAndControls() { + let style = WhereStylesheet.default.adaptiveHistory + #expect(style.panes == .single) + #expect(style.minimumHistoryWidth == 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/AdaptiveHistoryTestSupport.swift b/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift new file mode 100644 index 000000000..cf2323c4a --- /dev/null +++ b/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift @@ -0,0 +1,94 @@ +import CoreGraphics +import Observation +import SwiftUI +import TestHostSupport +@_spi(Testing) @testable import WhereUI + +enum AdaptiveHistoryTestSupport { + /// Native inner-portrait measurements; frames include reserved-region margins. + static var portrait: AdaptiveHistoryGeometry { + AdaptiveHistoryGeometry( + 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: AdaptiveHistoryGeometry.Region { + .init( + kind: .division, + frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), + isActive: true, + ) + } + + static var multiplePaneStyle: WhereStylesheet.AdaptiveHistoryStyle { + var style = WhereStylesheet.AdaptiveHistoryStyle() + style.panes = .multiple + return style + } +} + +@MainActor @Observable +final class AdaptiveHistoryHostState { + var pose = AdaptiveHistoryPose.outerPortrait + var renderedSize: CGSize = .zero + var panes = WhereStylesheet.AdaptiveHistoryStyle.Panes.single + var arrangement: AdaptiveHistoryArrangement? +} + +struct AdaptiveHistoryHostedSurface: View { + let state: AdaptiveHistoryHostState + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .adaptiveHistoryReservedRegions(state.pose.regions) + .overlay { AdaptiveHistoryPolicyProbe(state: state) } + .whereBroadwayRoot() + .onGeometryChange(for: CGSize.self) { $0.size } action: { state.renderedSize = $0 } + } +} + +private struct AdaptiveHistoryPolicyProbe: View { + let state: AdaptiveHistoryHostState + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + Color.clear + .allowsHitTesting(false) + .onChange(of: stylesheet.adaptiveHistory.panes, initial: true) { _, panes in + state.panes = panes + } + } +} + +extension AdaptiveHistoryTestSupport { + @MainActor static func resize( + _ controller: UIViewController, + state: AdaptiveHistoryHostState, + to pose: AdaptiveHistoryPose, + ) 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/CalendarNavigationModelTests.swift b/Where/WhereUI/Tests/CalendarNavigationModelTests.swift new file mode 100644 index 000000000..502ffc871 --- /dev/null +++ b/Where/WhereUI/Tests/CalendarNavigationModelTests.swift @@ -0,0 +1,60 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct CalendarNavigationModelTests { + @Test func remountReissuesTheInitialMonthWhenNobodyHasBrowsedYet() { + let navigation = CalendarNavigationModel() + let month = CalendarDay(year: 2026, month: 7, day: 1) + navigation.restorePosition(in: 2026, initialMonth: month) + navigation.scrollPosition.scrollTo(edge: .top) + navigation.restorePosition(in: 2026, initialMonth: month) + #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == month) + } + + @Test func waitsForTheRequestedYearBeforePositioning() { + let navigation = CalendarNavigationModel() + navigation.restorePosition(in: 2026, initialMonth: nil) + navigation.restorePosition( + in: 2026, + initialMonth: CalendarDay(year: 2025, month: 12, day: 1), + ) + #expect(navigation.initiallyPositionedYear == nil) + let month = CalendarDay(year: 2026, month: 7, day: 1) + navigation.restorePosition(in: 2026, initialMonth: month) + #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == month) + } + + @Test func remountUsesVisibleMonthWithoutReturningToToday() { + let navigation = CalendarNavigationModel() + let today = CalendarDay(year: 2026, month: 7, day: 1) + let browsed = CalendarDay(year: 2026, month: 2, day: 1) + navigation.restorePosition(in: 2026, initialMonth: today) + navigation.recordVisibleMonths([browsed, today]) + navigation.restorePosition(in: 2026, initialMonth: today) + #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == browsed) + } + + @Test func staleVisibilityCannotOverrideANewYear() { + let navigation = CalendarNavigationModel() + let oldMonth = CalendarDay(year: 2025, month: 12, day: 1) + let newMonth = CalendarDay(year: 2026, month: 7, day: 1) + navigation.restorePosition(in: 2025, initialMonth: oldMonth) + navigation.recordVisibleMonths([oldMonth]) + navigation.restorePosition(in: 2026, initialMonth: newMonth) + navigation.recordVisibleMonths([oldMonth]) + #expect(navigation.visibleMonth == nil) + #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == newMonth) + } + + @Test func visibilityDoesNotCreateScrollRequests() { + let navigation = CalendarNavigationModel() + let initial = CalendarDay(year: 2026, month: 7, day: 1) + let browsed = CalendarDay(year: 2026, month: 2, day: 1) + navigation.restorePosition(in: 2026, initialMonth: initial) + navigation.recordVisibleMonths([browsed]) + #expect(navigation.visibleMonth == browsed) + #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == initial) + } +} diff --git a/Where/WhereUI/Tests/HistoryNavigationModelTests.swift b/Where/WhereUI/Tests/HistoryNavigationModelTests.swift new file mode 100644 index 000000000..e13fc7e6c --- /dev/null +++ b/Where/WhereUI/Tests/HistoryNavigationModelTests.swift @@ -0,0 +1,73 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct HistoryNavigationModelTests { + @Test func selectingAMonthRevealsTheFirstOverlappingStayWhenRowsArrive() { + let model = HistoryNavigationModel() + let month = CalendarDay(year: 2026, month: 3, day: 1) + model.select(month: month, dateCalendar: HistoryNavigationTestSupport.calendar) + model.timeline.restorePosition(in: 2026) + model.timeline.reconcile( + recordedItems: [], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + let stint = HistoryNavigationTestSupport.stint( + from: month.adding(days: -2), + through: month.adding(days: 4), + ) + model.timeline.reconcile( + recordedItems: [.stay(stint)], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == month) + #expect(model.timeline.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .recorded(stint.id)) + #expect(model.selection?.interval == month ... CalendarDay(year: 2026, month: 3, day: 31)) + } + + @Test func selectingAStayHighlightsItsIntervalAndRevealsItsFirstMonth() { + let model = HistoryNavigationModel() + let start = CalendarDay(year: 2026, month: 2, day: 27) + let end = CalendarDay(year: 2026, month: 3, day: 5) + let stint = HistoryNavigationTestSupport.stint(from: start, through: end) + model.select(stint: stint, dateCalendar: HistoryNavigationTestSupport.calendar) + #expect(model.selection?.interval == start ... end) + #expect(model.selection?.region == .california) + #expect(model.calendar.displayedMonth == CalendarDay(year: 2026, month: 2, day: 1)) + #expect(model.timeline.visibleItem == .recorded(stint.id)) + #expect(model.selectedInterval(in: 2025) == nil) + } + + @Test func localScrollObservationsDoNotChangeSelectionOrMoveTheCompanionPane() { + let model = HistoryNavigationModel() + let selected = CalendarDay(year: 2026, month: 3, day: 1) + let browsed = CalendarDay(year: 2026, month: 5, day: 1) + model.select(month: selected, dateCalendar: HistoryNavigationTestSupport.calendar) + let selection = model.selection + model.calendar.recordVisibleMonths([browsed]) + #expect(model.selection == selection) + #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == selected) + #expect(model.timeline.visibleItem == nil) + model.timeline.restorePosition(in: 2026) + let stint = HistoryNavigationTestSupport.stint( + from: browsed, + through: browsed.adding(days: 8), + ) + model.timeline.recordVisibleItems([.recorded(stint.id)], in: 2026) + #expect(model.selection == selection) + #expect(model.calendar.displayedMonth == browsed) + #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == selected) + } + + @Test func planningPresentationHasOneOwnerAcrossBothLenses() { + let model = HistoryNavigationModel() + model.calendar.planningDestination = .new(.california) + #expect(model.planning.destination == .new(.california)) + model.planning.destination = nil + #expect(model.calendar.planningDestination == nil) + } +} diff --git a/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift b/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift new file mode 100644 index 000000000..f7402ded9 --- /dev/null +++ b/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift @@ -0,0 +1,41 @@ +import Foundation +import RegionKit +import WhereCore +@testable import WhereUI + +enum HistoryNavigationTestSupport { + static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + return calendar + } + + static func stint( + from start: CalendarDay, + through end: CalendarDay, + region: Region = .california, + ) -> RegionStint { + RegionStint( + region: region, + start: start.startOfDay(in: calendar), + end: end.startOfDay(in: calendar), + dayCount: start.days(through: end).count, + ) + } + + static func flight(from start: CalendarDay, through end: CalendarDay) -> FlightAssessment { + let departure = start.startOfDay(in: calendar).addingTimeInterval(12 * 60 * 60) + let arrival = end.startOfDay(in: calendar).addingTimeInterval(14 * 60 * 60) + return FlightAssessment( + id: .init(recordingSource: .legacy, departureSampleID: UUID()), + startedAt: departure, + lastObservationAt: arrival, + lastFlightAt: arrival.addingTimeInterval(-60 * 60), + airborneSampleIDs: [], + groundSampleIDs: [], + peakSpeedKMH: 900, + progress: .completed(arrivedAt: arrival), + inferredEndpoints: [], + ) + } +} diff --git a/Where/WhereUI/Tests/SceneNavigationModelTests.swift b/Where/WhereUI/Tests/SceneNavigationModelTests.swift new file mode 100644 index 000000000..8530c8a7c --- /dev/null +++ b/Where/WhereUI/Tests/SceneNavigationModelTests.swift @@ -0,0 +1,40 @@ +import SwiftUI +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct SceneNavigationModelTests { + @Test func anUnselectedLocationsSceneKeepsItsNavigationEmpty() { + let navigation = SceneNavigationModel() + #expect(navigation.locations.selectedRegion == nil) + #expect(navigation.locations.browsePath.isEmpty) + #expect(navigation.locations.compactColumn == .sidebar) + } + + @Test func repeatedRegionSelectionPreservesHistoryAndDrillInState() { + let navigation = SceneNavigationModel.Locations() + navigation.select(region: .california) + let month = CalendarDay(year: 2026, month: 2, day: 1) + navigation.calendar.reveal(month: month) + navigation.detailPath.append(month) + navigation.select(region: .california) + #expect(navigation.detailPath.count == 1) + #expect(navigation.calendar.displayedMonth == month) + navigation.select(region: .newYork) + #expect(navigation.detailPath.isEmpty) + #expect(navigation.calendar.displayedMonth == month) + } + + @Test func settingsSelectionRetainsSearchAndResetsOnlyAnExplicitlyChangedDestination() { + let navigation = SceneNavigationModel.Settings() + navigation.searchText = "appearance" + navigation.selectedRoute = SettingsRoute(.appearance) + navigation.path.append(SettingsRoute(.about)) + navigation.selectedRoute = SettingsRoute(.appearance) + #expect(navigation.path.count == 1) + navigation.selectedRoute = SettingsRoute(.about) + #expect(navigation.path.isEmpty) + #expect(navigation.searchText == "appearance") + } +} diff --git a/Where/WhereUI/Tests/TimelineNavigationModelTests.swift b/Where/WhereUI/Tests/TimelineNavigationModelTests.swift new file mode 100644 index 000000000..e1efe554b --- /dev/null +++ b/Where/WhereUI/Tests/TimelineNavigationModelTests.swift @@ -0,0 +1,145 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct TimelineNavigationModelTests { + @Test func selectionBeforeTheFirstMountTakesPriorityOverTheInitialAnchor() { + let model = TimelineNavigationModel() + let day = CalendarDay(year: 2026, month: 2, day: 1) + let selected = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 3)) + let current = HistoryNavigationTestSupport.stint( + from: day.adding(days: 30), + through: day.adding(days: 40), + ) + model.reveal(.recorded(selected.id)) + model.restorePosition(in: 2026, initialItem: .recorded(current.id)) + #expect(model.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .recorded(selected.id)) + } + + @Test func linkedTimelineStartsAtCurrentHistoryAndThenRestoresBrowsing() { + let model = TimelineNavigationModel() + let day = CalendarDay(year: 2026, month: 7, day: 10) + let current = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 3)) + let earlier = HistoryNavigationTestSupport.stint( + from: day.adding(days: -20), + through: day.adding(days: -10), + ) + model.restorePosition(in: 2026, initialItem: .recorded(current.id)) + #expect(model.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .recorded(current.id)) + model.recordVisibleItems([.recorded(earlier.id)], in: 2026) + model.restorePosition(in: 2026, initialItem: .recorded(current.id)) + #expect(model.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .recorded(earlier.id)) + } + + @Test func remountRestoresTheLocalAnchorWithoutACompanionCommand() { + let model = TimelineNavigationModel() + let day = CalendarDay(year: 2026, month: 4, day: 1) + let stint = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 8)) + model.restorePosition(in: 2026) + model.recordVisibleItems([.recorded(stint.id)], in: 2026) + model.restorePosition(in: 2026) + #expect(model.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .recorded(stint.id)) + } + + @Test func aNewYearDiscardsAnOlderPendingMonthAndVisibilityCallback() { + let model = TimelineNavigationModel() + let month = CalendarDay(year: 2025, month: 12, day: 1) + let stint = HistoryNavigationTestSupport.stint(from: month, through: month.adding(days: 8)) + model.restorePosition(in: 2025) + model.reveal(month: month) + model.restorePosition(in: 2026) + model.recordVisibleItems([.recorded(stint.id)], in: 2025) + model.reconcile( + recordedItems: [.stay(stint)], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.visibleItem == nil) + #expect(model.scrollPosition.viewID(type: TimelineNavigationModel.Item.self) == nil) + } + + @Test func monthSelectionCanResolveAgainstAStayWhoseEndChanges() { + let model = TimelineNavigationModel() + let january = CalendarDay(year: 2026, month: 1, day: 20) + let february = CalendarDay(year: 2026, month: 2, day: 1) + model.restorePosition(in: 2026) + model.reveal(month: february) + let short = HistoryNavigationTestSupport.stint( + from: january, + through: january.adding(days: 1), + ) + model.reconcile( + recordedItems: [.stay(short)], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.visibleItem == nil) + let extended = HistoryNavigationTestSupport.stint( + from: january, + through: february.adding(days: 2), + ) + #expect(short.id == extended.id) + model.reconcile( + recordedItems: [.stay(extended)], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.visibleItem == .recorded(extended.id)) + } + + @Test func monthSelectionRevealsTheDisplayedSegmentAfterAFlight() throws { + let model = TimelineNavigationModel() + let january = CalendarDay(year: 2026, month: 1, day: 1) + let february = CalendarDay(year: 2026, month: 2, day: 1) + let stint = HistoryNavigationTestSupport.stint( + from: january, + through: february.adding(days: 9), + ) + let flight = HistoryNavigationTestSupport.flight( + from: february.adding(days: -1), + through: february.adding(days: -1), + ) + let items = PresenceJourneyItem.items( + stints: [stint], + flights: [flight], + year: 2026, + calendar: HistoryNavigationTestSupport.calendar, + ) + let displayed = try #require(items.last) + #expect(TimelineNavigationModel.Item(displayed) != .recorded(stint.id)) + model.restorePosition(in: 2026) + model.reveal(month: february) + model.reconcile( + recordedItems: items, + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.visibleItem == TimelineNavigationModel.Item(displayed)) + #expect(model.requestedMonth == nil) + } + + @Test func flightOnlyHistoryResolvesACrossMonthFlightAndRestoresItsAnchor() { + let model = TimelineNavigationModel() + let february = CalendarDay(year: 2026, month: 2, day: 1) + let flight = HistoryNavigationTestSupport.flight( + from: february.adding(days: -1), + through: february, + ) + model.restorePosition(in: 2026) + model.reveal(month: february) + model.reconcile( + recordedItems: [.flight(flight)], + plannedItems: [], + calendar: HistoryNavigationTestSupport.calendar, + ) + #expect(model.visibleItem == .flight(flight.id)) + model.restorePosition(in: 2026) + #expect(model.scrollPosition + .viewID(type: TimelineNavigationModel.Item.self) == .flight(flight.id)) + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 53b43d248..22f5fb5fa 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: [BHorizontalSizeClass.unspecified, .compact, .regular], + [BVerticalSizeClass.unspecified, .compact, .regular], + ) + func historyPanePolicyFollowsBothSizeClasses( + horizontal: BHorizontalSizeClass, + vertical: BVerticalSizeClass, + ) throws { + var context = BContext(traits: .system) + context.traitOverrides.horizontalSizeClass = horizontal + context.traitOverrides.verticalSizeClass = vertical + context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.large) + let sheet = try context.stylesheets.get(WhereStylesheet.self) + let expected: WhereStylesheet.AdaptiveHistoryStyle.Panes = + horizontal == .regular && vertical == .regular ? .multiple : .single + #expect(sheet.adaptiveHistory.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).adaptiveHistory + #expect(regular.panes == .multiple) + context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.accessibility1) + let accessible = try context.stylesheets.get(WhereStylesheet.self).adaptiveHistory + #expect(accessible.panes == .single) + #expect(accessible.minimumHistoryWidth == regular.minimumHistoryWidth) + #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) From 35817a31aecf475833b74f89e74feb49044a5173 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 30 Oct 2026 17:41:05 -0400 Subject: [PATCH 02/11] Integrate column cards and flight fixtures with main --- Where/WhereUI/Sources/Primary/RegionSummaryCard.swift | 8 +++++++- Where/WhereUI/Tests/HistoryNavigationTestSupport.swift | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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/Tests/HistoryNavigationTestSupport.swift b/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift index f7402ded9..c766e4f88 100644 --- a/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift +++ b/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift @@ -34,8 +34,12 @@ enum HistoryNavigationTestSupport { airborneSampleIDs: [], groundSampleIDs: [], peakSpeedKMH: 900, - progress: .completed(arrivedAt: arrival), + progress: .completed(arrival: .observedGround( + firstObservedAt: arrival, + confirmedAt: arrival, + )), inferredEndpoints: [], + supportedIntervals: [.init(start: departure, end: arrival, kind: .observedMotion)], ) } } From db0338483822227e2314421046379cc6792056dd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 30 Oct 2026 18:42:06 -0400 Subject: [PATCH 03/11] Add themed column-card snapshot references --- .../themes/componentThemes.Column.png | 3 +++ .../themes/componentThemes.ColumnReduceTransparency.png | 3 +++ .../themes/componentThemes.ColumnReduceTransparency_dark.png | 3 +++ .../themes/componentThemes.Column_accessibility.png | 3 +++ .../themes/componentThemes.Column_dark.png | 3 +++ .../themes/other/componentThemes.Column_ax5.png | 3 +++ .../themes/other/componentThemes.Column_contrast.png | 3 +++ 7 files changed, 21 insertions(+) create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.ColumnReduceTransparency_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/componentThemes.Column_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionSummaryCardSnapshotTests/themes/other/componentThemes.Column_contrast.png 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 From 68bd35eed90fca7a1dc909ddc65320a365860fa9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 11 Nov 2026 11:56:30 -0500 Subject: [PATCH 04/11] test(WhereUI): cover normalized system size classes Keep all nine UIKit size-class combinations in the pane-policy regression while mapping through the two-case Broadway API. Unspecified inputs must still select a single pane. Validation: SwiftFormat and diff checks pass. Full unit integration runs at the rebased stack tip before publication; the root Broadway suites passed 141 tests. --- Where/WhereUI/Tests/WhereStylesheetTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 22f5fb5fa..dd9c05002 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -45,16 +45,16 @@ struct WhereStylesheetTests { @MainActor @Test( - arguments: [BHorizontalSizeClass.unspecified, .compact, .regular], - [BVerticalSizeClass.unspecified, .compact, .regular], + arguments: [UIUserInterfaceSizeClass.unspecified, .compact, .regular], + [UIUserInterfaceSizeClass.unspecified, .compact, .regular], ) - func historyPanePolicyFollowsBothSizeClasses( - horizontal: BHorizontalSizeClass, - vertical: BVerticalSizeClass, + func historyPanePolicyFollowsSystemSizeClasses( + horizontal: UIUserInterfaceSizeClass, + vertical: UIUserInterfaceSizeClass, ) throws { var context = BContext(traits: .system) - context.traitOverrides.horizontalSizeClass = horizontal - context.traitOverrides.verticalSizeClass = vertical + 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.AdaptiveHistoryStyle.Panes = From cb30be57a38c878800ce48776eca29ca0f8f865a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 11 Nov 2026 16:31:25 -0500 Subject: [PATCH 05/11] Replace scene navigation groundwork with scoped scroll restoration Share synthetic pose geometry through SnapshotKit. Keep explicit history selection separate from view-owned presentation state. Add native hosted restoration, context replacement, delayed initialization, and scope isolation guards. --- .bumper/RULES.md | 9 +- .bumper/Sources/WhereProjectRules.swift | 9 - .bumper/Tests/WhereProjectRulesTests.swift | 26 --- Shared/Broadway/BroadwayUI/AGENTS.md | 4 + Shared/Broadway/BroadwayUI/README.md | 21 ++ .../BroadwayUI/Sources/BScrollPosition.swift | 79 +++++++ .../Sources/BScrollRestorationScope.swift | 21 ++ .../Sources/BScrollRestorationStore.swift | 46 ++++ .../Tests/BScrollPositionTests.swift | 221 ++++++++++++++++++ Shared/SnapshotKit/AGENTS.md | 1 + Shared/SnapshotKit/README.md | 6 + Shared/SnapshotKit/Sources/SnapshotPose.swift | 57 +++++ .../SnapshotKit/Tests/SnapshotPoseTests.swift | 32 +++ Where/WhereUI/AGENTS.md | 6 +- Where/WhereUI/README.md | 11 +- .../AdaptiveHistory/AdaptiveHistoryPose.swift | 80 ------- .../SnapshotPose+WhereUI.swift | 32 +++ .../Model/CalendarNavigationModel.swift | 75 ------ .../Model/HistoryNavigationModel.swift | 62 ----- .../Sources/Model/HistorySelectionModel.swift | 126 ++++++++++ .../Model/PlanningPresentationModel.swift | 7 - .../Sources/Model/SceneNavigationModel.swift | 100 -------- .../Model/TimelineNavigationModel.swift | 118 ---------- .../Sources/Shared/PlanningPresentation.swift | 4 +- .../Tests/AdaptiveHistoryContainerTests.swift | 3 +- .../Tests/AdaptiveHistoryTestSupport.swift | 5 +- .../Tests/CalendarNavigationModelTests.swift | 60 ----- .../Tests/HistoryNavigationModelTests.swift | 73 ------ .../Tests/HistorySelectionModelTests.swift | 113 +++++++++ .../Tests/SceneNavigationModelTests.swift | 40 ---- .../Tests/TimelineNavigationModelTests.swift | 145 ------------ 31 files changed, 776 insertions(+), 816 deletions(-) create mode 100644 Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BScrollRestorationScope.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift create mode 100644 Shared/SnapshotKit/Sources/SnapshotPose.swift create mode 100644 Shared/SnapshotKit/Tests/SnapshotPoseTests.swift delete mode 100644 Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift create mode 100644 Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift delete mode 100644 Where/WhereUI/Sources/Model/CalendarNavigationModel.swift delete mode 100644 Where/WhereUI/Sources/Model/HistoryNavigationModel.swift create mode 100644 Where/WhereUI/Sources/Model/HistorySelectionModel.swift delete mode 100644 Where/WhereUI/Sources/Model/PlanningPresentationModel.swift delete mode 100644 Where/WhereUI/Sources/Model/SceneNavigationModel.swift delete mode 100644 Where/WhereUI/Sources/Model/TimelineNavigationModel.swift delete mode 100644 Where/WhereUI/Tests/CalendarNavigationModelTests.swift delete mode 100644 Where/WhereUI/Tests/HistoryNavigationModelTests.swift create mode 100644 Where/WhereUI/Tests/HistorySelectionModelTests.swift delete mode 100644 Where/WhereUI/Tests/SceneNavigationModelTests.swift delete mode 100644 Where/WhereUI/Tests/TimelineNavigationModelTests.swift diff --git a/.bumper/RULES.md b/.bumper/RULES.md index f5af2c596..d69a6fbdb 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -92,14 +92,7 @@ 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. -## Scene navigation ownership - -`where.scene_navigation_ownership` permits direct `SceneNavigationModel` construction in `MainTabs` and the DEBUG-only `AdaptiveHistoryLabModel`. -The root retains this model across layout changes and replaces it when the session changes. -Destination views receive focused child models. Isolated previews may construct child models. -Repair a violation by injecting the existing scene model or its child. -The syntax rule does not resolve type aliases, factories, or contextually inferred `.init()` calls. -`WhereProjectRulesTests` covers direct constructor mutations. The lab exception is path-scoped; release compilation must continue excluding that file's implementation. +## Reserved-region layout ownership `where.reserved_region_layout_ownership` permits `reservedRegions` calls only in `AdaptiveHistoryGeometry` within WhereUI. Views use that adapter or `AdaptiveHistoryContainer`. Keep the system margins and physical coordinate mapping there. diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index cbc5cfacb..ec0ba9eac 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -2,15 +2,6 @@ import BumperBowlingCore import SwiftSyntax let whereProjectRules = RuleSet { - Rules.constructionOwnership( - "SceneNavigationModel", - allowed: RuleScope.files([ - "Where/WhereUI/Sources/MainTabs.swift", - "Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryLabModel.swift", - ]) - .union(nonWhereProductionScope), - id: "where.scene_navigation_ownership", - ) Rules.constructionOwnership( "WhereServices", allowed: whereServicesConstructionScope.union(nonWhereProductionScope), diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index 1eec1eb88..b604f7ce2 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -3,32 +3,6 @@ import BumperBowlingTestSupport import Testing struct WhereProjectRulesTests { - @Test - func `only the stable tab root constructs scene navigation`() throws { - let allowed = try evaluate( - path: "Where/WhereUI/Sources/MainTabs.swift", - component: .whereUI, - source: "let navigation = SceneNavigationModel()", - ) - let rejected = try evaluate( - path: "Where/WhereUI/Sources/Primary/LocationsView.swift", - component: .whereUI, - source: "let navigation = SceneNavigationModel()", - ) - #expect(allowed.violations.isEmpty) - #expect(rejected.violations.map(\.rule.id) == ["where.scene_navigation_ownership"]) - } - - @Test - func `the DEBUG layout lab owns an independent demo scene`() throws { - let allowed = try evaluate( - path: "Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryLabModel.swift", - component: .whereUI, - source: "#if DEBUG\nlet navigation = SceneNavigationModel()\n#endif", - ) - #expect(allowed.violations.isEmpty) - } - @Test func `system reserved regions use one geometry adapter`() throws { let source = "let regions = proxy.reservedRegions(kind: .division, options: .includeInactive, layoutDirectionBehavior: .fixed)" diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index e00675afa..71bd8a058 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -21,4 +21,8 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. ## Testing +- 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. +- Capture the resolved scroll entry in 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. + Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 500fa6e8d..1d4442f66 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -36,6 +36,27 @@ 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(ScrollPosition(id: target, anchor: .top))` after content +is ready. This applies the initial policy once per context, even if native layout +already reported a provisional first item. Later calls preserve the retained position. +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..00171bddf --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift @@ -0,0 +1,79 @@ +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: @MainActor DynamicProperty { + @Environment(\.bScrollRestorationStore) private var sharedStore + @State private var localStore = BScrollRestorationStore() + private let key: BScrollRestorationStore.Key + private let context: () -> AnyHashable + private var resolvedEntry: BScrollRestorationStore.Entry? + + public init( + _ view: (some View).Type, + role: some Hashable, + context: some Hashable, + idType: ID.Type, + ) { + key = .init( + view: ObjectIdentifier(view), + role: AnyHashable(role), + content: ObjectIdentifier(idType), + ) + self.context = { AnyHashable(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 () -> some Hashable, + idType: ID.Type, + ) { + key = .init( + view: ObjectIdentifier(view), + role: AnyHashable(role), + content: ObjectIdentifier(idType), + ) + self.context = { AnyHashable(context()) } + } + + public mutating func update() { + resolvedEntry = (sharedStore ?? localStore).entry( + for: key, + context: context(), + idType: ID.self, + ) + } + + private var entry: BScrollRestorationStore.Entry { + guard let resolvedEntry else { + preconditionFailure("BScrollPosition must be read from a mounted view") + } + return resolvedEntry + } + + public var wrappedValue: ScrollPosition { + get { entry.position } + nonmutating set { entry.position = newValue } + } + + public var projectedValue: Binding { + // Capture this render's entry. An obsolete task or outgoing view cannot + // redirect a write into a new context's entry. + let entry = entry + // Register the value with SwiftUI even when the consumer only reads the binding. + _ = entry.position + return Binding(get: { entry.position }, set: { entry.position = $0 }) + } + + /// Apply the view's initial policy once its content is ready, once per context. + /// Native layout can publish a provisional target before asynchronous data arrives. + public func initialize(_ initialPosition: @autoclosure () -> ScrollPosition) { + guard !entry.isInitialized else { return } + entry.position = initialPosition() + entry.isInitialized = true + } +} 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..c913ca8b1 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift @@ -0,0 +1,46 @@ +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) + var isInitialized = false + } + + 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..1d91b0acd --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift @@ -0,0 +1,221 @@ +import BroadwayUI +import Observation +import SwiftUI +import TestHostSupport +import Testing + +@MainActor +struct BScrollPositionTests { + @Test func nativeScrollViewRestoresItsVisibleTargetAfterReplacement() throws { + let fixture = NativeScrollFixture() + fixture.initial = nil + let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try show(host) { controller in + try waitFor { fixture.position != nil && fixture.visible.contains(0) } + fixture.position?.wrappedValue.scrollTo(id: 12, anchor: .top) + try waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + let before = fixture.position?.wrappedValue.viewID(type: Int.self) + fixture.mounted = false + try waitFor { fixture.visible.isEmpty } + fixture.mounted = true + controller.view.layoutIfNeeded() + try waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == before) + } + } + + @Test func readinessReplacesProvisionalNativePositionOnlyOncePerContext() throws { + let fixture = NativeScrollFixture() + fixture.initial = nil + let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try show(host) { _ in + try waitFor { fixture.visible.contains(0) } + fixture.initial = 12 + try waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + fixture.initial = 20 + try waitFor { fixture.observedInitial == 20 } + #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == 12) + fixture.context = 2 + try waitFor { fixture.visible.contains(20) && !fixture.visible.contains(12) } + } + } + + @Test func replacementRetainsPositionAndContextChangeDiscardsIt() throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture) + .bScrollRestorationScope()) + try show(host) { _ in + try waitFor { fixture.bindings[.first] != nil } + let old = try #require(fixture.bindings[.first]) + old.wrappedValue.scrollTo(id: 7, anchor: .top) + fixture.mounted = false + try waitFor { fixture.disappeared } + fixture.mounted = true + try waitFor { + !fixture.disappeared && fixture.bindings[.first]?.wrappedValue + .viewID(type: Int.self) == 7 + } + fixture.context = 2 + try waitFor { fixture.observedContext == 2 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + old.wrappedValue.scrollTo(id: 99, anchor: .bottom) + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + fixture.context = 1 + try waitFor { fixture.observedContext == 1 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + } + } + + @Test func rolesAndScopesRemainIndependent() throws { + let first = ScrollFixture() + let second = ScrollFixture() + let host = UIHostingController(rootView: VStack { + ScrollHarness(fixture: first).bScrollRestorationScope() + ScrollHarness(fixture: second).bScrollRestorationScope() + }) + try show(host) { _ in + try waitFor { 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() throws { + let fixture = ScrollFixture() + let host = UIHostingController(rootView: ScrollHarness(fixture: fixture)) + try show(host) { _ in + try waitFor { fixture.bindings[.first] != nil } + fixture.bindings[.first]?.wrappedValue.scrollTo(id: 4, anchor: .top) + fixture.redraw += 1 + try waitFor { fixture.observedRedraw == 1 } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == 4) + fixture.mounted = false + try waitFor { fixture.disappeared } + fixture.mounted = true + try waitFor { !fixture.disappeared } + #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) + } + } +} + +private enum ScrollRole: Hashable { case first, second } + +@MainActor @Observable +private final class ScrollFixture { + 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] = [:] +} + +private struct ScrollHarness: View { + let fixture: ScrollFixture + var body: some View { + if fixture.mounted { + ScrollProbe( + fixture: fixture, + role: .first, + 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) { + fixture.bindings[role] = $position + if role == .first { fixture.observedContext = context } + } + .onChange(of: redraw, initial: true) { fixture.observedRedraw = redraw } + .onAppear { fixture.disappeared = false } + .onDisappear { fixture.disappeared = true } + } +} + +@MainActor @Observable +private final class NativeScrollFixture { + var mounted = true + var context = 1 + var initial: Int? = 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) + } + } + .scrollTargetLayout() + } + .scrollPosition($position) + .onScrollTargetVisibilityChange(idType: Int.self) { fixture.visible = $0 } + .onChange(of: fixture.context, initial: true) { + fixture.position = $position + initialize() + } + .onChange(of: fixture.initial) { initialize() } + .onDisappear { fixture.visible = [] } + } + + private func initialize() { + if let initial = fixture.initial { _position.initialize(ScrollPosition( + id: initial, + anchor: .top, + )) } + fixture.observedInitial = fixture.initial + } +} diff --git a/Shared/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index 4d872c557..6aa41ad2f 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.Pose`.** Keep app labels 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..81ac16f28 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.Pose` 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. Consumers own localized labels +and 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/SnapshotPose.swift b/Shared/SnapshotKit/Sources/SnapshotPose.swift new file mode 100644 index 000000000..fa04317ba --- /dev/null +++ b/Shared/SnapshotKit/Sources/SnapshotPose.swift @@ -0,0 +1,57 @@ +import CoreGraphics + +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 Pose: String, CaseIterable, Identifiable, Sendable { + case outerPortrait, outerLandscape, innerPortrait, innerLandscape + case tabletop, book, narrow + + public var id: Self { + self + } + + public struct ReservedRegion: Hashable, Sendable { + public enum Kind: Hashable, Sendable { case division, 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/SnapshotPoseTests.swift b/Shared/SnapshotKit/Tests/SnapshotPoseTests.swift new file mode 100644 index 000000000..a7a67b304 --- /dev/null +++ b/Shared/SnapshotKit/Tests/SnapshotPoseTests.swift @@ -0,0 +1,32 @@ +import SnapshotKit +import Testing + +struct SnapshotPoseTests { + @Test func foldsReuseFramesWithoutChangingCaptureIdentity() { + #expect(SnapshotConfiguration(device: SnapshotConfiguration.Pose.tabletop.frame) + .identifier == "duoInnerPortrait") + #expect(SnapshotConfiguration(device: SnapshotConfiguration.Pose.book.frame) + .identifier == "duoInnerLandscape") + #expect(SnapshotConfiguration.Pose.tabletop.size == SnapshotConfiguration.Pose.innerPortrait + .size) + #expect(SnapshotConfiguration.Pose.book.size == SnapshotConfiguration.Pose.innerLandscape + .size) + } + + @Test func foldingActivatesTheSameReservedDivision() throws { + for pair in [ + [SnapshotConfiguration.Pose.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.Pose.outerPortrait.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.Pose.outerLandscape.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.Pose.narrow.reservedRegions.isEmpty) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 96f9ea821..a639fc3d9 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -160,10 +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`. -- Construct `SceneNavigationModel` only at `MainTabs` or the DEBUG adaptive lab root. Destination views receive focused child state. Guard: `where.scene_navigation_ownership`. +- 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 `AdaptiveHistoryGeometry`. Keep authored pane limits in the stylesheet and measured geometry in the adapter. Guard: `where.reserved_region_layout_ownership`. -- Link explicit month and stay selections through `HistoryNavigationModel`. Keep domain identities typed. Scroll observations retain local anchors without moving a companion pane. -- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `TimelineNavigationModelTests`. +- Link explicit month and stay selections through `HistorySelectionModel`. Keep domain identities typed. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move a companion pane. +- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `HistorySelectionModelTests`. - 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. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 3305eeb96..e9736a34d 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -702,10 +702,11 @@ cases keep their device traits, content clearance, and full-content sizing. ## Adaptive history foundations -`SceneNavigationModel` groups navigation by destination. `HistoryNavigationModel` -connects explicit month and stay selections. Calendar and timeline models retain -typed scroll anchors without coupling their scroll offsets. A consumed month reveal -keeps its request identity, so repeated selections remain observable. +Stable destination views own navigation and presentation state. `HistorySelectionModel` +connects explicit month and stay selections through requests consumed independently +by each pane. BroadwayUI retains scroll positions for the current context only. +Changing region or year discards the old position; ordinary scrolling never selects +or moves a companion pane. Synthetic pose geometry comes from `SnapshotConfiguration.Pose`. `AdaptiveHistoryGeometry` maps safe areas and reserved regions into physical container coordinates. `AdaptiveHistoryArrangement` applies the stylesheet's pane @@ -713,4 +714,4 @@ limits. `AdaptiveHistoryContainer` hosts compact, columns, and tabletop arrangem 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 scene ownership and presents these arrangements. +app integration installs view-owned state and presents these arrangements. diff --git a/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift b/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift deleted file mode 100644 index fb135a284..000000000 --- a/Where/WhereUI/Sources/Developer/AdaptiveHistory/AdaptiveHistoryPose.swift +++ /dev/null @@ -1,80 +0,0 @@ -#if DEBUG - import SnapshotKit - import SwiftUI - - /// Named synthetic configurations from SnapshotKit's measured Duo profiles. - enum AdaptiveHistoryPose: String, CaseIterable, Identifiable { - case outerPortrait - case outerLandscape - case innerPortrait - case innerLandscape - case tabletop - case book - case narrow - - var id: Self { - self - } - - var title: String { - switch self { - case .outerPortrait: String(localized: .developerDuoOuterPortrait) - case .outerLandscape: String(localized: .developerDuoOuterLandscape) - case .innerPortrait: String(localized: .developerDuoInnerPortrait) - case .innerLandscape: String(localized: .developerDuoInnerLandscape) - case .tabletop: String(localized: .developerDuoTabletop) - case .book: String(localized: .developerDuoBook) - case .narrow: String(localized: .developerDuoNarrow) - } - } - - var frame: SnapshotConfiguration.Frame { - switch self { - case .outerPortrait: .duoOuterPortrait - case .outerLandscape: .duoOuterLandscape - case .innerPortrait, .tabletop: .duoInnerPortrait - case .innerLandscape, .book: .duoInnerLandscape - case .narrow: .narrowWindow - } - } - - var size: CGSize { - switch frame.size { - case let .fixed(size): size - case .intrinsic, .fullContent, .fullContent2D: - preconditionFailure("Adaptive lab profiles must be bounded viewports") - } - } - - var regions: [AdaptiveHistoryReservedRegion] { - switch self { - case .tabletop: - [.init( - kind: .division, - frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), - isActive: true, - )] - case .book: - [.init( - kind: .division, - frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), - isActive: true, - )] - case .innerPortrait: - [.init( - kind: .division, - frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), - isActive: false, - )] - case .innerLandscape: - [.init( - kind: .division, - frame: CGRect(x: 455.5, y: 0, width: 40, height: 669), - isActive: false, - )] - case .outerPortrait, .outerLandscape, .narrow: - [] - } - } - } -#endif diff --git a/Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift b/Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift new file mode 100644 index 000000000..381aa1734 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift @@ -0,0 +1,32 @@ +#if DEBUG + import SnapshotKit + import SwiftUI + + extension SnapshotConfiguration.Pose { + var title: String { + switch self { + case .outerPortrait: String(localized: .developerDuoOuterPortrait) + case .outerLandscape: String(localized: .developerDuoOuterLandscape) + case .innerPortrait: String(localized: .developerDuoInnerPortrait) + case .innerLandscape: String(localized: .developerDuoInnerLandscape) + case .tabletop: String(localized: .developerDuoTabletop) + case .book: String(localized: .developerDuoBook) + case .narrow: String(localized: .developerDuoNarrow) + } + } + + var regions: [AdaptiveHistoryReservedRegion] { + reservedRegions.map { region in + let kind: AdaptiveHistoryReservedRegion.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/CalendarNavigationModel.swift b/Where/WhereUI/Sources/Model/CalendarNavigationModel.swift deleted file mode 100644 index caa1fdc8f..000000000 --- a/Where/WhereUI/Sources/Model/CalendarNavigationModel.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Observation -import SwiftUI -import WhereCore - -/// Retains the calendar's scroll anchor and requested planning presentation across remounts. -@MainActor @Observable -final class CalendarNavigationModel { - private enum Anchor { - case pending - case initial(CalendarDay) - case browsed(CalendarDay) - - var month: CalendarDay? { - switch self { - case .pending: nil - case let .initial(month), let .browsed(month): month - } - } - } - - var scrollPosition = ScrollPosition(idType: CalendarDay.self) - let planning: PlanningPresentationModel - var planningDestination: PlannedStaysDestination? { - get { planning.destination } - set { planning.destination = newValue } - } - - private var anchor: Anchor = .pending - - init(planning: PlanningPresentationModel? = nil) { - self.planning = planning ?? PlanningPresentationModel() - } - - var displayedMonth: CalendarDay? { - anchor.month - } - - func reveal(month day: CalendarDay) { - let month = CalendarDay(year: day.year, month: day.month, day: 1) - anchor = .browsed(month) - scrollPosition.scrollTo(id: month, anchor: .top) - } - - var initiallyPositionedYear: Int? { - anchor.month?.year - } - - var visibleMonth: CalendarDay? { - switch anchor { - case .pending, .initial: nil - case let .browsed(month): month - } - } - - /// A restored pane uses its last visible month. Only a new year uses the initial anchor. - func restorePosition(in year: Int, initialMonth: CalendarDay?) { - if initiallyPositionedYear == year { - if let month = anchor.month, month.year == year { - scrollPosition.scrollTo(id: month, anchor: .top) - } - return - } - guard let initialMonth, initialMonth.year == year else { return } - // The section can include a forecast after its month. Align the start so - // that extra content cannot move the requested month above the viewport. - scrollPosition.scrollTo(id: initialMonth, anchor: .top) - anchor = .initial(initialMonth) - } - - /// Observing scrolling does not select an interval or move another pane. - func recordVisibleMonths(_ months: [CalendarDay]) { - guard let month = months.min(), month.year == initiallyPositionedYear else { return } - anchor = .browsed(month) - } -} diff --git a/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift b/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift deleted file mode 100644 index 693459258..000000000 --- a/Where/WhereUI/Sources/Model/HistoryNavigationModel.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Observation -import RegionKit -import WhereCore - -/// Explicit selections connect history panes. Scroll observations never write this selection. -@MainActor @Observable -final class HistoryNavigationModel { - struct Selection: Equatable { - let interval: ClosedRange - let region: Region? - } - - let planning = PlanningPresentationModel() - let calendar: CalendarNavigationModel - let timeline = TimelineNavigationModel() - private(set) var selection: Selection? - - init() { - calendar = CalendarNavigationModel(planning: planning) - } - - func select(month: CalendarDay, dateCalendar: Calendar) { - let start = CalendarDay(year: month.year, month: month.month, day: 1) - guard let interval = dateCalendar.dateInterval( - of: .month, - for: start.startOfDay(in: dateCalendar), - ) - else { return } - let end = CalendarDay(from: interval.end, in: dateCalendar).adding(days: -1) - selection = Selection(interval: start ... end, region: nil) - calendar.reveal(month: start) - timeline.reveal(month: start) - } - - func select(stint: RegionStint, dateCalendar: Calendar) { - let start = CalendarDay(from: stint.start, in: dateCalendar) - let end = CalendarDay(from: stint.end, in: dateCalendar) - selection = Selection(interval: start ... end, region: stint.region) - calendar.reveal(month: start) - timeline.reveal(.recorded(stint.id)) - } - - func select(plannedItem: PlanningTimelineItem) { - selection = Selection( - interval: plannedItem.start ... plannedItem.end, - region: plannedItem.region, - ) - calendar.reveal(month: plannedItem.start) - timeline.reveal(.planned(plannedItem.id)) - } - - func selectedInterval(in year: Int) -> ClosedRange? { - guard let selection, - selection.interval.lowerBound.year <= year, - selection.interval.upperBound.year >= year - else { return nil } - let start = max(selection.interval.lowerBound, CalendarDay(year: year, month: 1, day: 1)) - let end = min(selection.interval.upperBound, .lastDay(ofYear: year)) - return start ... end - } -} diff --git a/Where/WhereUI/Sources/Model/HistorySelectionModel.swift b/Where/WhereUI/Sources/Model/HistorySelectionModel.swift new file mode 100644 index 000000000..0ec1a7dcb --- /dev/null +++ b/Where/WhereUI/Sources/Model/HistorySelectionModel.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Connects explicit selections to displayed history data. It owns no scroll or presentation state. +@MainActor @Observable +final class HistorySelectionModel { + enum Pane: Hashable { case calendar, timeline } + + struct Selection: Equatable { + let interval: ClosedRange + let region: Region? + } + + enum Item: Hashable { + case recorded(RegionStint.ID) + case flight(FlightAssessment.ID) + case planned(PlanningTimelineItem.ID) + + init(_ item: PresenceJourneyItem) { + self = switch item { + case let .stay(stint): .recorded(stint.id) + case let .flight(flight): .flight(flight.id) + } + } + } + + struct Request: Identifiable { + struct ID: Hashable { private let value = UUID() } + let id = ID() + let month: CalendarDay + let item: Item? + } + + private(set) var selection: Selection? + private var request: Request? + private var consumed: Set = [] + + var requestID: Request.ID? { + request?.id + } + + func pendingRequest(for pane: Pane) -> Request? { + consumed.contains(pane) ? nil : request + } + + func consume(_ requestID: Request.ID, by pane: Pane) { + guard request?.id == requestID else { return } + consumed.insert(pane) + } + + func reset() { + selection = nil + request = nil + consumed = [] + } + + private func select(_ selection: Selection, item: Item?) { + self.selection = selection + let start = selection.interval.lowerBound + request = Request( + month: CalendarDay(year: start.year, month: start.month, day: 1), + item: item, + ) + consumed = [] + } + + func select(month: CalendarDay, dateCalendar: Calendar) { + let start = CalendarDay(year: month.year, month: month.month, day: 1) + guard let interval = dateCalendar.dateInterval( + of: .month, + for: start.startOfDay(in: dateCalendar), + ) else { return } + let end = CalendarDay(from: interval.end, in: dateCalendar).adding(days: -1) + select(Selection(interval: start ... end, region: nil), item: nil) + } + + func select(stint: RegionStint, dateCalendar: Calendar) { + let start = CalendarDay(from: stint.start, in: dateCalendar) + let end = CalendarDay(from: stint.end, in: dateCalendar) + select(Selection(interval: start ... end, region: stint.region), item: .recorded(stint.id)) + } + + func select(plannedItem: PlanningTimelineItem) { + select( + Selection(interval: plannedItem.start ... plannedItem.end, region: plannedItem.region), + item: .planned(plannedItem.id), + ) + } + + func selectedInterval(in year: Int) -> ClosedRange? { + guard let selection, selection.interval.lowerBound.year <= year, + selection.interval.upperBound.year >= year else { return nil } + return max(selection.interval.lowerBound, CalendarDay(year: year, month: 1, day: 1)) + ... min(selection.interval.upperBound, .lastDay(ofYear: year)) + } + + /// Resolve against the rows actually displayed. An unresolved request remains pending while + /// loading. + func timelineTarget( + recordedItems: [PresenceJourneyItem], + plannedItems: [PlanningTimelineItem], + calendar: Calendar, + ) -> Item? { + guard let request = pendingRequest(for: .timeline) else { return nil } + if let item = request.item { + return recordedItems.contains(where: { Item($0) == item }) + || plannedItems.contains(where: { .planned($0.id) == item }) ? item : nil + } + let start = request.month + guard let interval = calendar.dateInterval(of: .month, for: start.startOfDay(in: calendar)) + else { return nil } + let end = CalendarDay(from: interval.end, in: calendar).adding(days: -1) + if let item = recordedItems.first(where: { item in + switch item { + case let .stay(stint): + CalendarDay(from: stint.start, in: calendar) <= end + && CalendarDay(from: stint.end, in: calendar) >= start + case let .flight(flight): + flight.completedDays(in: calendar).contains { $0 >= start && $0 <= end } + } + }) { return Item(item) } + return plannedItems.first { $0.start <= end && $0.end >= start }.map { .planned($0.id) } + } +} diff --git a/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift b/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift deleted file mode 100644 index fd92154a3..000000000 --- a/Where/WhereUI/Sources/Model/PlanningPresentationModel.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Observation - -/// One presentation owner for the calendar and timeline, retained above adaptive containers. -@MainActor @Observable -final class PlanningPresentationModel { - var destination: PlannedStaysDestination? -} diff --git a/Where/WhereUI/Sources/Model/SceneNavigationModel.swift b/Where/WhereUI/Sources/Model/SceneNavigationModel.swift deleted file mode 100644 index 9b3a27134..000000000 --- a/Where/WhereUI/Sources/Model/SceneNavigationModel.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Observation -import RegionKit -import SwiftUI -import WhereCore - -/// Session-scoped presentation state. Resizing changes containers, never this owner or its -/// children. -@MainActor @Observable -final class SceneNavigationModel { - enum Tab: Hashable { - case locations - case year - case settings - } - - var selectedTab: Tab - let locations = Locations() - let year = Year() - let settings = Settings() - - init(selectedTab: Tab = .locations) { - self.selectedTab = selectedTab - } - - @MainActor @Observable - final class Locations { - enum Destination: Hashable { - case elsewhere - case flight(GPSCorrectionReview) - } - - var selectedRegion: Region? - var compactColumn = NavigationSplitViewColumn.sidebar - var browsePath = NavigationPath() - var detailPath = NavigationPath() - var scrollPosition = ScrollPosition(idType: Region.self) - var visibleRegion: Region? - let history = HistoryNavigationModel() - var calendar: CalendarNavigationModel { - history.calendar - } - - var showingResolution = false - var planningDestination: PlannedStaysDestination? { - get { history.planning.destination } - set { history.planning.destination = newValue } - } - - func select(region: Region) { - guard selectedRegion != region || compactColumn != .detail else { return } - if selectedRegion != region { detailPath = NavigationPath() } - selectedRegion = region - compactColumn = .detail - } - - func open(_ destination: Destination) { - browsePath.append(destination) - compactColumn = .sidebar - } - - func restoreBrowsePosition() { - guard let visibleRegion else { return } - scrollPosition.scrollTo(id: visibleRegion, anchor: .top) - } - } - - @MainActor @Observable - final class Year { - var mode: YearMode - let history = HistoryNavigationModel() - var calendar: CalendarNavigationModel { - history.calendar - } - - init(mode: YearMode = .calendar) { - self.mode = mode - } - } - - @MainActor @Observable - final class Settings { - // Settings, evidence, and feature-gallery routes share the same stack. - // NavigationPath retains each value's concrete Hashable identity. - var path = NavigationPath() - private var route: SettingsRoute? - var compactColumn = NavigationSplitViewColumn.sidebar - var selectedRoute: SettingsRoute? { - get { route } - set { - guard route != newValue else { return } - route = newValue - path = NavigationPath() - if newValue != nil { compactColumn = .detail } - } - } - - var searchText = "" - var showRegions = false - } -} diff --git a/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift b/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift deleted file mode 100644 index 9f6e20f8a..000000000 --- a/Where/WhereUI/Sources/Model/TimelineNavigationModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import Observation -import SwiftUI -import WhereCore - -/// Scroll observations retain a local anchor. Only explicit selection requests reveal a row. -@MainActor @Observable -final class TimelineNavigationModel { - /// Each explicit selection has its own identity. Consuming it keeps that - /// identity stable, so the view does not observe its own response as a request. - struct MonthReveal: Identifiable { - struct ID: Hashable { - private let value = UUID() - } - - enum State { - case pending(CalendarDay) - case consumed - } - - let id = ID() - var state: State - } - - enum Item: Hashable { - case recorded(RegionStint.ID) - case flight(FlightAssessment.ID) - case planned(PlanningTimelineItem.ID) - - init(_ item: PresenceJourneyItem) { - self = switch item { - case let .stay(stint): .recorded(stint.id) - case let .flight(flight): .flight(flight.id) - } - } - } - - var scrollPosition = ScrollPosition(idType: Item.self) - private(set) var visibleItem: Item? - private var year: Int? - private var monthReveal: MonthReveal? - - var monthRevealID: MonthReveal.ID? { - monthReveal?.id - } - - var requestedMonth: CalendarDay? { - guard case let .pending(month) = monthReveal?.state else { return nil } - return month - } - - func restorePosition(in year: Int, initialItem: Item? = nil) { - if self.year != year { - let isChangingYear = self.year != nil - self.year = year - if isChangingYear { visibleItem = nil } - if requestedMonth?.year != year { monthReveal = nil } - if let visibleItem { - scrollPosition.scrollTo(id: visibleItem, anchor: .center) - } else if let initialItem { - visibleItem = initialItem - scrollPosition.scrollTo(id: initialItem, anchor: .center) - } else { - scrollPosition.scrollTo(edge: .bottom) - } - } else if let visibleItem { - scrollPosition.scrollTo(id: visibleItem, anchor: .top) - } - } - - func recordVisibleItems(_ items: [Item], in year: Int) { - guard self.year == year, let first = items.first else { return } - visibleItem = first - } - - func reveal(_ item: Item) { - monthReveal = nil - revealPosition(item) - } - - private func revealPosition(_ item: Item) { - visibleItem = item - // Leading-edge alignment keeps the selected row visible after resizing. - scrollPosition.scrollTo(id: item, anchor: .top) - } - - func reveal(month: CalendarDay) { - monthReveal = MonthReveal(state: .pending(month)) - } - - /// A month selected while data is loading is resolved when its rows arrive. - func reconcile( - recordedItems: [PresenceJourneyItem], - plannedItems: [PlanningTimelineItem], - calendar: Calendar, - ) { - guard let month = requestedMonth else { return } - let start = CalendarDay(year: month.year, month: month.month, day: 1) - guard let end = calendar.dateInterval(of: .month, for: start.startOfDay(in: calendar)) - .map({ CalendarDay(from: $0.end, in: calendar).adding(days: -1) }) - else { return } - if let item = recordedItems.first(where: { item in - switch item { - case let .stay(stint): - CalendarDay(from: stint.start, in: calendar) <= end - && CalendarDay(from: stint.end, in: calendar) >= start - case let .flight(flight): - flight.completedDays(in: calendar).contains { $0 >= start && $0 <= end } - } - }) { - monthReveal?.state = .consumed - revealPosition(Item(item)) - } else if let item = plannedItems.first(where: { $0.start <= end && $0.end >= start }) { - monthReveal?.state = .consumed - revealPosition(.planned(item.id)) - } - } -} diff --git a/Where/WhereUI/Sources/Shared/PlanningPresentation.swift b/Where/WhereUI/Sources/Shared/PlanningPresentation.swift index 42f6a5260..ffc91d389 100644 --- a/Where/WhereUI/Sources/Shared/PlanningPresentation.swift +++ b/Where/WhereUI/Sources/Shared/PlanningPresentation.swift @@ -3,12 +3,12 @@ import SwiftUI /// Attach to a stable owner, above any container that changes during resizing. struct PlanningPresentation: ViewModifier { let report: YearReportModel - @Bindable var navigation: PlanningPresentationModel + @Binding var destination: PlannedStaysDestination? var isEnabled = true func body(content: Content) -> some View { if isEnabled { - content.sheet(item: $navigation.destination) { destination in + content.sheet(item: $destination) { destination in PlannedStaysDestinationView(destination: destination, report: report) } } else { diff --git a/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift index bb0f1eb44..d9d5f4a62 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift +++ b/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift @@ -1,3 +1,4 @@ +import SnapshotKit import SwiftUI import TestHostSupport import Testing @@ -18,7 +19,7 @@ struct AdaptiveHistoryContainerTests { try await show(host) { controller in for _ in 0 ..< 2 { for pose in [ - AdaptiveHistoryPose.outerPortrait, + SnapshotConfiguration.Pose.outerPortrait, .innerPortrait, .tabletop, .book, diff --git a/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift b/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift index cf2323c4a..59bce3544 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift +++ b/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift @@ -1,5 +1,6 @@ import CoreGraphics import Observation +import SnapshotKit import SwiftUI import TestHostSupport @_spi(Testing) @testable import WhereUI @@ -32,7 +33,7 @@ enum AdaptiveHistoryTestSupport { @MainActor @Observable final class AdaptiveHistoryHostState { - var pose = AdaptiveHistoryPose.outerPortrait + var pose = SnapshotConfiguration.Pose.outerPortrait var renderedSize: CGSize = .zero var panes = WhereStylesheet.AdaptiveHistoryStyle.Panes.single var arrangement: AdaptiveHistoryArrangement? @@ -68,7 +69,7 @@ extension AdaptiveHistoryTestSupport { @MainActor static func resize( _ controller: UIViewController, state: AdaptiveHistoryHostState, - to pose: AdaptiveHistoryPose, + to pose: SnapshotConfiguration.Pose, ) async throws { state.pose = pose controller.view.frame = CGRect(origin: .zero, size: pose.size) diff --git a/Where/WhereUI/Tests/CalendarNavigationModelTests.swift b/Where/WhereUI/Tests/CalendarNavigationModelTests.swift deleted file mode 100644 index 502ffc871..000000000 --- a/Where/WhereUI/Tests/CalendarNavigationModelTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct CalendarNavigationModelTests { - @Test func remountReissuesTheInitialMonthWhenNobodyHasBrowsedYet() { - let navigation = CalendarNavigationModel() - let month = CalendarDay(year: 2026, month: 7, day: 1) - navigation.restorePosition(in: 2026, initialMonth: month) - navigation.scrollPosition.scrollTo(edge: .top) - navigation.restorePosition(in: 2026, initialMonth: month) - #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == month) - } - - @Test func waitsForTheRequestedYearBeforePositioning() { - let navigation = CalendarNavigationModel() - navigation.restorePosition(in: 2026, initialMonth: nil) - navigation.restorePosition( - in: 2026, - initialMonth: CalendarDay(year: 2025, month: 12, day: 1), - ) - #expect(navigation.initiallyPositionedYear == nil) - let month = CalendarDay(year: 2026, month: 7, day: 1) - navigation.restorePosition(in: 2026, initialMonth: month) - #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == month) - } - - @Test func remountUsesVisibleMonthWithoutReturningToToday() { - let navigation = CalendarNavigationModel() - let today = CalendarDay(year: 2026, month: 7, day: 1) - let browsed = CalendarDay(year: 2026, month: 2, day: 1) - navigation.restorePosition(in: 2026, initialMonth: today) - navigation.recordVisibleMonths([browsed, today]) - navigation.restorePosition(in: 2026, initialMonth: today) - #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == browsed) - } - - @Test func staleVisibilityCannotOverrideANewYear() { - let navigation = CalendarNavigationModel() - let oldMonth = CalendarDay(year: 2025, month: 12, day: 1) - let newMonth = CalendarDay(year: 2026, month: 7, day: 1) - navigation.restorePosition(in: 2025, initialMonth: oldMonth) - navigation.recordVisibleMonths([oldMonth]) - navigation.restorePosition(in: 2026, initialMonth: newMonth) - navigation.recordVisibleMonths([oldMonth]) - #expect(navigation.visibleMonth == nil) - #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == newMonth) - } - - @Test func visibilityDoesNotCreateScrollRequests() { - let navigation = CalendarNavigationModel() - let initial = CalendarDay(year: 2026, month: 7, day: 1) - let browsed = CalendarDay(year: 2026, month: 2, day: 1) - navigation.restorePosition(in: 2026, initialMonth: initial) - navigation.recordVisibleMonths([browsed]) - #expect(navigation.visibleMonth == browsed) - #expect(navigation.scrollPosition.viewID(type: CalendarDay.self) == initial) - } -} diff --git a/Where/WhereUI/Tests/HistoryNavigationModelTests.swift b/Where/WhereUI/Tests/HistoryNavigationModelTests.swift deleted file mode 100644 index e13fc7e6c..000000000 --- a/Where/WhereUI/Tests/HistoryNavigationModelTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct HistoryNavigationModelTests { - @Test func selectingAMonthRevealsTheFirstOverlappingStayWhenRowsArrive() { - let model = HistoryNavigationModel() - let month = CalendarDay(year: 2026, month: 3, day: 1) - model.select(month: month, dateCalendar: HistoryNavigationTestSupport.calendar) - model.timeline.restorePosition(in: 2026) - model.timeline.reconcile( - recordedItems: [], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - let stint = HistoryNavigationTestSupport.stint( - from: month.adding(days: -2), - through: month.adding(days: 4), - ) - model.timeline.reconcile( - recordedItems: [.stay(stint)], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == month) - #expect(model.timeline.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .recorded(stint.id)) - #expect(model.selection?.interval == month ... CalendarDay(year: 2026, month: 3, day: 31)) - } - - @Test func selectingAStayHighlightsItsIntervalAndRevealsItsFirstMonth() { - let model = HistoryNavigationModel() - let start = CalendarDay(year: 2026, month: 2, day: 27) - let end = CalendarDay(year: 2026, month: 3, day: 5) - let stint = HistoryNavigationTestSupport.stint(from: start, through: end) - model.select(stint: stint, dateCalendar: HistoryNavigationTestSupport.calendar) - #expect(model.selection?.interval == start ... end) - #expect(model.selection?.region == .california) - #expect(model.calendar.displayedMonth == CalendarDay(year: 2026, month: 2, day: 1)) - #expect(model.timeline.visibleItem == .recorded(stint.id)) - #expect(model.selectedInterval(in: 2025) == nil) - } - - @Test func localScrollObservationsDoNotChangeSelectionOrMoveTheCompanionPane() { - let model = HistoryNavigationModel() - let selected = CalendarDay(year: 2026, month: 3, day: 1) - let browsed = CalendarDay(year: 2026, month: 5, day: 1) - model.select(month: selected, dateCalendar: HistoryNavigationTestSupport.calendar) - let selection = model.selection - model.calendar.recordVisibleMonths([browsed]) - #expect(model.selection == selection) - #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == selected) - #expect(model.timeline.visibleItem == nil) - model.timeline.restorePosition(in: 2026) - let stint = HistoryNavigationTestSupport.stint( - from: browsed, - through: browsed.adding(days: 8), - ) - model.timeline.recordVisibleItems([.recorded(stint.id)], in: 2026) - #expect(model.selection == selection) - #expect(model.calendar.displayedMonth == browsed) - #expect(model.calendar.scrollPosition.viewID(type: CalendarDay.self) == selected) - } - - @Test func planningPresentationHasOneOwnerAcrossBothLenses() { - let model = HistoryNavigationModel() - model.calendar.planningDestination = .new(.california) - #expect(model.planning.destination == .new(.california)) - model.planning.destination = nil - #expect(model.calendar.planningDestination == nil) - } -} diff --git a/Where/WhereUI/Tests/HistorySelectionModelTests.swift b/Where/WhereUI/Tests/HistorySelectionModelTests.swift new file mode 100644 index 000000000..79dbec1ee --- /dev/null +++ b/Where/WhereUI/Tests/HistorySelectionModelTests.swift @@ -0,0 +1,113 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct HistorySelectionModelTests { + private let calendar = HistoryNavigationTestSupport.calendar + + @Test func eachPaneConsumesItsRequestIndependentlyAndRepeatedSelectionGetsANewID() throws { + let model = HistorySelectionModel() + let month = CalendarDay(year: 2026, month: 3, day: 1) + model.select(month: month, dateCalendar: calendar) + let first = try #require(model.pendingRequest(for: .calendar)) + model.consume(first.id, by: .calendar) + #expect(model.requestID == first.id) + #expect(model.pendingRequest(for: .calendar) == nil) + #expect(model.pendingRequest(for: .timeline)?.id == first.id) + model.select(month: month, dateCalendar: calendar) + let second = try #require(model.pendingRequest(for: .calendar)) + #expect(second.id != first.id) + model.consume(first.id, by: .calendar) + #expect(model.pendingRequest(for: .calendar)?.id == second.id) + #expect(model.selection?.interval == month ... CalendarDay(year: 2026, month: 3, day: 31)) + model.reset() + #expect(model.selection == nil) + #expect(model.requestID == nil) + } + + @Test func aPendingMonthResolvesWhenAnOverlappingStayArrivesOrExtends() { + let model = HistorySelectionModel() + let january = CalendarDay(year: 2026, month: 1, day: 20) + let february = CalendarDay(year: 2026, month: 2, day: 1) + model.select(month: february, dateCalendar: calendar) + let short = HistoryNavigationTestSupport.stint( + from: january, + through: january.adding(days: 1), + ) + #expect(model + .timelineTarget(recordedItems: [], plannedItems: [], calendar: calendar) == nil) + #expect(model.timelineTarget( + recordedItems: [.stay(short)], + plannedItems: [], + calendar: calendar, + ) == nil) + let extended = HistoryNavigationTestSupport.stint( + from: january, + through: february.adding(days: 2), + ) + #expect(short.id == extended.id) + #expect(model.timelineTarget( + recordedItems: [.stay(extended)], + plannedItems: [], + calendar: calendar, + ) == .recorded(extended.id)) + #expect(model.pendingRequest(for: .timeline) != nil) + } + + @Test func resolvesDisplayedSegmentsAndCrossMonthFlights() throws { + let model = HistorySelectionModel() + let january = CalendarDay(year: 2026, month: 1, day: 1) + let february = CalendarDay(year: 2026, month: 2, day: 1) + let stint = HistoryNavigationTestSupport.stint( + from: january, + through: february.adding(days: 9), + ) + let flight = HistoryNavigationTestSupport.flight( + from: february.adding(days: -1), + through: february.adding(days: -1), + ) + let items = PresenceJourneyItem.items( + stints: [stint], + flights: [flight], + year: 2026, + calendar: calendar, + ) + let displayed = try #require(items.last) + model.select(month: february, dateCalendar: calendar) + #expect(HistorySelectionModel.Item(displayed) != .recorded(stint.id)) + #expect(model + .timelineTarget(recordedItems: items, plannedItems: [], calendar: calendar) == + HistorySelectionModel.Item(displayed)) + let crossing = HistoryNavigationTestSupport.flight( + from: february.adding(days: -1), + through: february, + ) + #expect(model.timelineTarget( + recordedItems: [.flight(crossing)], + plannedItems: [], + calendar: calendar, + ) == .flight(crossing.id)) + } + + @Test func explicitStaySelectionRetainsTypedIdentityAndInterval() { + let model = HistorySelectionModel() + let start = CalendarDay(year: 2026, month: 2, day: 27) + let end = CalendarDay(year: 2026, month: 3, day: 5) + let stint = HistoryNavigationTestSupport.stint(from: start, through: end) + model.select(stint: stint, dateCalendar: calendar) + #expect(model.selection?.interval == start ... end) + #expect(model.selection?.region == .california) + #expect(model.pendingRequest(for: .calendar)?.month == CalendarDay( + year: 2026, + month: 2, + day: 1, + )) + #expect(model.timelineTarget( + recordedItems: [.stay(stint)], + plannedItems: [], + calendar: calendar, + ) == .recorded(stint.id)) + #expect(model.selectedInterval(in: 2025) == nil) + } +} diff --git a/Where/WhereUI/Tests/SceneNavigationModelTests.swift b/Where/WhereUI/Tests/SceneNavigationModelTests.swift deleted file mode 100644 index 8530c8a7c..000000000 --- a/Where/WhereUI/Tests/SceneNavigationModelTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct SceneNavigationModelTests { - @Test func anUnselectedLocationsSceneKeepsItsNavigationEmpty() { - let navigation = SceneNavigationModel() - #expect(navigation.locations.selectedRegion == nil) - #expect(navigation.locations.browsePath.isEmpty) - #expect(navigation.locations.compactColumn == .sidebar) - } - - @Test func repeatedRegionSelectionPreservesHistoryAndDrillInState() { - let navigation = SceneNavigationModel.Locations() - navigation.select(region: .california) - let month = CalendarDay(year: 2026, month: 2, day: 1) - navigation.calendar.reveal(month: month) - navigation.detailPath.append(month) - navigation.select(region: .california) - #expect(navigation.detailPath.count == 1) - #expect(navigation.calendar.displayedMonth == month) - navigation.select(region: .newYork) - #expect(navigation.detailPath.isEmpty) - #expect(navigation.calendar.displayedMonth == month) - } - - @Test func settingsSelectionRetainsSearchAndResetsOnlyAnExplicitlyChangedDestination() { - let navigation = SceneNavigationModel.Settings() - navigation.searchText = "appearance" - navigation.selectedRoute = SettingsRoute(.appearance) - navigation.path.append(SettingsRoute(.about)) - navigation.selectedRoute = SettingsRoute(.appearance) - #expect(navigation.path.count == 1) - navigation.selectedRoute = SettingsRoute(.about) - #expect(navigation.path.isEmpty) - #expect(navigation.searchText == "appearance") - } -} diff --git a/Where/WhereUI/Tests/TimelineNavigationModelTests.swift b/Where/WhereUI/Tests/TimelineNavigationModelTests.swift deleted file mode 100644 index e1efe554b..000000000 --- a/Where/WhereUI/Tests/TimelineNavigationModelTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct TimelineNavigationModelTests { - @Test func selectionBeforeTheFirstMountTakesPriorityOverTheInitialAnchor() { - let model = TimelineNavigationModel() - let day = CalendarDay(year: 2026, month: 2, day: 1) - let selected = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 3)) - let current = HistoryNavigationTestSupport.stint( - from: day.adding(days: 30), - through: day.adding(days: 40), - ) - model.reveal(.recorded(selected.id)) - model.restorePosition(in: 2026, initialItem: .recorded(current.id)) - #expect(model.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .recorded(selected.id)) - } - - @Test func linkedTimelineStartsAtCurrentHistoryAndThenRestoresBrowsing() { - let model = TimelineNavigationModel() - let day = CalendarDay(year: 2026, month: 7, day: 10) - let current = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 3)) - let earlier = HistoryNavigationTestSupport.stint( - from: day.adding(days: -20), - through: day.adding(days: -10), - ) - model.restorePosition(in: 2026, initialItem: .recorded(current.id)) - #expect(model.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .recorded(current.id)) - model.recordVisibleItems([.recorded(earlier.id)], in: 2026) - model.restorePosition(in: 2026, initialItem: .recorded(current.id)) - #expect(model.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .recorded(earlier.id)) - } - - @Test func remountRestoresTheLocalAnchorWithoutACompanionCommand() { - let model = TimelineNavigationModel() - let day = CalendarDay(year: 2026, month: 4, day: 1) - let stint = HistoryNavigationTestSupport.stint(from: day, through: day.adding(days: 8)) - model.restorePosition(in: 2026) - model.recordVisibleItems([.recorded(stint.id)], in: 2026) - model.restorePosition(in: 2026) - #expect(model.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .recorded(stint.id)) - } - - @Test func aNewYearDiscardsAnOlderPendingMonthAndVisibilityCallback() { - let model = TimelineNavigationModel() - let month = CalendarDay(year: 2025, month: 12, day: 1) - let stint = HistoryNavigationTestSupport.stint(from: month, through: month.adding(days: 8)) - model.restorePosition(in: 2025) - model.reveal(month: month) - model.restorePosition(in: 2026) - model.recordVisibleItems([.recorded(stint.id)], in: 2025) - model.reconcile( - recordedItems: [.stay(stint)], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.visibleItem == nil) - #expect(model.scrollPosition.viewID(type: TimelineNavigationModel.Item.self) == nil) - } - - @Test func monthSelectionCanResolveAgainstAStayWhoseEndChanges() { - let model = TimelineNavigationModel() - let january = CalendarDay(year: 2026, month: 1, day: 20) - let february = CalendarDay(year: 2026, month: 2, day: 1) - model.restorePosition(in: 2026) - model.reveal(month: february) - let short = HistoryNavigationTestSupport.stint( - from: january, - through: january.adding(days: 1), - ) - model.reconcile( - recordedItems: [.stay(short)], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.visibleItem == nil) - let extended = HistoryNavigationTestSupport.stint( - from: january, - through: february.adding(days: 2), - ) - #expect(short.id == extended.id) - model.reconcile( - recordedItems: [.stay(extended)], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.visibleItem == .recorded(extended.id)) - } - - @Test func monthSelectionRevealsTheDisplayedSegmentAfterAFlight() throws { - let model = TimelineNavigationModel() - let january = CalendarDay(year: 2026, month: 1, day: 1) - let february = CalendarDay(year: 2026, month: 2, day: 1) - let stint = HistoryNavigationTestSupport.stint( - from: january, - through: february.adding(days: 9), - ) - let flight = HistoryNavigationTestSupport.flight( - from: february.adding(days: -1), - through: february.adding(days: -1), - ) - let items = PresenceJourneyItem.items( - stints: [stint], - flights: [flight], - year: 2026, - calendar: HistoryNavigationTestSupport.calendar, - ) - let displayed = try #require(items.last) - #expect(TimelineNavigationModel.Item(displayed) != .recorded(stint.id)) - model.restorePosition(in: 2026) - model.reveal(month: february) - model.reconcile( - recordedItems: items, - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.visibleItem == TimelineNavigationModel.Item(displayed)) - #expect(model.requestedMonth == nil) - } - - @Test func flightOnlyHistoryResolvesACrossMonthFlightAndRestoresItsAnchor() { - let model = TimelineNavigationModel() - let february = CalendarDay(year: 2026, month: 2, day: 1) - let flight = HistoryNavigationTestSupport.flight( - from: february.adding(days: -1), - through: february, - ) - model.restorePosition(in: 2026) - model.reveal(month: february) - model.reconcile( - recordedItems: [.flight(flight)], - plannedItems: [], - calendar: HistoryNavigationTestSupport.calendar, - ) - #expect(model.visibleItem == .flight(flight.id)) - model.restorePosition(in: 2026) - #expect(model.scrollPosition - .viewID(type: TimelineNavigationModel.Item.self) == .flight(flight.id)) - } -} From 9b386b549f3b75f6b140a81926e402e02f3cb834 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 11 Nov 2026 17:19:49 -0500 Subject: [PATCH 06/11] Restore native scroll targets after delayed content mounts --- Shared/Broadway/BroadwayUI/AGENTS.md | 6 +- Shared/Broadway/BroadwayUI/README.md | 14 +- .../BroadwayUI/Sources/BScrollPosition.swift | 131 +++++++++-- .../Sources/BScrollRestorationStore.swift | 12 +- .../Tests/BScrollPositionTests.swift | 203 ++++++++++++++---- 5 files changed, 303 insertions(+), 63 deletions(-) diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index 71bd8a058..e2a4a73cd 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -19,10 +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. -## Testing +## 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. -- Capture the resolved scroll entry in bindings. Outgoing views and obsolete contexts must not write into a replacement entry. Guard: `BScrollPositionTests`. +- 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 1d4442f66..ee3335858 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -50,9 +50,17 @@ 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(ScrollPosition(id: target, anchor: .top))` after content -is ready. This applies the initial policy once per context, even if native layout -already reported a provisional first item. Later calls preserve the retained position. +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. +A context closure can read a live observable input when the owning view updates. 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`. diff --git a/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift index 00171bddf..70fd49892 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift @@ -9,7 +9,52 @@ public struct BScrollPosition: @MainActor DynamicProper @State private var localStore = BScrollRestorationStore() private let key: BScrollRestorationStore.Key private let context: () -> AnyHashable - private var resolvedEntry: BScrollRestorationStore.Entry? + @State private var mount = Mount() + private var resolvedAttachment: Attachment? + + 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: AnyHashable? + private var key: BScrollRestorationStore.Key? + private var attachment: Attachment? + + func resolve( + store: BScrollRestorationStore, + key: BScrollRestorationStore.Key, + context: AnyHashable, + ) -> Attachment { + if self.store === store, self.key == key, self.context == context, + let attachment { return attachment } + let attachment = Attachment(entry: store.entry( + for: key, + context: context, + idType: ID.self, + )) + self.store = store + self.key = key + self.context = context + self.attachment = attachment + return attachment + } + } public init( _ view: (some View).Type, @@ -41,18 +86,29 @@ public struct BScrollPosition: @MainActor DynamicProper } public mutating func update() { - resolvedEntry = (sharedStore ?? localStore).entry( - for: key, + // 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(), - idType: ID.self, ) } - private var entry: BScrollRestorationStore.Entry { - guard let resolvedEntry else { + private var attachment: Attachment { + guard let resolvedAttachment else { preconditionFailure("BScrollPosition must be read from a mounted view") } - return resolvedEntry + 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 { @@ -61,19 +117,54 @@ public struct BScrollPosition: @MainActor DynamicProper } public var projectedValue: Binding { - // Capture this render's entry. An obsolete task or outgoing view cannot - // redirect a write into a new context's entry. - let entry = entry - // Register the value with SwiftUI even when the consumer only reads the binding. - _ = entry.position - return Binding(get: { entry.position }, set: { entry.position = $0 }) - } - - /// Apply the view's initial policy once its content is ready, once per context. - /// Native layout can publish a provisional target before asynchronous data arrives. - public func initialize(_ initialPosition: @autoclosure () -> ScrollPosition) { - guard !entry.isInitialized else { return } - entry.position = initialPosition() + // 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/BScrollRestorationStore.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift index c913ca8b1..58616c932 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift @@ -17,8 +17,18 @@ public final class BScrollRestorationStore { @Observable final class Entry { - var position = ScrollPosition(idType: ID.self) + 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] = [:] diff --git a/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift index 1d91b0acd..1c7e43ae8 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift @@ -5,106 +5,208 @@ import TestHostSupport import Testing @MainActor +@Suite(.serialized) struct BScrollPositionTests { - @Test func nativeScrollViewRestoresItsVisibleTargetAfterReplacement() throws { + @Test func nativeScrollViewRestoresItsVisibleTargetAfterReplacement() async throws { let fixture = NativeScrollFixture() fixture.initial = nil let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) .bScrollRestorationScope()) - try show(host) { controller in - try waitFor { fixture.position != nil && fixture.visible.contains(0) } + 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 waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } let before = fixture.position?.wrappedValue.viewID(type: Int.self) fixture.mounted = false - try waitFor { fixture.visible.isEmpty } + try await waitForAsync { fixture.visible.isEmpty } fixture.mounted = true controller.view.layoutIfNeeded() - try waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == before) } } - @Test func readinessReplacesProvisionalNativePositionOnlyOncePerContext() throws { + @Test func readinessReplacesProvisionalNativePositionOnlyOncePerContext() async throws { let fixture = NativeScrollFixture() fixture.initial = nil let host = UIHostingController(rootView: NativeScrollHarness(fixture: fixture) .bScrollRestorationScope()) - try show(host) { _ in - try waitFor { fixture.visible.contains(0) } + try await show(host) { _ in + try await waitForAsync { fixture.visible.contains(0) } fixture.initial = 12 - try waitFor { fixture.visible.contains(12) && !fixture.visible.contains(0) } + try await waitForAsync { fixture.visible.contains(12) && !fixture.visible.contains(0) } fixture.initial = 20 - try waitFor { fixture.observedInitial == 20 } + try await waitForAsync { fixture.observedInitial == 20 } #expect(fixture.position?.wrappedValue.viewID(type: Int.self) == 12) fixture.context = 2 - try waitFor { fixture.visible.contains(20) && !fixture.visible.contains(12) } + try await waitForAsync { fixture.visible.contains(20) && !fixture.visible.contains(12) } } } - @Test func replacementRetainsPositionAndContextChangeDiscardsIt() throws { + @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 show(host) { _ in - try waitFor { fixture.bindings[.first] != nil } + 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 waitFor { fixture.disappeared } + try await waitForAsync { fixture.disappeared } fixture.mounted = true - try waitFor { + try await waitForAsync { !fixture.disappeared && fixture.bindings[.first]?.wrappedValue .viewID(type: Int.self) == 7 } fixture.context = 2 - try waitFor { fixture.observedContext == 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 waitFor { fixture.observedContext == 1 } + try await waitForAsync { fixture.observedContext == 1 } #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) } } - @Test func rolesAndScopesRemainIndependent() throws { + @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 show(host) { _ in - try waitFor { first.bindings.count == 2 && second.bindings.count == 2 } + 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() throws { + @Test func missingScopeUsesViewLocalState() async throws { let fixture = ScrollFixture() let host = UIHostingController(rootView: ScrollHarness(fixture: fixture)) - try show(host) { _ in - try waitFor { fixture.bindings[.first] != nil } + 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 waitFor { fixture.observedRedraw == 1 } + try await waitForAsync { fixture.observedRedraw == 1 } #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == 4) fixture.mounted = false - try waitFor { fixture.disappeared } + try await waitForAsync { fixture.disappeared } fixture.mounted = true - try waitFor { !fixture.disappeared } + try await waitForAsync { !fixture.disappeared } #expect(fixture.bindings[.first]?.wrappedValue.viewID(type: Int.self) == nil) } } } -private enum ScrollRole: Hashable { case first, second } +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 @@ -112,6 +214,7 @@ private final class ScrollFixture { @ObservationIgnored var observedRedraw: Int? @ObservationIgnored var disappeared = false @ObservationIgnored var bindings: [ScrollRole: Binding] = [:] + @ObservationIgnored var initializers: [ScrollRole: @MainActor (Int) -> Void] = [:] } private struct ScrollHarness: View { @@ -120,7 +223,7 @@ private struct ScrollHarness: View { if fixture.mounted { ScrollProbe( fixture: fixture, - role: .first, + role: fixture.primaryRole, context: fixture.context, redraw: fixture.redraw, ) @@ -152,13 +255,19 @@ private struct ScrollProbe: View { var body: some View { Color.clear.frame(height: 10) .onChange(of: context, initial: true) { - fixture.bindings[role] = $position + 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 @@ -166,6 +275,10 @@ 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] = [] @@ -197,25 +310,41 @@ private struct NativeScrollProbe: View { 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 - initialize() } - .onChange(of: fixture.initial) { initialize() } - .onDisappear { fixture.visible = [] } + .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(ScrollPosition( + if let initial = fixture.initial { _position.initialize( id: initial, - anchor: .top, - )) } + anchor: fixture.initialAnchor, + ) } fixture.observedInitial = fixture.initial } } From 96455901082843cdf9d58bdf3c28f2b656c7e5ae Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Nov 2026 15:37:43 -0500 Subject: [PATCH 07/11] Name adaptive pane and presence selection groundwork by responsibility --- .bumper/RULES.md | 4 +- .bumper/Sources/WhereProjectRules.swift | 6 +- .bumper/Tests/WhereProjectRulesTests.swift | 2 +- Where/WhereUI/AGENTS.md | 6 +- Where/WhereUI/README.md | 10 +- .../SnapshotPose+WhereUI.swift | 4 +- ...del.swift => PresenceSelectionModel.swift} | 2 +- ...nt.swift => AdaptivePaneArrangement.swift} | 26 ++--- ...iner.swift => AdaptivePaneContainer.swift} | 62 ++++++------ ...metry.swift => AdaptivePaneGeometry.swift} | 2 +- ...oryStyle.swift => AdaptivePaneStyle.swift} | 6 +- ...election.swift => PresenceSelection.swift} | 8 +- .../Sources/Shared/WhereStylesheet.swift | 4 +- .../AdaptiveHistoryArrangementTests.swift | 94 ------------------- .../Tests/AdaptivePaneArrangementTests.swift | 94 +++++++++++++++++++ ...swift => AdaptivePaneContainerTests.swift} | 16 ++-- ....swift => AdaptivePaneGeometryTests.swift} | 14 +-- ...sts.swift => AdaptivePaneStyleTests.swift} | 8 +- ...rt.swift => AdaptivePaneTestSupport.swift} | 36 +++---- ...wift => PresenceSelectionModelTests.swift} | 14 +-- .../WhereUI/Tests/WhereStylesheetTests.swift | 10 +- 21 files changed, 214 insertions(+), 214 deletions(-) rename Where/WhereUI/Sources/Developer/{AdaptiveHistory => DuoLayout}/SnapshotPose+WhereUI.swift (88%) rename Where/WhereUI/Sources/Model/{HistorySelectionModel.swift => PresenceSelectionModel.swift} (99%) rename Where/WhereUI/Sources/Shared/{AdaptiveHistoryArrangement.swift => AdaptivePaneArrangement.swift} (85%) rename Where/WhereUI/Sources/Shared/{AdaptiveHistoryContainer.swift => AdaptivePaneContainer.swift} (63%) rename Where/WhereUI/Sources/Shared/{AdaptiveHistoryGeometry.swift => AdaptivePaneGeometry.swift} (99%) rename Where/WhereUI/Sources/Shared/{AdaptiveHistoryStyle.swift => AdaptivePaneStyle.swift} (79%) rename Where/WhereUI/Sources/Shared/{HistorySelection.swift => PresenceSelection.swift} (66%) delete mode 100644 Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift create mode 100644 Where/WhereUI/Tests/AdaptivePaneArrangementTests.swift rename Where/WhereUI/Tests/{AdaptiveHistoryContainerTests.swift => AdaptivePaneContainerTests.swift} (72%) rename Where/WhereUI/Tests/{AdaptiveHistoryGeometryTests.swift => AdaptivePaneGeometryTests.swift} (82%) rename Where/WhereUI/Tests/{AdaptiveHistoryStyleTests.swift => AdaptivePaneStyleTests.swift} (62%) rename Where/WhereUI/Tests/{AdaptiveHistoryTestSupport.swift => AdaptivePaneTestSupport.swift} (71%) rename Where/WhereUI/Tests/{HistorySelectionModelTests.swift => PresenceSelectionModelTests.swift} (92%) diff --git a/.bumper/RULES.md b/.bumper/RULES.md index d69a6fbdb..8ce84de00 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -94,8 +94,8 @@ its documented observation-task lifecycle. ## Reserved-region layout ownership -`where.reserved_region_layout_ownership` permits `reservedRegions` calls only in `AdaptiveHistoryGeometry` within WhereUI. -Views use that adapter or `AdaptiveHistoryContainer`. Keep the system margins and physical coordinate mapping there. +`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. diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index ec0ba9eac..cf0cebee9 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -314,7 +314,7 @@ private let reservedRegionLayoutOwnershipRule = Rules.files( 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/AdaptiveHistoryGeometry.swift" + guard file.path.rawValue != "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift" else { return [] } return functionCalls() .filter { match in @@ -323,10 +323,10 @@ private let reservedRegionLayoutOwnershipRule = Rules.files( .matches(in: file) .map { match in match.failure( - message: "Reserved regions are read outside AdaptiveHistoryGeometry.", + message: "Reserved regions are read outside AdaptivePaneGeometry.", evidence: ViolationEvidence( observed: file.path.rawValue, - expectation: "use AdaptiveHistoryGeometry or AdaptiveHistoryContainer", + expectation: "use AdaptivePaneGeometry or AdaptivePaneContainer", ), ) } diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index b604f7ce2..75d48d665 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -7,7 +7,7 @@ struct WhereProjectRulesTests { 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/AdaptiveHistoryGeometry.swift", + path: "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift", component: .whereUI, source: source, ) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index a639fc3d9..41b8f4846 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -161,9 +161,9 @@ Layering, localization, preview, and testing conventions live in the feature 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 `AdaptiveHistoryGeometry`. Keep authored pane limits in the stylesheet and measured geometry in the adapter. Guard: `where.reserved_region_layout_ownership`. -- Link explicit month and stay selections through `HistorySelectionModel`. Keep domain identities typed. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move a companion pane. -- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `HistorySelectionModelTests`. +- 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`. +- Link explicit month and stay selections through `PresenceSelectionModel`. Keep domain identities typed. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move a companion pane. +- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `PresenceSelectionModelTests`. - 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. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index e9736a34d..cef230a12 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -700,17 +700,17 @@ Developer Overlay snapshots retain their scene for native glass rendering. Detac windows produce material bands and clipping in the menu. The retained-scene cases keep their device traits, content clearance, and full-content sizing. -## Adaptive history foundations +## Adaptive pane foundations -Stable destination views own navigation and presentation state. `HistorySelectionModel` +Stable destination views own navigation and presentation state. `PresenceSelectionModel` connects explicit month and stay selections through requests consumed independently by each pane. BroadwayUI retains scroll positions for the current context only. Changing region or year discards the old position; ordinary scrolling never selects or moves a companion pane. Synthetic pose geometry comes from `SnapshotConfiguration.Pose`. -`AdaptiveHistoryGeometry` maps safe areas and reserved regions into physical -container coordinates. `AdaptiveHistoryArrangement` applies the stylesheet's pane -limits. `AdaptiveHistoryContainer` hosts compact, columns, and tabletop arrangements. +`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 diff --git a/Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift b/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift similarity index 88% rename from Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift rename to Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift index 381aa1734..e2990b369 100644 --- a/Where/WhereUI/Sources/Developer/AdaptiveHistory/SnapshotPose+WhereUI.swift +++ b/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift @@ -15,9 +15,9 @@ } } - var regions: [AdaptiveHistoryReservedRegion] { + var regions: [AdaptivePaneReservedRegion] { reservedRegions.map { region in - let kind: AdaptiveHistoryReservedRegion.Kind = switch region.kind { + let kind: AdaptivePaneReservedRegion.Kind = switch region.kind { case .division: .division case .occlusion: .occlusion } diff --git a/Where/WhereUI/Sources/Model/HistorySelectionModel.swift b/Where/WhereUI/Sources/Model/PresenceSelectionModel.swift similarity index 99% rename from Where/WhereUI/Sources/Model/HistorySelectionModel.swift rename to Where/WhereUI/Sources/Model/PresenceSelectionModel.swift index 0ec1a7dcb..0405ff914 100644 --- a/Where/WhereUI/Sources/Model/HistorySelectionModel.swift +++ b/Where/WhereUI/Sources/Model/PresenceSelectionModel.swift @@ -5,7 +5,7 @@ import WhereCore /// Connects explicit selections to displayed history data. It owns no scroll or presentation state. @MainActor @Observable -final class HistorySelectionModel { +final class PresenceSelectionModel { enum Pane: Hashable { case calendar, timeline } struct Selection: Equatable { diff --git a/Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift similarity index 85% rename from Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift rename to Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift index 18c08c511..69f2ec5ad 100644 --- a/Where/WhereUI/Sources/Shared/AdaptiveHistoryArrangement.swift +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneArrangement.swift @@ -2,9 +2,9 @@ import CoreGraphics /// Chooses a layout from available space and active reserved regions, without device or angle /// checks. -enum AdaptiveHistoryArrangement: Equatable { +enum AdaptivePaneArrangement: Equatable { struct Panes: Equatable { - var history: CGRect + var detail: CGRect var controls: CGRect } @@ -13,12 +13,12 @@ enum AdaptiveHistoryArrangement: Equatable { case tabletop(Panes) static func resolve( - geometry: AdaptiveHistoryGeometry, - style: WhereStylesheet.AdaptiveHistoryStyle, + geometry: AdaptivePaneGeometry, + style: WhereStylesheet.AdaptivePaneStyle, ) -> Self { guard style.panes == .multiple else { return .compact } let content = geometry.contentBounds - guard content.width >= style.minimumHistoryWidth, + guard content.width >= style.minimumDetailWidth, content.height >= style.minimumPaneHeight else { return .compact } @@ -28,7 +28,7 @@ enum AdaptiveHistoryArrangement: Equatable { 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 history = avoidingOcclusions( + let detail = avoidingOcclusions( CGRect( x: content.minX, y: content.minY, @@ -46,25 +46,25 @@ enum AdaptiveHistoryArrangement: Equatable { ), geometry: geometry, ) - guard history.height >= style.minimumPaneHeight, + guard detail.height >= style.minimumPaneHeight, controls.height >= style.minimumPaneHeight, - history.width >= style.minimumHistoryWidth, + detail.width >= style.minimumDetailWidth, controls.width >= style.minimumControlsWidth else { return .compact } - return .tabletop(Panes(history: history, controls: controls)) + 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 historyWidth = geometry.layoutDirection == .leftToRight + let detailWidth = geometry.layoutDirection == .leftToRight ? content.maxX - gap.maxX : gap.minX - content.minX guard controlsWidth >= style.minimumControlsWidth, - historyWidth >= style.minimumHistoryWidth + detailWidth >= style.minimumDetailWidth else { return .compact } } else { - guard content.width >= style.minimumControlsWidth + style.minimumHistoryWidth + guard content.width >= style.minimumControlsWidth + style.minimumDetailWidth else { return .compact } } return .columns @@ -74,7 +74,7 @@ enum AdaptiveHistoryArrangement: Equatable { /// Edge occlusions already outside the content safe area do not reduce it again. private static func avoidingOcclusions( _ pane: CGRect, - geometry: AdaptiveHistoryGeometry, + geometry: AdaptivePaneGeometry, ) -> CGRect { geometry.regions.reduce(pane) { available, region in guard region.isActive, region.kind == .occlusion, diff --git a/Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift similarity index 63% rename from Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift rename to Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift index 9731d2980..22f184331 100644 --- a/Where/WhereUI/Sources/Shared/AdaptiveHistoryContainer.swift +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift @@ -1,15 +1,15 @@ import SwiftUI /// Measures one bounded destination. Navigation and presentation owners stay outside it. -struct AdaptiveHistoryContainer: View { +struct AdaptivePaneContainer: View { @Environment(\.stylesheet) private var stylesheet - @ViewBuilder var content: (AdaptiveHistoryArrangement) -> Content + @ViewBuilder var content: (AdaptivePaneArrangement) -> Content var body: some View { - AdaptiveHistoryGeometryReader { geometry in - content(AdaptiveHistoryArrangement.resolve( + AdaptivePaneGeometryReader { geometry in + content(AdaptivePaneArrangement.resolve( geometry: geometry, - style: stylesheet.adaptiveHistory, + style: stylesheet.adaptivePane, )) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -18,11 +18,11 @@ struct AdaptiveHistoryContainer: View { } /// Shares the native/injected coordinate mapping with custom chrome above the destinations. -struct AdaptiveHistoryGeometryReader: View { +struct AdaptivePaneGeometryReader: View { // Physical reserved-region coordinates require the live direction at this container. @Environment(\.layoutDirection) private var layoutDirection - @Environment(\.adaptiveHistoryRegions) private var injectedRegions - @ViewBuilder var content: (AdaptiveHistoryGeometry) -> Content + @Environment(\.adaptivePaneRegions) private var injectedRegions + @ViewBuilder var content: (AdaptivePaneGeometry) -> Content var body: some View { GeometryReader { proxy in @@ -30,12 +30,12 @@ struct AdaptiveHistoryGeometryReader: View { } } - private func geometry(in proxy: GeometryProxy) -> AdaptiveHistoryGeometry { - var geometry = AdaptiveHistoryGeometry(proxy: proxy, layoutDirection: layoutDirection) + 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 - AdaptiveHistoryGeometry.Region( + AdaptivePaneGeometry.Region( kind: region.kind, frame: region.frame.offsetBy(dx: -origin.x, dy: -origin.y), isActive: region.isActive, @@ -46,19 +46,19 @@ struct AdaptiveHistoryGeometryReader: View { } } -/// History precedes controls in both source and accessibility order. Frames are physical, +/// 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 TabletopHistoryLayout: View { - let panes: AdaptiveHistoryArrangement.Panes - @ViewBuilder var history: () -> History +struct TabletopPaneLayout: View { + let panes: AdaptivePaneArrangement.Panes + @ViewBuilder var detail: () -> Detail @ViewBuilder var controls: () -> Controls var body: some View { ZStack(alignment: .topLeading) { - history() - .frame(width: panes.history.width, height: panes.history.height) + detail() + .frame(width: panes.detail.width, height: panes.detail.height) .clipped() - .position(x: panes.history.midX, y: panes.history.midY) + .position(x: panes.detail.midX, y: panes.detail.midY) controls() .frame(width: panes.controls.width, height: panes.controls.height) .clipped() @@ -68,19 +68,19 @@ struct TabletopHistoryLayout: View { } } -private struct AdaptiveHistoryRegions { - let regions: [AdaptiveHistoryGeometry.Region] +private struct AdaptivePaneRegions { + let regions: [AdaptivePaneGeometry.Region] let coordinateSpace: Namespace.ID } extension EnvironmentValues { - @Entry fileprivate var adaptiveHistoryRegions: AdaptiveHistoryRegions? + @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 AdaptiveHistoryReservedRegion { + @_spi(Testing) public struct AdaptivePaneReservedRegion { public enum Kind { case division case occlusion @@ -97,15 +97,15 @@ extension EnvironmentValues { } } - private struct AdaptiveHistoryRegionsModifier: ViewModifier { - let regions: [AdaptiveHistoryReservedRegion] + private struct AdaptivePaneRegionsModifier: ViewModifier { + let regions: [AdaptivePaneReservedRegion] @Namespace private var coordinateSpace func body(content: Content) -> some View { content - .environment(\.adaptiveHistoryRegions, AdaptiveHistoryRegions( + .environment(\.adaptivePaneRegions, AdaptivePaneRegions( regions: regions.map { region in - AdaptiveHistoryGeometry.Region( + AdaptivePaneGeometry.Region( kind: region.kind == .division ? .division : .occlusion, frame: region.frame, isActive: region.isActive, @@ -120,22 +120,22 @@ extension EnvironmentValues { 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 adaptiveHistoryReservedRegions( - _ regions: [AdaptiveHistoryReservedRegion], + @_spi(Testing) public func adaptivePaneReservedRegions( + _ regions: [AdaptivePaneReservedRegion], ) -> some View { - modifier(AdaptiveHistoryRegionsModifier(regions: regions)) + modifier(AdaptivePaneRegionsModifier(regions: regions)) } } #endif #if DEBUG #Preview { - AdaptiveHistoryContainer { arrangement in + AdaptivePaneContainer { arrangement in switch arrangement { case .compact: Color.blue case .columns: HStack { Color.blue; Color.green } case let .tabletop(panes): - TabletopHistoryLayout(panes: panes) { Color.blue } controls: { Color.green } + TabletopPaneLayout(panes: panes) { Color.blue } controls: { Color.green } } } .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift similarity index 99% rename from Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift rename to Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift index a3b67f4a5..9df70f602 100644 --- a/Where/WhereUI/Sources/Shared/AdaptiveHistoryGeometry.swift +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift @@ -3,7 +3,7 @@ import SwiftUI /// Container geometry in one coordinate space. Insets belong to independent physical edges. /// Reserved-region frames already include the system's margins. -struct AdaptiveHistoryGeometry: Equatable { +struct AdaptivePaneGeometry: Equatable { struct Insets: Equatable { var top: CGFloat var left: CGFloat diff --git a/Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift similarity index 79% rename from Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift rename to Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift index 1f6b05698..bbc14b29e 100644 --- a/Where/WhereUI/Sources/Shared/AdaptiveHistoryStyle.swift +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneStyle.swift @@ -3,7 +3,7 @@ import SwiftUI extension WhereStylesheet { /// Authored limits for two readable panes. Runtime geometry resolves the arrangement. - struct AdaptiveHistoryStyle: Equatable { + struct AdaptivePaneStyle: Equatable { enum Panes: Equatable { case single case multiple @@ -11,9 +11,9 @@ extension WhereStylesheet { var panes = Panes.single /// Point widths needed for the month grid and location/settings controls. - var minimumHistoryWidth: CGFloat = 320 + var minimumDetailWidth: CGFloat = 320 var minimumControlsWidth: CGFloat = 280 - /// Point height needed for independently scrollable history and controls. + /// Point height needed for independently scrollable detail and controls. var minimumPaneHeight: CGFloat = 220 var selection = Selection() diff --git a/Where/WhereUI/Sources/Shared/HistorySelection.swift b/Where/WhereUI/Sources/Shared/PresenceSelection.swift similarity index 66% rename from Where/WhereUI/Sources/Shared/HistorySelection.swift rename to Where/WhereUI/Sources/Shared/PresenceSelection.swift index bdf2a7046..512280632 100644 --- a/Where/WhereUI/Sources/Shared/HistorySelection.swift +++ b/Where/WhereUI/Sources/Shared/PresenceSelection.swift @@ -1,6 +1,6 @@ import SwiftUI -private struct HistorySelection: ViewModifier { +private struct PresenceSelection: ViewModifier { let isSelected: Bool let cornerRadius: CGFloat? @Environment(\.stylesheet) private var stylesheet @@ -9,7 +9,7 @@ private struct HistorySelection: ViewModifier { content .overlay { if isSelected { - let style = stylesheet.adaptiveHistory.selection + let style = stylesheet.adaptivePane.selection RoundedRectangle(cornerRadius: cornerRadius ?? style.cornerRadius) .strokeBorder(style.border, lineWidth: style.borderWidth) .allowsHitTesting(false) @@ -20,7 +20,7 @@ private struct HistorySelection: ViewModifier { } extension View { - func historySelection(_ isSelected: Bool, cornerRadius: CGFloat? = nil) -> some View { - modifier(HistorySelection(isSelected: isSelected, cornerRadius: cornerRadius)) + 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 609865cb9..81f55b249 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -36,7 +36,7 @@ struct WhereStylesheet: BStylesheet { var themePicker = ThemePickerStyle() var yearModePicker = YearModePickerStyle() var loggedDay = LoggedDayStyle() - var adaptiveHistory = AdaptiveHistoryStyle() + var adaptivePane = AdaptivePaneStyle() var settings = SettingsStyle.standard var featureDiscovery = FeatureDiscoveryStyle.standard var passportSeal = PassportSealStyle.standard @@ -58,7 +58,7 @@ struct WhereStylesheet: BStylesheet { traits.verticalSizeClass == .regular, !traits.contentSizeCategory.isAccessibilitySize { - adaptiveHistory.panes = .multiple + adaptivePane.panes = .multiple } developerOverlay.launcher.diameter = BScaledDimension.value( diff --git a/Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift b/Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift deleted file mode 100644 index bd42dc86d..000000000 --- a/Where/WhereUI/Tests/AdaptiveHistoryArrangementTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import CoreGraphics -import Testing -@testable import WhereUI - -struct AdaptiveHistoryArrangementTests { - private let style = AdaptiveHistoryTestSupport.multiplePaneStyle - - @Test func anActiveHorizontalDivisionSeparatesHistoryAndControls() { - var geometry = AdaptiveHistoryTestSupport.portrait - geometry.regions = [AdaptiveHistoryTestSupport.horizontalDivision] - let arrangement = AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) - guard case let .tabletop(panes) = arrangement else { - Issue.record("A usable horizontal division must choose tabletop.") - return - } - #expect(panes.history == 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.history.maxY == 40) - } - - @Test func aFlatDisplayIgnoresItsInactiveDivision() { - var geometry = AdaptiveHistoryTestSupport.portrait - var division = AdaptiveHistoryTestSupport.horizontalDivision - division.isActive = false - geometry.regions = [division] - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) - } - - @Test func verticalDivisionsRetainBrowseAndDetail() { - let geometry = AdaptiveHistoryGeometry( - 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(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) - } - - @Test func insufficientSpaceAndSinglePanePolicyCollapse() { - var geometry = AdaptiveHistoryTestSupport.portrait - geometry.bounds.size.width = style.minimumControlsWidth + style.minimumHistoryWidth - 1 - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) - geometry = AdaptiveHistoryTestSupport.portrait - geometry.regions = [AdaptiveHistoryTestSupport.horizontalDivision] - var single = style - single.panes = .single - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: single) == .compact) - geometry.regions[0].frame.origin.y = 200 - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) - } - - @Test func sideBarsParticipateInTheFitDecision() { - var geometry = AdaptiveHistoryTestSupport.portrait - geometry.insets.left = 40 - geometry.insets.right = 84 - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) - } - - @Test func rightToLeftPlacesTheHistoryWidthOnTheOppositeSide() { - var geometry = AdaptiveHistoryTestSupport.portrait - geometry.bounds.size.width = 780 - geometry.regions = [.init( - kind: .division, - frame: CGRect(x: 300, y: 0, width: 40, height: 951), - isActive: true, - )] - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .columns) - geometry.layoutDirection = .rightToLeft - #expect(AdaptiveHistoryArrangement.resolve(geometry: geometry, style: style) == .compact) - } - - @Test(arguments: [true, false]) - func tabletopAvoidsOnlyActiveOcclusions(isActive: Bool) { - var geometry = AdaptiveHistoryTestSupport.portrait - let camera = CGRect(x: 21, y: 210, width: 37, height: 58) - geometry.regions = [ - AdaptiveHistoryTestSupport.horizontalDivision, - .init(kind: .occlusion, frame: camera, isActive: isActive), - ] - guard case let .tabletop(panes) = AdaptiveHistoryArrangement.resolve( - geometry: geometry, - style: style, - ) else { - Issue.record("The camera leaves sufficient space for tabletop.") - return - } - #expect(panes.history.intersection(camera).isEmpty == isActive) - #expect(panes.controls.maxY == geometry.contentBounds.maxY) - } -} 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/AdaptiveHistoryContainerTests.swift b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift similarity index 72% rename from Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift rename to Where/WhereUI/Tests/AdaptivePaneContainerTests.swift index d9d5f4a62..7709155bb 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryContainerTests.swift +++ b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift @@ -5,11 +5,11 @@ import Testing @_spi(Testing) @testable import WhereUI @MainActor -struct AdaptiveHistoryContainerTests { +struct AdaptivePaneContainerTests { @Test func oneMountedContainerRespondsToBoundsTraitsAndReservedRegions() async throws { - let state = AdaptiveHistoryHostState() - let host = UIHostingController(rootView: AdaptiveHistoryHostedSurface(state: state) { - AdaptiveHistoryContainer { arrangement in + 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 } @@ -26,12 +26,12 @@ struct AdaptiveHistoryContainerTests { .narrow, .outerPortrait, ] { - try await AdaptiveHistoryTestSupport.resize(controller, state: state, to: pose) - let expected: AdaptiveHistoryArrangement = switch pose { + 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( - history: CGRect(x: 0, y: 0, width: 669, height: 455.5), + detail: CGRect(x: 0, y: 0, width: 669, height: 455.5), controls: CGRect(x: 0, y: 495.5, width: 669, height: 455.5), )) } @@ -39,7 +39,7 @@ struct AdaptiveHistoryContainerTests { state.arrangement == expected } if case let .tabletop(panes) = state.arrangement { - #expect(panes.history.maxY == 455.5) + #expect(panes.detail.maxY == 455.5) #expect(panes.controls.minY == 495.5) } } diff --git a/Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift b/Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift similarity index 82% rename from Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift rename to Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift index fb92859fa..ed3fad98b 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryGeometryTests.swift +++ b/Where/WhereUI/Tests/AdaptivePaneGeometryTests.swift @@ -2,9 +2,9 @@ import CoreGraphics import Testing @testable import WhereUI -struct AdaptiveHistoryGeometryTests { +struct AdaptivePaneGeometryTests { @Test func independentInsetsRetainTheContainersCoordinateSpace() { - let geometry = AdaptiveHistoryGeometry( + let geometry = AdaptivePaneGeometry( bounds: CGRect(x: 20, y: 30, width: 800, height: 600), insets: .init(top: 20, left: 50, bottom: 30, right: 80), regions: [], @@ -14,7 +14,7 @@ struct AdaptiveHistoryGeometryTests { } @Test func insetsNeverProduceNegativePaneDimensions() { - var geometry = AdaptiveHistoryTestSupport.portrait + var geometry = AdaptivePaneTestSupport.portrait geometry.bounds.size = CGSize(width: 20, height: 20) geometry.insets.left = 30 #expect(geometry.contentBounds.width == 0) @@ -22,8 +22,8 @@ struct AdaptiveHistoryGeometryTests { } @Test func floatingControlsChooseTheirSideOfAnActiveHinge() { - var geometry = AdaptiveHistoryTestSupport.portrait - geometry.regions = [AdaptiveHistoryTestSupport.horizontalDivision] + 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) @@ -34,9 +34,9 @@ struct AdaptiveHistoryGeometryTests { } @Test func floatingControlsAvoidAnActiveCameraWithoutMirroringRTL() { - var geometry = AdaptiveHistoryTestSupport.portrait + var geometry = AdaptivePaneTestSupport.portrait geometry.layoutDirection = .rightToLeft - let camera = AdaptiveHistoryGeometry.Region( + let camera = AdaptivePaneGeometry.Region( kind: .occlusion, frame: CGRect(x: 600, y: 820, width: 69, height: 97), isActive: true, diff --git a/Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift b/Where/WhereUI/Tests/AdaptivePaneStyleTests.swift similarity index 62% rename from Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift rename to Where/WhereUI/Tests/AdaptivePaneStyleTests.swift index d6d05eb83..dd46a8760 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryStyleTests.swift +++ b/Where/WhereUI/Tests/AdaptivePaneStyleTests.swift @@ -2,11 +2,11 @@ import SwiftUI import Testing @testable import WhereUI -struct AdaptiveHistoryStyleTests { - @Test func defaultsRequireEnoughRoomForReadableHistoryAndControls() { - let style = WhereStylesheet.default.adaptiveHistory +struct AdaptivePaneStyleTests { + @Test func defaultsRequireEnoughRoomForReadableDetailAndControls() { + let style = WhereStylesheet.default.adaptivePane #expect(style.panes == .single) - #expect(style.minimumHistoryWidth == 320) + #expect(style.minimumDetailWidth == 320) #expect(style.minimumControlsWidth == 280) #expect(style.minimumPaneHeight == 220) #expect(style.selection.cornerRadius == 8) diff --git a/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift similarity index 71% rename from Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift rename to Where/WhereUI/Tests/AdaptivePaneTestSupport.swift index 59bce3544..e3431fc94 100644 --- a/Where/WhereUI/Tests/AdaptiveHistoryTestSupport.swift +++ b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift @@ -5,10 +5,10 @@ import SwiftUI import TestHostSupport @_spi(Testing) @testable import WhereUI -enum AdaptiveHistoryTestSupport { +enum AdaptivePaneTestSupport { /// Native inner-portrait measurements; frames include reserved-region margins. - static var portrait: AdaptiveHistoryGeometry { - AdaptiveHistoryGeometry( + 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: [], @@ -16,7 +16,7 @@ enum AdaptiveHistoryTestSupport { ) } - static var horizontalDivision: AdaptiveHistoryGeometry.Region { + static var horizontalDivision: AdaptivePaneGeometry.Region { .init( kind: .division, frame: CGRect(x: 0, y: 455.5, width: 669, height: 40), @@ -24,51 +24,51 @@ enum AdaptiveHistoryTestSupport { ) } - static var multiplePaneStyle: WhereStylesheet.AdaptiveHistoryStyle { - var style = WhereStylesheet.AdaptiveHistoryStyle() + static var multiplePaneStyle: WhereStylesheet.AdaptivePaneStyle { + var style = WhereStylesheet.AdaptivePaneStyle() style.panes = .multiple return style } } @MainActor @Observable -final class AdaptiveHistoryHostState { +final class AdaptivePaneHostState { var pose = SnapshotConfiguration.Pose.outerPortrait var renderedSize: CGSize = .zero - var panes = WhereStylesheet.AdaptiveHistoryStyle.Panes.single - var arrangement: AdaptiveHistoryArrangement? + var panes = WhereStylesheet.AdaptivePaneStyle.Panes.single + var arrangement: AdaptivePaneArrangement? } -struct AdaptiveHistoryHostedSurface: View { - let state: AdaptiveHistoryHostState +struct AdaptivePaneHostedSurface: View { + let state: AdaptivePaneHostState @ViewBuilder var content: () -> Content var body: some View { content() - .adaptiveHistoryReservedRegions(state.pose.regions) - .overlay { AdaptiveHistoryPolicyProbe(state: state) } + .adaptivePaneReservedRegions(state.pose.regions) + .overlay { AdaptivePanePolicyProbe(state: state) } .whereBroadwayRoot() .onGeometryChange(for: CGSize.self) { $0.size } action: { state.renderedSize = $0 } } } -private struct AdaptiveHistoryPolicyProbe: View { - let state: AdaptiveHistoryHostState +private struct AdaptivePanePolicyProbe: View { + let state: AdaptivePaneHostState @Environment(\.stylesheet) private var stylesheet var body: some View { Color.clear .allowsHitTesting(false) - .onChange(of: stylesheet.adaptiveHistory.panes, initial: true) { _, panes in + .onChange(of: stylesheet.adaptivePane.panes, initial: true) { _, panes in state.panes = panes } } } -extension AdaptiveHistoryTestSupport { +extension AdaptivePaneTestSupport { @MainActor static func resize( _ controller: UIViewController, - state: AdaptiveHistoryHostState, + state: AdaptivePaneHostState, to pose: SnapshotConfiguration.Pose, ) async throws { state.pose = pose diff --git a/Where/WhereUI/Tests/HistorySelectionModelTests.swift b/Where/WhereUI/Tests/PresenceSelectionModelTests.swift similarity index 92% rename from Where/WhereUI/Tests/HistorySelectionModelTests.swift rename to Where/WhereUI/Tests/PresenceSelectionModelTests.swift index 79dbec1ee..a6bb5de34 100644 --- a/Where/WhereUI/Tests/HistorySelectionModelTests.swift +++ b/Where/WhereUI/Tests/PresenceSelectionModelTests.swift @@ -3,11 +3,11 @@ import WhereCore @testable import WhereUI @MainActor -struct HistorySelectionModelTests { +struct PresenceSelectionModelTests { private let calendar = HistoryNavigationTestSupport.calendar @Test func eachPaneConsumesItsRequestIndependentlyAndRepeatedSelectionGetsANewID() throws { - let model = HistorySelectionModel() + let model = PresenceSelectionModel() let month = CalendarDay(year: 2026, month: 3, day: 1) model.select(month: month, dateCalendar: calendar) let first = try #require(model.pendingRequest(for: .calendar)) @@ -27,7 +27,7 @@ struct HistorySelectionModelTests { } @Test func aPendingMonthResolvesWhenAnOverlappingStayArrivesOrExtends() { - let model = HistorySelectionModel() + let model = PresenceSelectionModel() let january = CalendarDay(year: 2026, month: 1, day: 20) let february = CalendarDay(year: 2026, month: 2, day: 1) model.select(month: february, dateCalendar: calendar) @@ -56,7 +56,7 @@ struct HistorySelectionModelTests { } @Test func resolvesDisplayedSegmentsAndCrossMonthFlights() throws { - let model = HistorySelectionModel() + let model = PresenceSelectionModel() let january = CalendarDay(year: 2026, month: 1, day: 1) let february = CalendarDay(year: 2026, month: 2, day: 1) let stint = HistoryNavigationTestSupport.stint( @@ -75,10 +75,10 @@ struct HistorySelectionModelTests { ) let displayed = try #require(items.last) model.select(month: february, dateCalendar: calendar) - #expect(HistorySelectionModel.Item(displayed) != .recorded(stint.id)) + #expect(PresenceSelectionModel.Item(displayed) != .recorded(stint.id)) #expect(model .timelineTarget(recordedItems: items, plannedItems: [], calendar: calendar) == - HistorySelectionModel.Item(displayed)) + PresenceSelectionModel.Item(displayed)) let crossing = HistoryNavigationTestSupport.flight( from: february.adding(days: -1), through: february, @@ -91,7 +91,7 @@ struct HistorySelectionModelTests { } @Test func explicitStaySelectionRetainsTypedIdentityAndInterval() { - let model = HistorySelectionModel() + let model = PresenceSelectionModel() let start = CalendarDay(year: 2026, month: 2, day: 27) let end = CalendarDay(year: 2026, month: 3, day: 5) let stint = HistoryNavigationTestSupport.stint(from: start, through: end) diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index dd9c05002..1f928fbee 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -57,9 +57,9 @@ struct WhereStylesheetTests { context.traitOverrides.verticalSizeClass = BVerticalSizeClass(vertical) context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.large) let sheet = try context.stylesheets.get(WhereStylesheet.self) - let expected: WhereStylesheet.AdaptiveHistoryStyle.Panes = + let expected: WhereStylesheet.AdaptivePaneStyle.Panes = horizontal == .regular && vertical == .regular ? .multiple : .single - #expect(sheet.adaptiveHistory.panes == expected) + #expect(sheet.adaptivePane.panes == expected) } @MainActor @@ -68,12 +68,12 @@ struct WhereStylesheetTests { context.traitOverrides.horizontalSizeClass = .regular context.traitOverrides.verticalSizeClass = .regular context.traitOverrides.contentSizeCategory = .init(DynamicTypeSize.large) - let regular = try context.stylesheets.get(WhereStylesheet.self).adaptiveHistory + 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).adaptiveHistory + let accessible = try context.stylesheets.get(WhereStylesheet.self).adaptivePane #expect(accessible.panes == .single) - #expect(accessible.minimumHistoryWidth == regular.minimumHistoryWidth) + #expect(accessible.minimumDetailWidth == regular.minimumDetailWidth) #expect(accessible.minimumControlsWidth == regular.minimumControlsWidth) } From 4acf65c0f02987bf217325780ce2aff1354acacb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Nov 2026 16:38:47 -0500 Subject: [PATCH 08/11] Keep scroll contexts typed and isolate unmounted access --- Shared/Broadway/BroadwayUI/README.md | 3 +++ .../BroadwayUI/Sources/BScrollPosition.swift | 26 ++++++++++++------- .../Tests/BScrollPositionTests.swift | 4 +-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index ee3335858..affed60c1 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -60,7 +60,10 @@ explicit commands. These retain alignment, which `ScrollPosition` does not expos 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`. diff --git a/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift index 70fd49892..1c3936e05 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift @@ -4,13 +4,18 @@ import SwiftUI /// Use distinct roles for independent panes. Context changes discard the previous position. @MainActor @propertyWrapper -public struct BScrollPosition: @MainActor DynamicProperty { +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: () -> AnyHashable + 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 @@ -32,20 +37,20 @@ public struct BScrollPosition: @MainActor DynamicProper @MainActor private final class Mount { private var store: BScrollRestorationStore? - private var context: AnyHashable? + private var context: Context? private var key: BScrollRestorationStore.Key? private var attachment: Attachment? func resolve( store: BScrollRestorationStore, key: BScrollRestorationStore.Key, - context: AnyHashable, + 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: context, + context: AnyHashable(context), idType: ID.self, )) self.store = store @@ -59,7 +64,7 @@ public struct BScrollPosition: @MainActor DynamicProper public init( _ view: (some View).Type, role: some Hashable, - context: some Hashable, + context: Context, idType: ID.Type, ) { key = .init( @@ -67,14 +72,14 @@ public struct BScrollPosition: @MainActor DynamicProper role: AnyHashable(role), content: ObjectIdentifier(idType), ) - self.context = { AnyHashable(context) } + 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 () -> some Hashable, + context: @escaping @MainActor () -> Context, idType: ID.Type, ) { key = .init( @@ -82,7 +87,7 @@ public struct BScrollPosition: @MainActor DynamicProper role: AnyHashable(role), content: ObjectIdentifier(idType), ) - self.context = { AnyHashable(context()) } + self.context = context } public mutating func update() { @@ -97,7 +102,8 @@ public struct BScrollPosition: @MainActor DynamicProper private var attachment: Attachment { guard let resolvedAttachment else { - preconditionFailure("BScrollPosition must be read from a mounted view") + assertionFailure("BScrollPosition must be read from a mounted view") + return unmountedAttachment } return resolvedAttachment } diff --git a/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift index 1c7e43ae8..001dee755 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BScrollPositionTests.swift @@ -242,7 +242,7 @@ private struct ScrollProbe: View { let role: ScrollRole let context: Int let redraw: Int - @BScrollPosition private var position: ScrollPosition + @BScrollPosition private var position: ScrollPosition init(fixture: ScrollFixture, role: ScrollRole, context: Int, redraw: Int) { self.fixture = fixture @@ -293,7 +293,7 @@ private struct NativeScrollHarness: View { private struct NativeScrollProbe: View { let fixture: NativeScrollFixture - @BScrollPosition private var position: ScrollPosition + @BScrollPosition private var position: ScrollPosition init(fixture: NativeScrollFixture) { self.fixture = fixture From eb9dd29bc25433a5112ba06e37994aad9e78b7dd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Nov 2026 16:38:48 -0500 Subject: [PATCH 09/11] Name shared Duo poses explicitly and own their localized labels --- Package.swift | 1 + Shared/SnapshotKit/AGENTS.md | 2 +- Shared/SnapshotKit/README.md | 6 +- .../Sources/Resources/Localizable.xcstrings | 83 +++++++++++++++++++ ...apshotPose.swift => SnapshotDuoPose.swift} | 30 ++++++- ...Tests.swift => SnapshotDuoPoseTests.swift} | 20 +++-- .../DuoLayout/SnapshotDuoPose+WhereUI.swift | 20 +++++ .../DuoLayout/SnapshotPose+WhereUI.swift | 32 ------- .../Sources/Resources/Localizable.xcstrings | 77 ----------------- .../Tests/AdaptivePaneContainerTests.swift | 2 +- .../Tests/AdaptivePaneTestSupport.swift | 4 +- 11 files changed, 148 insertions(+), 129 deletions(-) create mode 100644 Shared/SnapshotKit/Sources/Resources/Localizable.xcstrings rename Shared/SnapshotKit/Sources/{SnapshotPose.swift => SnapshotDuoPose.swift} (65%) rename Shared/SnapshotKit/Tests/{SnapshotPoseTests.swift => SnapshotDuoPoseTests.swift} (56%) create mode 100644 Where/WhereUI/Sources/Developer/DuoLayout/SnapshotDuoPose+WhereUI.swift delete mode 100644 Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift 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/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index 6aa41ad2f..3c9dfb4bf 100644 --- a/Shared/SnapshotKit/AGENTS.md +++ b/Shared/SnapshotKit/AGENTS.md @@ -52,7 +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.Pose`.** Keep app labels and production geometry adapters in consumers. Preserve the existing frame identifiers when adding fold-region fixtures. +- **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 81ac16f28..b536e5262 100644 --- a/Shared/SnapshotKit/README.md +++ b/Shared/SnapshotKit/README.md @@ -226,11 +226,11 @@ 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.Pose` composes these frames into seven synthetic poses for +`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. Consumers own localized labels -and adapt the generic regions to their production geometry seam. +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) | | --- | --- | --- | --- | 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/SnapshotPose.swift b/Shared/SnapshotKit/Sources/SnapshotDuoPose.swift similarity index 65% rename from Shared/SnapshotKit/Sources/SnapshotPose.swift rename to Shared/SnapshotKit/Sources/SnapshotDuoPose.swift index fa04317ba..35cebfd39 100644 --- a/Shared/SnapshotKit/Sources/SnapshotPose.swift +++ b/Shared/SnapshotKit/Sources/SnapshotDuoPose.swift @@ -1,18 +1,40 @@ 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 Pose: String, CaseIterable, Identifiable, Sendable { - case outerPortrait, outerLandscape, innerPortrait, innerLandscape - case tabletop, book, narrow + 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, occlusion } + public enum Kind: Hashable, Sendable { + case division + case occlusion + } + public let kind: Kind public let frame: CGRect public let isActive: Bool diff --git a/Shared/SnapshotKit/Tests/SnapshotPoseTests.swift b/Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift similarity index 56% rename from Shared/SnapshotKit/Tests/SnapshotPoseTests.swift rename to Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift index a7a67b304..9bf861754 100644 --- a/Shared/SnapshotKit/Tests/SnapshotPoseTests.swift +++ b/Shared/SnapshotKit/Tests/SnapshotDuoPoseTests.swift @@ -1,21 +1,23 @@ import SnapshotKit import Testing -struct SnapshotPoseTests { +struct SnapshotDuoPoseTests { @Test func foldsReuseFramesWithoutChangingCaptureIdentity() { - #expect(SnapshotConfiguration(device: SnapshotConfiguration.Pose.tabletop.frame) + #expect(SnapshotConfiguration(device: SnapshotConfiguration.DuoPose.tabletop.frame) .identifier == "duoInnerPortrait") - #expect(SnapshotConfiguration(device: SnapshotConfiguration.Pose.book.frame) + #expect(SnapshotConfiguration(device: SnapshotConfiguration.DuoPose.book.frame) .identifier == "duoInnerLandscape") - #expect(SnapshotConfiguration.Pose.tabletop.size == SnapshotConfiguration.Pose.innerPortrait + #expect(SnapshotConfiguration.DuoPose.tabletop.size == SnapshotConfiguration.DuoPose + .innerPortrait .size) - #expect(SnapshotConfiguration.Pose.book.size == SnapshotConfiguration.Pose.innerLandscape + #expect(SnapshotConfiguration.DuoPose.book.size == SnapshotConfiguration.DuoPose + .innerLandscape .size) } @Test func foldingActivatesTheSameReservedDivision() throws { for pair in [ - [SnapshotConfiguration.Pose.innerPortrait, .tabletop], + [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop], [.innerLandscape, .book], ] { let flat = try #require(pair[0].reservedRegions.first) @@ -25,8 +27,8 @@ struct SnapshotPoseTests { #expect(folded.isActive) #expect(folded.kind == .division) } - #expect(SnapshotConfiguration.Pose.outerPortrait.reservedRegions.isEmpty) - #expect(SnapshotConfiguration.Pose.outerLandscape.reservedRegions.isEmpty) - #expect(SnapshotConfiguration.Pose.narrow.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.DuoPose.outerPortrait.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.DuoPose.outerLandscape.reservedRegions.isEmpty) + #expect(SnapshotConfiguration.DuoPose.narrow.reservedRegions.isEmpty) } } 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/Developer/DuoLayout/SnapshotPose+WhereUI.swift b/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift deleted file mode 100644 index e2990b369..000000000 --- a/Where/WhereUI/Sources/Developer/DuoLayout/SnapshotPose+WhereUI.swift +++ /dev/null @@ -1,32 +0,0 @@ -#if DEBUG - import SnapshotKit - import SwiftUI - - extension SnapshotConfiguration.Pose { - var title: String { - switch self { - case .outerPortrait: String(localized: .developerDuoOuterPortrait) - case .outerLandscape: String(localized: .developerDuoOuterLandscape) - case .innerPortrait: String(localized: .developerDuoInnerPortrait) - case .innerLandscape: String(localized: .developerDuoInnerLandscape) - case .tabletop: String(localized: .developerDuoTabletop) - case .book: String(localized: .developerDuoBook) - case .narrow: String(localized: .developerDuoNarrow) - } - } - - 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/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 5b9e31ab8..b14b8aac2 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2853,50 +2853,6 @@ } } }, - "developer.duo.book" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Book" - } - } - } - }, - "developer.duo.innerLandscape" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open · Landscape" - } - } - } - }, - "developer.duo.innerPortrait" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open · Portrait" - } - } - } - }, - "developer.duo.narrow" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Narrow window" - } - } - } - }, "developer.duo.note" : { "extractionState" : "manual", "localizations" : { @@ -2908,28 +2864,6 @@ } } }, - "developer.duo.outerLandscape" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Closed · Landscape" - } - } - } - }, - "developer.duo.outerPortrait" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Closed · Portrait" - } - } - } - }, "developer.duo.pose" : { "extractionState" : "manual", "localizations" : { @@ -2952,17 +2886,6 @@ } } }, - "developer.duo.tabletop" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tabletop" - } - } - } - }, "developer.duo.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift index 7709155bb..2989c6606 100644 --- a/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift +++ b/Where/WhereUI/Tests/AdaptivePaneContainerTests.swift @@ -19,7 +19,7 @@ struct AdaptivePaneContainerTests { try await show(host) { controller in for _ in 0 ..< 2 { for pose in [ - SnapshotConfiguration.Pose.outerPortrait, + SnapshotConfiguration.DuoPose.outerPortrait, .innerPortrait, .tabletop, .book, diff --git a/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift index e3431fc94..5e58b1dc9 100644 --- a/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift +++ b/Where/WhereUI/Tests/AdaptivePaneTestSupport.swift @@ -33,7 +33,7 @@ enum AdaptivePaneTestSupport { @MainActor @Observable final class AdaptivePaneHostState { - var pose = SnapshotConfiguration.Pose.outerPortrait + var pose = SnapshotConfiguration.DuoPose.outerPortrait var renderedSize: CGSize = .zero var panes = WhereStylesheet.AdaptivePaneStyle.Panes.single var arrangement: AdaptivePaneArrangement? @@ -69,7 +69,7 @@ extension AdaptivePaneTestSupport { @MainActor static func resize( _ controller: UIViewController, state: AdaptivePaneHostState, - to pose: SnapshotConfiguration.Pose, + to pose: SnapshotConfiguration.DuoPose, ) async throws { state.pose = pose controller.view.frame = CGRect(origin: .zero, size: pose.size) From 7aa3146fa8b21c54c758d740ee93172fd9904ff8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Nov 2026 16:38:48 -0500 Subject: [PATCH 10/11] Remove the unused cross-pane selection coordinator groundwork --- Where/WhereUI/AGENTS.md | 3 +- Where/WhereUI/README.md | 10 +- .../Model/PresenceSelectionModel.swift | 126 ------------------ .../Tests/HistoryNavigationTestSupport.swift | 45 ------- .../Tests/PresenceSelectionModelTests.swift | 113 ---------------- 5 files changed, 6 insertions(+), 291 deletions(-) delete mode 100644 Where/WhereUI/Sources/Model/PresenceSelectionModel.swift delete mode 100644 Where/WhereUI/Tests/HistoryNavigationTestSupport.swift delete mode 100644 Where/WhereUI/Tests/PresenceSelectionModelTests.swift diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 41b8f4846..0d2c31daa 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -162,8 +162,7 @@ Layering, localization, preview, and testing conventions live in the feature - 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`. -- Link explicit month and stay selections through `PresenceSelectionModel`. Keep domain identities typed. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move a companion pane. -- Give each explicit month reveal a typed request identity and retain it when consumed. Resolve anchors against displayed stay segments and flight IDs. Guard: `PresenceSelectionModelTests`. +- 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. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index cef230a12..8f08ec61b 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -702,11 +702,11 @@ cases keep their device traits, content clearance, and full-content sizing. ## Adaptive pane foundations -Stable destination views own navigation and presentation state. `PresenceSelectionModel` -connects explicit month and stay selections through requests consumed independently -by each pane. BroadwayUI retains scroll positions for the current context only. -Changing region or year discards the old position; ordinary scrolling never selects -or moves a companion pane. Synthetic pose geometry comes from `SnapshotConfiguration.Pose`. +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 diff --git a/Where/WhereUI/Sources/Model/PresenceSelectionModel.swift b/Where/WhereUI/Sources/Model/PresenceSelectionModel.swift deleted file mode 100644 index 0405ff914..000000000 --- a/Where/WhereUI/Sources/Model/PresenceSelectionModel.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation -import Observation -import RegionKit -import WhereCore - -/// Connects explicit selections to displayed history data. It owns no scroll or presentation state. -@MainActor @Observable -final class PresenceSelectionModel { - enum Pane: Hashable { case calendar, timeline } - - struct Selection: Equatable { - let interval: ClosedRange - let region: Region? - } - - enum Item: Hashable { - case recorded(RegionStint.ID) - case flight(FlightAssessment.ID) - case planned(PlanningTimelineItem.ID) - - init(_ item: PresenceJourneyItem) { - self = switch item { - case let .stay(stint): .recorded(stint.id) - case let .flight(flight): .flight(flight.id) - } - } - } - - struct Request: Identifiable { - struct ID: Hashable { private let value = UUID() } - let id = ID() - let month: CalendarDay - let item: Item? - } - - private(set) var selection: Selection? - private var request: Request? - private var consumed: Set = [] - - var requestID: Request.ID? { - request?.id - } - - func pendingRequest(for pane: Pane) -> Request? { - consumed.contains(pane) ? nil : request - } - - func consume(_ requestID: Request.ID, by pane: Pane) { - guard request?.id == requestID else { return } - consumed.insert(pane) - } - - func reset() { - selection = nil - request = nil - consumed = [] - } - - private func select(_ selection: Selection, item: Item?) { - self.selection = selection - let start = selection.interval.lowerBound - request = Request( - month: CalendarDay(year: start.year, month: start.month, day: 1), - item: item, - ) - consumed = [] - } - - func select(month: CalendarDay, dateCalendar: Calendar) { - let start = CalendarDay(year: month.year, month: month.month, day: 1) - guard let interval = dateCalendar.dateInterval( - of: .month, - for: start.startOfDay(in: dateCalendar), - ) else { return } - let end = CalendarDay(from: interval.end, in: dateCalendar).adding(days: -1) - select(Selection(interval: start ... end, region: nil), item: nil) - } - - func select(stint: RegionStint, dateCalendar: Calendar) { - let start = CalendarDay(from: stint.start, in: dateCalendar) - let end = CalendarDay(from: stint.end, in: dateCalendar) - select(Selection(interval: start ... end, region: stint.region), item: .recorded(stint.id)) - } - - func select(plannedItem: PlanningTimelineItem) { - select( - Selection(interval: plannedItem.start ... plannedItem.end, region: plannedItem.region), - item: .planned(plannedItem.id), - ) - } - - func selectedInterval(in year: Int) -> ClosedRange? { - guard let selection, selection.interval.lowerBound.year <= year, - selection.interval.upperBound.year >= year else { return nil } - return max(selection.interval.lowerBound, CalendarDay(year: year, month: 1, day: 1)) - ... min(selection.interval.upperBound, .lastDay(ofYear: year)) - } - - /// Resolve against the rows actually displayed. An unresolved request remains pending while - /// loading. - func timelineTarget( - recordedItems: [PresenceJourneyItem], - plannedItems: [PlanningTimelineItem], - calendar: Calendar, - ) -> Item? { - guard let request = pendingRequest(for: .timeline) else { return nil } - if let item = request.item { - return recordedItems.contains(where: { Item($0) == item }) - || plannedItems.contains(where: { .planned($0.id) == item }) ? item : nil - } - let start = request.month - guard let interval = calendar.dateInterval(of: .month, for: start.startOfDay(in: calendar)) - else { return nil } - let end = CalendarDay(from: interval.end, in: calendar).adding(days: -1) - if let item = recordedItems.first(where: { item in - switch item { - case let .stay(stint): - CalendarDay(from: stint.start, in: calendar) <= end - && CalendarDay(from: stint.end, in: calendar) >= start - case let .flight(flight): - flight.completedDays(in: calendar).contains { $0 >= start && $0 <= end } - } - }) { return Item(item) } - return plannedItems.first { $0.start <= end && $0.end >= start }.map { .planned($0.id) } - } -} diff --git a/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift b/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift deleted file mode 100644 index c766e4f88..000000000 --- a/Where/WhereUI/Tests/HistoryNavigationTestSupport.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import RegionKit -import WhereCore -@testable import WhereUI - -enum HistoryNavigationTestSupport { - static var calendar: Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .gmt - return calendar - } - - static func stint( - from start: CalendarDay, - through end: CalendarDay, - region: Region = .california, - ) -> RegionStint { - RegionStint( - region: region, - start: start.startOfDay(in: calendar), - end: end.startOfDay(in: calendar), - dayCount: start.days(through: end).count, - ) - } - - static func flight(from start: CalendarDay, through end: CalendarDay) -> FlightAssessment { - let departure = start.startOfDay(in: calendar).addingTimeInterval(12 * 60 * 60) - let arrival = end.startOfDay(in: calendar).addingTimeInterval(14 * 60 * 60) - return FlightAssessment( - id: .init(recordingSource: .legacy, departureSampleID: UUID()), - startedAt: departure, - lastObservationAt: arrival, - lastFlightAt: arrival.addingTimeInterval(-60 * 60), - airborneSampleIDs: [], - groundSampleIDs: [], - peakSpeedKMH: 900, - progress: .completed(arrival: .observedGround( - firstObservedAt: arrival, - confirmedAt: arrival, - )), - inferredEndpoints: [], - supportedIntervals: [.init(start: departure, end: arrival, kind: .observedMotion)], - ) - } -} diff --git a/Where/WhereUI/Tests/PresenceSelectionModelTests.swift b/Where/WhereUI/Tests/PresenceSelectionModelTests.swift deleted file mode 100644 index a6bb5de34..000000000 --- a/Where/WhereUI/Tests/PresenceSelectionModelTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct PresenceSelectionModelTests { - private let calendar = HistoryNavigationTestSupport.calendar - - @Test func eachPaneConsumesItsRequestIndependentlyAndRepeatedSelectionGetsANewID() throws { - let model = PresenceSelectionModel() - let month = CalendarDay(year: 2026, month: 3, day: 1) - model.select(month: month, dateCalendar: calendar) - let first = try #require(model.pendingRequest(for: .calendar)) - model.consume(first.id, by: .calendar) - #expect(model.requestID == first.id) - #expect(model.pendingRequest(for: .calendar) == nil) - #expect(model.pendingRequest(for: .timeline)?.id == first.id) - model.select(month: month, dateCalendar: calendar) - let second = try #require(model.pendingRequest(for: .calendar)) - #expect(second.id != first.id) - model.consume(first.id, by: .calendar) - #expect(model.pendingRequest(for: .calendar)?.id == second.id) - #expect(model.selection?.interval == month ... CalendarDay(year: 2026, month: 3, day: 31)) - model.reset() - #expect(model.selection == nil) - #expect(model.requestID == nil) - } - - @Test func aPendingMonthResolvesWhenAnOverlappingStayArrivesOrExtends() { - let model = PresenceSelectionModel() - let january = CalendarDay(year: 2026, month: 1, day: 20) - let february = CalendarDay(year: 2026, month: 2, day: 1) - model.select(month: february, dateCalendar: calendar) - let short = HistoryNavigationTestSupport.stint( - from: january, - through: january.adding(days: 1), - ) - #expect(model - .timelineTarget(recordedItems: [], plannedItems: [], calendar: calendar) == nil) - #expect(model.timelineTarget( - recordedItems: [.stay(short)], - plannedItems: [], - calendar: calendar, - ) == nil) - let extended = HistoryNavigationTestSupport.stint( - from: january, - through: february.adding(days: 2), - ) - #expect(short.id == extended.id) - #expect(model.timelineTarget( - recordedItems: [.stay(extended)], - plannedItems: [], - calendar: calendar, - ) == .recorded(extended.id)) - #expect(model.pendingRequest(for: .timeline) != nil) - } - - @Test func resolvesDisplayedSegmentsAndCrossMonthFlights() throws { - let model = PresenceSelectionModel() - let january = CalendarDay(year: 2026, month: 1, day: 1) - let february = CalendarDay(year: 2026, month: 2, day: 1) - let stint = HistoryNavigationTestSupport.stint( - from: january, - through: february.adding(days: 9), - ) - let flight = HistoryNavigationTestSupport.flight( - from: february.adding(days: -1), - through: february.adding(days: -1), - ) - let items = PresenceJourneyItem.items( - stints: [stint], - flights: [flight], - year: 2026, - calendar: calendar, - ) - let displayed = try #require(items.last) - model.select(month: february, dateCalendar: calendar) - #expect(PresenceSelectionModel.Item(displayed) != .recorded(stint.id)) - #expect(model - .timelineTarget(recordedItems: items, plannedItems: [], calendar: calendar) == - PresenceSelectionModel.Item(displayed)) - let crossing = HistoryNavigationTestSupport.flight( - from: february.adding(days: -1), - through: february, - ) - #expect(model.timelineTarget( - recordedItems: [.flight(crossing)], - plannedItems: [], - calendar: calendar, - ) == .flight(crossing.id)) - } - - @Test func explicitStaySelectionRetainsTypedIdentityAndInterval() { - let model = PresenceSelectionModel() - let start = CalendarDay(year: 2026, month: 2, day: 27) - let end = CalendarDay(year: 2026, month: 3, day: 5) - let stint = HistoryNavigationTestSupport.stint(from: start, through: end) - model.select(stint: stint, dateCalendar: calendar) - #expect(model.selection?.interval == start ... end) - #expect(model.selection?.region == .california) - #expect(model.pendingRequest(for: .calendar)?.month == CalendarDay( - year: 2026, - month: 2, - day: 1, - )) - #expect(model.timelineTarget( - recordedItems: [.stay(stint)], - plannedItems: [], - calendar: calendar, - ) == .recorded(stint.id)) - #expect(model.selectedInterval(in: 2025) == nil) - } -} From fd1fd30ad67f8627c22b20707d6101c69df80681 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Nov 2026 16:38:48 -0500 Subject: [PATCH 11/11] Describe the geometry reader overlay consumers --- Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift index 22f184331..5b8aa1adf 100644 --- a/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift +++ b/Where/WhereUI/Sources/Shared/AdaptivePaneContainer.swift @@ -17,7 +17,8 @@ struct AdaptivePaneContainer: View { } } -/// Shares the native/injected coordinate mapping with custom chrome above the destinations. +/// 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