diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 712db69d7..07a79a581 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -179,3 +179,9 @@ directory. Delete or reshape the rule if the repository deliberately adopts a different logging vocabulary layout. Bumper's standard `singleNominalSpelling` shaper expresses the invariant; no custom syntax rule is needed. + +## ViewDocument boundaries + +ViewDocumentCore uses Foundation only. ViewDocumentUI depends on Core and system UI frameworks. +Both reject Broadway, Where, and SnapshotKit imports. The `ViewDocument rejects consumer dependencies` mutation test covers these boundaries. +Asset providers supply prepared images. The import rules do not inspect image contents or enforce resource limits; document validation owns those checks. diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 389cafd2d..207ddb39d 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -201,3 +201,45 @@ func `Where consumers can use InstallerKit`() throws { )) #expect(report.violations.isEmpty) } + +@Test +func `ViewDocument rejects consumer dependencies`() throws { + for component in [WhereComponent.viewDocumentCore, .viewDocumentUI] { + let path: RelativeFilePath = component == .viewDocumentCore ? + "Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift" : + "Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift" + let allowed = try bumper.evaluate(RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: path, + component: ComponentID(component.rawValue), + source: component == .viewDocumentCore ? "import Foundation" : + "import Foundation\nimport SwiftUI\nimport UIKit\nimport ViewDocumentCore", + )], + )) + #expect(allowed.violations.isEmpty) + for source in [ + "import WhereUI", + "import BroadwayUI", + "import BroadwayCore", + "import SnapshotKit", + "import SnapshotKitTesting", + ] { + let report = try bumper.evaluate(RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: path, + component: ComponentID(component.rawValue), + source: source, + )], + )) + let violation = try #require(report.violations.first) + #expect(report.violations.count == 1) + let expected: RuleID = source == "import WhereUI" ? .componentBoundary : + .forbiddenImport + #expect(violation.rule.id == expected) + #expect(violation.rule.severity == .error) + #expect(violation.path == path) + } + } +} diff --git a/AGENTS.md b/AGENTS.md index b4c70a518..9d8375332 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,10 @@ Keep the review history in the PR and link the rule to its guard or source. ## Targets +- ViewDocumentCore and ViewDocumentUI provide the independent document renderer. + Keep SnapshotKit in its test bundle; production rendering receives static-motion policy by injection. + See [ViewDocument](Shared/ViewDocument/AGENTS.md). + - For the current list of library products, apps, extensions, and test bundles, read [`Package.swift`](Package.swift) and [`Project.swift`](Project.swift). Each module's own `README.md` / diff --git a/BumperBowling.swift b/BumperBowling.swift index 23672898b..dc5f3d490 100644 --- a/BumperBowling.swift +++ b/BumperBowling.swift @@ -1,6 +1,8 @@ import BumperBowlingCore enum WhereComponent: String, ComponentKey { + case viewDocumentCore + case viewDocumentUI case periscope case loggingTests case installerKit @@ -17,6 +19,8 @@ enum WhereComponent: String, ComponentKey { let bumper = BumperProject { Included { + "Shared/ViewDocument/ViewDocumentCore/Sources" + "Shared/ViewDocument/ViewDocumentUI/Sources" "Shared/Periscope/PeriscopeCore/Sources" "Shared/Periscope/PeriscopeMacros/Sources" "Shared/Periscope/PeriscopeTools/Sources" @@ -51,6 +55,20 @@ let bumper = BumperProject { } Architecture(WhereComponent.self) { + Component(.viewDocumentCore) { + Owns("Shared/ViewDocument/ViewDocumentCore/Sources") + Modules("ViewDocumentCore") + Applies(.whereFoundationLayer) + DoesNotUse("BroadwayCore", "BroadwayUI", "SnapshotKit", "SnapshotKitTesting") + } + Component(.viewDocumentUI) { + Owns("Shared/ViewDocument/ViewDocumentUI/Sources") + Modules("ViewDocumentUI") + MayDependOn(.viewDocumentCore) + Applies(.wherePresentationLayer) + DoesNotUse("BroadwayCore", "BroadwayUI", "SnapshotKit", "SnapshotKitTesting") + } + Component(.periscope) { Owns("Shared/Periscope/PeriscopeCore/Sources") Owns("Shared/Periscope/PeriscopeMacros/Sources") diff --git a/Package.swift b/Package.swift index c25d4df0c..6d2afd1f8 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,8 @@ let package = Package( .macOS(.v26), ], products: [ + .library(name: "ViewDocumentCore", targets: ["ViewDocumentCore"]), + .library(name: "ViewDocumentUI", targets: ["ViewDocumentUI"]), .library(name: "InstallerKit", targets: ["InstallerKit"]), .library(name: "InstallerKitUI", targets: ["InstallerKitUI"]), .library(name: "CreditKit", targets: ["CreditKit"]), @@ -47,6 +49,12 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-syntax", exact: "603.0.2"), ], targets: [ + .target(name: "ViewDocumentCore", path: "Shared/ViewDocument/ViewDocumentCore/Sources"), + .target( + name: "ViewDocumentUI", + dependencies: [.target(name: "ViewDocumentCore")], + path: "Shared/ViewDocument/ViewDocumentUI/Sources", + ), .macro( name: "PeriscopeMacros", dependencies: [ diff --git a/Project.swift b/Project.swift index 47a2f838b..82546c9b5 100644 --- a/Project.swift +++ b/Project.swift @@ -851,6 +851,26 @@ let project = Project( .package(product: "TestHostSupport"), ], ), + unitTests( + name: "ViewDocumentUISnapshotTests", + bundleIdSuffix: "viewdocument.ui.snapshots", + productDependency: "ViewDocumentUI", + sources: ["Shared/ViewDocument/ViewDocumentUI/SnapshotTests/**"], + extraPackageProducts: ["SnapshotKitTesting"], + environmentVariables: snapshotEnvironment, + ), + unitTests( + name: "ViewDocumentCoreTests", + bundleIdSuffix: "viewdocument.core", + productDependency: "ViewDocumentCore", + sources: ["Shared/ViewDocument/ViewDocumentCore/Tests/**"], + ), + unitTests( + name: "ViewDocumentUITests", + bundleIdSuffix: "viewdocument.ui", + productDependency: "ViewDocumentUI", + sources: ["Shared/ViewDocument/ViewDocumentUI/Tests/**"], + ), unitTests( name: "BroadwayCoreTests", bundleIdSuffix: "broadway.core", @@ -912,6 +932,8 @@ let project = Project( "WhereUITests", "WhereIntentsTests", "BroadwayCatalog", + "ViewDocumentCoreTests", + "ViewDocumentUITests", "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", @@ -937,6 +959,8 @@ let project = Project( "WhereTests", "WhereUITests", "WhereIntentsTests", + "ViewDocumentCoreTests", + "ViewDocumentUITests", "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", @@ -975,6 +999,7 @@ let project = Project( name: "StuffSnapshotTests", shared: true, buildAction: .buildAction(targets: [ + "ViewDocumentUISnapshotTests", "WhereUISnapshotTests", "ToolsSnapshotTests", "InstallerKitUISnapshotTests", @@ -984,6 +1009,7 @@ let project = Project( ]), testAction: .targets( [ + "ViewDocumentUISnapshotTests", "WhereUISnapshotTests", "ToolsSnapshotTests", "InstallerKitUISnapshotTests", @@ -995,6 +1021,8 @@ let project = Project( ), ), testScheme(name: "WhereIntentsTests"), + testScheme(name: "ViewDocumentCoreTests"), + testScheme(name: "ViewDocumentUITests"), testScheme(name: "BroadwayCoreTests"), testScheme(name: "BroadwayUITests"), testScheme(name: "BroadwayCatalogTests"), diff --git a/Shared/ViewDocument/AGENTS.md b/Shared/ViewDocument/AGENTS.md new file mode 100644 index 000000000..9b2e37843 --- /dev/null +++ b/Shared/ViewDocument/AGENTS.md @@ -0,0 +1,12 @@ +# ViewDocument + +ViewDocument renders a finite, read-only document vocabulary. See [README.md](README.md) and the repository [rules](../../AGENTS.md). + +- Keep Core Foundation-only. Keep UI dependent only on Core and system frameworks. +- Never import Broadway, Where, or SnapshotKit into production targets. +- Validate before rendering. Keep validated documents immutable and reject missing assets before view construction. +- Keep modifier order stable. Host registries own images and approved symbols; the renderer never resolves URLs or file paths. +- Inject appearance, activity, and static-motion policy. Do not create animation tasks that outlive the view. +- Keep field and case names stable within a schema version. Reject unsupported schema versions. + +Run `./test ViewDocumentCoreTests` and `./test ViewDocumentUITests` for unit coverage. diff --git a/Shared/ViewDocument/README.md b/Shared/ViewDocument/README.md new file mode 100644 index 000000000..febdcef5a --- /dev/null +++ b/Shared/ViewDocument/README.md @@ -0,0 +1,57 @@ +# ViewDocument + +ViewDocument converts a versioned document into read-only SwiftUI content. It supports text, images, shapes, finite groups, ordered modifiers, and transform animations. + +`ViewDocumentCore` owns Codable values and validation. `ViewDocumentUI` renders them. Neither module requires Broadway or Where. + +## Render a document + +1. Prepare images and approved symbols with `ViewDocumentAssets`. +2. Decode with `ValidatedViewDocument.decode(_:resources:limits:)`. +3. Construct `ViewDocumentView` with that document, its assets, appearance values, and rendering policy. + +Decode and validate outside `body`. The renderer initializer throws if its assets differ from the validated resource requirements. +For trait-driven rendering, construct `PreparedViewDocument` once. Pass it to the nonthrowing renderer initializer as appearance inputs change. + +The schema version is 1. Codable enums use Swift's synthesized associated-value representation. Node identities and asset keys encode as strings through their RawRepresentable Codable conformance. + +```json +{ + "schemaVersion": 1, + "root": { + "id": "greeting", + "content": { "text": { "_0": "Hello" } }, + "modifiers": [], + "accessibility": { "automatic": {} } + } +} +``` + +`ViewDocument.Content` and `ViewModifierDescriptor` define the complete vocabulary. Arrays of modifiers apply in source order. Documents contain no executable expressions, actions, bindings, or loops. + +General content preserves text accessibility and supports image labels and grouping. Decoration policy hides the tree from accessibility. All document content is noninteractive. + +Hosts supply semantic background colors and dimension scales. Fonts preserve semantic Dynamic Type behavior. An omitted font weight preserves the system weight of that text style. Use fixed sizes only for intentional fixed artwork. + +Native palette references retain SwiftUI's light, dark, and Increased Contrast colors. Use them for system color meanings instead of captured RGB samples. +Hosts can inject separate background levels through `ViewDocumentAppearance.Backgrounds`. +The single-background initializer uses its supplied color for every level. + +Assets are prepared SwiftUI images keyed by typed identities. Hosts load packaged PNG/JPEG assets and register approved symbols before rendering. Documents cannot access paths or URLs. + +Use `raster(ViewImageDescriptor)` for fit, clipped fill, or native image tiling. The original `image` operation retains its fit behavior. +Tile scale is bounded to prevent excessive texture density. Tiling does not repeat document nodes. + +`ViewArtworkPlacement` describes a host-relative frame and separate paint bounds. Hosts validate placement before rendering. +Sizes have finite point caps. Fixed artwork shrinks uniformly when the host becomes narrower, including its offset. +Leading and trailing anchors, offsets, and outsets follow layout direction. +A host can supply smaller maximum outsets. Geometry then scales artwork, offsets, and paint bounds together to fit those reserved gaps. +Interior artwork cannot declare outsets. Exterior artwork can extend up to 128 points per edge. +The host must clip all transforms to the declared paint bounds and keep those bounds outside layout, hit testing, and accessibility. +The host owns surface composition, edge protection, reserved gaps, and viewport clipping. + +Animations sample elapsed seconds. Curves use linear, quadratic ease-in/out, or cosine ease-in-out interpolation. Autoreversing repetition counts complete forward-and-back cycles. Each animation has a separate static pose. Inactive views render that pose and restart on activation. Finite animations stop requesting frames after their final cycle. + +Validation checks versions, ranges, resource availability, identity uniqueness, and complexity. Standard limits are 1 MiB, depth 16, 256 nodes, and 32 animated nodes. Validation errors carry a document path. Unknown enum operations fail decoding; additive object fields follow Codable's forward-compatible decoding behavior. + +Validation also bounds cumulative scale amplification across modifiers, ancestor nodes, and animation poses. Individually valid scales can otherwise combine into transforms that native rendering cannot display. diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md new file mode 100644 index 000000000..8644c8e02 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -0,0 +1,12 @@ +# ViewDocumentCore + +ViewDocumentCore follows the [group contract](../AGENTS.md) and [repository rules](../../../AGENTS.md). See [README.md](README.md). + +Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. + +Validate modifier nesting, aggregate modifier counts, cumulative scale amplification, and amplified authored geometry before rendering wire input. Include every animation pose in the scale budget. Keep authored geometry costs separate from host layout measurement. + +Validate artwork placement before hosting it. Keep paint outsets finite and separate from layout bounds. Hosts must clip transformed artwork to those paint bounds. + +Keep semantic opacity and color blends finite and depth-bounded. Do not resolve them to captured RGB values. Guard: `ViewColorDescriptorTests`. +Keep native palette colors symbolic and background levels host-supplied. Guard: `ViewDocumentAppearanceTests` in ViewDocumentUI. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md new file mode 100644 index 000000000..f2794fc5d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -0,0 +1,14 @@ +# ViewDocumentCore + +See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. + +Validation also bounds modifier nesting and total modifier work. Standard limits allow 32 modifiers per node and 1,024 per document. + +Scale amplification is limited to 1,000 along each tree path, including all animation poses. Smaller scales do not restore this conservative budget. Sibling branches have separate scale budgets. + +Authored frame, padding, spacing, offset, and effect lengths share a 100-million-point budget after amplification. This rejects combinations that exceed native rendering precision. It is a conservative input budget, not a measurement of the final SwiftUI layout. Hosts still own measured layout and font metrics. + +`ViewArtworkPlacement` validates host-relative artwork independently of the view tree. Call `validate()` before accepting it and `geometry(in:rightToLeft:)` during layout. +Its `Extent` and `Frame` values use portable point coordinates. The host converts them to its rendering framework and enforces paint clipping. + +Portable colors include bounded opacity and perceptual blends. Semantic colors remain semantic through these operations. Color composition has a maximum depth of eight. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift new file mode 100644 index 000000000..7d7604f3a --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Immutable proof that the document satisfies the vocabulary and resource contract. +public struct ValidatedViewDocument: Hashable, Sendable { + public let document: ViewDocument + public let resources: ViewDocumentResources + + public init( + document: ViewDocument, + resources: ViewDocumentResources, + limits: ViewDocumentLimits, + ) throws { + guard limits.bytes > 0, limits.depth > 0, limits.nodes > 0, limits.animatedNodes >= 0, + limits.modifiersPerNode >= 0, limits.modifiers >= 0 + else { + throw ViewDocumentValidationError(path: "$", reason: "Invalid validation limits") + } + guard document.schemaVersion == 1 else { + throw ViewDocumentValidationError( + path: "$.schemaVersion", + reason: "Unsupported schema version", + ) + } + var validator = ViewDocumentValidator(resources: resources, limits: limits) + try validator.visit(document.root, path: "$.root", depth: 1, inheritedScaleAmplification: 1) + guard try JSONEncoder().encode(document).count <= limits.bytes else { + throw ViewDocumentValidationError(path: "$", reason: "Document exceeds byte limit") + } + self.document = document + self.resources = .init( + images: validator.referencedImages, + symbols: validator.referencedSymbols, + ) + } + + public static func decode( + _ data: Data, + resources: ViewDocumentResources, + limits: ViewDocumentLimits, + ) throws -> Self { + guard data.count <= limits.bytes else { + throw ViewDocumentValidationError(path: "$", reason: "Document exceeds byte limit") + } + do { + let document = try JSONDecoder().decode(ViewDocument.self, from: data) + return try Self(document: document, resources: resources, limits: limits) + } catch let error as DecodingError { + let context: DecodingError.Context + switch error { + case let .dataCorrupted(value), let .keyNotFound(_, value), let .typeMismatch( + _, + value, + ), let .valueNotFound(_, value): + context = value + @unknown default: + throw error + } + throw ViewDocumentValidationError( + path: "$" + context.codingPath.map { "." + $0.stringValue }.joined(), + reason: context.debugDescription, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift new file mode 100644 index 000000000..de8522bfc --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ViewAlignment: String, Codable, Hashable, Sendable { + case topLeading, top, topTrailing, leading, center, trailing, bottomLeading, bottom, + bottomTrailing +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift new file mode 100644 index 000000000..6b094fa75 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift @@ -0,0 +1,53 @@ +import Foundation + +extension ViewAnimationDescriptor { + /// Finite playback stops requesting frames after its last complete cycle. + public func isComplete(at elapsed: Double) -> Bool { + switch repetition { + case .once: elapsed >= delay + duration + case let .count(count, autoreverses): + elapsed >= delay + duration * Double(count) * (autoreverses ? 2 : 1) + case .forever: false + } + } + + /// Samples elapsed seconds without wall-clock dependencies or retained animation tasks. + public func pose(at elapsed: Double) -> Pose { + guard elapsed >= delay else { return from } + let time = max(0, elapsed - delay) / duration + let reverses: Bool + let cycles: Double + switch repetition { + case .once: reverses = false; cycles = 1 + case let .count(count, autoreverses): reverses = autoreverses; cycles = Double(count) + case let .forever(autoreverses): reverses = autoreverses; cycles = .infinity + } + let legs = reverses ? 2.0 : 1.0 + let progress: Double + if time >= cycles * legs { + progress = reverses ? 0 : 1 + } else { + let leg = floor(time) + let fraction = time - leg + progress = reverses && leg + .truncatingRemainder(dividingBy: 2) == 1 ? 1 - fraction : fraction + } + let eased: Double = switch curve { + case .linear: progress + case .easeIn: progress * progress + case .easeOut: 1 - (1 - progress) * (1 - progress) + case .easeInOut: (1 - cos(.pi * progress)) / 2 + } + func mix(_ start: Double, _ end: Double) -> Double { + start + (end - start) * eased + } + return Pose( + opacity: mix(from.opacity, to.opacity), + offsetX: mix(from.offsetX, to.offsetX), + offsetY: mix(from.offsetY, to.offsetY), + scaleX: mix(from.scaleX, to.scaleX), + scaleY: mix(from.scaleY, to.scaleY), + rotation: mix(from.rotation, to.rotation), + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift new file mode 100644 index 000000000..7032b5997 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A bounded transform vocabulary with an explicit accessible, deterministic pose. +public struct ViewAnimationDescriptor: Codable, Hashable, Sendable { + public var from: Pose + public var to: Pose + public var staticPose: Pose + public var duration: Double + public var delay: Double + public var curve: Curve + public var repetition: Repetition + + public init( + from: Pose, + to: Pose, + staticPose: Pose, + duration: Double, + delay: Double, + curve: Curve, + repetition: Repetition, + ) { + self.from = from + self.to = to + self.staticPose = staticPose + self.duration = duration + self.delay = delay + self.curve = curve + self.repetition = repetition + } + + public struct Pose: Codable, Hashable, Sendable { + public var opacity: Double + public var offsetX: Double + public var offsetY: Double + public var scaleX: Double + public var scaleY: Double + public var rotation: Double + + public init( + opacity: Double, + offsetX: Double, + offsetY: Double, + scaleX: Double, + scaleY: Double, + rotation: Double, + ) { + self.opacity = opacity + self.offsetX = offsetX + self.offsetY = offsetY + self.scaleX = scaleX + self.scaleY = scaleY + self.rotation = rotation + } + + public static let identity = Self( + opacity: 1, + offsetX: 0, + offsetY: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + ) + } + + public enum Curve: String, Codable, Hashable, Sendable { + case linear, easeIn, easeOut, easeInOut + } + + public enum Repetition: Codable, Hashable, Sendable { + case once + case count(Int, autoreverses: Bool) + case forever(autoreverses: Bool) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift new file mode 100644 index 000000000..383b65839 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Bounded artwork geometry, independent of the host's layout and interaction bounds. +public struct ViewArtworkPlacement: Codable, Hashable, Sendable { + public var anchor: ViewAlignment + public var size: Size + public var offset: Offset + public var plane: Plane + public var outsets: Outsets + + public init(anchor: ViewAlignment, size: Size, offset: Offset, plane: Plane, outsets: Outsets) { + self.anchor = anchor + self.size = size + self.offset = offset + self.plane = plane + self.outsets = outsets + } + + public enum Plane: String, Codable, Hashable, Sendable { + case interior, exterior + } + + /// Relative sizes are capped in points. Fixed sizes shrink uniformly for narrow hosts. + public enum Size: Codable, Hashable, Sendable { + case fixed(width: Double, height: Double) + case relative(width: Double, height: Double, maximumWidth: Double, maximumHeight: Double) + } + + public struct Offset: Codable, Hashable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { + self.x = x; self.y = y + } + + public static let zero = Self(x: 0, y: 0) + } + + public struct Outsets: Codable, Hashable, Sendable { + public var top: Double + public var leading: Double + public var bottom: Double + public var trailing: Double + public init(top: Double, leading: Double, bottom: Double, trailing: Double) { + self.top = top; self.leading = leading; self.bottom = bottom; self.trailing = trailing + } + + public static let zero = Self(top: 0, leading: 0, bottom: 0, trailing: 0) + } + + public func validate() throws { + func number(_ value: Double, _ field: String, _ range: ClosedRange) throws { + guard value.isFinite, range.contains(value) else { + throw ViewDocumentValidationError( + path: "$.placement." + field, + reason: "Invalid artwork geometry", + ) + } + } + switch size { + case let .fixed(width, height): + try number(width, "width", 1 ... 2048) + try number(height, "height", 1 ... 2048) + case let .relative(width, height, maximumWidth, maximumHeight): + try number(width, "width", 0.001 ... 2) + try number(height, "height", 0.001 ... 2) + try number(maximumWidth, "maximumWidth", 1 ... 2048) + try number(maximumHeight, "maximumHeight", 1 ... 2048) + } + try number(offset.x, "offset.x", -2048 ... 2048) + try number(offset.y, "offset.y", -2048 ... 2048) + for value in [outsets.top, outsets.leading, outsets.bottom, outsets.trailing] { + try number(value, "outsets", 0 ... 128) + } + if plane == .interior, outsets != .zero { + throw ViewDocumentValidationError( + path: "$.placement.outsets", + reason: "Interior artwork cannot overflow", + ) + } + } + + /// The renderer must clip to paintBounds; transforms cannot enlarge that contract. + public func geometry(in host: Extent, rightToLeft: Bool) -> Geometry { + geometry(in: host, rightToLeft: rightToLeft, maximumOutsets: nil) + } + + /// The host can reduce its reserved gutters without reducing native content width. + public func geometry( + in host: Extent, + rightToLeft: Bool, + maximumOutsets: Outsets?, + ) -> Geometry { + let width = host.width.isFinite ? max(0, Double(host.width)) : 0 + let height = host.height.isFinite ? max(0, Double(host.height)) : 0 + let proposed = switch size { + case let .fixed(width, height): Extent(width: width, height: height) + case let .relative(x, y, maximumWidth, maximumHeight): + Extent(width: min(width * x, maximumWidth), height: min(height * y, maximumHeight)) + } + var allowanceScale = 1.0 + if let maximumOutsets { + func constrain(_ requested: Double, _ available: Double) { + guard requested > 0 else { return } + let available = available.isFinite ? max(0, available) : 0 + allowanceScale = min(allowanceScale, available / requested) + } + constrain(outsets.top, maximumOutsets.top) + constrain(outsets.leading, maximumOutsets.leading) + constrain(outsets.bottom, maximumOutsets.bottom) + constrain(outsets.trailing, maximumOutsets.trailing) + } + let scale = proposed.width > 0 ? min(allowanceScale, width / Double(proposed.width)) : 0 + let artworkWidth = Double(proposed.width) * scale + let artworkHeight = Double(proposed.height) * scale + let horizontal: Double = switch anchor { + case .topLeading, .leading, .bottomLeading: rightToLeft ? 1 : 0 + case .top, .center, .bottom: 0.5 + case .topTrailing, .trailing, .bottomTrailing: rightToLeft ? 0 : 1 + } + let vertical: Double = switch anchor { + case .topLeading, .top, .topTrailing: 0 + case .leading, .center, .trailing: 0.5 + case .bottomLeading, .bottom, .bottomTrailing: 1 + } + let left = (rightToLeft ? outsets.trailing : outsets.leading) * allowanceScale + let right = (rightToLeft ? outsets.leading : outsets.trailing) * allowanceScale + let x = (width - artworkWidth) * horizontal + offset.x * scale * (rightToLeft ? -1 : 1) + let y = (height - artworkHeight) * vertical + offset.y * scale + return Geometry( + frame: Frame( + x: x, + y: y, + width: artworkWidth, + height: artworkHeight, + ), + paintBounds: Frame( + x: -left, + y: -outsets.top * allowanceScale, + width: width + left + right, + height: height + (outsets.top + outsets.bottom) * allowanceScale, + ), + ) + } + + public struct Extent: Equatable, Sendable { + public let width: Double + public let height: Double + public init(width: Double, height: Double) { + self.width = width; self.height = height + } + } + + public struct Frame: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + public var size: Extent { + .init(width: width, height: height) + } + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x; self.y = y; self.width = width; self.height = height + } + } + + public struct Geometry: Equatable, Sendable { + public let frame: Frame + public let paintBounds: Frame + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift new file mode 100644 index 000000000..960857f8c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A host-supplied image identity, never a URL or filesystem path. +public struct ViewAssetID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift new file mode 100644 index 000000000..7287f978a --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Portable colors. Explicit components use the sRGB color space. +public indirect enum ViewColorDescriptor: Codable, Hashable, Sendable { + case rgba(red: Double, green: Double, blue: Double, alpha: Double) + case semantic(Role) + case opacity(ViewColorDescriptor, Double) + /// A bounded perceptual blend of two portable colors. + case mix(ViewColorDescriptor, ViewColorDescriptor, fraction: Double) + + public enum Role: String, Codable, Hashable, Sendable { + case primary, secondary, accent, background + case secondaryBackground, groupedBackground, secondaryGroupedBackground + /// Native palette colors retain appearance and accessibility adaptation. + case red, orange, yellow, green, mint, teal, cyan, blue, indigo, purple, pink, brown, gray + case black, white, clear + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift new file mode 100644 index 000000000..50b0bc05d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Points, optionally scaled relative to a semantic font by the host. +public enum ViewDimension: Codable, Hashable, Sendable { + case points(Double) + case scaled(points: Double, relativeTo: ViewFontDescriptor.TextStyle) +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift new file mode 100644 index 000000000..22660d1d3 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A finite, read-only view tree. Validation is required before rendering. +public struct ViewDocument: Codable, Hashable, Sendable { + public var schemaVersion: Int + public var root: Node + + public init(schemaVersion: Int, root: Node) { + self.schemaVersion = schemaVersion + self.root = root + } + + public struct Node: Codable, Hashable, Sendable, Identifiable { + public struct ID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + } + + public var id: ID + public var content: Content + public var modifiers: [ViewModifierDescriptor] + public var animation: ViewAnimationDescriptor? + public var accessibility: Accessibility + + public init( + id: ID, + content: Content, + modifiers: [ViewModifierDescriptor], + animation: ViewAnimationDescriptor?, + accessibility: Accessibility, + ) { + self.id = id + self.content = content + self.modifiers = modifiers + self.animation = animation + self.accessibility = accessibility + } + } + + public indirect enum Content: Codable, Hashable, Sendable { + case text(String) + case image(ViewAssetID) + case raster(ViewImageDescriptor) + case symbol(ViewSymbolID) + case shape(ViewShapeDescriptor) + case spacer(minimum: Double) + case group(axis: Axis, alignment: ViewAlignment, spacing: Double, children: [Node]) + } + + public enum Axis: String, Codable, Hashable, Sendable { + case horizontal, vertical, overlay + } + + public enum Accessibility: Codable, Hashable, Sendable { + case automatic + case hidden + case label(String) + case combine + case contain + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift new file mode 100644 index 000000000..02b5013df --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Limits are explicit at validation boundaries. Production callers normally use `standard`. +public struct ViewDocumentLimits: Hashable, Sendable { + public var bytes: Int + public var depth: Int + public var nodes: Int + public var animatedNodes: Int + public var modifiersPerNode: Int + public var modifiers: Int + + public init( + bytes: Int, + depth: Int, + nodes: Int, + animatedNodes: Int, + modifiersPerNode: Int, + modifiers: Int, + ) { + self.bytes = bytes + self.depth = depth + self.nodes = nodes + self.animatedNodes = animatedNodes + self.modifiersPerNode = modifiersPerNode + self.modifiers = modifiers + } + + public static let standard = Self( + bytes: 1_048_576, + depth: 16, + nodes: 256, + animatedNodes: 32, + modifiersPerNode: 32, + modifiers: 1024, + ) +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift new file mode 100644 index 000000000..cff3d19c5 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Asset availability is checked before a document reaches the renderer. +public struct ViewDocumentResources: Hashable, Sendable { + public let images: Set + public let symbols: Set + + public init(images: Set, symbols: Set) { + self.images = images + self.symbols = symbols + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift new file mode 100644 index 000000000..7b86f2ae9 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Validation fails atomically and reports a path within the authored document. +public struct ViewDocumentValidationError: Error, Equatable, Sendable, LocalizedError { + public let path: String + public let reason: String + public var errorDescription: String? { + "\(path): \(reason)" + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift new file mode 100644 index 000000000..67edb8eef --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -0,0 +1,294 @@ +import Foundation + +struct ViewDocumentValidator { + // Bound compounded transforms and authored lengths before native rendering loses precision. + // Scale-down operations cannot refund these conservative budgets. + private static let maximumScaleAmplification = 1000.0 + private static let maximumAmplifiedGeometry = 100_000_000.0 + let resources: ViewDocumentResources + let limits: ViewDocumentLimits + var identities: Set = [] + var animations = 0 + var modifiers = 0 + var amplifiedGeometry = 0.0 + var referencedImages: Set = [] + var referencedSymbols: Set = [] + + mutating func visit( + _ node: ViewDocument.Node, + path: String, + depth: Int, + inheritedScaleAmplification: Double, + ) throws { + try require(depth <= limits.depth, path, "Tree exceeds depth limit") + try require(!node.id.rawValue.isEmpty, path + ".id", "Empty node identity") + try require(identities.insert(node.id).inserted, path + ".id", "Duplicate node identity") + try require(identities.count <= limits.nodes, path, "Tree exceeds node limit") + try require( + node.modifiers.count <= limits.modifiersPerNode, + path + ".modifiers", + "Node exceeds modifier limit", + ) + try require( + node.modifiers.count <= limits.modifiers - modifiers, + path + ".modifiers", + "Tree exceeds modifier limit", + ) + modifiers += node.modifiers.count + var scaleAmplification = inheritedScaleAmplification + for (index, modifier) in node.modifiers.enumerated() { + let modifierPath = path + ".modifiers[\(index)]" + try validate(modifier, modifierPath) + if case let .scale(x, y) = modifier { + scaleAmplification = try amplify( + scaleAmplification, + by: max(abs(x), abs(y)), + path: modifierPath, + ) + } + } + if let animation = node.animation { + animations += 1 + try require(animations <= limits.animatedNodes, path, "Tree exceeds animation limit") + try pose(animation.from, path) + try pose(animation.to, path) + try pose(animation.staticPose, path) + let animationScale = [animation.from, animation.to, animation.staticPose] + .reduce(1.0) { max($0, abs($1.scaleX), abs($1.scaleY)) } + scaleAmplification = try amplify( + scaleAmplification, + by: animationScale, + path: path + ".animation", + ) + let offset = [animation.from, animation.to, animation.staticPose] + .reduce(0.0) { max($0, abs($1.offsetX), abs($1.offsetY)) } + try includeGeometry( + offset, + amplification: scaleAmplification, + path: path + ".animation", + ) + try number(animation.duration, path + ".animation.duration", range: 0.1 ... 3600) + try number(animation.delay, path + ".animation.delay", range: 0 ... 3600) + switch animation.repetition { + case .once, .forever: break + case let .count(count, _): try require( + (1 ... 10000).contains(count), + path, + "Invalid repetition count", + ) + } + } + for (index, modifier) in node.modifiers.enumerated() { + try includeGeometry( + geometryCost(modifier), + amplification: scaleAmplification, + path: path + ".modifiers[\(index)]", + ) + } + switch node.content { + case .text: break + case let .image(asset): + try require(resources.images.contains(asset), path, "Missing image asset") + referencedImages.insert(asset) + case let .raster(image): + try require(resources.images.contains(image.asset), path, "Missing image asset") + referencedImages.insert(image.asset) + switch image.sizing { + case .fit, .fill: break + case let .tile(scale): try number( + scale, + path + ".sizing.scale", + range: 0.1 ... 10, + ) + } + case let .symbol(symbol): + try require(resources.symbols.contains(symbol), path, "Missing registered symbol") + referencedSymbols.insert(symbol) + case let .shape(value): try shape(value, path) + case let .spacer(minimum): + try number(minimum, path, range: 0 ... 100_000) + try includeGeometry(minimum, amplification: scaleAmplification, path: path) + case let .group(_, _, spacing, children): + try number(spacing, path + ".spacing", range: 0 ... 100_000) + try includeGeometry( + spacing * Double(max(0, children.count - 1)), + amplification: scaleAmplification, + path: path + ".spacing", + ) + for (index, child) in children.enumerated() { + try visit( + child, + path: path + ".children[\(index)]", + depth: depth + 1, + inheritedScaleAmplification: scaleAmplification, + ) + } + } + } + + private func amplify(_ inherited: Double, by scale: Double, path: String) throws -> Double { + let amplified = inherited * max(1, scale) + try require( + amplified <= Self.maximumScaleAmplification, + path, + "Combined scale exceeds the supported amplification", + ) + return amplified + } + + private mutating func includeGeometry( + _ points: Double, + amplification: Double, + path: String, + ) throws { + let cost = points * amplification + try require( + cost <= Self.maximumAmplifiedGeometry - amplifiedGeometry, + path, + "Combined authored geometry exceeds the rendering budget", + ) + amplifiedGeometry += cost + } + + /// Count authored extents conservatively; the host still owns measured layout and font metrics. + private func geometryCost(_ modifier: ViewModifierDescriptor) -> Double { + func points(_ dimension: ViewDimension?) -> Double { + switch dimension { + case let .points(value), let .scaled(value, _): value + case nil: 0 + } + } + return switch modifier { + case let .frame(width, height, _): max(points(width), points(height)) + case let .padding(value): 2 * points(value) + case let .offset(x, y): max(abs(x), abs(y)) + case let .shadow(_, radius, x, y): 2 * radius + max(abs(x), abs(y)) + case let .border(_, _, width): 2 * width + case .font, .foreground, .scale, .rotation, .opacity, .clip, .background, .blend: 0 + } + } + + func validate(_ modifier: ViewModifierDescriptor, _ path: String) throws { + switch modifier { + case let .font(font): + switch font.size { + case .semantic: break + case let .fixed(size): try number(size, path, range: 1 ... 1000) + } + case let .foreground(value): try paint(value, path) + case let .frame(width, height, _): + if let width { try dimension(width, path) } + if let height { try dimension(height, path) } + case let .padding(value): try dimension(value, path) + case let .offset(x, y): try number(x, path); try number(y, path) + case let .scale(x, y): try number(x, path, range: -100 ... 100); try number( + y, + path, + range: -100 ... 100, + ) + case let .rotation(degrees): try number(degrees, path) + case let .opacity(value): try number(value, path, range: 0 ... 1) + case let .clip(value): try shape(value, path) + case let .background(value, fill): try shape(value, path); try paint(fill, path) + case let .border(value, fill, width): + try shape(value, path); try paint(fill, path); try number( + width, + path, + range: 0 ... 1000, + ) + case let .shadow(value, radius, x, y): + try color(value, path); try number(radius, path, range: 0 ... 1000); try number( + x, + path, + ); try number(y, path) + case .blend: break + } + } + + func pose(_ pose: ViewAnimationDescriptor.Pose, _ path: String) throws { + try number(pose.opacity, path, range: 0 ... 1) + try number(pose.offsetX, path); try number(pose.offsetY, path) + try number(pose.scaleX, path, range: -100 ... 100); try number( + pose.scaleY, + path, + range: -100 ... 100, + ) + try number(pose.rotation, path) + } + + func dimension(_ dimension: ViewDimension, _ path: String) throws { + switch dimension { + case let .points(value), let .scaled(value, _): try number( + value, + path, + range: 0 ... 100_000, + ) + } + } + + func shape(_ shape: ViewShapeDescriptor, _ path: String) throws { + switch shape { + case .rectangle, .circle, .ellipse: break + case let .roundedRectangle(radius): try number(radius, path, range: 0 ... 100_000) + } + } + + func paint(_ paint: ViewPaint, _ path: String) throws { + switch paint { + case let .color(value): try color(value, path) + case let .linearGradient(stops, start, end): + try require( + (2 ... 64).contains(stops.count), + path, + "A gradient requires 2 to 64 stops", + ) + for point in [start, end] { + try number(point.x, path, range: 0 ... 1); try number( + point.y, + path, + range: 0 ... 1, + ) + } + var previous = 0.0 + for stop in stops { + try color(stop.color, path) + try number(stop.location, path, range: previous ... 1) + previous = stop.location + } + } + } + + func color(_ color: ViewColorDescriptor, _ path: String, depth: Int = 0) throws { + try require(depth <= 8, path, "Color composition exceeds depth limit") + switch color { + case let .opacity(value, alpha): + try self.color(value, path, depth: depth + 1) + try number(alpha, path, range: 0 ... 1) + case let .mix(first, second, fraction): + try self.color(first, path, depth: depth + 1) + try self.color(second, path, depth: depth + 1) + try number(fraction, path, range: 0 ... 1) + case .semantic: break + case let .rgba(red, green, blue, alpha): + for value in [red, green, blue, alpha] { + try number(value, path, range: 0 ... 1) + } + } + } + + func number( + _ value: Double, + _ path: String, + range: ClosedRange = -100_000 ... 100_000, + ) throws { + try require( + value.isFinite && range.contains(value), + path, + "Numeric value is outside the supported range", + ) + } + + func require(_ condition: Bool, _ path: String, _ reason: String) throws { + guard condition else { throw ViewDocumentValidationError(path: path, reason: reason) } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift new file mode 100644 index 000000000..b46d228b4 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift @@ -0,0 +1,34 @@ +import Foundation + +/// System typography preserves semantic Dynamic Type scaling. +public struct ViewFontDescriptor: Codable, Hashable, Sendable { + public var size: Size + /// Nil preserves the semantic text style's system weight. + public var weight: Weight? + /// Nil preserves the semantic font's inherited design. + public var design: Design? + + public init(size: Size, weight: Weight?, design: Design?) { + self.size = size + self.weight = weight + self.design = design + } + + public enum Size: Codable, Hashable, Sendable { + case semantic(TextStyle) + case fixed(Double) + } + + public enum TextStyle: String, CaseIterable, Codable, Hashable, Sendable { + case largeTitle, title, title2, title3, headline, subheadline, body, callout, footnote, + caption, caption2 + } + + public enum Weight: String, Codable, Hashable, Sendable { + case ultraLight, thin, light, regular, medium, semibold, bold, heavy, black + } + + public enum Design: String, Codable, Hashable, Sendable { + case standard, rounded, serif, monospaced + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift new file mode 100644 index 000000000..c3a9ee650 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift @@ -0,0 +1,19 @@ +import Foundation + +/// An approved image and its behavior within the proposed layout bounds. +public struct ViewImageDescriptor: Codable, Hashable, Sendable { + public var asset: ViewAssetID + public var sizing: Sizing + + public init(asset: ViewAssetID, sizing: Sizing) { + self.asset = asset + self.sizing = sizing + } + + public enum Sizing: Codable, Hashable, Sendable { + case fit + case fill + /// Scale relative to the image's intrinsic point size. Tiles never create document nodes. + case tile(scale: Double) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift new file mode 100644 index 000000000..e74032b57 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Ordered operations; a modifier applies to the result of all preceding operations. +public enum ViewModifierDescriptor: Codable, Hashable, Sendable { + case font(ViewFontDescriptor) + case foreground(ViewPaint) + case frame(width: ViewDimension?, height: ViewDimension?, alignment: ViewAlignment) + case padding(ViewDimension) + case offset(x: Double, y: Double) + case scale(x: Double, y: Double) + case rotation(degrees: Double) + case opacity(Double) + case clip(ViewShapeDescriptor) + case background(shape: ViewShapeDescriptor, paint: ViewPaint) + case border(shape: ViewShapeDescriptor, paint: ViewPaint, width: Double) + case shadow(color: ViewColorDescriptor, radius: Double, x: Double, y: Double) + case blend(Blend) + + public enum Blend: String, Codable, Hashable, Sendable { + case normal, multiply, screen, overlay, darken, lighten, plusLighter + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift new file mode 100644 index 000000000..56a1f9397 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A solid color or linear gradient in normalized local coordinates. +public enum ViewPaint: Codable, Hashable, Sendable { + case color(ViewColorDescriptor) + case linearGradient(stops: [Stop], start: Point, end: Point) + + public struct Stop: Codable, Hashable, Sendable { + public var color: ViewColorDescriptor + public var location: Double + public init(color: ViewColorDescriptor, location: Double) { + self.color = color + self.location = location + } + } + + public struct Point: Codable, Hashable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { + self.x = x; self.y = y + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift new file mode 100644 index 000000000..4b1fe9bd8 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum ViewShapeDescriptor: Codable, Hashable, Sendable { + case rectangle + case roundedRectangle(radius: Double) + case circle + case ellipse +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift new file mode 100644 index 000000000..a0d9472a4 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A key in the host's approved symbol registry, not a system symbol name. +public struct ViewSymbolID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift new file mode 100644 index 000000000..eb9338466 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ValidatedViewDocumentTests { + @Test func retainsOnlyReferencedResources() throws { + let used = ViewAssetID(rawValue: "used") + let unused = ViewAssetID(rawValue: "unused") + let result = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("image", content: .image(used))), + resources: .init(images: [used, unused], symbols: []), + limits: .standard, + ) + #expect(result.resources.images == [used]) + } + + @Test func rejectsDuplicateIdentities() throws { + let child = documentNode("same", content: .text("One")) + let root = documentNode( + "root", + content: .group( + axis: .vertical, + alignment: .leading, + spacing: 2, + children: [child, child], + ), + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path.contains("children[1]") == true) + } + + @Test func rejectsUnavailableAssets() { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "image", + content: .image(.init(rawValue: "pumpkin")), + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test(arguments: [Double.nan, .infinity, -0.1, 1.1]) + func rejectsInvalidOpacity(_ opacity: Double) { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "shape", + content: .shape(.circle), + modifiers: [.opacity(opacity)], + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func enforcesDepthAndNodeLimits() { + let root = documentNode( + "root", + content: .group( + axis: .overlay, + alignment: .center, + spacing: 0, + children: [documentNode("child", content: .text("Hello"))], + ), + ) + for limits in [ + ViewDocumentLimits( + bytes: 10000, + depth: 1, + nodes: 10, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ), + .init( + bytes: 10000, + depth: 10, + nodes: 1, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ), + ] { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + } + } + } + + @Test func rejectsVersionAndOversizedInput() { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 2, root: documentNode( + "root", + content: .text("Hello"), + )), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument.decode( + Data(repeating: 32, count: 1024), + resources: noDocumentResources, + limits: .init( + bytes: 100, + depth: 16, + nodes: 256, + animatedNodes: 32, + modifiersPerNode: 32, + modifiers: 1024, + ), + ) + } + } + + @Test func changedContentHasDifferentIdentity() throws { + let first = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("same", content: .text("First"))), + resources: noDocumentResources, + limits: .standard, + ) + let second = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("same", content: .text("Second"))), + resources: noDocumentResources, + limits: .standard, + ) + #expect(first != second) + #expect(Set([first, second]).count == 2) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift new file mode 100644 index 000000000..dad251ca2 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift @@ -0,0 +1,52 @@ +import Testing +import ViewDocumentCore + +struct ViewAnimationDescriptorSamplingTests { + @Test func delayEndpointsAndAutoreverseAreDeterministic() { + let end = ViewAnimationDescriptor.Pose( + opacity: 0.5, + offsetX: 10, + offsetY: 20, + scaleX: 2, + scaleY: 3, + rotation: 90, + ) + let animation = ViewAnimationDescriptor( + from: .identity, + to: end, + staticPose: .identity, + duration: 2, + delay: 1, + curve: .linear, + repetition: .count(1, autoreverses: true), + ) + #expect(animation.pose(at: 0) == .identity) + #expect(animation.pose(at: 1) == .identity) + #expect(animation.pose(at: 2).offsetX == 5) + #expect(animation.pose(at: 3) == end) + #expect(animation.pose(at: 4).offsetX == 5) + #expect(!animation.isComplete(at: 4.99)) + #expect(animation.isComplete(at: 5)) + #expect(animation.pose(at: 5) == .identity) + #expect(animation.pose(at: 100) == .identity) + } + + @Test func onceHoldsFinalPoseAndForeverRestarts() { + var end = ViewAnimationDescriptor.Pose.identity + end.rotation = 90 + var animation = ViewAnimationDescriptor( + from: .identity, + to: end, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .once, + ) + #expect(animation.isComplete(at: 2)) + #expect(animation.pose(at: 2) == end) + animation.repetition = .forever(autoreverses: false) + #expect(!animation.isComplete(at: 100_000)) + #expect(animation.pose(at: 2.5).rotation == 45) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift new file mode 100644 index 000000000..3b5e89755 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewArtworkPlacementTests { + @Test func hostGuttersShrinkArtworkBeforeNativeContent() throws { + let placement = ViewArtworkPlacement( + anchor: .bottomTrailing, + size: .fixed(width: 100, height: 80), + offset: .init(x: 40, y: 20), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 20, trailing: 40), + ) + try placement.validate() + let geometry = placement.geometry( + in: .init(width: 300, height: 100), + rightToLeft: false, + maximumOutsets: .init(top: 0, leading: 0, bottom: 20, trailing: 10), + ) + #expect(geometry.frame.width == 25) + #expect(geometry.frame.height == 20) + #expect(geometry.frame.x == 285) + #expect(geometry.paintBounds.width == 310) + #expect(geometry.paintBounds.height == 105) + let hidden = placement.geometry( + in: .init(width: 300, height: 100), + rightToLeft: true, + maximumOutsets: .zero, + ) + #expect(hidden.frame.width == 0) + #expect(hidden.paintBounds.width == 300) + } + + @Test func separatesOverflowFromHostLayoutAndMirrorsDirection() throws { + let placement = ViewArtworkPlacement( + anchor: .bottomTrailing, + size: .fixed(width: 80, height: 60), + offset: .init(x: 12, y: 24), + plane: .exterior, + outsets: .init(top: 0, leading: 4, bottom: 24, trailing: 12), + ) + try placement.validate() + let host = ViewArtworkPlacement.Extent(width: 300, height: 160) + let leftToRight = placement.geometry(in: host, rightToLeft: false) + let rightToLeft = placement.geometry(in: host, rightToLeft: true) + #expect(leftToRight.frame == ViewArtworkPlacement.Frame( + x: 232, + y: 124, + width: 80, + height: 60, + )) + #expect(leftToRight.paintBounds == ViewArtworkPlacement.Frame( + x: -4, + y: 0, + width: 316, + height: 184, + )) + #expect(rightToLeft.frame == ViewArtworkPlacement.Frame( + x: -12, + y: 124, + width: 80, + height: 60, + )) + #expect(rightToLeft.paintBounds == ViewArtworkPlacement.Frame( + x: -12, + y: 0, + width: 316, + height: 184, + )) + } + + @Test func narrowHostsScaleArtworkAndOffsetTogether() throws { + let placement = ViewArtworkPlacement( + anchor: .bottom, + size: .fixed(width: 200, height: 100), + offset: .init(x: 0, y: 20), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 20, trailing: 0), + ) + try placement.validate() + #expect(placement.geometry( + in: ViewArtworkPlacement.Extent(width: 100, height: 80), + rightToLeft: false, + ).frame + == ViewArtworkPlacement.Frame(x: 0, y: 40, width: 100, height: 50)) + } + + @Test func relativeSizingHasFinitePointCaps() throws { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .relative(width: 1, height: 0.5, maximumWidth: 400, maximumHeight: 300), + offset: .zero, + plane: .interior, + outsets: .zero, + ) + try placement.validate() + let geometry = placement.geometry( + in: ViewArtworkPlacement.Extent(width: 2000, height: 2000), + rightToLeft: false, + ) + #expect(geometry.frame.size == ViewArtworkPlacement.Extent(width: 400, height: 300)) + #expect(try JSONDecoder().decode( + ViewArtworkPlacement.self, + from: JSONEncoder().encode(placement), + ) == placement) + } + + @Test(arguments: [Double.nan, .infinity, -1, 129]) + func rejectsUnboundedOutsets(value: Double) { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .fixed(width: 80, height: 60), + offset: .zero, + plane: .exterior, + outsets: .init(top: value, leading: 0, bottom: 0, trailing: 0), + ) + #expect(throws: ViewDocumentValidationError.self) { try placement.validate() } + } + + @Test func interiorCannotClaimOverflow() { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .fixed(width: 80, height: 60), + offset: .zero, + plane: .interior, + outsets: .init(top: 1, leading: 0, bottom: 0, trailing: 0), + ) + #expect(throws: ViewDocumentValidationError.self) { try placement.validate() } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift new file mode 100644 index 000000000..d3e542676 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewColorDescriptorTests { + @Test func semanticCompositionsRoundTrip() throws { + let color = ViewColorDescriptor.mix( + .semantic(.primary), + .opacity(.semantic(.accent), 0.7), + fraction: 0.25, + ) + #expect(try JSONDecoder().decode( + ViewColorDescriptor.self, + from: JSONEncoder().encode(color), + ) == color) + _ = try validate(color) + } + + @Test func rejectsUnboundedCompositionsAndInvalidFractions() throws { + var deep = ViewColorDescriptor.semantic(.primary) + for _ in 0 ..< 9 { + deep = .opacity(deep, 0.5) + } + for color in [ + deep, + .opacity(.semantic(.primary), .infinity), + .mix(.semantic(.primary), .semantic(.accent), fraction: -0.1), + ] { + #expect(throws: ViewDocumentValidationError.self) { try validate(color) } + } + } + + private func validate(_ color: ViewColorDescriptor) throws -> ValidatedViewDocument { + try .init( + document: .init(schemaVersion: 1, root: documentNode( + "color", + content: .shape(.rectangle), + modifiers: [.foreground(.color(color))], + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift new file mode 100644 index 000000000..6a9092c9d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift @@ -0,0 +1,18 @@ +import ViewDocumentCore + +func documentNode( + _ name: String, + content: ViewDocument.Content, + modifiers: [ViewModifierDescriptor] = [], + animation: ViewAnimationDescriptor? = nil, +) -> ViewDocument.Node { + .init( + id: .init(rawValue: name), + content: content, + modifiers: modifiers, + animation: animation, + accessibility: .automatic, + ) +} + +let noDocumentResources = ViewDocumentResources(images: [], symbols: []) diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift new file mode 100644 index 000000000..7e8c1d61c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewDocumentTests { + @Test func preservesOrderedModifiersAndTypedIdentity() throws { + let document = ViewDocument( + schemaVersion: 1, + root: documentNode("label", content: .text("Hello"), modifiers: [ + .padding(.points(8)), + .opacity(0.5), + .rotation(degrees: 12), + ]), + ) + let data = try JSONEncoder().encode(document) + let decoded = try JSONDecoder().decode(ViewDocument.self, from: data) + #expect(decoded == document) + #expect(decoded.root.modifiers.count == 3) + } + + @Test func literalJSONIsAnIndependentInput() throws { + let json = Data( + #"{"schemaVersion":1,"root":{"id":"greeting","content":{"text":{"_0":"Hello"}},"modifiers":[],"accessibility":{"automatic":{}}}}"# + .utf8, + ) + let result = try ValidatedViewDocument.decode( + json, + resources: noDocumentResources, + limits: .standard, + ) + #expect(result.document.root.content == .text("Hello")) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift new file mode 100644 index 000000000..535d438bb --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift @@ -0,0 +1,282 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewDocumentValidatorTests { + @Test func amplifiedPaddingCannotExceedTheGeometryBudget() throws { + var modifiers: [ViewModifierDescriptor] = [ + .frame(width: .points(100_000), height: .points(100_000), alignment: .center), + ] + modifiers += Array(repeating: .padding(.points(100_000)), count: 5) + modifiers += [ + .background(shape: .rectangle, paint: .color(.semantic(.primary))), + .scale(x: 100, y: 100), + .scale(x: 10, y: 10), + ] + let document = ViewDocument( + schemaVersion: 1, + root: documentNode("padded", content: .shape(.rectangle), modifiers: modifiers), + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.reason == "Combined authored geometry exceeds the rendering budget") + } + + @Test func amplifiedChildFramesShareTheGeometryBudget() throws { + let children = (0 ..< 20).map { index in + documentNode("child-\(index)", content: .shape(.rectangle), modifiers: [ + .frame(width: .points(100_000), height: .points(100_000), alignment: .center), + ]) + } + var root = documentNode( + "root", + content: .group(axis: .horizontal, alignment: .center, spacing: 0, children: children), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers = [.scale(x: 100, y: 100), .scale(x: 10, y: 10)] + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.children[1].modifiers[0]") + } + + @Test func rejectsRepeatedScalesBeforeNativeRendering() throws { + // Five individually valid scales make a clipped 2-point shape disappear in SwiftUI. + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "shape", + content: .shape(.rectangle), + modifiers: [.frame(width: .points(2), height: .points(2), alignment: .center)] + + Array(repeating: .scale(x: 100, y: 100), count: 5), + )) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: document, + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.reason == "Combined scale exceeds the supported amplification") + #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func scaleBudgetFollowsAncestorsAndDoesNotCombineSiblings() throws { + let first = documentNode( + "first", + content: .shape(.circle), + modifiers: [.scale(x: 100, y: 100)], + ) + let second = documentNode( + "second", + content: .shape(.circle), + modifiers: [.scale(x: 100, y: 100)], + ) + var root = documentNode( + "root", + content: .group( + axis: .overlay, + alignment: .center, + spacing: 0, + children: [first, second], + ), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers = [.scale(x: 100, y: 100)] + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.children[0].modifiers[0]") + } + + @Test func everyAnimationPoseContributesToScaleBudget() throws { + let poses: [WritableKeyPath] = [ + \.from, + \.to, + \.staticPose, + ] + for pose in poses { + var animation = ViewAnimationDescriptor( + from: .identity, + to: .identity, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .once, + ) + animation[keyPath: pose].scaleX = 100 + let root = documentNode( + "root", + content: .shape(.circle), + modifiers: [.scale(x: 20, y: 20)], + animation: animation, + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.animation") + } + } + + @Test func moderateScaleAndScaleDownRemainValid() throws { + let root = documentNode("root", content: .shape(.circle), modifiers: [ + .scale(x: 10, y: -10), + .scale(x: 0.1, y: 0.1), + .scale(x: 10, y: 10), + ]) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + + @Test func boundsModifierNestingAndTotalWork() throws { + var root = documentNode( + "root", + content: .text(""), + modifiers: Array(repeating: .opacity(1), count: 32), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers.append(.opacity(1)) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + let child = documentNode("child", content: .text(""), modifiers: [.opacity(1)]) + root = documentNode( + "root", + content: .group(axis: .overlay, alignment: .center, spacing: 0, children: [child]), + modifiers: [.opacity(1)], + ) + var limits = ViewDocumentLimits.standard + limits.modifiers = 2 + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + limits.modifiers = 1 + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + } + } + + @Test func rejectsUnknownOperationsWithTheirPath() { + let json = #"{"schemaVersion":1,"root":{"id":"bad","content":{"button":{}},"modifiers":[],"accessibility":{"automatic":{}}}}"# + let error = #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument.decode( + .init(json.utf8), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path.contains("content") == true) + } + + @Test func rejectsUnorderedGradientStops() { + let paint = ViewPaint.linearGradient( + stops: [ + .init(color: .semantic(.primary), location: 0.8), + .init(color: .semantic(.secondary), location: 0.2), + ], + start: .init(x: 0, y: 0), + end: .init(x: 1, y: 1), + ) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + modifiers: [.foreground(paint)], + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func enforcesAnimationBudgetAndDuration() { + var animation = ViewAnimationDescriptor( + from: .identity, + to: .identity, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .forever(autoreverses: true), + ) + let limits = ViewDocumentLimits( + bytes: 10000, + depth: 16, + nodes: 256, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + animation: animation, + )), + resources: noDocumentResources, + limits: limits, + ) + } + animation.duration = 0 + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + animation: animation, + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift new file mode 100644 index 000000000..a728e2344 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewImageDescriptorTests { + @Test(arguments: [ViewImageDescriptor.Sizing.fit, .fill, .tile(scale: 0.5)]) + func checksResourcesAndRoundTrips(sizing: ViewImageDescriptor.Sizing) throws { + let asset = ViewAssetID(rawValue: "illustration") + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "art", + content: .raster(.init(asset: asset, sizing: sizing)), + )) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: document, + resources: noDocumentResources, + limits: .standard, + ) + } + let resources = ViewDocumentResources(images: [asset], symbols: []) + let decoded = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: resources, + limits: .standard, + ) + #expect(decoded.document == document) + #expect(decoded.resources.images == [asset]) + } + + @Test(arguments: [Double.nan, .infinity, 0, 0.01, 11]) + func rejectsExtremeTileDensity(scale: Double) { + let asset = ViewAssetID(rawValue: "illustration") + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "art", + content: .raster(.init(asset: asset, sizing: .tile(scale: scale))), + )) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: document, + resources: .init(images: [asset], symbols: []), + limits: .standard, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/AGENTS.md b/Shared/ViewDocument/ViewDocumentUI/AGENTS.md new file mode 100644 index 000000000..e4ad179c3 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/AGENTS.md @@ -0,0 +1,5 @@ +# ViewDocumentUI + +ViewDocumentUI follows the [group contract](../AGENTS.md) and [repository rules](../../../AGENTS.md). See [README.md](README.md). + +Keep source tests one-to-one. Run `./test ViewDocumentUITests`. diff --git a/Shared/ViewDocument/ViewDocumentUI/README.md b/Shared/ViewDocument/ViewDocumentUI/README.md new file mode 100644 index 000000000..86e64e501 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/README.md @@ -0,0 +1,6 @@ +# ViewDocumentUI + +See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. + +Raster images support fit, clipped fill, and tiling. Hosts provide decoded assets; rendering never performs resource loading. +Select `standaloneContentLightDark()` for the fast image gate. `standaloneContentExtended()` retains accessibility captures with the original reference names. diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift new file mode 100644 index 000000000..13195cc18 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift @@ -0,0 +1,96 @@ +import SnapshotKit +import SnapshotKitTesting +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +@MainActor +struct ViewDocumentViewSnapshotTests { + @Test func standaloneContentLightDark() async throws { + try await standaloneContent(snapshotTypes: [.standard]) + } + + @Test func standaloneContentExtended() async throws { + try await standaloneContent(snapshotTypes: [.accessibility]) + } + + private func standaloneContent(snapshotTypes: [SnapshotConfiguration + .SnapshotType]) async throws + { + let root = ViewDocument.Node( + id: .init(rawValue: "root"), + content: .group(axis: .vertical, alignment: .center, spacing: 20, children: [ + .init( + id: .init(rawValue: "title"), + content: .text("A document rendered with SwiftUI"), + modifiers: [.font(.init( + size: .semantic(.title2), + weight: .bold, + design: .rounded, + ))], + animation: nil, + accessibility: .automatic, + ), + .init( + id: .init(rawValue: "art"), + content: .shape(.roundedRectangle(radius: 18)), + modifiers: [ + .foreground(.linearGradient( + stops: [.init( + color: .rgba(red: 1, green: 0.4, blue: 0.1, alpha: 1), + location: 0, + ), .init( + color: .rgba(red: 0.6, green: 0.1, blue: 0.3, alpha: 1), + location: 1, + )], + start: .init(x: 0, y: 0), + end: .init(x: 1, y: 1), + )), + .frame(width: .points(160), height: .points(100), alignment: .center), + .rotation(degrees: 8), + ], + animation: nil, + accessibility: .label("Orange and red artwork"), + ), + .init( + id: .init(rawValue: "caption"), + content: .text("Finite layouts. Ordered modifiers. Accessible content."), + modifiers: [.font(.init( + size: .semantic(.body), + weight: .regular, + design: .standard, + ))], + animation: nil, + accessibility: .automatic, + ), + ]), + modifiers: [.padding(.points(24))], + animation: nil, + accessibility: .contain, + ) + let assets = ViewDocumentAssets(images: [:], symbols: [:]) + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: assets.resources, + limits: .standard, + ) + let view = try ViewDocumentView( + document: document, + assets: assets, + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + await assertSnapshots( + of: view.padding().frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground)), + named: "ReadOnlyContent", + configurations: SnapshotConfiguration.combinations( + devices: [.iPhone], + colorSchemes: [.light, .dark], + snapshotTypes: snapshotTypes, + ), + testName: "standaloneContent()", + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png new file mode 100644 index 000000000..1f2bff78e --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c633e1f3faefce6ff52f49ed74d5129cc9dcce74a915e6341e78618fa767af4d +size 323845 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png new file mode 100644 index 000000000..e99dec937 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a041b9ab2240a2ebe7c00633c2e01b839158e1d6c8c8e73744abfc0eea9c6fae +size 317089 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png new file mode 100644 index 000000000..1aa5a6b54 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd9ff8309965bdad62a41dd3fa8ac52ecbfae51510a070ca7a62b31b3a4cd22f +size 329905 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png new file mode 100644 index 000000000..3fdf0b8ea --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:728ab32cca37f80be763668abddafd57138fd60593c865437cb735843da4f51b +size 320002 diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift new file mode 100644 index 000000000..06a5ce5d1 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift @@ -0,0 +1,15 @@ +import ViewDocumentCore + +/// Pairs immutable validated content with its resources before entering a view body. +public struct PreparedViewDocument { + public let document: ValidatedViewDocument + let assets: ViewDocumentAssets + + public init(document: ValidatedViewDocument, assets: ViewDocumentAssets) throws { + guard document.resources.images.isSubset(of: assets.resources.images), + document.resources.symbols.isSubset(of: assets.resources.symbols) + else { throw ViewDocumentView.ResourceError.mismatchedAssets } + self.document = document + self.assets = assets + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift new file mode 100644 index 000000000..0be9c60e7 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ViewDocumentCore + +/// Visibility owns playback. Finite animations stop the timeline at their final pose. +struct ViewDocumentAnimatedView: View { + let animation: ViewAnimationDescriptor + let policy: ViewDocumentView.Policy + let content: AnyView + @State private var playback = Playback.waiting + + private enum Playback { + case waiting + case running(since: Date) + case holding(ViewAnimationDescriptor.Pose) + + var isPaused: Bool { + switch self { + case .waiting, .holding: true + case .running: false + } + } + } + + var body: some View { + if policy.motionIsStatic || !policy.isActive { + posed(animation.staticPose) + } else { + TimelineView(.animation( + minimumInterval: 1 / 30, + paused: playback.isPaused, + )) { context in + let elapsed: Double = if case let .running(epoch) = playback { + context.date.timeIntervalSince(epoch) + } else { 0 } + let pose: ViewAnimationDescriptor.Pose = switch playback { + case .waiting: animation.from + case .running: animation.pose(at: elapsed) + case let .holding(value): value + } + posed(pose) + .onChange(of: animation.isComplete(at: elapsed)) { _, complete in + if complete { playback = .holding(pose) } + } + } + .onAppear { playback = .running(since: Date()) } + .onDisappear { playback = .waiting } + } + } + + private func posed(_ pose: ViewAnimationDescriptor.Pose) -> some View { + content + .opacity(pose.opacity) + .scaleEffect(x: pose.scaleX, y: pose.scaleY) + .rotationEffect(.degrees(pose.rotation)) + .offset(x: pose.offsetX, y: pose.offsetY) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift new file mode 100644 index 000000000..6c519599d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift @@ -0,0 +1,192 @@ +import SwiftUI +import ViewDocumentCore + +/// Host-resolved appearance inputs. Resolution does not consult ambient UIKit traits. +public struct ViewDocumentAppearance { + /// Platform background levels supplied by the host. No platform color API is required here. + public struct Backgrounds: Equatable, Sendable { + public var primary: Color + public var secondary: Color + public var grouped: Color + public var secondaryGrouped: Color + + public init(primary: Color, secondary: Color, grouped: Color, secondaryGrouped: Color) { + self.primary = primary + self.secondary = secondary + self.grouped = grouped + self.secondaryGrouped = secondaryGrouped + } + } + + public var backgrounds: Backgrounds + public var background: Color { + get { backgrounds.primary } + set { backgrounds.primary = newValue } + } + + public var dimensionScales: [ViewFontDescriptor.TextStyle: Double] + + public init(background: Color, dimensionScales: [ViewFontDescriptor.TextStyle: Double]) { + self.init(backgrounds: .init( + primary: background, + secondary: background, + grouped: background, + secondaryGrouped: background, + ), dimensionScales: dimensionScales) + } + + public init(backgrounds: Backgrounds, dimensionScales: [ViewFontDescriptor.TextStyle: Double]) { + self.backgrounds = backgrounds + self.dimensionScales = dimensionScales + } + + public func color(_ value: ViewColorDescriptor) -> Color { + switch value { + case let .opacity(value, alpha): color(value).opacity(alpha) + case let .mix(first, second, fraction): color(first).mix( + with: color(second), + by: fraction, + in: .perceptual, + ) + case let .rgba(red, green, blue, alpha): Color( + .sRGB, + red: red, + green: green, + blue: blue, + opacity: alpha, + ) + case let .semantic(role): + switch role { + case .primary: .primary + case .secondary: .secondary + case .accent: .accentColor + case .background: background + case .secondaryBackground: backgrounds.secondary + case .groupedBackground: backgrounds.grouped + case .secondaryGroupedBackground: backgrounds.secondaryGrouped + case .red: .red + case .orange: .orange + case .yellow: .yellow + case .green: .green + case .mint: .mint + case .teal: .teal + case .cyan: .cyan + case .blue: .blue + case .indigo: .indigo + case .purple: .purple + case .pink: .pink + case .brown: .brown + case .gray: .gray + case .black: .black + case .white: .white + case .clear: .clear + } + } + } + + public func paint(_ value: ViewPaint) -> AnyShapeStyle { + switch value { + case let .color(value): AnyShapeStyle(color(value)) + case let .linearGradient(stops, start, end): + AnyShapeStyle(LinearGradient( + stops: stops.map { .init(color: color($0.color), location: $0.location) }, + startPoint: UnitPoint(x: start.x, y: start.y), + endPoint: UnitPoint(x: end.x, y: end.y), + )) + } + } + + public func dimension(_ value: ViewDimension) -> CGFloat { + switch value { + case let .points(points): points + case let .scaled(points, style): points * (dimensionScales[style] ?? 1) + } + } +} + +extension ViewFontDescriptor { + public var font: Font { + let base: Font = switch size { + case let .semantic(style): .system(style.textStyle, design: design?.fontDesign) + case let .fixed(points): + if let design { .system(size: points, design: design.fontDesign) } + else { .system(size: points) } + } + return weight.map { base.weight($0.fontWeight) } ?? base + } +} + +extension ViewFontDescriptor.TextStyle { + public var textStyle: Font.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption + case .caption2: .caption2 + } + } +} + +extension ViewFontDescriptor.Weight { + public var fontWeight: Font.Weight { + switch self { + case .ultraLight: .ultraLight + case .thin: .thin + case .light: .light + case .regular: .regular + case .medium: .medium + case .semibold: .semibold + case .bold: .bold + case .heavy: .heavy + case .black: .black + } + } +} + +extension ViewFontDescriptor.Design { + public var fontDesign: Font.Design { + switch self { + case .standard: .default + case .rounded: .rounded + case .serif: .serif + case .monospaced: .monospaced + } + } +} + +extension ViewAlignment { + public var alignment: Alignment { + switch self { + case .topLeading: .topLeading + case .top: .top + case .topTrailing: .topTrailing + case .leading: .leading + case .center: .center + case .trailing: .trailing + case .bottomLeading: .bottomLeading + case .bottom: .bottom + case .bottomTrailing: .bottomTrailing + } + } +} + +extension ViewModifierDescriptor.Blend { + public var blendMode: BlendMode { + switch self { + case .normal: .normal + case .multiply: .multiply + case .screen: .screen + case .overlay: .overlay + case .darken: .darken + case .lighten: .lighten + case .plusLighter: .plusLighter + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift new file mode 100644 index 000000000..4be483667 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift @@ -0,0 +1,17 @@ +import SwiftUI +import ViewDocumentCore + +/// Prepared images and approved symbols. No file or network work occurs during rendering. +public struct ViewDocumentAssets { + let images: [ViewAssetID: Image] + let symbols: [ViewSymbolID: Image] + + public init(images: [ViewAssetID: Image], symbols: [ViewSymbolID: Image]) { + self.images = images + self.symbols = symbols + } + + public var resources: ViewDocumentResources { + .init(images: Set(images.keys), symbols: Set(symbols.keys)) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift new file mode 100644 index 000000000..c74a81d8d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import ViewDocumentCore + +struct ViewDocumentGroupView: View { + let axis: ViewDocument.Axis + let alignment: ViewAlignment + let spacing: Double + let children: [ViewDocument.Node] + let assets: ViewDocumentAssets + let appearance: ViewDocumentAppearance + let policy: ViewDocumentView.Policy + + var body: some View { + switch axis { + case .horizontal: + HStack(alignment: alignment.alignment.vertical, spacing: spacing) { + nodes + } + case .vertical: + VStack(alignment: alignment.alignment.horizontal, spacing: spacing) { + nodes + } + case .overlay: + ZStack(alignment: alignment.alignment) { + nodes + } + } + } + + private var nodes: some View { + ForEach(children) { node in + ViewDocumentNodeView(node: node, assets: assets, appearance: appearance, policy: policy) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift new file mode 100644 index 000000000..c926772f0 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift @@ -0,0 +1,22 @@ +import SwiftUI +import ViewDocumentCore + +/// Fill clips at the proposed image bounds; tiling is one native paint operation. +struct ViewDocumentImage: View { + let image: Image + let sizing: ViewImageDescriptor.Sizing + + var body: some View { + switch sizing { + case .fit: image.resizable().scaledToFit() + case .fill: + GeometryReader { geometry in + image.resizable().scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + case let .tile(scale): + Rectangle().fill(ImagePaint(image: image, scale: scale)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift new file mode 100644 index 000000000..3aa56210a --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import ViewDocumentCore + +/// Type erasure is confined to the recursive document interpreter. +struct ViewDocumentNodeView: View { + let node: ViewDocument.Node + let assets: ViewDocumentAssets + let appearance: ViewDocumentAppearance + let policy: ViewDocumentView.Policy + + var body: some View { + let styled = node.modifiers.reduce(content()) { view, modifier in + modifier.applying(to: view, appearance: appearance) + } + let accessible = accessibility(styled) + if let animation = node.animation { + ViewDocumentAnimatedView(animation: animation, policy: policy, content: accessible) + } else { + accessible + } + } + + private func content() -> AnyView { + switch node.content { + case let .text(text): AnyView(Text(verbatim: text)) + case let .image(asset): AnyView(assets.images[asset]!.resizable().scaledToFit()) + case let .raster(descriptor): AnyView(ViewDocumentImage( + image: assets.images[descriptor.asset]!, + sizing: descriptor.sizing, + )) + case let .symbol(symbol): AnyView(assets.symbols[symbol]!) + case let .shape(shape): AnyView(ViewDocumentShape(descriptor: shape)) + case let .spacer(minimum): AnyView(Spacer(minLength: minimum)) + case let .group(axis, alignment, spacing, children): + AnyView(ViewDocumentGroupView( + axis: axis, + alignment: alignment, + spacing: spacing, + children: children, + assets: assets, + appearance: appearance, + policy: policy, + )) + } + } + + private func accessibility(_ content: AnyView) -> AnyView { + switch node.accessibility { + case .automatic: content + case .hidden: AnyView(content.accessibilityHidden(true)) + case let .label(label): AnyView(content.accessibilityLabel(Text(verbatim: label))) + case .combine: AnyView(content.accessibilityElement(children: .combine)) + case .contain: AnyView(content.accessibilityElement(children: .contain)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift new file mode 100644 index 000000000..312c7cfef --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift @@ -0,0 +1,16 @@ +import SwiftUI +import ViewDocumentCore + +struct ViewDocumentShape: Shape { + let descriptor: ViewShapeDescriptor + + func path(in rect: CGRect) -> Path { + switch descriptor { + case .rectangle: Rectangle().path(in: rect) + case let .roundedRectangle(radius): RoundedRectangle(cornerRadius: radius) + .path(in: rect) + case .circle: Circle().path(in: rect) + case .ellipse: Ellipse().path(in: rect) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift new file mode 100644 index 000000000..da439e80c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift @@ -0,0 +1,100 @@ +import SwiftUI +import ViewDocumentCore + +/// Renders validated read-only content. Host policy controls motion and decorative use. +public struct ViewDocumentView: View { + private let document: ValidatedViewDocument + private let assets: ViewDocumentAssets + private let appearance: ViewDocumentAppearance + private let policy: Policy + + public struct Policy: Equatable, Sendable { + public var purpose: Purpose + public var isActive: Bool + public var motionIsStatic: Bool + + public init(purpose: Purpose, isActive: Bool, motionIsStatic: Bool) { + self.purpose = purpose + self.isActive = isActive + self.motionIsStatic = motionIsStatic + } + + public enum Purpose: Sendable { + case content, decoration + } + } + + public init( + document: ValidatedViewDocument, + assets: ViewDocumentAssets, + appearance: ViewDocumentAppearance, + policy: Policy, + ) throws { + let prepared = try PreparedViewDocument(document: document, assets: assets) + self.init(prepared: prepared, appearance: appearance, policy: policy) + } + + public init( + prepared: PreparedViewDocument, + appearance: ViewDocumentAppearance, + policy: Policy, + ) { + let document = prepared.document + let assets = prepared.assets + self.document = document + self.assets = assets + self.appearance = appearance + self.policy = policy + } + + public enum ResourceError: Error { + case mismatchedAssets + } + + public var body: some View { + ViewDocumentNodeView( + node: document.document.root, + assets: assets, + appearance: appearance, + policy: policy, + ) + .id(document.document) + .accessibilityHidden(policy.purpose == .decoration) + .allowsHitTesting(false) + } +} + +#if DEBUG + #Preview("Read-only document") { ViewDocumentPreview.make() } + + @MainActor + private enum ViewDocumentPreview { + static func make() -> ViewDocumentView { + do { + let assets = ViewDocumentAssets(images: [:], symbols: [:]) + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "greeting"), + content: .text("Hello from ViewDocument"), + modifiers: [ + .font(.init(size: .semantic(.title), weight: .bold, design: .rounded)), + .padding(.points(24)), + ], + animation: nil, + accessibility: .automatic, + )), + resources: assets.resources, + limits: .standard, + ) + return try ViewDocumentView( + document: document, + assets: assets, + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + } catch { + preconditionFailure("Invalid built-in preview: \(error)") + } + } + } +#endif diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift new file mode 100644 index 000000000..e4d4c9b9a --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift @@ -0,0 +1,38 @@ +import SwiftUI +import ViewDocumentCore + +extension ViewModifierDescriptor { + func applying(to view: AnyView, appearance: ViewDocumentAppearance) -> AnyView { + switch self { + case let .font(font): AnyView(view.font(font.font)) + case let .foreground(paint): AnyView(view.foregroundStyle(appearance.paint(paint))) + case let .frame(width, height, alignment): + AnyView(view.frame( + width: width.map(appearance.dimension), + height: height.map(appearance.dimension), + alignment: alignment.alignment, + )) + case let .padding(value): AnyView(view.padding(appearance.dimension(value))) + case let .offset(x, y): AnyView(view.offset(x: x, y: y)) + case let .scale(x, y): AnyView(view.scaleEffect(x: x, y: y)) + case let .rotation(degrees): AnyView(view.rotationEffect(.degrees(degrees))) + case let .opacity(value): AnyView(view.opacity(value)) + case let .clip(shape): AnyView(view.clipShape(ViewDocumentShape(descriptor: shape))) + case let .background(shape, paint): + AnyView(view + .background(ViewDocumentShape(descriptor: shape).fill(appearance.paint(paint)))) + case let .border(shape, paint, width): + AnyView(view.overlay(ViewDocumentShape(descriptor: shape).stroke( + appearance.paint(paint), + lineWidth: width, + ))) + case let .shadow(color, radius, x, y): AnyView(view.shadow( + color: appearance.color(color), + radius: radius, + x: x, + y: y, + )) + case let .blend(blend): AnyView(view.blendMode(blend.blendMode)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift new file mode 100644 index 000000000..f84cd2979 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift @@ -0,0 +1,65 @@ +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +struct ViewDocumentAppearanceTests { + @Test func backgroundLevelsUseHostInputsAndTheLegacyInitializerHasOneFallback() { + let appearance = ViewDocumentAppearance(backgrounds: .init( + primary: .red, + secondary: .green, + grouped: .blue, + secondaryGrouped: .orange, + ), dimensionScales: [:]) + #expect(appearance.color(.semantic(.secondaryBackground)) == Color.green) + #expect(appearance.color(.semantic(.groupedBackground)) == Color.blue) + #expect(appearance.color(.semantic(.secondaryGroupedBackground)) == Color.orange) + let fallback = ViewDocumentAppearance(background: .purple, dimensionScales: [:]) + #expect(fallback.color(.semantic(.secondaryGroupedBackground)) == Color.purple) + } + + @Test func decodedNativePaletteRetainsSystemAdaptation() throws { + let decoded = try JSONDecoder().decode( + ViewColorDescriptor.self, + from: Data(#"{"opacity":{"_0":{"semantic":{"_0":"red"}},"_1":0.15}}"#.utf8), + ) + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [:]) + #expect(appearance.color(decoded) == Color.red.opacity(0.15)) + #expect(appearance.color(.semantic(.orange)) == Color.orange) + } + + @Test func preservesSemanticColorCompositions() { + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [:]) + #expect(appearance.color(.opacity(.semantic(.primary), 0.12)) == Color.primary + .opacity(0.12)) + #expect(appearance + .color(.mix(.semantic(.primary), .semantic(.accent), fraction: 0.25)) == Color.primary + .mix( + with: .accentColor, + by: 0.25, + in: .perceptual, + )) + } + + @Test func preservesSystemWeightUnlessExplicitlyOverridden() { + let semantic = ViewFontDescriptor( + size: .semantic(.headline), + weight: nil, + design: nil, + ) + #expect(semantic.font == Font.headline) + let regular = ViewFontDescriptor( + size: .semantic(.headline), + weight: .regular, + design: nil, + ) + #expect(regular.font == Font.headline.weight(.regular)) + #expect(regular.font != semantic.font) + } + + @Test func scalesOnlyAuthoredSemanticDimensions() { + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [.body: 2]) + #expect(appearance.dimension(.points(12)) == 12) + #expect(appearance.dimension(.scaled(points: 12, relativeTo: .body)) == 24) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift new file mode 100644 index 000000000..31a836cf6 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Testing +import ViewDocumentCore +@testable import ViewDocumentUI + +@MainActor +struct ViewDocumentImageTests { + @Test(arguments: [ViewImageDescriptor.Sizing.fit, .fill, .tile(scale: 0.5)]) + func imageSizingPreservesHostBounds(sizing: ViewImageDescriptor.Sizing) throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 20)).image { context in + UIColor.orange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 20)) + } + let renderer = ImageRenderer(content: ViewDocumentImage( + image: Image(uiImage: source), + sizing: sizing, + ) + .frame(width: 80, height: 80)) + let image = try #require(renderer.uiImage) + #expect(image.size == CGSize(width: 80, height: 80)) + } + + @Test func imageModesHaveDistinctPixelsAndFillStaysClipped() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let source = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 20), format: format) + .image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 20, height: 20)) + UIColor.blue.setFill() + context.fill(CGRect(x: 20, y: 0, width: 20, height: 20)) + } + func pixels(_ sizing: ViewImageDescriptor.Sizing) throws -> [UInt8] { + let view = ViewDocumentImage(image: Image(uiImage: source), sizing: sizing) + .frame(width: 80, height: 80).padding(10) + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + var bytes = [UInt8](repeating: 0, count: 100 * 100 * 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try #require(CGContext( + data: buffer.baseAddress, + width: 100, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 400, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: 100, height: 100)) + } + return bytes + } + let fit = try pixels(.fit) + let fill = try pixels(.fill) + let tile = try pixels(.tile(scale: 1)) + func channel(_ pixels: [UInt8], x: Int, y: Int, channel: Int) -> UInt8 { + pixels[(y * 100 + x) * 4 + channel] + } + // Top inset inside the host: fit is transparent, fill occupies the full height. + #expect(channel(fit, x: 20, y: 20, channel: 3) == 0) + #expect(channel(fill, x: 20, y: 20, channel: 3) == 255) + // The second tile starts red again; the cropped fill remains blue on the right. + #expect(channel(tile, x: 60, y: 50, channel: 0) == 255) + #expect(channel(fill, x: 60, y: 50, channel: 2) == 255) + // Cropped source pixels never escape into the surrounding padding. + #expect(channel(fill, x: 5, y: 50, channel: 3) == 0) + #expect(channel(fill, x: 95, y: 50, channel: 3) == 0) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift new file mode 100644 index 000000000..75fafa265 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +@MainActor +struct ViewDocumentViewTests { + @Test func rejectsAssetsThatDifferFromValidation() throws { + let asset = ViewAssetID(rawValue: "artwork") + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "root"), + content: .image(asset), + modifiers: [], + animation: nil, + accessibility: .label("Artwork"), + )), + resources: .init(images: [asset], symbols: []), + limits: .standard, + ) + #expect(throws: ViewDocumentView.ResourceError.self) { + try ViewDocumentView( + document: document, + assets: .init(images: [:], symbols: [:]), + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + } + } + + @Test func rendersStandaloneDocumentWithOrderedPadding() throws { + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "root"), + content: .shape(.rectangle), + modifiers: [ + .frame(width: .points(20), height: .points(10), alignment: .center), + .padding(.points(5)), + ], + animation: nil, + accessibility: .hidden, + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + let view = try ViewDocumentView( + document: document, + assets: .init(images: [:], symbols: [:]), + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + let renderer = ImageRenderer(content: view) + let image = try #require(renderer.uiImage) + #expect(image.size == CGSize(width: 30, height: 20)) + } +}