From fb90945d84bbb9fae0932cf1c25baf76af28e787 Mon Sep 17 00:00:00 2001 From: Matt <85322+mattmassicotte@users.noreply.github.com> Date: Tue, 3 Sep 2024 10:10:43 -0400 Subject: [PATCH] Attempt to simplify range/position abstractions with the Bounded protocol --- README.md | 14 +----- Sources/Ligature/Bounded.swift | 32 ++++++++++++++ Sources/Ligature/Platform.swift | 2 +- Sources/Ligature/SourceTokenizer.swift | 6 ++- Sources/Ligature/TextPositionSpace.swift | 29 ------------- Sources/Ligature/TextTokenizer.swift | 55 +++++++++++++++++++++--- 6 files changed, 86 insertions(+), 52 deletions(-) create mode 100644 Sources/Ligature/Bounded.swift delete mode 100644 Sources/Ligature/TextPositionSpace.swift diff --git a/README.md b/README.md index e78f548..1ed1d16 100644 --- a/README.md +++ b/README.md @@ -52,19 +52,7 @@ 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. The protocol `TextPositionSpace` provides a way of mapping positions into and out of ranges. Here's what the implemenation looks like for UITextRanges - -```swift -extension UITextView : TextPositionSpace { - public func decomposeRange(_ range: TextRange) -> (TextPosition, TextPosition) { - (range.start, range.end) - } - - public func composeRange(_ components: (TextPosition, TextPosition)) -> TextRange? { - textRange(from: components.0, to: components.1) - } -} -``` +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. ## Contributing and Collaboration diff --git a/Sources/Ligature/Bounded.swift b/Sources/Ligature/Bounded.swift new file mode 100644 index 0000000..9ba0312 --- /dev/null +++ b/Sources/Ligature/Bounded.swift @@ -0,0 +1,32 @@ +public protocol Bounded { + associatedtype Bound + + var lowerBound: Bound { get } + var upperBound: Bound { get } +} + +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, macCatalyst 15.0, *) +extension NSTextRange : Bounded { + public var lowerBound: NSTextLocation { location } + public var upperBound: NSTextLocation { endLocation } +} + +extension TextRange : Bounded { + public nonisolated var lowerBound: TextPosition { + MainActor.assumeIsolated { start } + } + + public nonisolated var upperBound: TextPosition { + MainActor.assumeIsolated { end } + } +} + +extension NSRange : Bounded {} + +extension Range : Bounded {} diff --git a/Sources/Ligature/Platform.swift b/Sources/Ligature/Platform.swift index 2d48845..6fcb112 100644 --- a/Sources/Ligature/Platform.swift +++ b/Sources/Ligature/Platform.swift @@ -3,8 +3,8 @@ import AppKit public typealias UserInterfaceLayoutDirection = NSUserInterfaceLayoutDirection +@MainActor open class TextPosition: NSObject { - } final class UTF16TextPosition: TextPosition { diff --git a/Sources/Ligature/SourceTokenizer.swift b/Sources/Ligature/SourceTokenizer.swift index d5c6933..f472f16 100644 --- a/Sources/Ligature/SourceTokenizer.swift +++ b/Sources/Ligature/SourceTokenizer.swift @@ -1,6 +1,8 @@ import Foundation -public struct SourceTokenizer where FallbackTokenzier.Position == Position, FallbackTokenzier.TextRange == TextRange { +public struct SourceTokenizer { + public typealias Position = FallbackTokenzier.Position + private let fallbackTokenzier: FallbackTokenzier init(fallbackTokenzier: FallbackTokenzier) { @@ -13,7 +15,7 @@ extension SourceTokenizer : TextTokenizer { return fallbackTokenzier.position(from: position, toBoundary: granularity, inDirection: direction) } - public func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> Ligature.TextRange? { + public func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> FallbackTokenzier.TextRange? { return fallbackTokenzier.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } diff --git a/Sources/Ligature/TextPositionSpace.swift b/Sources/Ligature/TextPositionSpace.swift deleted file mode 100644 index 6bcba98..0000000 --- a/Sources/Ligature/TextPositionSpace.swift +++ /dev/null @@ -1,29 +0,0 @@ -@MainActor -public protocol TextPositionSpace { - associatedtype Position - associatedtype TextRange - - func decomposeRange(_ range: TextRange) -> (Position, Position) - func composeRange(_ components: (Position, Position)) -> TextRange? -} - -extension TextPositionSpace { - public func composeRange(_ start: Position, _ end: Position) -> TextRange? { - composeRange((start, end)) - } -} - -#if canImport(UIKit) -import UIKit - -extension UITextView : TextPositionSpace { - public func decomposeRange(_ range: TextRange) -> (TextPosition, TextPosition) { - (range.start, range.end) - } - - public func composeRange(_ components: (TextPosition, TextPosition)) -> TextRange? { - textRange(from: components.0, to: components.1) - } -} - -#endif diff --git a/Sources/Ligature/TextTokenizer.swift b/Sources/Ligature/TextTokenizer.swift index 805eca9..f49350f 100644 --- a/Sources/Ligature/TextTokenizer.swift +++ b/Sources/Ligature/TextTokenizer.swift @@ -1,9 +1,15 @@ -import Foundation +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif @MainActor -public protocol TextTokenizer { - associatedtype Position - associatedtype TextRange +public protocol TextTokenizer { + associatedtype TextRange: Bounded + + typealias Position = TextRange.Bound + typealias RangeBuilder = (Position, Position) -> TextRange? func position(from position: Position, toBoundary granularity: TextGranularity, inDirection direction: TextDirection) -> Position? func rangeEnclosingPosition(_ position: Position, with granularity: TextGranularity, inDirection direction: TextDirection) -> TextRange? @@ -13,9 +19,44 @@ public protocol TextTokenizer { } #if canImport(UIKit) -import UIKit +extension UITextInputStringTokenizer : TextTokenizer {} +#endif + +extension TextTokenizer { + public func range( + from range: TextRange, + to granularity: TextGranularity, + in direction: TextDirection, + rangeBuilder: RangeBuilder + ) -> TextRange? { + let components = (range.lowerBound, range.upperBound) + + guard + let start = position(from: components.0, toBoundary: granularity, inDirection: direction), + let end = position(from: components.1, toBoundary: granularity, inDirection: direction) + else { + return nil + } -extension UITextInputStringTokenizer : TextTokenizer { + return rangeBuilder(start, end) + } } -#endif +extension TextTokenizer where TextRange == NSRange { + public func range(from range: TextRange, to granularity: TextGranularity, in direction: TextDirection) -> TextRange? { + self.range(from: range, to: granularity, in: direction, rangeBuilder: { NSRange($0..<$1) }) + } +} + +extension TextTokenizer where TextRange == Range { + public func range(from range: TextRange, to granularity: TextGranularity, in direction: TextDirection) -> TextRange? { + self.range(from: range, to: granularity, in: direction, rangeBuilder: { $0..<$1 }) + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, macCatalyst 15.0, *) +extension TextTokenizer where TextRange == NSTextRange { + public func range(from range: TextRange, to granularity: TextGranularity, in direction: TextDirection) -> TextRange? { + self.range(from: range, to: granularity, in: direction, rangeBuilder: { NSTextRange(location: $0, end: $1) }) + } +}