Skip to content

Commit

Permalink
implement an NSRange-based option
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Dec 16, 2024
1 parent 6278bde commit 3c07ec9
Show file tree
Hide file tree
Showing 10 changed files with 548 additions and 206 deletions.
18 changes: 5 additions & 13 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9
// swift-tools-version: 6.0

import PackageDescription

Expand All @@ -14,19 +14,11 @@ let package = Package(
products: [
.library(name: "Ligature", targets: ["Ligature"]),
],
dependencies: [
.package(url: "https://github.com/ChimeHQ/Glyph", revision: "dce014c6ee2564c44e38c222a3fdc6eef76892d6"),
],
targets: [
.target(name: "Ligature"),
.target(name: "Ligature", dependencies: ["Glyph"]),
.testTarget(name: "LigatureTests", dependencies: ["Ligature"]),
]
)

let swiftSettings: [SwiftSetting] = [
.enableExperimentalFeature("StrictConcurrency"),
.enableUpcomingFeature("DisableOutwardActorInference"),
]

for target in package.targets {
var settings = target.swiftSettings ?? []
settings.append(contentsOf: swiftSettings)
target.swiftSettings = settings
}
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@
</div>

# Ligature
A Swift package to aid in text selection, grouping, indentation, and manipulation.
A Swift package to aid in text selection, grouping, and manipulation.

Ligature includes aliases and implementations as needed to make parts of the UIKit and AppKit text interfaces source-compatible.

> [!WARNING]
> This is currently very WIP.
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.

You also might be interested in [Glyph][], a TextKit 1/2 abstraction system, as well as general AppKit/UIKit stuff like [NSUI][] or [KeyCodes][].

Expand All @@ -33,6 +30,9 @@ dependencies: [

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.

> [!WARNING]
> While quite usable, there are features the `TextTokenizer` API supports that are not fully implemented by the AppKit implementation.
```swift
// on UIKit
let tokenizer = TextInputStringTokenizer(textInput: someUITextView)
Expand All @@ -52,18 +52,17 @@ typealias TextDirection = UITextDirection
typealias UserInterfaceLayoutDirection = UIUserInterfaceLayoutDirection
```

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. This should be all automatically handled by the `TextTokenizer` protocol **if** you are using `NSRange` or `NSTextRange`. The cross-platform `TextRange` type cannot do this without additional work on your part, typically by involving the text view.
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.

## Contributing and Collaboration

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 on [mastodon](https://mastodon.social/@mattiem).
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).

I prefer collaboration, and would love to find ways to work together if you have a similar project.

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.

By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md).

[build status]: https://github.com/ChimeHQ/Ligature/actions
[build status badge]: https://github.com/ChimeHQ/Ligature/workflows/CI/badge.svg
[platforms]: https://swiftpackageindex.com/ChimeHQ/Ligature
Expand Down
47 changes: 47 additions & 0 deletions Sources/Ligature/MockRangeCalculator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

public final class MockRangeCalculator<R: Bounded & Equatable>: TextRangeCalculating where R.Bound == Int {
public typealias TextRange = R

private let textRangeBuilder: (Position, Position) -> TextRange?

public init(textRangeBuilder: @escaping (Position, Position) -> TextRange?) {
self.textRangeBuilder = textRangeBuilder
}

public var beginningOfDocument: Position {
0
}

public var endOfDocument: Position {
0
}

public func textRange(from fromPosition: Position, to toPosition: Position) -> TextRange? {
textRangeBuilder(fromPosition, toPosition)
}

public func position(from position: Position, offset: Int) -> Position? {
position + offset
}

public func position(from position: Position, in direction: Ligature.TextLayoutDirection, offset: Int) -> Position? {
nil
}

public func offset(from: Position, to toPosition: Position) -> Int {
toPosition - from
}

public func compare(_ position: Position, to other: Position) -> ComparisonResult {
if position < other {
return .orderedAscending
}

if other < position {
return .orderedDescending
}

return .orderedSame
}
}
45 changes: 45 additions & 0 deletions Sources/Ligature/NSTextSelection+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(watchOS, unavailable)
extension NSTextSelection.Granularity {
public init(_ granularity: TextGranularity) {
switch granularity {
case .character:
self = .character
case .paragraph:
self = .paragraph
case .word:
self = .word
case .sentence:
self = .sentence
case .line:
self = .line
case .document:
self = .paragraph
@unknown default:
self = .character
}
}

public var textGranularity: TextGranularity {
switch self {
case .character:
.character
case .line:
.line
case .paragraph:
.paragraph
case .sentence:
.sentence
case .word:
.word
@unknown default:
.character
}
}
}
131 changes: 28 additions & 103 deletions Sources/Ligature/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public typealias UserInterfaceLayoutDirection = NSUserInterfaceLayoutDirection
open class TextPosition: NSObject {
}

