diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme
new file mode 100644
index 000000000..ceaa1d0a1
--- /dev/null
+++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift
index c427db932..47a86c96c 100644
--- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift
+++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift
@@ -8,11 +8,11 @@
import SwiftUI
import UniformTypeIdentifiers
-struct CodeEditTextViewExampleDocument: FileDocument {
- var text: String
+struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable {
+ var text: NSTextStorage
init(text: String = "") {
- self.text = text
+ self.text = NSTextStorage(string: text)
}
static var readableContentTypes: [UTType] {
@@ -25,11 +25,21 @@ struct CodeEditTextViewExampleDocument: FileDocument {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
- text = String(bytes: data, encoding: .utf8) ?? ""
+ text = try NSTextStorage(
+ data: data,
+ options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain],
+ documentAttributes: nil
+ )
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
- let data = Data(text.utf8)
+ let data = try text.data(
+ from: NSRange(location: 0, length: text.length),
+ documentAttributes: [
+ .documentType: NSAttributedString.DocumentType.plain,
+ .characterEncoding: NSUTF8StringEncoding
+ ]
+ )
return .init(regularFileWithContents: data)
}
}
diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift
index c6b0f4f0f..1a64d8b54 100644
--- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift
+++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift
@@ -19,7 +19,11 @@ struct ContentView: View {
Toggle("Inset Edges", isOn: $enableEdgeInsets)
}
Divider()
- SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets)
+ SwiftUITextView(
+ text: document.text,
+ wrapLines: $wrapLines,
+ enableEdgeInsets: $enableEdgeInsets
+ )
}
}
}
diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift
index 96d5d732b..1bb836239 100644
--- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift
+++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift
@@ -10,13 +10,13 @@ import AppKit
import CodeEditTextView
struct SwiftUITextView: NSViewControllerRepresentable {
- @Binding var text: String
+ var text: NSTextStorage
@Binding var wrapLines: Bool
@Binding var enableEdgeInsets: Bool
func makeNSViewController(context: Context) -> TextViewController {
- let controller = TextViewController(string: text)
- context.coordinator.controller = controller
+ let controller = TextViewController(string: "")
+ controller.textView.setTextStorage(text)
controller.wrapLines = wrapLines
controller.enableEdgeInsets = enableEdgeInsets
return controller
@@ -26,39 +26,4 @@ struct SwiftUITextView: NSViewControllerRepresentable {
nsViewController.wrapLines = wrapLines
nsViewController.enableEdgeInsets = enableEdgeInsets
}
-
- func makeCoordinator() -> Coordinator {
- Coordinator(text: $text)
- }
-
- @MainActor
- public class Coordinator: NSObject {
- weak var controller: TextViewController?
- var text: Binding
-
- init(text: Binding) {
- self.text = text
- super.init()
-
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(textViewDidChangeText(_:)),
- name: TextView.textDidChangeNotification,
- object: nil
- )
- }
-
- @objc func textViewDidChangeText(_ notification: Notification) {
- guard let textView = notification.object as? TextView,
- let controller,
- controller.textView === textView else {
- return
- }
- text.wrappedValue = textView.string
- }
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- }
}
diff --git a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift
new file mode 100644
index 000000000..fefe98530
--- /dev/null
+++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift
@@ -0,0 +1,127 @@
+//
+// CTTypesetter+SuggestLineBreak.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import AppKit
+
+extension CTTypesetter {
+ /// Suggest a line break for the given line break strategy.
+ /// - Parameters:
+ /// - typesetter: The typesetter to use.
+ /// - strategy: The strategy that determines a valid line break.
+ /// - startingOffset: Where to start breaking.
+ /// - constrainingWidth: The available space for the line.
+ /// - Returns: An offset relative to the entire string indicating where to break.
+ func suggestLineBreak(
+ using string: NSAttributedString,
+ strategy: LineBreakStrategy,
+ subrange: NSRange,
+ constrainingWidth: CGFloat
+ ) -> Int {
+ switch strategy {
+ case .character:
+ return suggestLineBreakForCharacter(
+ string: string,
+ startingOffset: subrange.location,
+ constrainingWidth: constrainingWidth
+ )
+ case .word:
+ return suggestLineBreakForWord(
+ string: string,
+ subrange: subrange,
+ constrainingWidth: constrainingWidth
+ )
+ }
+ }
+
+ /// Suggest a line break for the character break strategy.
+ /// - Parameters:
+ /// - typesetter: The typesetter to use.
+ /// - startingOffset: Where to start breaking.
+ /// - constrainingWidth: The available space for the line.
+ /// - Returns: An offset relative to the entire string indicating where to break.
+ private func suggestLineBreakForCharacter(
+ string: NSAttributedString,
+ startingOffset: Int,
+ constrainingWidth: CGFloat
+ ) -> Int {
+ var breakIndex: Int
+ // Check if we need to skip to an attachment
+
+ breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth)
+ guard breakIndex < string.length else {
+ return breakIndex
+ }
+ let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string
+ if substring == LineEnding.carriageReturnLineFeed.rawValue {
+ // Breaking in the middle of the clrf line ending
+ breakIndex += 1
+ }
+
+ return breakIndex
+ }
+
+ /// Suggest a line break for the word break strategy.
+ /// - Parameters:
+ /// - typesetter: The typesetter to use.
+ /// - startingOffset: Where to start breaking.
+ /// - constrainingWidth: The available space for the line.
+ /// - Returns: An offset relative to the entire string indicating where to break.
+ private func suggestLineBreakForWord(
+ string: NSAttributedString,
+ subrange: NSRange,
+ constrainingWidth: CGFloat
+ ) -> Int {
+ var breakIndex = subrange.location + CTTypesetterSuggestClusterBreak(self, subrange.location, constrainingWidth)
+ let isBreakAtEndOfString = breakIndex >= subrange.max
+
+ let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string)
+ if isNextCharacterCarriageReturn {
+ breakIndex += 1
+ }
+
+ let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1, for: string))
+
+ if isBreakAtEndOfString || canLastCharacterBreak {
+ // Breaking either at the end of the string, or on a whitespace.
+ return breakIndex
+ } else if breakIndex - 1 > 0 {
+ // Try to walk backwards until we hit a whitespace or punctuation
+ var index = breakIndex - 1
+
+ while breakIndex - index < 100 && index > subrange.location {
+ if ensureCharacterCanBreakLine(at: index, for: string) {
+ return index + 1
+ }
+ index -= 1
+ }
+ }
+
+ return breakIndex
+ }
+
+ /// Ensures the character at the given index can break a line.
+ /// - Parameter index: The index to check at.
+ /// - Returns: True, if the character is a whitespace or punctuation character.
+ private func ensureCharacterCanBreakLine(at index: Int, for string: NSAttributedString) -> Bool {
+ let subrange = (string.string as NSString).rangeOfComposedCharacterSequence(at: index)
+ let set = CharacterSet(charactersIn: (string.string as NSString).substring(with: subrange))
+ return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters)
+ }
+
+ /// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position.
+ /// - Parameter breakIndex: The index to check in the string.
+ /// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence.
+ private func checkIfLineBreakOnCRLF(_ breakIndex: Int, for string: NSAttributedString) -> Bool {
+ guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else {
+ return false
+ }
+ let substringRange = NSRange(location: breakIndex - 1, length: 2)
+ let substring = string.attributedSubstring(from: substringRange).string
+
+ return substring == LineEnding.carriageReturnLineFeed.rawValue
+ }
+}
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift
new file mode 100644
index 000000000..61ca777f2
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift
@@ -0,0 +1,31 @@
+//
+// TextAttachment.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import AppKit
+
+/// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view.
+public protocol TextAttachment: AnyObject {
+ var width: CGFloat { get }
+ func draw(in context: CGContext, rect: NSRect)
+}
+
+/// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment.
+///
+/// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating
+/// the ``TextAttachmentManager``.
+public struct AnyTextAttachment: Equatable {
+ var range: NSRange
+ let attachment: any TextAttachment
+
+ var width: CGFloat {
+ attachment.width
+ }
+
+ public static func == (_ lhs: AnyTextAttachment, _ rhs: AnyTextAttachment) -> Bool {
+ lhs.range == rhs.range && lhs.attachment === rhs.attachment
+ }
+}
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift
new file mode 100644
index 000000000..5bad2de1e
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift
@@ -0,0 +1,160 @@
+//
+// TextAttachmentManager.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import Foundation
+
+/// Manages a set of attachments for the layout manager, provides methods for efficiently finding attachments for a
+/// line range.
+///
+/// If two attachments are overlapping, the one placed further along in the document will be
+/// ignored when laying out attachments.
+public final class TextAttachmentManager {
+ private var orderedAttachments: [AnyTextAttachment] = []
+ weak var layoutManager: TextLayoutManager?
+
+ /// Adds a new attachment, keeping `orderedAttachments` sorted by range.location.
+ /// If two attachments overlap, the layout phase will later ignore the one with the higher start.
+ /// - Complexity: `O(n log(n))` due to array insertion. Could be improved with a binary tree.
+ public func add(_ attachment: any TextAttachment, for range: NSRange) {
+ let attachment = AnyTextAttachment(range: range, attachment: attachment)
+ let insertIndex = findInsertionIndex(for: range.location)
+ orderedAttachments.insert(attachment, at: insertIndex)
+ layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach {
+ if $0.height != 0 {
+ layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height)
+ }
+ }
+ layoutManager?.setNeedsLayout()
+ }
+
+ /// Removes an attachment and invalidates layout for the removed range.
+ /// - Parameter offset: The offset the attachment begins at.
+ /// - Returns: The removed attachment, if it exists.
+ @discardableResult
+ public func remove(atOffset offset: Int) -> AnyTextAttachment? {
+ let index = findInsertionIndex(for: offset)
+
+ guard index < orderedAttachments.count && orderedAttachments[index].range.location == offset else {
+ return nil
+ }
+
+ let attachment = orderedAttachments.remove(at: index)
+ layoutManager?.invalidateLayoutForRange(attachment.range)
+ return attachment
+ }
+
+ /// Finds attachments starting in the given line range, and returns them as an array.
+ /// Returned attachment's ranges will be relative to the _document_, not the line.
+ /// - Complexity: `O(n log(n))`, ideally `O(log(n))`
+ public func getAttachmentsStartingIn(_ range: NSRange) -> [AnyTextAttachment] {
+ var results: [AnyTextAttachment] = []
+ var idx = findInsertionIndex(for: range.location)
+ while idx < orderedAttachments.count {
+ let attachment = orderedAttachments[idx]
+ let loc = attachment.range.location
+ if loc >= range.upperBound {
+ break
+ }
+ if range.contains(loc) {
+ if let lastResult = results.last, !lastResult.range.contains(attachment.range.location) {
+ results.append(attachment)
+ } else if results.isEmpty {
+ results.append(attachment)
+ }
+ }
+ idx += 1
+ }
+ return results
+ }
+
+ /// Returns all attachments whose ranges overlap the given query range.
+ ///
+ /// - Parameter range: The `NSRange` to test for overlap.
+ /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`.
+ public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] {
+ // Find the first attachment whose end is beyond the start of the query.
+ guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else {
+ return []
+ }
+
+ var results: [AnyTextAttachment] = []
+ var idx = startIdx
+
+ // Collect every subsequent attachment that truly overlaps the query.
+ while idx < orderedAttachments.count {
+ let attachment = orderedAttachments[idx]
+ if attachment.range.location >= range.upperBound {
+ break
+ }
+ if attachment.range.intersection(range)?.length ?? 0 > 0,
+ results.last?.range != attachment.range {
+ results.append(attachment)
+ }
+ idx += 1
+ }
+
+ return results
+ }
+
+ /// Updates the text attachments to stay in the same relative spot after the edit, and removes any attachments that
+ /// were in the updated range.
+ /// - Parameters:
+ /// - atOffset: The offset text was updated at.
+ /// - delta: The change delta, positive is an insertion.
+ package func textUpdated(atOffset: Int, delta: Int) {
+ for (idx, attachment) in orderedAttachments.enumerated().reversed() {
+ if attachment.range.contains(atOffset) {
+ orderedAttachments.remove(at: idx)
+ } else if attachment.range.location > atOffset {
+ orderedAttachments[idx].range.location += delta
+ }
+ }
+ }
+}
+
+private extension TextAttachmentManager {
+ /// Binary-searches `orderedAttachments` and returns the smallest index
+ /// at which `predicate(attachment)` is true (i.e. the lower-bound index).
+ ///
+ /// - Note: always returns a value in `0...orderedAttachments.count`.
+ /// If it returns `orderedAttachments.count`, no element satisfied
+ /// the predicate, but that’s still a valid insertion point.
+ func lowerBoundIndex(
+ where predicate: (AnyTextAttachment) -> Bool
+ ) -> Int {
+ var low = 0
+ var high = orderedAttachments.count
+ while low < high {
+ let mid = (low + high) / 2
+ if predicate(orderedAttachments[mid]) {
+ high = mid
+ } else {
+ low = mid + 1
+ }
+ }
+ return low
+ }
+
+ /// Returns the index in `orderedAttachments` at which an attachment whose
+ /// `range.location == location` *could* be inserted, keeping the array sorted.
+ ///
+ /// - Parameter location: the attachment’s `range.location`
+ /// - Returns: a valid insertion index in `0...orderedAttachments.count`
+ func findInsertionIndex(for location: Int) -> Int {
+ lowerBoundIndex { $0.range.location >= location }
+ }
+
+ /// Finds the first index whose attachment satisfies `predicate`.
+ ///
+ /// - Parameter predicate: the query predicate.
+ /// - Returns: the first matching index, or `nil` if none of the
+ /// attachments satisfy the predicate.
+ func firstIndex(where predicate: (AnyTextAttachment) -> Bool) -> Int? {
+ let idx = lowerBoundIndex { predicate($0) }
+ return idx < orderedAttachments.count ? idx : nil
+ }
+}
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift
index 192b8a981..b3d7d11bc 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift
@@ -46,7 +46,9 @@ extension TextLayoutManager: NSTextStorageDelegate {
removeLayoutLinesIn(range: insertedStringRange)
insertNewLines(for: editedRange)
- setNeedsLayout()
+ attachments.textUpdated(atOffset: editedRange.location, delta: delta)
+
+ invalidateLayoutForRange(insertedStringRange)
}
/// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift
index ff7315270..4e5efede5 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift
@@ -14,14 +14,14 @@ public extension TextLayoutManager {
/// if there is no delegate from `0` to the estimated document height.
///
/// - Returns: An iterator to iterate through all visible lines.
- func visibleLines() -> Iterator {
+ func visibleLines() -> YPositionIterator {
let visibleRect = delegate?.visibleRect ?? NSRect(
x: 0,
y: 0,
width: 0,
height: estimatedHeight()
)
- return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage)
+ return YPositionIterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), layoutManager: self)
}
/// Iterate over all lines in the y position range.
@@ -29,19 +29,193 @@ public extension TextLayoutManager {
/// - minY: The minimum y position to begin at.
/// - maxY: The maximum y position to iterate to.
/// - Returns: An iterator that will iterate through all text lines in the y position range.
- func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorage.TextLineStorageYIterator {
- lineStorage.linesStartingAt(minY, until: maxY)
+ func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> YPositionIterator {
+ YPositionIterator(minY: minY, maxY: maxY, layoutManager: self)
}
+ /// Iterate over all lines that overlap a document range.
+ /// - Parameters:
+ /// - range: The range in the document to iterate over.
+ /// - Returns: An iterator for lines in the range. The iterator returns lines that *overlap* with the range.
+ /// Returned lines may extend slightly before or after the queried range.
+ func linesInRange(_ range: NSRange) -> RangeIterator {
+ RangeIterator(range: range, layoutManager: self)
+ }
+
+ /// This iterator iterates over "visible" text positions that overlap a range of vertical `y` positions
+ /// using ``TextLayoutManager/determineVisiblePosition(for:)``.
+ ///
+ /// Next elements are retrieved lazily. Additionally, this iterator uses a stable `index` rather than a y position
+ /// or a range to fetch the next line. This means the line storage can be updated during iteration.
+ struct YPositionIterator: LazySequenceProtocol, IteratorProtocol {
+ typealias TextLinePosition = TextLineStorage.TextLinePosition
+
+ private weak var layoutManager: TextLayoutManager?
+ private let minY: CGFloat
+ private let maxY: CGFloat
+ private var currentPosition: (position: TextLinePosition, indexRange: ClosedRange)?
+
+ init(minY: CGFloat, maxY: CGFloat, layoutManager: TextLayoutManager) {
+ self.minY = minY
+ self.maxY = maxY
+ self.layoutManager = layoutManager
+ }
+
+ /// Iterates over the "visible" text positions.
+ ///
+ /// See documentation on ``TextLayoutManager/determineVisiblePosition(for:)`` for details.
+ public mutating func next() -> TextLineStorage.TextLinePosition? {
+ if let currentPosition {
+ guard let nextPosition = layoutManager?.lineStorage.getLine(
+ atIndex: currentPosition.indexRange.upperBound + 1
+ ), nextPosition.yPos < maxY else {
+ return nil
+ }
+ self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition)
+ return self.currentPosition?.position
+ } else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) {
+ currentPosition = layoutManager?.determineVisiblePosition(for: position)
+ return currentPosition?.position
+ }
- struct Iterator: LazySequenceProtocol, IteratorProtocol {
- private var storageIterator: TextLineStorage.TextLineStorageYIterator
+ return nil
+ }
+ }
+
+ /// This iterator iterates over "visible" text positions that overlap a document using
+ /// ``TextLayoutManager/determineVisiblePosition(for:)``.
+ ///
+ /// Next elements are retrieved lazily. Additionally, this iterator uses a stable `index` rather than a y position
+ /// or a range to fetch the next line. This means the line storage can be updated during iteration.
+ struct RangeIterator: LazySequenceProtocol, IteratorProtocol {
+ typealias TextLinePosition = TextLineStorage.TextLinePosition
+
+ private weak var layoutManager: TextLayoutManager?
+ private let range: NSRange
+ private var currentPosition: (position: TextLinePosition, indexRange: ClosedRange)?
- init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) {
- storageIterator = storage.linesStartingAt(minY, until: maxY)
+ init(range: NSRange, layoutManager: TextLayoutManager) {
+ self.range = range
+ self.layoutManager = layoutManager
}
+ /// Iterates over the "visible" text positions.
+ ///
+ /// See documentation on ``TextLayoutManager/determineVisiblePosition(for:)`` for details.
public mutating func next() -> TextLineStorage.TextLinePosition? {
- storageIterator.next()
+ if let currentPosition {
+ guard let nextPosition = layoutManager?.lineStorage.getLine(
+ atIndex: currentPosition.indexRange.upperBound + 1
+ ), nextPosition.range.location < range.max else {
+ return nil
+ }
+ self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition)
+ return self.currentPosition?.position
+ } else if let position = layoutManager?.lineStorage.getLine(atOffset: range.location) {
+ currentPosition = layoutManager?.determineVisiblePosition(for: position)
+ return currentPosition?.position
+ }
+
+ return nil
+ }
+ }
+
+ /// Determines the “visible” line position by merging any consecutive lines
+ /// that are spanned by text attachments. If an attachment overlaps beyond the
+ /// bounds of the original line, this method will extend the returned range to
+ /// cover the full span of those attachments (and recurse if further attachments
+ /// cross into newly included lines).
+ ///
+ /// For example, given the following: *(`[` == attachment start, `]` == attachment end)*
+ /// ```
+ /// Line 1
+ /// Line[ 2
+ /// Line 3
+ /// Line] 4
+ /// ```
+ /// If you start at the position for “Line 2”, the first and last attachments
+ /// overlap lines 2–4, so this method will extend the range to cover lines 2–4
+ /// and return a position whose `range` spans the entire attachment.
+ ///
+ /// # Why recursion?
+ ///
+ /// When an attachment extends the visible range, it may pull in new lines that themselves overlap other
+ /// attachments. A simple one‐pass merge wouldn’t catch those secondary overlaps. By calling
+ /// determineVisiblePosition again on the newly extended range, we ensure that all cascading attachments—no matter
+ /// how many lines they span—are folded into a single, coherent TextLinePosition before returning.
+ ///
+ /// - Parameter originalPosition: The initial `TextLinePosition` to inspect.
+ /// Pass in the position you got from `lineStorage.getLine(atOffset:)` or similar.
+ /// - Returns: A tuple containing `position`: A `TextLinePosition` whose `range` and `index` have been
+ /// adjusted to include any attachment‐spanned lines.. `indexRange`: A `ClosedRange` listing all of
+ /// the line indices that are now covered by the returned position.
+ /// Returns `nil` if `originalPosition` is `nil`.
+ func determineVisiblePosition(
+ for originalPosition: TextLineStorage.TextLinePosition?
+ ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? {
+ guard let originalPosition else { return nil }
+ return determineVisiblePositionRecursively(
+ for: (originalPosition, originalPosition.index...originalPosition.index),
+ recursionDepth: 0
+ )
+ }
+
+ /// Private implementation of ``TextLayoutManager/determineVisiblePosition(for:)``.
+ ///
+ /// Separated for readability. This method does not have an optional parameter, and keeps track of a recursion
+ /// depth.
+ private func determineVisiblePositionRecursively(
+ for originalPosition: (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange),
+ recursionDepth: Int
+ ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? {
+ // Arbitrary max recursion depth. Ensures we don't spiral into in an infinite recursion.
+ guard recursionDepth < 10 else {
+ logger.warning("Visible position recursed for over 10 levels, returning early.")
+ return originalPosition
+ }
+
+ let attachments = attachments.getAttachmentsOverlapping(originalPosition.position.range)
+ guard let firstAttachment = attachments.first, let lastAttachment = attachments.last else {
+ // No change, either no attachments or attachment doesn't span multiple lines.
+ return originalPosition
+ }
+
+ var minIndex = originalPosition.indexRange.lowerBound
+ var maxIndex = originalPosition.indexRange.upperBound
+ var newPosition = originalPosition.position
+
+ if firstAttachment.range.location < originalPosition.position.range.location,
+ let extendedLinePosition = lineStorage.getLine(atOffset: firstAttachment.range.location) {
+ newPosition = TextLineStorage.TextLinePosition(
+ data: extendedLinePosition.data,
+ range: NSRange(start: extendedLinePosition.range.location, end: newPosition.range.max),
+ yPos: extendedLinePosition.yPos,
+ height: extendedLinePosition.height,
+ index: extendedLinePosition.index
+ )
+ minIndex = min(minIndex, newPosition.index)
+ }
+
+ if lastAttachment.range.max > originalPosition.position.range.max,
+ let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
+ newPosition = TextLineStorage.TextLinePosition(
+ data: newPosition.data,
+ range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max),
+ yPos: newPosition.yPos,
+ height: newPosition.height,
+ index: newPosition.index // We want to keep the minimum index.
+ )
+ maxIndex = max(maxIndex, extendedLinePosition.index)
+ }
+
+ // Base case, we haven't updated anything
+ if minIndex...maxIndex == originalPosition.indexRange {
+ return (newPosition, minIndex...maxIndex)
+ } else {
+ // Recurse, to make sure we combine all necessary lines.
+ return determineVisiblePositionRecursively(
+ for: (newPosition, minIndex...maxIndex),
+ recursionDepth: recursionDepth + 1
+ )
}
}
}
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift
index b90c18b46..cdbc2704d 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift
@@ -61,11 +61,12 @@ extension TextLayoutManager {
/// to re-enter.
/// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this
/// is not the way to do so. This should only be called when macOS performs layout.
- public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
+ @discardableResult
+ public func layoutLines(in rect: NSRect? = nil) -> Set { // swiftlint:disable:this function_body_length
guard let visibleRect = rect ?? delegate?.visibleRect,
!isInTransaction,
let textStorage else {
- return
+ return []
}
// The macOS may call `layout` on the textView while we're laying out fragment views. This ensures the view
@@ -77,14 +78,18 @@ extension TextLayoutManager {
let minY = max(visibleRect.minY - verticalLayoutPadding, 0)
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
let originalHeight = lineStorage.height
- var usedFragmentIDs = Set()
+ var usedFragmentIDs = Set()
var forceLayout: Bool = needsLayout
var newVisibleLines: Set = []
var yContentAdjustment: CGFloat = 0
var maxFoundLineWidth = maxLineWidth
+#if DEBUG
+ var laidOutLines: Set = []
+#endif
+
// Layout all lines, fetching lines lazily as they are laid out.
- for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy {
+ for linePosition in linesStartingAt(minY, until: maxY).lazy {
guard linePosition.yPos < maxY else { continue }
// Three ways to determine if a line needs to be re-calculated.
let changedWidth = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth)
@@ -115,6 +120,9 @@ extension TextLayoutManager {
if maxFoundLineWidth < lineSize.width {
maxFoundLineWidth = lineSize.width
}
+#if DEBUG
+ laidOutLines.insert(linePosition.data.id)
+#endif
} else {
// Make sure the used fragment views aren't dequeued.
usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id))
@@ -147,6 +155,12 @@ extension TextLayoutManager {
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
+
+#if DEBUG
+ return laidOutLines
+#else
+ return []
+#endif
}
// MARK: - Layout Single Line
@@ -162,12 +176,13 @@ extension TextLayoutManager {
_ position: TextLineStorage.TextLinePosition,
textStorage: NSTextStorage,
layoutData: LineLayoutData,
- laidOutFragmentIDs: inout Set
+ laidOutFragmentIDs: inout Set
) -> CGSize {
let lineDisplayData = TextLine.DisplayData(
maxWidth: layoutData.maxWidth,
lineHeightMultiplier: lineHeightMultiplier,
- estimatedLineHeight: estimateLineHeight()
+ estimatedLineHeight: estimateLineHeight(),
+ breakStrategy: lineBreakStrategy
)
let line = position.data
@@ -178,7 +193,7 @@ extension TextLayoutManager {
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
- breakStrategy: lineBreakStrategy
+ attachments: attachments.getAttachmentsStartingIn(position.range)
)
} else {
line.prepareForDisplay(
@@ -186,7 +201,7 @@ extension TextLayoutManager {
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
- breakStrategy: lineBreakStrategy
+ attachments: attachments.getAttachmentsStartingIn(position.range)
)
}
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift
index c79b8b5f5..6a1a4df61 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift
@@ -29,7 +29,7 @@ extension TextLayoutManager {
/// - Parameter posY: The y position to find a line for.
/// - Returns: A text line position, if a line could be found at the given y position.
public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? {
- lineStorage.getLine(atPosition: posY)
+ determineVisiblePosition(for: lineStorage.getLine(atPosition: posY))?.position
}
/// Finds a text line for a given text offset.
@@ -46,7 +46,7 @@ extension TextLayoutManager {
if offset == lineStorage.length {
return lineStorage.last
} else {
- return lineStorage.getLine(atOffset: offset)
+ return determineVisiblePosition(for: lineStorage.getLine(atOffset: offset))?.position
}
}
@@ -56,7 +56,7 @@ extension TextLayoutManager {
/// - Returns: The text line position if any, `nil` if the index is out of bounds.
public func textLineForIndex(_ index: Int) -> TextLineStorage.TextLinePosition? {
guard index >= 0 && index < lineStorage.count else { return nil }
- return lineStorage.getLine(atIndex: index)
+ return determineVisiblePosition(for: lineStorage.getLine(atIndex: index))?.position
}
/// Calculates the text position at the given point in the view.
@@ -69,39 +69,83 @@ extension TextLayoutManager {
guard point.y <= estimatedHeight() else { // End position is a special case.
return textStorage?.length
}
- guard let position = lineStorage.getLine(atPosition: point.y),
- let fragmentPosition = position.data.typesetter.lineFragments.getLine(
- atPosition: point.y - position.yPos
+ guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y))?.position,
+ let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
+ atPosition: point.y - linePosition.yPos
) else {
return nil
}
let fragment = fragmentPosition.data
if fragment.width == 0 {
- return position.range.location + fragmentPosition.range.location
- } else if fragment.width < point.x - edgeInsets.left {
- let fragmentRange = CTLineGetStringRange(fragment.ctLine)
- let globalFragmentRange = NSRange(
- location: position.range.location + fragmentRange.location,
- length: fragmentRange.length
- )
- let endPosition = position.range.location + fragmentRange.location + fragmentRange.length
-
- // If the endPosition is at the end of the line, and the line ends with a line ending character
- // return the index before the eol.
- if endPosition == position.range.max,
- let lineEnding = LineEnding(line: textStorage?.substring(from: globalFragmentRange) ?? "") {
- return endPosition - lineEnding.length
- } else {
- return endPosition
- }
+ return linePosition.range.location + fragmentPosition.range.location
+ } else if fragment.width <= point.x - edgeInsets.left {
+ return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition)
+ } else {
+ return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition)
+ }
+ }
+
+ /// Finds a document offset after a line fragment. Returns a cursor position.
+ ///
+ /// If the fragment ends the line, return the position before the potential line break. This visually positions the
+ /// cursor at the end of the line, but before the break character. If deleted, it edits the visually selected line.
+ ///
+ /// If not at the line end, do the same with the fragment and respect any composed character sequences at
+ /// the line break.
+ ///
+ /// Return the line end position otherwise.
+ ///
+ /// - Parameters:
+ /// - fragmentPosition: The fragment position being queried.
+ /// - linePosition: The line position that contains the `fragment`.
+ /// - Returns: The position visually at the end of the line fragment.
+ private func findOffsetAfterEndOf(
+ fragmentPosition: TextLineStorage.TextLinePosition,
+ in linePosition: TextLineStorage.TextLinePosition
+ ) -> Int? {
+ let endPosition = fragmentPosition.data.documentRange.max
+
+ // If the endPosition is at the end of the line, and the line ends with a line ending character
+ // return the index before the eol.
+ if fragmentPosition.index == linePosition.data.lineFragments.count - 1,
+ let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentPosition.data.documentRange) ?? "") {
+ return endPosition - lineEnding.length
+ } else if fragmentPosition.index != linePosition.data.lineFragments.count - 1 {
+ // If this isn't the last fragment, we want to place the cursor at the offset right before the break
+ // index, to appear on the end of *this* fragment.
+ let string = (textStorage?.string as? NSString)
+ return string?.rangeOfComposedCharacterSequence(at: endPosition - 1).location
} else {
- // Somewhere in the fragment
+ // Otherwise, return the end of the fragment (and the end of the line).
+ return endPosition
+ }
+ }
+
+ /// Finds a document offset for a point that lies in a line fragment.
+ /// - Parameters:
+ /// - fragment: The fragment the point lies in.
+ /// - point: The point being queried, relative to the text view.
+ /// - linePosition: The position that contains the `fragment`.
+ /// - Returns: The offset (relative to the document) that's closest to the given point, or `nil` if it could not be
+ /// found.
+ private func findOffsetAtPoint(
+ inFragment fragment: LineFragment,
+ point: CGPoint,
+ inLine linePosition: TextLineStorage.TextLinePosition
+ ) -> Int? {
+ guard let (content, contentPosition) = fragment.findContent(atX: point.x - edgeInsets.left) else {
+ return nil
+ }
+ switch content.data {
+ case .text(let ctLine):
let fragmentIndex = CTLineGetStringIndexForPosition(
- fragment.ctLine,
- CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2)
+ ctLine,
+ CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
)
- return position.range.location + fragmentIndex
+ return fragmentIndex + contentPosition.offset + linePosition.range.location
+ case .attachment:
+ return contentPosition.offset + linePosition.range.location
}
}
@@ -117,10 +161,9 @@ extension TextLayoutManager {
guard offset != lineStorage.length else {
return rectForEndOffset()
}
- guard let linePosition = lineStorage.getLine(atOffset: offset) else {
+ guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atOffset: offset))?.position else {
return nil
}
-
guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
atOffset: offset - linePosition.range.location
) else {
@@ -129,18 +172,21 @@ extension TextLayoutManager {
// Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct
// length of the character at the offset.
- let realRange = textStorage?.length == 0
- ? NSRange(location: offset, length: 0)
- : (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset)
- ?? NSRange(location: offset, length: 0)
+ let realRange = if textStorage?.length == 0 {
+ NSRange(location: offset, length: 0)
+ } else if let string = textStorage?.string as? NSString {
+ string.rangeOfComposedCharacterSequence(at: offset)
+ } else {
+ NSRange(location: offset, length: 0)
+ }
let minXPos = characterXPosition(
in: fragmentPosition.data,
- for: realRange.location - linePosition.range.location
+ for: realRange.location - fragmentPosition.data.documentRange.location
)
let maxXPos = characterXPosition(
in: fragmentPosition.data,
- for: realRange.max - linePosition.range.location
+ for: realRange.max - fragmentPosition.data.documentRange.location
)
return CGRect(
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift
index 7cf7b0428..84195b00c 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift
@@ -64,12 +64,14 @@ public class TextLayoutManager: NSObject {
}
}
+ public let attachments: TextAttachmentManager = TextAttachmentManager()
+
// MARK: - Internal
weak var textStorage: NSTextStorage?
var lineStorage: TextLineStorage = TextLineStorage()
var markedTextManager: MarkedTextManager = MarkedTextManager()
- let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
+ let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
package var visibleLineIds: Set = []
/// Used to force a complete re-layout using `setNeedsLayout`
package var needsLayout: Bool = false
@@ -130,6 +132,7 @@ public class TextLayoutManager: NSObject {
self.renderDelegate = renderDelegate
super.init()
prepareTextLines()
+ attachments.layoutManager = self
}
/// Prepares the layout manager for use.
diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift
index 6e366d3c5..34e930b75 100644
--- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift
+++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift
@@ -18,7 +18,7 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject {
range: NSRange,
stringRef: NSTextStorage,
markedRanges: MarkedRanges?,
- breakStrategy: LineBreakStrategy
+ attachments: [AnyTextAttachment]
)
func estimatedLineHeight() -> CGFloat?
@@ -35,14 +35,14 @@ public extension TextLayoutManagerRenderDelegate {
range: NSRange,
stringRef: NSTextStorage,
markedRanges: MarkedRanges?,
- breakStrategy: LineBreakStrategy
+ attachments: [AnyTextAttachment]
) {
textLine.prepareForDisplay(
displayData: displayData,
range: range,
stringRef: stringRef,
markedRanges: markedRanges,
- breakStrategy: breakStrategy
+ attachments: attachments
)
}
diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift
index 923848ab1..1c777bcf5 100644
--- a/Sources/CodeEditTextView/TextLine/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift
@@ -11,9 +11,44 @@ import CodeEditTextViewObjC
/// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line
/// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment.
public final class LineFragment: Identifiable, Equatable {
+ public struct FragmentContent: Equatable {
+ public enum Content: Equatable {
+ case text(line: CTLine)
+ case attachment(attachment: AnyTextAttachment)
+ }
+
+ public let data: Content
+ public let width: CGFloat
+
+ public var length: Int {
+ switch data {
+ case .text(let line):
+ CTLineGetStringRange(line).length
+ case .attachment(let attachment):
+ attachment.range.length
+ }
+ }
+
+#if DEBUG
+ var isText: Bool {
+ switch data {
+ case .text:
+ true
+ case .attachment:
+ false
+ }
+ }
+#endif
+ }
+
+ public struct ContentPosition {
+ let xPos: CGFloat
+ let offset: Int
+ }
+
public let id = UUID()
public let documentRange: NSRange
- public var ctLine: CTLine
+ public var contents: [FragmentContent]
public var width: CGFloat
public var height: CGFloat
public var descent: CGFloat
@@ -26,14 +61,14 @@ public final class LineFragment: Identifiable, Equatable {
init(
documentRange: NSRange,
- ctLine: CTLine,
+ contents: [FragmentContent],
width: CGFloat,
height: CGFloat,
descent: CGFloat,
lineHeightMultiplier: CGFloat
) {
self.documentRange = documentRange
- self.ctLine = ctLine
+ self.contents = contents
self.width = width
self.height = height
self.descent = descent
@@ -44,12 +79,6 @@ public final class LineFragment: Identifiable, Equatable {
lhs.id == rhs.id
}
- /// Finds the x position of the offset in the string the fragment represents.
- /// - Parameter offset: The offset, relative to the start of the *line*.
- /// - Returns: The x position of the character in the drawn line, from the left.
- @available(*, deprecated, renamed: "layoutManager.characterXPosition(in:)", message: "Moved to layout manager")
- public func xPos(for offset: Int) -> CGFloat { _xPos(for: offset) }
-
/// Finds the x position of the offset in the string the fragment represents.
///
/// Underscored, because although this needs to be accessible outside this class, the relevant layout manager method
@@ -58,7 +87,19 @@ public final class LineFragment: Identifiable, Equatable {
/// - Parameter offset: The offset, relative to the start of the *line*.
/// - Returns: The x position of the character in the drawn line, from the left.
func _xPos(for offset: Int) -> CGFloat {
- return CTLineGetOffsetForStringIndex(ctLine, offset, nil)
+ guard let (content, position) = findContent(at: offset) else {
+ return width
+ }
+ switch content.data {
+ case .text(let ctLine):
+ return CTLineGetOffsetForStringIndex(
+ ctLine,
+ CTLineGetStringRange(ctLine).location + offset - position.offset,
+ nil
+ ) + position.xPos
+ case .attachment:
+ return position.xPos
+ }
}
public func draw(in context: CGContext, yPos: CGFloat) {
@@ -82,27 +123,62 @@ public final class LineFragment: Identifiable, Equatable {
ContextSetHiddenSmoothingStyle(context, 16)
context.textMatrix = .init(scaleX: 1, y: -1)
- context.textPosition = CGPoint(
- x: 0,
- y: yPos + height - descent + (heightDifference/2)
- ).pixelAligned
- CTLineDraw(ctLine, context)
+ var currentPosition: CGFloat = 0.0
+ var currentLocation = 0
+ for content in contents {
+ context.saveGState()
+ switch content.data {
+ case .text(let ctLine):
+ context.textPosition = CGPoint(
+ x: currentPosition,
+ y: yPos + height - descent + (heightDifference/2)
+ ).pixelAligned
+ CTLineDraw(ctLine, context)
+ case .attachment(let attachment):
+ attachment.attachment.draw(
+ in: context,
+ rect: NSRect(x: currentPosition, y: yPos, width: attachment.width, height: scaledHeight)
+ )
+ }
+ context.restoreGState()
+ currentPosition += content.width
+ currentLocation += content.length
+ }
context.restoreGState()
}
- /// Calculates the drawing rect for a given range.
- /// - Parameter range: The range to calculate the bounds for, relative to the line.
- /// - Returns: A rect that contains the text contents in the given range.
- @available(*, deprecated, renamed: "layoutManager.characterRect(in:)", message: "Moved to layout manager")
- public func rectFor(range: NSRange) -> CGRect {
- let minXPos = CTLineGetOffsetForStringIndex(ctLine, range.lowerBound, nil)
- let maxXPos = CTLineGetOffsetForStringIndex(ctLine, range.upperBound, nil)
- return CGRect(
- x: minXPos,
- y: 0,
- width: maxXPos - minXPos,
- height: scaledHeight
- )
+ package func findContent(at location: Int) -> (content: FragmentContent, position: ContentPosition)? {
+ var position = ContentPosition(xPos: 0, offset: 0)
+
+ for content in contents {
+ let length = content.length
+ let width = content.width
+
+ if (position.offset..<(position.offset + length)).contains(location) {
+ return (content, position)
+ }
+
+ position = ContentPosition(xPos: position.xPos + width, offset: position.offset + length)
+ }
+
+ return nil
+ }
+
+ package func findContent(atX xPos: CGFloat) -> (content: FragmentContent, position: ContentPosition)? {
+ var position = ContentPosition(xPos: 0, offset: 0)
+
+ for content in contents {
+ let length = content.length
+ let width = content.width
+
+ if (position.xPos..<(position.xPos + width)).contains(xPos) {
+ return (content, position)
+ }
+
+ position = ContentPosition(xPos: position.xPos + width, offset: position.offset + length)
+ }
+
+ return nil
}
}
diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift
index 9e1ac6289..2eee6f375 100644
--- a/Sources/CodeEditTextView/TextLine/TextLine.swift
+++ b/Sources/CodeEditTextView/TextLine/TextLine.swift
@@ -48,13 +48,13 @@ public final class TextLine: Identifiable, Equatable {
/// - range: The range this text range represents in the entire document.
/// - stringRef: A reference to the string storage for the document.
/// - markedRanges: Any marked ranges in the line.
- /// - breakStrategy: Determines how line breaks are calculated.
+ /// - attachments: Any attachments overlapping the line range.
public func prepareForDisplay(
displayData: DisplayData,
range: NSRange,
stringRef: NSTextStorage,
markedRanges: MarkedRanges?,
- breakStrategy: LineBreakStrategy
+ attachments: [AnyTextAttachment]
) {
let string = stringRef.attributedSubstring(from: range)
self.maxWidth = displayData.maxWidth
@@ -62,8 +62,8 @@ public final class TextLine: Identifiable, Equatable {
string,
documentRange: range,
displayData: displayData,
- breakStrategy: breakStrategy,
- markedRanges: markedRanges
+ markedRanges: markedRanges,
+ attachments: attachments
)
needsLayout = false
}
@@ -77,11 +77,18 @@ public final class TextLine: Identifiable, Equatable {
public let maxWidth: CGFloat
public let lineHeightMultiplier: CGFloat
public let estimatedLineHeight: CGFloat
+ public let breakStrategy: LineBreakStrategy
- public init(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, estimatedLineHeight: CGFloat) {
+ public init(
+ maxWidth: CGFloat,
+ lineHeightMultiplier: CGFloat,
+ estimatedLineHeight: CGFloat,
+ breakStrategy: LineBreakStrategy = .character
+ ) {
self.maxWidth = maxWidth
self.lineHeightMultiplier = lineHeightMultiplier
self.estimatedLineHeight = estimatedLineHeight
+ self.breakStrategy = breakStrategy
}
}
}
diff --git a/Sources/CodeEditTextView/TextLine/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter.swift
deleted file mode 100644
index 0a0afdb44..000000000
--- a/Sources/CodeEditTextView/TextLine/Typesetter.swift
+++ /dev/null
@@ -1,246 +0,0 @@
-//
-// Typesetter.swift
-// CodeEditTextView
-//
-// Created by Khan Winter on 6/21/23.
-//
-
-import Foundation
-import CoreText
-
-final public class Typesetter {
- public var typesetter: CTTypesetter?
- public var string: NSAttributedString!
- public var documentRange: NSRange?
- public var lineFragments = TextLineStorage()
-
- // MARK: - Init & Prepare
-
- public init() { }
-
- public func typeset(
- _ string: NSAttributedString,
- documentRange: NSRange,
- displayData: TextLine.DisplayData,
- breakStrategy: LineBreakStrategy,
- markedRanges: MarkedRanges?
- ) {
- self.documentRange = documentRange
- lineFragments.removeAll()
- if let markedRanges {
- let mutableString = NSMutableAttributedString(attributedString: string)
- for markedRange in markedRanges.ranges {
- mutableString.addAttributes(markedRanges.attributes, range: markedRange)
- }
- self.string = mutableString
- } else {
- self.string = string
- }
- self.typesetter = CTTypesetterCreateWithAttributedString(self.string)
- generateLines(
- maxWidth: displayData.maxWidth,
- lineHeightMultiplier: displayData.lineHeightMultiplier,
- estimatedLineHeight: displayData.estimatedLineHeight,
- breakStrategy: breakStrategy
- )
- }
-
- // MARK: - Generate lines
-
- /// Generate line fragments.
- /// - Parameters:
- /// - maxWidth: The maximum width the line can be.
- /// - lineHeightMultiplier: The multiplier to apply to an empty line's height.
- /// - estimatedLineHeight: The estimated height of an empty line.
- private func generateLines(
- maxWidth: CGFloat,
- lineHeightMultiplier: CGFloat,
- estimatedLineHeight: CGFloat,
- breakStrategy: LineBreakStrategy
- ) {
- guard let typesetter else { return }
- var lines: [TextLineStorage.BuildItem] = []
- var height: CGFloat = 0
- if string.length == 0 {
- // Insert an empty fragment
- let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0))
- let fragment = LineFragment(
- documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0),
- ctLine: ctLine,
- width: 0,
- height: estimatedLineHeight/lineHeightMultiplier,
- descent: 0,
- lineHeightMultiplier: lineHeightMultiplier
- )
- lines = [.init(data: fragment, length: 0, height: fragment.scaledHeight)]
- } else {
- var startIndex = 0
- while startIndex < string.length {
- let lineBreak = suggestLineBreak(
- using: typesetter,
- strategy: breakStrategy,
- startingOffset: startIndex,
- constrainingWidth: maxWidth
- )
- let lineFragment = typesetLine(
- range: NSRange(start: startIndex, end: lineBreak),
- lineHeightMultiplier: lineHeightMultiplier
- )
- lines.append(.init(
- data: lineFragment,
- length: lineBreak - startIndex,
- height: lineFragment.scaledHeight
- ))
- startIndex = lineBreak
- height = lineFragment.scaledHeight
- }
- }
- // Use an efficient tree building algorithm rather than adding lines sequentially
- lineFragments.build(from: lines, estimatedLineHeight: height)
- }
-
- /// Typeset a new fragment.
- /// - Parameters:
- /// - range: The range of the fragment.
- /// - lineHeightMultiplier: The multiplier to apply to the line's height.
- /// - Returns: A new line fragment.
- private func typesetLine(range: NSRange, lineHeightMultiplier: CGFloat) -> LineFragment {
- let ctLine = CTTypesetterCreateLine(typesetter!, CFRangeMake(range.location, range.length))
- var ascent: CGFloat = 0
- var descent: CGFloat = 0
- var leading: CGFloat = 0
- let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading))
- let height = ascent + descent + leading
- let range = NSRange(location: (documentRange ?? .notFound).location + range.location, length: range.length)
- return LineFragment(
- documentRange: range,
- ctLine: ctLine,
- width: width,
- height: height,
- descent: descent,
- lineHeightMultiplier: lineHeightMultiplier
- )
- }
-
- // MARK: - Line Breaks
-
- /// Suggest a line break for the given line break strategy.
- /// - Parameters:
- /// - typesetter: The typesetter to use.
- /// - strategy: The strategy that determines a valid line break.
- /// - startingOffset: Where to start breaking.
- /// - constrainingWidth: The available space for the line.
- /// - Returns: An offset relative to the entire string indicating where to break.
- private func suggestLineBreak(
- using typesetter: CTTypesetter,
- strategy: LineBreakStrategy,
- startingOffset: Int,
- constrainingWidth: CGFloat
- ) -> Int {
- switch strategy {
- case .character:
- return suggestLineBreakForCharacter(
- using: typesetter,
- startingOffset: startingOffset,
- constrainingWidth: constrainingWidth
- )
- case .word:
- return suggestLineBreakForWord(
- using: typesetter,
- startingOffset: startingOffset,
- constrainingWidth: constrainingWidth
- )
- }
- }
-
- /// Suggest a line break for the character break strategy.
- /// - Parameters:
- /// - typesetter: The typesetter to use.
- /// - startingOffset: Where to start breaking.
- /// - constrainingWidth: The available space for the line.
- /// - Returns: An offset relative to the entire string indicating where to break.
- private func suggestLineBreakForCharacter(
- using typesetter: CTTypesetter,
- startingOffset: Int,
- constrainingWidth: CGFloat
- ) -> Int {
- var breakIndex: Int
- breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth)
- guard breakIndex < string.length else {
- return breakIndex
- }
- let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string
- if substring == LineEnding.carriageReturnLineFeed.rawValue {
- // Breaking in the middle of the clrf line ending
- return breakIndex + 1
- }
- return breakIndex
- }
-
- /// Suggest a line break for the word break strategy.
- /// - Parameters:
- /// - typesetter: The typesetter to use.
- /// - startingOffset: Where to start breaking.
- /// - constrainingWidth: The available space for the line.
- /// - Returns: An offset relative to the entire string indicating where to break.
- private func suggestLineBreakForWord(
- using typesetter: CTTypesetter,
- startingOffset: Int,
- constrainingWidth: CGFloat
- ) -> Int {
- var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth)
-
- let isBreakAtEndOfString = breakIndex >= string.length
-
- let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex)
- if isNextCharacterCarriageReturn {
- breakIndex += 1
- }
-
- let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1))
-
- if isBreakAtEndOfString || canLastCharacterBreak {
- // Breaking either at the end of the string, or on a whitespace.
- return breakIndex
- } else if breakIndex - 1 > 0 {
- // Try to walk backwards until we hit a whitespace or punctuation
- var index = breakIndex - 1
-
- while breakIndex - index < 100 && index > startingOffset {
- if ensureCharacterCanBreakLine(at: index) {
- return index + 1
- }
- index -= 1
- }
- }
-
- return breakIndex
- }
-
- /// Ensures the character at the given index can break a line.
- /// - Parameter index: The index to check at.
- /// - Returns: True, if the character is a whitespace or punctuation character.
- private func ensureCharacterCanBreakLine(at index: Int) -> Bool {
- let set = CharacterSet(
- charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string
- )
- return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters)
- }
-
- /// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position.
- /// - Parameter breakIndex: The index to check in the string.
- /// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence.
- private func checkIfLineBreakOnCRLF(_ breakIndex: Int) -> Bool {
- guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else {
- return false
- }
- let substringRange = NSRange(location: breakIndex - 1, length: 2)
- let substring = string.attributedSubstring(from: substringRange).string
-
- return substring == LineEnding.carriageReturnLineFeed.rawValue
- }
-
- deinit {
- lineFragments.removeAll()
- }
-}
diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift
new file mode 100644
index 000000000..7466a9e5d
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift
@@ -0,0 +1,16 @@
+//
+// CTLineTypesetData.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import AppKit
+
+/// Represents layout information received from a `CTTypesetter` for a `CTLine`.
+struct CTLineTypesetData {
+ let ctLine: CTLine
+ let descent: CGFloat
+ let width: CGFloat
+ let height: CGFloat
+}
diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift
new file mode 100644
index 000000000..f6bb487b2
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift
@@ -0,0 +1,24 @@
+//
+// LineFragmentTypesetContext.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import CoreGraphics
+
+/// Represents partial parsing state for typesetting a line fragment. Used once during typesetting and then discarded.
+struct LineFragmentTypesetContext {
+ var contents: [LineFragment.FragmentContent] = []
+ var start: Int
+ var width: CGFloat
+ var height: CGFloat
+ var descent: CGFloat
+
+ mutating func clear() {
+ contents.removeAll(keepingCapacity: true)
+ width = 0
+ height = 0
+ descent = 0
+ }
+}
diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift
new file mode 100644
index 000000000..9f0f713d9
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift
@@ -0,0 +1,82 @@
+//
+// TypesetContext.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 4/24/25.
+//
+
+import Foundation
+
+/// Represents partial parsing state for typesetting a line. Used once during typesetting and then discarded.
+/// Contains a few methods for appending data or popping the current line data.
+struct TypesetContext {
+ let documentRange: NSRange
+ let displayData: TextLine.DisplayData
+
+ /// Accumulated generated line fragments.
+ var lines: [TextLineStorage.BuildItem] = []
+ var maxHeight: CGFloat = 0
+ /// The current fragment typesetting context.
+ var fragmentContext = LineFragmentTypesetContext(start: 0, width: 0.0, height: 0.0, descent: 0.0)
+
+ /// Tracks the current position when laying out runs
+ var currentPosition: Int = 0
+
+ // MARK: - Fragment Context Modification
+
+ /// Appends an attachment to the current ``fragmentContext``
+ /// - Parameter attachment: The type-erased attachment to append.
+ mutating func appendAttachment(_ attachment: AnyTextAttachment) {
+ // Check if we can append this attachment to the current line
+ if fragmentContext.width + attachment.width > displayData.maxWidth {
+ popCurrentData()
+ }
+
+ // Add the attachment to the current line
+ fragmentContext.contents.append(
+ .init(data: .attachment(attachment: attachment), width: attachment.width)
+ )
+ fragmentContext.width += attachment.width
+ fragmentContext.height = fragmentContext.height == 0 ? maxHeight : fragmentContext.height
+ currentPosition += attachment.range.length
+ }
+
+ /// Appends a text range to the current ``fragmentContext``
+ /// - Parameters:
+ /// - typesettingRange: The range relative to the typesetter for the current fragment context.
+ /// - lineBreak: The position that the text fragment should end at, relative to the typesetter's range.
+ /// - typesetData: Data received from the typesetter.
+ mutating func appendText(typesettingRange: NSRange, lineBreak: Int, typesetData: CTLineTypesetData) {
+ fragmentContext.contents.append(
+ .init(data: .text(line: typesetData.ctLine), width: typesetData.width)
+ )
+ fragmentContext.width += typesetData.width
+ fragmentContext.height = typesetData.height
+ fragmentContext.descent = max(typesetData.descent, fragmentContext.descent)
+ currentPosition = lineBreak + typesettingRange.location
+ }
+
+ // MARK: - Pop Fragments
+
+ /// Pop the current fragment state into a new line fragment, and reset the fragment state.
+ mutating func popCurrentData() {
+ let fragment = LineFragment(
+ documentRange: NSRange(
+ location: fragmentContext.start + documentRange.location,
+ length: currentPosition - fragmentContext.start
+ ),
+ contents: fragmentContext.contents,
+ width: fragmentContext.width,
+ height: fragmentContext.height,
+ descent: fragmentContext.descent,
+ lineHeightMultiplier: displayData.lineHeightMultiplier
+ )
+ lines.append(
+ .init(data: fragment, length: currentPosition - fragmentContext.start, height: fragment.scaledHeight)
+ )
+ maxHeight = max(maxHeight, fragment.scaledHeight)
+
+ fragmentContext.clear()
+ fragmentContext.start = currentPosition
+ }
+}
diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift
new file mode 100644
index 000000000..10c6d244e
--- /dev/null
+++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift
@@ -0,0 +1,249 @@
+//
+// Typesetter.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import AppKit
+import CoreText
+
+/// The `Typesetter` is responsible for producing text fragments from a document range. It transforms a text line
+/// and attachments into a sequence of `LineFragment`s, which reflect the visual structure of the text line.
+///
+/// This class has one primary method: ``typeset(_:documentRange:displayData:markedRanges:attachments:)``, which
+/// performs the typesetting algorithm and breaks content into runs using attachments.
+///
+/// To retrieve the line fragments generated by this class, access the ``lineFragments`` property.
+final public class Typesetter {
+ struct ContentRun {
+ let range: NSRange
+ let type: RunType
+
+ enum RunType {
+ case attachment(AnyTextAttachment)
+ case string(CTTypesetter)
+ }
+ }
+
+ public var documentRange: NSRange?
+ public var lineFragments = TextLineStorage()
+
+ // MARK: - Init & Prepare
+
+ public init() { }
+
+ public func typeset(
+ _ string: NSAttributedString,
+ documentRange: NSRange,
+ displayData: TextLine.DisplayData,
+ markedRanges: MarkedRanges?,
+ attachments: [AnyTextAttachment] = []
+ ) {
+ let string = makeString(string: string, markedRanges: markedRanges)
+ lineFragments.removeAll()
+
+ // Fast path
+ if string.length == 0 || displayData.maxWidth <= 0 {
+ typesetEmptyLine(displayData: displayData, string: string)
+ return
+ }
+ let (lines, maxHeight) = typesetLineFragments(
+ string: string,
+ documentRange: documentRange,
+ displayData: displayData,
+ attachments: attachments
+ )
+ lineFragments.build(from: lines, estimatedLineHeight: maxHeight)
+ }
+
+ private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) -> NSAttributedString {
+ if let markedRanges {
+ let mutableString = NSMutableAttributedString(attributedString: string)
+ for markedRange in markedRanges.ranges {
+ mutableString.addAttributes(markedRanges.attributes, range: markedRange)
+ }
+ return mutableString
+ }
+
+ return string
+ }
+
+ // MARK: - Create Content Lines
+
+ /// Breaks up the string into a series of 'runs' making up the visual content of this text line.
+ /// - Parameters:
+ /// - string: The string reference to use.
+ /// - documentRange: The range in the string reference.
+ /// - attachments: Any text attachments overlapping the string reference.
+ /// - Returns: A series of content runs making up this line.
+ func createContentRuns(
+ string: NSAttributedString,
+ documentRange: NSRange,
+ attachments: [AnyTextAttachment]
+ ) -> [ContentRun] {
+ var attachments = attachments
+ var currentPosition = 0
+ let maxPosition = documentRange.length
+ var runs: [ContentRun] = []
+
+ while currentPosition < maxPosition {
+ guard let nextAttachment = attachments.first else {
+ // No attachments, use the remaining length
+ if maxPosition > currentPosition {
+ let range = NSRange(location: currentPosition, length: maxPosition - currentPosition)
+ let substring = string.attributedSubstring(from: range)
+ runs.append(
+ ContentRun(
+ range: range,
+ type: .string(CTTypesetterCreateWithAttributedString(substring))
+ )
+ )
+ }
+ break
+ }
+ attachments.removeFirst()
+ // adjust the range to be relative to the line
+ let attachmentRange = NSRange(
+ location: nextAttachment.range.location - documentRange.location,
+ length: nextAttachment.range.length
+ )
+
+ // Use the space before the attachment
+ if nextAttachment.range.location > currentPosition {
+ let range = NSRange(start: currentPosition, end: attachmentRange.location)
+ let substring = string.attributedSubstring(from: range)
+ runs.append(
+ ContentRun(range: range, type: .string(CTTypesetterCreateWithAttributedString(substring)))
+ )
+ }
+
+ runs.append(ContentRun(range: attachmentRange, type: .attachment(nextAttachment)))
+ currentPosition = attachmentRange.max
+ }
+
+ return runs
+ }
+
+ // MARK: - Typeset Content Runs
+
+ func typesetLineFragments(
+ string: NSAttributedString,
+ documentRange: NSRange,
+ displayData: TextLine.DisplayData,
+ attachments: [AnyTextAttachment]
+ ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) {
+ let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments)
+ var context = TypesetContext(documentRange: documentRange, displayData: displayData)
+
+ for run in contentRuns {
+ switch run.type {
+ case .attachment(let attachment):
+ context.appendAttachment(attachment)
+ case .string(let typesetter):
+ layoutTextUntilLineBreak(
+ context: &context,
+ string: string,
+ range: run.range,
+ typesetter: typesetter,
+ displayData: displayData
+ )
+ }
+ }
+
+ if !context.fragmentContext.contents.isEmpty {
+ context.popCurrentData()
+ }
+
+ return (context.lines, context.maxHeight)
+ }
+
+ // MARK: - Layout Text Fragments
+
+ func layoutTextUntilLineBreak(
+ context: inout TypesetContext,
+ string: NSAttributedString,
+ range: NSRange,
+ typesetter: CTTypesetter,
+ displayData: TextLine.DisplayData
+ ) {
+ let substring = string.attributedSubstring(from: range)
+
+ // Layout as many fragments as possible in this content run
+ while context.currentPosition < range.max {
+ // The line break indicates the distance from the range we’re typesetting on that should be broken at.
+ // It's relative to the range being typeset, not the line
+ let lineBreak = typesetter.suggestLineBreak(
+ using: substring,
+ strategy: displayData.breakStrategy,
+ subrange: NSRange(start: context.currentPosition - range.location, end: range.length),
+ constrainingWidth: displayData.maxWidth - context.fragmentContext.width
+ )
+
+ // Indicates the subrange on the range that the typesetter knows about. This may not be the entire line
+ let typesetSubrange = NSRange(location: context.currentPosition - range.location, length: lineBreak)
+ let typesetData = typesetLine(typesetter: typesetter, range: typesetSubrange)
+
+ // The typesetter won't tell us if 0 characters can fit in the constrained space. This checks to
+ // make sure we can fit something. If not, we pop and continue
+ if lineBreak == 1 && context.fragmentContext.width + typesetData.width > displayData.maxWidth {
+ context.popCurrentData()
+ continue
+ }
+
+ // Amend the current line data to include this line, popping the current line afterwards
+ context.appendText(typesettingRange: range, lineBreak: lineBreak, typesetData: typesetData)
+
+ // If this isn't the end of the line, we should break so we pop the context and start a new fragment.
+ if context.currentPosition != range.max {
+ context.popCurrentData()
+ }
+ }
+ }
+
+ // MARK: - Typeset CTLines
+
+ /// Typeset a new fragment.
+ /// - Parameters:
+ /// - range: The range of the fragment.
+ /// - lineHeightMultiplier: The multiplier to apply to the line's height.
+ /// - Returns: A new line fragment.
+ private func typesetLine(typesetter: CTTypesetter, range: NSRange) -> CTLineTypesetData {
+ let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(range.location, range.length))
+ var ascent: CGFloat = 0
+ var descent: CGFloat = 0
+ var leading: CGFloat = 0
+ let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading))
+ let height = ascent + descent + leading
+ return CTLineTypesetData(
+ ctLine: ctLine,
+ descent: descent,
+ width: width,
+ height: height
+ )
+ }
+
+ /// Typesets a single, 0-length line fragment.
+ /// - Parameter displayData: Relevant information for layout estimation.
+ private func typesetEmptyLine(displayData: TextLine.DisplayData, string: NSAttributedString) {
+ let typesetter = CTTypesetterCreateWithAttributedString(string)
+ // Insert an empty fragment
+ let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0))
+ let fragment = LineFragment(
+ documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0),
+ contents: [.init(data: .text(line: ctLine), width: 0.0)],
+ width: 0,
+ height: displayData.estimatedLineHeight / displayData.lineHeightMultiplier,
+ descent: 0,
+ lineHeightMultiplier: displayData.lineHeightMultiplier
+ )
+ lineFragments.build(
+ from: [.init(data: fragment, length: 0, height: fragment.scaledHeight)],
+ estimatedLineHeight: 0
+ )
+ }
+
+ deinit {
+ lineFragments.removeAll()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift
index d94563a7b..eb7e8a349 100644
--- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift
+++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift
@@ -25,36 +25,46 @@ public extension TextSelectionManager {
decomposeCharacters: Bool = false,
suggestedXPos: CGFloat? = nil
) -> NSRange {
+ var range: NSRange
switch direction {
case .backward:
guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0
- return extendSelectionHorizontal(
+ range = extendSelectionHorizontal(
from: offset,
destination: destination,
delta: -1,
decomposeCharacters: decomposeCharacters
)
case .forward:
- return extendSelectionHorizontal(
+ range = extendSelectionHorizontal(
from: offset,
destination: destination,
delta: 1,
decomposeCharacters: decomposeCharacters
)
case .up:
- return extendSelectionVertical(
+ range = extendSelectionVertical(
from: offset,
destination: destination,
up: true,
suggestedXPos: suggestedXPos
)
case .down:
- return extendSelectionVertical(
+ range = extendSelectionVertical(
from: offset,
destination: destination,
up: false,
suggestedXPos: suggestedXPos
)
}
+
+ // Extend ranges to include attachments.
+ if let attachments = layoutManager?.attachments.getAttachmentsOverlapping(range) {
+ attachments.forEach { textAttachment in
+ range.formUnion(textAttachment.range)
+ }
+ }
+
+ return range
}
}
diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift
index 45907d6c1..60b0c7e60 100644
--- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift
+++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift
@@ -13,7 +13,7 @@ extension TextSelectionManager {
public func drawSelections(in rect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
- var highlightedLines: Set = []
+ var highlightedLines: Set = []
// For each selection in the rect
for textSelection in textSelections {
if textSelection.range.isEmpty {
@@ -41,7 +41,7 @@ extension TextSelectionManager {
in rect: NSRect,
for textSelection: TextSelection,
context: CGContext,
- highlightedLines: inout Set
+ highlightedLines: inout Set
) {
guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location),
!highlightedLines.contains(linePosition.data.id) else {
diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift
index 666f9b711..da5165f32 100644
--- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift
+++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift
@@ -37,7 +37,7 @@ extension TextSelectionManager {
height: rect.height
).intersection(rect)
- for linePosition in layoutManager.lineStorage.linesInRange(range) {
+ for linePosition in layoutManager.linesInRange(range) {
fillRects.append(
contentsOf: getFillRects(in: validTextDrawingRect, selectionRange: range, forPosition: linePosition)
)
diff --git a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift
index 9befba72a..812919d0c 100644
--- a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift
+++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift
@@ -14,7 +14,9 @@ extension TextView {
}
override public func viewWillMove(toSuperview newSuperview: NSView?) {
- guard let scrollView = enclosingScrollView else {
+ super.viewWillMove(toSuperview: newSuperview)
+ guard let clipView = newSuperview as? NSClipView,
+ let scrollView = enclosingScrollView ?? clipView.enclosingScrollView else {
return
}
diff --git a/Sources/CodeEditTextView/TextView/TextView+Setup.swift b/Sources/CodeEditTextView/TextView/TextView+Setup.swift
index c894cf04e..a17d39026 100644
--- a/Sources/CodeEditTextView/TextView/TextView+Setup.swift
+++ b/Sources/CodeEditTextView/TextView/TextView+Setup.swift
@@ -28,9 +28,6 @@ extension TextView {
}
func setUpScrollListeners(scrollView: NSScrollView) {
- NotificationCenter.default.removeObserver(self, name: NSScrollView.willStartLiveScrollNotification, object: nil)
- NotificationCenter.default.removeObserver(self, name: NSScrollView.didEndLiveScrollNotification, object: nil)
-
NotificationCenter.default.addObserver(
self,
selector: #selector(scrollViewWillStartScroll),
@@ -44,6 +41,22 @@ extension TextView {
name: NSScrollView.didEndLiveScrollNotification,
object: scrollView
)
+
+ NotificationCenter.default.addObserver(
+ forName: NSView.boundsDidChangeNotification,
+ object: scrollView.contentView,
+ queue: .main
+ ) { [weak self] _ in
+ self?.updatedViewport(self?.visibleRect ?? .zero)
+ }
+
+ NotificationCenter.default.addObserver(
+ forName: NSView.frameDidChangeNotification,
+ object: scrollView.contentView,
+ queue: .main
+ ) { [weak self] _ in
+ self?.updatedViewport(self?.visibleRect ?? .zero)
+ }
}
@objc func scrollViewWillStartScroll() {
diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift
similarity index 91%
rename from Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift
rename to Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift
index 85a8eb68e..07f222fb6 100644
--- a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift
+++ b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift
@@ -8,8 +8,7 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate {
_ displayData: TextLine.DisplayData,
_ range: NSRange,
_ stringRef: NSTextStorage,
- _ markedRanges: MarkedRanges?,
- _ breakStrategy: LineBreakStrategy
+ _ markedRanges: MarkedRanges?
) -> Void)?
var estimatedLineHeightOverride: (() -> CGFloat)?
@@ -20,21 +19,20 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate {
range: NSRange,
stringRef: NSTextStorage,
markedRanges: MarkedRanges?,
- breakStrategy: LineBreakStrategy
+ attachments: [AnyTextAttachment]
) {
prepareForDisplay?(
textLine,
displayData,
range,
stringRef,
- markedRanges,
- breakStrategy
+ markedRanges
) ?? textLine.prepareForDisplay(
displayData: displayData,
range: range,
stringRef: stringRef,
markedRanges: markedRanges,
- breakStrategy: breakStrategy
+ attachments: []
)
}
@@ -62,13 +60,13 @@ struct OverridingLayoutManagerRenderingTests {
@Test
func overriddenLineHeight() {
- mockDelegate.prepareForDisplay = { textLine, displayData, range, stringRef, markedRanges, breakStrategy in
+ mockDelegate.prepareForDisplay = { textLine, displayData, range, stringRef, markedRanges in
textLine.prepareForDisplay(
displayData: displayData,
range: range,
stringRef: stringRef,
markedRanges: markedRanges,
- breakStrategy: breakStrategy
+ attachments: []
)
// Update all text fragments to be height = 2.0
textLine.lineFragments.forEach { fragmentPosition in
diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift
new file mode 100644
index 000000000..a3510c608
--- /dev/null
+++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift
@@ -0,0 +1,111 @@
+//
+// TextLayoutManagerAttachmentsTests.swift
+// CodeEditTextView
+//
+// Created by Khan Winter on 5/5/25.
+//
+
+import Testing
+import AppKit
+@testable import CodeEditTextView
+
+@Suite
+@MainActor
+struct TextLayoutManagerAttachmentsTests {
+ let textView: TextView
+ let textStorage: NSTextStorage
+ let layoutManager: TextLayoutManager
+
+ init() throws {
+ textView = TextView(string: "12\n45\n78\n01\n")
+ textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
+ textStorage = textView.textStorage
+ layoutManager = try #require(textView.layoutManager)
+ }
+
+ @Test
+ func addAndGetAttachments() throws {
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8))
+ #expect(layoutManager.attachments.getAttachmentsOverlapping(textView.documentRange).count == 1)
+ #expect(layoutManager.attachments.getAttachmentsOverlapping(NSRange(start: 0, end: 3)).count == 1)
+ #expect(layoutManager.attachments.getAttachmentsStartingIn(NSRange(start: 0, end: 3)).count == 1)
+ }
+
+ // MARK: - Determine Visible Line Tests
+
+ @Test
+ func determineVisibleLinesMovesForwards() throws {
+ // From middle of the first line, to middle of the third line
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8))
+
+ // Start with the first line, should extend to the third line
+ let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 0)) // zero-indexed
+ let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition))
+
+ #expect(newPosition.indexRange == 0...2)
+ #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three
+ }
+
+ @Test
+ func determineVisibleLinesMovesBackwards() throws {
+ // From middle of the first line, to middle of the third line
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8))
+
+ // Start with the third line, should extend back to the first line
+ let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed
+ let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition))
+
+ #expect(newPosition.indexRange == 0...2)
+ #expect(newPosition.position.range == NSRange(start: 0, end: 9)) // Lines one -> three
+ }
+
+ @Test
+ func determineVisibleLinesMergesMultipleAttachments() throws {
+ // Two attachments, meeting at the third line. `determineVisiblePosition` should merge all four lines.
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7))
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 7, end: 11))
+
+ let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed
+ let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition))
+
+ #expect(newPosition.indexRange == 0...3)
+ #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four
+ }
+
+ @Test
+ func determineVisibleLinesMergesOverlappingAttachments() throws {
+ // Two attachments, overlapping at the third line. `determineVisiblePosition` should merge all four lines.
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7))
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 5, end: 11))
+
+ let originalPosition = try #require(layoutManager.lineStorage.getLine(atIndex: 2)) // zero-indexed
+ let newPosition = try #require(layoutManager.determineVisiblePosition(for: originalPosition))
+
+ #expect(newPosition.indexRange == 0...3)
+ #expect(newPosition.position.range == NSRange(start: 0, end: 12)) // Lines one -> four
+ }
+
+ // MARK: - Iterator Tests
+
+ @Test
+ func iterateWithAttachments() {
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 1, end: 2))
+
+ let lines = layoutManager.linesStartingAt(0, until: 1000)
+
+ // Line "5" is from the trailing newline. That shows up as an empty line in the view.
+ #expect(lines.map { $0.index } == [0, 1, 2, 3, 4])
+ }
+
+ @Test
+ func iterateWithMultilineAttachments() {
+ // Two attachments, meeting at the third line.
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 7))
+ layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 7, end: 11))
+
+ let lines = layoutManager.linesStartingAt(0, until: 1000)
+
+ // Line "5" is from the trailing newline. That shows up as an empty line in the view.
+ #expect(lines.map { $0.index } == [0, 4])
+ }
+}
diff --git a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift
similarity index 62%
rename from Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift
rename to Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift
index 1dcc9a7dd..dc34b6e25 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift
+++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift
@@ -43,6 +43,7 @@ struct TextLayoutManagerTests {
init() throws {
textView = TextView(string: "A\nB\nC\nD")
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
+ textView.updateFrameIfNeeded()
textStorage = textView.textStorage
layoutManager = try #require(textView.layoutManager)
}
@@ -101,7 +102,6 @@ struct TextLayoutManagerTests {
layoutManager.lineStorage.validateInternalState()
}
- /// # 04/09/25
/// This ensures that getting line rect info does not invalidate layout. The issue was previously caused by a
/// call to ``TextLayoutManager/preparePositionForDisplay``.
@Test
@@ -130,4 +130,79 @@ struct TextLayoutManagerTests {
#expect(lineFragmentIDs == afterLineFragmentIDs, "Line fragments were invalidated by `rectsFor(range:)` call.")
layoutManager.lineStorage.validateInternalState()
}
+
+ /// It's easy to iterate through lines by taking the last line's range, and adding one to the end of the range.
+ /// However, that will always skip lines that are empty, but represent a line. This test ensures that when we
+ /// iterate over a range, we'll always find those empty lines.
+ ///
+ /// Related implementation: ``TextLayoutManager/Iterator``
+ @Test
+ func yPositionIteratorDoesNotSkipEmptyLines() {
+ // Layout manager keeps 1-length lines at the 2nd and 4th lines.
+ textStorage.mutableString.setString("A\n\nB\n\nC")
+ layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
+
+ var lineIndexes: [Int] = []
+ for line in layoutManager.linesStartingAt(0.0, until: 1000.0) {
+ lineIndexes.append(line.index)
+ }
+
+ var lastLineIndex: Int?
+ for lineIndex in lineIndexes {
+ if let lastIndex = lastLineIndex {
+ #expect(lineIndex - 1 == lastIndex, "Skipped an index when iterating.")
+ } else {
+ #expect(lineIndex == 0, "First index was not 0")
+ }
+ lastLineIndex = lineIndex
+ }
+ }
+
+ /// See comment for `yPositionIteratorDoesNotSkipEmptyLines`.
+ @Test
+ func rangeIteratorDoesNotSkipEmptyLines() {
+ // Layout manager keeps 1-length lines at the 2nd and 4th lines.
+ textStorage.mutableString.setString("A\n\nB\n\nC")
+ layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
+
+ var lineIndexes: [Int] = []
+ for line in layoutManager.linesInRange(textView.documentRange) {
+ lineIndexes.append(line.index)
+ }
+
+ var lastLineIndex: Int?
+ for lineIndex in lineIndexes {
+ if let lastIndex = lastLineIndex {
+ #expect(lineIndex - 1 == lastIndex, "Skipped an index when iterating.")
+ } else {
+ #expect(lineIndex == 0, "First index was not 0")
+ }
+ lastLineIndex = lineIndex
+ }
+ }
+
+ @Test
+ func afterLayoutDoesntNeedLayout() {
+ layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
+ #expect(layoutManager.needsLayout == false)
+ }
+
+ @Test
+ func invalidatingRangeLaysOutLines() {
+ layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
+
+ let lineIds = Set(layoutManager.linesInRange(NSRange(start: 2, end: 4)).map { $0.data.id })
+ layoutManager.invalidateLayoutForRange(NSRange(start: 2, end: 4))
+
+ #expect(layoutManager.needsLayout == false) // No forced layout
+ #expect(
+ layoutManager
+ .linesInRange(NSRange(start: 2, end: 4))
+ .allSatisfy({ $0.data.needsLayout(maxWidth: .infinity) })
+ )
+
+ let invalidatedLineIds = layoutManager.layoutLines()
+
+ #expect(invalidatedLineIds == lineIds, "Invalidated lines != lines that were laid out in next pass.")
+ }
}
diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift
index 3b62e8fbe..e065cb69c 100644
--- a/Tests/CodeEditTextViewTests/TypesetterTests.swift
+++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift
@@ -1,19 +1,41 @@
import XCTest
@testable import CodeEditTextView
-// swiftlint:disable all
+final class DemoTextAttachment: TextAttachment {
+ var width: CGFloat
+
+ init(width: CGFloat = 100) {
+ self.width = width
+ }
+
+ func draw(in context: CGContext, rect: NSRect) {
+ context.saveGState()
+ context.setFillColor(NSColor.red.cgColor)
+ context.fill(rect)
+ context.restoreGState()
+ }
+}
class TypesetterTests: XCTestCase {
- let limitedLineWidthDisplayData = TextLine.DisplayData(maxWidth: 150, lineHeightMultiplier: 1.0, estimatedLineHeight: 20.0)
- let unlimitedLineWidthDisplayData = TextLine.DisplayData(maxWidth: .infinity, lineHeightMultiplier: 1.0, estimatedLineHeight: 20.0)
+ // NOTE: makes chars that are ~6.18 pts wide
+ let attributes: [NSAttributedString.Key: Any] = [.font: NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)]
+ var typesetter: Typesetter!
+
+ override func setUp() {
+ typesetter = Typesetter()
+ continueAfterFailure = false
+ }
func test_LineFeedBreak() {
- let typesetter = Typesetter()
typesetter.typeset(
NSAttributedString(string: "testline\n"),
documentRange: NSRange(location: 0, length: 9),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .word,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .word
+ ),
markedRanges: nil
)
@@ -22,8 +44,12 @@ class TypesetterTests: XCTestCase {
typesetter.typeset(
NSAttributedString(string: "testline\n"),
documentRange: NSRange(location: 0, length: 9),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .character,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
markedRanges: nil
)
@@ -31,12 +57,15 @@ class TypesetterTests: XCTestCase {
}
func test_carriageReturnBreak() {
- let typesetter = Typesetter()
typesetter.typeset(
NSAttributedString(string: "testline\r"),
documentRange: NSRange(location: 0, length: 9),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .word,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .word
+ ),
markedRanges: nil
)
@@ -45,8 +74,12 @@ class TypesetterTests: XCTestCase {
typesetter.typeset(
NSAttributedString(string: "testline\r"),
documentRange: NSRange(location: 0, length: 9),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .character,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
markedRanges: nil
)
@@ -54,12 +87,15 @@ class TypesetterTests: XCTestCase {
}
func test_carriageReturnLineFeedBreak() {
- let typesetter = Typesetter()
typesetter.typeset(
NSAttributedString(string: "testline\r\n"),
documentRange: NSRange(location: 0, length: 10),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .word,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .word
+ ),
markedRanges: nil
)
@@ -68,13 +104,186 @@ class TypesetterTests: XCTestCase {
typesetter.typeset(
NSAttributedString(string: "testline\r\n"),
documentRange: NSRange(location: 0, length: 10),
- displayData: unlimitedLineWidthDisplayData,
- breakStrategy: .character,
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
markedRanges: nil
)
XCTAssertEqual(typesetter.lineFragments.count, 1, "Typesetter typeset incorrect number of lines.")
}
-}
-// swiftlint:enable all
+ func test_wrapLinesReturnsValidFragmentRanges() throws {
+ // Ensure that when wrapping, each wrapped line fragment has correct ranges.
+ typesetter.typeset(
+ NSAttributedString(string: String(repeating: "A", count: 1000), attributes: attributes),
+ documentRange: NSRange(location: 0, length: 1000),
+ displayData: TextLine.DisplayData(
+ maxWidth: 150,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
+ markedRanges: nil,
+ attachments: []
+ )
+
+ let firstFragment = try XCTUnwrap(typesetter.lineFragments.first)
+
+ for fragment in typesetter.lineFragments {
+ // The end of the fragment shouldn't extend beyond the valid document range
+ XCTAssertLessThanOrEqual(fragment.range.max, 1000)
+ // Because we're breaking on characters, and filling each line with the same char
+ // Each fragment should be as long or shorter than the first fragment.
+ XCTAssertLessThanOrEqual(fragment.range.length, firstFragment.range.length)
+ }
+ }
+
+ // MARK: - Attachments
+
+ func test_layoutSingleFragmentWithAttachment() throws {
+ let attachment = DemoTextAttachment()
+ typesetter.typeset(
+ NSAttributedString(string: "ABC"),
+ documentRange: NSRange(location: 0, length: 3),
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
+ markedRanges: nil,
+ attachments: [AnyTextAttachment(range: NSRange(location: 1, length: 1), attachment: attachment)]
+ )
+
+ XCTAssertEqual(typesetter.lineFragments.count, 1)
+ let fragment = try XCTUnwrap(typesetter.lineFragments.first?.data)
+ XCTAssertEqual(fragment.contents.count, 3)
+ XCTAssertTrue(fragment.contents[0].isText)
+ XCTAssertFalse(fragment.contents[1].isText)
+ XCTAssertTrue(fragment.contents[2].isText)
+ XCTAssertEqual(
+ fragment.contents[1],
+ .init(
+ data: .attachment(attachment: .init(range: NSRange(location: 1, length: 1), attachment: attachment)),
+ width: attachment.width
+ )
+ )
+ }
+
+ func test_layoutSingleFragmentEntirelyAttachment() throws {
+ let attachment = DemoTextAttachment()
+ typesetter.typeset(
+ NSAttributedString(string: "ABC"),
+ documentRange: NSRange(location: 0, length: 3),
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
+ markedRanges: nil,
+ attachments: [AnyTextAttachment(range: NSRange(location: 0, length: 3), attachment: attachment)]
+ )
+
+ XCTAssertEqual(typesetter.lineFragments.count, 1)
+ let fragment = try XCTUnwrap(typesetter.lineFragments.first?.data)
+ XCTAssertEqual(fragment.contents.count, 1)
+ XCTAssertFalse(fragment.contents[0].isText)
+ XCTAssertEqual(
+ fragment.contents[0],
+ .init(
+ data: .attachment(attachment: .init(range: NSRange(location: 0, length: 3), attachment: attachment)),
+ width: attachment.width
+ )
+ )
+ }
+
+ func test_wrapLinesWithAttachment() throws {
+ let attachment = DemoTextAttachment(width: 130)
+
+ // Total should be slightly > 160px, breaking off 2 and 3
+ typesetter.typeset(
+ NSAttributedString(string: "ABC123", attributes: attributes),
+ documentRange: NSRange(location: 0, length: 6),
+ displayData: TextLine.DisplayData(
+ maxWidth: 150,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
+ markedRanges: nil,
+ attachments: [.init(range: NSRange(location: 1, length: 1), attachment: attachment)]
+ )
+
+ XCTAssertEqual(typesetter.lineFragments.count, 2)
+
+ var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data)
+ XCTAssertEqual(fragment.contents.count, 3) // First fragment includes the attachment and characters after
+ XCTAssertTrue(fragment.contents[0].isText)
+ XCTAssertFalse(fragment.contents[1].isText)
+ XCTAssertTrue(fragment.contents[2].isText)
+
+ fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data)
+ XCTAssertEqual(fragment.contents.count, 1) // Second fragment is only text
+ XCTAssertTrue(fragment.contents[0].isText)
+ }
+
+ func test_wrapLinesWithWideAttachment() throws {
+ // Attachment takes up more than the available room.
+ // Expected result: attachment is on it's own line fragment with no other text.
+ let attachment = DemoTextAttachment(width: 150)
+
+ typesetter.typeset(
+ NSAttributedString(string: "ABC123", attributes: attributes),
+ documentRange: NSRange(location: 0, length: 6),
+ displayData: TextLine.DisplayData(
+ maxWidth: 150,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .character
+ ),
+ markedRanges: nil,
+ attachments: [.init(range: NSRange(location: 1, length: 1), attachment: attachment)]
+ )
+
+ XCTAssertEqual(typesetter.lineFragments.count, 3)
+
+ var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data)
+ XCTAssertEqual(fragment.documentRange, NSRange(location: 0, length: 1))
+ XCTAssertEqual(fragment.contents.count, 1)
+ XCTAssertTrue(fragment.contents[0].isText)
+
+ fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data)
+ XCTAssertEqual(fragment.documentRange, NSRange(location: 1, length: 1))
+ XCTAssertEqual(fragment.contents.count, 1)
+ XCTAssertFalse(fragment.contents[0].isText)
+
+ fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 2)?.data)
+ XCTAssertEqual(fragment.documentRange, NSRange(location: 2, length: 4))
+ XCTAssertEqual(fragment.contents.count, 1)
+ XCTAssertTrue(fragment.contents[0].isText)
+ }
+
+ func test_wrapLinesDoesNotBreakOnLastNewline() throws {
+ let attachment = DemoTextAttachment(width: 50)
+ let string = NSAttributedString(string: "AB CD\n12 34\nWX YZ\n", attributes: attributes)
+ typesetter.typeset(
+ string,
+ documentRange: NSRange(location: 0, length: 15),
+ displayData: TextLine.DisplayData(
+ maxWidth: .infinity,
+ lineHeightMultiplier: 1.0,
+ estimatedLineHeight: 20.0,
+ breakStrategy: .word
+ ),
+ markedRanges: nil,
+ attachments: [.init(range: NSRange(start: 4, end: 15), attachment: attachment)]
+ )
+
+ XCTAssertEqual(typesetter.lineFragments.count, 1)
+ }
+}