diff --git a/Package.resolved b/Package.resolved index 8c2de6b74..db99c086c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "13c69621d8a7bc34851161e1b06d0ef0e1304c504885cc1f439a9d38564c5f97", + "originHash" : "9ee38d6f53b74893089a214e320ca61727938f544556a98e447376e3d3f9f15e", "pins" : [ { "identity" : "darwinprivateframeworks", diff --git a/Sources/OpenSwiftUICore/Shim/TextNonDarwinShims.swift b/Sources/OpenSwiftUICore/Shim/TextNonDarwinShims.swift index 2eafb11fc..343683582 100644 --- a/Sources/OpenSwiftUICore/Shim/TextNonDarwinShims.swift +++ b/Sources/OpenSwiftUICore/Shim/TextNonDarwinShims.swift @@ -8,6 +8,14 @@ public import UIFoundation_Private public typealias NSInteger = Int +class NSStringDrawingContext: NSObject { + var wrapsForTruncationMode = false + var wantsBaselineOffset = false + var wantsScaledLineHeight = false + var wantsScaledBaselineOffset = false + var cachesLayout = false +} + package enum NSWritingDirection: Int { case natural = -1 case leftToRight = 0 @@ -83,6 +91,12 @@ package class NSTextLineFragment: NSObject { } extension NSAttributedString { + // Work around https://github.com/swiftlang/swift/issues/71874. + @_nonoverride + public convenience init() { + self.init(string: "") + } + public convenience init(_ attributedString: AttributedString) { self.init(string: String(attributedString)) } diff --git a/Sources/OpenSwiftUICore/Test/AttributeCountInfoKey.swift b/Sources/OpenSwiftUICore/Test/AttributeCountInfoKey.swift new file mode 100644 index 000000000..4ce3cd6a7 --- /dev/null +++ b/Sources/OpenSwiftUICore/Test/AttributeCountInfoKey.swift @@ -0,0 +1,19 @@ +// +// AttributeCountInfoKey.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - AttributeCountInfoKey + +package struct AttributeCountInfoKey: HostPreferenceKey { + package static let defaultValue = AttributeCountTestInfo() + + package static func reduce( + value: inout AttributeCountTestInfo, + nextValue: () -> AttributeCountTestInfo + ) { + value.merge(nextValue()) + } +} diff --git a/Sources/OpenSwiftUICore/Test/AttributeCountTestInfo.swift b/Sources/OpenSwiftUICore/Test/AttributeCountTestInfo.swift index 354704fb9..990bcd7d8 100644 --- a/Sources/OpenSwiftUICore/Test/AttributeCountTestInfo.swift +++ b/Sources/OpenSwiftUICore/Test/AttributeCountTestInfo.swift @@ -2,9 +2,10 @@ // AttributeCountTestInfo.swift // OpenSwiftUICore // +// Audited for 6.5.4 // Status: Complete -// MARK: - AttributeCountTestInfo [6.4.41] +// MARK: - AttributeCountTestInfo package struct AttributeCountTestInfo: Equatable { var attributeCounts: [String: UInt32] = [:] @@ -19,17 +20,3 @@ package struct AttributeCountTestInfo: Equatable { history.merge(other.history) { $0 + $1 } } } - -// MARK: - AttributeCountInfoKey [6.4.41] - -package struct AttributeCountInfoKey: HostPreferenceKey { - package static let defaultValue = AttributeCountTestInfo() - - package static func reduce( - value: inout AttributeCountTestInfo, - nextValue: () -> AttributeCountTestInfo - ) { - value.merge(nextValue()) - } -} - diff --git a/Sources/OpenSwiftUICore/View/Text/AttributedString/Text+NSAttributedString.swift b/Sources/OpenSwiftUICore/View/Text/AttributedString/Text+NSAttributedString.swift index 44a9d8f50..54839eb32 100644 --- a/Sources/OpenSwiftUICore/View/Text/AttributedString/Text+NSAttributedString.swift +++ b/Sources/OpenSwiftUICore/View/Text/AttributedString/Text+NSAttributedString.swift @@ -7,6 +7,9 @@ package import Foundation package import UIFoundation_Private +#if canImport(CoreText) +package import CoreText +#endif package func makeParagraphStyle(environment: EnvironmentValues) -> NSMutableParagraphStyle { let paragraphStyle = NSMutableParagraphStyle() @@ -107,10 +110,81 @@ extension NSAttributedString { package struct EncodedFontMetrics { package var capHeight: CGFloat, ascender: CGFloat, descender: CGFloat, leading: CGFloat package var outsets: EdgeInsets + + init(capHeight: CGFloat, ascender: CGFloat, descender: CGFloat, leading: CGFloat, outsets: EdgeInsets) { + self.capHeight = capHeight + self.ascender = ascender + self.descender = descender + self.leading = leading + self.outsets = outsets + } + + init() { + self.capHeight = .zero + self.ascender = .zero + self.descender = .zero + self.leading = .zero + self.outsets = .zero + } } package var maxFontMetrics: EncodedFontMetrics { - _openSwiftUIUnimplementedFailure() + #if canImport(CoreText) + var capHeight: CGFloat = 0 + var ascender: CGFloat = 0 + var descender: CGFloat = 0 + var leading: CGFloat? + var outsets = EdgeInsets.zero + let hasOversizedScalars = string.rangeOfCharacter(from: Self.oversizedScalars) != nil + let usesTextRenderingMetrics = Semantics.TextRenderingMetrics.isEnabled + enumerateAttribute( + .kitFont, + in: NSRange(location: 0, length: length), + options: .longestEffectiveRangeNotRequired + ) { value, _, _ in + guard let value else { + return + } + let font = value as! CTFont + let fontAscender = font.ascender + let fontDescender = font.descender + capHeight = max(capHeight, font.capHeight) + ascender = max(ascender, fontAscender) + descender = max(descender, fontDescender) + leading = leading.map { max($0, font.leading) } ?? font.leading + if hasOversizedScalars || font.mayRequireLanguageAwareOutsets { + var left: CGFloat = 0 + var top: CGFloat = 0 + var right: CGFloat = 0 + var bottom: CGFloat = 0 + if CTFontGetLanguageAwareOutsets(font, &left, &top, &right, &bottom) { + outsets.top = max(outsets.top, top) + outsets.leading = max(outsets.leading, left) + outsets.bottom = max(outsets.bottom, bottom) + outsets.trailing = max(outsets.trailing, right) + return + } + } + if usesTextRenderingMetrics, !font.isSystemUIFont { + var clippingAscender = fontAscender + var clippingDescender = fontDescender + if CTFontGetClippingMetrics(font, &clippingAscender, &clippingDescender) { + outsets.top = max(outsets.top, max(0, clippingAscender - fontAscender)) + outsets.bottom = max(outsets.bottom, max(0, clippingDescender - fontDescender)) + } + } + } + return EncodedFontMetrics( + capHeight: capHeight, + ascender: ascender, + descender: -descender, + leading: leading ?? 0, + outsets: outsets + ) + #else + _openSwiftUIPlatformUnimplementedWarning() + return EncodedFontMetrics() + #endif } } diff --git a/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift b/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift index d6f4146ea..9b88f8ee7 100644 --- a/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift +++ b/Sources/OpenSwiftUICore/View/Text/Font/CoreText+Private.swift @@ -57,6 +57,25 @@ package func CTFontDescriptorCreateForUIType( // MARK: - CTFont private API +@_silgen_name("CTFontCopySystemUIFontExcessiveLineHeightCharacterSet") +package func CTFontCopySystemUIFontExcessiveLineHeightCharacterSet() -> CFCharacterSet? + +@_silgen_name("CTFontGetLanguageAwareOutsets") +package func CTFontGetLanguageAwareOutsets( + _ font: CTFont, + _ left: UnsafeMutablePointer, + _ top: UnsafeMutablePointer, + _ right: UnsafeMutablePointer, + _ bottom: UnsafeMutablePointer +) -> Bool + +@_silgen_name("CTFontGetClippingMetrics") +package func CTFontGetClippingMetrics( + _ font: CTFont, + _ ascent: UnsafeMutablePointer, + _ descent: UnsafeMutablePointer +) -> Bool + @_silgen_name("CTFontIsSystemUIFont") package func CTFontIsSystemUIFont(_ font: CTFont) -> Bool diff --git a/Sources/OpenSwiftUICore/View/Text/Font/CoreTextUtil.swift b/Sources/OpenSwiftUICore/View/Text/Font/CoreTextUtil.swift index 479442d90..5d88a1e50 100644 --- a/Sources/OpenSwiftUICore/View/Text/Font/CoreTextUtil.swift +++ b/Sources/OpenSwiftUICore/View/Text/Font/CoreTextUtil.swift @@ -49,7 +49,7 @@ extension CTFont { } package var stylisticClass: CTFontStylisticClass { - .init(rawValue: CTFontGetSymbolicTraits(self).rawValue & CTFontSymbolicTraits.traitClassMask.rawValue) + .init(rawValue: symbolicTraits.rawValue & CTFontSymbolicTraits.traitClassMask.rawValue) } package func scaled( @@ -85,14 +85,10 @@ extension CTFont { } package var mayRequireLanguageAwareOutsets: Bool { - guard symbolicTraits.isEmpty else { + guard !symbolicTraits.contains(.italicTrait), weight <= kCTFontWeightHeavy else { return true } - guard weight <= kCTFontWeightHeavy else { - return true - } - let stylisticClass = stylisticClass - return stylisticClass == [.modernSerifsClass] || stylisticClass == [.oldStyleSerifsClass, .modernSerifsClass] + return [CTFontStylisticClass.scriptsClass, .ornamentalsClass].contains(stylisticClass) } } diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+Measure.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+Measure.swift new file mode 100644 index 000000000..afd08915a --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+Measure.swift @@ -0,0 +1,122 @@ +// +// Text+StringDrawingContext.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP +// ID: 68D51D45BC7225E72FB030F887E5A492 (SwiftUICore) + +public import Foundation + +@available(OpenSwiftUI_v6_0, *) +extension NSAttributedString { + @usableFromInline + package struct Metrics: Equatable { + package var size: CGSize + package let scale: CGFloat + package var firstBaseline: CGFloat + package var lastBaseline: CGFloat + package var baselineAdjustment: CGFloat + package var requestedWidth: CGFloat + package var numberOfLines: UInt? + package var hasTruncatedRanges: Bool + + package init( + size: CGSize, + scale: CGFloat, + firstBaseline: CGFloat, + lastBaseline: CGFloat, + baselineAdjustment: CGFloat, + requestedWidth: CGFloat, + numberOfLines: UInt?, + hasTruncatedRanges: Bool + ) { + self.size = size + self.scale = scale + self.firstBaseline = firstBaseline + self.lastBaseline = lastBaseline + self.baselineAdjustment = baselineAdjustment + self.requestedWidth = requestedWidth + self.numberOfLines = numberOfLines + self.hasTruncatedRanges = hasTruncatedRanges + } + } + + struct MetricsCache { + var kitCache: AnyObject? + let string: NSAttributedString + let lineLimit: Int? + let lowerLineLimit: Int? + let minScaleFactor: CGFloat + let bodyHeadOutdent: CGFloat + let pixelLength: CGFloat + let widthIsFlexible: Bool + let drawWithRequestedWidth: Bool + let isCollapsible: Bool + var entries: [(CGSize, Metrics)] + + init( + _ string: NSAttributedString?, + scaleFactorOverride: CGFloat?, + lineLimit: Int?, + lowerLineLimit: Int?, + minScaleFactor: CGFloat, + bodyHeadOutdent: CGFloat, + pixelLength: CGFloat, + widthIsFlexible: Bool, + drawWithRequestedWidth: Bool, + isCollapsible: Bool + ) { + self.kitCache = nil + self.string = string?.scaled(by: scaleFactorOverride ?? 1) ?? .emptyString + self.lineLimit = lineLimit + self.lowerLineLimit = lowerLineLimit + // A scale override has already been baked into `string`, so the drawing + // context must not apply the original minimum scale factor a second time. + self.minScaleFactor = scaleFactorOverride == nil ? minScaleFactor : 1 + self.bodyHeadOutdent = bodyHeadOutdent + self.pixelLength = pixelLength + self.widthIsFlexible = widthIsFlexible + self.drawWithRequestedWidth = drawWithRequestedWidth + self.isCollapsible = isCollapsible + self.entries = [] + } + + mutating func metrics( + requestedSize: CGSize, + layoutMargins: EdgeInsets, + wantsNumberOfLineFragments: Bool, + context: TextDrawingContext + ) -> Metrics { + _openSwiftUIUnimplementedFailure() + } + } + + private static let emptyString = NSAttributedString() + + private func measured( + requestedSize: CGSize, + lineLimit: Int?, + lowerLineLimit: Int?, + minScaleFactor: CGFloat, + bodyHeadOutdent: CGFloat, + widthIsFlexible: Bool, + kitCache: inout AnyObject?, + isCollapsible: Bool, + wantsNumberOfLineFragments: Bool, + context: TextDrawingContext + ) -> NSAttributedString.Metrics { + _openSwiftUIUnimplementedFailure() + } +} + +@available(*, unavailable) +extension NSAttributedString.Metrics: Sendable {} + +extension ResolvedStyledText { + // FIXME + package class StringDrawing: ResolvedStyledText {} + + // FIXME + package class TextLayoutManager: ResolvedStyledText {} +} diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift index f478180e2..ce404ea67 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift @@ -6,6 +6,8 @@ // Status: Complete // ID: 22747AAF70EE5063D02F299CE90A18BE (SwiftUICore) +import Foundation + // MARK: TextSizingModifier protocol TextSizingModifier: Equatable { @@ -76,15 +78,50 @@ extension Text { } } -//extension Text.Sizing { -// func layoutMargins( -// for: NSAttributedString, -// metrics: inout NSAttributedString.EncodedFontMetrics? -// layoutProperties: LayoutProperties -// ) { -// _openSwiftUIUnimplementedFailure() -// } -//} +extension Text.Sizing { + func layoutMargins( + for attributedString: NSAttributedString, + metrics: inout NSAttributedString.EncodedFontMetrics?, + layoutProperties: TextLayoutProperties + ) -> EdgeInsets { + var margins: EdgeInsets + switch storage { + case .standard: + margins = .zero + case .uniformLineHeight: + let fontMetrics = attributedString.maxFontMetrics + metrics = fontMetrics + if let leading = metrics?.leading, leading != 0 { + let fontLineHeight = fontMetrics.ascender - fontMetrics.descender + let roundedLineHeight = fontLineHeight.rounded(.up, toMultipleOf: layoutProperties.pixelLength) + let adjustment = (leading + fontLineHeight - roundedLineHeight) * 0.5 + margins = EdgeInsets(horizontal: 0, vertical: adjustment) + } else { + margins = .zero + } + case .adjustsForOversizedCharacters: + #if canImport(CoreText) + margins = attributedString.oversizedDrawingMargin(from: NSAttributedString.oversizedScalarsWithoutEmoji) + margins.round(.up, toMultipleOf: layoutProperties.pixelLength) + #else + _openSwiftUIPlatformUnimplementedWarning() + margins = .zero + #endif + } + if layoutProperties.writingMode == .verticalRightToLeft { + margins = EdgeInsets( + top: margins.leading, + leading: margins.bottom, + bottom: margins.trailing, + trailing: margins.top + ) + } + for modifier in modifiers.reversed() { + modifier.updateLayoutMargins(&margins) + } + return margins + } +} private struct TextSizingKey: EnvironmentKey { static let defaultValue: Text.Sizing = .standard diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+View.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+View.swift index daa834ac6..3f95b68c8 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+View.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+View.swift @@ -11,6 +11,9 @@ package import OpenAttributeGraphShims public import OpenCoreGraphicsShims package import OpenRenderBoxShims import UIFoundation_Private +#if canImport(CoreText) +package import CoreText +#endif // MARK: - Text + View [WIP] @@ -309,7 +312,7 @@ public struct TextLayoutProperties: Equatable { package var textSizing: Text.Sizing = .standard - private var textShape: TextShape = .bounds + fileprivate var textShape: TextShape = .bounds private struct Flags: OptionSet { var rawValue: UInt8 @@ -442,7 +445,63 @@ extension TextLayoutProperties: ProtobufMessage { } } -// MARK: - ResolvedStyledText [WIP] +#if canImport(CoreText) +extension NSAttributedString { + static let oversizedScalars: CharacterSet = { + guard let characterSet = CTFontCopySystemUIFontExcessiveLineHeightCharacterSet() else { + return CharacterSet() + } + return characterSet as CharacterSet + }() + + static let oversizedScalarsWithoutEmoji = oversizedScalars.subtracting( + NSCharacterSet.ic_emoji as CharacterSet + ) + + fileprivate func allFonts() -> Set { + var fonts: Set = [] + enumerateAttribute( + .kitFont, + in: NSRange(location: 0, length: length), + options: [] + ) { value, _, _ in + guard let value else { + return + } + let font = value as! CTFont + fonts.insert(font) + } + return fonts + } + + func oversizedDrawingMargin(from characterSet: CharacterSet) -> EdgeInsets { + guard string.rangeOfCharacter(from: characterSet) != nil else { + return .zero + } + return allFonts().reduce(into: .zero) { margins, font in + var left: CGFloat = 0 + var top: CGFloat = 0 + var right: CGFloat = 0 + var bottom: CGFloat = 0 + guard CTFontGetLanguageAwareOutsets( + font, + &left, + &top, + &right, + &bottom + ) else { + return + } + margins.top = max(margins.top, top) + margins.leading = max(margins.leading, left) + margins.bottom = max(margins.bottom, bottom) + margins.trailing = max(margins.trailing, right) + } + } +} +#endif + +// MARK: - ResolvedStyledText [Blocked by ORB] @available(OpenSwiftUI_v6_0, *) @usableFromInline @@ -453,13 +512,12 @@ package class ResolvedStyledText: CustomStringConvertible { final package var scaleFactorOverride: CGFloat? { didSet { - // TODO - _openSwiftUIUnimplementedWarning() + resetCache() } } package func resetCache() { - _openSwiftUIUnimplementedFailure() + _openSwiftUIBaseClassAbstractMethod() } final package let storage: NSAttributedString? @@ -469,8 +527,7 @@ package class ResolvedStyledText: CustomStringConvertible { final package let archiveOptions: ArchivedViewInput.Value package var drawingMargins: EdgeInsets { - _openSwiftUIUnimplementedWarning() - return .zero + .zero } final package let isCollapsible: Bool @@ -481,8 +538,6 @@ package class ResolvedStyledText: CustomStringConvertible { final package let transitions: [Text.ResolvedProperties.Transition] - final private var _computedMaxFontMetrics: NSAttributedString.EncodedFontMetrics? - final package var isDynamic: Bool { guard let storage else { return false @@ -491,21 +546,36 @@ package class ResolvedStyledText: CustomStringConvertible { } final package var isEmpty: Bool { - _openSwiftUIUnimplementedFailure() + guard let storage else { + return true + } + return storage.length == 0 } final package var needsStyledRendering: Bool { - _openSwiftUIUnimplementedWarning() - return false + features.contains(.keyColor) || ( + features.contains(.attachments) && + archiveOptions.isArchived && + !isDynamic + ) } final package var needsRBDisplayList: Bool { - _openSwiftUIUnimplementedWarning() - return false + !isDynamic && ( + features.contains(.customRenderer) || + (archiveOptions.isArchived && archiveOptions.preciseTextLayout) + ) } + final private var _computedMaxFontMetrics: NSAttributedString.EncodedFontMetrics? + final package var maxFontMetrics: NSAttributedString.EncodedFontMetrics { - _openSwiftUIUnimplementedFailure() + if let _computedMaxFontMetrics { + return _computedMaxFontMetrics + } + let metrics = storage?.maxFontMetrics ?? .init() + _computedMaxFontMetrics = metrics + return metrics } final var schedule: (any TimelineSchedule)? { @@ -523,23 +593,33 @@ package class ResolvedStyledText: CustomStringConvertible { archiveOptions: ArchivedViewInput.Value, isCollapsible: Bool, features: Text.ResolvedProperties.Features, - suffix: ResolvedTextSuffix, - attachments: Text.ResolvedProperties.CustomAttachments, + suffix _: ResolvedTextSuffix, + attachments _: Text.ResolvedProperties.CustomAttachments, styles: [_ShapeStyle_Pack.Style], transitions: [Text.ResolvedProperties.Transition], - scaleFactorOverride: CGFloat? + scaleFactorOverride _: CGFloat? ) { - // FIXME - _openSwiftUIUnimplementedWarning() self.storage = storage self.layoutProperties = layoutProperties - self.layoutMargins = layoutMargins ?? .zero self.stylePadding = stylePadding self.archiveOptions = archiveOptions self.isCollapsible = isCollapsible self.features = features self.styles = styles self.transitions = transitions + var computedMaxFontMetrics: NSAttributedString.EncodedFontMetrics? = nil + if let layoutMargins { + self.layoutMargins = layoutMargins + } else if let storage { + self.layoutMargins = layoutProperties.textSizing.layoutMargins( + for: storage, + metrics: &computedMaxFontMetrics, + layoutProperties: layoutProperties + ) + } else { + self.layoutMargins = .zero + } + self._computedMaxFontMetrics = computedMaxFontMetrics } package func lineHeightScalingAdjustment( @@ -547,7 +627,24 @@ package class ResolvedStyledText: CustomStringConvertible { maximumLineHeight: CGFloat, minimumLineHeight: CGFloat ) -> CGFloat { - _openSwiftUIUnimplementedFailure() + guard (lineHeightMultiple != 0 && lineHeightMultiple < 1) || + maximumLineHeight != 0 || + minimumLineHeight != 0 + else { + return .zero + } + let fontMetrics = maxFontMetrics + let fontLineHeight = fontMetrics.ascender - fontMetrics.descender + let multiple = lineHeightMultiple == 0 ? 1 : lineHeightMultiple + let proposedLineHeight = multiple * fontLineHeight + let maximum = maximumLineHeight == 0 ? proposedLineHeight : maximumLineHeight + let constrainedLineHeight = proposedLineHeight.clamp( + min: minimumLineHeight, + max: maximum + ) + return constrainedLineHeight < fontLineHeight + ? fontLineHeight - constrainedLineHeight + : .zero } final package func draw( @@ -557,65 +654,71 @@ package class ResolvedStyledText: CustomStringConvertible { context: TextDrawingContext = .shared, renderer: TextRendererBoxBase? = nil ) { - _openSwiftUIUnimplementedFailure() + draw( + in: drawingArea, + with: measuredSize, + applyingMarginOffsets: applyingMarginOffsets, + containsResolvable: isDynamic, + context: context, + renderer: renderer + ) } package var majorAxis: Axis { - _openSwiftUIUnimplementedFailure() + _openSwiftUIBaseClassAbstractMethod() } package func drawingScale(size: CGSize) -> CGFloat { - _openSwiftUIUnimplementedFailure() + _openSwiftUIBaseClassAbstractMethod() } package func spacing() -> Spacing { - _openSwiftUIUnimplementedWarning() - return .init() + _openSwiftUIBaseClassAbstractMethod() } package func sizeThatFits(_ proposedSize: _ProposedSize) -> CGSize { - _openSwiftUIUnimplementedWarning() - return CGSize(width: 50, height: 50) + _openSwiftUIBaseClassAbstractMethod() } package func size(in request: CGSize) -> CGSize { - _openSwiftUIUnimplementedWarning() - return CGSize(width: 50, height: 50) + _openSwiftUIBaseClassAbstractMethod() } package func frameSize(in request: CGSize) -> CGSize { - _openSwiftUIUnimplementedWarning() - return CGSize(width: 50, height: 50) + size(in: request) } + // FIXME package func _deleteMethod1() { _openSwiftUIUnimplementedFailure() } - package func _deleteMethod2() { - _openSwiftUIUnimplementedFailure() + package func metrics( + in size: CGSize, + layoutMargins: EdgeInsets? + ) -> NSAttributedString.Metrics { + _openSwiftUIBaseClassAbstractMethod() } package func size( in request: CGSize, context: TextDrawingContext ) -> CGSize { - _openSwiftUIUnimplementedFailure() + _openSwiftUIBaseClassAbstractMethod() } package func explicitAlignment( _ k: AlignmentKey, at size: CGSize ) -> CGFloat? { - _openSwiftUIUnimplementedWarning() - return nil + _openSwiftUIBaseClassAbstractMethod() } package func linkURL( at point: CGPoint, in size: CGSize ) -> URL? { - _openSwiftUIUnimplementedFailure() + _openSwiftUIBaseClassAbstractMethod() } package func draw( @@ -626,14 +729,7 @@ package class ResolvedStyledText: CustomStringConvertible { context: TextDrawingContext, renderer: TextRendererBoxBase? = nil ) { - #if canImport(Darwin) - var r = drawingArea - r.y = r.height - self.storage?.draw(with: r, context: context.ctx) - _openSwiftUIUnimplementedWarning() - #else - _openSwiftUIPlatformUnimplementedFailure() - #endif + _openSwiftUIBaseClassAbstractMethod() } package func layoutValue( @@ -641,15 +737,45 @@ package class ResolvedStyledText: CustomStringConvertible { with measuredSize: CGSize, applyingMarginOffsets: Bool = true ) -> Text.Layout? { - _openSwiftUIUnimplementedFailure() + nil } final package func resolvedContent(in context: ResolvableStringResolutionContext) -> NSAttributedString? { - _openSwiftUIUnimplementedFailure() + guard let storage else { + return nil + } + guard storage.isDynamic, archiveOptions.isArchived else { + return storage + } + guard let mutable = storage.mutableCopy() as? NSMutableAttributedString else { + return nil + } + ResolvableTextSegmentAttribute.update(mutable, in: context) + return mutable } final package func resolvingContent(in context: ResolvableStringResolutionContext) -> ResolvedStyledText { - _openSwiftUIUnimplementedFailure() + guard let storage, + storage.isDynamic, + let mutable = storage.mutableCopy() as? NSMutableAttributedString + else { + return self + } + ResolvableTextSegmentAttribute.update(mutable, in: context) + return ResolvedStyledText.styledText( + storage: mutable, + layoutProperties: layoutProperties, + layoutMargins: nil, + stylePadding: stylePadding, + archiveOptions: archiveOptions, + isCollapsible: isCollapsible, + features: features, + suffix: .none, + attachments: .init(), + styles: styles, + transitions: transitions, + scaleFactorOverride: scaleFactorOverride + ) } final package func nextUpdate( @@ -669,21 +795,26 @@ package class ResolvedStyledText: CustomStringConvertible { } final package var updatesAsynchronously: Bool { - _openSwiftUIUnimplementedFailure() + isDynamic && archiveOptions.isArchived } @usableFromInline final package var description: String { - _openSwiftUIUnimplementedFailure() + storage?.string ?? "" } final package var accessibilityText: Text { - _openSwiftUIUnimplementedFailure() + guard let storage else { + return Text("") + } + return AccessibilityText(storage).text } -// final package var cgStyleHandler: RBCGStyleHandler? { -// _openSwiftUIUnimplementedFailure() -// } + // TODO: RB + + final package var cgStyleHandler: ORBCGStyleHandler? { + _openSwiftUIUnimplementedFailure() + } final package func makeRBDisplayList( for size: CGSize, @@ -696,20 +827,24 @@ package class ResolvedStyledText: CustomStringConvertible { extension ResolvedStyledText { package func textSizeCacheMetrics(in size: CGSize) -> (UInt?, CGSize) { - _openSwiftUIUnimplementedFailure() + let metrics = metrics(in: size, layoutMargins: nil) + return (metrics.numberOfLines, metrics.size) } package func linkURLMetrics( in size: CGSize, layoutMargins: EdgeInsets ) -> CGFloat { - _openSwiftUIUnimplementedFailure() + metrics(in: size, layoutMargins: layoutMargins).scale } } @available(*, unavailable) extension ResolvedStyledText: Sendable {} +// FIXME: RB +package class ORBCGStyleHandler {} + // MARK: - ResolvedStyledText + layout extension extension ResolvedStyledText { @@ -724,7 +859,7 @@ extension ResolvedStyledText { explicitAlignment( VerticalAlignment.lastTextBaseline.key, at: size - ) ?? .zero + ) ?? size.height } package func frame(in request: CGSize, renderer: TextRendererBoxBase?) -> CGRect { @@ -767,91 +902,156 @@ extension ResolvedStyledText { } } -extension ResolvedStyledText { - // FIXME - package class StringDrawing: ResolvedStyledText {} -} - // MARK: - TextDrawingContext -#if !canImport(Darwin) -class NSStringDrawingContext {} -#endif - @_spi(ForOpenSwiftUIOnly) @available(OpenSwiftUI_v6_0, *) final public class TextDrawingContext { @AtomicBox var ctx: NSStringDrawingContext - init(ctx: NSStringDrawingContext) { - self.ctx = ctx - } - - static let shared: TextDrawingContext = { + private init() { let ctx = NSStringDrawingContext() - #if canImport(Darwin) ctx.wrapsForTruncationMode = true ctx.wantsBaselineOffset = true ctx.wantsScaledLineHeight = true ctx.wantsScaledBaselineOffset = true ctx.cachesLayout = true - #endif - return TextDrawingContext(ctx: ctx) - }() + self.ctx = ctx + } + + static let shared = TextDrawingContext() } @_spi(ForOpenSwiftUIOnly) @available(*, unavailable) extension TextDrawingContext: Sendable {} -// MARK: - ResolvedStyledText + Extension [WIP] - -//extension ResolvedStyledText { -// package static func styledText( -// storage: NSAttributedString?, -// layoutProperties: TextLayoutProperties, -// layoutMargins: EdgeInsets?, -// stylePadding: EdgeInsets, -// archiveOptions: ArchivedViewInput.Value, -// isCollapsible: Bool, -// features: Text.ResolvedProperties.Features, -// suffix: ResolvedTextSuffix, -// attachments: Text.ResolvedProperties.CustomAttachments, -// styles: [_ShapeStyle_Pack.Style], -// transitions: [Text.ResolvedProperties.Transition], -// scaleFactorOverride: CGFloat?, -// isInitialResolution: Bool = true -// ) -> ResolvedStyledText -// -// package static func styledText( -// storage: NSAttributedString?, -// stylePadding: EdgeInsets = EdgeInsets(), -// layoutProperties: TextLayoutProperties, -// archiveOptions: ArchivedViewInput.Value = .init(), -// isCollapsible: Bool = false, -// features: Text.ResolvedProperties.Features = .init(), -// suffix: ResolvedTextSuffix = .none, -// attachments: Text.ResolvedProperties.CustomAttachments = .init(), -// styles: [_ShapeStyle_Pack.Style] = .init(), -// transitions: [Text.ResolvedProperties.Transition] = .init() -// ) -> ResolvedStyledText -// -// package static func styledText( -// storage: NSAttributedString?, -// stylePadding: EdgeInsets = EdgeInsets(), -// environment: EnvironmentValues, -// archiveOptions: ArchivedViewInput.Value = .init(), -// isCollapsible: Bool = false, -// features: Text.ResolvedProperties.Features = .init(), -// suffix: ResolvedTextSuffix = .none, -// attachments: Text.ResolvedProperties.CustomAttachments = .init(), -// styles: [_ShapeStyle_Pack.Style] = .init(), -// transitions: [Text.ResolvedProperties.Transition] = .init(), -// writingMode: Text.WritingMode? = nil, -// sizeFitting: Bool = false -// ) -> ResolvedStyledText -//} +// MARK: - ResolvedStyledText + styledText + +extension ResolvedStyledText { + package static func styledText( + storage: NSAttributedString?, + layoutProperties: TextLayoutProperties, + layoutMargins: EdgeInsets?, + stylePadding: EdgeInsets, + archiveOptions: ArchivedViewInput.Value, + isCollapsible: Bool, + features: Text.ResolvedProperties.Features, + suffix: ResolvedTextSuffix, + attachments: Text.ResolvedProperties.CustomAttachments, + styles: [_ShapeStyle_Pack.Style], + transitions: [Text.ResolvedProperties.Transition], + scaleFactorOverride: CGFloat? + ) -> ResolvedStyledText { + let layoutManagerFeatures: Text.ResolvedProperties.Features = [ + .customRenderer, + .useTextLayoutManager, + .produceTextLayout, + .checkInterpolationStrategy, + ] + let requiresTextLayoutManager = + layoutProperties.writingMode != .horizontalTopToBottom || + !features.intersection(layoutManagerFeatures).isEmpty || + !attachments.isEmpty || + suffix != .none || + layoutProperties.textShape != .bounds + if requiresTextLayoutManager { + return TextLayoutManager( + storage: storage, + layoutProperties: layoutProperties, + layoutMargins: layoutMargins, + stylePadding: stylePadding, + archiveOptions: archiveOptions, + isCollapsible: isCollapsible, + features: features, + suffix: suffix, + attachments: attachments, + styles: styles, + transitions: transitions, + scaleFactorOverride: scaleFactorOverride + ) + } else { + return StringDrawing( + storage: storage, + layoutProperties: layoutProperties, + layoutMargins: layoutMargins, + stylePadding: stylePadding, + archiveOptions: archiveOptions, + isCollapsible: isCollapsible, + features: features, + suffix: suffix, + attachments: attachments, + styles: styles, + transitions: transitions, + scaleFactorOverride: scaleFactorOverride + ) + } + } + + package static func styledText( + storage: NSAttributedString?, + stylePadding: EdgeInsets = EdgeInsets(), + layoutProperties: TextLayoutProperties, + archiveOptions: ArchivedViewInput.Value = .init(), + isCollapsible: Bool = false, + features: Text.ResolvedProperties.Features = .init(), + suffix: ResolvedTextSuffix = .none, + attachments: Text.ResolvedProperties.CustomAttachments = .init(), + styles: [_ShapeStyle_Pack.Style] = .init(), + transitions: [Text.ResolvedProperties.Transition] = .init() + ) -> ResolvedStyledText { + styledText( + storage: storage, + layoutProperties: layoutProperties, + layoutMargins: nil, + stylePadding: stylePadding, + archiveOptions: archiveOptions, + isCollapsible: isCollapsible, + features: features, + suffix: suffix, + attachments: attachments, + styles: styles, + transitions: transitions, + scaleFactorOverride: nil + ) + } + + package static func styledText( + storage: NSAttributedString?, + stylePadding: EdgeInsets = EdgeInsets(), + environment: EnvironmentValues, + archiveOptions: ArchivedViewInput.Value = .init(), + isCollapsible: Bool = false, + features: Text.ResolvedProperties.Features = .init(), + suffix: ResolvedTextSuffix = .none, + attachments: Text.ResolvedProperties.CustomAttachments = .init(), + styles: [_ShapeStyle_Pack.Style] = .init(), + transitions: [Text.ResolvedProperties.Transition] = .init(), + writingMode: Text.WritingMode?, + sizeFitting: Bool = false + ) -> ResolvedStyledText { + var layoutProperties = TextLayoutProperties(environment) + if let writingMode { + layoutProperties.writingMode = writingMode + } + layoutProperties.sizeFitting = sizeFitting + return styledText( + storage: storage, + layoutProperties: layoutProperties, + layoutMargins: nil, + stylePadding: stylePadding, + archiveOptions: archiveOptions, + isCollapsible: isCollapsible, + features: features, + suffix: suffix, + attachments: attachments, + styles: styles, + transitions: transitions, + scaleFactorOverride: nil + ) + } +} // MARK: - CodableResolvedStyledText [WIP] diff --git a/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift b/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift index 76e1e9d5a..77e7717ed 100644 --- a/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift +++ b/Sources/OpenSwiftUICore/View/Text/Util/ReducedTimelineSchedule.swift @@ -23,6 +23,15 @@ extension NSAttributedString { } var updateSchedule: any TimelineSchedule { - _openSwiftUIUnimplementedFailure() + guard length >= 1, + let schedule = attribute( + .updateSchedule, + at: 0, + effectiveRange: nil + ) as? any TimelineSchedule + else { + return ExplicitTimelineSchedule([]) + } + return schedule } } diff --git a/Sources/OpenSwiftUI_SPI/Shims/UIFoundation/NSAttributedString.h b/Sources/OpenSwiftUI_SPI/Shims/UIFoundation/NSAttributedString.h index 805d22d7d..584c818d7 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/UIFoundation/NSAttributedString.h +++ b/Sources/OpenSwiftUI_SPI/Shims/UIFoundation/NSAttributedString.h @@ -64,6 +64,10 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN @property(readonly, assign, nonatomic) BOOL isEmptyOrTerminatedByParagraphSeparator_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(isEmptyOrTerminatedByParagraphSeparator); @end +@interface NSCharacterSet (OpenSwiftUI_SPI) +@property(class, readonly, nonatomic) NSCharacterSet *ic_emojiCharacterSet; +@end + OPENSWIFTUI_ASSUME_NONNULL_END #endif diff --git a/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift new file mode 100644 index 000000000..2132885e3 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift @@ -0,0 +1,49 @@ +// +// NSAttributedStringTests.swift +// OpenSwiftUICoreTests +// + +#if canImport(CoreText) + +import CoreText +import Foundation +@testable import OpenSwiftUICore +import Testing + +struct NSAttributedStringTests { + @Test + func maxFontMetricsWithoutFontAttributes() { + let metrics = NSAttributedString(string: "OpenSwiftUI").maxFontMetrics + + #expect(metrics.capHeight == 0) + #expect(metrics.ascender == 0) + #expect(metrics.descender == 0) + #expect(metrics.leading == 0) + #expect(metrics.outsets == .zero) + } + + @Test + func maxFontMetricsAggregatesFontRuns() { + let smallFont = CTFontCreateWithName("Helvetica" as CFString, 12, nil) + let largeFont = CTFontCreateWithName("Helvetica" as CFString, 24, nil) + let expectedCapHeight = max(CTFontGetCapHeight(smallFont), CTFontGetCapHeight(largeFont)) + let expectedAscender = max(CTFontGetAscent(smallFont), CTFontGetAscent(largeFont)) + let expectedDescender = -max(CTFontGetDescent(smallFont), CTFontGetDescent(largeFont)) + let expectedLeading = max(CTFontGetLeading(smallFont), CTFontGetLeading(largeFont)) + + for (firstFont, secondFont) in [(smallFont, largeFont), (largeFont, smallFont)] { + let attributedString = NSMutableAttributedString(string: "ab") + attributedString.addAttribute(.kitFont, value: firstFont, range: NSRange(location: 0, length: 1)) + attributedString.addAttribute(.kitFont, value: secondFont, range: NSRange(location: 1, length: 1)) + + let metrics = attributedString.maxFontMetrics + + #expect(metrics.capHeight == expectedCapHeight) + #expect(metrics.ascender == expectedAscender) + #expect(metrics.descender == expectedDescender) + #expect(metrics.leading == expectedLeading) + } + } +} + +#endif