├── .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 | --------------------------------------------------------------------------------