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) + } +}