From 86f35a1a6bef5c5da8115abe93cc5934e77ea552 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 31 May 2026 22:50:19 +0800 Subject: [PATCH 1/3] Implement discrete format style time data sources --- .../Transition/ContentTransition.swift | 11 + ...ontentTransitionProvidingFormatStyle.swift | 14 + .../Text/Text/Text+DiscreteFormatStyle.swift | 734 +++++++++++++++++- .../View/Text/Text/TimeDataFormatting.swift | 167 ++++ 4 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift create mode 100644 Sources/OpenSwiftUICore/View/Text/Text/TimeDataFormatting.swift 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/Text/ContentTransitionProvidingFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift new file mode 100644 index 000000000..ecf3fcf30 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift @@ -0,0 +1,14 @@ +// +// protocol ContentTransitionProvidingFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +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/Text/Text+DiscreteFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift index cca42b60d..0d2f12bc4 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift @@ -3,10 +3,734 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Empty +// Status: WIP // ID: C8A98712CE9284278805F6E671356D1B (SwiftUICore) -package import Foundation +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: - Environment-Dependent FormatStyle Hooks + +protocol CalendarDependentFormatStyle: FormatStyle { + func calendar(_ calendar: Calendar) -> Self +} + +protocol TimeZoneDependentFormatStyle: FormatStyle { + func timeZone(_ timeZone: TimeZone) -> Self +} + +protocol InterfaceIdiomDependentFormatStyle: FormatStyle { + func interfaceIdiom(_ idiom: AnyInterfaceIdiom) -> Self +} + +protocol TextAlignmentDependentFormatStyle: FormatStyle { + func textAlignment(_ alignment: TextAlignment) -> Self +} + +protocol UpdateFrequencyDependentFormatStyle: FormatStyle { + func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self +} + +extension FormatStyle { + package func calendar(_ calendar: Calendar) -> Self { + guard let style = self as? any CalendarDependentFormatStyle else { + return self + } + return style.calendar(calendar) as! Self + } + + package func timeZone(_ timeZone: TimeZone) -> Self { + guard let style = self as? any TimeZoneDependentFormatStyle else { + return self + } + return style.timeZone(timeZone) as! Self + } +} + +// 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 } @@ -20,6 +744,10 @@ extension AttributedString: AttributedStringConvertible { extension String: AttributedStringConvertible { package var attributedString: AttributedString { - AttributedString(self) + AttributedString(self, attributes: AttributeContainer()) } } + +extension Bundle { + package static let systemFormatStyle: Bundle = .openSwiftUICore +} diff --git a/Sources/OpenSwiftUICore/View/Text/Text/TimeDataFormatting.swift b/Sources/OpenSwiftUICore/View/Text/Text/TimeDataFormatting.swift new file mode 100644 index 000000000..b3981a2f8 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/Text/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 { + 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 + ) + } + } +} From 762e073069e943594a135a220cc8007442fa284b Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 1 Jun 2026 23:32:36 +0800 Subject: [PATCH 2/3] Update FormatStyle --- .../CalendarDependentFormatStyle.swift | 80 +++++++++ ...alizationContextDependentFormatStyle.swift | 0 ...ontentTransitionProvidingFormatStyle.swift | 2 +- .../InterfaceIdiomDependentFormatStyle.swift | 16 ++ ...afelySerializableDiscreteFormatStyle.swift | 19 ++ .../Text+DiscreteFormatStyle.swift | 40 +---- .../TextAlignmentDependentFormatStyle.swift | 16 ++ .../TimeDataFormatting.swift | 2 +- .../TimeZoneDependentFormatStyle.swift | 74 ++++++++ .../UpdateFrequencyDependentFormatStyle.swift | 168 ++++++++++++++++++ 10 files changed, 376 insertions(+), 41 deletions(-) create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/CalendarDependentFormatStyle.swift rename Sources/OpenSwiftUICore/View/Text/{Text => FormatStyle}/CapitalizationContextDependentFormatStyle.swift (100%) rename Sources/OpenSwiftUICore/View/Text/{Text => FormatStyle}/ContentTransitionProvidingFormatStyle.swift (88%) create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/InterfaceIdiomDependentFormatStyle.swift create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/SafelySerializableDiscreteFormatStyle.swift rename Sources/OpenSwiftUICore/View/Text/{Text => FormatStyle}/Text+DiscreteFormatStyle.swift (96%) create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/TextAlignmentDependentFormatStyle.swift rename Sources/OpenSwiftUICore/View/Text/{Text => FormatStyle}/TimeDataFormatting.swift (98%) create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeZoneDependentFormatStyle.swift create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/UpdateFrequencyDependentFormatStyle.swift 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 100% rename from Sources/OpenSwiftUICore/View/Text/Text/CapitalizationContextDependentFormatStyle.swift rename to Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift diff --git a/Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift similarity index 88% rename from Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift rename to Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift index ecf3fcf30..f5ef43a7f 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/ContentTransitionProvidingFormatStyle.swift +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/ContentTransitionProvidingFormatStyle.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Complete +// Status: WIP (Blocked by SystemFormatStyle) package import Foundation 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/Text/Text+DiscreteFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift similarity index 96% rename from Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift rename to Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift index 0d2f12bc4..5cc2cc2a9 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+DiscreteFormatStyle.swift +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/Text+DiscreteFormatStyle.swift @@ -3,7 +3,7 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: C8A98712CE9284278805F6E671356D1B (SwiftUICore) public import Foundation @@ -425,44 +425,6 @@ extension TimeDataSource { @available(OpenSwiftUI_v6_0, *) public enum SystemFormatStyle: Sendable {} -// MARK: - Environment-Dependent FormatStyle Hooks - -protocol CalendarDependentFormatStyle: FormatStyle { - func calendar(_ calendar: Calendar) -> Self -} - -protocol TimeZoneDependentFormatStyle: FormatStyle { - func timeZone(_ timeZone: TimeZone) -> Self -} - -protocol InterfaceIdiomDependentFormatStyle: FormatStyle { - func interfaceIdiom(_ idiom: AnyInterfaceIdiom) -> Self -} - -protocol TextAlignmentDependentFormatStyle: FormatStyle { - func textAlignment(_ alignment: TextAlignment) -> Self -} - -protocol UpdateFrequencyDependentFormatStyle: FormatStyle { - func updateFrequency(_ frequency: TimeDataFormatting.UpdateFrequency) -> Self -} - -extension FormatStyle { - package func calendar(_ calendar: Calendar) -> Self { - guard let style = self as? any CalendarDependentFormatStyle else { - return self - } - return style.calendar(calendar) as! Self - } - - package func timeZone(_ timeZone: TimeZone) -> Self { - guard let style = self as? any TimeZoneDependentFormatStyle else { - return self - } - return style.timeZone(timeZone) as! Self - } -} - // MARK: - Text + DiscreteFormatStyle extension Text { 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/Text/TimeDataFormatting.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift similarity index 98% rename from Sources/OpenSwiftUICore/View/Text/Text/TimeDataFormatting.swift rename to Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift index b3981a2f8..30affdefc 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/TimeDataFormatting.swift +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/TimeDataFormatting.swift @@ -20,7 +20,7 @@ enum TimeDataFormatting: ResolvableStringAttributeFamily { nil } - enum UpdateFrequency: Hashable, Comparable, Codable { + enum UpdateFrequency: Hashable, Comparable, Codable, Sendable { case high case second case minute 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 From 747aeeaee2ea06762ad8e42d5d28f987d9e5b905 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 2 Jun 2026 00:30:42 +0800 Subject: [PATCH 3/3] Fix Linux format style build --- .../CapitalizationContextDependentFormatStyle.swift | 2 -- Sources/OpenSwiftUICore/View/Text/Text/Text.swift | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/CapitalizationContextDependentFormatStyle.swift index 8a3dae0df..a3231b929 100644 --- a/Sources/OpenSwiftUICore/View/Text/FormatStyle/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/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