mirror of
https://github.com/apple/swift-foundation.git
synced 2025-05-21 21:09:57 +08:00
776 lines
39 KiB
Swift
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)
|
|
}
|
|
}
|