diff --git a/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift b/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift index 1db42bdea..3d3b912c1 100644 --- a/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift +++ b/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift @@ -19,7 +19,7 @@ struct ProgressViewUITests { openSwiftUIAssertSnapshot(of: IndeterminateProgressViewExample()) } - @Test(.disabled("ResolvableTextSegmentAttribute is not implemented yet")) + @Test(.disabled("TextLayoutManager is not implemented yet")) func defaultDateProgressLabelInitializers() { openSwiftUIAssertSnapshot(of: DefaultDateProgressLabelExample()) } diff --git a/Sources/OpenSwiftUICore/View/Text/Resolve/ConfigurationBasedResolvableStringAttribute.swift b/Sources/OpenSwiftUICore/View/Text/Resolve/ConfigurationBasedResolvableStringAttribute.swift index 246095d09..17ed74cf4 100644 --- a/Sources/OpenSwiftUICore/View/Text/Resolve/ConfigurationBasedResolvableStringAttribute.swift +++ b/Sources/OpenSwiftUICore/View/Text/Resolve/ConfigurationBasedResolvableStringAttribute.swift @@ -163,6 +163,14 @@ private protocol InvalidationConfigurtaionProvider { var invalidationConfiguration: ResolvableAttributeConfiguration { get } } +extension ReducedTimelineSchedule: InvalidationConfigurtaionProvider where T1: InvalidationConfigurtaionProvider, T2: InvalidationConfigurtaionProvider { + var invalidationConfiguration: ResolvableAttributeConfiguration { + var configuration = t1.invalidationConfiguration + configuration.reduce(t2.invalidationConfiguration) + return configuration + } +} + extension ResolvableAttributeConfiguration.Schedule: InvalidationConfigurtaionProvider {} extension TimeDataFormatting.Resolvable: InvalidationConfigurtaionProvider {} diff --git a/Sources/OpenSwiftUICore/View/Text/Resolve/ResolvableTextSegmentAttribute.swift b/Sources/OpenSwiftUICore/View/Text/Resolve/ResolvableTextSegmentAttribute.swift index 0ac6db811..7410930c4 100644 --- a/Sources/OpenSwiftUICore/View/Text/Resolve/ResolvableTextSegmentAttribute.swift +++ b/Sources/OpenSwiftUICore/View/Text/Resolve/ResolvableTextSegmentAttribute.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Complete-Stubbed +// Status: Complete // ID: E9C99F480CB4DD26488FF949B5D8B9E1 (SwiftUICore) package import Foundation @@ -14,42 +14,273 @@ extension NSAttributedString.Key { package static let resolvableTextSegment: NSAttributedString.Key = .init(ResolvableTextSegmentAttribute.name) } -// MARK: - ResolvableTextSegmentAttribute [TODO] +// MARK: - ResolvableTextSegmentAttribute package enum ResolvableTextSegmentAttribute: CodableAttributedStringKey { - // FIXME + + // MARK: - ResolvableTextSegmentAttribute.Value + package struct Value: Codable, Hashable { + private let uuid: UUID + + @CodableRawRepresentable + var resolvableAttributeKey: NSAttributedString.Key + + private var runs: [Run] + + fileprivate init( + uuid: UUID, + resolvableAttributeKey: NSAttributedString.Key, + runs: [Run] + ) { + self.uuid = uuid + self.resolvableAttributeKey = resolvableAttributeKey + self.runs = runs + } + package func isAttributeRequiredForResolution( _ attribute: NSAttributedString.Key, includeNonFunctionalAttributes: Bool ) -> Bool { - _openSwiftUIUnimplementedFailure() + if attribute == .resolvableTextSegment || + attribute == .updateSchedule || + attribute == resolvableAttributeKey + { + return true + } + guard includeNonFunctionalAttributes else { + return false + } + return runs.contains { run in + run.attributesToApply[attribute] == nil && + !run.attributeKeysToErase.contains(attribute) + } + } + + package static func == (lhs: Value, rhs: Value) -> Bool { + lhs.uuid == rhs.uuid + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(uuid) + } + + func restoreDefault( + in range: NSRange, + of string: NSMutableAttributedString + ) { + for run in runs { + let runRange = NSRange( + location: range.location + run.range.lowerBound, + length: run.range.upperBound - run.range.lowerBound + ) + for key in run.attributeKeysToErase { + string.removeAttribute(key, range: runRange) + } + string.addAttributes(run.attributesToApply, range: runRange) + } + } + + func toggleAttributes( + in range: NSRange, + of string: NSMutableAttributedString + ) { + string.removeAttribute(.resolvableTextSegment, range: range) + let targetRuns = string.runs(in: range) + restoreDefault(in: range, of: string) + let currentRuns = string.runs(in: range) + var reverseRuns: [Run] = [] + reverseRuns.reserveCapacity(max(targetRuns.count, currentRuns.count)) + var currentRunIndex = 0 + for targetRun in targetRuns { + while currentRunIndex < currentRuns.count { + let currentRun = currentRuns[currentRunIndex] + let intersection = NSIntersectionRange(targetRun.range, currentRun.range) + guard intersection.length != 0 else { + break + } + reverseRuns.append(Run( + range: NSRange( + location: intersection.location - range.location, + length: intersection.length + ), + oldAttributes: targetRun.attributes, + newAttributes: currentRun.attributes + )) + currentRunIndex += 1 + } + currentRunIndex = max(currentRunIndex - 1, 0) + } + let value = Value( + uuid: UUID(), + resolvableAttributeKey: resolvableAttributeKey, + runs: reverseRuns + ) + string.addAttribute(.resolvableTextSegment, value: value, range: range) + } + + func update( + _ range: NSRange, + of string: NSMutableAttributedString, + in context: ResolvableStringResolutionContext + ) { + guard let attributeValue = string.attributes(at: range.location, effectiveRange: nil)[resolvableAttributeKey], + let resolvable = attributeValue as? any ResolvableStringAttribute, + let resolved = resolvable.resolve(in: context) else { + Log.internalWarning( + "Unable to update ResolvableStringAttributein \(range) of \(string)\"" + ) + return + } + restoreDefault(in: range, of: string) + string.removeAttribute(.resolvableTextSegment, range: range) + string.removeAttribute(resolvableAttributeKey, range: range) + let defaultAttributes = string.attributes(at: range.location, effectiveRange: nil) + + var resolvedString = String(resolved.characters) + if context.environment.shouldRedactContent { + resolvedString = String(repeating: "􀮷", count: resolvedString.count) + } + resolvedString = resolvedString.caseConvertedIfNeeded(context.environment) + string.replaceCharacters(in: range, with: resolvedString) + let attributedResolution = NSAttributedString(resolved) + attributedResolution.enumerateAttributes( + in: attributedResolution.range + ) { attributes, runRange, _ in + string.addAttributes( + attributes, + range: NSRange( + location: range.location + runRange.location, + length: runRange.length + ) + ) + } + let newRange = NSRange( + location: range.location, + length: attributedResolution.length + ) + let value = Value( + uuid: UUID(), + resolvableAttributeKey: resolvableAttributeKey, + runs: string.runs(in: newRange).map { run in + Run( + range: NSRange( + location: run.range.location - newRange.location, + length: run.range.length + ), + oldAttributes: defaultAttributes, + newAttributes: run.attributes + ) + } + ) + string.addAttribute(.resolvableTextSegment, value: value, range: newRange) + string.addAttribute(resolvableAttributeKey, value: resolvable, range: newRange) + } + + // MARK: - ResolvableTextSegmentAttribute.Run + + fileprivate struct Run: Codable { + let range: Range + + @CodableNSAttributes + var attributesToApply: [NSAttributedString.Key: Any] + + @ProxyCodable + var attributeKeysToErase: [NSAttributedString.Key] + + init( + range: NSRange, + oldAttributes: [NSAttributedString.Key: Any], + newAttributes: [NSAttributedString.Key: Any] + ) { + func areEqual(_ lhs: Any, _ rhs: Any) -> Bool { + func areEqual(_ lhs: T, _ rhs: Any) -> Bool where T: Equatable { + guard let rhs = rhs as? T else { + return false + } + return lhs == rhs + } + guard let lhs = lhs as? any Equatable else { + return false + } + return areEqual(lhs, rhs) + } + + self.range = range.lowerBound ..< range.upperBound + guard !newAttributes.isEmpty else { + attributesToApply = [:] + attributeKeysToErase = [] + return + } + var equalKeys: Set = [] + let attributesToApply = oldAttributes.filter { key, oldValue in + guard let newValue = newAttributes[key] else { + return true + } + guard areEqual(newValue, oldValue) else { + return true + } + equalKeys.insert(key) + return false + } + let attributeKeysToErase = newAttributes.keys.filter { key in + attributesToApply[key] == nil && !equalKeys.contains(key) + } + self.attributesToApply = attributesToApply + self.attributeKeysToErase = attributeKeysToErase + } } } package static let name: String = "OpenSwiftUI.resolvableTextSegment" } +// MARK: - ResolvableTextSegmentAttribute + Updates + extension ResolvableTextSegmentAttribute { package static func legacySegment( resolvableAttributeKey: NSAttributedString.Key, length: Int ) -> Value { - _openSwiftUIUnimplementedFailure() + Value( + uuid: UUID(), + resolvableAttributeKey: resolvableAttributeKey, + runs: [Value.Run( + range: NSRange(location: 0, length: length), + oldAttributes: [:], + newAttributes: [:] + )] + ) } package static func toggleAttributes(in string: NSMutableAttributedString) { - _openSwiftUIUnimplementedFailure() + string.enumerateAttribute(.resolvableTextSegment, in: string.range) { value, range, _ in + guard let value = value as? Value else { + return + } + value.toggleAttributes(in: range, of: string) + } } package static func update( _ string: NSMutableAttributedString, in context: ResolvableStringResolutionContext ) { - _openSwiftUIUnimplementedFailure() + string.enumerateAttribute( + .resolvableTextSegment, + in: string.range, + options: .reverse + ) { value, range, _ in + guard let value = value as? Value else { + return + } + value.update(range, of: string, in: context) + } } } +// MARK: - ResolvableTextSegmentAttribute + Construction + extension ResolvableTextSegmentAttribute { package static func buildDynamicTextSegment( for resolvable: R, @@ -59,8 +290,116 @@ extension ResolvableTextSegmentAttribute { options: Text.ResolveOptions, properties: inout Text.ResolvedProperties ) -> NSMutableAttributedString? where R: ResolvableStringAttribute { - _openSwiftUIUnimplementedWarning() - return nil + if options.contains(.includeSupportForRepeatedResolution) { + buildResolvableTextSegment( + for: resolvable, + style: style, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + options: options, + properties: &properties + ) + } else { + buildUpdatableTextSegment( + for: resolvable, + style: style, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + options: options, + properties: &properties + ) + } + } + + private static func buildResolvableTextSegment( + for resolvable: R, + style: Text.Style, + environment: EnvironmentValues, + includeDefaultAttributes: Bool, + options: Text.ResolveOptions, + properties: inout Text.ResolvedProperties + ) -> NSMutableAttributedString? where R: ResolvableStringAttribute { + var variant = resolvable.sizeVariant(environment.textSizeVariant).resolvable + guard let string = buildStaticTextSegment( + for: resolvable, + style: style, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + options: options, + properties: &properties + ) else { + return nil + } + let content = string.string + let defaultAttributes = style.nsAttributes( + content: { content }, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + with: options, + properties: &properties + ) + var resolver = PlatformAttributeResolver( + content: content, + style: style, + environment: environment, + options: options, + defaultAttributes: defaultAttributes, + properties: properties + ) + variant.makePlatformAttributes(resolver: &resolver) + properties = resolver.properties + let value = Value( + uuid: UUID(), + resolvableAttributeKey: R.attribute, + runs: string.runs().map { run in + Value.Run( + range: run.range, + oldAttributes: defaultAttributes, + newAttributes: run.attributes + ) + } + ) + string.addUniformAttribute(R.attribute, value: variant) + string.addUniformAttribute(.resolvableTextSegment, value: value) + return string + } + + private static func buildUpdatableTextSegment( + for resolvable: R, + style: Text.Style, + environment: EnvironmentValues, + includeDefaultAttributes: Bool, + options: Text.ResolveOptions, + properties: inout Text.ResolvedProperties + ) -> NSMutableAttributedString? where R: ResolvableStringAttribute { + let variant = resolvable.sizeVariant(environment.textSizeVariant) + if variant.exact { + properties.features.insert(.isUniqueSizeVariant) + } + guard let string = buildStaticTextSegment( + for: variant.resolvable, + style: style, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + options: options, + properties: &properties + ) else { + return nil + } + let value = Value( + uuid: UUID(), + resolvableAttributeKey: R.attribute, + runs: string.runs().map { run in + Value.Run( + range: run.range, + oldAttributes: [:], + newAttributes: [:] + ) + } + ) + string.addUniformAttribute(R.attribute, value: variant.resolvable) + string.addUniformAttribute(.resolvableTextSegment, value: value) + return string } package static func buildStaticTextSegment( @@ -71,12 +410,50 @@ extension ResolvableTextSegmentAttribute { options: Text.ResolveOptions, properties: inout Text.ResolvedProperties ) -> NSMutableAttributedString? where R: ResolvableStringAttribute { - _openSwiftUIUnimplementedWarning() - return nil + let variant = resolvable.sizeVariant(environment.textSizeVariant) + if variant.exact { + properties.features.insert(.isUniqueSizeVariant) + } + guard let resolved = variant.resolvable.initialResolution( + in: environment, + options: options, + properties: &properties + ) else { + return nil + } + let result = NSMutableAttributedString(resolved) + result.convertToPlatformStyled( + style: style, + environment: environment, + includeDefaultAttributes: includeDefaultAttributes, + options: options, + properties: &properties + ) + if environment.sensitiveContent { + properties.addSensitive() + } + return result } } -// MARK: - PlatformAttributeResolver [TODO] +// MARK: - ResolvableStringAttribute + Initial Resolution + +extension ResolvableStringAttribute { + fileprivate func initialResolution( + in environment: EnvironmentValues, + options: Text.ResolveOptions, + properties: inout Text.ResolvedProperties + ) -> AttributedString? { + properties.features.formUnion(requiredFeatures) + if options.contains(.writeAuxiliaryMetadata) { + return AttributedString(String.nsAttachment) + } else { + return resolve(in: ResolvableStringResolutionContext(environment: environment)) + } + } +} + +// MARK: - PlatformAttributeResolver package struct PlatformAttributeResolver { let content: String @@ -86,10 +463,38 @@ package struct PlatformAttributeResolver { let defaultAttributes: [NSAttributedString.Key: Any] var properties: Text.ResolvedProperties - func platformAttributes( + mutating func platformAttributes( for container: AttributeContainer, includeDefaultValueAttributes: Bool ) -> [NSAttributedString.Key: Any] { - _openSwiftUIUnimplementedFailure() + #if canImport(Darwin) + var attributes = [NSAttributedString.Key: Any](container) + var style = style + attributes.transferAttributedStringStyles(to: &style) + let content = content + let platformAttributes = style.nsAttributes( + content: { content }, + environment: environment, + includeDefaultAttributes: true, + with: options, + properties: &properties + ) + attributes.merge(platformAttributes) { _, new in new } + guard !includeDefaultValueAttributes else { + return attributes + } + for (key, defaultValue) in defaultAttributes { + guard let value = attributes[key], + AttributeContainer([key: value]) == AttributeContainer([key: defaultValue]) + else { + continue + } + attributes[key] = nil + } + return attributes + #else + _openSwiftUIPlatformUnimplementedWarning() + return [:] + #endif } } diff --git a/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift b/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift index 6ae7ac797..80cec9e45 100644 --- a/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift +++ b/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift @@ -3,11 +3,98 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete package import Foundation -// TODO: ReducedTimelineSchedule +// MARK: - ReducedTimelineSchedule + +struct ReducedTimelineSchedule where T1: TimelineSchedule, T2: TimelineSchedule { + let t1: T1 + let t2: T2 + + func entries( + from startDate: Date, + mode: TimelineScheduleMode + ) -> ReducedSequence { + t1.entries(from: startDate, mode: mode).reduced( + with: t2.entries(from: startDate, mode: mode) + ) + } +} + +extension ReducedTimelineSchedule: TimelineSchedule {} + +extension ReducedTimelineSchedule: Equatable where T1: Equatable, T2: Equatable {} + +extension TimelineSchedule { + func reduced(with schedule: S) -> ReducedTimelineSchedule where S: TimelineSchedule { + ReducedTimelineSchedule(t1: self, t2: schedule) + } +} + +// MARK: - ReducedSequence + +struct ReducedSequence: Sequence where S1: Sequence, S2: Sequence, S1.Element: Comparable, S1.Element == S2.Element { + struct Iterator: IteratorProtocol { + var s1: S1.Iterator + var s2: S2.Iterator + + init(s1: S1.Iterator, s2: S2.Iterator) { + self.s1 = s1 + self.s2 = s2 + } + + mutating func next() -> S1.Element? { + var s1 = self.s1 + var s2 = self.s2 + switch (s1.next(), s2.next()) { + case let (element1?, element2?): + if element2 < element1 { + self.s2 = s2 + return element2 + } + self.s1 = s1 + if element1 == element2 { + self.s2 = s2 + } + return element1 + case let (element1?, nil): + self.s1 = s1 + return element1 + case let (nil, element2?): + self.s2 = s2 + return element2 + case (nil, nil): + return nil + } + } + } + + let s1: S1 + let s2: S2 + + func makeIterator() -> Iterator { + Iterator(s1: s1.makeIterator(), s2: s2.makeIterator()) + } +} + +extension Sequence where Element: Comparable { + func reduced(with sequence: S) -> ReducedSequence where S: Sequence, Element == S.Element { + ReducedSequence(s1: self, s2: sequence) + } +} + +// MARK: - ResolvableStringAttribute + Schedule + +extension ResolvableStringAttribute { + func reduceSchedule(with schedule: S) -> any TimelineSchedule where S: TimelineSchedule { + guard let ownSchedule = self.schedule else { + return schedule + } + return schedule.reduced(with: ownSchedule) + } +} // MARK: - NSAttributedString + Extension @@ -51,13 +138,20 @@ extension NSMutableAttributedString { return attribute(.updateSchedule, at: 0, effectiveRange: nil) as? any TimelineSchedule } var schedule: (any TimelineSchedule)? - enumerateAttribute(.resolvableTextSegment, in: range) { value, _, _ in - guard value != nil else { + enumerateAttribute(.resolvableTextSegment, in: range) { value, range, _ in + guard let value = value as? ResolvableTextSegmentAttribute.Value, + let resolvable = attribute( + value.resolvableAttributeKey, + at: range.location, + effectiveRange: nil + ) as? any ResolvableStringAttribute else { return } - // TODO: ResolvableTextSegmentAttribute - _openSwiftUIUnimplementedWarning() - schedule = nil + if let currentSchedule = schedule { + schedule = resolvable.reduceSchedule(with: currentSchedule) + } else { + schedule = resolvable.schedule + } } if let schedule { addAttribute(.updateSchedule, value: schedule, range: range) diff --git a/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ConfigurationBasedResolvableStringAttributeTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ConfigurationBasedResolvableStringAttributeTests.swift index 17b34a86e..9f99c7779 100644 --- a/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ConfigurationBasedResolvableStringAttributeTests.swift +++ b/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ConfigurationBasedResolvableStringAttributeTests.swift @@ -1,7 +1,6 @@ // // ConfigurationBasedResolvableStringAttributeTests.swift // OpenSwiftUICoreTests -// import Foundation @_spi(Private) @testable import OpenSwiftUICore diff --git a/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ResolvableTextSegmentAttributeTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ResolvableTextSegmentAttributeTests.swift new file mode 100644 index 000000000..22434ccff --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Text/Resolve/ResolvableTextSegmentAttributeTests.swift @@ -0,0 +1,442 @@ +// +// ResolvableTextSegmentAttributeTests.swift +// OpenSwiftUICoreTests + +import Foundation +@_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import Testing + +struct ResolvableTextSegmentAttributeTests { + @Test + func legacySegmentIdentityAndRequiredAttributes() { + let first = ResolvableTextSegmentAttribute.legacySegment( + resolvableAttributeKey: TestResolvable.attribute, + length: 4 + ) + let second = ResolvableTextSegmentAttribute.legacySegment( + resolvableAttributeKey: TestResolvable.attribute, + length: 4 + ) + + #expect(first != second) + #expect(first.isAttributeRequiredForResolution( + .resolvableTextSegment, + includeNonFunctionalAttributes: false + )) + #expect(first.isAttributeRequiredForResolution( + .updateSchedule, + includeNonFunctionalAttributes: false + )) + #expect(first.isAttributeRequiredForResolution( + TestResolvable.attribute, + includeNonFunctionalAttributes: false + )) + #expect(first.isAttributeRequiredForResolution( + NSAttributedString.Key("unrelated"), + includeNonFunctionalAttributes: true + )) + #expect(!first.isAttributeRequiredForResolution( + NSAttributedString.Key("unrelated"), + includeNonFunctionalAttributes: false + )) + + #expect(first == first) + } + + @Test + func staticSegmentResolvesContentAndMetadata() throws { + var properties = Text.ResolvedProperties() + let resolved = try #require(ResolvableTextSegmentAttribute.buildStaticTextSegment( + for: TestResolvable(text: "initial"), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: [], + properties: &properties + )) + #expect(resolved.string == "initial") + #expect(properties.features.contains(.attachments)) + #expect(resolved.attribute( + .resolvableTextSegment, + at: 0, + effectiveRange: nil + ) == nil) + + properties = Text.ResolvedProperties() + let metadata = try #require(ResolvableTextSegmentAttribute.buildStaticTextSegment( + for: TestResolvable(text: "initial"), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: Text.ResolveOptions(rawValue: 1 << 2), + properties: &properties + )) + #expect(metadata.string == String.nsAttachment) + } + + @Test + func dynamicSegmentTogglesAttributesAndIdentity() throws { + var properties = Text.ResolvedProperties() + let string = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: TestResolvable(text: "initial", kern: 3), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: Text.ResolveOptions(rawValue: 1 << 7), + properties: &properties + )) + let first = try #require(string.attribute( + NSAttributedString.Key.resolvableTextSegment, + at: 0, + effectiveRange: nil + ) as? ResolvableTextSegmentAttribute.Value) + #if canImport(Darwin) + #expect((string.attribute( + .kitKern, + at: 0, + effectiveRange: nil + ) as? NSNumber)?.doubleValue == 3) + #endif + + ResolvableTextSegmentAttribute.toggleAttributes(in: string) + let second = try #require(string.attribute( + NSAttributedString.Key.resolvableTextSegment, + at: 0, + effectiveRange: nil + ) as? ResolvableTextSegmentAttribute.Value) + #expect(second != first) + #expect(string.string == "initial") + #if canImport(Darwin) + #expect(string.attribute( + .kitKern, + at: 0, + effectiveRange: nil + ) == nil) + #endif + + ResolvableTextSegmentAttribute.toggleAttributes(in: string) + let third = try #require(string.attribute( + NSAttributedString.Key.resolvableTextSegment, + at: 0, + effectiveRange: nil + ) as? ResolvableTextSegmentAttribute.Value) + #expect(third != second) + #expect(string.string == "initial") + #if canImport(Darwin) + #expect((string.attribute( + .kitKern, + at: 0, + effectiveRange: nil + ) as? NSNumber)?.doubleValue == 3) + #endif + } + + @Test + func updateReplacesSegmentsInReverseOrder() throws { + var properties = Text.ResolvedProperties() + let first = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: OrderTrackingResolvable( + identifier: "first", + initialText: "first-initial", + updatedText: "1" + ), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: [], + properties: &properties + )) + let second = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: OrderTrackingResolvable( + identifier: "second", + initialText: "second-initial", + updatedText: "22" + ), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: [], + properties: &properties + )) + first.append(second) + OrderTrackingResolvable.resetResolutionOrder() + + ResolvableTextSegmentAttribute.update( + first, + in: ResolvableStringResolutionContext( + environment: EnvironmentValues(), + maximumWidth: 7 + ) + ) + + #expect(OrderTrackingResolvable.resolutionOrder == ["second", "first"]) + #expect(first.string == "122") + for location in 0 ..< first.length { + #expect(first.attribute( + OrderTrackingResolvable.attribute, + at: location, + effectiveRange: nil + ) is OrderTrackingResolvable) + #expect(first.attribute( + .resolvableTextSegment, + at: location, + effectiveRange: nil + ) is ResolvableTextSegmentAttribute.Value) + } + } + + @Test + func updatableSegmentUsesSizeVariantAndTracksExactness() throws { + var compactEnvironment = EnvironmentValues() + compactEnvironment.textSizeVariant = .compact + var compactProperties = Text.ResolvedProperties() + let compact = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: SizeVariantResolvable(text: "unselected"), + style: Text.Style(), + environment: compactEnvironment, + includeDefaultAttributes: false, + options: [], + properties: &compactProperties + )) + #expect(compact.string == "compact") + #expect(compactProperties.features.contains(.isUniqueSizeVariant)) + + var smallEnvironment = EnvironmentValues() + smallEnvironment.textSizeVariant = .small + var smallProperties = Text.ResolvedProperties() + let small = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: SizeVariantResolvable(text: "unselected"), + style: Text.Style(), + environment: smallEnvironment, + includeDefaultAttributes: false, + options: [], + properties: &smallProperties + )) + #expect(small.string == "small") + #expect(!smallProperties.features.contains(.isUniqueSizeVariant)) + } + + @Test + func updateScheduleCombinesResolvableSegmentsAndCachesResult() throws { + let firstDate = Date(timeIntervalSinceReferenceDate: 10) + let secondDate = Date(timeIntervalSinceReferenceDate: 20) + let thirdDate = Date(timeIntervalSinceReferenceDate: 30) + let fourthDate = Date(timeIntervalSinceReferenceDate: 40) + var properties = Text.ResolvedProperties() + let string = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: ScheduledResolvable( + text: "first", + dates: [firstDate, thirdDate] + ), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: [], + properties: &properties + )) + let second = try #require(ResolvableTextSegmentAttribute.buildDynamicTextSegment( + for: ScheduledResolvable( + text: "second", + dates: [secondDate, thirdDate, fourthDate] + ), + style: Text.Style(), + environment: EnvironmentValues(), + includeDefaultAttributes: false, + options: [], + properties: &properties + )) + string.append(second) + + let schedule = try #require(string.resolveUpdateSchedule(recalculate: true)) + #expect(Array(schedule.entries(from: firstDate, mode: .normal)) == [ + firstDate, + secondDate, + thirdDate, + fourthDate, + ]) + #expect(string.isDynamic) + + let cachedSchedule = try #require(string.resolveUpdateSchedule(recalculate: false)) + #expect(Array(cachedSchedule.entries(from: firstDate, mode: .normal)) == [ + firstDate, + secondDate, + thirdDate, + fourthDate, + ]) + } + + @Test + func recalculatingUpdateScheduleRemovesStaleCache() throws { + let string = NSMutableAttributedString(string: "static") + string.addAttribute( + .updateSchedule, + value: ExplicitTimelineSchedule([ + Date(timeIntervalSinceReferenceDate: 10), + ]), + range: string.range + ) + #expect(string.resolveUpdateSchedule(recalculate: false) != nil) + + #expect(string.resolveUpdateSchedule(recalculate: true) == nil) + #expect(!string.isDynamic) + } + + #if canImport(Darwin) + @Test + func platformAttributeResolverFiltersDefaultValues() { + let container = AttributeContainer() + var baseResolver = PlatformAttributeResolver( + content: "content", + style: Text.Style(), + environment: EnvironmentValues(), + options: Text.ResolveOptions(rawValue: 1 << 1), + defaultAttributes: [:], + properties: Text.ResolvedProperties() + ) + let includingDefaults = baseResolver.platformAttributes( + for: container, + includeDefaultValueAttributes: true + ) + #expect(!includingDefaults.isEmpty) + #expect(baseResolver.properties.features.contains(.keyColor)) + + var filteringResolver = PlatformAttributeResolver( + content: "content", + style: Text.Style(), + environment: EnvironmentValues(), + options: Text.ResolveOptions(rawValue: 1 << 1), + defaultAttributes: includingDefaults, + properties: Text.ResolvedProperties() + ) + let excludingDefaults = filteringResolver.platformAttributes( + for: container, + includeDefaultValueAttributes: false + ) + #expect(excludingDefaults.isEmpty) + } + + @Test + func platformAttributeResolverPrefersResolvedPlatformAttributeOnConflict() { + var style = Text.Style() + style.baselineOffset = 2 + let container = AttributeContainer([ + .kitBaselineOffset: CGFloat(1), + ]) + var resolver = PlatformAttributeResolver( + content: "content", + style: style, + environment: EnvironmentValues(), + options: [], + defaultAttributes: [:], + properties: Text.ResolvedProperties() + ) + + let attributes = resolver.platformAttributes( + for: container, + includeDefaultValueAttributes: true + ) + + #expect((attributes[.kitBaselineOffset] as? NSNumber)?.doubleValue == 2) + } + #endif +} + +private struct TestResolvable: ResolvableStringAttribute, ResolvableStringAttributeFamily, Codable { + static let attribute = NSAttributedString.Key("OpenSwiftUI.TestResolvable") + + var text: String + var kern: CGFloat? = nil + + func resolve(in context: ResolvableStringResolutionContext) -> AttributedString? { + let text: String + if let maximumWidth = context.maximumWidth { + text = String(Int(maximumWidth)) + } else { + text = self.text + } + var result = AttributedString(text) + if let kern { + result.openSwiftUI.kern = kern + } + return result + } + + var schedule: ExplicitTimelineSchedule<[Date]>? { + nil + } + + var requiredFeatures: Text.ResolvedProperties.Features { + .attachments + } +} + +private struct OrderTrackingResolvable: ResolvableStringAttribute, ResolvableStringAttributeFamily, Codable { + static let attribute = NSAttributedString.Key("OpenSwiftUI.OrderTrackingResolvable") + + private static let resolutions = AtomicBox(wrappedValue: [String]()) + + var identifier: String + var initialText: String + var updatedText: String + + static var resolutionOrder: [String] { + resolutions.wrappedValue + } + + static func resetResolutionOrder() { + resolutions.access { $0.removeAll(keepingCapacity: true) } + } + + func resolve(in context: ResolvableStringResolutionContext) -> AttributedString? { + guard context.maximumWidth != nil else { + return AttributedString(initialText) + } + Self.resolutions.access { $0.append(identifier) } + return AttributedString(updatedText) + } + + var schedule: ExplicitTimelineSchedule<[Date]>? { + nil + } +} + +private struct SizeVariantResolvable: ResolvableStringAttribute, ResolvableStringAttributeFamily, Codable { + static let attribute = NSAttributedString.Key("OpenSwiftUI.SizeVariantResolvable") + + var text: String + + func resolve(in context: ResolvableStringResolutionContext) -> AttributedString? { + AttributedString(text) + } + + var schedule: ExplicitTimelineSchedule<[Date]>? { + nil + } + + func sizeVariant(_ sizeVariant: TextSizeVariant) -> (resolvable: Self, exact: Bool) { + let text: String + if sizeVariant == .compact { + text = "compact" + } else if sizeVariant == .small { + text = "small" + } else { + text = "regular" + } + return (Self(text: text), sizeVariant == .compact) + } +} + +private struct ScheduledResolvable: ResolvableStringAttribute, ResolvableStringAttributeFamily, Codable { + static let attribute = NSAttributedString.Key("OpenSwiftUI.ScheduledResolvable") + + var text: String + var dates: [Date] + + func resolve(in context: ResolvableStringResolutionContext) -> AttributedString? { + AttributedString(text) + } + + var schedule: ExplicitTimelineSchedule<[Date]>? { + ExplicitTimelineSchedule(dates) + } +} diff --git a/Tests/OpenSwiftUICoreTests/View/Text/Util/ReducedTimelineScheduleTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/Util/ReducedTimelineScheduleTests.swift new file mode 100644 index 000000000..f766419ad --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Text/Util/ReducedTimelineScheduleTests.swift @@ -0,0 +1,54 @@ +// +// ReducedTimelineScheduleTests.swift +// OpenSwiftUICoreTests + +import Foundation +@testable import OpenSwiftUICore +import Testing + +struct ReducedTimelineScheduleTests { + @Test + func entriesAreMergedInOrderWithoutDuplicates() { + let firstDate = Date(timeIntervalSinceReferenceDate: 10) + let secondDate = Date(timeIntervalSinceReferenceDate: 20) + let sharedDate = Date(timeIntervalSinceReferenceDate: 30) + let fourthDate = Date(timeIntervalSinceReferenceDate: 40) + let fifthDate = Date(timeIntervalSinceReferenceDate: 50) + let schedule = ReducedTimelineSchedule( + t1: TestTimelineSchedule(dates: [firstDate, sharedDate, fifthDate]), + t2: TestTimelineSchedule(dates: [secondDate, sharedDate, fourthDate]) + ) + + #expect(Array(schedule.entries(from: firstDate, mode: .normal)) == [ + firstDate, + secondDate, + sharedDate, + fourthDate, + fifthDate, + ]) + } + + @Test + func equalityComparesBothSchedules() { + let firstDate = Date(timeIntervalSinceReferenceDate: 10) + let secondDate = Date(timeIntervalSinceReferenceDate: 20) + let first = TestTimelineSchedule(dates: [firstDate]) + let second = TestTimelineSchedule(dates: [secondDate]) + let schedule = ReducedTimelineSchedule(t1: first, t2: second) + + #expect(schedule == ReducedTimelineSchedule(t1: first, t2: second)) + #expect(schedule != ReducedTimelineSchedule(t1: second, t2: second)) + #expect(schedule != ReducedTimelineSchedule(t1: first, t2: first)) + } +} + +private struct TestTimelineSchedule: TimelineSchedule, Equatable { + let dates: [Date] + + func entries( + from _: Date, + mode _: TimelineScheduleMode + ) -> [Date] { + dates + } +}