2024-05-31 13:50:07 -07:00

776 lines
39 KiB
Swift

//===----------------------------------------------------------------------===//
//
// 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
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
extension Duration {
/// A `FormatStyle` that displays a duration as a list of duration units, such as "2 hours, 43 minutes, 26 seconds" in English.
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
public struct UnitsFormatStyle : FormatStyle, Sendable {
/// Specifies the width of the unit and the spacing of the value and the unit.
public struct UnitWidth : Codable, Hashable, Sendable {
var width: Measurement<UnitDuration>.FormatStyle.UnitWidth
var patternStyle: UATimeUnitStyle
private init(width: Measurement<UnitDuration>.FormatStyle.UnitWidth, patternStyle: UATimeUnitStyle) {
self.width = width
self.patternStyle = patternStyle
}
/// Shows the full unit name, such as "3 hours" for a 3-hour duration in the en_US locale.
public static var wide: UnitWidth { .init(width: .wide, patternStyle: UATIMEUNITSTYLE_FULL) }
/// Shows the abbreviated unit name, such as "3 hr" for a 3-hour duration in the en_US locale.
public static var abbreviated: UnitWidth { .init(width: .abbreviated, patternStyle: UATIMEUNITSTYLE_ABBREVIATED) }
/// Shows the abbreviated unit name with a condensed space between the value and unit, such as "3hr" for a 3-hour duration in the en_US locale.
public static var condensedAbbreviated: UnitWidth { .init(width: .abbreviated, patternStyle: UATIMEUNITSTYLE_SHORTER) }
/// Shows the shortest unit name, such as "3h" for a 3-hour duration in the en_US locale.
public static var narrow: UnitWidth { .init(width: .narrow, patternStyle: UATIMEUNITSTYLE_NARROW) }
private enum CodingKeys: CodingKey {
case width
case patternStyle
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.width = try container.decode(Measurement<UnitDuration>.FormatStyle.UnitWidth.self, forKey: .width)
let rawValue = try container.decode(UATimeUnitStyle.RawValue.self, forKey: .patternStyle)
self.patternStyle = UATimeUnitStyle(rawValue)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.width, forKey: .width)
try container.encode(self.patternStyle.rawValue, forKey: .patternStyle)
}
public func hash(into hasher: inout Hasher) {
hasher.combine(width)
hasher.combine(patternStyle.rawValue)
}
}
/// Units that a duration can be displayed as with `UnitsFormatStyle`.
public struct Unit : Codable, Hashable, Sendable {
// Sorted from largest to smallest
enum _Unit : Int, Codable, Hashable, Comparable, CaseIterable {
static func < (lhs: Duration.UnitsFormatStyle.Unit._Unit, rhs: Duration.UnitsFormatStyle.Unit._Unit) -> Bool {
// It's more intuitive to represent comparison by the unit duration, i.e. .weeks > .hours
// But the raw value is ordered the other way; reverse the comparison
lhs.rawValue > rhs.rawValue
}
case weeks
case days
case hours
case minutes
case seconds
case milliseconds
case microseconds
case nanoseconds
}
var unit: _Unit
var icuSkeleton: String {
var subtype: String
switch unit {
case .weeks:
subtype = "week"
case .days:
subtype = "day"
case .hours:
subtype = "hour"
case .minutes:
subtype = "minute"
case .seconds:
subtype = "second"
case .milliseconds:
subtype = "millisecond"
case .microseconds:
subtype = "microsecond"
case .nanoseconds:
subtype = "nanosecond"
}
return "measure-unit/duration-\(subtype)"
}
/// The unit for weeks. One week is always 604800 seconds.
public static var weeks: Unit { .init(unit: .weeks) }
/// The unit for days. One day is always 86400 seconds.
public static var days: Unit { .init(unit: .days) }
/// The unit for hours. One day is 3600 seconds.
public static var hours: Unit { .init(unit: .hours) }
/// The unit for minutes. One minute is 60 seconds.
public static var minutes: Unit { .init(unit: .minutes) }
/// The unit for seconds.
public static var seconds: Unit { .init(unit: .seconds) }
/// The unit for milliseconds.
public static var milliseconds: Unit { .init(unit: .milliseconds) }
/// The unit for microseconds.
public static var microseconds: Unit { .init(unit: .microseconds) }
/// The unit for nanoseconds.
public static var nanoseconds: Unit { .init(unit: .nanoseconds) }
}
/// Specifies how zero value units are handled.
public struct ZeroValueUnitsDisplayStrategy : Codable, Hashable, Sendable {
var length: Int
/// Excludes zero-value units from the formatted string.
public static var hide: ZeroValueUnitsDisplayStrategy { .init(length: 0) }
/// Displays zero-value units with zero padding to the specified length.
public static func show(length: Int) -> ZeroValueUnitsDisplayStrategy { .init(length: length)}
}
/// Specifies how a duration is displayed if it cannot be represented exactly with the allowed units.
///
/// For example, you can change this option to show a duration of 1 hour and 15 minutes as "1.25 hr", "1 hr", or "1.5 hr" with different lengths and rounding rules when hour is the only allowed unit.
public struct FractionalPartDisplayStrategy : Codable, Hashable, Sendable {
public var minimumLength: Int
public var maximumLength: Int
public var roundingRule: FloatingPointRoundingRule
public var roundingIncrement: Double?
init(minimumLength: Int, maximumLength: Int, roundingRule: FloatingPointRoundingRule, roundingIncrement: Double?) {
self.minimumLength = minimumLength
self.maximumLength = maximumLength
self.roundingRule = roundingRule
self.roundingIncrement = roundingIncrement
}
/// Displays the remaining part as the fractional part of the smallest unit.
/// - Parameters:
/// - lengthLimits: The range of the length of the fractional part.
/// - roundingRule: Rounding rule for the remaining value.
/// - roundingIncrement: Rounding increment for the remaining value.
public init<Range: RangeExpression>(lengthLimits: Range, roundingRule: FloatingPointRoundingRule = .toNearestOrEven, roundingIncrement: Double? = nil) where Range.Bound == Int {
let (lower, upper) = lengthLimits.clampedLowerAndUpperBounds(0..<Int.max)
self.init(minimumLength: lower ?? 0, maximumLength: upper ?? Int.max, roundingRule: roundingRule, roundingIncrement: roundingIncrement)
}
/// Displays the remaining part as the fractional part of the smallest unit.
/// - Parameters:
/// - length: The length of the fractional part.
/// - rule: Rounding rule for the remaining value.
/// - increment: Rounding increment for the remaining value.
public static func show(length: Int, rounded rule: FloatingPointRoundingRule = .toNearestOrEven, increment: Double? = nil) -> FractionalPartDisplayStrategy {
.init(minimumLength: length, maximumLength: length, roundingRule: rule, roundingIncrement: increment)
}
/// Excludes the remaining part.
public static var hide: FractionalPartDisplayStrategy {
.init(minimumLength: 0, maximumLength: 0, roundingRule: .toNearestOrEven, roundingIncrement: nil)
}
/// Excludes the remaining part with the specified rounding rule.
/// - Parameter rounded: Rounding rule for the remaining value.
public static func hide(rounded: FloatingPointRoundingRule = .toNearestOrEven) -> FractionalPartDisplayStrategy {
.init(minimumLength: 0, maximumLength: 0, roundingRule: rounded, roundingIncrement: nil)
}
}
/// The locale to use when formatting the duration.
public var locale: Locale
/// The units that may be included in the output string.
public var allowedUnits: Set<Unit>
/// The width of the unit and the spacing between the value and the unit.
public var unitWidth: UnitWidth
/// The maximum number of time units to include in the output string.
public var maximumUnitCount: Int?
/// The strategy for how zero-value units are handled.
public var zeroValueUnitsDisplay: ZeroValueUnitsDisplayStrategy
/// The strategy for displaying a duration if it cannot be represented exactly with the allowed units.
public var fractionalPartDisplay: FractionalPartDisplayStrategy
/// The padding or truncating behavior of the unit value.
///
/// For example, set this to `2...` to force 2-digit padding on all units.
public var valueLengthLimits: Range<Int>?
/// Creates an instance using the provided specifications.
/// - Parameters:
/// - allowedUnits: The units that may be included in the output string.
/// - width: The width of the unit and the spacing between the value and the unit.
/// - maximumUnitCount: The maximum number of time units to include in the output string.
/// - zeroValueUnits: The strategy for how zero-value units are handled.
/// - valueLength: The padding or truncating behavior of the unit value. Negative values are ignored.
/// - fractionalPart: The strategy for displaying a duration if it cannot be represented exactly with the allowed units.
public init(allowedUnits: Set<Unit>, width: UnitWidth, maximumUnitCount: Int? = nil, zeroValueUnits: ZeroValueUnitsDisplayStrategy = .hide, valueLength: Int? = nil, fractionalPart: FractionalPartDisplayStrategy = .hide) {
self.allowedUnits = allowedUnits
self.unitWidth = width
self.maximumUnitCount = maximumUnitCount
self.zeroValueUnitsDisplay = zeroValueUnits
self.fractionalPartDisplay = fractionalPart
if let valueLength, valueLength > 0 {
let upperBound = min(Int.max - 1, valueLength)
self.valueLengthLimits = upperBound ..< upperBound + 1
} else {
self.valueLengthLimits = nil
}
self.locale = .autoupdatingCurrent
}
/// Creates an instance using the provided specifications.
/// - Parameters:
/// - allowedUnits: The units that may be included in the output string.
/// - width: The width of the unit and the spacing between the value and the unit.
/// - maximumUnitCount: The maximum number of time units to include in the output string.
/// - zeroValueUnits: The strategy for how zero-value units are handled.
/// - valueLengthLimits: The padding or truncating behavior of the unit value. Values with negative bounds are ignored.
/// - fractionalPart: The strategy for displaying a duration if it cannot be represented exactly with the allowed units.
public init<ValueRange: RangeExpression>(allowedUnits: Set<Unit>, width: UnitWidth, maximumUnitCount: Int? = nil, zeroValueUnits: ZeroValueUnitsDisplayStrategy = .hide, valueLengthLimits: ValueRange, fractionalPart: FractionalPartDisplayStrategy = .hide) where ValueRange.Bound == Int {
self.allowedUnits = allowedUnits
self.unitWidth = width
self.maximumUnitCount = maximumUnitCount
self.zeroValueUnitsDisplay = zeroValueUnits
self.fractionalPartDisplay = fractionalPart
let (lower, upper) = valueLengthLimits.clampedLowerAndUpperBounds(0..<Int.max)
if lower == nil && upper == nil {
self.valueLengthLimits = nil
} else {
self.valueLengthLimits = (lower ?? 0) ..< (upper ?? Int.max)
}
self.locale = .autoupdatingCurrent
}
// MARK: - `FormatStyle` conformance
/// Creates a locale-aware string representation from a duration value.
/// - Parameter duration: The value to format.
/// - Returns: A string representation of the duration.
public func format(_ duration: Duration) -> String {
let formattedFields = _formatFields(duration)
var result = _getFullListPattern(length: formattedFields.count)
for formattedField in formattedFields.reversed() {
let range = result._range(of: "{0}", anchored: false, backwards: true)!
result.replaceSubrange(range, with: formattedField)
}
return result
}
// The number format does not contain rounding settings because it's handled on the value itself
func _createNumberFormatStyle(useFractionalLimitsIfAvailable: Bool) -> FloatingPointFormatStyle<Double> {
var collection = NumberFormatStyleConfiguration.Collection()
let fractionalLimits = useFractionalLimitsIfAvailable ? fractionalPartDisplay.minimumLength...fractionalPartDisplay.maximumLength : 0...0
let zeroValueLimits = zeroValueUnitsDisplay.length...
if let valueLengthLimits = valueLengthLimits, zeroValueUnitsDisplay.length > 0 {
let tightestLimits = zeroValueLimits.relative(to: valueLengthLimits)
collection.precision = .integerAndFractionLength(integerLimits: tightestLimits, fractionLimits: fractionalLimits)
} else if let valueLengthLimits = valueLengthLimits {
collection.precision = .integerAndFractionLength(integerLimits: valueLengthLimits, fractionLimits: fractionalLimits)
} else if zeroValueUnitsDisplay.length > 0 {
collection.precision = .integerAndFractionLength(integerLimits: zeroValueLimits, fractionLimits: fractionalLimits)
} else {
collection.precision = .fractionLength(fractionalLimits)
}
var format = FloatingPointFormatStyle<Double>(locale: locale)
format.collection = collection
return format
}
func _formatFields(_ duration: Duration) -> [String] {
let skeletons = _getSkeletons(duration)
return skeletons.map { (skeleton: String, unit: Unit, value: Double) in
let numberFormatter = ICUMeasurementNumberFormatter.create(for: skeleton, locale: locale)!
let formatted = numberFormatter.format(value)
return formatted ?? "\(value) \(unit.icuSkeleton)" // Return a description if ICU can't format it
}
}
func _getSkeletons(_ duration: Duration) -> [(skeleton: String, measurementUnit: Unit, measurementValue: Double)] {
let (units, values) = Self.unitsToUse(duration: duration, allowedUnits: allowedUnits, maximumUnitCount: maximumUnitCount, roundSmallerParts: fractionalPartDisplay.roundingRule, trailingFractionalPartLength: fractionalPartDisplay.maximumLength, roundingIncrement: fractionalPartDisplay.roundingIncrement, dropZeroUnits: zeroValueUnitsDisplay.length <= 0)
let numberFormatStyleWithFraction = _createNumberFormatStyle(useFractionalLimitsIfAvailable: true)
let numberFormatStyleNoFraction = _createNumberFormatStyle(useFractionalLimitsIfAvailable: false)
if units.count == 0, let smallest = allowedUnits.min(by: { $0.unit.rawValue > $1.unit.rawValue }) {
// Fallback to the smallest allowed unit when there is no units to show, such as when the duration is 0 and client wants to hide zero fields
let skeleton = ICUMeasurementNumberFormatter.skeleton(smallest.icuSkeleton, width: .init(unitWidth), usage: nil, numberFormatStyle: numberFormatStyleWithFraction)
return [(skeleton, measurementUnit: smallest, measurementValue: 0)]
}
var result = [(skeleton: String, measurementUnit: Unit, measurementValue: Double)]()
let isNegative = values.contains(where: { $0 < 0 })
lazy var mostSignificantUnit = units.map(\.unit).max()
for (index, (unit, value)) in zip(units, values).enumerated() {
var numberFormatStyle: FloatingPointFormatStyle<Double>
if index == units.count - 1 {
numberFormatStyle = numberFormatStyleWithFraction
} else {
numberFormatStyle = numberFormatStyleNoFraction
}
var value = value
// we only want the sign to show for the first component
// and only if the overall value is negative
if isNegative && unit.unit == mostSignificantUnit {
numberFormatStyle = numberFormatStyle.sign(strategy: .always(includingZero: true))
// 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 value == .zero {
value = -0.1
}
} else {
numberFormatStyle = numberFormatStyle.sign(strategy: .never)
}
let skeleton = ICUMeasurementNumberFormatter.skeleton(unit.icuSkeleton, width: .init(unitWidth), usage: nil, numberFormatStyle: numberFormatStyle)
result.append((skeleton: skeleton, measurementUnit: unit, measurementValue: value))
}
return result
}
func _getListPattern(_ type: UATimeUnitListPattern) -> String {
let listPattern = _withFixedUCharBuffer(size: 128) { buffer, size, status in
uatmufmt_getListPattern(locale.identifier, unitWidth.patternStyle, type, buffer, size, &status)
}
if let listPattern {
return listPattern
} else {
let fallbackPattern = "{0}, {1}"
return fallbackPattern
}
}
// A list pattern has a form such as
//
// unit-short{
// 2{"{0}, {1}"}
// end{"{0}, {1}"}
// middle{"{0}, {1}"}
// start{"{0}, {1}"}
// }
//
// Returns a "combined list pattern" that contains all the start, middle and end parts. The returned pattern uses "{0}" as the placeholder. The result looks something like this: "{0}, {0}, {0}, and {0}"
func _getFullListPattern(length: Int) -> String {
let placeholder = "{0}"
let lastPlaceholder = "{1}"
var pattern: String!
switch length {
case 1:
pattern = placeholder
case 2:
pattern = self._getListPattern(UATIMEUNITLISTPAT_TWO_ONLY)
pattern.replace(lastPlaceholder, with: placeholder)
case let length:
let middle = self._getListPattern(UATIMEUNITLISTPAT_MIDDLE_PIECE)
pattern = self._getListPattern(UATIMEUNITLISTPAT_START_PIECE)
// Each of the three pieces provides _two_ placeholders each,
// such that we start with two and each replacement adds one
// more, so start the loop at 2 as well.
for _ in 2 ..< (length - 1) {
pattern.replace(lastPlaceholder, with: middle)
}
pattern.replace(lastPlaceholder, with: self._getListPattern(UATIMEUNITLISTPAT_END_PIECE))
pattern.replace(lastPlaceholder, with: placeholder)
}
return pattern
}
static func removingZeroUnits(units: [Unit], values: [Double]) -> (units: [Unit], values: [Double]) {
var nonZeroUnits = [Unit]()
var nonZeroValues = [Double]()
for (idx, value) in values.enumerated() {
if value != 0 {
nonZeroUnits.append(units[idx])
nonZeroValues.append(value)
}
}
return (nonZeroUnits, nonZeroValues)
}
// Returns the units that are going to show up in the final string, sorted from largest to smallest
static func unitsToUse(duration: Duration, allowedUnits: Set<Unit>, maximumUnitCount: Int?, roundSmallerParts: FloatingPointRoundingRule, trailingFractionalPartLength: Int, roundingIncrement: Double?, dropZeroUnits: Bool) -> (units: [Unit], values: [Double]) {
var units = allowedUnits.sorted { $0.unit.rawValue < $1.unit.rawValue }
var values = duration.valuesForUnits(units, trailingFractionalLength: trailingFractionalPartLength, smallestUnitRounding: roundSmallerParts, roundingIncrement: roundingIncrement)
// First check if we fit in `maximumUnitCount`
if maximumUnitCount == nil || allowedUnits.count <= maximumUnitCount! {
return dropZeroUnits ? removingZeroUnits(units: units, values: values) : (units, values)
}
let maximumUnitCount = maximumUnitCount!
// If we can drop zero fields, check if the units fit in after dropping them
if dropZeroUnits {
let (nonZeroUnits, nonZeroValues) = removingZeroUnits(units: units, values: values)
if nonZeroUnits.count <= maximumUnitCount {
return (nonZeroUnits, nonZeroValues)
} else {
units = nonZeroUnits
values = nonZeroValues
}
}
// We can't drop zero fields, or dropping them still exceeds `maximumUnitCount`
// Move on to collapse units to fit into `maximumUnitCount`
let idx = values.firstIndex { $0 != 0 }
guard let idx = idx else {
// Cannot generate a list of units using `allowedUnits`.
return (units, values)
}
let r = idx ..< min(units.count, idx + maximumUnitCount)
let usefulUnits = Array(units[r])
let usefulValues = duration.valuesForUnits(usefulUnits, trailingFractionalLength: trailingFractionalPartLength, smallestUnitRounding: roundSmallerParts, roundingIncrement: roundingIncrement)
return (usefulUnits, usefulValues)
}
/// A modifier to set the locale of the format style.
/// - Parameter locale: The locale to apply to the format style.
/// - Returns: A copy of this format with the new locale set.
public func locale(_ locale: Locale) -> Self {
var new = self
new.locale = locale
return new
}
/// Returns a `Duration.UnitsFormatStyle.Attributed` style to format a duration as an attributed string using the configuration of this format style. Units in the string are annotated with the `durationField` and `measurement` attribute keys and the `DurationFieldAttribute` and `MeasurementAttribute` attribute values.
///
/// For example, formatting a duration of 2 hours, 43 minutes, 26.25 second in `en_US` locale yeilds the following conceptually
/// ```
/// 2 { durationField: .hours, component: .value }
/// hours { durationField: .hours, component: .unit }
/// , { nil }
/// 43 { durationField: .minutes, component: .value }
/// minutes { durationField: .minutes, component: .unit }
/// , { nil }
/// 26.25 { durationField: .seconds, component: .value }
/// seconds { durationField: .seconds, component: .unit }
/// ```
public var attributed: Attributed {
Attributed(innerStyle: self)
}
}
// For testing purpose. See notes about String._Encoding
internal typealias _UnitsFormatStyle = UnitsFormatStyle
}
// `FormatStyle` static membership lookup
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
extension FormatStyle where Self == Duration.UnitsFormatStyle {
/// A factory function to create a units format style to format a duration.
/// - Parameters:
/// - units: The units that may be included in the output string.
/// - width: The width of the unit and the spacing between the value and the unit.
/// - maximumUnitCount: The maximum number of time units to include in the output string.
/// - zeroValueUnits: The strategy for how zero-value units are handled.
/// - valueLength: The padding or truncating behavior of the unit value.
/// - fractionalPart: The strategy for displaying a duration if it cannot be represented exactly with the allowed units.
/// - Returns: A format style to format a duration.
public static func units(allowed units: Set<Duration.UnitsFormatStyle.Unit> = [.hours, .minutes, .seconds], width: Duration.UnitsFormatStyle.UnitWidth = .abbreviated, maximumUnitCount : Int? = nil, zeroValueUnits: Duration.UnitsFormatStyle.ZeroValueUnitsDisplayStrategy = .hide, valueLength: Int? = nil, fractionalPart: Duration.UnitsFormatStyle.FractionalPartDisplayStrategy = .hide) -> Self {
.init(allowedUnits: units, width: width, maximumUnitCount: maximumUnitCount, zeroValueUnits: zeroValueUnits, valueLength: valueLength, fractionalPart: fractionalPart)
}
/// A factory function to create a units format style to format a duration.
/// - Parameters:
/// - allowedUnits: The units that may be included in the output string.
/// - width: The width of the unit and the spacing between the value and the unit.
/// - maximumUnitCount: The maximum number of time units to include in the output string.
/// - zeroValueUnits: The strategy for how zero-value units are handled.
/// - valueLengthLimits: The padding or truncating behavior of the unit value.
/// - fractionalPart: The strategy for displaying a duration if it cannot be represented exactly with the allowed units.
/// - Returns: A format style to format a duration.
public static func units<ValueRange: RangeExpression>(allowed units: Set<Duration.UnitsFormatStyle.Unit> = [.hours, .minutes, .seconds], width: Duration.UnitsFormatStyle.UnitWidth = .abbreviated, maximumUnitCount : Int? = nil, zeroValueUnits: Duration.UnitsFormatStyle.ZeroValueUnitsDisplayStrategy = .hide, valueLengthLimits: ValueRange, fractionalPart: Duration.UnitsFormatStyle.FractionalPartDisplayStrategy = .hide) -> Self where ValueRange.Bound == Int {
.init(allowedUnits: units, width: width, maximumUnitCount: maximumUnitCount, zeroValueUnits: zeroValueUnits, valueLengthLimits: valueLengthLimits, fractionalPart: fractionalPart)
}
}
// MARK: - Attributed style
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
extension Duration.UnitsFormatStyle {
/// A format style to format a duration as an attributed string. Units in the string are annotated with the `durationField` and `measurement` attribute keys and the `DurationFieldAttribute` and `MeasurementAttribute` attribute values.
///
/// You can use `Duration.UnitsFormatStyle` to configure the style, and create an `Attributed` format with its `public var attributed: Attributed`
///
/// For example, formatting a duration of 2 hours, 43 minutes, 26.25 second in `en_US` locale yeilds the following conceptually
/// ```
/// 2 { durationField: .hours, component: .value }
/// hours { durationField: .hours, component: .unit }
/// , { nil }
/// 43 { durationField: .minutes, component: .value }
/// minutes { durationField: .minutes, component: .unit }
/// , { nil }
/// 26.25 { durationField: .seconds, component: .value }
/// seconds { durationField: .seconds, component: .unit }
/// ```
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
@dynamicMemberLookup
public struct Attributed : FormatStyle, Sendable {
var innerStyle: Duration.UnitsFormatStyle
/// Formats a duration as an attributed string with `DurationFieldAttribute`.
public func format(_ duration: Duration) -> AttributedString {
let formattedFields = _formatFields(duration)
var result = AttributedString(innerStyle._getFullListPattern(length: formattedFields.count))
for formattedField in formattedFields.reversed() {
let range = result.range(of: "{0}", options: [.backwards])!
result.replaceSubrange(range, with: formattedField)
}
return result
}
/// A modifier to set the locale of the format style.
/// - Parameter locale: The locale to apply to the format style.
/// - Returns: A copy of this format with the new locale set.
public func locale(_ locale: Locale) -> Self {
Attributed(innerStyle: innerStyle.locale(locale))
}
func _formatFields(_ duration: Duration) -> [AttributedString] {
typealias Component = AttributeScopes.FoundationAttributes.MeasurementAttribute.Component
typealias DurationField = AttributeScopes.FoundationAttributes.DurationFieldAttribute.Field
let skeletons = innerStyle._getSkeletons(duration)
return skeletons.map { (skeleton: String, unit: Unit, value: Double) in
let numberFormatter = ICUMeasurementNumberFormatter.create(for: skeleton, locale: innerStyle.locale)!
var durationField: DurationField!
switch unit.unit {
case .weeks:
durationField = .weeks
case .days:
durationField = .days
case .hours:
durationField = .hours
case .minutes:
durationField = .minutes
case .seconds:
durationField = .seconds
case .milliseconds:
durationField = .milliseconds
case .microseconds:
durationField = .microseconds
case .nanoseconds:
durationField = .nanoseconds
}
guard let (str, attributes) = numberFormatter.attributedFormatPositions(.floatingPoint(value)) else {
return AttributedString(innerStyle.format(duration), attributes: .init().durationField(durationField))
}
var attrStr = AttributedString(str)
attrStr.durationField = durationField
for attr in attributes {
var component: Component?
switch attr.field {
case .measureUnit:
component = .unit
default:
component = .value
}
let strRange = String.Index(utf16Offset: attr.begin, in: str)..<String.Index(utf16Offset: attr.end, in: str)
if let range = Range(strRange, in: attrStr) {
attrStr[range].measurement = component
}
}
return attrStr
}
}
}
}
// MARK: Dynamic Member Lookup
@available(FoundationPreview 0.4, *)
extension Duration.UnitsFormatStyle.Attributed {
public subscript<T>(dynamicMember key: KeyPath<Duration.UnitsFormatStyle, T>) -> T {
innerStyle[keyPath: key]
}
public subscript<T>(dynamicMember key: WritableKeyPath<Duration.UnitsFormatStyle, T>) -> T {
get {
innerStyle[keyPath: key]
}
set {
innerStyle[keyPath: key] = newValue
}
}
}
// MARK: DiscreteFormatStyle Conformance
@available(FoundationPreview 0.4, *)
extension Duration.UnitsFormatStyle.Attributed : DiscreteFormatStyle {
public func discreteInput(before input: Duration) -> Duration? {
self.innerStyle.discreteInput(before: input)
}
public func discreteInput(after input: Duration) -> Duration? {
self.innerStyle.discreteInput(after: input)
}
}
@available(FoundationPreview 0.4, *)
extension Duration.UnitsFormatStyle : DiscreteFormatStyle {
public func discreteInput(before input: Duration) -> Duration? {
let (bound, isIncluded) = self.bound(for: input, countingDown: true)
return isIncluded ? bound.nextDown : bound
}
public func discreteInput(after input: Duration) -> Duration? {
let (bound, isIncluded) = self.bound(for: input, countingDown: false)
return isIncluded ? bound.nextUp : bound
}
private func bound(for input: Duration, countingDown: Bool) -> (bound: Duration, includedInRangeOfInput: Bool) {
// Initially we determine the interval for the smallest unit that is used to
// format `input`. If `forceRoundingToFull` is true, that is because we are
// rounding `toNearestOr` and we are close to the point where `interval`
// changes.
let (interval, forceRoundingToFull) = interval(for: input,
countingDown: countingDown,
allowedUnits: self.allowedUnits)
// Thus, if `forceRoundingToFull` is true, we round `.towardZero`. E.g.
// if we can only show one unit and we're at -70 seconds, we format that
// as "1 minute". By rounding `.towardZero`, we get 60 seconds as the
// `unadjustedBound`, not 30 seconds as we would get for `toNearestOr`
// rounding.
let (unadjustedBound, includedInRangeOfInput) = Duration.bound(for: input,
in: interval,
countingDown: countingDown,
roundingRule: forceRoundingToFull ? .towardZero : self.fractionalPartDisplay.roundingRule)
// If we didn't `forceRoundingToFull`, we're done at this point. However,
// if we did, we determine the bound again, disallowing the unit that
// would just fit the `unadjustedBound` (in the example `.minute`), so
// we get the appropriate bound for the smaller unit, which would be
// 59.5 seconds in the example, rendered as "59 seconds".
if forceRoundingToFull {
let (bound, includedInRangeOfInput) = Duration.bound(for: unadjustedBound,
in: self.interval(for: unadjustedBound,
countingDown: countingDown,
allowedUnits: allowedUnits.filter({ Duration.interval(for: $0) < abs(unadjustedBound) })).duration,
countingDown: countingDown,
roundingRule: self.fractionalPartDisplay.roundingRule)
return (bound, includedInRangeOfInput)
} else {
return (unadjustedBound, includedInRangeOfInput)
}
}
private func interval(for input: Duration, countingDown: Bool, allowedUnits: Set<Unit>) -> (duration: Duration, forceRoundingToFull: Bool) {
let allowedUnits = Unit._Unit.allCases.filter({ allowedUnits.contains(.init(unit: $0)) }).map({ Unit(unit: $0) })
guard let smallestAllowedUnit = allowedUnits.last else {
return (.seconds(Int64.max), false)
}
var remainder = input
var visibleUnitLimit = self.maximumUnitCount ?? allowedUnits.count
var smallestInterval: Duration!
var forceRoundingToFull = false
let roundsToHalf = self.fractionalPartDisplay.roundingRule == .toNearestOrEven || self.fractionalPartDisplay.roundingRule == .toNearestOrAwayFromZero
for unit in allowedUnits {
guard visibleUnitLimit > 0 else {
break
}
let unitInterval = Duration.interval(for: unit)
let roundedRemainder = input.rounded(increment: Duration.interval(for: smallestAllowedUnit,
fractionalDigits: self.fractionalPartDisplay.maximumLength,
roundingIncrement: self.fractionalPartDisplay.roundingIncrement),
rule: self.fractionalPartDisplay.roundingRule)
guard unit == smallestAllowedUnit || unitInterval < abs(roundedRemainder) || unitInterval == abs(roundedRemainder) && (remainder < .zero) == countingDown else {
continue
}
var interval: Duration
if unit == smallestAllowedUnit || visibleUnitLimit == 1 {
interval = Duration.interval(for: unit, fractionalDigits: self.fractionalPartDisplay.maximumLength, roundingIncrement: self.fractionalPartDisplay.roundingIncrement)
} else {
interval = Duration.interval(for: unit)
}
if roundsToHalf && countingDown == (remainder > .zero) && abs(remainder) <= unitInterval + interval / 2 && unit != smallestAllowedUnit && visibleUnitLimit == 1 {
forceRoundingToFull = true
} else {
forceRoundingToFull = false
}
let value = roundedRemainder.rounded(increment: interval, rule: .towardZero)
remainder -= value
if value != .zero || self.zeroValueUnitsDisplay.length > 0 {
visibleUnitLimit -= 1
}
smallestInterval = interval
}
return (smallestInterval, forceRoundingToFull)
}
}