diff --git a/README.md b/README.md index c551458..e9fcd58 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ dependencies: [ func characterIndexes(within rect: CGRect) -> IndexSet func enumerateLineFragments(for rect: CGRect, strictIntersection: Bool, block: (CGRect, NSRange, inout Bool) -> Void) func enumerateLineFragments(in range: NSRange, block: (CGRect, NSRange, inout Bool) -> Void) +func boundingRect(for range: NSRange) -> NSRect? ``` ### `NSTextLayoutManager` Additions @@ -45,9 +46,17 @@ func enumerateLineFragments(with provider: NSTextElementProvider, block: (NSText ### `NSTextView`/`UITextView` Additions -``` +```swift func characterIndexes(within rect: CGRect) -> IndexSet var visibleCharacterIndexes: IndexSet +func boundingRect(for range: NSRange) -> NSRect? +``` + +### `NSRange` and `NSTextRange` Additions + +```swift +NSRange.init?(_ textRange: NSTextRange) +NSTextRange.init?(_ range: NSRange) ``` ## Contributing and Collaboration diff --git a/Sources/Glyph/NSTextContainer+Additions.swift b/Sources/Glyph/NSTextContainer+Additions.swift index 9c123da..229b2b1 100644 --- a/Sources/Glyph/NSTextContainer+Additions.swift +++ b/Sources/Glyph/NSTextContainer+Additions.swift @@ -6,16 +6,8 @@ import UIKit #if os(macOS) || os(iOS) || os(visionOS) extension NSTextContainer { - var nonDowngradingLayoutManager: NSLayoutManager? { - if #available(macOS 12.0, iOS 15.0, *), textLayoutManager != nil { - return nil - } - - return layoutManager - } - private func tk1EnumerateLineFragments(for rect: CGRect, strictIntersection: Bool, block: (CGRect, NSRange, inout Bool) -> Void) { - guard let layoutManager = nonDowngradingLayoutManager else { return } + guard let layoutManager = layoutManager else { return } let glyphRange = layoutManager.glyphRange(forBoundingRect: rect, in: self) @@ -95,4 +87,30 @@ extension NSTextContainer { tk1EnumerateLineFragments(in: range, block: block) } } + +extension NSTextContainer { + public func boundingRect(for range: NSRange) -> NSRect? { + if #available(macOS 12.0, iOS 15.0, *), let textLayoutManager { + return textLayoutManager.boundingRect(for: range) + } + + return tk1BoundingRect(for: range) + } + + private func tk1BoundingRect(for range: NSRange) -> NSRect? { + guard let layoutManager else { return nil } + + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + + return layoutManager.boundingRect(forGlyphRange: glyphRange, in: self) + } + + private func tk1TextRange(intersecting rect: CGRect) -> NSRange? { + guard let layoutManager else { return nil } + + let glyphRange = layoutManager.glyphRange(forBoundingRect: rect, in: self) + + return layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + } +} #endif diff --git a/Sources/Glyph/NSTextLayoutManager+Additions.swift b/Sources/Glyph/NSTextLayoutManager+Additions.swift index 907778c..ac9f89c 100644 --- a/Sources/Glyph/NSTextLayoutManager+Additions.swift +++ b/Sources/Glyph/NSTextLayoutManager+Additions.swift @@ -64,7 +64,39 @@ extension NSTextLayoutManager { }) } - public func enumerateLineFragments(in range: NSRange, options: NSTextLayoutFragment.EnumerationOptions = [], block: (CGRect, NSRange, inout Bool) -> Void) { + private func enumerateTextLineFragments( + in range: NSRange, + options: NSTextLayoutFragment.EnumerationOptions = [], + block: (NSTextLineFragment, CGRect, NSRange, inout Bool) -> Void + ) { + guard let textContentManager else { return } + + let docStart = documentRange.location + guard + let start = textContentManager.location(docStart, offsetBy: range.lowerBound), + let end = textContentManager.location(docStart, offsetBy: range.upperBound) + else { + return + } + + enumerateTextLayoutFragments(from: start, options: options) { fragment in + let fragmentRange = fragment.rangeInElement + + var stop = false + + fragment.enumerateLineFragments(with: textContentManager) { lineFragment, frame, elementRange in + block(lineFragment, frame, elementRange, &stop) + } + + return stop == false && fragmentRange.endLocation.compare(end) == .orderedAscending + } + } + + public func enumerateLineFragments( + in range: NSRange, + options: NSTextLayoutFragment.EnumerationOptions = [], + block: (CGRect, NSRange, inout Bool) -> Void + ) { guard let textContentManager else { return } let start = documentRange.location @@ -85,5 +117,15 @@ extension NSTextLayoutManager { return stop == false && fragmentRange.endLocation.compare(end) == .orderedAscending } } + + func boundingRect(for range: NSRange) -> NSRect? { + var rect: NSRect? = nil + + enumerateTextLineFragments(in: range, options: [.ensuresLayout]) { lineFragment, lineRect, lineRange, stop in + rect = rect?.union(lineRect) ?? lineRect + } + + return rect + } } #endif diff --git a/Sources/Glyph/NSTextRange+NSRange.swift b/Sources/Glyph/NSTextRange+NSRange.swift index 89d106e..369045a 100644 --- a/Sources/Glyph/NSTextRange+NSRange.swift +++ b/Sources/Glyph/NSTextRange+NSRange.swift @@ -7,6 +7,31 @@ import UIKit // Taken from https://github.com/chimeHQ/Rearrange #if os(macOS) || os(iOS) || os(visionOS) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +final class UTF16TextLocation: NSObject, NSTextLocation { + let value: Int + + init(value: Int) { + self.value = value + } + + func compare(_ location: any NSTextLocation) -> ComparisonResult { + guard let utf16Loc = location as? UTF16TextLocation else { + return .orderedSame + } + + if value < utf16Loc.value { + return .orderedAscending + } + + if value > utf16Loc.value { + return .orderedDescending + } + + return .orderedSame + } +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension NSRange { init(_ textRange: NSTextRange, provider: NSTextElementProvider) { @@ -26,5 +51,28 @@ extension NSRange { self.init(start..<end) } + + public init?(_ textRange: NSTextRange) { + guard + let start = textRange.location as? UTF16TextLocation, + let end = textRange.endLocation as? UTF16TextLocation + else { + return nil + } + + self.init(start.value..<end.value) + } } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension NSTextRange { + public convenience init?(_ range: NSRange) { + let start = UTF16TextLocation(value: range.lowerBound) + let end = UTF16TextLocation(value: range.upperBound) + + self.init(location: start, end: end) + } +} + #endif diff --git a/Sources/Glyph/NSTextView+Additions.swift b/Sources/Glyph/NSTextView+Additions.swift index e589df9..777d8b3 100644 --- a/Sources/Glyph/NSTextView+Additions.swift +++ b/Sources/Glyph/NSTextView+Additions.swift @@ -33,5 +33,22 @@ extension TextView { public var visibleCharacterIndexes: IndexSet { characterIndexes(within: visibleContainerRect) } + + /// Returns the bounding rectangle for the given text range. + public func boundingRect(for range: NSRange) -> NSRect? { +#if os(macOS) && !targetEnvironment(macCatalyst) + guard let rect = textContainer?.boundingRect(for: range) else { + return nil + } +#elseif os(iOS) || os(visionOS) + guard let rect = textContainer.boundingRect(for: range) else { + return nil + } +#endif + + let origin = textContainerOrigin + + return rect.offsetBy(dx: origin.x, dy: origin.y) + } } #endif