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

519 lines
23 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
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<Double>(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<T>(dynamicMember key: KeyPath<Duration.TimeFormatStyle, T>) -> T {
innerStyle[keyPath: key]
}
public subscript<T>(dynamicMember key: WritableKeyPath<Duration.TimeFormatStyle, T>) -> 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)
}
}
}