Skip to content

Commit

Permalink
Fragment enumeration and visible set computation
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Apr 5, 2024
1 parent 727cb92 commit f245683
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 2 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions Sources/Glyph/Glyph.swift

This file was deleted.

10 changes: 10 additions & 0 deletions Sources/Glyph/NSLayoutManager+Additions.swift
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions Sources/Glyph/NSTextContainer+Additions.swift
Original file line number Diff line number Diff line change
@@ -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

65 changes: 65 additions & 0 deletions Sources/Glyph/NSTextLayoutManager+Additions.swift
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions Sources/Glyph/NSTextRange+NSRange.swift
Original file line number Diff line number Diff line change
@@ -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..<end)
}
}
#endif
43 changes: 43 additions & 0 deletions Sources/Glyph/NSTextView+Additions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#if os(macOS) && !targetEnvironment(macCatalyst)
import AppKit

typealias TextView = NSTextView
#elseif os(iOS) || os(visionOS)
import UIKit

typealias TextView = UITextView
#endif

extension TextView {

Check failure on line 11 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'TextView' in scope

Check failure on line 11 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'TextView' in scope
var visibleContainerRect: CGRect {

Check failure on line 12 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'CGRect' in scope
#if os(macOS) && !targetEnvironment(macCatalyst)
let origin = textContainerOrigin
return visibleRect.offsetBy(dx: -origin.x, dy: -origin.y)
#elseif os(iOS) || os(visionOS)
return CGRect(origin: contentOffset, size: bounds.size)
#endif

}

/// Returns an IndexSet representing the content within `rect`.
public func textSet(for rect: CGRect) -> IndexSet {

Check failure on line 23 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'IndexSet' in scope

Check failure on line 23 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'CGRect' in scope
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..<range.upperBound)
}

return set
}

/// Returns an IndexSet representing the visible content.
public var visibleTextSet: IndexSet {

Check failure on line 40 in Sources/Glyph/NSTextView+Additions.swift

View workflow job for this annotation

GitHub Actions / Test (platform=tvOS Simulator,name=Apple TV)

cannot find type 'IndexSet' in scope
textSet(for: visibleContainerRect)
}
}

0 comments on commit f245683

Please sign in to comment.