From 0a7bf589d85aa80e6d69795e60063f4541b6c1e4 Mon Sep 17 00:00:00 2001 From: Max Obermeier Date: Thu, 10 Apr 2025 12:31:37 -0700 Subject: [PATCH 1/2] implementation --- .../FoundationAttributes.swift | 97 ++++++++++++++++++- .../AttributedStringTests.swift | 24 ++++- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift b/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift index 2c0e11440..41720b997 100644 --- a/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift +++ b/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift @@ -34,7 +34,14 @@ extension AttributeScopes { @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) public let durationField: DurationFieldAttribute - + + /// The base writing direction of a paragraph. + #if FOUNDATION_FRAMEWORK + @_spi(AttributedStringWritingDirection) + #endif + @available(FoundationPreview 6.2, *) + public let writingDirection: WritingDirectionAttribute + #if FOUNDATION_FRAMEWORK @available(FoundationPreview 0.1, *) public let agreementConcept: AgreementConceptAttribute @@ -507,7 +514,21 @@ extension AttributeScopes.FoundationAttributes { case nanoseconds } } - + + /// The attribute key for the base writing direction of a paragraph. + #if FOUNDATION_FRAMEWORK + @_spi(AttributedStringWritingDirection) + #endif + @available(FoundationPreview 6.2, *) + @frozen + public enum WritingDirectionAttribute: CodableAttributedStringKey { + public typealias Value = AttributedString.WritingDirection + public static let name: String = "Foundation.WritingDirection" + + public static let runBoundaries: AttributedString.AttributeRunBoundaries? = .paragraph + public static let inheritedByAddedText = false + } + #if FOUNDATION_FRAMEWORK @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) public struct LocalizedStringArgumentAttributes { @@ -647,6 +668,9 @@ extension AttributeScopes.FoundationAttributes.ByteCountAttribute : Sendable {} @available(*, unavailable) extension AttributeScopes.FoundationAttributes.DurationFieldAttribute : Sendable {} +@available(*, unavailable) +extension AttributeScopes.FoundationAttributes.WritingDirectionAttribute: Sendable {} + #if FOUNDATION_FRAMEWORK @available(macOS, unavailable, introduced: 14.0) @@ -819,3 +843,72 @@ extension AttributeScopes.FoundationAttributes.LocalizedNumberFormatAttribute.Va } #endif // FOUNDATION_FRAMEWORK + +extension AttributedString { + /// The writing direction of a piece of text. + /// + /// Writing direction defines the base direction in which bidirectional text + /// lays out its directional runs. A directional run is a contigous sequence + /// of characters that all have the same effective directionality, which can + /// be determined using the Unicode BiDi algorithm. The ``leftToRight`` + /// writing direction puts the directional run that is placed first in the + /// storage leftmost, and places subsequent directional runs towards the + /// right. The ``rightToLeft`` writing direction puts the directional run + /// that is placed first in the storage rightmost, and places subsequent + /// directional runs towards the left. + /// + /// Note that writing direction is a property separate from a text's + /// alignment, its line layout direction, or its character direction. + /// However, it is often used to determine the default alignment of a + /// paragraph. E.g. English (a language with + /// ``Locale/LanguageDirection-swift.enum/leftToRight`` + /// ``Locale/Language-swift.struct/characterDirection``) is usually aligned + /// to the left, but may be centered or aligned to the right for special + /// effect, or to be visually more appealing in a user interface. + /// + /// For bidirectional text to be perceived as laid out correctly, make sure + /// that the writing direction is set to the value equivalent to the + /// ``Locale/Language-swift.struct/characterDirection`` of the primary + /// language in the text. E.g. an English sentence that contains some + /// Arabic (a language with + /// ``Locale/LanguageDirection-swift.enum/rightToLeft`` + /// ``Locale/Language-swift.struct/characterDirection``) words, should use + /// a ``leftToRight`` writing direction. An Arabic sentence that contains + /// some English words, should use a ``rightToLeft`` writing direction. + /// + /// Writing direction is always orthogonoal to the line layout direction + /// chosen to display a certain text. The line layout direction is the + /// direction in which a sequence of lines is placed in. E.g. English text + /// is usually displayed with a line layout direction of + /// ``Locale/LanguageDirection-swift.enum/topToBottom``. While languages do + /// have an associated line language direction (see + /// ``Locale/Language-swift.struct/lineLayoutDirection``), not all displays + /// of text follow the line layout direction of the text's primary language. + /// + /// Horizontal script is script with a line layout direction of either + /// ``Locale/LanguageDirection-swift.enum/topToBottom`` or + /// ``Locale/LanguageDirection-swift.enum/bottomToTop``. Vertical script + /// has a ``Locale/LanguageDirection-swift.enum/leftToRight`` or + /// ``Locale/LanguageDirection-swift.enum/rightToLeft`` line layout + /// direction. In vertical scripts, a writing direction of ``leftToRight`` + /// is interpreted as top-to-bottom and a writing direction of + /// ``rightToLeft`` is interpreted as bottom-to-top. + #if FOUNDATION_FRAMEWORK + @_spi(AttributedStringWritingDirection) + #endif + @available(FoundationPreview 6.2, *) + @frozen + public enum WritingDirection: Codable, Hashable, CaseIterable, Sendable { + /// A left-to-right writing direction in horizontal script. + /// + /// - Note: In vertical scripts, this equivalent to a top-to-bottom + /// writing direction. + case leftToRight + + /// A right-to-left writing direction in horizontal script. + /// + /// - Note: In vertical scripts, this equivalent to a bottom-to-top + /// writing direction. + case rightToLeft + } +} diff --git a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift index fe2245eaf..4462b842d 100644 --- a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift +++ b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift @@ -19,7 +19,7 @@ import TestSupport #endif // FOUNDATION_FRAMEWORK #if FOUNDATION_FRAMEWORK -@testable @_spi(AttributedString) import Foundation +@testable @_spi(AttributedString) @_spi(AttributedStringWritingDirection) import Foundation // For testing default attribute scope conversion #if canImport(Accessibility) import Accessibility @@ -2599,4 +2599,26 @@ E { XCTAssertEqual(testConstrainedContainer.filter(inheritedByAddedText: true), AttributeContainer.testInt(2).testParagraphConstrained(3).testCharacterConstrained(4)) XCTAssertEqual(testConstrainedContainer.filter(inheritedByAddedText: false), AttributeContainer.testNonExtended(5)) } + + func testWritingDirectionBehavior() { + // Indicate that this sentence is primarily right to left, because the English term "Swift" is embedded into an Arabic sentence. + var string = AttributedString("Swift مذهل!", attributes: .init().writingDirection(.rightToLeft)) + + // To remove the information about the writing direction, set it to `nil`: + string.writingDirection = nil + + let range = string.range(of: "Swift")! + + // When setting or removing the value from a certain range, the value will always be applied to the entire paragraph(s) that intersect with that range: + string[range].writingDirection = .leftToRight + assert(string.runs[\.writingDirection].count == 1) + + string.append(AttributedString(" It is awesome for working with strings!")) + assert(string.runs[\.writingDirection].count == 1) + assert(string.writingDirection == .leftToRight) + + string.append(AttributedString("\nThe new paragraph does not inherit the writing direction.")) + assert(string.runs[\.writingDirection].count == 2) + assert(string.runs.last?.writingDirection == nil) + } } From 6d0f49aa98675491f1ce8c0045560d2da96b109b Mon Sep 17 00:00:00 2001 From: Max Obermeier Date: Thu, 10 Apr 2025 15:41:04 -0700 Subject: [PATCH 2/2] address review --- .../FoundationAttributes.swift | 2 +- .../AttributedStringTests.swift | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift b/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift index 41720b997..656dafe61 100644 --- a/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift +++ b/Sources/FoundationEssentials/AttributedString/FoundationAttributes.swift @@ -523,7 +523,7 @@ extension AttributeScopes.FoundationAttributes { @frozen public enum WritingDirectionAttribute: CodableAttributedStringKey { public typealias Value = AttributedString.WritingDirection - public static let name: String = "Foundation.WritingDirection" + public static let name: String = "Foundation.WritingDirectionAttribute" public static let runBoundaries: AttributedString.AttributeRunBoundaries? = .paragraph public static let inheritedByAddedText = false diff --git a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift index 4462b842d..b77a4b238 100644 --- a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift +++ b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringTests.swift @@ -2600,25 +2600,29 @@ E { XCTAssertEqual(testConstrainedContainer.filter(inheritedByAddedText: false), AttributeContainer.testNonExtended(5)) } - func testWritingDirectionBehavior() { + func testWritingDirectionBehavior() throws { // Indicate that this sentence is primarily right to left, because the English term "Swift" is embedded into an Arabic sentence. var string = AttributedString("Swift مذهل!", attributes: .init().writingDirection(.rightToLeft)) + XCTAssertEqual(string.writingDirection, .rightToLeft) + // To remove the information about the writing direction, set it to `nil`: string.writingDirection = nil - let range = string.range(of: "Swift")! + XCTAssertEqual(string.writingDirection, nil) + + let range = try XCTUnwrap(string.range(of: "Swift")) // When setting or removing the value from a certain range, the value will always be applied to the entire paragraph(s) that intersect with that range: string[range].writingDirection = .leftToRight - assert(string.runs[\.writingDirection].count == 1) + XCTAssertEqual(string.runs[\.writingDirection].count, 1) string.append(AttributedString(" It is awesome for working with strings!")) - assert(string.runs[\.writingDirection].count == 1) - assert(string.writingDirection == .leftToRight) + XCTAssertEqual(string.runs[\.writingDirection].count, 1) + XCTAssertEqual(string.writingDirection, .leftToRight) string.append(AttributedString("\nThe new paragraph does not inherit the writing direction.")) - assert(string.runs[\.writingDirection].count == 2) - assert(string.runs.last?.writingDirection == nil) + XCTAssertEqual(string.runs[\.writingDirection].count, 2) + XCTAssertEqual(string.runs.last?.writingDirection, nil) } }