├── Test ├── App │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Test.imageset │ │ │ ├── face.png │ │ │ └── Contents.json │ │ ├── async.imageset │ │ │ ├── async.png │ │ │ └── Contents.json │ │ ├── wide.imageset │ │ │ ├── wide.png │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── TestApp.swift │ ├── Test.entitlements │ ├── Mastodon │ │ ├── DTOs.swift │ │ ├── MastodonAPI.swift │ │ └── MastodonView.swift │ ├── Provider │ │ ├── URLSessionEmojiProvider.swift │ │ ├── UpsideDownEmojiProvider.swift │ │ └── NukeEmojiProvider.swift │ ├── SFSymbolEmojiView.swift │ ├── LocalEmojiView.swift │ ├── RemoteEmojiView.swift │ ├── ChangingRemoteEmojiView.swift │ ├── EmojiTestView.swift │ ├── AnimatedEmojiView.swift │ └── ContentView.swift ├── Tests │ ├── __Snapshots__ │ │ └── EmojiTextTests │ │ │ ├── test_Async.1.png │ │ │ ├── test_Empty.1.png │ │ │ ├── test_Wide.1.png │ │ │ ├── test_Multiple.1.png │ │ │ ├── test_No_Emoji.1.png │ │ │ ├── test_iPhone.1.png │ │ │ ├── test_Async_Offset.1.png │ │ │ ├── test_Async_Scaled.1.png │ │ │ ├── test_Markdown_Full.1.png │ │ │ ├── test_iPhone_Scaled.1.png │ │ │ ├── test_Async_Markdown.1.png │ │ │ ├── test_EmojiInMarkdown.1.png │ │ │ ├── test_Prepend_Append.1.png │ │ │ ├── test_Wide_Custom_Scaled.1.png │ │ │ ├── test_Async_Custom_Scaled.1.png │ │ │ ├── test_iPhone_RenderingMode.1.png │ │ │ ├── test_Async_Markdown_Double.1.png │ │ │ ├── test_Async_Offset_Negative.1.png │ │ │ ├── test_Async_Offset_Positive.1.png │ │ │ ├── test_Async_Verbatim_Double.1.png │ │ │ ├── test_EmojiInMarkdownNested.1.png │ │ │ └── test_Markdown_InlineOnlyPreservingWhitespace.1.png │ ├── SnapshotTests.xctestplan │ ├── TestEmojiProvider.swift │ ├── Emojis.swift │ └── EmojiTextTests.swift └── Test.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ ├── xcshareddata │ └── xcschemes │ │ ├── SnapshotTests.xcscheme │ │ └── Test.xcscheme │ └── project.pbxproj ├── .spi.yml ├── Sources └── EmojiText │ ├── Model │ ├── Emoji │ │ ├── Protocols │ │ │ ├── AsyncCustomEmoji.swift │ │ │ ├── SyncCustomEmoji.swift │ │ │ └── CustomEmoji.swift │ │ ├── SFSymbolEmoji.swift │ │ ├── LocalEmoji.swift │ │ ├── RemoteEmoji.swift │ │ └── RenderedEmoji.swift │ ├── RawImage.swift │ ├── RenderedImage.swift │ └── AttributedPartialstring.swift │ ├── Extensions │ ├── Data+Extensions.swift │ ├── FloatingPoint+Extensions.swift │ ├── Publisher+Extensions.swift │ ├── Integer+Extensions.swift │ ├── AttributedString+Extensions.swift │ ├── Text+Extensions.swift │ ├── UIContentSizeCategory+Extensions.swift │ ├── CGImageSource+Extensions.swift │ ├── String+Extensions.swift │ ├── Font+Extensions.swift │ └── Image+Extensions.swift │ ├── Domain │ ├── EmojiProviderError.swift │ ├── AnimatedEmojiMode.swift │ ├── EmojiError.swift │ └── AnimatedImageType.swift │ ├── Documentation.docc │ ├── Placeholder.md │ ├── Emoji_Size.md │ ├── ImagePipeline.md │ └── Documentation.md │ ├── Lock.swift │ ├── Provider │ ├── SyncEmojiProvider.swift │ ├── Implementations │ │ ├── DefaultSyncEmojiProvider.swift │ │ └── DefaultAsyncEmojiProvider.swift │ └── AsyncEmojiProvider.swift │ ├── Render │ ├── EmojiRenderer.swift │ ├── EmojiTextRenderer.swift │ ├── Markdown │ │ ├── EmojiReplacer.swift │ │ └── MarkdownEmojiRenderer.swift │ └── VerbatimEmojiRenderer.swift │ ├── Environment │ ├── EmojiTextNamespace.swift │ ├── Environment+Helpers.swift │ ├── EmojiTextEnvironmentValues.swift │ └── Environment.swift │ ├── Logger.swift │ ├── Typealiases.swift │ ├── Preview Content │ └── Preview.swift │ ├── CADisplayLinkPublisher.swift │ └── EmojiText.swift ├── Package.swift ├── Package@swift-6.0.swift ├── .gitignore ├── README.md ├── .swiftlint.yml └── LICENSE /Test/App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/Test.imageset/face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/App/Assets.xcassets/Test.imageset/face.png -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/async.imageset/async.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/App/Assets.xcassets/async.imageset/async.png -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/wide.imageset/wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/App/Assets.xcassets/wide.imageset/wide.png -------------------------------------------------------------------------------- /Test/App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Empty.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Empty.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Wide.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Wide.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Multiple.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Multiple.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_No_Emoji.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_No_Emoji.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Scaled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Scaled.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Markdown_Full.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Markdown_Full.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone_Scaled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone_Scaled.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Markdown.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Markdown.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_EmojiInMarkdown.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_EmojiInMarkdown.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Prepend_Append.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Prepend_Append.1.png -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [EmojiText] 5 | custom_documentation_parameters: [--include-extended-types] 6 | platform: ios 7 | -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Wide_Custom_Scaled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Wide_Custom_Scaled.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Custom_Scaled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Custom_Scaled.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone_RenderingMode.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_iPhone_RenderingMode.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Markdown_Double.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Markdown_Double.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset_Negative.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset_Negative.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset_Positive.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Offset_Positive.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Verbatim_Double.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Async_Verbatim_Double.1.png -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_EmojiInMarkdownNested.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_EmojiInMarkdownNested.1.png -------------------------------------------------------------------------------- /Test/Test.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Test/Tests/__Snapshots__/EmojiTextTests/test_Markdown_InlineOnlyPreservingWhitespace.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/EmojiText/main/Test/Tests/__Snapshots__/EmojiTextTests/test_Markdown_InlineOnlyPreservingWhitespace.1.png -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "wide.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/async.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "async.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Test/App/TestApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestApp.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 18.02.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct TestApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Test/Test.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/Protocols/AsyncCustomEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncCustomEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 14.07.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom emoji that requires lazy loading 12 | public protocol AsyncCustomEmoji: CustomEmoji { 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/Protocols/SyncCustomEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncCustomEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 14.07.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom emoji that can be loaded immediately 12 | public protocol SyncCustomEmoji: CustomEmoji { 13 | } 14 | -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/Test.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "face.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Data+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 14.08.23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | func readBytes(count: Int) -> [UInt8] { 12 | var bytes = [UInt8](repeating: 0, count: count) 13 | copyBytes(to: &bytes, count: count) 14 | return bytes 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/EmojiText/Domain/EmojiProviderError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiProviderError.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 13.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum EmojiProviderError: Swift.Error { 11 | /// Thrown when the fetched data is invalid 12 | case invalidData 13 | /// Thrown when an unsupported emojis is trying to be fetched 14 | case unsupportedEmoji 15 | } 16 | -------------------------------------------------------------------------------- /Test/App/Test.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Sources/EmojiText/Documentation.docc/Placeholder.md: -------------------------------------------------------------------------------- 1 | # Placeholder 2 | 3 | Remote emojis are replaced by a placeholder image. Default is the SF Symbol 􀓔 (`square.dashed`). 4 | 5 | ## Overview 6 | 7 | You can provide a placeholder image with your own placeholder using 8 | 9 | ```swift 10 | .emojiText.placeholder(systemName: /* SF Symbol */) 11 | ``` 12 | 13 | or 14 | 15 | ```swift 16 | .emojiText.placeholder(image: /* some UIImage or NSImage */) 17 | ``` 18 | -------------------------------------------------------------------------------- /Sources/EmojiText/Documentation.docc/Emoji_Size.md: -------------------------------------------------------------------------------- 1 | # Emoji Size 2 | 3 | While ``EmojiText`` tries to determine the size of the emoji based on the current font and dynamic type size this only works with the system text styles, this is due to limitations of `SwiftUI.Font` 4 | 5 | ## Overview 6 | 7 | In case you use a custom font or want to override the calculation of the emoji size for some other reason you can provide a emoji size with 8 | 9 | ```swift 10 | .emojiText.size(/* size in px */) 11 | ``` 12 | -------------------------------------------------------------------------------- /Sources/EmojiText/Documentation.docc/ImagePipeline.md: -------------------------------------------------------------------------------- 1 | # Nuke 2 | 3 | ``EmojiText`` resolves remote emojis using `Nuke` and defaults to `ImagePipeline.shared` for the `ImagePipeline` 4 | 5 | ## Overview 6 | 7 | In order to use a custom `ImagePipeline` you can provide a custom pipeline with 8 | 9 | ```swift 10 | .environment(\.emojiText.imagePipeline, ImagePipeline()) 11 | ``` 12 | 13 | For further information, please refer to the official documenation: [Nuke](https://kean-docs.github.io/nuke/documentation/nuke/getting-started/) 14 | -------------------------------------------------------------------------------- /Sources/EmojiText/Lock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lock.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 16.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | final class Lock: @unchecked Sendable { 11 | private var _value: T 12 | private let lock: NSLocking 13 | 14 | init(_ image: T, lock: NSLocking = NSLock()) { 15 | self._value = image 16 | self.lock = lock 17 | } 18 | 19 | var wrappedValue: T { 20 | get { 21 | lock.withLock { _value } 22 | } 23 | set { 24 | lock.withLock { _value = newValue } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/FloatingPoint+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingPoint+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 14.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FloatingPoint { 11 | func isAlmostEqual( 12 | to other: Self, 13 | tolerance: Self = Self.ulpOfOne.squareRoot() 14 | ) -> Bool { 15 | guard self.isFinite, other.isFinite else { 16 | return false 17 | } 18 | 19 | let scale = max(abs(self), abs(other), .leastNormalMagnitude) 20 | return abs(self - other) < scale * tolerance 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Test/App/Mastodon/DTOs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DTOs.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 14.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Status: Codable, Equatable { 11 | let id: String 12 | 13 | let content: String 14 | let account: Account 15 | 16 | let emojis: [Emoji] 17 | } 18 | 19 | struct Account: Codable, Equatable { 20 | let id: String 21 | 22 | let username: String 23 | let displayName: String 24 | 25 | let emojis: [Emoji] 26 | } 27 | 28 | struct Emoji: Codable, Equatable { 29 | let shortcode: String 30 | let url: URL 31 | } 32 | -------------------------------------------------------------------------------- /Sources/EmojiText/Provider/SyncEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiProvider.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 13.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A provider loading emoji's in a synchronized way 11 | public protocol SyncEmojiProvider { 12 | /// Load the sync emoji 13 | /// - Parameters: 14 | /// - emoji: The sync emoji to load. 15 | /// - height: The desired height of the emoji. 16 | /// - Returns: The image representing the emoji or `nil` if the emoji couldn't be loaded. 17 | func emojiImage(emoji: any SyncCustomEmoji, height: CGFloat?) -> EmojiImage? 18 | } 19 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Publisher+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Publisher+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.08.23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension Publisher where Failure == Never { 12 | func values(stopOnLowPowerMode: Bool) -> AsyncPublisher> { 13 | filter { _ in 14 | if stopOnLowPowerMode, ProcessInfo.processInfo.isLowPowerModeEnabled { 15 | return false 16 | } else { 17 | return true 18 | } 19 | } 20 | .eraseToAnyPublisher() 21 | .values 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EmojiText/Render/EmojiRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiRenderer.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 28.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | protocol EmojiRenderer { 12 | func render(string: String, emojis: [String: RenderedEmoji], size: CGFloat?) -> Text 13 | func renderAnimated(string: String, emojis: [String: RenderedEmoji], size: CGFloat?, at time: CFTimeInterval) -> Text 14 | } 15 | 16 | extension EmojiRenderer { 17 | func renderAnimated(string: String, emojis: [String: RenderedEmoji], size: CGFloat?, at time: CFTimeInterval) -> Text { 18 | render(string: string, emojis: emojis, size: size) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Integer+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Integer+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Returns the greatest common divisor of the given numbers. 11 | /// 12 | /// - Parameters: 13 | /// - x: A signed number. 14 | /// - y: A signed number. 15 | /// - Returns: The greatest common divisor between `x` and `y`. 16 | @inlinable func gcd(_ rhs: T, _ lhs: T) -> T where T: Comparable, T: SignedInteger { 17 | let remainder = abs(rhs) % abs(lhs) 18 | if remainder != 0 { 19 | return gcd(abs(lhs), remainder) 20 | } else { 21 | return abs(lhs) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EmojiText/Environment/EmojiTextNamespace.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiTextNamespace.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 30.01.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension View { 11 | /// The ``EmojiText`` namespace 12 | var emojiText: EmojiTextNamespace { 13 | EmojiTextNamespace(self) 14 | } 15 | } 16 | 17 | /// ``EmojiText`` namespace 18 | public struct EmojiTextNamespace { 19 | /// The content of the namespace 20 | public let content: Content 21 | 22 | /// Create a new ``EmojiText`` namespace 23 | /// - Parameter content: The content of the namespace 24 | public init(_ content: Content) { 25 | self.content = content 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Test/Tests/SnapshotTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "1997B000-AB88-4613-BCE6-964ECFB59962", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:Test.xcodeproj", 14 | "identifier" : "6334D1E12B33B4B500E9D8DE", 15 | "name" : "Test" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : false, 21 | "target" : { 22 | "containerPath" : "container:Test.xcodeproj", 23 | "identifier" : "6334D1F22B33B4B600E9D8DE", 24 | "name" : "SnapshotTests" 25 | } 26 | } 27 | ], 28 | "version" : 1 29 | } 30 | -------------------------------------------------------------------------------- /Sources/EmojiText/Domain/AnimatedEmojiMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedEmojiMode.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.08.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// The mode of animated emojis 12 | public enum AnimatedEmojiMode { 13 | /// Never play animated emoji 14 | case never 15 | /// Disable animated emoji when device is in low-power mode 16 | case disabledOnLowPower 17 | /// Always play animated emoji 18 | case always 19 | 20 | var disabledOnLowPower: Bool { 21 | switch self { 22 | case .never: 23 | return true 24 | case .disabledOnLowPower: 25 | return true 26 | case .always: 27 | return false 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Test/Tests/TestEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestEmojiProvider.swift 3 | // SnapshotTests 4 | // 5 | // Created by David Walter on 02.10.24. 6 | // 7 | 8 | import Foundation 9 | import EmojiText 10 | 11 | struct TestEmojiProvider: AsyncEmojiProvider { 12 | init() { 13 | } 14 | 15 | // MARK: - AsyncEmojiProvider 16 | 17 | func fetchEmojiCached(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 18 | switch emoji.shortcode { 19 | case "wide": 20 | return EmojiImage(named: "wide") 21 | default: 22 | return EmojiImage(named: "async") 23 | } 24 | } 25 | 26 | func lazyEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 27 | EmojiImage(systemName: "exclamationmark.triangle")?.pngData() ?? Data() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/EmojiText/Render/EmojiTextRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiTextRenderer.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 01.02.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct EmojiTextRenderer { 12 | let emoji: RenderedEmoji 13 | 14 | func render(_ size: CGFloat?, at renderTime: CFTimeInterval) -> Text { 15 | // Surround the image with zero-width spaces to give the emoji a default height 16 | var text = Text("\u{200B}\(emoji.frame(at: renderTime))\u{200B}") 17 | 18 | if let baselineOffset = emoji.baselineOffset { 19 | text = text.baselineOffset(baselineOffset) 20 | } 21 | 22 | if let size { 23 | text = text.font(.system(size: size)) 24 | } 25 | 26 | return text.accessibilityLabel(emoji.shortcode) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "EmojiText", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v12), 11 | .tvOS(.v15), 12 | .watchOS(.v8), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "EmojiText", 18 | targets: ["EmojiText"] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/swiftlang/swift-markdown", from: "0.5.0") 23 | ], 24 | targets: [ 25 | .target( 26 | name: "EmojiText", 27 | dependencies: [ 28 | .product(name: "Markdown", package: "swift-markdown") 29 | ] 30 | ) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "EmojiText", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v12), 11 | .tvOS(.v15), 12 | .watchOS(.v8), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "EmojiText", 18 | targets: ["EmojiText"] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/swiftlang/swift-markdown", from: "0.5.0") 23 | ], 24 | targets: [ 25 | .target( 26 | name: "EmojiText", 27 | dependencies: [ 28 | .product(name: "Markdown", package: "swift-markdown") 29 | ] 30 | ) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Sources/EmojiText/Provider/Implementations/DefaultSyncEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultEmojiProvider.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 14.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DefaultSyncEmojiProvider: SyncEmojiProvider { 11 | // MARK: - SyncEmojiProvider 12 | 13 | func emojiImage(emoji: any SyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 14 | switch emoji { 15 | case let emoji as LocalEmoji: 16 | if let color = emoji.color { 17 | #if os(macOS) 18 | return emoji.image.withColor(color) 19 | #else 20 | return emoji.image.withTintColor(color, renderingMode: .alwaysTemplate) 21 | #endif 22 | } else { 23 | return emoji.image 24 | } 25 | default: 26 | return nil 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/EmojiText/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 19.02.23. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | extension Logger { 12 | #if swift(>=5.10) && compiler(<6.0) 13 | nonisolated(unsafe) static let text = Logger(subsystem: "at.davidwalter.EmojiText", category: "Text") 14 | nonisolated(unsafe) static let emojiText = Logger(subsystem: "at.davidwalter.EmojiText", category: "EmojiText") 15 | nonisolated(unsafe) static let animatedImage = Logger(subsystem: "at.davidwalter.EmojiText", category: "Animated Image") 16 | #else 17 | static let text = Logger(subsystem: "at.davidwalter.EmojiText", category: "Text") 18 | static let emojiText = Logger(subsystem: "at.davidwalter.EmojiText", category: "EmojiText") 19 | static let animatedImage = Logger(subsystem: "at.davidwalter.EmojiText", category: "Animated Image") 20 | #endif 21 | } 22 | -------------------------------------------------------------------------------- /Test/App/Provider/URLSessionEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionEmojiProvider.swift 3 | // Test 4 | // 5 | // Created by David Walter on 13.07.24. 6 | // 7 | 8 | import Foundation 9 | import EmojiText 10 | 11 | struct URLSessionEmojiProvider: AsyncEmojiProvider { 12 | private let session: URLSession 13 | 14 | init(session: URLSession) { 15 | self.session = session 16 | } 17 | 18 | // MARK: - AsyncEmojiProvider 19 | 20 | func lazyEmojiCached(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 21 | return nil 22 | } 23 | 24 | func lazyEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 25 | switch emoji { 26 | case let emoji as RemoteEmoji: 27 | let (data, _) = try await session.data(from: emoji.url) 28 | return data 29 | default: 30 | throw EmojiProviderError.unsupportedEmoji 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Test/App/SFSymbolEmojiView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SFSymbolEmojiView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 23.04.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct SFSymbolEmojiView: View { 12 | var emojis: [any CustomEmoji] { 13 | [ 14 | SFSymbolEmoji(shortcode: "iphone"), 15 | SFSymbolEmoji(shortcode: "person.fill.badge.plus", symbolRenderingMode: .multicolor) 16 | ] 17 | } 18 | 19 | var body: some View { 20 | EmojiTestView { 21 | EmojiText(verbatim: "iPhone :iphone:", emojis: emojis) 22 | EmojiText(verbatim: "Person :person.fill.badge.plus:", emojis: emojis) 23 | } 24 | .navigationTitle("SF Symbol Emoji") 25 | } 26 | } 27 | 28 | struct SFSymbolEmojiView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | NavigationStack { 31 | SFSymbolEmojiView() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/EmojiText/Domain/EmojiError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiError.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Internal errors for loading images 11 | internal enum EmojiError: LocalizedError { 12 | /// The data was corrupted. 13 | case invalidData 14 | /// The static fallback data was corrupted. 15 | case staticData 16 | /// The animated image data was corrupted. 17 | case animatedData 18 | /// The given image type is either not animated or in an unknown format. 19 | case notAnimated 20 | 21 | var errorDescription: String? { 22 | switch self { 23 | case .invalidData: 24 | return "The image data could not be read" 25 | case .staticData: 26 | return "The static fallback image could not be read" 27 | case .animatedData: 28 | return "The animated image data could not be read" 29 | case .notAnimated: 30 | return "The provided image is not animated or in an unknown format" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/AttributedString+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributedString+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 20.12.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension AttributedString { 11 | func distance(from start: AttributedString.Index, to end: AttributedString.Index) -> Int { 12 | characters.distance(from: start, to: end) 13 | } 14 | } 15 | 16 | extension AttributedString.Runs.Element { 17 | func emoji(from values: [String: RenderedEmoji]) -> RenderedEmoji? { 18 | guard let imageURL = attributes[AttributeScopes.FoundationAttributes.ImageURLAttribute.self] else { return nil } 19 | guard imageURL.scheme == String.emojiScheme else { return nil } 20 | if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { 21 | guard let host = imageURL.host(percentEncoded: false) else { return nil } 22 | return values[host] 23 | } else { 24 | guard let host = imageURL.host else { return nil } 25 | return values[host] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Test/App/LocalEmojiView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalEmojiView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 04.02.24. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct LocalEmojiView: View { 12 | var emojis: [any CustomEmoji] { 13 | [ 14 | LocalEmoji(shortcode: "original", image: EmojiImage(named: "Test")!), 15 | LocalEmoji(shortcode: "template", image: EmojiImage(named: "Test")!, renderingMode: .template), 16 | LocalEmoji(shortcode: "color", image: EmojiImage(named: "Test")!, color: .systemBlue) 17 | ] 18 | } 19 | 20 | var body: some View { 21 | EmojiTestView { 22 | EmojiText(verbatim: "Original :original:", emojis: emojis) 23 | EmojiText(verbatim: "Template :template:", emojis: emojis) 24 | EmojiText(verbatim: "Color :color:", emojis: emojis) 25 | } 26 | .navigationTitle("Local Emoji") 27 | } 28 | } 29 | 30 | struct LocalEmojiView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | NavigationStack { 33 | LocalEmojiView() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Test/App/Mastodon/MastodonAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastodonAPI.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 14.12.23. 6 | // 7 | 8 | import Foundation 9 | import Observation 10 | 11 | @Observable 12 | final class MastodonAPI: Sendable { 13 | private let baseURL: URL 14 | private let decoder: JSONDecoder 15 | 16 | init(host: String) { 17 | baseURL = URL(string: host)! 18 | let decoder = JSONDecoder() 19 | decoder.keyDecodingStrategy = .convertFromSnakeCase 20 | self.decoder = decoder 21 | } 22 | 23 | func loadStatus(id: String) async throws -> Status { 24 | let url = URL(string: "api/v1/statuses/\(id)", relativeTo: baseURL)! 25 | let (data, _) = try await URLSession.shared.data(from: url) 26 | let status = try decoder.decode(Status.self, from: data) 27 | return status 28 | } 29 | 30 | func loadCustomEmoji() async throws -> [Emoji] { 31 | let url = URL(string: "/api/v1/custom_emojis", relativeTo: baseURL)! 32 | let (data, _) = try await URLSession.shared.data(from: url) 33 | let status = try decoder.decode([Emoji].self, from: data) 34 | return status 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/EmojiText/Typealiases.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typealiases.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 12.02.23. 6 | // 7 | 8 | import Foundation 9 | 10 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(watchOS) || os(visionOS) 11 | import UIKit 12 | 13 | /// Platform indepdendent image alias. Will be `UIImage`. 14 | public typealias EmojiImage = UIImage 15 | typealias EmojiFont = UIFont 16 | /// Platform indepdendent color alias. Will be `UIColor`. 17 | public typealias EmojiColor = UIColor 18 | 19 | extension EmojiColor { 20 | static var placeholderEmoji: EmojiColor { 21 | #if os(watchOS) 22 | .gray 23 | #else 24 | .placeholderText 25 | #endif 26 | } 27 | } 28 | #elseif os(macOS) 29 | import AppKit 30 | 31 | /// Platform indepdendent image alias. Will be `NSImage`. 32 | public typealias EmojiImage = NSImage 33 | typealias EmojiFont = NSFont 34 | /// Platform indepdendent color alias. Will be `NSColor`. 35 | public typealias EmojiColor = NSColor 36 | 37 | extension EmojiColor { 38 | static var placeholderEmoji: EmojiColor { 39 | .placeholderTextColor 40 | } 41 | } 42 | #else 43 | #error("Unsupported platform") 44 | #endif 45 | -------------------------------------------------------------------------------- /Sources/EmojiText/Preview Content/Preview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preview.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.01.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if DEBUG 11 | // swiftlint:disable force_unwrapping 12 | extension [CustomEmoji] { 13 | static var emojis: [any CustomEmoji] { 14 | let remote = [ 15 | RemoteEmoji( 16 | shortcode: "a", 17 | url: URL(string: "https://dummyimage.com/64x64/0A6FFF/fff&text=A") 18 | ), 19 | RemoteEmoji( 20 | shortcode: "wide", 21 | url: URL(string: "https://dummyimage.com/256x64/DE3A3B/fff&text=wide") 22 | ) 23 | ] 24 | .compactMap { $0 } 25 | let local = [ 26 | SFSymbolEmoji(shortcode: "iphone") 27 | ] 28 | return remote + local 29 | } 30 | 31 | static var animatedEmojis: [any CustomEmoji] { 32 | [ 33 | RemoteEmoji( 34 | shortcode: "gif", 35 | url: URL(string: "https://ezgif.com/images/format-demo/butterfly.gif")! 36 | ) 37 | ] 38 | .compactMap { $0 } 39 | } 40 | } 41 | // swiftlint:enable force_unwrapping 42 | #endif 43 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Text+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Text+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 20.12.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Text { 11 | init?(_ attributedPartialstring: inout AttributedPartialstring) { 12 | self.init(attributedSubstrings: attributedPartialstring.consume()) 13 | } 14 | 15 | init?(attributedSubstrings: [AttributedSubstring]) { 16 | guard !attributedSubstrings.isEmpty else { 17 | return nil 18 | } 19 | 20 | let string = attributedSubstrings.reduce(AttributedString()) { partialResult, substring in 21 | partialResult + substring 22 | } 23 | 24 | self.init(string) 25 | } 26 | 27 | init(repating text: Text, count: Int) { 28 | self = Array(repeating: text, count: max(count, 1)).joined() 29 | } 30 | } 31 | 32 | extension [Text] { 33 | func joined() -> Text { 34 | guard var result = first else { 35 | return Text(verbatim: "") 36 | } 37 | 38 | for element in dropFirst() { 39 | result = result + element 40 | } 41 | 42 | return result 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/EmojiText/Render/Markdown/EmojiReplacer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiReplacer.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 30.01.24. 6 | // 7 | 8 | import Foundation 9 | import Markdown 10 | 11 | struct EmojiReplacer: MarkupRewriter { 12 | let emojis: [String: RenderedEmoji] 13 | 14 | mutating func visitText(_ text: Text) -> Markup? { 15 | var string = text.string 16 | for shortcode in emojis.keys { 17 | // Replace emojis with a Markdown image with a custom URL Scheme 18 | string = string.replacingOccurrences( 19 | of: ":\(shortcode):", 20 | // Inject `String.emojiSeparator` in order to be able to remove spaces between emojis 21 | with: "\(String.emojiSeparator)![\(shortcode)](\(String.emojiScheme)://\(shortcode))\(String.emojiSeparator)" 22 | ) 23 | } 24 | return Markdown.Text(string) 25 | } 26 | 27 | // MARK: - Workarounds 28 | 29 | /// `AttributedString` would simply add the language as part of the code block, therefore we remove it 30 | mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Markup? { 31 | CodeBlock(language: nil, codeBlock.code) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Test/Tests/Emojis.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Emojis.swift 3 | // Tests 4 | // 5 | // Created by David Walter on 18.02.23. 6 | // 7 | 8 | import Foundation 9 | import EmojiText 10 | import SwiftUI 11 | 12 | enum Emojis { 13 | static var async: RemoteEmoji { 14 | RemoteEmoji(shortcode: "async", url: URL(string: "https://dummyimage.com/64x64/0A6FFF/fff&text=A")!) 15 | } 16 | 17 | static var asyncWithOffset: RemoteEmoji { 18 | RemoteEmoji(shortcode: "async_offset", url: URL(string: "https://dummyimage.com/64x64/0A6FFF/fff&text=A")!, baselineOffset: -8) 19 | } 20 | 21 | static var iPhone: SFSymbolEmoji { 22 | SFSymbolEmoji(shortcode: "iphone") 23 | } 24 | 25 | static func iPhone(renderingMode: Image.TemplateRenderingMode? = nil) -> SFSymbolEmoji { 26 | SFSymbolEmoji(shortcode: "iphone", renderingMode: renderingMode) 27 | } 28 | 29 | static var multiple: [any CustomEmoji] { 30 | [ 31 | SFSymbolEmoji(shortcode: "face.smiling"), 32 | SFSymbolEmoji(shortcode: "face.dashed") 33 | ] 34 | } 35 | 36 | static var wide: RemoteEmoji { 37 | RemoteEmoji(shortcode: "wide", url: URL(string: "https://dummyimage.com/256x64/DE3A3B/fff&text=wide")!) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Test/App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/EmojiText/Provider/Implementations/DefaultAsyncEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultEmojiProvider.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 13.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct DefaultAsyncEmojiProvider: AsyncEmojiProvider { 11 | private let session: URLSession 12 | 13 | public init(session: URLSession = .shared) { 14 | self.session = session 15 | } 16 | 17 | // MARK: - AsyncEmojiProvider 18 | 19 | public func cachedEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) -> Data? { 20 | guard let cache = session.configuration.urlCache else { return nil } 21 | switch emoji { 22 | case let emoji as RemoteEmoji: 23 | let request = URLRequest(url: emoji.url) 24 | guard let response = cache.cachedResponse(for: request) else { return nil } 25 | return response.data 26 | default: 27 | return nil 28 | } 29 | } 30 | 31 | public func fetchEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 32 | switch emoji { 33 | case let emoji as RemoteEmoji: 34 | let (data, _) = try await session.data(from: emoji.url) 35 | return data 36 | default: 37 | throw EmojiProviderError.unsupportedEmoji 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/UIContentSizeCategory+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIContentSizeCategory+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import SwiftUI 9 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(visionOS) 10 | import UIKit 11 | 12 | extension UIContentSizeCategory { 13 | init(from value: DynamicTypeSize) { 14 | switch value { 15 | case .xSmall: 16 | self = .extraSmall 17 | case .small: 18 | self = .small 19 | case .medium: 20 | self = .medium 21 | case .large: 22 | self = .large 23 | case .xLarge: 24 | self = .extraLarge 25 | case .xxLarge: 26 | self = .extraExtraLarge 27 | case .xxxLarge: 28 | self = .extraExtraExtraLarge 29 | case .accessibility1: 30 | self = .accessibilityMedium 31 | case .accessibility2: 32 | self = .accessibilityLarge 33 | case .accessibility3: 34 | self = .accessibilityExtraLarge 35 | case .accessibility4: 36 | self = .accessibilityExtraExtraLarge 37 | case .accessibility5: 38 | self = .accessibilityExtraExtraExtraLarge 39 | @unknown default: 40 | self = .large 41 | } 42 | } 43 | } 44 | #endif 45 | -------------------------------------------------------------------------------- /Test/App/RemoteEmojiView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteEmoji.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 23.04.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct RemoteEmojiView: View { 12 | var emojis: [any CustomEmoji] { 13 | [ 14 | RemoteEmoji( 15 | shortcode: "a", 16 | url: URL(string: "https://dummyimage.com/64x64/0A6FFF/fff&text=A")! 17 | ), 18 | RemoteEmoji( 19 | shortcode: "wide", 20 | url: URL(string: "https://dummyimage.com/256x64/DE3A3B/fff&text=wide")! 21 | ), 22 | RemoteEmoji( 23 | shortcode: "never", 24 | url: URL(string: "https://github.com/divadretlaw/EmojiText/Package.swift")! 25 | ), 26 | SFSymbolEmoji(shortcode: "iphone") 27 | ] 28 | } 29 | 30 | var body: some View { 31 | EmojiTestView { 32 | EmojiText(verbatim: "Hello Emoji :a:", emojis: emojis) 33 | EmojiText(verbatim: "Hello Wide :wide:", emojis: emojis) 34 | EmojiText(verbatim: "Hello Never :never:", emojis: emojis) 35 | } 36 | .navigationTitle("Remote Emoji") 37 | } 38 | } 39 | 40 | struct RemoteEmojiView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | NavigationStack { 43 | RemoteEmojiView() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Test/App/Provider/UpsideDownEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpsideDownEmojiProvider.swift 3 | // Test 4 | // 5 | // Created by David Walter on 14.07.24. 6 | // 7 | 8 | import Foundation 9 | import EmojiText 10 | #if canImport(UIKit) 11 | import UIKit 12 | #endif 13 | 14 | struct UpsideDownEmojiProvider: SyncEmojiProvider { 15 | func emojiImage(emoji: any SyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 16 | switch emoji { 17 | case let emoji as LocalEmoji: 18 | #if canImport(UIKit) 19 | return emoji.image.upsideDown() 20 | #else 21 | return emoji.image 22 | #endif 23 | default: 24 | return nil 25 | } 26 | } 27 | } 28 | 29 | #if canImport(UIKit) 30 | private extension UIImage { 31 | func upsideDown() -> UIImage? { 32 | UIGraphicsBeginImageContextWithOptions(size, false, scale) 33 | guard let context = UIGraphicsGetCurrentContext() else { return nil } 34 | 35 | context.translateBy(x: size.width / 2, y: size.height / 2) 36 | context.scaleBy(x: 1.0, y: -1.0) 37 | context.translateBy(x: -size.width / 2, y: -size.height / 2) 38 | 39 | draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) 40 | 41 | let newImage = UIGraphicsGetImageFromCurrentImageContext() 42 | UIGraphicsEndImageContext() 43 | return newImage 44 | } 45 | } 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/Protocols/CustomEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom emoji 12 | public protocol CustomEmoji: Hashable, Equatable, Identifiable, Sendable { 13 | associatedtype ID = String 14 | 15 | /// Shortcode of the emoji 16 | var shortcode: String { get } 17 | /// The mode SwiftUI uses to render this emoji 18 | var renderingMode: Image.TemplateRenderingMode? { get } 19 | /// The symbol rendering mode to use for this emoji 20 | var symbolRenderingMode: SymbolRenderingMode? { get } 21 | /// The emoji baseline offset 22 | var baselineOffset: CGFloat? { get } 23 | } 24 | 25 | // MARK: - Default Implementations 26 | 27 | // swiftlint:disable missing_docs 28 | public extension CustomEmoji { 29 | var renderingMode: Image.TemplateRenderingMode? { nil } 30 | var symbolRenderingMode: SymbolRenderingMode? { nil } 31 | var baselineOffset: CGFloat? { nil } 32 | 33 | // MARK: Hashable 34 | 35 | func hash(into hasher: inout Hasher) { 36 | hasher.combine(shortcode) 37 | } 38 | 39 | // MARK: Equatable 40 | 41 | static func == (lhs: Self, rhs: Self) -> Bool { 42 | lhs.id == rhs.id 43 | } 44 | 45 | // MARK: Identifiable 46 | 47 | var id: String { shortcode } 48 | } 49 | // swiftlint:enable missing_docs 50 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/CGImageSource+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGImageSource+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 23.08.23. 6 | // 7 | 8 | import Foundation 9 | import ImageIO 10 | 11 | extension CGImageSource { 12 | func properties(for index: Int, key: CFString) -> [String: Any]? { 13 | guard let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as? [String: Any] else { 14 | return nil 15 | } 16 | 17 | return properties[key as String] as? [String: Any] 18 | } 19 | 20 | func delay(for index: Int, type: AnimatedImageType) -> Double { 21 | guard let properties = properties(for: index, key: type.propertiesKey) else { 22 | return 0 23 | } 24 | 25 | guard let delayObject = properties[type.unclampedDelayTimeKey as String] as? AnyObject, 26 | let value = delayObject.doubleValue, 27 | value > 0 else { 28 | return (properties[type.delayTimeKey as String] as? AnyObject)?.doubleValue ?? 0 29 | } 30 | 31 | return value 32 | } 33 | 34 | func containsAnimatedKeys(for type: AnimatedImageType) -> Bool { 35 | guard let properties = properties(for: 0, key: type.propertiesKey) else { 36 | return false 37 | } 38 | 39 | return properties.keys.contains(type.unclampedDelayTimeKey as String) 40 | || properties.keys.contains(type.delayTimeKey as String) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Test/App/ChangingRemoteEmojiView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChangingRemoteEmojiView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 16.07.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct ChangingRemoteEmojiView: View { 12 | @State private var emojis: [any CustomEmoji] = [ 13 | RemoteEmoji(shortcode: "custom", url: URL(string: "https://dummyimage.com/64x64/00f/fff&text=A")!), 14 | ] 15 | 16 | var body: some View { 17 | List { 18 | Section { 19 | EmojiText(verbatim: "Custom Emoji :custom:", emojis: emojis) 20 | } 21 | 22 | Section { 23 | Button { 24 | self.emojis = [ 25 | RemoteEmoji(shortcode: "custom", url: URL(string: "https://dummyimage.com/64x64/00f/fff&text=A")!) 26 | ] 27 | } label: { 28 | Text("Set A") 29 | } 30 | 31 | Button { 32 | self.emojis = [ 33 | RemoteEmoji(shortcode: "custom", url: URL(string: "https://dummyimage.com/64x64/f00/000&text=B")!) 34 | ] 35 | } label: { 36 | Text("Set B") 37 | } 38 | } 39 | } 40 | .navigationTitle("Changing Remote Emoji") 41 | } 42 | } 43 | 44 | struct ChangingRemoteEmojiView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | ChangingRemoteEmojiView() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static var emojiScheme: String { 12 | "custom-emoji" 13 | } 14 | 15 | static var emojiSeparator: String { 16 | "" 17 | } 18 | 19 | /// Split the text on the injected emoji separator 20 | /// 21 | /// - Parameter omittingSpacesBetweenEmojis: Remove any spaces between emojis. Defaults to `true`, 22 | /// - Returns: The split text with every emoji separated 23 | func splitOnEmoji(omittingSpacesBetweenEmojis: Bool = true) -> [String] { 24 | let splits: [String] 25 | if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { 26 | splits = self 27 | .split(separator: String.emojiSeparator, omittingEmptySubsequences: true) 28 | .map { String($0) } 29 | } else { 30 | splits = components(separatedBy: String.emojiSeparator) 31 | .filter { !$0.isEmpty } 32 | } 33 | 34 | if omittingSpacesBetweenEmojis { 35 | // Remove any spaces between emojis 36 | // This will often drastically reduce the amount of text contactenations 37 | // needed when rendering the emojis. If we reach around ~500 or more the render would crash 38 | return splits.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } 39 | } else { 40 | return splits 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Test/App/Provider/NukeEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NukeEmojiProvider.swift 3 | // Test 4 | // 5 | // Created by David Walter on 19.04.25. 6 | // 7 | 8 | import Foundation 9 | import EmojiText 10 | import Nuke 11 | 12 | struct NukeEmojiProvider: AsyncEmojiProvider { 13 | private let pipeline: ImagePipeline 14 | 15 | init(pipeline: ImagePipeline = .shared) { 16 | self.pipeline = pipeline 17 | } 18 | 19 | // MARK: - AsyncEmojiProvider 20 | 21 | func cachedEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 22 | switch emoji { 23 | case let emoji as RemoteEmoji: 24 | let request = request(for: emoji, height: height) 25 | guard let container = pipeline.cache[request] else { return nil } 26 | return container.image 27 | default: 28 | return nil 29 | } 30 | } 31 | 32 | func fetchEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 33 | switch emoji { 34 | case let emoji as RemoteEmoji: 35 | let request = request(for: emoji, height: height) 36 | let (data, _) = try await pipeline.data(for: request) 37 | return data 38 | default: 39 | throw EmojiProviderError.unsupportedEmoji 40 | } 41 | } 42 | 43 | // MARK: - Helper 44 | 45 | private func request(for emoji: RemoteEmoji, height: CGFloat?) -> ImageRequest { 46 | if let height { 47 | ImageRequest(url: emoji.url, processors: [.resize(height: height)]) 48 | } else { 49 | ImageRequest(url: emoji.url) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Test/App/EmojiTestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 23.04.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct EmojiTestView: View where Content: View { 12 | @ViewBuilder var content: () -> Content 13 | 14 | var body: some View { 15 | List { 16 | Section { 17 | sectionContent 18 | } header: { 19 | Text("Default") 20 | } 21 | 22 | Section { 23 | sectionContent 24 | } header: { 25 | Text("Emoji BaselineOffset = -20") 26 | } 27 | .emojiText.baselineOffset(-20) 28 | 29 | Section { 30 | sectionContent 31 | } header: { 32 | Text("Emoji BaselineOffset = 20") 33 | } 34 | .emojiText.baselineOffset(-20) 35 | 36 | Section { 37 | sectionContent 38 | } header: { 39 | Text("Emoji Size = 30") 40 | } 41 | .emojiText.size(30) 42 | 43 | Section { 44 | sectionContent 45 | } header: { 46 | Text("Emoji Size = 10") 47 | } 48 | .emojiText.size(10) 49 | } 50 | } 51 | 52 | @ViewBuilder 53 | var sectionContent: some View { 54 | Group { 55 | content() 56 | } 57 | 58 | Group { 59 | content() 60 | } 61 | .font(.title) 62 | 63 | Group { 64 | content() 65 | } 66 | .font(.caption2) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Test/App/AnimatedEmojiView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedEmojiView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct AnimatedEmojiView: View { 12 | var emojis: [any CustomEmoji] { 13 | [ 14 | RemoteEmoji(shortcode: "webp", url: URL(string: "https://ezgif.com/images/format-demo/butterfly-small.webp")!), 15 | RemoteEmoji(shortcode: "apng", url: URL(string: "https://ezgif.com/images/format-demo/butterfly.png")!), 16 | RemoteEmoji(shortcode: "gif", url: URL(string: "https://ezgif.com/images/format-demo/butterfly.gif")!) 17 | ] 18 | } 19 | 20 | @State private var isAnimating = true 21 | 22 | var body: some View { 23 | EmojiTestView { 24 | EmojiText(verbatim: "WebP :webp:", emojis: emojis) 25 | .animated(isAnimating) 26 | EmojiText(verbatim: "APNG :apng:", emojis: emojis) 27 | .animated(isAnimating) 28 | EmojiText(verbatim: "GIF :gif:", emojis: emojis) 29 | .animated(isAnimating) 30 | } 31 | .navigationTitle("Animated Emoji") 32 | .toolbar { 33 | ToolbarItem(placement: .primaryAction) { 34 | Button { 35 | isAnimating.toggle() 36 | } label: { 37 | if isAnimating { 38 | Image(systemName: "pause") 39 | } else { 40 | Image(systemName: "play") 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | struct AnimatedEmojiView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | AnimatedEmojiView() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/SFSymbolEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SFSymbolEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 12.02.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom emoji from SF Symbols 12 | public struct SFSymbolEmoji: CustomEmoji { 13 | /// Shortcode of the SF Symbol 14 | public let shortcode: String 15 | /// The mode SwiftUI uses to render this emoji 16 | public let renderingMode: Image.TemplateRenderingMode? 17 | /// The symbol rendering mode to use for this emoji 18 | public let symbolRenderingMode: SymbolRenderingMode? 19 | 20 | /// Initialize a SF Symbol custom emoji 21 | /// 22 | /// - Parameters: 23 | /// - shortcode: The SF Symbol code of the emoji 24 | /// - symbolRenderingMode: The symbol rendering mode to use for this emoji 25 | /// - renderingMode: The mode SwiftUI uses to render this emoji 26 | public init(shortcode: String, symbolRenderingMode: SymbolRenderingMode? = nil, renderingMode: Image.TemplateRenderingMode? = nil) { 27 | self.shortcode = shortcode 28 | self.symbolRenderingMode = symbolRenderingMode 29 | self.renderingMode = renderingMode 30 | } 31 | 32 | static var fallback: Self { 33 | SFSymbolEmoji(shortcode: "square.dashed", symbolRenderingMode: .monochrome, renderingMode: .template) 34 | } 35 | 36 | // MARK: - Hashable 37 | 38 | public func hash(into hasher: inout Hasher) { 39 | hasher.combine(shortcode) 40 | hasher.combine(renderingMode) 41 | } 42 | } 43 | 44 | extension EmojiImage { 45 | static func from(emoji: SFSymbolEmoji) -> EmojiImage { 46 | EmojiImage(systemName: emoji.shortcode) 47 | ?? EmojiImage(systemName: SFSymbolEmoji.fallback.shortcode) 48 | ?? EmojiImage() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/EmojiText/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``EmojiText`` 2 | 3 | Display text with custom emojis in the format `:emoji:` 4 | 5 | ## Overview 6 | 7 | Remote emoji 8 | 9 | ```swift 10 | EmojiText(verbatim: "Hello :my_emoji:", 11 | emojis: [RemoteEmoji(shortcode: "my_emoji", url: /* URL to emoji */)]) 12 | ``` 13 | 14 | Local emoji 15 | 16 | ```swift 17 | EmojiText(verbatim: "Hello :my_emoji:", 18 | emojis: [LocalEmoji(shortcode: "my_emoji", image: /* some UIImage or NSImage */)]) 19 | ``` 20 | 21 | SF Symbol 22 | 23 | ```swift 24 | EmojiText(verbatim: "Hello Moon & Starts :moon.stars:", 25 | emojis: [SFSymbolEmoji(shortcode: "moon.stars")]) 26 | ``` 27 | 28 | ### Markdown 29 | 30 | Also supports Markdown 31 | 32 | ```swift 33 | EmojiText(markdown: "**Hello** *World* :my_emoji:", 34 | emojis: [RemoteEmoji(shortcode: "my_emoji", url: /* URL to emoji */)]) 35 | ``` 36 | 37 | ### Animated Emoji 38 | 39 | > Warning: 40 | > This feature is in beta and therefore is opt-in only. Performance may vary. 41 | 42 | Enable animation by setting adding the `.animated()` modifier to `EmojiText`. 43 | 44 | ```swift 45 | EmojiText(verbatim: "GIF :my_gif:", 46 | emojis: [RemoteEmoji(shortcode: "my_gif", url: /* URL to gif */)]) 47 | .animated() 48 | ``` 49 | 50 | Supported formats: 51 | 52 | - APNG 53 | - GIF 54 | - WebP 55 | 56 | > Info: 57 | > The animation will automatically pause when using low-power mode. To always play animations, even in low-power mode set the animation mode to ``AnimatedEmojiMode/always`` 58 | > 59 | > ```swift 60 | > EmojiText(verbatim: "GIF :my_gif:", 61 | > emojis: [RemoteEmoji(shortcode: "my_gif", url: /* URL to gif */)]) 62 | > .animated() 63 | > .environment(\.emojiText.animatedMode, .always) 64 | > ``` 65 | 66 | ## Topics 67 | 68 | ### Configuration 69 | 70 | - 71 | - 72 | - 73 | -------------------------------------------------------------------------------- /Sources/EmojiText/CADisplayLinkPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CADisplayLinkPublisher.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(visionOS) 11 | extension CADisplayLink { 12 | struct CADisplayLinkPublisher { 13 | let mode: RunLoop.Mode 14 | let stopOnLowPowerMode: Bool 15 | 16 | init(mode: RunLoop.Mode, stopOnLowPowerMode: Bool) { 17 | self.mode = mode 18 | self.stopOnLowPowerMode = stopOnLowPowerMode 19 | } 20 | 21 | var targetTimestamps: AsyncStream { 22 | AsyncStream { continuation in 23 | let displayLink = DisplayLink(mode: mode) { displayLink in 24 | if stopOnLowPowerMode, ProcessInfo.processInfo.isLowPowerModeEnabled { 25 | // Do not yield information on low-power mode 26 | } else { 27 | continuation.yield(displayLink.targetTimestamp) 28 | } 29 | } 30 | 31 | continuation.onTermination = { _ in 32 | displayLink.stop() 33 | } 34 | } 35 | } 36 | } 37 | 38 | static func publish(mode: RunLoop.Mode, stopOnLowPowerMode: Bool) -> CADisplayLinkPublisher { 39 | CADisplayLinkPublisher(mode: mode, stopOnLowPowerMode: stopOnLowPowerMode) 40 | } 41 | } 42 | 43 | private final class DisplayLink: NSObject, @unchecked Sendable { 44 | private var displayLink: CADisplayLink! 45 | private let handler: (CADisplayLink) -> Void 46 | 47 | init(mode: RunLoop.Mode, handler: @Sendable @escaping (CADisplayLink) -> Void) { 48 | self.handler = handler 49 | super.init() 50 | 51 | displayLink = CADisplayLink(target: self, selector: #selector(handle(displayLink:))) 52 | displayLink.add(to: .main, forMode: mode) 53 | } 54 | 55 | func stop() { 56 | displayLink.invalidate() 57 | } 58 | 59 | @objc func handle(displayLink: CADisplayLink) { 60 | handler(displayLink) 61 | } 62 | } 63 | #endif 64 | -------------------------------------------------------------------------------- /Sources/EmojiText/Render/VerbatimEmojiRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbatimEmojiRenderer.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import OSLog 10 | 11 | struct VerbatimEmojiRenderer: EmojiRenderer { 12 | let shouldOmitSpacesBetweenEmojis: Bool 13 | 14 | func render(string: String, emojis: [String: RenderedEmoji], size: CGFloat?) -> Text { 15 | renderAnimated(string: string, emojis: emojis, size: size, at: 0) 16 | } 17 | 18 | func renderAnimated(string: String, emojis: [String: RenderedEmoji], size: CGFloat?, at time: CFTimeInterval) -> Text { 19 | let string = renderString(from: string, with: emojis) 20 | 21 | var result = Text(verbatim: "") 22 | 23 | let splits = string.splitOnEmoji(omittingSpacesBetweenEmojis: shouldOmitSpacesBetweenEmojis) 24 | for substring in splits { 25 | if let emoji = emojis[substring] { 26 | // If the part is an emoji we render it as an inline image 27 | let text = EmojiTextRenderer(emoji: emoji).render(size, at: time) 28 | result = result + text 29 | } else { 30 | // Otherwise we just render the part as String 31 | result = result + Text(verbatim: substring) 32 | } 33 | } 34 | 35 | return result 36 | } 37 | 38 | private func renderString(from string: String, with emojis: [String: RenderedEmoji]) -> String { 39 | var text = string 40 | 41 | for shortcode in emojis.keys { 42 | text = text.replacingOccurrences(of: ":\(shortcode):", with: "\(String.emojiSeparator)\(shortcode)\(String.emojiSeparator)") 43 | } 44 | 45 | return text 46 | } 47 | } 48 | 49 | #if DEBUG 50 | #Preview { 51 | List { 52 | EmojiText( 53 | verbatim: "Hello :a:", 54 | emojis: .emojis 55 | ) 56 | EmojiText( 57 | verbatim: "World :wide:", 58 | emojis: .emojis 59 | ) 60 | EmojiText( 61 | verbatim: "Hello World :test:", 62 | emojis: .emojis 63 | ) 64 | } 65 | } 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/LocalEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom local emoji 12 | public struct LocalEmoji: SyncCustomEmoji { 13 | /// Shortcode of the emoji 14 | public let shortcode: String 15 | /// The image representing the emoji 16 | private let _image: Lock 17 | /// The color to render the emoji with 18 | /// 19 | /// Set `nil` if you don't want to override the color. 20 | public let color: EmojiColor? 21 | /// The mode SwiftUI uses to render this emoji 22 | public let renderingMode: Image.TemplateRenderingMode? 23 | /// The emoji baseline offset 24 | public let baselineOffset: CGFloat? 25 | 26 | /// Initialize a local custom emoji 27 | /// 28 | /// - Parameters: 29 | /// - shortcode: The shortcode of the emoji 30 | /// - image: The image containing the emoji 31 | /// - color: Override the color to render the emoji with 32 | /// - renderingMode: The mode SwiftUI uses to render this emoji 33 | /// - baselineOffset: The baseline offset to use when rendering this emoji 34 | public init(shortcode: String, image: EmojiImage, color: EmojiColor? = nil, renderingMode: Image.TemplateRenderingMode? = nil, baselineOffset: CGFloat? = nil) { 35 | self.shortcode = shortcode 36 | self._image = Lock(image) 37 | self.renderingMode = renderingMode 38 | self.baselineOffset = baselineOffset 39 | self.color = color 40 | } 41 | 42 | public var image: EmojiImage { 43 | _image.wrappedValue 44 | } 45 | 46 | // MARK: - Hashable 47 | 48 | public func hash(into hasher: inout Hasher) { 49 | hasher.combine(shortcode) 50 | hasher.combine(image) 51 | hasher.combine(renderingMode) 52 | hasher.combine(baselineOffset) 53 | hasher.combine(color) 54 | } 55 | 56 | // MARK: - Equatable 57 | 58 | public static func == (lhs: Self, rhs: Self) -> Bool { 59 | guard lhs.shortcode == rhs.shortcode else { return false } 60 | return lhs.image == rhs.image 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Test/Test.xcodeproj/xcshareddata/xcschemes/SnapshotTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 18 | 19 | 20 | 21 | 23 | 29 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 53 | 54 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Sources/EmojiText/Domain/AnimatedImageType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedImageType.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import Foundation 9 | import ImageIO 10 | 11 | enum AnimatedImageType: CaseIterable, Sendable { 12 | case gif 13 | case apng 14 | case webp 15 | 16 | init?(from data: Data) { 17 | let magicType = Self.allCases.first { type in 18 | let magicBytes = type.magicBytes 19 | return data.readBytes(count: magicBytes.count) == magicBytes 20 | } 21 | guard let type = magicType else { return nil } 22 | self = type 23 | } 24 | 25 | var magicBytes: [UInt8] { 26 | switch self { 27 | case .gif: 28 | return [0x47, 0x49, 0x46] 29 | case .apng: 30 | return [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] 31 | case .webp: 32 | return [0x52, 0x49, 0x46, 0x46] 33 | } 34 | } 35 | 36 | var propertiesKey: CFString { 37 | switch self { 38 | case .gif: 39 | return kCGImagePropertyGIFDictionary 40 | case .apng: 41 | return kCGImagePropertyPNGDictionary 42 | case .webp: 43 | return kCGImagePropertyWebPDictionary 44 | } 45 | } 46 | 47 | var checkKey: CFString { 48 | switch self { 49 | case .gif: 50 | return kCGImagePropertyGIFLoopCount 51 | case .apng: 52 | return kCGImagePropertyGIFLoopCount 53 | case .webp: 54 | return kCGImagePropertyGIFLoopCount 55 | } 56 | } 57 | 58 | var delayTimeKey: CFString { 59 | switch self { 60 | case .gif: 61 | return kCGImagePropertyGIFDelayTime 62 | case .apng: 63 | return kCGImagePropertyAPNGDelayTime 64 | case .webp: 65 | return kCGImagePropertyWebPDelayTime 66 | } 67 | } 68 | 69 | var unclampedDelayTimeKey: CFString { 70 | switch self { 71 | case .gif: 72 | return kCGImagePropertyGIFUnclampedDelayTime 73 | case .apng: 74 | return kCGImagePropertyAPNGUnclampedDelayTime 75 | case .webp: 76 | return kCGImagePropertyWebPUnclampedDelayTime 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/RemoteEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// A custom remote emoji 12 | public struct RemoteEmoji: AsyncCustomEmoji { 13 | /// Shortcode of the emoji 14 | public let shortcode: String 15 | /// Remote location of the emoji 16 | public let url: URL 17 | /// The mode SwiftUI uses to render this emoji 18 | public let renderingMode: Image.TemplateRenderingMode? 19 | /// The emoji baseline offset 20 | public let baselineOffset: CGFloat? 21 | 22 | /// Initialize a remote custom emoji 23 | /// 24 | /// - Parameters: 25 | /// - shortcode: The shortcode of the emoji 26 | /// - url: The remote location of the emoji 27 | /// - renderingMode: The mode SwiftUI uses to render this emoji 28 | /// - baselineOffset: The baseline offset to use when rendering this emoji 29 | public init(shortcode: String, url: URL, renderingMode: Image.TemplateRenderingMode? = nil, baselineOffset: CGFloat? = nil) { 30 | self.shortcode = shortcode 31 | self.url = url 32 | self.renderingMode = renderingMode 33 | self.baselineOffset = baselineOffset 34 | } 35 | 36 | /// Initialize a remote custom emoji 37 | /// 38 | /// - Parameters: 39 | /// - shortcode: The shortcode of the emoji 40 | /// - url: The remote location of the emoji 41 | /// - renderingMode: The mode SwiftUI uses to render this emoji 42 | /// - baselineOffset: The baseline offset to use when rendering this emoji 43 | public init?(shortcode: String, url: URL?, renderingMode: Image.TemplateRenderingMode? = nil, baselineOffset: CGFloat? = nil) { 44 | guard let url else { return nil } 45 | self.init(shortcode: shortcode, url: url, renderingMode: renderingMode, baselineOffset: baselineOffset) 46 | } 47 | 48 | // MARK: - Hashable 49 | 50 | public func hash(into hasher: inout Hasher) { 51 | hasher.combine(shortcode) 52 | hasher.combine(url) 53 | hasher.combine(renderingMode) 54 | hasher.combine(baselineOffset) 55 | } 56 | 57 | // MARK: - Equatable 58 | 59 | public static func == (lhs: Self, rhs: Self) -> Bool { 60 | guard lhs.shortcode == rhs.shortcode else { return false } 61 | return lhs.url == rhs.url 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/EmojiText/Environment/Environment+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment+Helpers.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 04.02.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension EmojiTextNamespace where Content: View { 11 | /// Set the placeholder emoji 12 | /// 13 | /// - Parameters: 14 | /// - systemName: The SF Symbol code of the emoji 15 | /// - symbolRenderingMode: The symbol rendering mode to use for this emoji 16 | /// - renderingMode: The mode SwiftUI uses to render this emoji 17 | func placeholder(systemName: String, symbolRenderingMode: SymbolRenderingMode? = nil, renderingMode: Image.TemplateRenderingMode? = nil) -> some View { 18 | content.environment(\.emojiText.placeholder, SFSymbolEmoji(shortcode: systemName, symbolRenderingMode: symbolRenderingMode, renderingMode: renderingMode)) 19 | } 20 | 21 | /// Set the placeholder emoji 22 | /// 23 | /// - Parameters: 24 | /// - image: The image to use as placeholder 25 | /// - renderingMode: The mode SwiftUI uses to render this emoji 26 | func placeholder(image: EmojiImage, renderingMode: Image.TemplateRenderingMode? = nil) -> some View { 27 | content.environment(\.emojiText.placeholder, LocalEmoji(shortcode: "placeholder", image: image, renderingMode: renderingMode)) 28 | } 29 | 30 | /// Set the size of the inline custom emojis 31 | /// 32 | /// - Parameter size: The size to render the custom emojis in 33 | /// 34 | /// While ``EmojiText`` tries to determine the size of the emoji based on the current font and dynamic type size 35 | /// this only works with the system text styles, this is due to limitations of `SwiftUI.Font`. 36 | /// In case you use a custom font or want to override the calculation of the emoji size for some other reason 37 | /// you can provide a emoji size 38 | func size(_ size: CGFloat?) -> some View { 39 | content.environment(\.emojiText.size, size) 40 | } 41 | 42 | /// Overrides the baseline for custom emojis 43 | /// 44 | /// - Parameter offset: The size to render the custom emojis in 45 | /// 46 | /// While ``EmojiText`` tries to determine the baseline offset of the emoji based on the current font and dynamic type size 47 | /// this only works with the system text styles, this is due to limitations of `SwiftUI.Font`. 48 | /// In case you use a custom font or want to override the calculation of the emoji baseline offset for some other reason 49 | /// you can provide a emoji baseline offset 50 | func baselineOffset(_ offset: CGFloat?) -> some View { 51 | content.environment(\.emojiText.baselineOffset, offset) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swift 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 3 | 4 | ### Swift ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | Package.resolved 46 | # *.xcodeproj 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # Pods/ 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 | # Carthage/Checkouts 64 | 65 | Carthage/Build/ 66 | 67 | # Accio dependency management 68 | Dependencies/ 69 | .accio/ 70 | 71 | # fastlane 72 | # It is recommended to not store the screenshots in the git repo. 73 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 74 | # For more information about the recommended setup visit: 75 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 76 | 77 | fastlane/report.xml 78 | fastlane/Preview.html 79 | fastlane/screenshots/**/*.png 80 | fastlane/test_output 81 | 82 | # Code Injection 83 | # After new code Injection tools there's a generated folder /iOSInjectionProject 84 | # https://github.com/johnno1962/injectionforxcode 85 | 86 | iOSInjectionProject/ 87 | 88 | # End of https://www.toptal.com/developers/gitignore/api/swift 89 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/RawImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RawImage.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 23.08.23. 6 | // 7 | 8 | import Foundation 9 | import ImageIO 10 | import OSLog 11 | 12 | /// A wrapper arround ``EmojiImage`` to support animated images on all platforms. 13 | struct RawImage: Sendable { 14 | private let _static: Lock 15 | private let _frames: Lock<[EmojiImage]?> 16 | /// The time interval for displaying an animated image. 17 | /// 18 | /// For a non-animated image, the value of this property is 0.0. 19 | let duration: TimeInterval 20 | 21 | init?(frames: [EmojiImage], duration: TimeInterval) { 22 | guard let image = frames.first else { return nil } 23 | 24 | let lock = NSLock() 25 | self._static = Lock(image, lock: lock) 26 | self._frames = Lock(frames, lock: lock) 27 | self.duration = duration 28 | } 29 | 30 | init(image: EmojiImage) { 31 | let lock = NSLock() 32 | self._static = Lock(image, lock: lock) 33 | self._frames = Lock(nil, lock: lock) 34 | self.duration = 0 35 | } 36 | 37 | init(static data: Data) throws { 38 | if let image = EmojiImage(data: data) { 39 | self = RawImage(image: image) 40 | } else { 41 | throw EmojiError.invalidData 42 | } 43 | } 44 | 45 | init(animated data: Data) throws { 46 | do { 47 | guard let type = AnimatedImageType(from: data) else { 48 | throw EmojiError.notAnimated 49 | } 50 | 51 | guard let source = CGImageSourceCreateWithData(data as CFData, nil), source.containsAnimatedKeys(for: type) else { 52 | throw EmojiError.animatedData 53 | } 54 | 55 | if let image = EmojiImage.animatedImage(from: source, type: type) { 56 | self = image 57 | } else { 58 | throw EmojiError.animatedData 59 | } 60 | } catch { 61 | // In case an error occurs while loading the animated image 62 | // we fall back to a static image 63 | if let image = EmojiImage(data: data) { 64 | self = RawImage(image: image) 65 | } else { 66 | Logger.animatedImage.warning("Unable to decode animated image: \(error.localizedDescription).") 67 | throw EmojiError.staticData 68 | } 69 | } 70 | } 71 | 72 | /// A static representation of the animated image 73 | var `static`: EmojiImage { 74 | _static.wrappedValue 75 | } 76 | 77 | /// The complete array of image objects that compose the animation of an animated object. 78 | /// 79 | /// For a non-animated image, the value of this property is nil. 80 | var frames: [EmojiImage]? { 81 | _frames.wrappedValue 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Test/Test.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "03fbb67d6b95caed1d2947c56b5b0c6335532d0e0acdaafff9ca1a49d9f64e6d", 3 | "pins" : [ 4 | { 5 | "identity" : "html2markdown", 6 | "kind" : "remoteSourceControl", 7 | "location" : "git@github.com:divadretlaw/HTML2Markdown.git", 8 | "state" : { 9 | "revision" : "fe37711094b261d9fceeaf021143002ba37e563e", 10 | "version" : "3.0.2" 11 | } 12 | }, 13 | { 14 | "identity" : "nuke", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/kean/Nuke", 17 | "state" : { 18 | "revision" : "0ead44350d2737db384908569c012fe67c421e4d", 19 | "version" : "12.8.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-cmark", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/swiftlang/swift-cmark.git", 26 | "state" : { 27 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", 28 | "version" : "0.6.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-custom-dump", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 35 | "state" : { 36 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 37 | "version" : "1.3.3" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-markdown", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/swiftlang/swift-markdown", 44 | "state" : { 45 | "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", 46 | "version" : "0.6.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-snapshot-testing", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 53 | "state" : { 54 | "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", 55 | "version" : "1.18.3" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-syntax", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swiftlang/swift-syntax", 62 | "state" : { 63 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 64 | "version" : "601.0.1" 65 | } 66 | }, 67 | { 68 | "identity" : "swiftsoup", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/scinfu/SwiftSoup", 71 | "state" : { 72 | "revision" : "bba848db50462894e7fc0891d018dfecad4ef11e", 73 | "version" : "2.8.7" 74 | } 75 | }, 76 | { 77 | "identity" : "xctest-dynamic-overlay", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 80 | "state" : { 81 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 82 | "version" : "1.5.2" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/RenderedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RenderedImage.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 15.08.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RenderedImage: Hashable, Equatable, @unchecked Sendable { 11 | private let systemName: String? 12 | private let platformImage: EmojiImage? 13 | private let animationImages: [EmojiImage]? 14 | private let duration: TimeInterval 15 | 16 | init(image: EmojiImage, animated: Bool, targetHeight: CGFloat) { 17 | self.systemName = nil 18 | self.platformImage = image.scalePreservingAspectRatio(targetHeight: targetHeight) 19 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(watchOS) || os(visionOS) 20 | if animated { 21 | self.animationImages = image.images?.map { $0.scalePreservingAspectRatio(targetHeight: targetHeight) } 22 | } else { 23 | self.animationImages = nil 24 | } 25 | self.duration = image.duration 26 | #else 27 | // No support for animated images on this platform 28 | self.animationImages = nil 29 | self.duration = 0 30 | #endif 31 | } 32 | 33 | init(image: RawImage, animated: Bool, targetHeight: CGFloat) { 34 | self.systemName = nil 35 | self.platformImage = image.static.scalePreservingAspectRatio(targetHeight: targetHeight) 36 | if animated { 37 | self.animationImages = image.frames?.map { $0.scalePreservingAspectRatio(targetHeight: targetHeight) } 38 | } else { 39 | self.animationImages = nil 40 | } 41 | self.duration = image.duration 42 | } 43 | 44 | init(systemName: String) { 45 | self.systemName = systemName 46 | self.platformImage = nil 47 | self.animationImages = nil 48 | self.duration = 0 49 | } 50 | 51 | var image: Image { 52 | if let systemName = systemName { 53 | return Image(systemName: systemName) 54 | } else if let image = platformImage { 55 | return Image(emojiImage: image) 56 | } else { 57 | return Image(emojiImage: EmojiImage()) 58 | } 59 | } 60 | 61 | var isAnimated: Bool { 62 | guard let animationImages = animationImages else { return false } 63 | return !animationImages.isEmpty && duration > 0 64 | } 65 | 66 | func frame(at time: CFTimeInterval) -> Image { 67 | guard isAnimated, let rawImages = animationImages else { return image } 68 | 69 | let count = TimeInterval(rawImages.count) 70 | let fps = count / duration 71 | let totalFps = time * fps 72 | 73 | let frame = totalFps.truncatingRemainder(dividingBy: count) 74 | let index = Int(frame) 75 | 76 | return Image(emojiImage: rawImages[index]) 77 | } 78 | 79 | // MARK: - Hashable 80 | 81 | func hash(into hasher: inout Hasher) { 82 | hasher.combine(systemName) 83 | hasher.combine(platformImage) 84 | hasher.combine(animationImages) 85 | hasher.combine(duration) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/EmojiText/Environment/EmojiTextEnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiTextEnvironmentValues.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 30.01.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A collection of emojitext environment values propagated through a view hierarchy. 11 | public struct EmojiTextEnvironmentValues: CustomStringConvertible { 12 | private var values: EnvironmentValues 13 | 14 | /// Creates a emoji text environment values instance. 15 | /// 16 | /// You don't typically create an instance of ``EmojiTextEnvironmentValues`` 17 | /// directly. Doing so would provide access only to default values that 18 | /// don't update based on system settings or device characteristics. 19 | /// Instead, you rely on an environment values' instance 20 | /// that SwiftUI manages for you when you use the ``Environment`` 21 | /// property wrapper and the ``View/environment(_:_:)`` view modifier. 22 | init() { 23 | values = EnvironmentValues() 24 | } 25 | 26 | /// Accesses the environment value associated with a custom key. 27 | /// 28 | /// Create custom environment values by defining a key 29 | /// that conforms to the `EnvironmentKey` protocol, and then using that 30 | /// key with the subscript operator of the `EnvironmentValues` structure 31 | /// to get and set a value for that key: 32 | /// 33 | /// private struct MyEnvironmentKey: EnvironmentKey { 34 | /// static let defaultValue: String = "Default value" 35 | /// } 36 | /// 37 | /// extension EmojiTextEnvironmentValues { 38 | /// var myCustomValue: String { 39 | /// get { self[MyEnvironmentKey.self] } 40 | /// set { self[MyEnvironmentKey.self] = newValue } 41 | /// } 42 | /// } 43 | /// 44 | /// You use custom environment values the same way you use system-provided 45 | /// values, setting a value with the `View/environment(_:_:)` view 46 | /// modifier, and reading values with the `Environment` property wrapper. 47 | /// You can also provide a dedicated view modifier as a convenience for 48 | /// setting the value: 49 | /// 50 | /// extension View { 51 | /// func myCustomValue(_ myCustomValue: String) -> some View { 52 | /// environment(\.emojiText.myCustomValue, myCustomValue) 53 | /// } 54 | /// } 55 | /// 56 | public subscript(key: K.Type) -> K.Value where K: EnvironmentKey { 57 | get { values[key] } 58 | set { values[key] = newValue } 59 | } 60 | 61 | /// A string that represents the contents of the environment values instance. 62 | public var description: String { 63 | values.description 64 | } 65 | } 66 | 67 | private struct EmojiTextEnvironmentKey: EnvironmentKey { 68 | static var defaultValue: EmojiTextEnvironmentValues { 69 | EmojiTextEnvironmentValues() 70 | } 71 | } 72 | 73 | public extension EnvironmentValues { 74 | /// The ``EmojiText`` environment values. A subset of `SwiftUI.EnvironmentValues` 75 | /// that only contains values related to emoji text. 76 | var emojiText: EmojiTextEnvironmentValues { 77 | get { self[EmojiTextEnvironmentKey.self] } 78 | set { self[EmojiTextEnvironmentKey.self] = newValue } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EmojiText 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdivadretlaw%2FEmojiText%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/divadretlaw/EmojiText) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdivadretlaw%2FEmojiText%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/divadretlaw/EmojiText) 5 | 6 | 7 | Render Custom Emoji in `Text`. Supports local and remote emojis. 8 | 9 | ## Usage 10 | 11 | Remote emoji 12 | 13 | ```swift 14 | EmojiText(verbatim: "Hello :my_emoji:", 15 | emojis: [RemoteEmoji(shortcode: "my_emoji", url: /* URL to emoji */)]) 16 | ``` 17 | 18 | Local emoji 19 | 20 | ```swift 21 | EmojiText(verbatim: "Hello :my_emoji:", 22 | emojis: [LocalEmoji(shortcode: "my_emoji", image: /* some UIImage or NSImage */)]) 23 | ``` 24 | 25 | SF Symbol 26 | 27 | ```swift 28 | EmojiText(verbatim: "Hello Moon & Starts :moon.stars:", 29 | emojis: [SFSymbolEmoji(shortcode: "moon.stars")]) 30 | ``` 31 | 32 | ### Markdown 33 | 34 | Also supports Markdown 35 | 36 | ```swift 37 | EmojiText(markdown: "**Hello** *World* :my_emoji:", 38 | emojis: [RemoteEmoji(shortcode: "my_emoji", url: /* URL to emoji */)]) 39 | ``` 40 | 41 | ### Animated Emoji 42 | 43 | > [!WARNING] 44 | > This feature is in beta and therefore is opt-in only. Performance may vary. 45 | 46 | Currently only UIKit platforms support animated emoji. 47 | 48 | Enable animation by setting adding the `.animated()` modifier to `EmojiText`. 49 | 50 | ```swift 51 | EmojiText(verbatim: "GIF :my_gif:", 52 | emojis: [RemoteEmoji(shortcode: "my_gif", url: /* URL to gif */)]) 53 | .animated() 54 | ``` 55 | 56 | Supported formats: 57 | 58 | - APNG 59 | - GIF 60 | - WebP 61 | 62 | > [!INFO] 63 | > The animation will automatically pause when using low-power mode. To always play animations, even in low-power mode set the animation mode to `AnimatedEmojiMode.always` 64 | > 65 | > ```swift 66 | > EmojiText(verbatim: "GIF :my_gif:", 67 | > emojis: [RemoteEmoji(shortcode: "my_gif", url: /* URL to gif */)]) 68 | > .animated() 69 | > .environment(\.emojiText.AnimatedMode, .always) 70 | > ``` 71 | 72 | ## Configuration 73 | 74 | Remote emojis are replaced by a placeholder image when loading. Default is the SF Symbol `square.dashed` but you can overide the placeholder image with 75 | 76 | ```swift 77 | .emojiText.placeholder(systemName: /* SF Symbol */) 78 | ``` 79 | 80 | or 81 | 82 | ```swift 83 | .emojiText.placeholder(image: /* some UIImage or NSImage */) 84 | ``` 85 | 86 | Remote emojis use `URLSession.shared` to load them, but you can provide a custom `URLSession` 87 | 88 | ```swift 89 | .environment(\.emojiText.asyncEmojiProvider, DefaultAsyncEmojiProvider(session: myUrlSession)) 90 | ``` 91 | 92 | You can also replace the remote image loading and caching entirely. For example with [Nuke](https://github.com/kean/Nuke) 93 | 94 | ```swift 95 | .environment(\.emojiText.asyncEmojiProvider, NukeEmojiProvider()) 96 | ``` 97 | 98 | See [`NukeEmojiProvider`](Test/App/Provider/NukeEmojiProvider.swift) in the Test-App for a reference implementation of a `AsyncEmojiProvider` using Nuke. 99 | 100 | ## License 101 | 102 | See [LICENSE](LICENSE) 103 | -------------------------------------------------------------------------------- /Test/App/Mastodon/MastodonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastodonView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 14.12.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | import Nuke 11 | import HTML2Markdown 12 | 13 | struct MastodonView: View { 14 | @Environment(MastodonAPI.self) private var api 15 | var statusId: String 16 | 17 | init( 18 | statusId: String 19 | ) { 20 | self.statusId = statusId 21 | } 22 | 23 | @State private var instanceEmojis: [Emoji] = [] 24 | @State private var status: Status? 25 | @State private var uuid = UUID() 26 | 27 | @Environment(\.displayScale) private var displayScale 28 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 29 | 30 | var body: some View { 31 | Form { 32 | if let status { 33 | VStack(alignment: .leading) { 34 | EmojiText(markdown: "\(status.account.displayName)", emojis: status.customEmojis + customEmojis) 35 | .font(.largeTitle) 36 | .foregroundStyle(.primary) 37 | .id(uuid) 38 | Text("@\(status.account.username)") 39 | .font(.title) 40 | .foregroundStyle(.secondary) 41 | } 42 | Group { 43 | EmojiText(markdown: status.text, emojis: status.customEmojis) 44 | } 45 | .id(uuid) 46 | } else { 47 | ProgressView() 48 | } 49 | 50 | Section { 51 | LabeledContent("Display Scale", value: displayScale.description) 52 | 53 | Button { 54 | uuid = UUID() 55 | } label: { 56 | Text("Force re-render") 57 | } 58 | } header: { 59 | Text("Debug") 60 | } 61 | } 62 | .formStyle(.grouped) 63 | .animation(.default, value: status) 64 | .navigationTitle("Mastodon") 65 | .task { 66 | do { 67 | self.instanceEmojis = try await api.loadCustomEmoji() 68 | self.status = try await api.loadStatus(id: statusId) 69 | } catch { 70 | print(error.localizedDescription) 71 | } 72 | } 73 | .textSelection(.enabled) 74 | } 75 | 76 | var customEmojis: [any CustomEmoji] { 77 | instanceEmojis.map { emoji in 78 | RemoteEmoji(shortcode: emoji.shortcode, url: emoji.url) 79 | } 80 | } 81 | } 82 | 83 | extension Status { 84 | var text: String { 85 | do { 86 | let dom = try HTMLParser().parse(html: content) 87 | return dom.markdownFormatted(options: .mastodon) 88 | } catch { 89 | return content 90 | } 91 | } 92 | 93 | var customEmojis: [any CustomEmoji] { 94 | emojis.map { emoji in 95 | RemoteEmoji(shortcode: emoji.shortcode, url: emoji.url) 96 | } 97 | } 98 | } 99 | 100 | struct MastodonView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | MastodonView(statusId: "111773699547425887") 103 | .environment(MastodonAPI(host: "https://universeodon.com")) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/EmojiText/Provider/AsyncEmojiProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncEmojiProvider.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 12.07.24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | /// A provider loading emoji's in a asynchronous way e.g. from a remote url. 12 | /// 13 | /// > Note: 14 | /// > The default implementation of ``AsyncEmojiProvider/cachedEmojiImage(emoji:height:)-1t879`` calls 15 | /// > ``AsyncEmojiProvider/cachedEmojiData(emoji:height:)-4j1xz`` so you only have to provider one implementation. 16 | public protocol AsyncEmojiProvider: Sendable { 17 | /// Fetch the async emoji from cache (if applicable) 18 | /// - Parameters: 19 | /// - url: The async emoji to fetch. 20 | /// - height: The desired height of the emoji. 21 | /// - Returns: The data representing the emoji or `nil` if the emoji wasn't cached. 22 | func cachedEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) -> Data? 23 | 24 | /// Fetch the async emoji from cache (if applicable) 25 | /// - Parameters: 26 | /// - url: The async emoji to fetch. 27 | /// - height: The desired height of the emoji. 28 | /// - Returns: The image representing the emoji or `nil` if the emoji wasn't cached. 29 | func cachedEmojiImage(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? 30 | 31 | /// Fetch the async emoji and return its image data 32 | /// - Parameters: 33 | /// - emoji: The async emoji to fetch. 34 | /// - height: The desired height of the emoji. 35 | /// - Returns: Image data representation 36 | /// - Throws: An error if the image could not be fetched 37 | func fetchEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data 38 | 39 | @available(*, deprecated, renamed: "cachedEmojiImage(emoji:height:)") 40 | func lazyEmojiCached(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? 41 | @available(*, deprecated, renamed: "fetchEmojiData(emoji:height:)") 42 | func lazyEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data 43 | } 44 | 45 | public extension AsyncEmojiProvider { 46 | func cachedEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) -> Data? { 47 | nil 48 | } 49 | 50 | func cachedEmojiImage(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 51 | if let data = cachedEmojiData(emoji: emoji, height: height) { 52 | return EmojiImage(data: data) 53 | } else if let image = lazyEmojiCached(emoji: emoji, height: height) { 54 | Logger.emojiText.error("Loaded image from 'lazyEmojiCached(emoji:height:)' which is deprecated. Please switch to new implementations.") 55 | return image 56 | } else { 57 | return nil 58 | } 59 | } 60 | } 61 | 62 | // MARK: - Deprecations 63 | 64 | public extension AsyncEmojiProvider { 65 | func lazyEmojiCached(emoji: any AsyncCustomEmoji, height: CGFloat?) -> EmojiImage? { 66 | nil 67 | } 68 | 69 | func lazyEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 70 | throw EmojiProviderError.unsupportedEmoji 71 | } 72 | 73 | func fetchEmojiData(emoji: any AsyncCustomEmoji, height: CGFloat?) async throws -> Data { 74 | defer { 75 | Logger.emojiText.error("Fetched data from 'lazyEmojiData(emoji:height:)' which is deprecated. Please switch to new implementations.") 76 | } 77 | return try await lazyEmojiData(emoji: emoji, height: height) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Test/Test.xcodeproj/xcshareddata/xcschemes/Test.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 46 | 47 | 48 | 49 | 50 | 60 | 62 | 68 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Sources/EmojiText/Environment/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | // MARK: - Environment Keys 12 | 13 | private struct EmojiPlaceholderKey: EnvironmentKey { 14 | static var defaultValue: any CustomEmoji { 15 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(watchOS) || os(visionOS) 16 | if let image = UIImage(systemName: "square.dashed") { 17 | return LocalEmoji(shortcode: "placeholder", image: image, color: .placeholderEmoji, renderingMode: .template) 18 | } 19 | #elseif os(macOS) 20 | if let image = NSImage(systemName: "square.dashed") { 21 | return LocalEmoji(shortcode: "placeholder", image: image, color: .placeholderEmoji, renderingMode: .template) 22 | } 23 | #endif 24 | 25 | return SFSymbolEmoji(shortcode: "placeholder", symbolRenderingMode: .monochrome, renderingMode: .template) 26 | } 27 | } 28 | 29 | private struct EmojiSizeKey: EnvironmentKey { 30 | static var defaultValue: CGFloat? { 31 | nil 32 | } 33 | } 34 | 35 | private struct EmojiBaselineOffsetKey: EnvironmentKey { 36 | static var defaultValue: CGFloat? { 37 | nil 38 | } 39 | } 40 | 41 | private struct EmojiAnimatedModeKey: EnvironmentKey { 42 | static var defaultValue: AnimatedEmojiMode { 43 | .disabledOnLowPower 44 | } 45 | } 46 | 47 | private struct SyncEmojiProviderKey: EnvironmentKey { 48 | static var defaultValue: SyncEmojiProvider { 49 | DefaultSyncEmojiProvider() 50 | } 51 | } 52 | 53 | private struct AsyncEmojiProviderKey: EnvironmentKey { 54 | static var defaultValue: AsyncEmojiProvider { 55 | DefaultAsyncEmojiProvider() 56 | } 57 | } 58 | 59 | #if os(watchOS) || os(macOS) 60 | private struct EmojiTimerKey: EnvironmentKey { 61 | typealias Value = Publishers.Autoconnect 62 | 63 | static var defaultValue: Publishers.Autoconnect { 64 | #if os(watchOS) 65 | // Render in 24 fps 66 | Timer.publish(every: 1 / 24, on: .main, in: .common).autoconnect() 67 | #else 68 | // Render in 60 fps 69 | Timer.publish(every: 1 / 60, on: .main, in: .common).autoconnect() 70 | #endif 71 | } 72 | } 73 | #endif 74 | 75 | // MARK: - Environment Values 76 | 77 | public extension EmojiTextEnvironmentValues { 78 | /// The ``SyncEmojiProvider`` used to fetch emoji 79 | var syncEmojiProvider: SyncEmojiProvider { 80 | get { self[SyncEmojiProviderKey.self] } 81 | set { self[SyncEmojiProviderKey.self] = newValue } 82 | } 83 | 84 | /// The ``AsyncEmojiProvider`` used to fetch lazy emoji 85 | var asyncEmojiProvider: AsyncEmojiProvider { 86 | get { self[AsyncEmojiProviderKey.self] } 87 | set { self[AsyncEmojiProviderKey.self] = newValue } 88 | } 89 | 90 | /// The ``AnimatedEmojiMode`` that animated emojis should use 91 | var animatedMode: AnimatedEmojiMode { 92 | get { self[EmojiAnimatedModeKey.self] } 93 | set { self[EmojiAnimatedModeKey.self] = newValue } 94 | } 95 | } 96 | 97 | internal extension EmojiTextEnvironmentValues { 98 | /// The placeholder emoji to use if the emoji isn't yet loaded. 99 | var placeholder: any CustomEmoji { 100 | get { self[EmojiPlaceholderKey.self] } 101 | set { self[EmojiPlaceholderKey.self] = newValue } 102 | } 103 | 104 | #if os(watchOS) || os(macOS) 105 | var timer: Publishers.Autoconnect { 106 | get { self[EmojiTimerKey.self] } 107 | set { self[EmojiTimerKey.self] = newValue } 108 | } 109 | #endif 110 | 111 | /// The size of the inline custom emojis. Set `nil` to automatically determine the size based on the font size. 112 | var size: CGFloat? { 113 | get { self[EmojiSizeKey.self] } 114 | set { self[EmojiSizeKey.self] = newValue } 115 | } 116 | 117 | /// The baseline for custom emojis. Set `nil` to not override the baseline offset and use the default value. 118 | var baselineOffset: CGFloat? { 119 | get { self[EmojiBaselineOffsetKey.self] } 120 | set { self[EmojiBaselineOffsetKey.self] = newValue } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Test/App/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // EmojiTextTest 4 | // 5 | // Created by David Walter on 18.02.23. 6 | // 7 | 8 | import SwiftUI 9 | import EmojiText 10 | 11 | struct ContentView: View { 12 | var body: some View { 13 | #if os(macOS) 14 | list 15 | #else 16 | TabView { 17 | list 18 | about 19 | } 20 | #endif 21 | } 22 | 23 | var list: some View { 24 | NavigationStack { 25 | Form { 26 | Section { 27 | NavigationLink { 28 | RemoteEmojiView() 29 | } label: { 30 | Text("Remote Emoji") 31 | } 32 | 33 | NavigationLink { 34 | ChangingRemoteEmojiView() 35 | } label: { 36 | Text("Changing Remote Emoji") 37 | } 38 | 39 | NavigationLink { 40 | SFSymbolEmojiView() 41 | } label: { 42 | Text("SF Symbol Emoji") 43 | } 44 | 45 | NavigationLink { 46 | LocalEmojiView() 47 | } label: { 48 | Text("Local Emoji") 49 | } 50 | 51 | NavigationLink { 52 | AnimatedEmojiView() 53 | } label: { 54 | Text("Animated Emoji") 55 | } 56 | } header: { 57 | Text("Simple") 58 | } 59 | 60 | Section { 61 | NavigationLink { 62 | MastodonView(statusId: "111773699547425887") 63 | .environment(MastodonAPI(host: "https://universeodon.com")) 64 | } label: { 65 | Text("Test Status") 66 | } 67 | 68 | NavigationLink { 69 | MastodonView(statusId: "111572969777556029") 70 | .environment(MastodonAPI(host: "https://mastodon.de")) 71 | } label: { 72 | Text("Lots of Emoji-Test") 73 | } 74 | 75 | NavigationLink { 76 | MastodonView(statusId: "111324359951561858") 77 | .environment(MastodonAPI(host: "https://tapbots.social")) 78 | } label: { 79 | Text("Emoji & Link") 80 | } 81 | } header: { 82 | Text("Mastodon") 83 | } 84 | 85 | Section { 86 | NavigationLink { 87 | RemoteEmojiView() 88 | .environment(\.emojiText.asyncEmojiProvider, NukeEmojiProvider()) 89 | } label: { 90 | Text("Remote Emoji") 91 | } 92 | NavigationLink { 93 | LocalEmojiView() 94 | .environment(\.emojiText.syncEmojiProvider, UpsideDownEmojiProvider()) 95 | } label: { 96 | Text("Local Emoji") 97 | } 98 | } header: { 99 | Text("Custom Emoji Provider") 100 | } 101 | } 102 | .navigationTitle("EmojiText") 103 | } 104 | .formStyle(.grouped) 105 | .tag(0) 106 | .tabItem { 107 | Label("Emojis", systemImage: "face.smiling") 108 | } 109 | } 110 | 111 | var about: some View { 112 | NavigationStack { 113 | List { 114 | Text("Testing app for snapshot tests and quick debbugging") 115 | } 116 | .navigationTitle("About") 117 | } 118 | .tag(1) 119 | .tabItem { 120 | Label("About", systemImage: "info.circle") 121 | } 122 | } 123 | } 124 | 125 | struct ContentView_Previews: PreviewProvider { 126 | static var previews: some View { 127 | ContentView() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/AttributedPartialstring.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributedPartialstring.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.12.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AttributedPartialstring: AttributedStringProtocol, Sendable { 11 | fileprivate var substrings: [AttributedSubstring] 12 | 13 | init() { 14 | substrings = [] 15 | } 16 | 17 | mutating func append(_ substring: AttributedSubstring) { 18 | substrings.append(substring) 19 | } 20 | 21 | mutating func consume() -> [AttributedSubstring] { 22 | defer { 23 | self.substrings = [] 24 | } 25 | return self.substrings 26 | } 27 | 28 | // MARK: - AttributedStringProtocol 29 | 30 | var startIndex: AttributedString.Index { 31 | AttributedString(self).startIndex 32 | } 33 | 34 | var endIndex: AttributedString.Index { 35 | AttributedString(self).endIndex 36 | } 37 | 38 | var runs: AttributedString.Runs { 39 | AttributedString(self).runs 40 | } 41 | 42 | var characters: AttributedString.CharacterView { 43 | AttributedString(self).characters 44 | } 45 | 46 | var unicodeScalars: AttributedString.UnicodeScalarView { 47 | AttributedString(self).unicodeScalars 48 | } 49 | 50 | subscript( 51 | _ value: K.Type 52 | ) -> K.Value? where K: AttributedStringKey, K.Value: Sendable { 53 | get { 54 | AttributedString(self)[value] 55 | } 56 | set { 57 | substrings = substrings.map { substring in 58 | var substring = substring 59 | substring[value] = newValue 60 | return substring 61 | } 62 | } 63 | } 64 | 65 | subscript( 66 | dynamicMember keyPath: KeyPath 67 | ) -> K.Value? where K: AttributedStringKey, K.Value: Sendable { 68 | get { 69 | AttributedString(self)[dynamicMember: keyPath] 70 | } 71 | set { 72 | substrings = substrings.map { substring in 73 | var substring = substring 74 | substring[dynamicMember: keyPath] = newValue 75 | return substring 76 | } 77 | } 78 | } 79 | 80 | subscript( 81 | dynamicMember keyPath: KeyPath 82 | ) -> ScopedAttributeContainer where S: AttributeScope { 83 | get { 84 | AttributedString(self)[dynamicMember: keyPath] 85 | } 86 | set { 87 | substrings = substrings.map { substring in 88 | var substring = substring 89 | substring[dynamicMember: keyPath] = newValue 90 | return substring 91 | } 92 | } 93 | } 94 | 95 | subscript( 96 | bounds: R 97 | ) -> AttributedSubstring where R: RangeExpression, R.Bound == AttributedString.Index { 98 | AttributedString(self)[bounds] 99 | } 100 | 101 | mutating func setAttributes( 102 | _ attributes: AttributeContainer 103 | ) { 104 | substrings = substrings.map { substring in 105 | var substring = substring 106 | substring.setAttributes(attributes) 107 | return substring 108 | } 109 | } 110 | 111 | mutating func mergeAttributes( 112 | _ attributes: AttributeContainer, 113 | mergePolicy: AttributedString.AttributeMergePolicy 114 | ) { 115 | substrings = substrings.map { substring in 116 | var substring = substring 117 | substring.mergeAttributes(attributes, mergePolicy: mergePolicy) 118 | return substring 119 | } 120 | } 121 | 122 | mutating func replaceAttributes( 123 | _ attributes: AttributeContainer, 124 | with others: AttributeContainer 125 | ) { 126 | substrings = substrings.map { substring in 127 | var substring = substring 128 | substring.replaceAttributes(attributes, with: others) 129 | return substring 130 | } 131 | } 132 | 133 | // MARK: - CustomStringConvertible 134 | 135 | var description: String { 136 | AttributedString(self).description 137 | } 138 | } 139 | 140 | private extension AttributedString { 141 | init(_ value: AttributedPartialstring) { 142 | self = value.substrings.reduce(AttributedString()) { partialResult, substring in 143 | partialResult + substring 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/EmojiText/Model/Emoji/RenderedEmoji.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RenderedEmoji.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 12.02.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import OSLog 11 | 12 | /// A rendered custom emoji 13 | struct RenderedEmoji: Hashable, Equatable, Identifiable, Sendable { 14 | let shortcode: String 15 | let baselineOffset: CGFloat? 16 | let renderingMode: Image.TemplateRenderingMode? 17 | let symbolRenderingMode: SymbolRenderingMode? 18 | 19 | private let rawImage: RenderedImage 20 | private let sourceHash: Int 21 | private let placeholderId: UUID? 22 | 23 | init(from emoji: any CustomEmoji, image: RawImage, animated: Bool = false, targetHeight: CGFloat, baselineOffset: CGFloat? = nil) { 24 | self.shortcode = emoji.shortcode 25 | self.rawImage = RenderedImage(image: image, animated: animated, targetHeight: targetHeight) 26 | self.renderingMode = emoji.renderingMode 27 | self.baselineOffset = emoji.baselineOffset ?? baselineOffset 28 | self.symbolRenderingMode = nil 29 | self.placeholderId = nil 30 | // The source hash is the cominbed value of the emoji & target height 31 | var hasher = Hasher() 32 | hasher.combine(emoji) 33 | hasher.combine(targetHeight) 34 | self.sourceHash = hasher.finalize() 35 | } 36 | 37 | init(from emoji: LocalEmoji, animated: Bool = false, targetHeight: CGFloat, baselineOffset: CGFloat? = nil) { 38 | self.shortcode = emoji.shortcode 39 | self.rawImage = RenderedImage(image: emoji.image.withColor(emoji.color), animated: animated, targetHeight: targetHeight) 40 | self.renderingMode = emoji.renderingMode 41 | self.baselineOffset = emoji.baselineOffset ?? baselineOffset 42 | self.symbolRenderingMode = nil 43 | self.placeholderId = nil 44 | // The source hash is the cominbed value of the emoji & target height 45 | var hasher = Hasher() 46 | hasher.combine(emoji) 47 | hasher.combine(targetHeight) 48 | self.sourceHash = hasher.finalize() 49 | } 50 | 51 | init(from emoji: SFSymbolEmoji) { 52 | self.shortcode = emoji.shortcode 53 | self.rawImage = RenderedImage(systemName: emoji.shortcode) 54 | self.renderingMode = emoji.renderingMode 55 | self.baselineOffset = emoji.baselineOffset 56 | self.symbolRenderingMode = emoji.symbolRenderingMode 57 | self.placeholderId = nil 58 | self.sourceHash = emoji.hashValue 59 | } 60 | 61 | init(from emoji: any CustomEmoji, placeholder: any CustomEmoji, animated: Bool = false, targetHeight: CGFloat, baselineOffset: CGFloat? = nil) { 62 | self.shortcode = emoji.shortcode 63 | self.renderingMode = emoji.renderingMode 64 | self.baselineOffset = emoji.baselineOffset ?? baselineOffset 65 | self.symbolRenderingMode = emoji.symbolRenderingMode 66 | self.placeholderId = UUID() 67 | // The source hash is the cominbed value of the emoji & target height 68 | var hasher = Hasher() 69 | hasher.combine(emoji) 70 | hasher.combine(targetHeight) 71 | self.sourceHash = hasher.finalize() 72 | 73 | switch placeholder { 74 | case let localEmoji as LocalEmoji: 75 | self.rawImage = RenderedImage(image: localEmoji.image.withColor(localEmoji.color), animated: animated, targetHeight: targetHeight) 76 | case let sfSymbolEmoji as SFSymbolEmoji: 77 | self.rawImage = RenderedImage(systemName: sfSymbolEmoji.shortcode) 78 | default: 79 | self.rawImage = RenderedImage(systemName: SFSymbolEmoji.fallback.shortcode) 80 | Logger.emojiText.error("Unsupported CustomEmoji was used as placeholder. Only LocalEmoji and SFSymbolEmoji are supported. This is a bug. Please file a report at https://github.com/divadretlaw/EmojiText") 81 | } 82 | } 83 | 84 | var isPlaceholder: Bool { 85 | placeholderId != nil 86 | } 87 | 88 | var isAnimated: Bool { 89 | rawImage.isAnimated 90 | } 91 | 92 | var image: Image { 93 | rawImage.image 94 | .renderingMode(renderingMode) 95 | .symbolRenderingMode(symbolRenderingMode) 96 | } 97 | 98 | func frame(at time: CFTimeInterval) -> Image { 99 | rawImage.frame(at: time) 100 | .renderingMode(renderingMode) 101 | .symbolRenderingMode(symbolRenderingMode) 102 | } 103 | 104 | func hasSameSource(as value: RenderedEmoji) -> Bool { 105 | sourceHash == value.sourceHash 106 | } 107 | 108 | // MARK: - Hashable 109 | 110 | func hash(into hasher: inout Hasher) { 111 | hasher.combine(shortcode) 112 | hasher.combine(placeholderId) 113 | hasher.combine(sourceHash) 114 | } 115 | 116 | // MARK: - Equatable 117 | 118 | static func == (lhs: Self, rhs: Self) -> Bool { 119 | guard lhs.shortcode == rhs.shortcode, 120 | lhs.isPlaceholder == rhs.isPlaceholder else { return false } 121 | return lhs.rawImage == rhs.rawImage 122 | } 123 | 124 | // MARK: - Identifiable 125 | 126 | var id: String { shortcode } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Font+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Font+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(visionOS) 11 | import UIKit 12 | 13 | extension UIFont { 14 | static func preferredFont(from font: Font?, compatibleWith traitCollection: UITraitCollection? = nil) -> UIFont { 15 | guard let font = font else { 16 | return UIFont.preferredFont(forTextStyle: .body) 17 | } 18 | 19 | switch font { 20 | case .largeTitle: 21 | #if os(tvOS) 22 | return UIFont.preferredFont(forTextStyle: .title1, compatibleWith: traitCollection) 23 | #else 24 | return UIFont.preferredFont(forTextStyle: .largeTitle, compatibleWith: traitCollection) 25 | #endif 26 | case .title: 27 | return UIFont.preferredFont(forTextStyle: .title1, compatibleWith: traitCollection) 28 | case .title2: 29 | return UIFont.preferredFont(forTextStyle: .title2, compatibleWith: traitCollection) 30 | case .title3: 31 | return UIFont.preferredFont(forTextStyle: .title3, compatibleWith: traitCollection) 32 | case .headline: 33 | return UIFont.preferredFont(forTextStyle: .headline, compatibleWith: traitCollection) 34 | case .subheadline: 35 | return UIFont.preferredFont(forTextStyle: .subheadline, compatibleWith: traitCollection) 36 | case .callout: 37 | return UIFont.preferredFont(forTextStyle: .callout, compatibleWith: traitCollection) 38 | case .caption: 39 | return UIFont.preferredFont(forTextStyle: .caption1, compatibleWith: traitCollection) 40 | case .caption2: 41 | return UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traitCollection) 42 | case .footnote: 43 | return UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traitCollection) 44 | case .body: 45 | fallthrough 46 | default: 47 | return UIFont.preferredFont(forTextStyle: .body, compatibleWith: traitCollection) 48 | } 49 | } 50 | 51 | static func preferredFont(from font: Font?, for dynamicTypeSize: DynamicTypeSize) -> UIFont { 52 | let traitCollection = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(from: dynamicTypeSize)) 53 | return UIFont.preferredFont(from: font, compatibleWith: traitCollection) 54 | } 55 | } 56 | #elseif os(watchOS) 57 | import UIKit 58 | 59 | extension UIFont { 60 | static func preferredFont(from font: Font?) -> UIFont { 61 | guard let font = font else { 62 | return UIFont.preferredFont(forTextStyle: .body) 63 | } 64 | 65 | switch font { 66 | case .largeTitle: 67 | return UIFont.preferredFont(forTextStyle: .largeTitle) 68 | case .title: 69 | return UIFont.preferredFont(forTextStyle: .title1) 70 | case .title2: 71 | return UIFont.preferredFont(forTextStyle: .title2) 72 | case .title3: 73 | return UIFont.preferredFont(forTextStyle: .title3) 74 | case .headline: 75 | return UIFont.preferredFont(forTextStyle: .headline) 76 | case .subheadline: 77 | return UIFont.preferredFont(forTextStyle: .subheadline) 78 | case .callout: 79 | return UIFont.preferredFont(forTextStyle: .callout) 80 | case .caption: 81 | return UIFont.preferredFont(forTextStyle: .caption1) 82 | case .caption2: 83 | return UIFont.preferredFont(forTextStyle: .caption2) 84 | case .footnote: 85 | return UIFont.preferredFont(forTextStyle: .footnote) 86 | case .body: 87 | fallthrough 88 | default: 89 | return UIFont.preferredFont(forTextStyle: .body) 90 | } 91 | } 92 | 93 | static func preferredFont(from font: Font?, for dynamicTypeSize: DynamicTypeSize) -> UIFont { 94 | UIFont.preferredFont(from: font) 95 | } 96 | } 97 | #endif 98 | 99 | #if os(macOS) 100 | import AppKit 101 | 102 | extension NSFont { 103 | static func preferredFont(from font: Font?, for dynamicTypeSize: DynamicTypeSize) -> NSFont { 104 | guard let font = font else { 105 | return NSFont.preferredFont(forTextStyle: .body) 106 | } 107 | 108 | switch font { 109 | case .largeTitle: 110 | return NSFont.preferredFont(forTextStyle: .largeTitle) 111 | case .title: 112 | return NSFont.preferredFont(forTextStyle: .title1) 113 | case .title2: 114 | return NSFont.preferredFont(forTextStyle: .title2) 115 | case .title3: 116 | return NSFont.preferredFont(forTextStyle: .title3) 117 | case .headline: 118 | return NSFont.preferredFont(forTextStyle: .headline) 119 | case .subheadline: 120 | return NSFont.preferredFont(forTextStyle: .subheadline) 121 | case .callout: 122 | return NSFont.preferredFont(forTextStyle: .callout) 123 | case .caption: 124 | return NSFont.preferredFont(forTextStyle: .caption1) 125 | case .caption2: 126 | return NSFont.preferredFont(forTextStyle: .caption2) 127 | case .footnote: 128 | return NSFont.preferredFont(forTextStyle: .footnote) 129 | case .body: 130 | fallthrough 131 | default: 132 | return NSFont.preferredFont(forTextStyle: .body) 133 | } 134 | } 135 | } 136 | #endif 137 | -------------------------------------------------------------------------------- /Sources/EmojiText/Render/Markdown/MarkdownEmojiRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownEmojiRenderer.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 26.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import Markdown 10 | import OSLog 11 | 12 | struct MarkdownEmojiRenderer: EmojiRenderer { 13 | let shouldOmitSpacesBetweenEmojis: Bool 14 | let interpretedSyntax: AttributedString.MarkdownParsingOptions.InterpretedSyntax 15 | private let formatterOptions: MarkupFormatter.Options 16 | 17 | init( 18 | shouldOmitSpacesBetweenEmojis: Bool, 19 | interpretedSyntax: AttributedString.MarkdownParsingOptions.InterpretedSyntax 20 | ) { 21 | self.shouldOmitSpacesBetweenEmojis = shouldOmitSpacesBetweenEmojis 22 | self.interpretedSyntax = interpretedSyntax 23 | 24 | self.formatterOptions = MarkupFormatter.Options( 25 | unorderedListMarker: .star, 26 | orderedListNumerals: .incrementing(start: 1) 27 | ) 28 | } 29 | 30 | func render(string: String, emojis: [String: RenderedEmoji], size: CGFloat?) -> SwiftUI.Text { 31 | renderAnimated(string: string, emojis: emojis, size: size, at: 0) 32 | } 33 | 34 | func renderAnimated(string: String, emojis: [String: RenderedEmoji], size: CGFloat?, at time: CFTimeInterval) -> SwiftUI.Text { 35 | let attributedString = renderAttributedString(from: string, with: emojis) 36 | 37 | var result = Text(verbatim: "") 38 | var partialString = AttributedPartialstring() 39 | 40 | for run in attributedString.runs { 41 | if let emoji = run.emoji(from: emojis) { 42 | // If the run is an emoji we render it as an interpolated image in a Text view 43 | let text = EmojiTextRenderer(emoji: emoji).render(size, at: time) 44 | 45 | // If the same emoji is added multiple times in a row the run gets merged into one 46 | // with their shortcodes joined. Therefore we simply divide distance of the range by 47 | // the character count of the emojo to calculate how often the emoji needs to be displayed 48 | let distance = attributedString.distance(from: run.range.lowerBound, to: run.range.upperBound) 49 | let count = emoji.shortcode.count 50 | 51 | if distance == count { 52 | // Emoji is only displayed once 53 | result = [ 54 | result, 55 | Text(&partialString), 56 | text 57 | ] 58 | .compactMap { $0 } 59 | .joined() 60 | } else { 61 | // Emojis is displayed multiple times 62 | result = [ 63 | result, 64 | Text(&partialString), 65 | Text(repating: text, count: distance / count) 66 | ] 67 | .compactMap { $0 } 68 | .joined() 69 | } 70 | } else { 71 | // Otherwise we just append the run to AttributedPartialstring 72 | partialString.append(attributedString[run.range]) 73 | } 74 | } 75 | 76 | return [result, Text(&partialString)] 77 | .compactMap { $0 } 78 | .joined() 79 | } 80 | 81 | private func renderAttributedString(from string: String, with emojis: [String: RenderedEmoji]) -> AttributedString { 82 | do { 83 | let options = AttributedString.MarkdownParsingOptions( 84 | allowsExtendedAttributes: true, 85 | interpretedSyntax: interpretedSyntax, 86 | failurePolicy: .returnPartiallyParsedIfPossible 87 | ) 88 | 89 | // We need to replace \\ with \\\\ otherwise the Markdown parser 90 | // will interpret the previously escaped characters when rendering 91 | // them in AttributedString 92 | let escapedString = string.replacingOccurrences(of: "\\", with: "\\\\") 93 | let originalDocument = Document(parsing: escapedString) 94 | var emojiReplacer = EmojiReplacer(emojis: emojis) 95 | let emojiDocument = emojiReplacer.visitDocument(originalDocument) ?? originalDocument 96 | 97 | let markdown = emojiDocument.format(options: formatterOptions) 98 | .splitOnEmoji(omittingSpacesBetweenEmojis: shouldOmitSpacesBetweenEmojis) 99 | .joined() 100 | 101 | return try AttributedString(markdown: markdown, options: options) 102 | } catch { 103 | Logger.text.error("Unable to parse Markdown, falling back to verbatim string: \(error.localizedDescription)") 104 | return AttributedString(stringLiteral: string) 105 | } 106 | } 107 | } 108 | 109 | #if DEBUG 110 | #Preview { 111 | List { 112 | EmojiText( 113 | markdown: "Hello :a:", 114 | emojis: .emojis 115 | ) 116 | EmojiText( 117 | markdown: "Hello [:a:](https://github.com)", 118 | emojis: .emojis 119 | ) 120 | EmojiText( 121 | markdown: """ 122 | Hello :a: 123 | 124 | ``` 125 | EmojiText(markdown: "Hello :a:", emojis: .emojis) 126 | ``` 127 | 128 | World :wide: 129 | """, 130 | emojis: .emojis 131 | ) 132 | EmojiText( 133 | markdown: """ 134 | # Hello :a: 135 | ## Hello :a: 136 | ### Hello :a: 137 | **Hello :a:** 138 | *Hello :a:* 139 | _Hello :a:_ 140 | `Hello :a:` 141 | 142 | * Hello :a: 143 | * Hello :wide: 144 | 145 | 1. Hello :a: 146 | 2. Hello :wide: 147 | """, 148 | emojis: .emojis 149 | ) 150 | } 151 | } 152 | #endif 153 | -------------------------------------------------------------------------------- /Sources/EmojiText/Extensions/Image+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+Extensions.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import SwiftUI 9 | import ImageIO 10 | 11 | extension Image { 12 | init(emojiImage: EmojiImage) { 13 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(watchOS) || os(visionOS) 14 | self.init(uiImage: emojiImage) 15 | #elseif os(macOS) 16 | self.init(nsImage: emojiImage) 17 | #else 18 | self.init(systemName: "exclamationmark.triangle.fill") 19 | #endif 20 | } 21 | } 22 | 23 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(watchOS) || os(visionOS) 24 | import UIKit 25 | 26 | extension UIImage { 27 | func scalePreservingAspectRatio(targetHeight: CGFloat) -> UIImage { 28 | guard !targetHeight.isAlmostEqual(to: size.height) else { 29 | return self 30 | } 31 | 32 | let scaleFactor = targetHeight / size.height 33 | 34 | // Compute the new image size that preserves aspect ratio 35 | let scaledImageSize = CGSize( 36 | width: size.width * scaleFactor, 37 | height: size.height * scaleFactor 38 | ) 39 | 40 | #if os(watchOS) 41 | UIGraphicsBeginImageContextWithOptions(scaledImageSize, false, scale) 42 | defer { UIGraphicsEndImageContext() } 43 | 44 | // Draw and return the resized UIImage 45 | self.draw(in: CGRect( 46 | origin: .zero, 47 | size: scaledImageSize 48 | )) 49 | 50 | return UIGraphicsGetImageFromCurrentImageContext() ?? self 51 | #else 52 | // Draw and return the resized UIImage 53 | let renderer = UIGraphicsImageRenderer( 54 | size: scaledImageSize 55 | ) 56 | 57 | return renderer.image { _ in 58 | self.draw(in: CGRect( 59 | origin: .zero, 60 | size: scaledImageSize 61 | )) 62 | } 63 | #endif 64 | } 65 | 66 | static func animatedImage(from source: CGImageSource, type: AnimatedImageType) -> RawImage? { 67 | typealias CGImageWrapper = (source: CGImage, delay: Int) 68 | var images: [CGImageWrapper] = [] 69 | for index in 0.. UIImage { 94 | guard let color else { 95 | return self 96 | } 97 | 98 | return withTintColor(color, renderingMode: .alwaysTemplate) 99 | } 100 | } 101 | #endif 102 | 103 | #if os(macOS) 104 | import AppKit 105 | 106 | extension NSImage { 107 | convenience init?(systemName: String) { 108 | self.init(systemSymbolName: systemName, accessibilityDescription: systemName) 109 | } 110 | 111 | func scalePreservingAspectRatio(targetHeight: CGFloat) -> NSImage { 112 | guard !targetHeight.isAlmostEqual(to: size.height) else { return self } 113 | 114 | let scaleFactor = targetHeight / size.height 115 | 116 | // Compute the new image size that preserves aspect ratio 117 | let scaledImageSize = CGSize( 118 | width: size.width * scaleFactor, 119 | height: size.height * scaleFactor 120 | ) 121 | 122 | return NSImage(size: scaledImageSize, flipped: false) { rect in 123 | self.draw(in: rect) 124 | return true 125 | } 126 | } 127 | 128 | convenience init(cgImage: CGImage) { 129 | self.init(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) 130 | } 131 | 132 | static func animatedImage(from source: CGImageSource, type: AnimatedImageType) -> RawImage? { 133 | typealias CGImageWrapper = (source: CGImage, delay: Int) 134 | var images: [CGImageWrapper] = [] 135 | for index in 0.. NSImage { 160 | guard let color else { 161 | return self 162 | } 163 | 164 | // swiftlint:disable:next force_cast 165 | let image = self.copy() as! NSImage 166 | image.lockFocus() 167 | 168 | color.set() 169 | 170 | let imageRect = NSRect(origin: .zero, size: image.size) 171 | imageRect.fill(using: .sourceIn) 172 | 173 | image.unlockFocus() 174 | image.isTemplate = false 175 | 176 | return image 177 | } 178 | } 179 | #endif 180 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # By default, SwiftLint uses a set of sensible default rules you can adjust: 2 | disabled_rules: # rule identifiers turned on by default to exclude from running 3 | # - blanket_disable_command 4 | # - block_based_kvo 5 | # - class_delegate_protocol 6 | # - closing_brace 7 | # - closure_parameter_position 8 | # - colon 9 | # - comma 10 | # - comment_spacing 11 | # - compiler_protocol_init 12 | # - computed_accessors_order 13 | # - control_statement 14 | - cyclomatic_complexity 15 | # - deployment_target 16 | # - discouraged_direct_init 17 | # - duplicate_conditions 18 | # - duplicate_enum_cases 19 | # - duplicate_imports 20 | # - duplicated_key_in_dictionary_literal 21 | # - dynamic_inline 22 | # - empty_enum_arguments 23 | # - empty_parameters 24 | # - empty_parentheses_with_trailing_closure 25 | # - file_length 26 | # - for_where 27 | # - force_cast 28 | # - force_try 29 | - function_body_length 30 | # - function_parameter_count 31 | # - generic_type_name 32 | # - identifier_name 33 | # - implicit_getter 34 | # - inclusive_language 35 | # - invalid_swiftlint_command 36 | # - is_disjoint 37 | # - large_tuple 38 | # - leading_whitespace 39 | # - legacy_cggeometry_functions 40 | # - legacy_constant 41 | # - legacy_constructor 42 | # - legacy_hashing 43 | # - legacy_nsgeometry_functions 44 | # - legacy_random 45 | - line_length 46 | # - mark 47 | # - multiple_closures_with_trailing_closure 48 | # - nesting 49 | - no_fallthrough_only 50 | # - no_space_in_method_call 51 | # - non_optional_string_data_conversion 52 | # - notification_center_detachment 53 | # - ns_number_init_as_function_reference 54 | # - nsobject_prefer_isequal 55 | # - opening_brace 56 | # - operator_whitespace 57 | # - orphaned_doc_comment 58 | # - private_over_fileprivate 59 | # - private_unit_test 60 | # - protocol_property_accessors_order 61 | # - reduce_boolean 62 | # - redundant_discardable_let 63 | # - redundant_objc_attribute 64 | # - redundant_optional_initialization 65 | # - redundant_set_access_control 66 | # - redundant_string_enum_value 67 | # - redundant_void_return 68 | # - return_arrow_whitespace 69 | # - self_in_property_initialization 70 | - shorthand_operator 71 | # - statement_position 72 | # - superfluous_disable_command 73 | # - switch_case_alignment 74 | # - syntactic_sugar 75 | # - todo 76 | # - trailing_comma 77 | # - trailing_newline 78 | # - trailing_semicolon 79 | - trailing_whitespace 80 | # - type_body_length 81 | # - type_name 82 | # - unavailable_condition 83 | # - unneeded_break_in_switch 84 | # - unneeded_override 85 | # - unneeded_synthesized_initializer 86 | # - unused_closure_parameter 87 | # - unused_control_flow_label 88 | # - unused_enumerated 89 | # - unused_optional_binding 90 | # - unused_setter_value 91 | # - valid_ibinspectable 92 | # - vertical_parameter_alignment 93 | # - vertical_whitespace 94 | # - void_function_in_ternary 95 | # - void_return 96 | # - xctfail_message 97 | opt_in_rules: # some rules are turned off by default, so you need to opt-in 98 | - accessibility_label_for_image 99 | - accessibility_trait_for_button 100 | - anonymous_argument_in_multiline_closure 101 | - array_init 102 | # - attributes 103 | - balanced_xctest_lifecycle 104 | # - capture_variable 105 | # - closure_body_length 106 | - closure_end_indentation 107 | - closure_spacing 108 | - collection_alignment 109 | - comma_inheritance 110 | # - conditional_returns_on_newline 111 | - contains_over_filter_count 112 | - contains_over_filter_is_empty 113 | - contains_over_first_not_nil 114 | - contains_over_range_nil_comparison 115 | - convenience_type 116 | - direct_return 117 | - discarded_notification_center_observer 118 | - discouraged_assert 119 | - discouraged_none_name 120 | - discouraged_object_literal 121 | - discouraged_optional_boolean 122 | # - discouraged_optional_collection 123 | - empty_collection_literal 124 | - empty_count 125 | - empty_string 126 | - empty_xctest_method 127 | - enum_case_associated_values_count 128 | - expiring_todo 129 | # - explicit_acl 130 | - explicit_enum_raw_value 131 | - explicit_init 132 | # - explicit_self 133 | # - explicit_top_level_acl 134 | # - explicit_type_interface 135 | - extension_access_modifier 136 | # - fallthrough 137 | - fatal_error_message 138 | - file_header 139 | # - file_name 140 | - file_name_no_space 141 | # - file_types_order 142 | - final_test_case 143 | - first_where 144 | - flatmap_over_map_reduce 145 | - force_unwrapping 146 | # - function_default_parameter_at_end 147 | - ibinspectable_in_extension 148 | - identical_operands 149 | - implicit_return 150 | # - implicitly_unwrapped_optional 151 | # - indentation_width 152 | - joined_default_parameter 153 | - last_where 154 | - legacy_multiple 155 | - legacy_objc_type 156 | # - let_var_whitespace 157 | - literal_expression_end_indentation 158 | - local_doc_comment 159 | - lower_acl_than_parent 160 | - missing_docs 161 | - modifier_order 162 | - multiline_arguments 163 | # - multiline_arguments_brackets 164 | - multiline_function_chains 165 | - multiline_literal_brackets 166 | - multiline_parameters 167 | - multiline_parameters_brackets 168 | - nimble_operator 169 | # - no_extension_access_modifier 170 | - no_grouping_extension 171 | # - no_magic_numbers 172 | - non_overridable_class_declaration 173 | - nslocalizedstring_key 174 | # - nslocalizedstring_require_bundle 175 | # - number_separator 176 | - object_literal 177 | # - one_declaration_per_file 178 | - operator_usage_whitespace 179 | - optional_enum_case_matching 180 | - overridden_super_call 181 | - override_in_extension 182 | - pattern_matching_keywords 183 | - period_spacing 184 | - prefer_nimble 185 | # - prefer_self_in_static_references 186 | - prefer_self_type_over_type_of_self 187 | - prefer_zero_over_explicit_init 188 | - prefixed_toplevel_constant 189 | - private_action 190 | - private_outlet 191 | - private_subject 192 | # - private_swiftui_state 193 | - prohibited_interface_builder 194 | - prohibited_super_call 195 | - quick_discouraged_call 196 | - quick_discouraged_focused_test 197 | - quick_discouraged_pending_test 198 | - raw_value_for_camel_cased_codable_enum 199 | - reduce_into 200 | - redundant_nil_coalescing 201 | - redundant_self_in_closure 202 | # - redundant_type_annotation 203 | # - required_deinit 204 | - required_enum_case 205 | - return_value_from_void_function 206 | - self_binding 207 | # - shorthand_optional_binding 208 | - single_test_class 209 | # - sorted_enum_cases 210 | - sorted_first_last 211 | # - sorted_imports 212 | - static_operator 213 | # - strict_fileprivate 214 | - strong_iboutlet 215 | # - superfluous_else 216 | - switch_case_on_newline 217 | - test_case_accessibility 218 | - toggle_bool 219 | - trailing_closure 220 | # - type_contents_order 221 | # - typesafe_array_init 222 | - unavailable_function 223 | - unhandled_throwing_task 224 | - unneeded_parentheses_in_closure_argument 225 | - unowned_variable_capture 226 | - untyped_error_in_catch 227 | # - unused_declaration 228 | # - unused_import 229 | - vertical_parameter_alignment_on_call 230 | # - vertical_whitespace_between_cases 231 | - vertical_whitespace_closing_braces 232 | - vertical_whitespace_opening_braces 233 | - weak_delegate 234 | - xct_specific_matcher 235 | - yoda_condition 236 | 237 | included: 238 | - Sources -------------------------------------------------------------------------------- /Test/Tests/EmojiTextTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiTextTests.swift 3 | // Tests 4 | // 5 | // Created by David Walter on 18.02.23. 6 | // 7 | 8 | import Testing 9 | @preconcurrency import SnapshotTesting 10 | @testable import EmojiText 11 | import SwiftUI 12 | 13 | @MainActor struct EmojiTextTests { 14 | @Test func test_Empty() { 15 | let view = EmojiText(verbatim: "", emojis: []) 16 | assertSnapshot(of: view, as: .image(layout: .fixed(width: 100, height: 100))) 17 | } 18 | 19 | @Test func test_No_Emoji() { 20 | let view = EmojiText(verbatim: "Hello World", emojis: []) 21 | assertSnapshot(of: view, as: .image) 22 | } 23 | 24 | @Test func test_Async() { 25 | let view = EmojiText(verbatim: "Hello Async :async:", emojis: [Emojis.async]) 26 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 27 | assertSnapshot(of: view, as: .image) 28 | } 29 | 30 | @Test func test_Async_Verbatim_Double() { 31 | let view = EmojiText(verbatim: "Hello Async :async: :async:", emojis: [Emojis.async]) 32 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 33 | assertSnapshot(of: view, as: .image) 34 | } 35 | 36 | @Test func test_Async_Markdown_Double() { 37 | let view = EmojiText(markdown: "Hello Async :async: :async:", emojis: [Emojis.async]) 38 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 39 | assertSnapshot(of: view, as: .image) 40 | } 41 | 42 | @Test func test_Async_Scaled() { 43 | let view = EmojiText(verbatim: "Hello Async :async:", emojis: [Emojis.async]) 44 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 45 | .font(.largeTitle) 46 | assertSnapshot(of: view, as: .image) 47 | } 48 | 49 | @Test func test_Async_Custom_Scaled() { 50 | let view = EmojiText(verbatim: "Hello Async :async:", emojis: [Emojis.async]) 51 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 52 | .emojiText.size(30) 53 | assertSnapshot(of: view, as: .image) 54 | } 55 | 56 | @Test func test_Async_Offset() { 57 | let view = EmojiText(verbatim: "Hello Async :async: and :async_offset:", emojis: [Emojis.async, Emojis.asyncWithOffset]) 58 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 59 | assertSnapshot(of: view, as: .image) 60 | } 61 | 62 | @Test func test_Async_Offset_Positive() { 63 | let view = EmojiText(verbatim: "Hello Async :async:", emojis: [Emojis.async]) 64 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 65 | .emojiText.baselineOffset(8) 66 | assertSnapshot(of: view, as: .image) 67 | } 68 | 69 | @Test func test_Async_Offset_Negative() { 70 | let view = EmojiText(verbatim: "Hello Async :async:", emojis: [Emojis.async]) 71 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 72 | .emojiText.baselineOffset(-8) 73 | assertSnapshot(of: view, as: .image) 74 | } 75 | 76 | @MainActor 77 | func test_Async_Markdown() { 78 | let view = EmojiText(markdown: "**Hello** _Async_ :async:", emojis: [Emojis.async]) 79 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 80 | assertSnapshot(of: view, as: .image) 81 | } 82 | 83 | @Test func test_iPhone() { 84 | let view = EmojiText(verbatim: "SF Symbol for iPhone: :iphone:", emojis: [Emojis.iPhone]) 85 | assertSnapshot(of: view, as: .image) 86 | } 87 | 88 | @Test func test_iPhone_Scaled() { 89 | let view = EmojiText(verbatim: "SF Symbol for iPhone: :iphone:", emojis: [Emojis.iPhone]) 90 | .font(.largeTitle) 91 | assertSnapshot(of: view, as: .image) 92 | } 93 | 94 | @Test func test_iPhone_RenderingMode() { 95 | let view = EmojiText(verbatim: "SF Symbol for iPhone: :iphone:", emojis: [Emojis.iPhone(renderingMode: .template)]) 96 | assertSnapshot(of: view, as: .image) 97 | } 98 | 99 | @Test func test_Multiple() { 100 | let view = EmojiText(verbatim: "Hello :face.smiling: how are you? :face.dashed:", emojis: Emojis.multiple) 101 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 102 | assertSnapshot(of: view, as: .image) 103 | } 104 | 105 | @Test func test_Prepend_Append() { 106 | let view = EmojiText(verbatim: "Hello :face.smiling: how are you? :face.dashed:", emojis: Emojis.multiple) 107 | .prepend { 108 | Text("Prepended - ") 109 | } 110 | .append { 111 | Text(" - Appended") 112 | } 113 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 114 | assertSnapshot(of: view, as: .image) 115 | } 116 | 117 | @Test func test_Wide() { 118 | let view = EmojiText(verbatim: "Hello Wide :wide:", emojis: [Emojis.wide]) 119 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 120 | assertSnapshot(of: view, as: .image) 121 | } 122 | 123 | @Test func test_Wide_Custom_Scaled() { 124 | let view = EmojiText(verbatim: "Hello Wide :wide:", emojis: [Emojis.wide]) 125 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 126 | .emojiText.size(30) 127 | assertSnapshot(of: view, as: .image) 128 | } 129 | 130 | @Test func test_EmojiInMarkdown() { 131 | let view = EmojiText(markdown: "**Hello :async:** _Async :async:_ :async:", emojis: [Emojis.async]) 132 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 133 | assertSnapshot(of: view, as: .image) 134 | } 135 | 136 | @Test func test_EmojiInMarkdownNested() { 137 | let view = EmojiText(markdown: "**Hello :async: _World_** with `code` and Mi**x***e*d", emojis: [Emojis.async]) 138 | .environment(\.emojiText.asyncEmojiProvider, TestEmojiProvider()) 139 | assertSnapshot(of: view, as: .image) 140 | } 141 | 142 | @Test func test_Markdown_InlineOnlyPreservingWhitespace() { 143 | let markdown = """ 144 | # Title 1 145 | 146 | ## Title 2 147 | 148 | ### Title 3 149 | 150 | **Bold** 151 | 152 | *Italic* 153 | 154 | 1. List 155 | 2. List 156 | 157 | * List 158 | * List 159 | 160 | `inline code` 161 | 162 | ```swift 163 | code block 164 | ``` 165 | 166 | > quote 167 | """ 168 | let view = EmojiText(markdown: markdown, interpretedSyntax: .inlineOnlyPreservingWhitespace, emojis: []) 169 | assertSnapshot(of: view, as: .image) 170 | } 171 | 172 | @Test func test_Markdown_Full() { 173 | let markdown = """ 174 | # Title 1 175 | 176 | ## Title 2 177 | 178 | ### Title 3 179 | 180 | **Bold** 181 | 182 | *Italic* 183 | 184 | 1. List 185 | 2. List 186 | 187 | * List 188 | * List 189 | 190 | `inline code` 191 | 192 | ```swift 193 | code block 194 | ``` 195 | 196 | > quote 197 | """ 198 | let view = EmojiText(markdown: markdown, interpretedSyntax: .full, emojis: []) 199 | assertSnapshot(of: view, as: .image) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 David Walter (davidwalter.at) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Sources/EmojiText/EmojiText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmojiText.swift 3 | // EmojiText 4 | // 5 | // Created by David Walter on 11.01.23. 6 | // 7 | 8 | import SwiftUI 9 | import OSLog 10 | 11 | /// A view that displays one or more lines of text with support for custom emojis. 12 | /// 13 | /// Custom Emojis are in the format `:emoji:`. 14 | /// Supports local and remote custom emojis. 15 | /// 16 | /// Remote emojis are resolved using [Nuke](https://github.com/kean/Nuke) 17 | @MainActor public struct EmojiText: View { 18 | @Environment(\.font) var font 19 | @Environment(\.dynamicTypeSize) var dynamicTypeSize 20 | @Environment(\.displayScale) var displayScale 21 | @Environment(\.colorScheme) var colorScheme 22 | 23 | @Environment(\.emojiText.syncEmojiProvider) var syncEmojiProvider 24 | @Environment(\.emojiText.asyncEmojiProvider) var asyncEmojiProvider 25 | 26 | @Environment(\.emojiText.placeholder) var placeholder 27 | @Environment(\.emojiText.size) var size 28 | @Environment(\.emojiText.baselineOffset) var baselineOffset 29 | #if os(watchOS) || os(macOS) 30 | @Environment(\.emojiText.timer) var timer 31 | #endif 32 | @Environment(\.emojiText.animatedMode) var animatedMode 33 | 34 | let raw: String 35 | let emojis: [any CustomEmoji] 36 | let renderer: EmojiRenderer 37 | 38 | var prepend: (() -> Text)? 39 | var append: (() -> Text)? 40 | 41 | var shouldAnimateIfNeeded: Bool = false 42 | 43 | @State var renderedEmojis: [String: RenderedEmoji]? 44 | @State var renderTime: CFTimeInterval = 0 45 | 46 | public var body: some View { 47 | makeContent 48 | .task(id: hashValue, priority: .high) { 49 | guard !emojis.isEmpty else { 50 | renderedEmojis = [:] 51 | return 52 | } 53 | 54 | // Hash of currently displayed emojis 55 | let renderedHash = renderedEmojis.hashValue 56 | var emojis: [String: RenderedEmoji] = renderedEmojis ?? [:] 57 | 58 | // Load emojis. Will set placeholders for lazy emojis 59 | emojis = emojis.merging(loadEmojis()) { current, new in 60 | if current.hasSameSource(as: new) { 61 | if !new.isPlaceholder || current.isPlaceholder { 62 | return new 63 | } else { 64 | return current 65 | } 66 | } else { 67 | return new 68 | } 69 | } 70 | renderedEmojis = emojis 71 | 72 | // Load lazy emojis if needed (e.g. placeholders were set or source emojis changed) 73 | if renderedHash != emojis.hashValue || emojis.contains(where: \.value.isPlaceholder) { 74 | emojis = emojis.merging(await loadLazyEmojis()) { _, new in 75 | new 76 | } 77 | renderedEmojis = emojis 78 | } 79 | 80 | guard shouldAnimateIfNeeded, needsAnimation else { return } 81 | 82 | #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(visionOS) 83 | for await targetTimestamp in CADisplayLink.publish(mode: .common, stopOnLowPowerMode: animatedMode.disabledOnLowPower).targetTimestamps { 84 | renderTime = targetTimestamp 85 | } 86 | #else 87 | for await time in timer.values(stopOnLowPowerMode: animatedMode.disabledOnLowPower) { 88 | renderTime = time.timeIntervalSinceReferenceDate as CFTimeInterval 89 | } 90 | #endif 91 | } 92 | } 93 | 94 | var makeContent: Text { 95 | let result: Text 96 | 97 | if needsAnimation { 98 | result = renderer.renderAnimated(string: raw, emojis: renderedEmojis ?? loadEmojis(), size: size, at: renderTime) 99 | } else { 100 | result = renderer.render(string: raw, emojis: renderedEmojis ?? loadEmojis(), size: size) 101 | } 102 | 103 | return [prepend?(), result, append?()] 104 | .compactMap { $0 } 105 | .joined() 106 | } 107 | 108 | // MARK: - Load Emojis 109 | 110 | func loadEmojis() -> [String: RenderedEmoji] { 111 | let font = EmojiFont.preferredFont(from: font, for: dynamicTypeSize) 112 | let baselineOffset = baselineOffset ?? -(font.pointSize - font.capHeight) / 2 113 | 114 | var renderedEmojis = [String: RenderedEmoji]() 115 | 116 | for emoji in emojis { 117 | switch emoji { 118 | case let sfSymbolEmoji as SFSymbolEmoji: 119 | // SF Symbol emoji don't require a placeholder as they can be loaded instantly 120 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 121 | from: sfSymbolEmoji 122 | ) 123 | case let emoji as any SyncCustomEmoji: 124 | if let image = syncEmojiProvider.emojiImage(emoji: emoji, height: targetHeight) { 125 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 126 | from: emoji, 127 | image: RawImage(image: image), 128 | animated: shouldAnimateIfNeeded, 129 | targetHeight: targetHeight, 130 | baselineOffset: baselineOffset 131 | ) 132 | } else { 133 | // Sync emoji wasn't loaded and a placeholder will be used instead 134 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 135 | from: emoji, 136 | placeholder: placeholder, 137 | targetHeight: targetHeight, 138 | baselineOffset: baselineOffset 139 | ) 140 | } 141 | case let emoji as any AsyncCustomEmoji: 142 | // Try to load remote emoji from cache 143 | let resizeHeight = targetHeight * displayScale 144 | if let image = asyncEmojiProvider.cachedEmojiImage(emoji: emoji, height: resizeHeight) { 145 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 146 | from: emoji, 147 | image: RawImage(image: image), 148 | animated: shouldAnimateIfNeeded, 149 | targetHeight: targetHeight, 150 | baselineOffset: baselineOffset 151 | ) 152 | } else { 153 | // Async emoji wasn't found in cache and a placeholder will be used instead 154 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 155 | from: emoji, 156 | placeholder: placeholder, 157 | targetHeight: targetHeight, 158 | baselineOffset: baselineOffset 159 | ) 160 | } 161 | default: 162 | // Set a placeholder for all other emoji 163 | renderedEmojis[emoji.shortcode] = RenderedEmoji( 164 | from: emoji, 165 | placeholder: placeholder, 166 | targetHeight: targetHeight, 167 | baselineOffset: baselineOffset 168 | ) 169 | } 170 | } 171 | 172 | return renderedEmojis 173 | } 174 | 175 | func loadLazyEmojis() async -> [String: RenderedEmoji] { 176 | let font = EmojiFont.preferredFont(from: font, for: dynamicTypeSize) 177 | let baselineOffset = baselineOffset ?? -(font.pointSize - font.capHeight) / 2 178 | let resizeHeight = targetHeight * displayScale 179 | 180 | return await withTaskGroup(of: RenderedEmoji?.self, returning: [String: RenderedEmoji].self) { [asyncEmojiProvider, targetHeight, shouldAnimateIfNeeded] group in 181 | for emoji in emojis { 182 | switch emoji { 183 | case let emoji as any AsyncCustomEmoji: 184 | _ = group.addTaskUnlessCancelled { 185 | do { 186 | let image: RawImage 187 | let data = try await asyncEmojiProvider.fetchEmojiData(emoji: emoji, height: resizeHeight) 188 | if shouldAnimateIfNeeded { 189 | image = try RawImage(animated: data) 190 | } else { 191 | image = try RawImage(static: data) 192 | } 193 | return RenderedEmoji( 194 | from: emoji, 195 | image: image, 196 | animated: shouldAnimateIfNeeded, 197 | targetHeight: targetHeight, 198 | baselineOffset: baselineOffset 199 | ) 200 | } catch { 201 | Logger.emojiText.error("Unable to load '\(type(of: emoji))' with code '\(emoji.shortcode)': \(error.localizedDescription)") 202 | return nil 203 | } 204 | } 205 | default: 206 | continue 207 | } 208 | } 209 | // Collect TaskGroup results 210 | var result: [String: RenderedEmoji] = [:] 211 | for await emoji in group { 212 | if let emoji { 213 | result[emoji.shortcode] = emoji 214 | } 215 | } 216 | return result 217 | } 218 | } 219 | 220 | // MARK: - Initializers 221 | 222 | /// Initialize a Markdown formatted ``EmojiText`` with support for custom emojis. 223 | /// 224 | /// - Parameters: 225 | /// - markdown: The string that contains the Markdown formatting. 226 | /// - interpretedSyntax: The syntax for intepreting a Markdown string. Defaults to `.inlineOnlyPreservingWhitespace`. 227 | /// - emojis: The custom emojis to render. 228 | /// - shoulOmitSpacesBetweenEmojis: Whether to omit spaces between emojis. Defaults to `true.` 229 | /// 230 | /// > Info: 231 | /// > Consider removing spaces between emojis as this will often drastically reduce 232 | /// the amount of text contactenations needed to render the emojis. 233 | /// > 234 | /// > There is a limit in SwiftUI Text concatenations and if this limit is reached the application will crash. 235 | public init( 236 | markdown content: String, 237 | interpretedSyntax: AttributedString.MarkdownParsingOptions.InterpretedSyntax = .inlineOnlyPreservingWhitespace, 238 | emojis: [any CustomEmoji], 239 | shouldOmitSpacesBetweenEmojis: Bool = true 240 | ) { 241 | self.renderer = MarkdownEmojiRenderer(shouldOmitSpacesBetweenEmojis: shouldOmitSpacesBetweenEmojis, interpretedSyntax: interpretedSyntax) 242 | self.raw = content 243 | self.emojis = emojis.filter { content.contains(":\($0.shortcode):") } 244 | } 245 | 246 | /// Initialize a ``EmojiText`` with support for custom emojis. 247 | /// 248 | /// - Parameters: 249 | /// - verbatim: A string to display without localization. 250 | /// - emojis: The custom emojis to render. 251 | /// - shoulOmitSpacesBetweenEmojis: Whether to omit spaces between emojis. Defaults to `true. 252 | /// 253 | /// > Info: 254 | /// > Consider removing spaces between emojis as this will often drastically reduce 255 | /// the amount of text contactenations needed to render the emojis. 256 | /// > 257 | /// > There is a limit in SwiftUI Text concatenations and if this limit is reached the application will crash. 258 | public init( 259 | verbatim content: String, 260 | emojis: [any CustomEmoji], 261 | shouldOmitSpacesBetweenEmojis: Bool = true 262 | ) { 263 | self.renderer = VerbatimEmojiRenderer(shouldOmitSpacesBetweenEmojis: shouldOmitSpacesBetweenEmojis) 264 | self.raw = content 265 | self.emojis = emojis.filter { content.contains(":\($0.shortcode):") } 266 | } 267 | 268 | /// Initialize a ``EmojiText`` with support for custom emojis. 269 | /// 270 | /// - Parameters: 271 | /// - content: A string value to display without localization. 272 | /// - emojis: The custom emojis to render. 273 | /// 274 | /// > Info: 275 | /// > Consider removing spaces between emojis as this will often drastically reduce 276 | /// the amount of text contactenations needed to render the emojis. 277 | /// > 278 | /// > There is a limit in SwiftUI Text concatenations and if this limit is reached the application will crash. 279 | public init( 280 | _ content: S, 281 | emojis: [any CustomEmoji], 282 | shouldOmitSpacesBetweenEmojis: Bool = true 283 | ) where S: StringProtocol { 284 | self.init(verbatim: String(content), emojis: emojis, shouldOmitSpacesBetweenEmojis: shouldOmitSpacesBetweenEmojis) 285 | } 286 | 287 | // MARK: - Modifier 288 | 289 | /// Prepend `Text` to the ``EmojiText`` view. 290 | /// 291 | /// - Parameter text: Callback generating the text to prepend 292 | /// - Returns: ``EmojiText`` with some text prepended 293 | public func prepend(text: @Sendable @escaping () -> Text) -> Self { 294 | var view = self 295 | view.prepend = text 296 | return view 297 | } 298 | 299 | /// Append `Text` to the ``EmojiText`` view. 300 | /// 301 | /// - Parameter text: Callback generating the text to append 302 | /// - Returns: ``EmojiText`` with some text appended 303 | public func append(text: @Sendable @escaping () -> Text) -> Self { 304 | var view = self 305 | view.append = text 306 | return view 307 | } 308 | 309 | /// Enable animated emoji 310 | /// 311 | /// - Parameter value: Enable or disable the animated emoji 312 | /// - Returns: ``EmojiText`` with animated emoji enabled or disabled. 313 | public func animated(_ value: Bool = true) -> Self { 314 | var view = self 315 | view.shouldAnimateIfNeeded = value 316 | return view 317 | } 318 | 319 | // MARK: - Helper 320 | 321 | // swiftlint:disable:next legacy_hashing 322 | var hashValue: Int { 323 | var hasher = Hasher() 324 | hasher.combine(raw) 325 | for emoji in emojis { 326 | hasher.combine(emoji) 327 | } 328 | hasher.combine(shouldAnimateIfNeeded) 329 | hasher.combine(size) 330 | hasher.combine(animatedMode) 331 | hasher.combine(displayScale) 332 | hasher.combine(colorScheme) 333 | hasher.combine(dynamicTypeSize) 334 | return hasher.finalize() 335 | } 336 | 337 | var targetHeight: CGFloat { 338 | if let emojiSize = size { 339 | return emojiSize 340 | } else { 341 | let font = EmojiFont.preferredFont(from: font, for: dynamicTypeSize) 342 | return font.pointSize 343 | } 344 | } 345 | 346 | var needsAnimation: Bool { 347 | guard let renderedEmojis else { return false } 348 | 349 | switch animatedMode { 350 | case .never: 351 | return false 352 | default: 353 | return renderedEmojis.contains { $1.isAnimated } 354 | } 355 | } 356 | } 357 | 358 | #if DEBUG 359 | #Preview { 360 | List { 361 | Section { 362 | EmojiText(verbatim: "Hello Emoji :a:", emojis: .emojis) 363 | EmojiText(verbatim: "Hello iPhone :iphone:", emojis: .emojis) 364 | EmojiText(verbatim: "Hello :a: :a: Double", emojis: .emojis) 365 | EmojiText(verbatim: "Hello Wide :wide:", emojis: .emojis) 366 | } header: { 367 | Text("Verbatim") 368 | } 369 | 370 | Section { 371 | EmojiText(markdown: "**Hello** _Emoji_ :a:", emojis: .emojis) 372 | EmojiText(markdown: "**Hello :a: :a: _Double_**", emojis: .emojis) 373 | } header: { 374 | Text("Markdown") 375 | } 376 | } 377 | } 378 | #endif 379 | -------------------------------------------------------------------------------- /Test/Test.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 631ECE5B2B6FA2B10051E3CB /* LocalEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631ECE5A2B6FA2B10051E3CB /* LocalEmojiView.swift */; }; 11 | 6334D1E62B33B4B500E9D8DE /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D1E52B33B4B500E9D8DE /* TestApp.swift */; }; 12 | 6334D1E82B33B4B500E9D8DE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D1E72B33B4B500E9D8DE /* ContentView.swift */; }; 13 | 6334D1EA2B33B4B600E9D8DE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6334D1E92B33B4B600E9D8DE /* Assets.xcassets */; }; 14 | 6334D1EE2B33B4B600E9D8DE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6334D1ED2B33B4B600E9D8DE /* Preview Assets.xcassets */; }; 15 | 6334D2122B33B4DF00E9D8DE /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 6334D2112B33B4DF00E9D8DE /* SnapshotTesting */; }; 16 | 6334D21B2B33B51B00E9D8DE /* EmojiText in Frameworks */ = {isa = PBXBuildFile; productRef = 6334D21A2B33B51B00E9D8DE /* EmojiText */; }; 17 | 6334D2222B33B64F00E9D8DE /* AnimatedEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D21C2B33B64F00E9D8DE /* AnimatedEmojiView.swift */; }; 18 | 6334D2232B33B64F00E9D8DE /* EmojiTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D21D2B33B64F00E9D8DE /* EmojiTestView.swift */; }; 19 | 6334D2252B33B64F00E9D8DE /* RemoteEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D21F2B33B64F00E9D8DE /* RemoteEmojiView.swift */; }; 20 | 6334D2262B33B64F00E9D8DE /* ChangingRemoteEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D2202B33B64F00E9D8DE /* ChangingRemoteEmojiView.swift */; }; 21 | 6334D2272B33B64F00E9D8DE /* SFSymbolEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D2212B33B64F00E9D8DE /* SFSymbolEmojiView.swift */; }; 22 | 6334D2312B33B6DC00E9D8DE /* EmojiTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D22D2B33B6DC00E9D8DE /* EmojiTextTests.swift */; }; 23 | 6334D2342B33B6DC00E9D8DE /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334D2302B33B6DC00E9D8DE /* Emojis.swift */; }; 24 | 6352F9C62C44177C003ABABC /* URLSessionEmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6352F9C52C44177C003ABABC /* URLSessionEmojiProvider.swift */; }; 25 | 6352F9C92C441A68003ABABC /* UpsideDownEmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6352F9C82C441A68003ABABC /* UpsideDownEmojiProvider.swift */; }; 26 | 6353366B2B6852B5004E4DD2 /* HTML2Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 6353366A2B6852B5004E4DD2 /* HTML2Markdown */; }; 27 | 639A28012DB3925900681CBA /* NukeEmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639A28002DB3925900681CBA /* NukeEmojiProvider.swift */; }; 28 | 639A28042DB3927300681CBA /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 639A28032DB3927300681CBA /* Nuke */; }; 29 | 639F664C2B63ED4D00B7F747 /* DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639F66492B63ED4D00B7F747 /* DTOs.swift */; }; 30 | 639F664D2B63ED4D00B7F747 /* MastodonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639F664A2B63ED4D00B7F747 /* MastodonAPI.swift */; }; 31 | 639F664E2B63ED4D00B7F747 /* MastodonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639F664B2B63ED4D00B7F747 /* MastodonView.swift */; }; 32 | 63D7A9AB2CACA88700807EE1 /* TestEmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D7A9AA2CACA88700807EE1 /* TestEmojiProvider.swift */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXContainerItemProxy section */ 36 | 6334D1F42B33B4B600E9D8DE /* PBXContainerItemProxy */ = { 37 | isa = PBXContainerItemProxy; 38 | containerPortal = 6334D1DA2B33B4B500E9D8DE /* Project object */; 39 | proxyType = 1; 40 | remoteGlobalIDString = 6334D1E12B33B4B500E9D8DE; 41 | remoteInfo = Test; 42 | }; 43 | /* End PBXContainerItemProxy section */ 44 | 45 | /* Begin PBXFileReference section */ 46 | 631ECE5A2B6FA2B10051E3CB /* LocalEmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalEmojiView.swift; sourceTree = ""; }; 47 | 6334D1E22B33B4B500E9D8DE /* Test.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Test.app; sourceTree = BUILT_PRODUCTS_DIR; }; 48 | 6334D1E52B33B4B500E9D8DE /* TestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 49 | 6334D1E72B33B4B500E9D8DE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 50 | 6334D1E92B33B4B600E9D8DE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 51 | 6334D1EB2B33B4B600E9D8DE /* Test.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Test.entitlements; sourceTree = ""; }; 52 | 6334D1ED2B33B4B600E9D8DE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 53 | 6334D1F32B33B4B600E9D8DE /* SnapshotTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SnapshotTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 54 | 6334D21C2B33B64F00E9D8DE /* AnimatedEmojiView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedEmojiView.swift; sourceTree = ""; }; 55 | 6334D21D2B33B64F00E9D8DE /* EmojiTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiTestView.swift; sourceTree = ""; }; 56 | 6334D21F2B33B64F00E9D8DE /* RemoteEmojiView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteEmojiView.swift; sourceTree = ""; }; 57 | 6334D2202B33B64F00E9D8DE /* ChangingRemoteEmojiView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangingRemoteEmojiView.swift; sourceTree = ""; }; 58 | 6334D2212B33B64F00E9D8DE /* SFSymbolEmojiView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SFSymbolEmojiView.swift; sourceTree = ""; }; 59 | 6334D22D2B33B6DC00E9D8DE /* EmojiTextTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiTextTests.swift; sourceTree = ""; }; 60 | 6334D2302B33B6DC00E9D8DE /* Emojis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = ""; }; 61 | 6334D23B2B33BD5800E9D8DE /* SnapshotTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SnapshotTests.xctestplan; sourceTree = ""; }; 62 | 6352F9C52C44177C003ABABC /* URLSessionEmojiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionEmojiProvider.swift; sourceTree = ""; }; 63 | 6352F9C82C441A68003ABABC /* UpsideDownEmojiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpsideDownEmojiProvider.swift; sourceTree = ""; }; 64 | 639A28002DB3925900681CBA /* NukeEmojiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeEmojiProvider.swift; sourceTree = ""; }; 65 | 639F66492B63ED4D00B7F747 /* DTOs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DTOs.swift; path = App/Mastodon/DTOs.swift; sourceTree = SOURCE_ROOT; }; 66 | 639F664A2B63ED4D00B7F747 /* MastodonAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MastodonAPI.swift; path = App/Mastodon/MastodonAPI.swift; sourceTree = SOURCE_ROOT; }; 67 | 639F664B2B63ED4D00B7F747 /* MastodonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MastodonView.swift; path = App/Mastodon/MastodonView.swift; sourceTree = SOURCE_ROOT; }; 68 | 63D7A9AA2CACA88700807EE1 /* TestEmojiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestEmojiProvider.swift; sourceTree = ""; }; 69 | /* End PBXFileReference section */ 70 | 71 | /* Begin PBXFrameworksBuildPhase section */ 72 | 6334D1DF2B33B4B500E9D8DE /* Frameworks */ = { 73 | isa = PBXFrameworksBuildPhase; 74 | buildActionMask = 2147483647; 75 | files = ( 76 | 639A28042DB3927300681CBA /* Nuke in Frameworks */, 77 | 6353366B2B6852B5004E4DD2 /* HTML2Markdown in Frameworks */, 78 | 6334D21B2B33B51B00E9D8DE /* EmojiText in Frameworks */, 79 | ); 80 | runOnlyForDeploymentPostprocessing = 0; 81 | }; 82 | 6334D1F02B33B4B600E9D8DE /* Frameworks */ = { 83 | isa = PBXFrameworksBuildPhase; 84 | buildActionMask = 2147483647; 85 | files = ( 86 | 6334D2122B33B4DF00E9D8DE /* SnapshotTesting in Frameworks */, 87 | ); 88 | runOnlyForDeploymentPostprocessing = 0; 89 | }; 90 | /* End PBXFrameworksBuildPhase section */ 91 | 92 | /* Begin PBXGroup section */ 93 | 631ECE572B6F933B0051E3CB /* Frameworks */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | ); 97 | name = Frameworks; 98 | sourceTree = ""; 99 | }; 100 | 6334D1D92B33B4B500E9D8DE = { 101 | isa = PBXGroup; 102 | children = ( 103 | 6334D1E42B33B4B500E9D8DE /* App */, 104 | 6334D1F62B33B4B600E9D8DE /* Tests */, 105 | 6334D1E32B33B4B500E9D8DE /* Products */, 106 | 631ECE572B6F933B0051E3CB /* Frameworks */, 107 | ); 108 | sourceTree = ""; 109 | }; 110 | 6334D1E32B33B4B500E9D8DE /* Products */ = { 111 | isa = PBXGroup; 112 | children = ( 113 | 6334D1E22B33B4B500E9D8DE /* Test.app */, 114 | 6334D1F32B33B4B600E9D8DE /* SnapshotTests.xctest */, 115 | ); 116 | name = Products; 117 | sourceTree = ""; 118 | }; 119 | 6334D1E42B33B4B500E9D8DE /* App */ = { 120 | isa = PBXGroup; 121 | children = ( 122 | 6352F9C72C441A59003ABABC /* Provider */, 123 | 63932D9A2B64377200E193FB /* Mastodon */, 124 | 6334D1E52B33B4B500E9D8DE /* TestApp.swift */, 125 | 6334D1E72B33B4B500E9D8DE /* ContentView.swift */, 126 | 6334D21C2B33B64F00E9D8DE /* AnimatedEmojiView.swift */, 127 | 6334D2202B33B64F00E9D8DE /* ChangingRemoteEmojiView.swift */, 128 | 6334D21D2B33B64F00E9D8DE /* EmojiTestView.swift */, 129 | 6334D21F2B33B64F00E9D8DE /* RemoteEmojiView.swift */, 130 | 6334D2212B33B64F00E9D8DE /* SFSymbolEmojiView.swift */, 131 | 631ECE5A2B6FA2B10051E3CB /* LocalEmojiView.swift */, 132 | 6334D1E92B33B4B600E9D8DE /* Assets.xcassets */, 133 | 6334D1EB2B33B4B600E9D8DE /* Test.entitlements */, 134 | 6334D1EC2B33B4B600E9D8DE /* Preview Content */, 135 | ); 136 | path = App; 137 | sourceTree = ""; 138 | }; 139 | 6334D1EC2B33B4B600E9D8DE /* Preview Content */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | 6334D1ED2B33B4B600E9D8DE /* Preview Assets.xcassets */, 143 | ); 144 | path = "Preview Content"; 145 | sourceTree = ""; 146 | }; 147 | 6334D1F62B33B4B600E9D8DE /* Tests */ = { 148 | isa = PBXGroup; 149 | children = ( 150 | 6334D23B2B33BD5800E9D8DE /* SnapshotTests.xctestplan */, 151 | 6334D2302B33B6DC00E9D8DE /* Emojis.swift */, 152 | 6334D22D2B33B6DC00E9D8DE /* EmojiTextTests.swift */, 153 | 63D7A9AA2CACA88700807EE1 /* TestEmojiProvider.swift */, 154 | ); 155 | path = Tests; 156 | sourceTree = ""; 157 | }; 158 | 6352F9C72C441A59003ABABC /* Provider */ = { 159 | isa = PBXGroup; 160 | children = ( 161 | 6352F9C52C44177C003ABABC /* URLSessionEmojiProvider.swift */, 162 | 6352F9C82C441A68003ABABC /* UpsideDownEmojiProvider.swift */, 163 | 639A28002DB3925900681CBA /* NukeEmojiProvider.swift */, 164 | ); 165 | path = Provider; 166 | sourceTree = ""; 167 | }; 168 | 63932D9A2B64377200E193FB /* Mastodon */ = { 169 | isa = PBXGroup; 170 | children = ( 171 | 639F66492B63ED4D00B7F747 /* DTOs.swift */, 172 | 639F664A2B63ED4D00B7F747 /* MastodonAPI.swift */, 173 | 639F664B2B63ED4D00B7F747 /* MastodonView.swift */, 174 | ); 175 | path = Mastodon; 176 | sourceTree = ""; 177 | }; 178 | /* End PBXGroup section */ 179 | 180 | /* Begin PBXNativeTarget section */ 181 | 6334D1E12B33B4B500E9D8DE /* Test */ = { 182 | isa = PBXNativeTarget; 183 | buildConfigurationList = 6334D2072B33B4B600E9D8DE /* Build configuration list for PBXNativeTarget "Test" */; 184 | buildPhases = ( 185 | 6334D1DE2B33B4B500E9D8DE /* Sources */, 186 | 6334D1DF2B33B4B500E9D8DE /* Frameworks */, 187 | 6334D1E02B33B4B500E9D8DE /* Resources */, 188 | ); 189 | buildRules = ( 190 | ); 191 | dependencies = ( 192 | ); 193 | name = Test; 194 | packageProductDependencies = ( 195 | 6334D21A2B33B51B00E9D8DE /* EmojiText */, 196 | 6353366A2B6852B5004E4DD2 /* HTML2Markdown */, 197 | 639A28032DB3927300681CBA /* Nuke */, 198 | ); 199 | productName = Test; 200 | productReference = 6334D1E22B33B4B500E9D8DE /* Test.app */; 201 | productType = "com.apple.product-type.application"; 202 | }; 203 | 6334D1F22B33B4B600E9D8DE /* SnapshotTests */ = { 204 | isa = PBXNativeTarget; 205 | buildConfigurationList = 6334D20A2B33B4B600E9D8DE /* Build configuration list for PBXNativeTarget "SnapshotTests" */; 206 | buildPhases = ( 207 | 6334D1EF2B33B4B600E9D8DE /* Sources */, 208 | 6334D1F02B33B4B600E9D8DE /* Frameworks */, 209 | 6334D1F12B33B4B600E9D8DE /* Resources */, 210 | ); 211 | buildRules = ( 212 | ); 213 | dependencies = ( 214 | 6334D1F52B33B4B600E9D8DE /* PBXTargetDependency */, 215 | ); 216 | name = SnapshotTests; 217 | packageProductDependencies = ( 218 | 6334D2112B33B4DF00E9D8DE /* SnapshotTesting */, 219 | ); 220 | productName = TestTests; 221 | productReference = 6334D1F32B33B4B600E9D8DE /* SnapshotTests.xctest */; 222 | productType = "com.apple.product-type.bundle.unit-test"; 223 | }; 224 | /* End PBXNativeTarget section */ 225 | 226 | /* Begin PBXProject section */ 227 | 6334D1DA2B33B4B500E9D8DE /* Project object */ = { 228 | isa = PBXProject; 229 | attributes = { 230 | BuildIndependentTargetsInParallel = 1; 231 | LastSwiftUpdateCheck = 1510; 232 | LastUpgradeCheck = 1600; 233 | TargetAttributes = { 234 | 6334D1E12B33B4B500E9D8DE = { 235 | CreatedOnToolsVersion = 15.1; 236 | }; 237 | 6334D1F22B33B4B600E9D8DE = { 238 | CreatedOnToolsVersion = 15.1; 239 | LastSwiftMigration = 1510; 240 | TestTargetID = 6334D1E12B33B4B500E9D8DE; 241 | }; 242 | }; 243 | }; 244 | buildConfigurationList = 6334D1DD2B33B4B500E9D8DE /* Build configuration list for PBXProject "Test" */; 245 | compatibilityVersion = "Xcode 14.0"; 246 | developmentRegion = en; 247 | hasScannedForEncodings = 0; 248 | knownRegions = ( 249 | en, 250 | Base, 251 | ); 252 | mainGroup = 6334D1D92B33B4B500E9D8DE; 253 | packageReferences = ( 254 | 6334D2102B33B4DF00E9D8DE /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 255 | 6334D2192B33B51B00E9D8DE /* XCLocalSwiftPackageReference ".." */, 256 | 635336692B6852B5004E4DD2 /* XCRemoteSwiftPackageReference "HTML2Markdown" */, 257 | 639A28022DB3927300681CBA /* XCRemoteSwiftPackageReference "Nuke" */, 258 | ); 259 | productRefGroup = 6334D1E32B33B4B500E9D8DE /* Products */; 260 | projectDirPath = ""; 261 | projectRoot = ""; 262 | targets = ( 263 | 6334D1E12B33B4B500E9D8DE /* Test */, 264 | 6334D1F22B33B4B600E9D8DE /* SnapshotTests */, 265 | ); 266 | }; 267 | /* End PBXProject section */ 268 | 269 | /* Begin PBXResourcesBuildPhase section */ 270 | 6334D1E02B33B4B500E9D8DE /* Resources */ = { 271 | isa = PBXResourcesBuildPhase; 272 | buildActionMask = 2147483647; 273 | files = ( 274 | 6334D1EE2B33B4B600E9D8DE /* Preview Assets.xcassets in Resources */, 275 | 6334D1EA2B33B4B600E9D8DE /* Assets.xcassets in Resources */, 276 | ); 277 | runOnlyForDeploymentPostprocessing = 0; 278 | }; 279 | 6334D1F12B33B4B600E9D8DE /* Resources */ = { 280 | isa = PBXResourcesBuildPhase; 281 | buildActionMask = 2147483647; 282 | files = ( 283 | ); 284 | runOnlyForDeploymentPostprocessing = 0; 285 | }; 286 | /* End PBXResourcesBuildPhase section */ 287 | 288 | /* Begin PBXSourcesBuildPhase section */ 289 | 6334D1DE2B33B4B500E9D8DE /* Sources */ = { 290 | isa = PBXSourcesBuildPhase; 291 | buildActionMask = 2147483647; 292 | files = ( 293 | 6334D2272B33B64F00E9D8DE /* SFSymbolEmojiView.swift in Sources */, 294 | 6352F9C62C44177C003ABABC /* URLSessionEmojiProvider.swift in Sources */, 295 | 639F664D2B63ED4D00B7F747 /* MastodonAPI.swift in Sources */, 296 | 6334D1E82B33B4B500E9D8DE /* ContentView.swift in Sources */, 297 | 639F664E2B63ED4D00B7F747 /* MastodonView.swift in Sources */, 298 | 6334D2252B33B64F00E9D8DE /* RemoteEmojiView.swift in Sources */, 299 | 639A28012DB3925900681CBA /* NukeEmojiProvider.swift in Sources */, 300 | 631ECE5B2B6FA2B10051E3CB /* LocalEmojiView.swift in Sources */, 301 | 6334D2232B33B64F00E9D8DE /* EmojiTestView.swift in Sources */, 302 | 6352F9C92C441A68003ABABC /* UpsideDownEmojiProvider.swift in Sources */, 303 | 6334D2222B33B64F00E9D8DE /* AnimatedEmojiView.swift in Sources */, 304 | 6334D1E62B33B4B500E9D8DE /* TestApp.swift in Sources */, 305 | 6334D2262B33B64F00E9D8DE /* ChangingRemoteEmojiView.swift in Sources */, 306 | 639F664C2B63ED4D00B7F747 /* DTOs.swift in Sources */, 307 | ); 308 | runOnlyForDeploymentPostprocessing = 0; 309 | }; 310 | 6334D1EF2B33B4B600E9D8DE /* Sources */ = { 311 | isa = PBXSourcesBuildPhase; 312 | buildActionMask = 2147483647; 313 | files = ( 314 | 6334D2342B33B6DC00E9D8DE /* Emojis.swift in Sources */, 315 | 63D7A9AB2CACA88700807EE1 /* TestEmojiProvider.swift in Sources */, 316 | 6334D2312B33B6DC00E9D8DE /* EmojiTextTests.swift in Sources */, 317 | ); 318 | runOnlyForDeploymentPostprocessing = 0; 319 | }; 320 | /* End PBXSourcesBuildPhase section */ 321 | 322 | /* Begin PBXTargetDependency section */ 323 | 6334D1F52B33B4B600E9D8DE /* PBXTargetDependency */ = { 324 | isa = PBXTargetDependency; 325 | target = 6334D1E12B33B4B500E9D8DE /* Test */; 326 | targetProxy = 6334D1F42B33B4B600E9D8DE /* PBXContainerItemProxy */; 327 | }; 328 | /* End PBXTargetDependency section */ 329 | 330 | /* Begin XCBuildConfiguration section */ 331 | 6334D2052B33B4B600E9D8DE /* Debug */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ALWAYS_SEARCH_USER_PATHS = NO; 335 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 336 | CLANG_ANALYZER_NONNULL = YES; 337 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 338 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 339 | CLANG_ENABLE_MODULES = YES; 340 | CLANG_ENABLE_OBJC_ARC = YES; 341 | CLANG_ENABLE_OBJC_WEAK = YES; 342 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 343 | CLANG_WARN_BOOL_CONVERSION = YES; 344 | CLANG_WARN_COMMA = YES; 345 | CLANG_WARN_CONSTANT_CONVERSION = YES; 346 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 347 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 348 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 349 | CLANG_WARN_EMPTY_BODY = YES; 350 | CLANG_WARN_ENUM_CONVERSION = YES; 351 | CLANG_WARN_INFINITE_RECURSION = YES; 352 | CLANG_WARN_INT_CONVERSION = YES; 353 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 354 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 355 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 356 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 357 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 358 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 359 | CLANG_WARN_STRICT_PROTOTYPES = YES; 360 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 361 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 362 | CLANG_WARN_UNREACHABLE_CODE = YES; 363 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 364 | COPY_PHASE_STRIP = NO; 365 | DEAD_CODE_STRIPPING = YES; 366 | DEBUG_INFORMATION_FORMAT = dwarf; 367 | ENABLE_STRICT_OBJC_MSGSEND = YES; 368 | ENABLE_TESTABILITY = YES; 369 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 370 | GCC_C_LANGUAGE_STANDARD = gnu17; 371 | GCC_DYNAMIC_NO_PIC = NO; 372 | GCC_NO_COMMON_BLOCKS = YES; 373 | GCC_OPTIMIZATION_LEVEL = 0; 374 | GCC_PREPROCESSOR_DEFINITIONS = ( 375 | "DEBUG=1", 376 | "$(inherited)", 377 | ); 378 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 379 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 380 | GCC_WARN_UNDECLARED_SELECTOR = YES; 381 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 382 | GCC_WARN_UNUSED_FUNCTION = YES; 383 | GCC_WARN_UNUSED_VARIABLE = YES; 384 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 385 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 386 | MTL_FAST_MATH = YES; 387 | ONLY_ACTIVE_ARCH = YES; 388 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 389 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 390 | }; 391 | name = Debug; 392 | }; 393 | 6334D2062B33B4B600E9D8DE /* Release */ = { 394 | isa = XCBuildConfiguration; 395 | buildSettings = { 396 | ALWAYS_SEARCH_USER_PATHS = NO; 397 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 398 | CLANG_ANALYZER_NONNULL = YES; 399 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 400 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 401 | CLANG_ENABLE_MODULES = YES; 402 | CLANG_ENABLE_OBJC_ARC = YES; 403 | CLANG_ENABLE_OBJC_WEAK = YES; 404 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 405 | CLANG_WARN_BOOL_CONVERSION = YES; 406 | CLANG_WARN_COMMA = YES; 407 | CLANG_WARN_CONSTANT_CONVERSION = YES; 408 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 409 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 410 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 411 | CLANG_WARN_EMPTY_BODY = YES; 412 | CLANG_WARN_ENUM_CONVERSION = YES; 413 | CLANG_WARN_INFINITE_RECURSION = YES; 414 | CLANG_WARN_INT_CONVERSION = YES; 415 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 416 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 417 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 418 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 419 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 420 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 421 | CLANG_WARN_STRICT_PROTOTYPES = YES; 422 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 423 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 424 | CLANG_WARN_UNREACHABLE_CODE = YES; 425 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 426 | COPY_PHASE_STRIP = NO; 427 | DEAD_CODE_STRIPPING = YES; 428 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 429 | ENABLE_NS_ASSERTIONS = NO; 430 | ENABLE_STRICT_OBJC_MSGSEND = YES; 431 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 432 | GCC_C_LANGUAGE_STANDARD = gnu17; 433 | GCC_NO_COMMON_BLOCKS = YES; 434 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 435 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 436 | GCC_WARN_UNDECLARED_SELECTOR = YES; 437 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 438 | GCC_WARN_UNUSED_FUNCTION = YES; 439 | GCC_WARN_UNUSED_VARIABLE = YES; 440 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 441 | MTL_ENABLE_DEBUG_INFO = NO; 442 | MTL_FAST_MATH = YES; 443 | SWIFT_COMPILATION_MODE = wholemodule; 444 | }; 445 | name = Release; 446 | }; 447 | 6334D2082B33B4B600E9D8DE /* Debug */ = { 448 | isa = XCBuildConfiguration; 449 | buildSettings = { 450 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 451 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 452 | CODE_SIGN_ENTITLEMENTS = App/Test.entitlements; 453 | CODE_SIGN_STYLE = Automatic; 454 | CURRENT_PROJECT_VERSION = 1; 455 | DEAD_CODE_STRIPPING = YES; 456 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 457 | ENABLE_PREVIEWS = YES; 458 | GENERATE_INFOPLIST_FILE = YES; 459 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 460 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 461 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 462 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 463 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 464 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 465 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 466 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 467 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 468 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 469 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 470 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 471 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 472 | MACOSX_DEPLOYMENT_TARGET = 14.0; 473 | MARKETING_VERSION = 1.0; 474 | PRODUCT_BUNDLE_IDENTIFIER = at.davidwalter.EmojiText.Test; 475 | PRODUCT_NAME = "$(TARGET_NAME)"; 476 | SDKROOT = auto; 477 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 478 | SUPPORTS_MACCATALYST = NO; 479 | SWIFT_EMIT_LOC_STRINGS = YES; 480 | SWIFT_VERSION = 6.0; 481 | TARGETED_DEVICE_FAMILY = "1,2,7"; 482 | }; 483 | name = Debug; 484 | }; 485 | 6334D2092B33B4B600E9D8DE /* Release */ = { 486 | isa = XCBuildConfiguration; 487 | buildSettings = { 488 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 489 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 490 | CODE_SIGN_ENTITLEMENTS = App/Test.entitlements; 491 | CODE_SIGN_STYLE = Automatic; 492 | CURRENT_PROJECT_VERSION = 1; 493 | DEAD_CODE_STRIPPING = YES; 494 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 495 | ENABLE_PREVIEWS = YES; 496 | GENERATE_INFOPLIST_FILE = YES; 497 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 498 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 499 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 500 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 501 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 502 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 503 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 504 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 505 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 506 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 507 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 508 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 509 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 510 | MACOSX_DEPLOYMENT_TARGET = 14.0; 511 | MARKETING_VERSION = 1.0; 512 | PRODUCT_BUNDLE_IDENTIFIER = at.davidwalter.EmojiText.Test; 513 | PRODUCT_NAME = "$(TARGET_NAME)"; 514 | SDKROOT = auto; 515 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 516 | SUPPORTS_MACCATALYST = NO; 517 | SWIFT_EMIT_LOC_STRINGS = YES; 518 | SWIFT_VERSION = 6.0; 519 | TARGETED_DEVICE_FAMILY = "1,2,7"; 520 | }; 521 | name = Release; 522 | }; 523 | 6334D20B2B33B4B600E9D8DE /* Debug */ = { 524 | isa = XCBuildConfiguration; 525 | buildSettings = { 526 | BUNDLE_LOADER = "$(TEST_HOST)"; 527 | CLANG_ENABLE_MODULES = YES; 528 | CODE_SIGN_STYLE = Automatic; 529 | CURRENT_PROJECT_VERSION = 1; 530 | DEAD_CODE_STRIPPING = YES; 531 | GENERATE_INFOPLIST_FILE = YES; 532 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 533 | MACOSX_DEPLOYMENT_TARGET = 14.2; 534 | MARKETING_VERSION = 1.0; 535 | PRODUCT_BUNDLE_IDENTIFIER = at.davidwalter.EmojiText.SnapshotTests; 536 | PRODUCT_NAME = "$(TARGET_NAME)"; 537 | SDKROOT = auto; 538 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 539 | SWIFT_EMIT_LOC_STRINGS = NO; 540 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 541 | SWIFT_STRICT_CONCURRENCY = complete; 542 | SWIFT_VERSION = 6.0; 543 | TARGETED_DEVICE_FAMILY = "1,2"; 544 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Test.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Test"; 545 | }; 546 | name = Debug; 547 | }; 548 | 6334D20C2B33B4B600E9D8DE /* Release */ = { 549 | isa = XCBuildConfiguration; 550 | buildSettings = { 551 | BUNDLE_LOADER = "$(TEST_HOST)"; 552 | CLANG_ENABLE_MODULES = YES; 553 | CODE_SIGN_STYLE = Automatic; 554 | CURRENT_PROJECT_VERSION = 1; 555 | DEAD_CODE_STRIPPING = YES; 556 | GENERATE_INFOPLIST_FILE = YES; 557 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 558 | MACOSX_DEPLOYMENT_TARGET = 14.2; 559 | MARKETING_VERSION = 1.0; 560 | PRODUCT_BUNDLE_IDENTIFIER = at.davidwalter.EmojiText.SnapshotTests; 561 | PRODUCT_NAME = "$(TARGET_NAME)"; 562 | SDKROOT = auto; 563 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 564 | SWIFT_EMIT_LOC_STRINGS = NO; 565 | SWIFT_STRICT_CONCURRENCY = complete; 566 | SWIFT_VERSION = 6.0; 567 | TARGETED_DEVICE_FAMILY = "1,2"; 568 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Test.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Test"; 569 | }; 570 | name = Release; 571 | }; 572 | /* End XCBuildConfiguration section */ 573 | 574 | /* Begin XCConfigurationList section */ 575 | 6334D1DD2B33B4B500E9D8DE /* Build configuration list for PBXProject "Test" */ = { 576 | isa = XCConfigurationList; 577 | buildConfigurations = ( 578 | 6334D2052B33B4B600E9D8DE /* Debug */, 579 | 6334D2062B33B4B600E9D8DE /* Release */, 580 | ); 581 | defaultConfigurationIsVisible = 0; 582 | defaultConfigurationName = Release; 583 | }; 584 | 6334D2072B33B4B600E9D8DE /* Build configuration list for PBXNativeTarget "Test" */ = { 585 | isa = XCConfigurationList; 586 | buildConfigurations = ( 587 | 6334D2082B33B4B600E9D8DE /* Debug */, 588 | 6334D2092B33B4B600E9D8DE /* Release */, 589 | ); 590 | defaultConfigurationIsVisible = 0; 591 | defaultConfigurationName = Release; 592 | }; 593 | 6334D20A2B33B4B600E9D8DE /* Build configuration list for PBXNativeTarget "SnapshotTests" */ = { 594 | isa = XCConfigurationList; 595 | buildConfigurations = ( 596 | 6334D20B2B33B4B600E9D8DE /* Debug */, 597 | 6334D20C2B33B4B600E9D8DE /* Release */, 598 | ); 599 | defaultConfigurationIsVisible = 0; 600 | defaultConfigurationName = Release; 601 | }; 602 | /* End XCConfigurationList section */ 603 | 604 | /* Begin XCLocalSwiftPackageReference section */ 605 | 6334D2192B33B51B00E9D8DE /* XCLocalSwiftPackageReference ".." */ = { 606 | isa = XCLocalSwiftPackageReference; 607 | relativePath = ..; 608 | }; 609 | /* End XCLocalSwiftPackageReference section */ 610 | 611 | /* Begin XCRemoteSwiftPackageReference section */ 612 | 6334D2102B33B4DF00E9D8DE /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { 613 | isa = XCRemoteSwiftPackageReference; 614 | repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing"; 615 | requirement = { 616 | kind = upToNextMajorVersion; 617 | minimumVersion = 1.17.5; 618 | }; 619 | }; 620 | 635336692B6852B5004E4DD2 /* XCRemoteSwiftPackageReference "HTML2Markdown" */ = { 621 | isa = XCRemoteSwiftPackageReference; 622 | repositoryURL = "git@github.com:divadretlaw/HTML2Markdown.git"; 623 | requirement = { 624 | kind = upToNextMajorVersion; 625 | minimumVersion = 3.0.2; 626 | }; 627 | }; 628 | 639A28022DB3927300681CBA /* XCRemoteSwiftPackageReference "Nuke" */ = { 629 | isa = XCRemoteSwiftPackageReference; 630 | repositoryURL = "https://github.com/kean/Nuke"; 631 | requirement = { 632 | kind = upToNextMajorVersion; 633 | minimumVersion = 12.8.0; 634 | }; 635 | }; 636 | /* End XCRemoteSwiftPackageReference section */ 637 | 638 | /* Begin XCSwiftPackageProductDependency section */ 639 | 6334D2112B33B4DF00E9D8DE /* SnapshotTesting */ = { 640 | isa = XCSwiftPackageProductDependency; 641 | package = 6334D2102B33B4DF00E9D8DE /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; 642 | productName = SnapshotTesting; 643 | }; 644 | 6334D21A2B33B51B00E9D8DE /* EmojiText */ = { 645 | isa = XCSwiftPackageProductDependency; 646 | productName = EmojiText; 647 | }; 648 | 6353366A2B6852B5004E4DD2 /* HTML2Markdown */ = { 649 | isa = XCSwiftPackageProductDependency; 650 | package = 635336692B6852B5004E4DD2 /* XCRemoteSwiftPackageReference "HTML2Markdown" */; 651 | productName = HTML2Markdown; 652 | }; 653 | 639A28032DB3927300681CBA /* Nuke */ = { 654 | isa = XCSwiftPackageProductDependency; 655 | package = 639A28022DB3927300681CBA /* XCRemoteSwiftPackageReference "Nuke" */; 656 | productName = Nuke; 657 | }; 658 | /* End XCSwiftPackageProductDependency section */ 659 | }; 660 | rootObject = 6334D1DA2B33B4B500E9D8DE /* Project object */; 661 | } 662 | --------------------------------------------------------------------------------