├── .editorconfig
├── .github
├── FUNDING.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
└── Ligature
│ ├── MockTextTokenizer.swift
│ ├── NSTextSelection+Extensions.swift
│ ├── Platform.swift
│ ├── SourceTokenizer.swift
│ ├── TextDirection+Extensions.swift
│ ├── TextInputStringTokenizer.swift
│ ├── TextRange+Bounded.swift
│ ├── TextTokenizer.swift
│ ├── TextView+TextRangeCalculating.swift
│ └── UTF16CodePointTextViewTextTokenizer.swift
└── Tests
└── LigatureTests
├── PlatformTests.swift
└── TextTokenizerTests.swift
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [mattmassicotte]
2 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - 'README.md'
9 | - 'CODE_OF_CONDUCT.md'
10 | - '.editorconfig'
11 | - '.spi.yml'
12 | pull_request:
13 | branches:
14 | - main
15 |
16 | jobs:
17 | test:
18 | name: Test
19 | timeout-minutes: 30
20 | runs-on: macOS-15
21 | env:
22 | DEVELOPER_DIR: /Applications/Xcode_16.1.app
23 | strategy:
24 | matrix:
25 | destination:
26 | - "platform=macOS"
27 | - "platform=macOS,variant=Mac Catalyst"
28 | - "platform=iOS Simulator,name=iPhone 16"
29 | - "platform=tvOS Simulator,name=Apple TV"
30 | - "platform=visionOS Simulator,name=Apple Vision Pro"
31 |
32 | steps:
33 | - name: Checkout
34 | uses: actions/checkout@v4
35 | - name: Test platform ${{ matrix.destination }}
36 | run: set -o pipefail && xcodebuild -scheme Ligature -destination "${{ matrix.destination }}" test | xcbeautify
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual
11 | identity and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the overall
27 | community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or advances of
32 | any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email address,
36 | without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at
64 | support@chimehq.com.
65 | All complaints will be reviewed and investigated promptly and fairly.
66 |
67 | All community leaders are obligated to respect the privacy and security of the
68 | reporter of any incident.
69 |
70 | ## Enforcement Guidelines
71 |
72 | Community leaders will follow these Community Impact Guidelines in determining
73 | the consequences for any action they deem in violation of this Code of Conduct:
74 |
75 | ### 1. Correction
76 |
77 | **Community Impact**: Use of inappropriate language or other behavior deemed
78 | unprofessional or unwelcome in the community.
79 |
80 | **Consequence**: A private, written warning from community leaders, providing
81 | clarity around the nature of the violation and an explanation of why the
82 | behavior was inappropriate. A public apology may be requested.
83 |
84 | ### 2. Warning
85 |
86 | **Community Impact**: A violation through a single incident or series of
87 | actions.
88 |
89 | **Consequence**: A warning with consequences for continued behavior. No
90 | interaction with the people involved, including unsolicited interaction with
91 | those enforcing the Code of Conduct, for a specified period of time. This
92 | includes avoiding interactions in community spaces as well as external channels
93 | like social media. Violating these terms may lead to a temporary or permanent
94 | ban.
95 |
96 | ### 3. Temporary Ban
97 |
98 | **Community Impact**: A serious violation of community standards, including
99 | sustained inappropriate behavior.
100 |
101 | **Consequence**: A temporary ban from any sort of interaction or public
102 | communication with the community for a specified period of time. No public or
103 | private interaction with the people involved, including unsolicited interaction
104 | with those enforcing the Code of Conduct, is allowed during this period.
105 | Violating these terms may lead to a permanent ban.
106 |
107 | ### 4. Permanent Ban
108 |
109 | **Community Impact**: Demonstrating a pattern of violation of community
110 | standards, including sustained inappropriate behavior, harassment of an
111 | individual, or aggression toward or disparagement of classes of individuals.
112 |
113 | **Consequence**: A permanent ban from any sort of public interaction within the
114 | community.
115 |
116 | ## Attribution
117 |
118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
119 | version 2.1, available at
120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
121 |
122 | Community Impact Guidelines were inspired by
123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
124 |
125 | For answers to common questions about this code of conduct, see the FAQ at
126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
127 | [https://www.contributor-covenant.org/translations][translations].
128 |
129 | [homepage]: https://www.contributor-covenant.org
130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
131 | [Mozilla CoC]: https://github.com/mozilla/diversity
132 | [FAQ]: https://www.contributor-covenant.org/faq
133 | [translations]: https://www.contributor-covenant.org/translations
134 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2024, Chime
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "5ff6c4c74b01337bc2c9319006e3d804345d67918fddb482b317f4dd354dd914",
3 | "pins" : [
4 | {
5 | "identity" : "glyph",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/ChimeHQ/Glyph",
8 | "state" : {
9 | "branch" : "main",
10 | "revision" : "1ad045842d04479c93a3eea3742ab79943313b79"
11 | }
12 | },
13 | {
14 | "identity" : "rearrange",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/ChimeHQ/Rearrange",
17 | "state" : {
18 | "branch" : "main",
19 | "revision" : "8e3b90208a7ed89f304e4daafd06b905f6657f6d"
20 | }
21 | }
22 | ],
23 | "version" : 3
24 | }
25 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Ligature",
7 | platforms: [
8 | .macOS(.v10_15),
9 | .iOS(.v13),
10 | .tvOS(.v13),
11 | .macCatalyst(.v13),
12 | .visionOS(.v1)
13 | ],
14 | products: [
15 | .library(name: "Ligature", targets: ["Ligature"]),
16 | ],
17 | dependencies: [
18 | .package(url: "https://github.com/ChimeHQ/Glyph", branch: "main"),
19 | .package(url: "https://github.com/ChimeHQ/Rearrange", branch: "main"),
20 | ],
21 | targets: [
22 | .target(name: "Ligature", dependencies: ["Glyph", "Rearrange"]),
23 | .testTarget(name: "LigatureTests", dependencies: ["Ligature"]),
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [![Build Status][build status badge]][build status]
4 | [![Platforms][platforms badge]][platforms]
5 | [![Documentation][documentation badge]][documentation]
6 | [![Matrix][matrix badge]][matrix]
7 |
8 |
9 |
10 | # Ligature
11 | A Swift package to aid in text selection, grouping, and manipulation.
12 |
13 | Ligature includes aliases and implementations as needed to make parts of the UIKit and AppKit text interfaces source-compatible. The core types actually go futher than this and should be fully text system-agnostic.
14 |
15 | You also might be interested in [Glyph][], a TextKit 1/2 abstraction system, as well as general AppKit/UIKit stuff like [NSUI][] or [KeyCodes][].
16 |
17 | [Glyph]: https://github.com/ChimeHQ/Glyph
18 | [NSUI]: https://github.com/mattmassicotte/NSUI
19 | [KeyCodes]: https://github.com/ChimeHQ/KeyCodes
20 |
21 | ## Installation
22 |
23 | ```swift
24 | dependencies: [
25 | .package(url: "https://github.com/ChimeHQ/Ligature", branch: "main")
26 | ],
27 | ```
28 |
29 | ## Usage
30 |
31 | The core protocol for the tokenization functionality is `TextTokenizer`. It is a little more abstract than `UITextInputTokenizer`, but ultimately compatible. With UIKit, `TextInputStringTokenizer` is just a typealias for `UITextInputStringTokenizer`. Ligature provides an implementation for use with AppKit.
32 |
33 | > [!WARNING]
34 | > While quite usable, there are features the `TextTokenizer` API supports that are not fully implemented by the AppKit implementation.
35 |
36 | ```swift
37 | // on UIKit
38 | let tokenizer = TextInputStringTokenizer(textInput: someUITextView)
39 |
40 | // with AppKit
41 | let tokenizer = TextInputStringTokenizer(textInput: someNSTextInputClient)
42 | ```
43 |
44 | Ligature uses platform-independent aliases to represent many text-related structures. For the most part, these are based on their UIKit representations. Typically, AppKit doesn't have a source-compatible implementation, so wrappers and/or compatible implementations are provided.
45 |
46 | ```swift
47 | typealias TextPosition = UITextPosition
48 | typealias TextRange = UITextRange
49 | typealias TextGranularity = UITextGranularity
50 | typealias TextStorageDirection = UITextStorageDirection
51 | typealias TextDirection = UITextDirection
52 | typealias UserInterfaceLayoutDirection = UIUserInterfaceLayoutDirection
53 | ```
54 |
55 | There are a variety of range/position models within AppKit, UIKit, and even between TextKit 1 and 2. Some abstraction is, unfortunately, required to model this, and that is not free. If it is important to operate within `NSRange` values, you can use `UTF16CodePointTextViewTextTokenizer` directly.
56 |
57 | ## Contributing and Collaboration
58 |
59 | I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][discord] are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me [here](https://www.massicotte.org/about).
60 |
61 | I prefer collaboration, and would love to find ways to work together if you have a similar project.
62 |
63 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace.
64 |
65 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md).
66 |
67 | [build status]: https://github.com/ChimeHQ/Ligature/actions
68 | [build status badge]: https://github.com/ChimeHQ/Ligature/workflows/CI/badge.svg
69 | [platforms]: https://swiftpackageindex.com/ChimeHQ/Ligature
70 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FLigature%2Fbadge%3Ftype%3Dplatforms
71 | [documentation]: https://swiftpackageindex.com/ChimeHQ/Ligature/main/documentation
72 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue
73 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org
74 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix
75 | [discord]: https://discord.gg/esFpX6sErJ
76 |
--------------------------------------------------------------------------------
/Sources/Ligature/MockTextTokenizer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Rearrange
3 |
4 | public final class MockTextTokenizer: TextTokenizer where R.Bound: Equatable {
5 | public typealias TextRange = R
6 |
7 | public enum Request: Equatable {
8 | case position(Position, TextGranularity, TextDirection)
9 | }
10 |
11 | public enum Response: Equatable {
12 | case position(Position?)
13 | case rangeEnclosingPosition(R?)
14 | }
15 |
16 | public private(set) var requests: [Request] = []
17 | public var responses: [Response] = []
18 |
19 | public init() {
20 | }
21 |
22 | public func closestMatchingVerticalLocation(to location: Int, above: Bool) -> Int? {
23 | return nil
24 | }
25 |
26 | public func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection, alignment: CGFloat?) -> Position? {
27 | requests.append(.position(position, granularity, direction))
28 |
29 | switch responses.removeFirst() {
30 | case let .position(value):
31 | return value
32 | default:
33 | fatalError("wrong return type")
34 | }
35 | }
36 |
37 | public func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> R? {
38 | return nil
39 | }
40 |
41 | public func isPosition(_ position: Position, atBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
42 | return false
43 | }
44 |
45 | public func isPosition(_ position: Position, withinTextUnit granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
46 | return false
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/Ligature/NSTextSelection+Extensions.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | #elseif canImport(UIKit)
4 | import UIKit
5 | #endif
6 |
7 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
8 | @available(watchOS, unavailable)
9 | extension NSTextSelection.Granularity {
10 | public init(_ granularity: TextGranularity) {
11 | switch granularity {
12 | case .character:
13 | self = .character
14 | case .paragraph:
15 | self = .paragraph
16 | case .word:
17 | self = .word
18 | case .sentence:
19 | self = .sentence
20 | case .line:
21 | self = .line
22 | case .document:
23 | self = .paragraph
24 | @unknown default:
25 | self = .character
26 | }
27 | }
28 |
29 | public var textGranularity: TextGranularity {
30 | switch self {
31 | case .character:
32 | .character
33 | case .line:
34 | .line
35 | case .paragraph:
36 | .paragraph
37 | case .sentence:
38 | .sentence
39 | case .word:
40 | .word
41 | @unknown default:
42 | .character
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/Ligature/Platform.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 |
4 | public typealias UserInterfaceLayoutDirection = NSUserInterfaceLayoutDirection
5 |
6 | @MainActor
7 | open class TextPosition: NSObject {
8 | }
9 |
10 | @MainActor
11 | final class UTF16TextPosition: TextPosition {
12 | let value: Int
13 |
14 | init(value: Int) {
15 | self.value = value
16 | }
17 |
18 | func offsetPosition(by amount: Int, maximum: Int) -> UTF16TextPosition? {
19 | let new = value + amount
20 | if new < 0 || new > maximum {
21 | return nil
22 | }
23 |
24 | return UTF16TextPosition(value: new)
25 | }
26 | }
27 |
28 | extension UTF16TextPosition {
29 | public override var debugDescription: String {
30 | String(value)
31 | }
32 | }
33 |
34 | @MainActor
35 | open class TextRange: NSObject {
36 | let start: TextPosition
37 | let end: TextPosition
38 |
39 | public override init() {
40 | self.start = UTF16TextPosition(value: 0)
41 | self.end = UTF16TextPosition(value: 0)
42 | }
43 |
44 | init(start: TextPosition, end: TextPosition) {
45 | self.start = start
46 | self.end = end
47 | }
48 |
49 | init(_ range: NSRange) {
50 | self.start = UTF16TextPosition(value: range.lowerBound)
51 | self.end = UTF16TextPosition(value: range.upperBound)
52 | }
53 |
54 | var isEmpty: Bool {
55 | return true
56 | }
57 | }
58 |
59 | extension TextRange {
60 | open override var debugDescription: String {
61 | MainActor.assumeIsolated {
62 | "{\(start.debugDescription), \(end.debugDescription)}"
63 | }
64 | }
65 |
66 | open override var description: String {
67 | debugDescription
68 | }
69 | }
70 |
71 | extension NSRange {
72 | @MainActor
73 | public init?(_ textRange: TextRange, textView: NSTextView) {
74 | let location = textView.offset(from: textView.beginningOfDocument, to: textRange.start)
75 | let length = textView.offset(from: textRange.start, to: textRange.end)
76 |
77 | if location < 0 || length < 0 {
78 | return nil
79 | }
80 |
81 | self.init(location: location, length: length)
82 | }
83 | }
84 |
85 | /// Matches the implementation of `UITextGranularity`.
86 | public enum TextGranularity : Int, Sendable, Hashable, Codable {
87 | case character = 0
88 | case word = 1
89 | case sentence = 2
90 | case paragraph = 3
91 | case line = 4
92 | case document = 5
93 | }
94 |
95 | extension NSSelectionGranularity {
96 | public var textGranularity: TextGranularity {
97 | switch self {
98 | case .selectByCharacter: .character
99 | case .selectByParagraph: .paragraph
100 | case .selectByWord: .word
101 | @unknown default:
102 | .character
103 | }
104 | }
105 | }
106 |
107 | @available(macOS 12.0, *)
108 | extension NSTextSelection.Affinity {
109 | public init(_ affinity: NSSelectionAffinity) {
110 | switch affinity {
111 | case .downstream:
112 | self = .downstream
113 | case .upstream:
114 | self = .upstream
115 | @unknown default:
116 | assertionFailure("Unhandled affinity")
117 | self = .downstream
118 | }
119 | }
120 | }
121 |
122 | public enum TextStorageDirection : Int, Sendable, Hashable, Codable {
123 | case forward = 0
124 | case backward = 1
125 | }
126 |
127 | public enum TextLayoutDirection : Int, Sendable, Hashable, Codable {
128 | case right = 2
129 | case left = 3
130 | case up = 4
131 | case down = 5
132 | }
133 |
134 | /// Matches the implementation of `UITextDirection`.
135 | public struct TextDirection : RawRepresentable, Hashable, Sendable {
136 | public var rawValue: Int
137 |
138 | public init(rawValue: Int) {
139 | if rawValue < 0 || rawValue > 5 {
140 | fatalError("invalid direction value")
141 | }
142 |
143 | self.rawValue = rawValue
144 | }
145 |
146 | public static func storage(_ direction: TextStorageDirection) -> TextDirection {
147 | TextDirection(rawValue: direction.rawValue)
148 | }
149 |
150 | public static func layout(_ direction: TextLayoutDirection) -> TextDirection {
151 | TextDirection(rawValue: direction.rawValue)
152 | }
153 | }
154 |
155 | typealias TextView = NSTextView
156 |
157 | #elseif canImport(UIKit)
158 | import UIKit
159 |
160 | public typealias TextPosition = UITextPosition
161 | public typealias TextRange = UITextRange
162 | public typealias TextGranularity = UITextGranularity
163 | public typealias TextStorageDirection = UITextStorageDirection
164 | public typealias TextLayoutDirection = UITextLayoutDirection
165 | public typealias TextDirection = UITextDirection
166 | public typealias TextInputStringTokenizer = UITextInputStringTokenizer
167 | public typealias UserInterfaceLayoutDirection = UIUserInterfaceLayoutDirection
168 |
169 | typealias TextView = UITextView
170 |
171 | #endif
172 |
--------------------------------------------------------------------------------
/Sources/Ligature/SourceTokenizer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct SourceTokenizer {
4 | public typealias Position = FallbackTokenzier.Position
5 |
6 | private let fallbackTokenzier: FallbackTokenzier
7 |
8 | init(fallbackTokenzier: FallbackTokenzier) {
9 | self.fallbackTokenzier = fallbackTokenzier
10 | }
11 | }
12 |
13 | extension SourceTokenizer : TextTokenizer {
14 | public func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection, alignment: CGFloat?) -> Position? {
15 | return fallbackTokenzier.position(from: position, toBoundary: granularity, inDirection: direction, alignment: alignment)
16 | }
17 |
18 | public func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> FallbackTokenzier.TextRange? {
19 | return fallbackTokenzier.rangeEnclosingPosition(position, with: granularity, inDirection: direction)
20 | }
21 |
22 | public func isPosition(_ position: Position, atBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
23 | return fallbackTokenzier.isPosition(position, atBoundary: granularity, inDirection: direction)
24 | }
25 |
26 | public func isPosition(_ position: Position, withinTextUnit granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
27 | return fallbackTokenzier.isPosition(position, withinTextUnit: granularity, inDirection: direction)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/Ligature/TextDirection+Extensions.swift:
--------------------------------------------------------------------------------
1 | extension TextLayoutDirection {
2 | public func textStorageDirection(with layout: UserInterfaceLayoutDirection) -> TextStorageDirection? {
3 | switch (self, layout) {
4 | case (.left, .leftToRight), (.right, .rightToLeft):
5 | return .backward
6 | case (.left, .rightToLeft), (.right, .leftToRight):
7 | return .forward
8 | case (.down, _), (.up, _):
9 | return nil
10 | @unknown default:
11 | return nil
12 | }
13 | }
14 | }
15 |
16 | extension TextDirection {
17 | public func textStorageDirection(with layout: UserInterfaceLayoutDirection) -> TextStorageDirection? {
18 | switch rawValue {
19 | case TextStorageDirection.forward.rawValue:
20 | return .forward
21 | case TextStorageDirection.backward.rawValue:
22 | return .backward
23 | default:
24 | return TextLayoutDirection(rawValue: rawValue)?.textStorageDirection(with: layout)
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/Ligature/TextInputStringTokenizer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if os(macOS)
3 | import AppKit
4 |
5 | @MainActor
6 | public struct TextInputStringTokenizer {
7 | private let internalTokenizer: UTF16CodePointTextViewTextTokenizer
8 |
9 | public init(textInput: NSTextView) {
10 | self.internalTokenizer = UTF16CodePointTextViewTextTokenizer(textView: textInput)
11 | }
12 | }
13 |
14 | extension TextInputStringTokenizer : TextTokenizer {
15 | public typealias Position = TextPosition
16 |
17 | public func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection, alignment: CGFloat?) -> Position? {
18 | guard let position = position as? UTF16TextPosition else { return nil }
19 |
20 | return internalTokenizer.position(
21 | from: position.value,
22 | toBoundary: granularity,
23 | inDirection: direction,
24 | alignment: alignment
25 | )
26 | .map { UTF16TextPosition(value: $0) }
27 | }
28 |
29 | public func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> TextRange? {
30 | guard let position = position as? UTF16TextPosition else { return nil }
31 |
32 | return internalTokenizer.rangeEnclosingPosition(
33 | position.value,
34 | with: granularity,
35 | inDirection: direction
36 | )
37 | .map { TextRange($0) }
38 | }
39 |
40 | public func isPosition(_ position: Position, atBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
41 | guard let position = position as? UTF16TextPosition else { return false }
42 |
43 | return internalTokenizer.isPosition(position.value, atBoundary: granularity, inDirection: direction)
44 | }
45 |
46 | public func isPosition(_ position: Position, withinTextUnit granularity: TextGranularity, inDirection direction: TextDirection) -> Bool {
47 | guard let position = position as? UTF16TextPosition else { return false }
48 |
49 | return internalTokenizer.isPosition(position.value, withinTextUnit: granularity, inDirection: direction)
50 | }
51 | }
52 |
53 | #endif
54 |
--------------------------------------------------------------------------------
/Sources/Ligature/TextRange+Bounded.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | #elseif canImport(UIKit)
4 | import UIKit
5 | #endif
6 |
7 | import Rearrange
8 |
9 | #if os(macOS) || canImport(UIKit)
10 |
11 | extension TextRange : Rearrange.Bounded {
12 | public nonisolated var lowerBound: TextPosition {
13 | MainActor.assumeIsolated { start }
14 | }
15 |
16 | public nonisolated var upperBound: TextPosition {
17 | MainActor.assumeIsolated { end }
18 | }
19 | }
20 |
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/Ligature/TextTokenizer.swift:
--------------------------------------------------------------------------------
1 | import Rearrange
2 |
3 | #if os(macOS)
4 | import AppKit
5 | #elseif canImport(UIKit)
6 | import UIKit
7 | #endif
8 |
9 | @MainActor
10 | public protocol TextTokenizer {
11 | associatedtype TextRange: Bounded
12 |
13 | typealias Position = TextRange.Bound
14 |
15 | func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection, alignment: CGFloat?) -> Position?
16 | func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> TextRange?
17 |
18 | func isPosition(_ position: Position, atBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Bool
19 | func isPosition(_ position: Position, withinTextUnit granularity: TextGranularity, inDirection direction: TextDirection) -> Bool
20 | }
21 |
22 | extension TextTokenizer {
23 | public func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Position? {
24 | self.position(from: position, toBoundary: granularity, inDirection: direction, alignment: nil)
25 | }
26 | }
27 |
28 | #if canImport(UIKit)
29 | extension UITextInputStringTokenizer : TextTokenizer {
30 | public func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection, alignment: CGFloat?) -> Position? {
31 | self.position(from: position, toBoundary: granularity, inDirection: direction)
32 | }
33 | }
34 | #endif
35 |
--------------------------------------------------------------------------------
/Sources/Ligature/TextView+TextRangeCalculating.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | import Rearrange
4 |
5 | #if os(macOS)
6 | import AppKit
7 |
8 | extension NSTextView: @preconcurrency @retroactive TextRangeCalculating {
9 | public var beginningOfDocument: TextPosition {
10 | UTF16TextPosition(value: 0)
11 | }
12 |
13 | public var endOfDocument: TextPosition {
14 | UTF16TextPosition(value: textStorage?.length ?? 0)
15 | }
16 |
17 | public func textRange(from fromPosition: TextPosition, to toPosition: TextPosition) -> TextRange? {
18 | TextRange(start: fromPosition, end: toPosition)
19 | }
20 |
21 | public func position(from position: TextPosition, offset: Int) -> TextPosition? {
22 | guard let utf16Position = position as? UTF16TextPosition else { return nil }
23 |
24 | return UTF16TextPosition(value: utf16Position.value + offset)
25 | }
26 |
27 | public func position(from position: TextPosition, in direction: TextLayoutDirection, offset: Int) -> TextPosition? {
28 | guard let utf16Position = position as? UTF16TextPosition else { return nil }
29 |
30 | let start = utf16Position.value
31 |
32 | switch (direction, userInterfaceLayoutDirection) {
33 | case (.left, .leftToRight), (.right, .rightToLeft):
34 | return UTF16TextPosition(value: start + offset)
35 | case (.right, .leftToRight), (.left, .rightToLeft):
36 | return UTF16TextPosition(value: start - offset)
37 | default:
38 | return nil
39 | }
40 | }
41 |
42 | public func compare(_ position: TextPosition, to other: TextPosition) -> ComparisonResult {
43 | guard
44 | let a = position as? UTF16TextPosition,
45 | let b = other as? UTF16TextPosition
46 | else {
47 | return .orderedSame
48 | }
49 |
50 | if a.value < b.value {
51 | return .orderedAscending
52 | }
53 |
54 | if a.value > b.value {
55 | return .orderedDescending
56 | }
57 |
58 | return .orderedSame
59 | }
60 |
61 | public func offset(from: TextPosition, to toPosition: TextPosition) -> Int {
62 | guard
63 | let a = from as? UTF16TextPosition,
64 | let b = toPosition as? UTF16TextPosition
65 | else {
66 | return 0
67 | }
68 |
69 | return b.value - a.value
70 | }
71 | }
72 |
73 | #elseif canImport(UIKit)
74 | import UIKit
75 |
76 | extension UITextView : @preconcurrency @retroactive TextRangeCalculating {
77 | }
78 |
79 | #endif
80 |
--------------------------------------------------------------------------------
/Sources/Ligature/UTF16CodePointTextViewTextTokenizer.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | #elseif canImport(UIKit)
4 | import UIKit
5 | #endif
6 |
7 | #if os(macOS) || os(iOS) || os(visionOS)
8 |
9 | import Glyph
10 | import Rearrange
11 |
12 | extension NSAttributedString {
13 | func writingDirection(at index: Int) -> NSWritingDirection {
14 | if index >= length {
15 | return .natural
16 | }
17 |
18 | let attrs = attributes(at: index, effectiveRange: nil)
19 | guard let direction = attrs[.writingDirection] as? NSNumber else {
20 | return .natural
21 | }
22 |
23 | return NSWritingDirection(rawValue: direction.intValue) ?? .natural
24 | }
25 | }
26 |
27 | struct Line {
28 | let start: Int
29 | let end: Int
30 | let contentsEnd: Int
31 | }
32 |
33 | @MainActor
34 | public struct UTF16CodePointTextViewTextTokenizer {
35 | private let textView: TextView
36 |
37 | #if os(macOS)
38 | public init(textView: NSTextView) {
39 | self.textView = textView
40 | }
41 | #else
42 | public init(textView: UITextView) {
43 | self.textView = textView
44 | }
45 | #endif
46 |
47 | private var storage: NSTextStorage? {
48 | textView.textStorage
49 | }
50 |
51 | private var textContainer: NSTextContainer? {
52 | textView.textContainer
53 | }
54 |
55 | private var maximum: Int {
56 | storage?.length ?? 0
57 | }
58 |
59 | private func line(within range: NSRange) -> Line? {
60 | guard let string = storage?.string as? NSString else {
61 | return nil
62 | }
63 |
64 | var start: Int = range.lowerBound
65 | var end: Int = range.upperBound
66 | var contentsEnd: Int = range.upperBound
67 |
68 | string.getLineStart(&start, end: &end, contentsEnd: &contentsEnd, for: range)
69 |
70 | return Line(start: start, end: end, contentsEnd: contentsEnd)
71 | }
72 | }
73 |
74 | extension UTF16CodePointTextViewTextTokenizer {
75 | public func boundingRect(for range: NSRange) -> CGRect? {
76 | textView.boundingRect(for: range)
77 | }
78 |
79 | }
80 |
81 | extension UTF16CodePointTextViewTextTokenizer : TextTokenizer {
82 | public typealias TextRange = NSRange
83 |
84 | /// A variant of position(from:toBoundary:inDirection:) that can take alignment into account.
85 | public func position(
86 | from position: Position,
87 | toBoundary granularity: TextGranularity,
88 | inDirection direction: TextDirection,
89 | alignment: CGFloat?
90 | ) -> Position? {
91 | switch (granularity, direction) {
92 | case (.character, .storage(.forward)):
93 | guard let storage else { return position }
94 |
95 | // moving to the very last position is always allowed and requires no checks
96 | if position + 1 >= maximum {
97 | return nil
98 | }
99 |
100 | let pos = min(position + 1, maximum - 1)
101 |
102 | let charRange = (storage.string as NSString).rangeOfComposedCharacterSequence(at: pos)
103 | if charRange.lowerBound == pos {
104 | return pos
105 | }
106 |
107 | return charRange.upperBound
108 | case (.character, .storage(.backward)):
109 | guard let storage else { return position }
110 |
111 | if position <= 0 {
112 | return nil
113 | }
114 |
115 | let pos = position - 1
116 |
117 | let charRange = (storage.string as NSString).rangeOfComposedCharacterSequence(at: pos)
118 |
119 | return charRange.lowerBound
120 | case (.line, .storage(.forward)):
121 | guard let fragment = textContainer?.lineFragment(for: position, offset: 0) else {
122 | return nil
123 | }
124 |
125 | let fragmentRange = fragment.1
126 |
127 | return line(within: fragmentRange)?.contentsEnd
128 | case (.line, .storage(.backward)):
129 | guard let fragment = textContainer?.lineFragment(for: position, offset: 0) else {
130 | return nil
131 | }
132 |
133 | let fragmentRange = fragment.1
134 |
135 | return line(within: fragmentRange)?.start
136 | case (_, .layout(.left)):
137 | guard let storage else { return position }
138 |
139 | let rtl = storage.writingDirection(at: position) == .rightToLeft
140 | let resolvedDir: TextDirection = rtl ? .storage(.forward) : .storage(.backward)
141 |
142 | return self.position(from: position, toBoundary: granularity, inDirection: resolvedDir, alignment: alignment)
143 | case (_, .layout(.right)):
144 | guard let storage else { return position }
145 |
146 | let rtl = storage.writingDirection(at: position) == .rightToLeft
147 | let resolvedDir: TextDirection = rtl ? .storage(.backward) : .storage(.forward)
148 |
149 | return self.position(from: position, toBoundary: granularity, inDirection: resolvedDir, alignment: alignment)
150 | #if os(macOS)
151 | case (.character, .layout(.down)):
152 | guard
153 | let alignment = alignment ?? boundingRect(for: NSRange(position.. TextRange? {
185 | return nil
186 | }
187 |
188 | public func isPosition(
189 | _ position: Position,
190 | atBoundary granularity: TextGranularity,
191 | inDirection direction: TextDirection
192 | ) -> Bool {
193 | let forward = storage?.writingDirection(at: position) != .rightToLeft
194 | let start = forward ? max(position - 1, 0) : 0
195 | let end = forward ? maximum - 1 : min(position + 1, maximum - 1)
196 | let options: NSString.EnumerationOptions
197 |
198 | // shortcuts - this are always at boundaries by definition
199 | switch direction {
200 | case .storage(.forward):
201 | if position == maximum {
202 | return true
203 | }
204 | case .storage(.backward):
205 | if position <= 0 {
206 | return true
207 | }
208 | default:
209 | break
210 | }
211 |
212 | switch granularity {
213 | case .character:
214 | options = [.byComposedCharacterSequences]
215 | case .word:
216 | options = [.byWords]
217 | case .line:
218 | options = [.byLines]
219 | case .sentence:
220 | options = [.bySentences]
221 | case .paragraph:
222 | options = [.byParagraphs]
223 | case .document:
224 | return false
225 | @unknown default:
226 | return false
227 | }
228 |
229 | var atBoundary = false
230 |
231 | guard let string = (storage?.string as? NSString) else {
232 | return false
233 | }
234 |
235 | string.enumerateSubstrings(in: NSRange(start.. Bool {
251 | return false
252 | }
253 | }
254 | #endif
255 |
--------------------------------------------------------------------------------
/Tests/LigatureTests/PlatformTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import Ligature
3 |
4 | final class PlatformTests: XCTestCase {
5 | func testLayoutTranslation() {
6 | let left = TextDirection(rawValue: TextLayoutDirection.left.rawValue)
7 |
8 | XCTAssertEqual(left.textStorageDirection(with: .leftToRight), .backward)
9 | XCTAssertEqual(left.textStorageDirection(with: .rightToLeft), .forward)
10 |
11 | let right = TextDirection(rawValue: TextLayoutDirection.right.rawValue)
12 |
13 | XCTAssertEqual(right.textStorageDirection(with: .leftToRight), .forward)
14 | XCTAssertEqual(right.textStorageDirection(with: .rightToLeft), .backward)
15 | }
16 | }
17 |
18 | #if os(macOS)
19 | import AppKit
20 |
21 | @MainActor
22 | extension PlatformTests {
23 | func testPositionOffset() throws {
24 | let view = NSTextView()
25 |
26 | view.text = "abcdef"
27 |
28 | let position = try XCTUnwrap(view.position(from: view.beginningOfDocument, offset: 3))
29 |
30 | XCTAssertEqual(view.offset(from: view.beginningOfDocument, to: position), 3)
31 | }
32 |
33 | func testMakeTextRange() throws {
34 | let view = NSTextView()
35 |
36 | view.text = "abcdef"
37 |
38 | let position = try XCTUnwrap(view.position(from: view.beginningOfDocument, offset: 3))
39 | _ = try XCTUnwrap(view.textRange(from: view.beginningOfDocument, to: position))
40 | }
41 | }
42 |
43 | #endif
44 |
--------------------------------------------------------------------------------
/Tests/LigatureTests/TextTokenizerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | #if os(macOS)
3 | import AppKit
4 |
5 | typealias TextView = NSTextView
6 |
7 | extension NSTextView {
8 | var text: String {
9 | get { string }
10 | set { self.string = newValue }
11 | }
12 | }
13 |
14 | #elseif canImport(UIKit)
15 | import UIKit
16 |
17 | typealias TextView = UITextView
18 |
19 | #endif
20 |
21 | import Ligature
22 |
23 | final class TextTokenizerTests: XCTestCase {
24 | @MainActor
25 | func testIsPositionByWord() throws {
26 | let input = TextView()
27 | input.text = "abc def"
28 |
29 | let tokenzier = TextInputStringTokenizer(textInput: input)
30 |
31 | let start = input.beginningOfDocument
32 | let middle = try XCTUnwrap(input.position(from: start, offset: 1))
33 | let end = try XCTUnwrap(input.position(from: start, offset: 7))
34 |
35 | XCTAssertFalse(tokenzier.isPosition(start, atBoundary: .word, inDirection: .storage(.forward)))
36 | XCTAssertTrue(tokenzier.isPosition(start, atBoundary: .word, inDirection: .storage(.backward)))
37 |
38 | XCTAssertFalse(tokenzier.isPosition(middle, atBoundary: .word, inDirection: .storage(.forward)))
39 | XCTAssertFalse(tokenzier.isPosition(middle, atBoundary: .word, inDirection: .storage(.backward)))
40 |
41 | XCTAssertTrue(tokenzier.isPosition(end, atBoundary: .word, inDirection: .storage(.forward)))
42 | XCTAssertFalse(tokenzier.isPosition(end, atBoundary: .word, inDirection: .storage(.backward)))
43 | }
44 |
45 | @MainActor
46 | func testPositionByCharacter() throws {
47 | let input = TextView()
48 | input.text = "abc def"
49 |
50 | let tokenzier = TextInputStringTokenizer(textInput: input)
51 |
52 | let start = input.beginningOfDocument
53 | let end = try XCTUnwrap(input.position(from: start, offset: 7))
54 |
55 | XCTAssertNil(tokenzier.position(from: start, toBoundary: .character, inDirection: .storage(.backward)))
56 | XCTAssertNil(tokenzier.position(from: start, toBoundary: .character, inDirection: .layout(.left)))
57 |
58 | let pos1 = try XCTUnwrap(tokenzier.position(from: start, toBoundary: .character, inDirection: .storage(.forward)))
59 | XCTAssertEqual(input.offset(from: start, to: pos1), 1)
60 |
61 | #if os(macOS)
62 | // why does this produce a nil on iOS?
63 | let pos2 = try XCTUnwrap(tokenzier.position(from: start, toBoundary: .character, inDirection: .layout(.right)))
64 | XCTAssertEqual(input.offset(from: start, to: pos2), 1)
65 | #endif
66 |
67 | XCTAssertNil(tokenzier.position(from: end, toBoundary: .character, inDirection: .storage(.forward)))
68 | #if os(macOS)
69 | // why does this *not* produce a nil on iOS?
70 | XCTAssertNil(tokenzier.position(from: end, toBoundary: .character, inDirection: .layout(.right)))
71 | #endif
72 |
73 | let pos3 = try XCTUnwrap(tokenzier.position(from: end, toBoundary: .character, inDirection: .storage(.backward)))
74 | XCTAssertEqual(input.offset(from: start, to: pos3), 6)
75 |
76 | let pos4 = try XCTUnwrap(tokenzier.position(from: end, toBoundary: .character, inDirection: .layout(.left)))
77 | XCTAssertEqual(input.offset(from: start, to: pos4), 6)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------