//===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // // Copyright (c) 2020 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// #if canImport(FoundationEssentials) import FoundationEssentials #endif internal import _FoundationICU let hourSymbol: Character = "h" let minuteSymbol: Character = "m" let secondSymbol: Character = "s" let quoteSymbol: Character = "'" @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension Duration { /// Format style to format a `Duration` in a localized positional format. /// For example, one hour and ten minutes is displayed as “1:10:00” in /// the U.S. English locale, or “1.10.00” in the Finnish locale. @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) public struct TimeFormatStyle : FormatStyle, Sendable { /// The units to display a Duration with and configurations for the units. public struct Pattern : Hashable, Codable, Sendable { enum Fields: Hashable, Codable { case hourMinute(roundSeconds: FloatingPointRoundingRule) case hourMinuteSecond(fractionalSecondsLength: Int, roundFractionalSeconds: FloatingPointRoundingRule) case minuteSecond(fractionalSecondsLength: Int, roundFractionalSeconds: FloatingPointRoundingRule) } var fields: Fields var paddingForLargestField: Int? var roundingRule: FloatingPointRoundingRule { switch fields { case .hourMinute(roundSeconds: let rule), .hourMinuteSecond(fractionalSecondsLength: _, roundFractionalSeconds: let rule), .minuteSecond(fractionalSecondsLength: _, roundFractionalSeconds: let rule): return rule } } init(fields: Fields, paddingForLargestField: Int? = nil) { self.fields = fields self.paddingForLargestField = paddingForLargestField } /// Displays a duration in hours and minutes. public static var hourMinute: Pattern { .init(fields: .hourMinute(roundSeconds: .toNearestOrEven)) } /// Displays a duration in terms of hours and minutes with the specified configurations. /// - Parameters: /// - padHourToLength: Padding for the hour field. For example, one hour is formatted as "01:00" in en_US locale when this value is set to 2. /// - roundSeconds: Rounding rule for the remaining second values. /// - Returns: A pattern to format a duration with. public static func hourMinute(padHourToLength: Int, roundSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> Pattern { .init(fields: .hourMinute(roundSeconds: roundSeconds), paddingForLargestField: padHourToLength) } /// Displays a duration in hours, minutes, and seconds. public static var hourMinuteSecond: Pattern { .init(fields: .hourMinuteSecond(fractionalSecondsLength: 0, roundFractionalSeconds: .toNearestOrEven)) } /// Displays a duration in terms of hours, minutes, and seconds with the specified configurations. /// - Parameters: /// - padHourToLength: Padding for the hour field. For example, one hour is formatted as "01:00:00" in en_US locale when this value is set to 2. /// - fractionalSecondsLength: The length of the fractional seconds. For example, one hour is formatted as "1:00:00.00" in en_US locale when this value is set to 2. /// - roundFractionalSeconds: Rounding rule for the fractional second values. /// - Returns: A pattern to format a duration with. public static func hourMinuteSecond(padHourToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> Pattern { .init(fields: .hourMinuteSecond(fractionalSecondsLength: fractionalSecondsLength, roundFractionalSeconds: roundFractionalSeconds), paddingForLargestField: padHourToLength) } /// Displays a duration in minutes and seconds. For example, one hour is formatted as "60:00" in en_US locale. public static var minuteSecond: Pattern { .init(fields: .minuteSecond(fractionalSecondsLength: 0, roundFractionalSeconds: .toNearestOrEven)) } /// Displays a duration in minutes and seconds with the specified configurations. /// - Parameters: /// - padMinuteToLength: Padding for the minute field. For example, five minutes is formatted as "05:00" in en_US locale when this value is set to 2. /// - fractionalSecondsLength: The length of the fractional seconds. For example, one hour is formatted as "1:00:00.00" in en_US locale when this value is set to 2. /// - roundFractionalSeconds: Rounding rule for the fractional second values. /// - Returns: A pattern to format a duration with. public static func minuteSecond(padMinuteToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> Pattern { .init(fields: .minuteSecond(fractionalSecondsLength: fractionalSecondsLength, roundFractionalSeconds: roundFractionalSeconds), paddingForLargestField: padMinuteToLength) } } var _attributed: Attributed /// The locale to use when formatting the duration. public var locale: Locale { get { _attributed.locale } set { _attributed.locale = newValue } } /// The pattern to display a Duration with. public var pattern: Pattern { get { _attributed.pattern } set { _attributed.pattern = newValue } } /// The attributed format style corresponding to this style. public var attributed: Attributed { return _attributed } /// Creates an instance using the provided pattern and locale. /// - Parameters: /// - pattern: A `Pattern` to specify the units to include in the displayed string and the behavior of the units. /// - locale: The `Locale` used to create the string representation of the duration. public init(pattern: Pattern, locale: Locale = .autoupdatingCurrent) { self._attributed = Attributed(pattern: pattern, locale: locale) } fileprivate init(_ attributedStyle: Attributed) { self._attributed = attributedStyle } // `FormatStyle` conformance /// Creates a locale-aware string representation from a duration value. /// - Parameter value: The value to format. /// - Returns: A string representation of the duration. public func format(_ value: Duration) -> String { String(_attributed.format(value).characters[...]) } /// Modifies the format style to use the specified locale. /// - Parameter locale: The locale to use when formatting a duration. /// - Returns: A format style with the provided locale. public func locale(_ locale: Locale) -> Self { var new = self new.locale = locale return new } } // For testing purpose. See notes about String._Encoding internal typealias _TimeFormatStyle = TimeFormatStyle } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension FormatStyle where Self == Duration.TimeFormatStyle { /// A factory variable to create a time format style to format a duration. /// - Parameter pattern: A `Pattern` to specify the units to include in the displayed string and the behavior of the units. /// - Returns: A format style to format a duration. public static func time(pattern: Duration.TimeFormatStyle.Pattern) -> Self { .init(pattern: pattern) } } // MARK: - Attributed style @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension Duration.TimeFormatStyle { /// Formats a duration as an attributed string with the `durationField` attribute key and `FoundationAttributes.DurationFieldAttribute` attribute. /// /// For example, two hour, 43 minute and 26.25 seconds can be formatted as an attributed string, "2:43:26.25" with the following run text and attributes: /// ``` /// 2 { durationField: .hours } /// : { nil } /// 43 { durationField: .minutes } /// : { nil } /// 26.25 { durationField: .seconds } /// ``` @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) @dynamicMemberLookup public struct Attributed : FormatStyle, Sendable { typealias Pattern = Duration.TimeFormatStyle.Pattern var pattern: Pattern var grouping: NumberFormatStyleConfiguration.Grouping = .automatic var locale: Locale internal init(pattern: Pattern, locale: Locale) { self.pattern = pattern self.locale = locale } /// Modifies the format style to use the specified locale. /// - Parameter locale: The locale to use when formatting a duration. /// - Returns: A format style with the provided locale. public func locale(_ locale: Locale) -> Self { var new = self new.locale = locale return new } /// Formats a duration as an attributed string with `DurationFieldAttribute`. /// - Parameter value: The value to format. /// - Returns: An attributed string to represent the duration. public func format(_ value: Duration) -> AttributedString { var uPattern: UATimeUnitTimePattern var fallbackPattern: String switch pattern.fields { case .hourMinute: uPattern = .hourMinute fallbackPattern = "h':'mm" break case .hourMinuteSecond: uPattern = .hourMinuteSecond fallbackPattern = "h':'mm':'ss" break case .minuteSecond: uPattern = .minuteSecond fallbackPattern = "m':'ss" break } var patternString: String! let capacity = 128 withUnsafeTemporaryAllocation(of: UChar.self, capacity: capacity) { ptr in var status = U_ZERO_ERROR let len = uatmufmt_getTimePattern(locale.identifier, uPattern, ptr.baseAddress!, Int32(capacity), &status) guard status.isSuccess, let bufferStart = ptr.baseAddress, let str = String(_utf16: bufferStart, count: Int(len)) else { patternString = fallbackPattern return } patternString = str.lowercased() } let units: [Duration.UnitsFormatStyle.Unit] let rounding: FloatingPointRoundingRule let lastUnitFractionalLen: Int switch pattern.fields { case .hourMinute(let roundSeconds): units = [ .hours, .minutes ] rounding = roundSeconds lastUnitFractionalLen = 0 case .hourMinuteSecond(let fractionalSecondsLength, let roundFractionalSeconds): units = [ .hours, .minutes, .seconds ] rounding = roundFractionalSeconds lastUnitFractionalLen = fractionalSecondsLength case .minuteSecond(let fractionalSecondsLength, let roundFractionalSeconds): units = [ .minutes, .seconds ] rounding = roundFractionalSeconds lastUnitFractionalLen = fractionalSecondsLength } let values = value.valuesForUnits(units, trailingFractionalLength: lastUnitFractionalLen, smallestUnitRounding: rounding, roundingIncrement: nil) assert(units.count == values.count) let unitValues = Dictionary(uniqueKeysWithValues: zip(units, values)) let patternComponents = Self.componentsFromPatternString(patternString, patternSet: [ hourSymbol, minuteSymbol, secondSymbol ]) return formatWithPatternComponents(patternComponents, hour: unitValues[.hours] ?? 0, minute: unitValues[.minutes] ?? 0, second: unitValues[.seconds] ?? 0) } internal struct PatternComponent { // Consecutive characters in a pattern that represents a single symbol or a literal string. // For example, in the "h':'mm" pattern, this could be [":"] or ["m", "m"]. let symbols: [Character] // True if this component is one of the pre-defined symbols, or false if it's a literal string surrounded by single quotation marks. let isField: Bool // The start and end index of the component let start: Int let end: Int } // Parses a pattern string and returns a list of components the pattern specifies. // The pattern string should consist symbols (represented by a character) and literals (enclosed with single quotation marks). static func componentsFromPatternString(_ pattern: String, patternSet: [Character]) -> [PatternComponent] { var inQuote: Bool = false // inside a quoted literal var runBegin: Int = 0 var runSymbol: Character? var runIsField: Bool = true var result = [PatternComponent]() var token = [Character]() for (idx, c) in pattern.enumerated() { // Record the previous token up until the current position if we see // 1) a different symbol while we're in the middle of parsing a field. This would happen if the pattern consists of multiple consecutive symbols that are not separated by literals, such as "hmmss". // or // 2) a literal while parsing a field, such as the first quotation mark of "h':'mm" let isField = !inQuote && patternSet.contains(c) if idx > runBegin, (isField && c != runSymbol) || (!isField && runIsField) { result.append(PatternComponent(symbols: token, isField: runIsField, start: runBegin, end: idx)) token = [] runBegin = idx } if c == quoteSymbol { if runSymbol == quoteSymbol { token.append(quoteSymbol) } else { inQuote = !inQuote } } else { token.append(c) } runIsField = isField runSymbol = c } if !token.isEmpty { result.append(PatternComponent(symbols: token, isField: runIsField, start: runBegin, end: pattern.count)) } return result } func formatWithPatternComponents(_ components: [PatternComponent], hour: Double, minute: Double, second: Double) -> AttributedString { // The number format does not contain rounding settings because it's handled on the value itself var numberFormatStyle = FloatingPointFormatStyle(locale: locale).grouping(grouping) var result = AttributedString() let isNegative = hour < 0 || minute < 0 || second < 0 for component in components { var attr: AttributeScopes.FoundationAttributes.DurationFieldAttribute.Field? var substring: String if component.isField { let patternSymbols = component.symbols guard let symbol = patternSymbols.first else { continue } var value: Double? let isMostSignificantField: Bool var minIntLength = patternSymbols.count switch symbol { case hourSymbol: value = hour if let padding = pattern.paddingForLargestField { minIntLength = max(padding, patternSymbols.count) } numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: 0...0)) attr = .hours isMostSignificantField = true case minuteSymbol: value = minute switch pattern.fields { case .hourMinute: numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: 0...0)) isMostSignificantField = false case .hourMinuteSecond: numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: 0...0)) isMostSignificantField = false case .minuteSecond: if let padding = pattern.paddingForLargestField { minIntLength = max(padding, patternSymbols.count) } numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: 0...0)) isMostSignificantField = true } attr = .minutes case secondSymbol: value = second switch pattern.fields { case .hourMinute: break case .hourMinuteSecond(let fractionalSecondsLength, _): numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: fractionalSecondsLength...fractionalSecondsLength)) case .minuteSecond(let fractionalSecondsLength, _): numberFormatStyle = numberFormatStyle.precision(.integerAndFractionLength(integerLimits: minIntLength..., fractionLimits: fractionalSecondsLength...fractionalSecondsLength)) } attr = .seconds isMostSignificantField = false default: isMostSignificantField = false } if var value = value { // we only want the sign to show for the first component // and only if the overall value is negative let showNegativeSign = isNegative && isMostSignificantField // if the first component is zero, we normally wouldn't get // a negative sign, so we make the value a small negative // value that still rounds to zero if showNegativeSign && value == 0 { value = -0.1 } substring = numberFormatStyle .sign(strategy: showNegativeSign ? .always(includingZero: true) : .never) .format(value) } else { substring = String(component.symbols) } } else { substring = String(component.symbols) } let attrSubstring: AttributedString if let attr = attr { attrSubstring = AttributedString(substring, attributes: .init().durationField(attr)) } else { attrSubstring = AttributedString(substring) } result += attrSubstring } return result } } } @available(FoundationPreview 0.4, *) extension Duration.TimeFormatStyle { /// Returns a modified style that applies the given `grouping` rule to the highest field in the /// pattern. public func grouping(_ grouping: NumberFormatStyleConfiguration.Grouping) -> Self { var copy = self copy._attributed.grouping = grouping return copy } /// The `grouping` rule applied to high number values on the largest field in the pattern. public var grouping: NumberFormatStyleConfiguration.Grouping { get { _attributed.grouping } set { _attributed.grouping = newValue } } } @available(FoundationPreview 0.4, *) extension Duration.TimeFormatStyle.Attributed { /// Returns a modified style that applies the given `grouping` rule to the highest field in the /// pattern. public func grouping(_ grouping: NumberFormatStyleConfiguration.Grouping) -> Self { var copy = self copy.grouping = grouping return copy } } // MARK: Dynamic Member Lookup @available(FoundationPreview 0.4, *) extension Duration.TimeFormatStyle.Attributed { private var innerStyle: Duration.TimeFormatStyle { get { .init(self) } set { self = newValue._attributed } } public subscript(dynamicMember key: KeyPath) -> T { innerStyle[keyPath: key] } public subscript(dynamicMember key: WritableKeyPath) -> T { get { innerStyle[keyPath: key] } set { innerStyle[keyPath: key] = newValue } } } // MARK: DiscreteFormatStyle Conformance @available(FoundationPreview 0.4, *) extension Duration.TimeFormatStyle.Attributed : DiscreteFormatStyle { public func discreteInput(before input: Duration) -> Duration? { Duration.TimeFormatStyle(pattern: pattern, locale: locale).discreteInput(before: input) } public func discreteInput(after input: Duration) -> Duration? { Duration.TimeFormatStyle(pattern: pattern, locale: locale).discreteInput(after: input) } } @available(FoundationPreview 0.4, *) extension Duration.TimeFormatStyle : DiscreteFormatStyle { public func discreteInput(before input: Duration) -> Duration? { let (bound, isIncluded) = Duration.bound(for: input, in: interval(for: input), countingDown: true, roundingRule: self.pattern.roundingRule) return isIncluded ? bound.nextDown : bound } public func discreteInput(after input: Duration) -> Duration? { let (bound, isIncluded) = Duration.bound(for: input, in: interval(for: input), countingDown: false, roundingRule: self.pattern.roundingRule) return isIncluded ? bound.nextUp : bound } private func interval(for input: Duration) -> Duration { switch pattern.fields { case .hourMinute: return .seconds(60) case .hourMinuteSecond(fractionalSecondsLength: let length, _): return Duration.interval(for: .seconds, fractionalDigits: length) case .minuteSecond(fractionalSecondsLength: let length, _): return Duration.interval(for: .seconds, fractionalDigits: length) } } }