From f245683cf115b19bdaa026c1f1dfc92592943133 Mon Sep 17 00:00:00 2001 From: Matt <85322+mattmassicotte@users.noreply.github.com> Date: Fri, 5 Apr 2024 11:49:05 -0400 Subject: [PATCH] Fragment enumeration and visible set computation --- README.md | 22 ++++++ Sources/Glyph/Glyph.swift | 2 - Sources/Glyph/NSLayoutManager+Additions.swift | 10 +++ Sources/Glyph/NSTextContainer+Additions.swift | 73 +++++++++++++++++++ .../Glyph/NSTextLayoutManager+Additions.swift | 65 +++++++++++++++++ Sources/Glyph/NSTextRange+NSRange.swift | 30 ++++++++ Sources/Glyph/NSTextView+Additions.swift | 43 +++++++++++ 7 files changed, 243 insertions(+), 2 deletions(-) delete mode 100644 Sources/Glyph/Glyph.swift create mode 100644 Sources/Glyph/NSLayoutManager+Additions.swift create mode 100644 Sources/Glyph/NSTextContainer+Additions.swift create mode 100644 Sources/Glyph/NSTextLayoutManager+Additions.swift create mode 100644 Sources/Glyph/NSTextRange+NSRange.swift create mode 100644 Sources/Glyph/NSTextView+Additions.swift diff --git a/README.md b/README.md index 3e87cdc..56a330e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ # Glyph Make life with TextKit better +This library adds functionality to TextKit to make it easier to use. It works with both TextKit 1 and 2, and will not downgrade TextKit 2 views. + ## Installation ```swift @@ -18,6 +20,26 @@ dependencies: [ ], ``` +## Usage + +### `NSTextContainer` Additions + +```swift +func enumerateLineFragments(for rect: CGRect, strictIntersection: Bool, block: (CGRect, NSRange, inout Bool) -> Void) +``` + +### `NSTextLayoutManager` Additions + +```swift +func enumerateLineFragments(for rect: CGRect, options: NSTextLayoutFragment.EnumerationOptions = [], block: (CGRect, NSTextRange?, inout Bool) -> Void) +``` + +### `NSTextView`/`UITextView` Additions + +```swift +var visibleTextSet: IndexSet +``` + ## Contributing and Collaboration I would love to hear from you! Issues or pull requests work great. A [Discord server][discord] is also available for live help, but I have a strong bias towards answering in the form of documentation. diff --git a/Sources/Glyph/Glyph.swift b/Sources/Glyph/Glyph.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/Glyph/Glyph.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/Glyph/NSLayoutManager+Additions.swift b/Sources/Glyph/NSLayoutManager+Additions.swift new file mode 100644 index 0000000..1885956 --- /dev/null +++ b/Sources/Glyph/NSLayoutManager+Additions.swift @@ -0,0 +1,10 @@ +#if os(macOS) && !targetEnvironment(macCatalyst) +import AppKit +#elseif os(iOS) || os(visionOS) +import UIKit +#endif + +#if os(macOS) || os(iOS) || os(visionOS) +extension NSLayoutManager { +} +#endif diff --git a/Sources/Glyph/NSTextContainer+Additions.swift b/Sources/Glyph/NSTextContainer+Additions.swift new file mode 100644 index 0000000..47faaf2 --- /dev/null +++ b/Sources/Glyph/NSTextContainer+Additions.swift @@ -0,0 +1,73 @@ +#if os(macOS) && !targetEnvironment(macCatalyst) +import AppKit +#elseif os(iOS) || os(visionOS) +import UIKit +#endif + +#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 + } + + func textRange(for rect: CGRect) -> NSRange? { + guard let layoutManager = nonDowngradingLayoutManager else { return nil } + + let glyphRange = layoutManager.glyphRange(forBoundingRect: rect, in: self) + + return layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + } + + func tk1EnumerateLineFragments(for rect: CGRect, strictIntersection: Bool, block: (CGRect, NSRange, inout Bool) -> Void) { + guard let layoutManager = nonDowngradingLayoutManager else { return } + + let glyphRange = layoutManager.glyphRange(forBoundingRect: rect, in: self) + + withoutActuallyEscaping(block) { escapingBlock in + layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { (fragmentRect, _, _, fragmentRange, stop) in + var innerStop = false + + if strictIntersection { + let intersectingRect = fragmentRect.intersection(rect) + let intersectingGlyphRange = layoutManager.glyphRange(forBoundingRectWithoutAdditionalLayout: intersectingRect, in: self) + let intersectingRange = layoutManager.characterRange(forGlyphRange: intersectingGlyphRange, actualGlyphRange: nil) + + escapingBlock(intersectingRect, intersectingRange, &innerStop) + } else { + escapingBlock(fragmentRect, fragmentRange, &innerStop) + } + + stop.pointee = ObjCBool(innerStop) + } + } + } + + /// Enumerate the line fragments that intersect a rect. + /// + /// - Parameter strictIntersection: If true, the result will only be rect and range strictly within the `rect` parameter. This is more expensive to compute. + public func enumerateLineFragments(for rect: CGRect, strictIntersection: Bool, block: (CGRect, NSRange, inout Bool) -> Void) { + if #available(macOS 12.0, iOS 15.0, *), let textLayoutManager { + guard let textContentManager = textLayoutManager.textContentManager else { + return + } + + textLayoutManager.enumerateLineFragments(for: rect) { fragmentRect, textRange, stop in + guard let textRange else { return } + + let range = NSRange(textRange, provider: textContentManager) + + block(fragmentRect, range, &stop) + } + + return + } + + tk1EnumerateLineFragments(for: rect, strictIntersection: strictIntersection, block: block) + } +} +#endif + diff --git a/Sources/Glyph/NSTextLayoutManager+Additions.swift b/Sources/Glyph/NSTextLayoutManager+Additions.swift new file mode 100644 index 0000000..d04da9d --- /dev/null +++ b/Sources/Glyph/NSTextLayoutManager+Additions.swift @@ -0,0 +1,65 @@ +#if os(macOS) && !targetEnvironment(macCatalyst) +import AppKit +#elseif os(iOS) || os(visionOS) +import UIKit +#endif + +#if os(macOS) || os(iOS) || os(visionOS) +@available(macOS 12.0, iOS 15.0, *) +extension NSTextLayoutManager { + public func enumerateLineFragments(for rect: CGRect, options: NSTextLayoutFragment.EnumerationOptions = [], block: (CGRect, NSTextRange?, inout Bool) -> Void) { + // if this is nil, our optmizations will have no effect + let viewportRange = textViewportLayoutController.viewportRange ?? documentRange + let viewportBounds = textViewportLayoutController.viewportBounds + let reversed = options.contains(.reverse) + + // we're going to start at a document limit, which is definitely correct but suboptimal + + var location: NSTextLocation + + if reversed { + location = documentRange.endLocation + + if rect.maxY <= viewportBounds.maxY { + location = viewportRange.endLocation + } + + if rect.maxY <= viewportBounds.minY { + location = viewportRange.location + } + } else { + location = documentRange.location + + if rect.minY >= viewportBounds.minY { + location = viewportRange.location + } + + if rect.minY >= viewportBounds.maxY { + location = viewportRange.endLocation + } + } + + enumerateTextLayoutFragments(from: location, options: options, using: { fragment in + let frame = fragment.layoutFragmentFrame + let elementRange = fragment.textElement?.elementRange + + var keepGoing: Bool + + if reversed { + keepGoing = frame.minY < rect.minY + } else { + keepGoing = frame.maxY < rect.maxY + } + + if keepGoing == false { + return false + } + + block(frame, elementRange, &keepGoing) + + return keepGoing + }) + } + +} +#endif diff --git a/Sources/Glyph/NSTextRange+NSRange.swift b/Sources/Glyph/NSTextRange+NSRange.swift new file mode 100644 index 0000000..89d106e --- /dev/null +++ b/Sources/Glyph/NSTextRange+NSRange.swift @@ -0,0 +1,30 @@ +#if os(macOS) && !targetEnvironment(macCatalyst) +import AppKit +#elseif os(iOS) || os(visionOS) +import UIKit +#endif + +// Taken from https://github.com/chimeHQ/Rearrange + +#if os(macOS) || os(iOS) || os(visionOS) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension NSRange { + init(_ textRange: NSTextRange, provider: NSTextElementProvider) { + let docLocation = provider.documentRange.location + + let start = provider.offset?(from: docLocation, to: textRange.location) ?? NSNotFound + if start == NSNotFound { + self.init(location: start, length: 0) + return + } + + let end = provider.offset?(from: docLocation, to: textRange.endLocation) ?? NSNotFound + if end == NSNotFound { + self.init(location: NSNotFound, length: 0) + return + } + + self.init(start.. IndexSet { + var set = IndexSet() + +#if os(macOS) && !targetEnvironment(macCatalyst) + guard let textContainer else { + return set + } +#endif + + textContainer.enumerateLineFragments(for: rect, strictIntersection: true) { _, range, _ in + set.insert(integersIn: range.lowerBound..