diff --git a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift index 483a02439..bb2a4944f 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift @@ -350,6 +350,17 @@ public struct ContentTransition: Equatable, Sendable { /// system uses an opacity transition instead. public static let interpolate: ContentTransition = .init(storage: .named(.init())) // FIXME + public static func numericText(countsDown: Bool = false) -> ContentTransition { + // FIXME + .init(storage: .named(.init(name: .numericText(.init(direction: .fixed(downwards: countsDown)))))) + } + + @_spi(Private) + @available(*, deprecated, message: "replaced by numericText(countsDown:)") + public static func numericText(increasing: Bool) -> ContentTransition { + .numericText(countsDown: !increasing) + } + // MARK: - ContentTransition.Options @_spi(Private) diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/CalendarDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/CalendarDependentFormatStyle.swift new file mode 100644 index 000000000..03ae480da --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/CalendarDependentFormatStyle.swift @@ -0,0 +1,80 @@ +// +// CalendarDependentFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete (Blocked by WhitespaceRemovingFormatStyle/SystemFormatStyle) +// ID: 26D279F2E8972E56094553A13FA39915 (SwiftUICore) + +package import Foundation + +protocol CalendarDependentFormatStyle: FormatStyle { + func withCalendar(_ calendar: Calendar) -> Self +} + +extension FormatStyle { + package func calendar(_ calendar: Calendar) -> Self { + guard let style = self as? any CalendarDependentFormatStyle else { + return self + } + return style.withCalendar(calendar) as! Self + } +} + +#if canImport(Darwin) +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.FormatStyle: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + var style = self + style.calendar = calendar + return style + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.VerbatimFormatStyle: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + var style = self + style.calendar = calendar + return style + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.ComponentsFormatStyle: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + self.calendar(calendar) + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension Date.FormatStyle.Attributed: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + var style = self + style[dynamicMember: \.calendar] = calendar + return style + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension Date.VerbatimFormatStyle.Attributed: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + var style = self + style[dynamicMember: \.calendar] = calendar + return style + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.AnchoredRelativeFormatStyle: CalendarDependentFormatStyle { + func withCalendar(_ calendar: Calendar) -> Self { + var style = self + style.calendar = calendar + return style + } +} + +// TODO: Add conformance when these concrete format styles land: +// WhitespaceRemovingFormatStyle where A: CalendarDependentFormatStyle +// SystemFormatStyle.DateReference +#endif diff --git a/Sources/OpenSwiftUICore/View/Text/Text/CapitalizationContextDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift similarity index 97% rename from Sources/OpenSwiftUICore/View/Text/Text/CapitalizationContextDependentFormatStyle.swift rename to Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift index 8a3dae0df..a3231b929 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/CapitalizationContextDependentFormatStyle.swift +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift @@ -6,7 +6,6 @@ // Status: Complete (Blocked by SystemFormatStyle) // ID: B2C9C13C743DF2F6E22ED614C39E3A5D (SwiftUICore) -#if canImport(Darwin) public import Foundation protocol CapitalizationContextDependentFormatStyle: FormatStyle { @@ -44,4 +43,3 @@ extension EnvironmentValues { // Date.AnchoredRelativeFormatStyle // Date.FormatStyle // ... -#endif diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift new file mode 100644 index 000000000..f5ef43a7f --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift @@ -0,0 +1,14 @@ +// +// protocol ContentTransitionProvidingFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP (Blocked by SystemFormatStyle) + +package import Foundation + +package protocol ContentTransitionProvidingFormatStyle: FormatStyle { + func contentTransition( + for source: Source + ) -> ContentTransition where Source: TimeDataSourceStorage, Source.Value == FormatInput +} diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/InterfaceIdiomDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/InterfaceIdiomDependentFormatStyle.swift new file mode 100644 index 000000000..5cf622250 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/InterfaceIdiomDependentFormatStyle.swift @@ -0,0 +1,16 @@ +// +// InterfaceIdiomDependentFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP (Blocked by SystemFormatStyle) + +import Foundation + +protocol InterfaceIdiomDependentFormatStyle: FormatStyle { + func interfaceIdiom(_ idiom: AnyInterfaceIdiom) -> Self +} + +// TODO: Add conformance when these concrete format styles land: +// SystemFormatStyle.Timer +// SystemFormatStyle.Stopwatch diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/SafelySerializableDiscreteFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/SafelySerializableDiscreteFormatStyle.swift new file mode 100644 index 000000000..c0922a7d3 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/SafelySerializableDiscreteFormatStyle.swift @@ -0,0 +1,19 @@ +// +// SafelySerializableDiscreteFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP +// ID: 6E7304511B702F2103288779936F04AA (SwiftUICore) + +import Foundation + +protocol SafelySerializableDiscreteFormatStyle: DiscreteFormatStyle where FormatOutput: AttributedStringConvertible { + static func representation( + of resolvable: TimeDataFormatting.Resolvable, + for version: ArchivedViewInput.DeploymentVersion + ) -> any ResolvableStringAttributeRepresentation + where Source: TimeDataSourceStorage, Source.Value == FormatInput +} + +// TODO: Conformance diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift new file mode 100644 index 000000000..5cc2cc2a9 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift @@ -0,0 +1,715 @@ +// +// Text+DiscreteFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: C8A98712CE9284278805F6E671356D1B (SwiftUICore) + +public import Foundation + +// MARK: - TimeDataSource + +/// A source of time related data. +/// +/// Instances of this type provide ``Text`` with live and automatically updating +/// values in Widgets, Live Activities, watchOS Complications, and of course +/// regular apps. +@available(OpenSwiftUI_v6_0, *) +public struct TimeDataSource { + fileprivate let box: BoxBase + + fileprivate init(box: BoxBase) { + self.box = box + } + + fileprivate class BoxBase: @unchecked Sendable { + fileprivate func textStorage( + for format: F + ) -> AnyTextStorage where F: DiscreteFormatStyle, F.FormatInput == Value, F.FormatOutput: AttributedStringConvertible { + _openSwiftUIBaseClassAbstractMethod() + } + } +} + +// MARK: - TimeDataSourceStorage + +package protocol TimeDataSourceStorage: Decodable, Encodable, Hashable, Sendable { + associatedtype Value + + func value(for date: Date) -> Value + func date(for value: Value) -> Date + func round(_ value: Value, _ rule: FloatingPointRoundingRule, toMultipleOf multiple: Double) -> Value + func convergesToZero(_ value: Value) -> Bool + var end: Value? { get } +} + +extension TimeDataSourceStorage { + package func withValue(for date: Date, call closure: (Value) -> Value?) -> Date? { + let value = value(for: date) + guard let nextValue = closure(value) else { + return nil + } + return self.date(for: nextValue) + } + + package var end: Value? { + nil + } +} + +// MARK: - TimeDataSource + Date + +extension TimeDataSource where Value == Date { + package enum DateStorage: TimeDataSourceStorage { + package typealias Value = Date + + case identity + case identityWithPause(pauseDate: Date) + + private enum CodingKeys: CodingKey { + case identity + case identityWithPause + } + + private enum IdentityCodingKeys: CodingKey { + } + + private enum IdentityWithPauseCodingKeys: CodingKey { + case pauseDate + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard container.allKeys.count == 1 else { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + switch container.allKeys[0] { + case .identity: + _ = try container.nestedContainer(keyedBy: IdentityCodingKeys.self, forKey: .identity) + self = .identity + case .identityWithPause: + let nestedContainer = try container.nestedContainer( + keyedBy: IdentityWithPauseCodingKeys.self, + forKey: .identityWithPause + ) + let pauseDate = try nestedContainer.decode(Date.self, forKey: .pauseDate) + self = .identityWithPause(pauseDate: pauseDate) + } + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .identity: + _ = container.nestedContainer(keyedBy: IdentityCodingKeys.self, forKey: .identity) + case let .identityWithPause(pauseDate): + var nestedContainer = container.nestedContainer( + keyedBy: IdentityWithPauseCodingKeys.self, + forKey: .identityWithPause + ) + try nestedContainer.encode(pauseDate, forKey: .pauseDate) + } + } + + package func value(for date: Date) -> Date { + self.date(for: date) + } + + package func date(for value: Date) -> Date { + switch self { + case .identity: + value + case let .identityWithPause(pauseDate): + min(value, pauseDate) + } + } + + package func round(_ value: Date, _ rule: FloatingPointRoundingRule, toMultipleOf multiple: Double) -> Date { + Date( + timeIntervalSinceReferenceDate: value.timeIntervalSinceReferenceDate.rounded( + rule, + toMultipleOf: multiple + ) + ) + } + + package func convergesToZero(_ value: Date) -> Bool { + false + } + + package var end: Date? { + guard case let .identityWithPause(pauseDate) = self else { + return nil + } + return pauseDate + } + } + + private final class DateBox: BoxBase, @unchecked Sendable { + private let storage: DateStorage + + init(storage: DateStorage) { + self.storage = storage + } + + override fileprivate func textStorage( + for format: F + ) -> AnyTextStorage where F: DiscreteFormatStyle, F.FormatInput == Date, F.FormatOutput: AttributedStringConvertible { + TimeDataFormattingStorage(source: storage, format: format, reducedLuminanceBudget: nil) + } + } +} + +// MARK: - TimeDataSource + Duration + +extension TimeDataSource where Value == Duration { + package enum DurationStorage: TimeDataSourceStorage { + package typealias Value = Duration + + case durationOffset(date: Date) + + private enum CodingKeys: CodingKey { + case durationOffset + } + + private enum DurationOffsetCodingKeys: CodingKey { + case date + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard container.allKeys.count == 1 else { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + let nestedContainer = try container.nestedContainer( + keyedBy: DurationOffsetCodingKeys.self, + forKey: .durationOffset + ) + let date = try nestedContainer.decode(Date.self, forKey: .date) + self = .durationOffset(date: date) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + var nestedContainer = container.nestedContainer( + keyedBy: DurationOffsetCodingKeys.self, + forKey: .durationOffset + ) + switch self { + case let .durationOffset(date): + try nestedContainer.encode(date, forKey: .date) + } + } + + package func value(for date: Date) -> Duration { + switch self { + case let .durationOffset(referenceDate): + Duration.seconds(date.timeIntervalSince(referenceDate)) + } + } + + package func date(for value: Duration) -> Date { + switch self { + case let .durationOffset(referenceDate): + referenceDate.addingTimeInterval(Double(value)) + } + } + + package func round(_ value: Duration, _ rule: FloatingPointRoundingRule, toMultipleOf multiple: Double) -> Duration { + Duration.seconds(Double(value).rounded(rule, toMultipleOf: multiple)) + } + + package func convergesToZero(_ value: Duration) -> Bool { + value < .zero + } + } + + private final class DurationBox: BoxBase, @unchecked Sendable { + private let storage: DurationStorage + + init(storage: DurationStorage) { + self.storage = storage + } + + override fileprivate func textStorage( + for format: F + ) -> AnyTextStorage where F: DiscreteFormatStyle, F.FormatInput == Duration, F.FormatOutput: AttributedStringConvertible { + TimeDataFormattingStorage(source: storage, format: format, reducedLuminanceBudget: nil) + } + } +} + +// MARK: - TimeDataSource + Range + +extension TimeDataSource where Value == Range { + package enum DateRangeStorage: TimeDataSourceStorage { + package typealias Value = Range + + case dateRangeStartingAt(date: Date) + case dateRangeEndingAt(date: Date) + + private enum CodingKeys: CodingKey { + case dateRangeStartingAt + case dateRangeEndingAt + } + + private enum DateRangeStartingAtCodingKeys: CodingKey { + case date + } + + private enum DateRangeEndingAtCodingKeys: CodingKey { + case date + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard container.allKeys.count == 1 else { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + switch container.allKeys[0] { + case .dateRangeStartingAt: + let nestedContainer = try container.nestedContainer( + keyedBy: DateRangeStartingAtCodingKeys.self, + forKey: .dateRangeStartingAt + ) + let date = try nestedContainer.decode(Date.self, forKey: .date) + self = .dateRangeStartingAt(date: date) + case .dateRangeEndingAt: + let nestedContainer = try container.nestedContainer( + keyedBy: DateRangeEndingAtCodingKeys.self, + forKey: .dateRangeEndingAt + ) + let date = try nestedContainer.decode(Date.self, forKey: .date) + self = .dateRangeEndingAt(date: date) + } + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .dateRangeStartingAt(date): + var nestedContainer = container.nestedContainer( + keyedBy: DateRangeStartingAtCodingKeys.self, + forKey: .dateRangeStartingAt + ) + try nestedContainer.encode(date, forKey: .date) + case let .dateRangeEndingAt(date): + var nestedContainer = container.nestedContainer( + keyedBy: DateRangeEndingAtCodingKeys.self, + forKey: .dateRangeEndingAt + ) + try nestedContainer.encode(date, forKey: .date) + } + } + + package func value(for date: Date) -> Range { + switch self { + case let .dateRangeStartingAt(startDate): + startDate..) -> Date { + switch self { + case let .dateRangeStartingAt(startDate): + guard value.lowerBound == startDate else { + logFault() + return .distantFuture + } + return value.upperBound + case let .dateRangeEndingAt(endDate): + guard value.upperBound == endDate else { + logFault() + return .distantFuture + } + return value.lowerBound + } + } + + private func logFault() { + let bound: String + switch self { + case .dateRangeStartingAt: + bound = "lowerBound" + case .dateRangeEndingAt: + bound = "upperBound" + } + Log.externalWarning("Misconfigured Text(_:format:). The TimeDataSource is expecting the \(bound) to remain fixed, but the DiscreteFormatStyle was trying to move it. The Text will not update.") + } + + package func round(_ value: Range, _ rule: FloatingPointRoundingRule, toMultipleOf multiple: Double) -> Range { + switch self { + case let .dateRangeStartingAt(startDate): + let delta = value.upperBound.timeIntervalSince(startDate).rounded(rule, toMultipleOf: multiple) + let roundedDate = startDate.addingTimeInterval(delta) + return startDate..) -> Bool { + switch self { + case .dateRangeStartingAt: + false + case .dateRangeEndingAt: + true + } + } + } + + private final class DateRangeBox: BoxBase, @unchecked Sendable { + private let storage: DateRangeStorage + + init(storage: DateRangeStorage) { + self.storage = storage + } + + override fileprivate func textStorage( + for format: F + ) -> AnyTextStorage where F: DiscreteFormatStyle, F.FormatInput == Range, F.FormatOutput: AttributedStringConvertible { + TimeDataFormattingStorage(source: storage, format: format, reducedLuminanceBudget: nil) + } + } +} + +@available(OpenSwiftUI_v6_0, *) +extension TimeDataSource: Sendable where Value: Sendable {} + +@available(OpenSwiftUI_v6_0, *) +extension TimeDataSource { + + /// A time data source that produces `Date.now`. + public static var currentDate: TimeDataSource { + TimeDataSource(box: TimeDataSource.DateBox(storage: .identity)) + } + + /// A time data source that produces the offset between `Date.now` and the given + /// `date` as a `Duration`. + public static func durationOffset(to date: Date) -> TimeDataSource { + TimeDataSource(box: TimeDataSource.DurationBox(storage: .durationOffset(date: date))) + } + + /// A time data source that produces `date.. TimeDataSource> { + TimeDataSource>(box: TimeDataSource>.DateRangeBox(storage: .dateRangeStartingAt(date: date))) + } + + /// A time data source that produces `min(date, Date.now).. TimeDataSource> { + TimeDataSource>(box: TimeDataSource>.DateRangeBox(storage: .dateRangeEndingAt(date: date))) + } +} + +// MARK: - SystemFormatStyle + +/// A namespace for format styles that implement designs used across Apple's +/// platformes. +@available(OpenSwiftUI_v6_0, *) +public enum SystemFormatStyle: Sendable {} + +// MARK: - Text + DiscreteFormatStyle + +extension Text { + package init( + source: Source, + format: Format, + reducedLuminanceBudget: Double? + ) where Source: TimeDataSourceStorage, + Format: DiscreteFormatStyle, + Source.Value == Format.FormatInput, + Format.FormatOutput: AttributedStringConvertible { + self.init(anyTextStorage: TimeDataFormattingStorage( + source: source, + format: format, + reducedLuminanceBudget: reducedLuminanceBudget + )) + } + + /// Creates a text view that displays the current system time as defined by the + /// given format style, keeping the text up to date as time progresses. + /// + /// Use this initializer to create a text view that updates as time progresses, just + /// like ``init(_:style:)``, but with the flexibility of Foundation's `FormatStyle` + /// protocol. + /// + /// In the following example, the first ``Text`` view presents the offset to + /// `startDate`, whereas the second view displays a stopwatch counting from + /// `startDate`. Both views are kept up to date as time progresses. + /// + /// Text(.currentDate, format: .offset(to: startDate)) + /// Text(.currentDate, format: .stopwatch(startingAt: startDate)) + /// + /// ## Redaction for Reduced Luminance + /// + /// When the text is displayed with reduced luminance and frame rate, it + /// automatically modifies the `format` or its output so it never shows outdated + /// information. + /// + /// If the `format` is known to OpenSwiftUI and allows removing units or fields, + /// OpenSwiftUI removes parts that change more frequently than the frame rate + /// allows. E.g. a string like _13 minutes, 22 seconds_ would change to just + /// `13 minutes`. + /// + /// Otherwise, OpenSwiftUI inspects the `durationField`, `dateField`, and `measurement` + /// attributes on the formatted output to determine which ranges need to be + /// redacted. If these attributes are not present, all digits are redacted using + /// dashes. + @available(OpenSwiftUI_v6_0, *) + public init( + _ source: TimeDataSource, + format: F + ) where V == F.FormatInput, F: DiscreteFormatStyle, F.FormatOutput == AttributedString { + self.init(anyTextStorage: source.box.textStorage(for: format)) + } + + /// Creates a text view that displays the current system time as defined by the + /// given format style, keeping the text up to date as time progresses. + /// + /// Use this initializer to create a text view that updates as time progresses, just + /// like ``init(_:style:)``, but with the flexibility of Foundation's `FormatStyle` + /// protocol. + /// + /// In the following example, the first ``Text`` view presents the current date and + /// time, whereas the second view displays a soccer clock counting from `startDate`. + /// Both views are kept up to date as time progresses. + /// + /// Text(.currentDate, format: .dateTime) + /// Text(.durationOffset(to: startDate), format: .time(pattern: .minuteSecond)) + /// + /// ## Redaction for Reduced Luminance + /// + /// When the text is displayed with reduced luminance and frame rate, it + /// automatically modifies the `format` or its output so it never shows outdated + /// information. + /// + /// If the `format` is known to OpenSwiftUI and allows removing units or fields, + /// OpenSwiftUI removes parts that change more frequently than the frame rate + /// allows. E.g. a string like _13 minutes, 22 seconds_ would change to just + /// `13 minutes`. + /// + /// Otherwise, all digits in the formatted output are redacted using dashes. + @_disfavoredOverload + @available(OpenSwiftUI_v6_0, *) + public init( + _ source: TimeDataSource, + format: F + ) where V == F.FormatInput, F: DiscreteFormatStyle, F.FormatOutput == String { + self.init(anyTextStorage: source.box.textStorage(for: format)) + } +} + +extension LocalizedStringKey.StringInterpolation { + /// Appends a text view that displays the current system time as defined by the + /// given format style, keeping the text up to date as time progresses. + /// + /// Use this initializer to create a text view that updates as time progresses, just + /// like ``init(_:style:)``, but with the flexibility of Foundation's `FormatStyle` + /// protocol. + /// + /// In the following example, the first ``Text`` view presents the offset to + /// `startDate`, whereas the second view displays a stopwatch counting from + /// `startDate`. Both views are kept up to date as time progresses. + /// + /// Text(.currentDate, format: .offset(to: startDate)) + /// Text(.currentDate, format: .stopwatch(startingAt: startDate)) + /// + /// - Note: Don't call this method directly; it's used by the compiler when + /// interpreting string interpolations. + /// + /// ## Redaction for Reduced Luminance + /// + /// When the text is displayed with reduced luminance and frame rate, it + /// automatically modifies the `format` or its output so it never shows outdated + /// information. + /// + /// If the `format` is known to OpenSwiftUI and allows removing units or fields, + /// OpenSwiftUI removes parts that change more frequently than the frame rate + /// allows. E.g. a string like _13 minutes, 22 seconds_ would change to just + /// `13 minutes`. + /// + /// Otherwise, OpenSwiftUI inspects the `durationField`, `dateField`, and `measurement` + /// attributes on the formatted output to determine which ranges need to be + /// redacted. If these attributes are not present, all digits are redacted using + /// dashes. + @_semantics("openswiftui.localized.appendInterpolation_@_specifier") + @_semantics("swiftui.localized.appendInterpolation_@_specifier") + @available(OpenSwiftUI_v6_0, *) + public mutating func appendInterpolation( + _ source: TimeDataSource, + format: F + ) where V == F.FormatInput, F: DiscreteFormatStyle, F.FormatOutput == AttributedString { + appendInterpolation(Text(source, format: format)) + } + + /// Appends a text view that displays the current system time as defined by the + /// given format style, keeping the text up to date as time progresses. + /// + /// Use this initializer to create a text view that updates as time progresses, just + /// like ``init(_:style:)``, but with the flexibility of Foundation's `FormatStyle` + /// protocol. + /// + /// In the following example, the first ``Text`` view presents the current date and + /// time, whereas the second view displays a soccer clock counting from `startDate`. + /// Both views are kept up to date as time progresses. + /// + /// Text(.currentDate, format: .dateTime) + /// Text(.durationOffset(to: startDate), format: .time(pattern: .minuteSecond)) + /// + /// - Note: Don't call this method directly; it's used by the compiler when + /// interpreting string interpolations. + /// + /// ## Redaction for Reduced Luminance + /// + /// When the text is displayed with reduced luminance and frame rate, it + /// automatically modifies the `format` or its output so it never shows outdated + /// information. + /// + /// If the `format` is known to OpenSwiftUI and allows removing units or fields, + /// OpenSwiftUI removes parts that change more frequently than the frame rate + /// allows. E.g. a string like _13 minutes, 22 seconds_ would change to just + /// `13 minutes`. + /// + /// Otherwise, all digits in the formatted output are redacted using dashes. + @_disfavoredOverload + @_semantics("openswiftui.localized.appendInterpolation_@_specifier") + @_semantics("swiftui.localized.appendInterpolation_@_specifier") + @available(OpenSwiftUI_v6_0, *) + public mutating func appendInterpolation( + _ source: TimeDataSource, + format: F + ) where V == F.FormatInput, F: DiscreteFormatStyle, F.FormatOutput == String { + appendInterpolation(Text(source, format: format)) + } +} + +// MARK: - TimeDataFormattingStorage + +@available(OpenSwiftUI_v6_0, *) +private final class TimeDataFormattingStorage: AnyTextStorage, @unchecked Sendable where Source: TimeDataSourceStorage, Format: DiscreteFormatStyle, Source.Value == Format.FormatInput, Format.FormatOutput: AttributedStringConvertible { + var source: Source + var format: Format + var reducedLuminanceBudget: Double? + + init(source: Source, format: Format, reducedLuminanceBudget: Double?) { + self.source = source + self.format = format + self.reducedLuminanceBudget = reducedLuminanceBudget + } + + override func resolve( + into result: inout T, + in environment: EnvironmentValues, + with options: Text.ResolveOptions + ) where T: ResolvedTextContainer { + var resolvedFormat = format + .locale(environment.locale) + .calendar(environment.calendar) + .timeZone(environment.timeZone) + + let idiom: AnyInterfaceIdiom + if let resultIdiom = result.idiom { + idiom = resultIdiom + } else { + Log.internalWarning("TimeDataFormattingStorage was resolved without idiom!") + idiom = _GraphInputs.defaultInterfaceIdiom + } + if reducedLuminanceBudget == nil, + let dependentFormat = resolvedFormat as? any InterfaceIdiomDependentFormatStyle { + resolvedFormat = dependentFormat.interfaceIdiom(idiom) as! Format + } + if let dependentFormat = resolvedFormat as? any TextAlignmentDependentFormatStyle { + resolvedFormat = dependentFormat.textAlignment(environment.multilineTextAlignment) as! Format + } + if let dependentFormat = resolvedFormat as? any CapitalizationContextDependentFormatStyle { + resolvedFormat = dependentFormat.capitalizationContext(environment.capitalizationContext.resolved) as! Format + } + let contentTransition: ContentTransition + if let style = resolvedFormat as? any ContentTransitionProvidingFormatStyle { + contentTransition = style.contentTransition(for: source) + } else { + contentTransition = .numericText(countsDown: isDeployedOnOrAfter(.v6)) + } + let secondsUpdateFrequencyBudget: Double + if let reducedLuminanceBudget { + secondsUpdateFrequencyBudget = reducedLuminanceBudget + } else { + let frequency: TimeDataFormatting.UpdateFrequency + switch idiom { + case .complication: frequency = .high + case .widget: frequency = .minute + case .watch: frequency = .minute + default: frequency = .minute + } + secondsUpdateFrequencyBudget = frequency.interval + } + let resolved = TimeDataFormatting.Resolvable( + source: source, + format: resolvedFormat, + secondsUpdateFrequencyBudget: secondsUpdateFrequencyBudget + ) + result.append( + resolvable: resolved, + in: environment, + with: options, + transition: contentTransition + ) + } + + override func resolvesToEmpty( + in environment: EnvironmentValues, + with options: Text.ResolveOptions + ) -> Bool { + false + } + + override func isEqual(to other: AnyTextStorage) -> Bool { + guard let other = other as? TimeDataFormattingStorage else { + return false + } + return source == other.source && format == other.format + } + + override func isStyled(options: Text.ResolveOptions) -> Bool { + Format.FormatOutput.self == AttributedString.self + } +} + +// MARK: - AttributedStringConvertible + +package protocol AttributedStringConvertible { + var attributedString: AttributedString { get } +} + +extension AttributedString: AttributedStringConvertible { + package var attributedString: AttributedString { + self + } +} + +extension String: AttributedStringConvertible { + package var attributedString: AttributedString { + AttributedString(self, attributes: AttributeContainer()) + } +} + +extension Bundle { + package static let systemFormatStyle: Bundle = .openSwiftUICore +} diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/TextAlignmentDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TextAlignmentDependentFormatStyle.swift new file mode 100644 index 000000000..1a7c7088f --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TextAlignmentDependentFormatStyle.swift @@ -0,0 +1,16 @@ +// +// TextAlignmentDependentFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete (Blocked by SystemFormatStyle) + +import Foundation + +protocol TextAlignmentDependentFormatStyle: FormatStyle { + func textAlignment(_ alignment: TextAlignment) -> Self +} + +// TODO: Add conformance when these concrete format styles land: +// SystemFormatStyle.Timer +// SystemFormatStyle.Stopwatch diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift new file mode 100644 index 000000000..30affdefc --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift @@ -0,0 +1,167 @@ +// +// TimeDataFormatting.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP +// ID: C320C90E4A458BC2E4049E0630068186 (SwiftUICore) + +import Foundation + +// MARK: - TimeDataFormatting + +@available(OpenSwiftUI_v6_0, *) +enum TimeDataFormatting: ResolvableStringAttributeFamily { + static var attribute: NSAttributedString.Key { + NSAttributedString.Key("OpenSwiftUITimeDataFormatting") + } + + static func decode(from decoder: any Decoder) throws -> (any ResolvableStringAttribute)? { + nil + } + + enum UpdateFrequency: Hashable, Comparable, Codable, Sendable { + case high + case second + case minute + + var interval: Double { + switch self { + case .high: 0.0 + case .second: 1.0 + case .minute: 60.0 + } + } + + static func < (lhs: UpdateFrequency, rhs: UpdateFrequency) -> Bool { + lhs.interval < rhs.interval + } + } + + struct Resolvable: ResolvableStringAttribute where Source: TimeDataSourceStorage, Format: DiscreteFormatStyle, Source.Value == Format.FormatInput, Format.FormatOutput: AttributedStringConvertible { + typealias Family = TimeDataFormatting + typealias Schedule = Resolvable + typealias Entries = AnySequence + + let source: Source + let format: Format + let secondsUpdateFrequencyBudget: Double + var configuration: Configuration + var sizeVariant: TextSizeVariant + + init( + source: Source, + format: Format, + secondsUpdateFrequencyBudget: Double, + configuration: Configuration, + sizeVariant: TextSizeVariant + ) { + self.source = source + self.format = format + self.secondsUpdateFrequencyBudget = secondsUpdateFrequencyBudget + self.configuration = configuration + self.sizeVariant = sizeVariant + } + + init( + source: Source, + format: Format, + secondsUpdateFrequencyBudget: Double, + sizeVariant: TextSizeVariant = .regular + ) { + // TODO: makeResolvable + let result = Configuration.makeConfiguration( + from: source, + format: format, + sizeVariant: sizeVariant, + secondsUpdateFrequencyBudget: secondsUpdateFrequencyBudget + ) + self.source = source + self.format = format + self.secondsUpdateFrequencyBudget = secondsUpdateFrequencyBudget + self.configuration = result.configuration + self.sizeVariant = sizeVariant + } + + static func encode( + _ resolvable: Resolvable, + to encoder: any Encoder + ) throws { + _ = resolvable + _ = encoder + } + + func representation( + for version: ArchivedViewInput.DeploymentVersion + ) -> any ResolvableStringAttributeRepresentation { + _ = version + return self + } + + func resolve(in context: ResolvableStringResolutionContext) -> AttributedString? { + let value = source.value(for: context.date) + return format.format(value).attributedString + } + + var schedule: Schedule? { + nil + } + + func entries( + from startDate: Date, + mode: TimelineScheduleMode + ) -> AnySequence { + _ = startDate + _ = mode + return AnySequence([]) + } + } + + // FIXME + struct Configuration where Source: TimeDataSourceStorage, Format: DiscreteFormatStyle, Source.Value == Format.FormatInput, Format.FormatOutput: AttributedStringConvertible { + var source: Source + var highFrequencyFormat: Format + var lowFrequencyFormat: Format? + + init( + source: Source, + highFrequencyFormat: Format, + lowFrequencyFormat: Format? + ) { + self.source = source + self.highFrequencyFormat = highFrequencyFormat + self.lowFrequencyFormat = lowFrequencyFormat + } + + static func makeConfiguration( + from source: Source, + format: Format, + sizeVariant: TextSizeVariant, + secondsUpdateFrequencyBudget: Double + ) -> (configuration: Configuration, exact: Bool) { + let lowFrequencyFormat: Format? + if let dependentFormat = format as? any UpdateFrequencyDependentFormatStyle { + let updateFrequency: UpdateFrequency + if secondsUpdateFrequencyBudget <= UpdateFrequency.high.interval { + updateFrequency = .high + } else if secondsUpdateFrequencyBudget <= UpdateFrequency.second.interval { + updateFrequency = .second + } else { + updateFrequency = .minute + } + lowFrequencyFormat = dependentFormat.updateFrequency(updateFrequency) as? Format + } else { + lowFrequencyFormat = nil + } + let exact = sizeVariant == .regular + return ( + Configuration( + source: source, + highFrequencyFormat: format, + lowFrequencyFormat: lowFrequencyFormat + ), + exact + ) + } + } +} diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeZoneDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeZoneDependentFormatStyle.swift new file mode 100644 index 000000000..848663fa6 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeZoneDependentFormatStyle.swift @@ -0,0 +1,74 @@ +// +// TimeZoneDependentFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete (Blocked by WhitespaceRemovingFormatStyle/SystemFormatStyle) +// ID: CB8B6A6747C7DA30909815F805FD2B45 (SwiftUICore) + +public import Foundation + +private protocol TimeZoneDependentFormatStyle: FormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self +} + +extension FormatStyle { + package func timeZone(_ timeZone: TimeZone) -> Self { + guard let style = self as? any TimeZoneDependentFormatStyle else { + return self + } + return style.withTimeZone(timeZone) as! Self + } +} + +#if canImport(Darwin) +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.FormatStyle: TimeZoneDependentFormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self { + var style = self + style.timeZone = timeZone + return style + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.VerbatimFormatStyle: TimeZoneDependentFormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self { + var style = self + style.timeZone = timeZone + return style + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension Date.ISO8601FormatStyle: TimeZoneDependentFormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self { + var style = self + style.timeZone = timeZone + return style + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension Date.FormatStyle.Attributed: TimeZoneDependentFormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self { + var style = self + style[dynamicMember: \.timeZone] = timeZone + return style + } +} + +// extension WhitespaceRemovingFormatStyle: TimeZoneDependentFormatStyle where A: TimeZoneDependentFormatStyle {} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension Date.VerbatimFormatStyle.Attributed: TimeZoneDependentFormatStyle { + func withTimeZone(_ timeZone: TimeZone) -> Self { + var style = self + style[dynamicMember: \.timeZone] = timeZone + return style + } +} + +// extension SystemFormatStyle.DateReference: TimeZoneDependentFormatStyle {} + +#endif diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/UpdateFrequencyDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/UpdateFrequencyDependentFormatStyle.swift new file mode 100644 index 000000000..292cf8757 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/UpdateFrequencyDependentFormatStyle.swift @@ -0,0 +1,168 @@ +// +// UpdateFrequencyDependentFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete (Blocked by WhitespaceRemovingFormatStyle/SystemFormatStyle) + +import Foundation + +protocol UpdateFrequencyDependentFormatStyle: FormatStyle { + func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self +} + +#if canImport(Darwin) +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//extension Date.FormatStyle: UpdateFrequencyDependentFormatStyle { +// func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self { +// switch frequency { +// case .high: +// self +// case .second: +// secondFraction(.omitted) +// case .minute: +// second(.omitted).secondFraction(.omitted) +// } +// } +//} +// +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//extension Date.FormatStyle.Attributed: UpdateFrequencyDependentFormatStyle { +// func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self { +// switch frequency { +// case .high: +// self +// case .second: +// secondFraction(.omitted) +// case .minute: +// second(.omitted).secondFraction(.omitted) +// } +// } +//} +// +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//extension Date.AnchoredRelativeFormatStyle: UpdateFrequencyDependentFormatStyle { +// func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self { +// guard frequency != .high else { +// return self +// } +// +// let minimumField: Date.ComponentsFormatStyle.Field = frequency == .second ? .second : .minute +// var style = self +// style.allowedFields = Set(style.allowedFields.filter { field in +// field.updateFrequencyOrder >= minimumField.updateFrequencyOrder +// }) +// if style.allowedFields.isEmpty { +// style.allowedFields.insert(minimumField) +// } +// return style +// } +//} +// +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//extension Duration.UnitsFormatStyle: UpdateFrequencyDependentFormatStyle { +// func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self { +// guard frequency != .high else { +// return self +// } +// +// let minimumUnit: Duration.UnitsFormatStyle.Unit = frequency == .second ? .seconds : .minutes +// var style = self +// style.allowedUnits = Set(style.allowedUnits.filter { unit in +// unit.updateFrequencyOrder >= minimumUnit.updateFrequencyOrder +// }) +// if style.allowedUnits.isEmpty { +// style.allowedUnits.insert(minimumUnit) +// } +// +// if let smallestUnit = style.allowedUnits.min(by: { lhs, rhs in +// lhs.updateFrequencyOrder < rhs.updateFrequencyOrder +// }) { +// let increment = frequency.interval / smallestUnit.seconds +// if increment.isFinite, increment > 0.0 { +// let existingIncrement = style.fractionalPartDisplay.roundingIncrement +// style.fractionalPartDisplay.roundingIncrement = existingIncrement.map { +// min($0, increment) +// } ?? increment +// } +// } +// return style +// } +//} +// +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//private extension Date.ComponentsFormatStyle.Field { +// var updateFrequencyOrder: Int { +// if self == .second { +// 0 +// } else if self == .minute { +// 1 +// } else if self == .hour { +// 2 +// } else if self == .day { +// 3 +// } else if self == .week { +// 4 +// } else if self == .month { +// 5 +// } else if self == .year { +// 6 +// } else { +// 0 +// } +// } +//} +// +//@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +//private extension Duration.UnitsFormatStyle.Unit { +// var updateFrequencyOrder: Int { +// if self == .nanoseconds { +// 0 +// } else if self == .microseconds { +// 1 +// } else if self == .milliseconds { +// 2 +// } else if self == .seconds { +// 3 +// } else if self == .minutes { +// 4 +// } else if self == .hours { +// 5 +// } else if self == .days { +// 6 +// } else if self == .weeks { +// 7 +// } else { +// 0 +// } +// } +// +// var seconds: Double { +// if self == .nanoseconds { +// 0.000000001 +// } else if self == .microseconds { +// 0.000001 +// } else if self == .milliseconds { +// 0.001 +// } else if self == .seconds { +// 1.0 +// } else if self == .minutes { +// 60.0 +// } else if self == .hours { +// 3600.0 +// } else if self == .days { +// 86400.0 +// } else if self == .weeks { +// 604800.0 +// } else { +// 0.0 +// } +// } +//} + +// TODO: Add conformance when these concrete format styles land: +// WhitespaceRemovingFormatStyle where A: UpdateFrequencyDependentFormatStyle +// SystemFormatStyle.DateReference +// SystemFormatStyle.Timer +// SystemFormatStyle.Stopwatch +#endif diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift deleted file mode 100644 index cca42b60d..000000000 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Text+DiscreteFormatStyle.swift -// OpenSwiftUICore -// -// Audited for 6.5.4 -// Status: Empty -// ID: C8A98712CE9284278805F6E671356D1B (SwiftUICore) - -package import Foundation - -package protocol AttributedStringConvertible { - var attributedString: AttributedString { get } -} - -extension AttributedString: AttributedStringConvertible { - package var attributedString: AttributedString { - self - } -} - -extension String: AttributedStringConvertible { - package var attributedString: AttributedString { - AttributedString(self) - } -} diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text.swift index 439f0c320..a865ceb6c 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text.swift @@ -445,8 +445,6 @@ extension Text { } } -#if canImport(Darwin) - // MARK: - Text.System extension Text { @@ -492,11 +490,16 @@ extension String.System { extension Bundle { package static var kit: Bundle { - Bundle( + #if canImport(Darwin) + return Bundle( for: NSClassFromString( isAppKitBased() ? "NSApplication" : "UIApplication" )! ) + #else + _openSwiftUIPlatformUnimplementedWarning() + return .main + #endif } } @@ -537,5 +540,3 @@ extension Bundle { Bundle(for: OpenSwiftUICoreClass.self) } } - -#endif