├── 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.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://swiftpackageindex.com/divadretlaw/EmojiText)
4 | [](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 |
--------------------------------------------------------------------------------