@MainActor
final class UTF16TextPosition: TextPosition {
let value: Int

Expand All @@ -32,8 +33,8 @@ extension UTF16TextPosition {

@MainActor
open class TextRange: NSObject {
var start: TextPosition
var end: TextPosition
let start: TextPosition
let end: TextPosition

public override init() {
self.start = UTF16TextPosition(value: 0)
Expand All @@ -45,6 +46,11 @@ open class TextRange: NSObject {
self.end = end
}

init(_ range: NSRange) {
self.start = UTF16TextPosition(value: range.lowerBound)
self.end = UTF16TextPosition(value: range.upperBound)
}

var isEmpty: Bool {
return true
}
Expand All @@ -56,6 +62,24 @@ extension TextRange {
"{\(start.debugDescription), \(end.debugDescription)}"
}
}

open override var description: String {
debugDescription
}
}

extension NSRange {
@MainActor
public init?(_ textRange: TextRange, textView: NSTextView) {
let location = textView.offset(from: textView.beginningOfDocument, to: textRange.start)
let length = textView.offset(from: textRange.start, to: textRange.end)

if location < 0 || length < 0 {
return nil
}

self.init(location: location, length: length)
}
}

/// Matches the implementation of `UITextGranularity`.
Expand Down Expand Up @@ -130,67 +154,6 @@ public struct TextDirection : RawRepresentable, Hashable, Sendable {

typealias TextView = NSTextView

extension NSTextView {
public var beginningOfDocument: TextPosition {
UTF16TextPosition(value: 0)
}

public func textRange(from fromPosition: TextPosition, to toPosition: TextPosition) -> TextRange? {
TextRange(start: fromPosition, end: toPosition)
}

public func position(from position: TextPosition, offset: Int) -> TextPosition? {
guard let utf16Position = position as? UTF16TextPosition else { return nil }

return UTF16TextPosition(value: utf16Position.value + offset)
}

public func position(from position: TextPosition, in direction: TextLayoutDirection, offset: Int) -> TextPosition? {
guard let utf16Position = position as? UTF16TextPosition else { return nil }

let start = utf16Position.value

switch (direction, userInterfaceLayoutDirection) {
case (.left, .leftToRight), (.right, .rightToLeft):
return UTF16TextPosition(value: start + offset)
case (.right, .leftToRight), (.left, .rightToLeft):
return UTF16TextPosition(value: start - offset)
default:
return nil
}
}

public func compare(_ position: TextPosition, to other: TextPosition) -> ComparisonResult {
guard
let a = position as? UTF16TextPosition,
let b = other as? UTF16TextPosition
else {
return .orderedSame
}

if a.value < b.value {
return .orderedAscending
}

if a.value > b.value {
return .orderedDescending
}

return .orderedSame
}

public func offset(from: TextPosition, to toPosition: TextPosition) -> Int {
guard
let a = from as? UTF16TextPosition,
let b = toPosition as? UTF16TextPosition
else {
return 0
}

return b.value - a.value
}
}

#elseif canImport(UIKit)
import UIKit

Expand All @@ -203,44 +166,6 @@ public typealias TextDirection = UITextDirection
public typealias TextInputStringTokenizer = UITextInputStringTokenizer
public typealias UserInterfaceLayoutDirection = UIUserInterfaceLayoutDirection

#endif

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(watchOS, unavailable)
extension NSTextSelection.Granularity {
public init(_ granularity: TextGranularity) {
switch granularity {
case .character:
self = .character
case .paragraph:
self = .paragraph
case .word:
self = .word
case .sentence:
self = .sentence
case .line:
self = .line
case .document:
self = .paragraph
@unknown default:
self = .character
}
}
typealias TextView = UITextView

public var textGranularity: TextGranularity {
switch self {
case .character:
.character
case .line:
.line
case .paragraph:
.paragraph
case .sentence:
.sentence
case .word:
.word
@unknown default:
.character
}
}
}
#endif
31 changes: 31 additions & 0 deletions Sources/Ligature/RangeTranslator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
public struct RangeTranslator<RangeA, RangeB> {
public let to: (RangeA) -> RangeB?
public let from: (RangeB) -> RangeA?

public init(
to: @escaping (RangeA) -> RangeB?,
from: @escaping (RangeB) -> RangeA?
) {
self.to = to
self.from = from
}
}

#if os(macOS)
import AppKit

extension NSTextView {
public var rangeTranslator: RangeTranslator<NSRange, TextRange> {
.init(
to: { [weak self] in
self?.textRange(from: $0)
},
from: { [weak self] textRange in
guard let self else { return nil }

return NSRange(textRange, textView: self)
}
)
}
}
#endif
Loading

0 comments on commit 3c07ec9

Please sign in to comment.