From 5807b02d100e2bb89af4e82042f828bd2e98e4fc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Apr 2025 10:13:08 -0500 Subject: [PATCH 01/34] Initial Work --- .../CodeEditTextViewExampleDocument.swift | 27 +- .../Views/ContentView.swift | 12 +- .../Views/SwiftUITextView.swift | 41 +-- .../CTTypesetter+SuggestLineBreak.swift | 130 +++++++++ .../TextAttachmentManager.swift | 31 +++ .../TextLayoutManager+Public.swift | 40 +-- .../TextLine/LineFragment.swift | 128 +++++++-- .../TextLine/TextAttachment.swift | 26 ++ .../TextLine/Typesetter.swift | 246 ------------------ .../Typesetter/CTLineTypesetData.swift | 15 ++ .../LineFragmentTypesetContext.swift | 23 ++ .../TextLine/Typesetter/TypesetContext.swift | 64 +++++ .../TextLine/Typesetter/Typesetter.swift | 235 +++++++++++++++++ .../TypesetterTests.swift | 151 ++++++++++- 14 files changed, 824 insertions(+), 345 deletions(-) create mode 100644 Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift create mode 100644 Sources/CodeEditTextView/TextLine/TextAttachment.swift delete mode 100644 Sources/CodeEditTextView/TextLine/Typesetter.swift create mode 100644 Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift create mode 100644 Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift create mode 100644 Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift create mode 100644 Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift index c427db932..5f205abd3 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,28 @@ 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 + ) + print(String(decoding: data, as: UTF8.self), text.string) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let data = Data(text.utf8) + let data = try text.data(for: NSRange(location: 0, length: text.length)) return .init(regularFileWithContents: data) } } + +extension NSAttributedString { + func data(for range: NSRange) throws -> Data { + try data( + from: range, + documentAttributes: [ + .documentType: NSAttributedString.DocumentType.plain, + .characterEncoding: NSUTF8StringEncoding + ] + ) + } +} diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index c6b0f4f0f..e5f5ac256 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -17,9 +17,19 @@ struct ContentView: View { HStack { Toggle("Wrap Lines", isOn: $wrapLines) Toggle("Inset Edges", isOn: $enableEdgeInsets) + Button { + + } label: { + Text("Insert Attachment") + } + } 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..3845018f9 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift @@ -0,0 +1,130 @@ +// +// File.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, + startingOffset: Int, + constrainingWidth: CGFloat + ) -> Int { + switch strategy { + case .character: + return suggestLineBreakForCharacter( + string: string, + startingOffset: startingOffset, + constrainingWidth: constrainingWidth + ) + case .word: + return suggestLineBreakForWord( + string: string, + 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( + 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, + startingOffset: Int, + constrainingWidth: CGFloat + ) -> Int { + var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth) + + let isBreakAtEndOfString = breakIndex >= string.length + + 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 > startingOffset { + 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 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, 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/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift new file mode 100644 index 000000000..c404275e3 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift @@ -0,0 +1,31 @@ +// +// 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: [TextAttachmentBox] = [] + + public func addAttachment(_ attachment: any TextAttachment, for range: NSRange) { + let box = TextAttachmentBox(range: range, attachment: attachment) + + // Insert new box into the ordered list. + + } + + /// Finds attachments for the given line range, and returns them as an array. + /// Returned attachment's ranges will be relative to the _document_, not the line. + public func attachments(forLineRange range: NSRange) -> [TextAttachmentBox] { + // Use binary search to find start/end index + + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index c79b8b5f5..f149afd1d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -69,40 +69,42 @@ 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 = lineStorage.getLine(atPosition: point.y), + 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 + return linePosition.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 + let fragmentRange = fragment.documentRange + let endPosition = linePosition.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) ?? "") { + if endPosition == linePosition.range.max, + let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") { return endPosition - lineEnding.length } else { return endPosition } - } else { - // Somewhere in the fragment - let fragmentIndex = CTLineGetStringIndexForPosition( - fragment.ctLine, - CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2) - ) - return position.range.location + fragmentIndex + } else if let (content, contentPosition) = fragment.findContent(atX: point.x) { + switch content.data { + case .text(let ctLine): + let fragmentIndex = CTLineGetStringIndexForPosition( + ctLine, + CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) + ) + return fragmentIndex + contentPosition.offset + linePosition.range.location + case .attachment: + return contentPosition.offset + linePosition.range.location + } } + + return nil } // MARK: - Rect For Offset diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 923848ab1..dece0a60f 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: TextAttachmentBox) + } + + let data: Content + let width: CGFloat + + 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,15 @@ 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, offset - position.offset, nil) + position.xPos + case .attachment: + return position.xPos + } } public func draw(in context: CGContext, yPos: CGFloat) { @@ -82,27 +119,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/TextAttachment.swift b/Sources/CodeEditTextView/TextLine/TextAttachment.swift new file mode 100644 index 000000000..7cd924168 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/TextAttachment.swift @@ -0,0 +1,26 @@ +// +// TextAttachment.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import AppKit + +public struct TextAttachmentBox: Equatable { + let range: NSRange + let attachment: any TextAttachment + + var width: CGFloat { + attachment.width + } + + public static func ==(_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> Bool { + lhs.range == rhs.range && lhs.attachment === rhs.attachment + } +} + +public protocol TextAttachment: AnyObject { + var width: CGFloat { get } + func draw(in context: CGContext, rect: NSRect) +} 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..5be2eae53 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift @@ -0,0 +1,15 @@ +// +// CTLineTypesetData.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import AppKit + +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..1260a7f0f --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift @@ -0,0 +1,23 @@ +// +// LineFragmentTypesetContext.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import CoreGraphics + +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..5fd5628a9 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -0,0 +1,64 @@ +// +// TypesetContext.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/24/25. +// + +import Foundation + +struct TypesetContext { + let documentRange: NSRange + let displayData: TextLine.DisplayData + + var lines: [TextLineStorage.BuildItem] = [] + var maxHeight: CGFloat = 0 + var fragmentContext: LineFragmentTypesetContext = .init(start: 0, width: 0.0, height: 0.0, descent: 0.0) + var currentPosition: Int = 0 + + mutating func appendAttachment(_ attachment: TextAttachmentBox) { + // 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 + } + + mutating func appendText(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 + } + + 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..5a6ca4b88 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -0,0 +1,235 @@ +// +// Typesetter.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/21/23. +// + +import AppKit +import CoreText + +final public class Typesetter { + struct ContentRun { + let range: NSRange + let type: RunType + + enum RunType { + case attachment(TextAttachmentBox) + case string(CTTypesetter) + } + } + + public var string: NSAttributedString = NSAttributedString(string: "") + 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?, + attachments: [TextAttachmentBox] = [] + ) { + makeString(string: string, markedRanges: markedRanges) + lineFragments.removeAll() + + // Fast path + if string.length == 0 { + typesetEmptyLine(displayData: displayData) + return + } + let (lines, maxHeight) = typesetLineFragments( + documentRange: documentRange, + displayData: displayData, + breakStrategy: breakStrategy, + attachments: attachments + ) + lineFragments.build(from: lines, estimatedLineHeight: maxHeight) + } + + private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) { + 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 + } + } + + // MARK: - Create Content Lines + + /// Breaks up the string into a series of 'runs' making up the visual content of this text line. + /// - Parameters: + /// - 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(documentRange: NSRange, attachments: [TextAttachmentBox]) -> [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( + documentRange: NSRange, + displayData: TextLine.DisplayData, + breakStrategy: LineBreakStrategy, + attachments: [TextAttachmentBox] + ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { + let contentRuns = createContentRuns(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, + range: run.range, + typesetter: typesetter, + displayData: displayData, + breakStrategy: breakStrategy + ) + } + } + + if !context.fragmentContext.contents.isEmpty { + context.popCurrentData() + } + + return (context.lines, context.maxHeight) + } + + // MARK: - Layout Text Fragments + + func layoutTextUntilLineBreak( + context: inout TypesetContext, + range: NSRange, + typesetter: CTTypesetter, + displayData: TextLine.DisplayData, + breakStrategy: LineBreakStrategy + ) { + // Layout as many fragments as possible in this content run + while context.currentPosition < range.max { + let lineBreak = typesetter.suggestLineBreak( + using: string, + strategy: breakStrategy, + startingOffset: context.currentPosition - range.location, + constrainingWidth: displayData.maxWidth - context.fragmentContext.width + ) + + let typesetData = typesetLine( + typesetter: typesetter, + range: NSRange(location: context.currentPosition - range.location, length: lineBreak) + ) + + // 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(lineBreak: lineBreak, typesetData: typesetData) + + if lineBreak != range.length { + 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) { + let typesetter = CTTypesetterCreateWithAttributedString(self.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: displayData.estimatedLineHeight + ) + } + + deinit { + lineFragments.removeAll() + } +} diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index 3b62e8fbe..ba2b2a277 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -1,14 +1,43 @@ 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)] + let limitedLineWidthDisplayData = TextLine.DisplayData( + maxWidth: 150, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0 + ) + let unlimitedLineWidthDisplayData = TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0 + ) + + 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), @@ -31,7 +60,6 @@ class TypesetterTests: XCTestCase { } func test_carriageReturnBreak() { - let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r"), documentRange: NSRange(location: 0, length: 9), @@ -54,7 +82,6 @@ class TypesetterTests: XCTestCase { } func test_carriageReturnLineFeedBreak() { - let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r\n"), documentRange: NSRange(location: 0, length: 10), @@ -75,6 +102,114 @@ class TypesetterTests: XCTestCase { XCTAssertEqual(typesetter.lineFragments.count, 1, "Typesetter typeset incorrect number of lines.") } -} -// swiftlint:enable all + // MARK: - Attachments + + func test_layoutSingleFragmentWithAttachment() throws { + let attachment = DemoTextAttachment() + typesetter.typeset( + NSAttributedString(string: "ABC"), + documentRange: NSRange(location: 0, length: 3), + displayData: unlimitedLineWidthDisplayData, + breakStrategy: .character, + markedRanges: nil, + attachments: [TextAttachmentBox(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: unlimitedLineWidthDisplayData, + breakStrategy: .character, + markedRanges: nil, + attachments: [TextAttachmentBox(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: limitedLineWidthDisplayData, // 150 px + 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: limitedLineWidthDisplayData, // 150 px + 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) + } +} From cd1cfd47037415a43eff1a5fb2dc90cfb362a025 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Apr 2025 13:02:14 -0500 Subject: [PATCH 02/34] Introduce Layout Manager API --- .../CodeEditTextViewExample.xcscheme | 78 ++++++++++++++++++ .../CodeEditTextViewExampleDocument.swift | 1 - .../CTTypesetter+SuggestLineBreak.swift | 3 +- .../TextAttachmentManager.swift | 31 ------- .../TextAttachments}/TextAttachment.swift | 0 .../TextAttachmentManager.swift | 80 +++++++++++++++++++ .../TextLayoutManager+Layout.swift | 6 +- .../TextLayoutManager+Public.swift | 5 +- .../TextLayoutManager/TextLayoutManager.swift | 3 + .../TextLayoutManagerRenderDelegate.swift | 9 ++- .../CodeEditTextView/TextLine/TextLine.swift | 6 +- .../TextLine/Typesetter/Typesetter.swift | 4 +- .../TextView/TextView+Menu.swift | 16 +++- ...verridingLayoutManagerRenderingTests.swift | 6 +- 14 files changed, 199 insertions(+), 49 deletions(-) create mode 100644 Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme delete mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift rename Sources/CodeEditTextView/{TextLine => TextLayoutManager/TextAttachments}/TextAttachment.swift (100%) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift 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 5f205abd3..88efa7329 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift @@ -30,7 +30,6 @@ struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable { options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain], documentAttributes: nil ) - print(String(decoding: data, as: UTF8.self), text.string) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { diff --git a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift index 3845018f9..2f163f893 100644 --- a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift +++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift @@ -1,5 +1,5 @@ // -// File.swift +// CTTypesetter+SuggestLineBreak.swift // CodeEditTextView // // Created by Khan Winter on 4/24/25. @@ -126,5 +126,4 @@ extension CTTypesetter { return substring == LineEnding.carriageReturnLineFeed.rawValue } - } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift deleted file mode 100644 index c404275e3..000000000 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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: [TextAttachmentBox] = [] - - public func addAttachment(_ attachment: any TextAttachment, for range: NSRange) { - let box = TextAttachmentBox(range: range, attachment: attachment) - - // Insert new box into the ordered list. - - } - - /// Finds attachments for the given line range, and returns them as an array. - /// Returned attachment's ranges will be relative to the _document_, not the line. - public func attachments(forLineRange range: NSRange) -> [TextAttachmentBox] { - // Use binary search to find start/end index - - } -} diff --git a/Sources/CodeEditTextView/TextLine/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift similarity index 100% rename from Sources/CodeEditTextView/TextLine/TextAttachment.swift rename to Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift new file mode 100644 index 000000000..c056129e9 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -0,0 +1,80 @@ +// +// 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: [TextAttachmentBox] = [] + weak var layoutManager: TextLayoutManager? + + /// Adds a new attachment box, 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 box = TextAttachmentBox(range: range, attachment: attachment) + let insertIndex = findInsertionIndex(for: range.location) + orderedAttachments.insert(box, at: insertIndex) + layoutManager?.invalidateLayoutForRange(range) + } + + public func remove(atOffset offset: Int) { + let index = findInsertionIndex(for: offset) + + // Check if the attachment at this index starts exactly at the offset + if index < orderedAttachments.count, + orderedAttachments[index].range.location == offset { + let invalidatedRange = orderedAttachments.remove(at: index).range + layoutManager?.invalidateLayoutForRange(invalidatedRange) + } else { + assertionFailure("No attachment found at offset \(offset)") + } + } + + /// Finds attachments for 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 attachments(in range: NSRange) -> [TextAttachmentBox] { + var results: [TextAttachmentBox] = [] + var idx = findInsertionIndex(for: range.location) + while idx < orderedAttachments.count { + let box = orderedAttachments[idx] + let loc = box.range.location + if loc >= range.upperBound { + break + } + if range.contains(loc) { + results.append(box) + } + idx += 1 + } + return results + } +} + +private extension TextAttachmentManager { + /// Returns the index in `orderedAttachments` at which an attachment with + /// `range.location == location` should be inserted to keep the array sorted. + /// (Lower‐bound search.) + func findInsertionIndex(for location: Int) -> Int { + var low = 0 + var high = orderedAttachments.count + while low < high { + let mid = (low + high) / 2 + if orderedAttachments[mid].range.location < location { + low = mid + 1 + } else { + high = mid + } + } + return low + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index b90c18b46..b0dee70cb 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -178,7 +178,8 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy + breakStrategy: lineBreakStrategy, + attachments: attachments.attachments(in: position.range) ) } else { line.prepareForDisplay( @@ -186,7 +187,8 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy + breakStrategy: lineBreakStrategy, + attachments: attachments.attachments(in: position.range) ) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index f149afd1d..7acc5f99c 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -80,13 +80,12 @@ extension TextLayoutManager { if fragment.width == 0 { return linePosition.range.location + fragmentPosition.range.location } else if fragment.width < point.x - edgeInsets.left { - let fragmentRange = fragment.documentRange - let endPosition = linePosition.range.location + fragmentRange.location + fragmentRange.length + let endPosition = fragment.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 endPosition == linePosition.range.max, - let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") { + let lineEnding = LineEnding(line: textStorage?.substring(from: fragment.documentRange) ?? "") { return endPosition - lineEnding.length } else { return endPosition diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 7cf7b0428..9b5847852 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -64,6 +64,8 @@ public class TextLayoutManager: NSObject { } } + public var attachments: TextAttachmentManager = TextAttachmentManager() + // MARK: - Internal weak var textStorage: NSTextStorage? @@ -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..cf8590f70 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -18,7 +18,8 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + breakStrategy: LineBreakStrategy, + attachments: [TextAttachmentBox] ) func estimatedLineHeight() -> CGFloat? @@ -35,14 +36,16 @@ public extension TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + breakStrategy: LineBreakStrategy, + attachments: [TextAttachmentBox] ) { textLine.prepareForDisplay( displayData: displayData, range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy + breakStrategy: breakStrategy, + attachments: attachments ) } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 9e1ac6289..710727844 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -54,7 +54,8 @@ public final class TextLine: Identifiable, Equatable { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + breakStrategy: LineBreakStrategy, + attachments: [TextAttachmentBox] ) { let string = stringRef.attributedSubstring(from: range) self.maxWidth = displayData.maxWidth @@ -63,7 +64,8 @@ public final class TextLine: Identifiable, Equatable { documentRange: range, displayData: displayData, breakStrategy: breakStrategy, - markedRanges: markedRanges + markedRanges: markedRanges, + attachments: attachments ) needsLayout = false } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 5a6ca4b88..22b938ae5 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -162,9 +162,9 @@ final public class Typesetter { let lineBreak = typesetter.suggestLineBreak( using: string, strategy: breakStrategy, - startingOffset: context.currentPosition - range.location, + startingOffset: context.currentPosition, constrainingWidth: displayData.maxWidth - context.fragmentContext.width - ) + ) - range.location let typesetData = typesetLine( typesetter: typesetter, diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 508f0caf6..9bc6982aa 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -7,6 +7,15 @@ import AppKit +class Buh: TextAttachment { + var width: CGFloat = 100 + + func draw(in context: CGContext, rect: NSRect) { + context.setFillColor(NSColor.red.cgColor) + context.fill(rect) + } +} + extension TextView { override public func menu(for event: NSEvent) -> NSMenu? { guard event.type == .rightMouseDown else { return nil } @@ -16,9 +25,14 @@ extension TextView { menu.items = [ NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "c"), - NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v") + NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v"), + NSMenuItem(title: "Attach", action: #selector(buh), keyEquivalent: "b") ] return menu } + + @objc func buh() { + layoutManager.attachments.add(Buh(), for: selectedRange()) + } } diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift index 85a8eb68e..1de30493c 100644 --- a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift @@ -34,7 +34,8 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy + breakStrategy: breakStrategy, + attachments: [] ) } @@ -68,7 +69,8 @@ struct OverridingLayoutManagerRenderingTests { range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy + breakStrategy: breakStrategy, + attachments: [] ) // Update all text fragments to be height = 2.0 textLine.lineFragments.forEach { fragmentPosition in From af713a331e22194acb0e63d18e17eafd205b586b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 2 May 2025 13:10:59 -0500 Subject: [PATCH 03/34] Handle Attachments In Layout Manager Iterator --- .../CTTypesetter+SuggestLineBreak.swift | 1 - .../TextAttachments/TextAttachment.swift | 2 +- .../TextAttachmentManager.swift | 68 +++++++++++++++--- .../TextLayoutManager+Edits.swift | 6 +- .../TextLayoutManager+Iterator.swift | 69 +++++++++++++++++-- .../TextLayoutManager+Layout.swift | 6 +- .../TextLine/Typesetter/Typesetter.swift | 7 +- .../TextView/TextView+Lifecycle.swift | 4 +- .../TextView/TextView+Menu.swift | 8 ++- .../TextView/TextView+Setup.swift | 19 ++++- 10 files changed, 161 insertions(+), 29 deletions(-) diff --git a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift index 2f163f893..72ae8dfa1 100644 --- a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift +++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift @@ -76,7 +76,6 @@ extension CTTypesetter { constrainingWidth: CGFloat ) -> Int { var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth) - let isBreakAtEndOfString = breakIndex >= string.length let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 7cd924168..948d44b03 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -15,7 +15,7 @@ public struct TextAttachmentBox: Equatable { attachment.width } - public static func ==(_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> Bool { + public static func == (_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> 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 index c056129e9..576d87ae6 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -23,26 +23,28 @@ public final class TextAttachmentManager { let box = TextAttachmentBox(range: range, attachment: attachment) let insertIndex = findInsertionIndex(for: range.location) orderedAttachments.insert(box, at: insertIndex) + layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { + layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) + } layoutManager?.invalidateLayoutForRange(range) } public func remove(atOffset offset: Int) { let index = findInsertionIndex(for: offset) - // Check if the attachment at this index starts exactly at the offset - if index < orderedAttachments.count, - orderedAttachments[index].range.location == offset { - let invalidatedRange = orderedAttachments.remove(at: index).range - layoutManager?.invalidateLayoutForRange(invalidatedRange) - } else { + guard index < orderedAttachments.count && orderedAttachments[index].range.location == offset else { assertionFailure("No attachment found at offset \(offset)") + return } + + let attachment = orderedAttachments.remove(at: index) + layoutManager?.invalidateLayoutForRange(attachment.range) } - /// Finds attachments for the given line range, and returns them as an array. + /// 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 attachments(in range: NSRange) -> [TextAttachmentBox] { + public func attachments(startingIn range: NSRange) -> [TextAttachmentBox] { var results: [TextAttachmentBox] = [] var idx = findInsertionIndex(for: range.location) while idx < orderedAttachments.count { @@ -52,10 +54,43 @@ public final class TextAttachmentManager { break } if range.contains(loc) { + if let lastResult = results.last, !lastResult.range.contains(box.range.location) { + results.append(box) + } else if results.isEmpty { + results.append(box) + } + } + idx += 1 + } + return results + } + + /// Returns all attachments whose ranges overlap the given query range. + /// + /// - Parameter query: The `NSRange` to test for overlap. + /// - Returns: An array of `TextAttachmentBox` instances whose ranges intersect `query`. + func attachments(overlapping query: NSRange) -> [TextAttachmentBox] { + // Find the first attachment whose end is beyond the start of the query. + guard let startIdx = firstIndex(where: { $0.range.upperBound > query.location }) else { + return [] + } + + var results: [TextAttachmentBox] = [] + var idx = startIdx + + // Collect every subsequent attachment that truly overlaps the query. + while idx < orderedAttachments.count { + let box = orderedAttachments[idx] + if box.range.location >= query.upperBound { + break + } + if NSIntersectionRange(box.range, query).length > 0, + results.last?.range != box.range { results.append(box) } idx += 1 } + return results } } @@ -77,4 +112,21 @@ private extension TextAttachmentManager { } return low } + + /// Finds the first index that matches a callback. + /// - Parameter predicate: The query predicate. + /// - Returns: The first index that matches the given predicate. + func firstIndex(where predicate: (TextAttachmentBox) -> 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 < orderedAttachments.count ? low : nil + } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index 192b8a981..7f9fafffb 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -46,7 +46,11 @@ extension TextLayoutManager: NSTextStorageDelegate { removeLayoutLinesIn(range: insertedStringRange) insertNewLines(for: editedRange) - setNeedsLayout() + attachments.attachments(overlapping: insertedStringRange).forEach { attachment in + attachments.remove(atOffset: attachment.range.location) + } + + 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..55f1bc9b3 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -21,7 +21,7 @@ public extension TextLayoutManager { width: 0, height: estimatedHeight() ) - return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage) + return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), layoutManager: self) } /// Iterate over all lines in the y position range. @@ -29,19 +29,74 @@ 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) -> Iterator { + Iterator(minY: minY, maxY: maxY, layoutManager: self) } struct Iterator: LazySequenceProtocol, IteratorProtocol { - private var storageIterator: TextLineStorage.TextLineStorageYIterator + typealias TextLinePosition = TextLineStorage.TextLinePosition - init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) { - storageIterator = storage.linesStartingAt(minY, until: maxY) + private weak var layoutManager: TextLayoutManager? + private let minY: CGFloat + private let maxY: CGFloat + private var currentPosition: TextLinePosition? + + init(minY: CGFloat, maxY: CGFloat, layoutManager: TextLayoutManager) { + self.minY = minY + self.maxY = maxY + self.layoutManager = layoutManager } public mutating func next() -> TextLineStorage.TextLinePosition? { - storageIterator.next() + // Determine the 'visible' line at the next position. This iterator may skip lines that are covered by + // attachments, so we use the line position's range to get the next position. Once we have the position, + // we'll create a new one that reflects what we actually want to display. + // Eg for the following setup: + // Line 1 + // Line[ 2 <- Attachment start + // Line 3 + // Line] 4 <- Attachment end + // The iterator will first return the line 1 position, then, line 2 is queried but has an attachment. + // So, we extend the line until the end of the attachment (line 4), and return the position extended that + // far. + // This retains information line line index and position in the text storage. + + if let currentPosition { + guard let nextPosition = layoutManager?.lineStorage.getLine( + atOffset: currentPosition.range.max + 1 + ), nextPosition.yPos < maxY else { + return nil + } + self.currentPosition = determineVisiblePosition(for: nextPosition) + return self.currentPosition + } else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) { + currentPosition = determineVisiblePosition(for: position) + return currentPosition + } + + return nil + } + + private func determineVisiblePosition(for originalPosition: TextLinePosition) -> TextLinePosition { + guard let attachment = layoutManager?.attachments.attachments(startingIn: originalPosition.range).last, + attachment.range.max > originalPosition.range.max else { + // No change, either no attachments or attachment doesn't span multiple lines. + return originalPosition + } + + guard let extendedLinePosition = layoutManager?.lineStorage.getLine(atOffset: attachment.range.max) else { + return originalPosition + } + + let newPosition = TextLinePosition( + data: originalPosition.data, + range: NSRange(start: originalPosition.range.location, end: extendedLinePosition.range.max), + yPos: originalPosition.yPos, + height: originalPosition.height, + index: originalPosition.index + ) + + return determineVisiblePosition(for: newPosition) } } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index b0dee70cb..f71f0c9a3 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -84,7 +84,7 @@ extension TextLayoutManager { var maxFoundLineWidth = maxLineWidth // 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) @@ -179,7 +179,7 @@ extension TextLayoutManager { stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), breakStrategy: lineBreakStrategy, - attachments: attachments.attachments(in: position.range) + attachments: attachments.attachments(startingIn: position.range) ) } else { line.prepareForDisplay( @@ -188,7 +188,7 @@ extension TextLayoutManager { stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), breakStrategy: lineBreakStrategy, - attachments: attachments.attachments(in: position.range) + attachments: attachments.attachments(startingIn: position.range) ) } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 22b938ae5..663b999fd 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -50,6 +50,7 @@ final public class Typesetter { attachments: attachments ) lineFragments.build(from: lines, estimatedLineHeight: maxHeight) + } private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) { @@ -162,9 +163,9 @@ final public class Typesetter { let lineBreak = typesetter.suggestLineBreak( using: string, strategy: breakStrategy, - startingOffset: context.currentPosition, + startingOffset: context.currentPosition - range.location, constrainingWidth: displayData.maxWidth - context.fragmentContext.width - ) - range.location + ) let typesetData = typesetLine( typesetter: typesetter, @@ -181,7 +182,7 @@ final public class Typesetter { // Amend the current line data to include this line, popping the current line afterwards context.appendText(lineBreak: lineBreak, typesetData: typesetData) - if lineBreak != range.length { + if context.currentPosition != range.max { context.popCurrentData() } } 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+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 9bc6982aa..37e48c80f 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -33,6 +33,12 @@ extension TextView { } @objc func buh() { - layoutManager.attachments.add(Buh(), for: selectedRange()) + if layoutManager.attachments.attachments( + startingIn: selectedRange() + ).first?.range.location == selectedRange().location { + layoutManager.attachments.remove(atOffset: selectedRange().location) + } else { + layoutManager.attachments.add(Buh(), for: selectedRange()) + } } } 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() { From 8d967da2aaf838372ff470f43c8bb448daad65df Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 2 May 2025 23:24:42 -0500 Subject: [PATCH 04/34] Finish Tests, Fix Bugs --- .../TextAttachmentManager.swift | 2 +- .../TextLayoutManager+Iterator.swift | 65 ++++++---- .../TextLayoutManager+Public.swift | 113 ++++++++++++------ .../TextLine/LineFragment.swift | 6 +- .../TextLine/Typesetter/TypesetContext.swift | 7 +- .../TextLine/Typesetter/Typesetter.swift | 12 +- .../TextLineStorage+Structs.swift | 10 +- .../TypesetterTests.swift | 22 ++++ 8 files changed, 173 insertions(+), 64 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 576d87ae6..eeca93734 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -112,7 +112,7 @@ private extension TextAttachmentManager { } return low } - + /// Finds the first index that matches a callback. /// - Parameter predicate: The query predicate. /// - Returns: The first index that matches the given predicate. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 55f1bc9b3..2c0311569 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -51,11 +51,13 @@ public extension TextLayoutManager { // Determine the 'visible' line at the next position. This iterator may skip lines that are covered by // attachments, so we use the line position's range to get the next position. Once we have the position, // we'll create a new one that reflects what we actually want to display. - // Eg for the following setup: + // For example, with the following setup: ([ == Attachment start, ] == Attachment end) + // // Line 1 - // Line[ 2 <- Attachment start + // Line[ 2 // Line 3 - // Line] 4 <- Attachment end + // Line] 4 + // // The iterator will first return the line 1 position, then, line 2 is queried but has an attachment. // So, we extend the line until the end of the attachment (line 4), and return the position extended that // far. @@ -67,35 +69,58 @@ public extension TextLayoutManager { ), nextPosition.yPos < maxY else { return nil } - self.currentPosition = determineVisiblePosition(for: nextPosition) + self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition) return self.currentPosition } else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) { - currentPosition = determineVisiblePosition(for: position) + currentPosition = layoutManager?.determineVisiblePosition(for: position) return currentPosition } return nil } + } - private func determineVisiblePosition(for originalPosition: TextLinePosition) -> TextLinePosition { - guard let attachment = layoutManager?.attachments.attachments(startingIn: originalPosition.range).last, - attachment.range.max > originalPosition.range.max else { - // No change, either no attachments or attachment doesn't span multiple lines. - return originalPosition - } + // TODO: Docs - guard let extendedLinePosition = layoutManager?.lineStorage.getLine(atOffset: attachment.range.max) else { - return originalPosition - } + func determineVisiblePosition( + for originalPosition: TextLineStorage.TextLinePosition? + ) -> TextLineStorage.TextLinePosition? { + guard let originalPosition else { return nil} + + let attachments = attachments.attachments(overlapping: originalPosition.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 + } - let newPosition = TextLinePosition( - data: originalPosition.data, - range: NSRange(start: originalPosition.range.location, end: extendedLinePosition.range.max), - yPos: originalPosition.yPos, - height: originalPosition.height, - index: originalPosition.index + var newPosition = originalPosition + + if firstAttachment.range.location < originalPosition.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 ) + } + + if lastAttachment.range.max > originalPosition.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 + ) + } + if newPosition == originalPosition { + return newPosition + } else { + // Recurse, to make sure we combine all necessary lines. return determineVisiblePosition(for: newPosition) } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 7acc5f99c..b838ff00d 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)) } /// 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)) } } @@ -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)) } /// Calculates the text position at the given point in the view. @@ -69,7 +69,7 @@ extension TextLayoutManager { guard point.y <= estimatedHeight() else { // End position is a special case. return textStorage?.length } - guard let linePosition = lineStorage.getLine(atPosition: point.y), + guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y)), let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atPosition: point.y - linePosition.yPos ) else { @@ -80,30 +80,73 @@ extension TextLayoutManager { if fragment.width == 0 { return linePosition.range.location + fragmentPosition.range.location } else if fragment.width < point.x - edgeInsets.left { - let endPosition = fragment.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 endPosition == linePosition.range.max, - let lineEnding = LineEnding(line: textStorage?.substring(from: fragment.documentRange) ?? "") { - return endPosition - lineEnding.length - } else { - return endPosition - } - } else if let (content, contentPosition) = fragment.findContent(atX: point.x) { - switch content.data { - case .text(let ctLine): - let fragmentIndex = CTLineGetStringIndexForPosition( - ctLine, - CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) - ) - return fragmentIndex + contentPosition.offset + linePosition.range.location - case .attachment: - return contentPosition.offset + linePosition.range.location - } + return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition) + } else { + return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition) } + } - return nil + /// 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 { + // 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) else { + return nil + } + switch content.data { + case .text(let ctLine): + let fragmentIndex = CTLineGetStringIndexForPosition( + ctLine, + CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) + ) + return fragmentIndex + contentPosition.offset + linePosition.range.location + case .attachment: + return contentPosition.offset + linePosition.range.location + } } // MARK: - Rect For Offset @@ -118,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)) else { return nil } - guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atOffset: offset - linePosition.range.location ) else { @@ -130,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/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index dece0a60f..38fd432c8 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -92,7 +92,11 @@ public final class LineFragment: Identifiable, Equatable { } switch content.data { case .text(let ctLine): - return CTLineGetOffsetForStringIndex(ctLine, offset - position.offset, nil) + position.xPos + return CTLineGetOffsetForStringIndex( + ctLine, + CTLineGetStringRange(ctLine).location + offset - position.offset, + nil + ) + position.xPos case .attachment: return position.xPos } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 5fd5628a9..8254d338e 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -11,9 +11,12 @@ struct TypesetContext { let documentRange: NSRange let displayData: TextLine.DisplayData + /// Accumulated generated line fragments. var lines: [TextLineStorage.BuildItem] = [] var maxHeight: CGFloat = 0 var fragmentContext: LineFragmentTypesetContext = .init(start: 0, width: 0.0, height: 0.0, descent: 0.0) + + /// Tracks the current position when laying out runs var currentPosition: Int = 0 mutating func appendAttachment(_ attachment: TextAttachmentBox) { @@ -31,14 +34,14 @@ struct TypesetContext { currentPosition += attachment.range.length } - mutating func appendText(lineBreak: Int, typesetData: CTLineTypesetData) { + 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 + currentPosition = lineBreak + typesettingRange.location } mutating func popCurrentData() { diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 663b999fd..535a8850c 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -160,6 +160,8 @@ final public class Typesetter { ) { // 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: string, strategy: breakStrategy, @@ -167,10 +169,9 @@ final public class Typesetter { constrainingWidth: displayData.maxWidth - context.fragmentContext.width ) - let typesetData = typesetLine( - typesetter: typesetter, - range: NSRange(location: context.currentPosition - range.location, length: lineBreak) - ) + // 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 @@ -180,8 +181,9 @@ final public class Typesetter { } // Amend the current line data to include this line, popping the current line afterwards - context.appendText(lineBreak: lineBreak, typesetData: typesetData) + 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() } diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift index b022a9b1c..1a744d376 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift @@ -8,7 +8,7 @@ import Foundation extension TextLineStorage where Data: Identifiable { - public struct TextLinePosition { + public struct TextLinePosition: Equatable { init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) { self.data = data self.range = range @@ -35,6 +35,14 @@ extension TextLineStorage where Data: Identifiable { public let height: CGFloat /// The index of the position. public let index: Int + + public static func == (_ lhs: TextLinePosition, _ rhs: TextLinePosition) -> Bool { + lhs.data.id == rhs.data.id && + lhs.range == rhs.range && + lhs.yPos == rhs.yPos && + lhs.height == rhs.height && + lhs.index == rhs.index + } } struct NodePosition { diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index ba2b2a277..bb5b20373 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -103,6 +103,28 @@ class TypesetterTests: XCTestCase { XCTAssertEqual(typesetter.lineFragments.count, 1, "Typesetter typeset incorrect number of lines.") } + 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: limitedLineWidthDisplayData, // 150 px + 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 { From 0c39c0232f7840abdbe9a14792e23ab41aaef771 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 09:59:49 -0500 Subject: [PATCH 05/34] Fix Iterator Bug, Add Some Tests, Document Method --- .../TextLayoutManager+Iterator.swift | 70 ++++++++++++------- .../TextLayoutManager+Public.swift | 14 ++-- .../TextLine/Typesetter/Typesetter.swift | 2 +- ...verridingLayoutManagerRenderingTests.swift | 0 .../TextLayoutManagerAttachmentsTests.swift | 54 ++++++++++++++ .../TextLayoutManagerTests.swift | 28 ++++++++ 6 files changed, 135 insertions(+), 33 deletions(-) rename Tests/CodeEditTextViewTests/{ => LayoutManager}/OverridingLayoutManagerRenderingTests.swift (100%) create mode 100644 Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift rename Tests/CodeEditTextViewTests/{ => LayoutManager}/TextLayoutManagerTests.swift (81%) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 2c0311569..c2b3864b8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -39,7 +39,7 @@ public extension TextLayoutManager { private weak var layoutManager: TextLayoutManager? private let minY: CGFloat private let maxY: CGFloat - private var currentPosition: TextLinePosition? + private var currentPosition: (position: TextLinePosition, indexRange: ClosedRange)? init(minY: CGFloat, maxY: CGFloat, layoutManager: TextLayoutManager) { self.minY = minY @@ -47,52 +47,70 @@ public extension TextLayoutManager { self.layoutManager = layoutManager } + /// Iterates over the "visible" text positions. + /// + /// See documentation on ``TextLayoutManager/determineVisiblePosition(for:)`` for details. public mutating func next() -> TextLineStorage.TextLinePosition? { - // Determine the 'visible' line at the next position. This iterator may skip lines that are covered by - // attachments, so we use the line position's range to get the next position. Once we have the position, - // we'll create a new one that reflects what we actually want to display. - // For example, with the following setup: ([ == Attachment start, ] == Attachment end) - // - // Line 1 - // Line[ 2 - // Line 3 - // Line] 4 - // - // The iterator will first return the line 1 position, then, line 2 is queried but has an attachment. - // So, we extend the line until the end of the attachment (line 4), and return the position extended that - // far. - // This retains information line line index and position in the text storage. - if let currentPosition { guard let nextPosition = layoutManager?.lineStorage.getLine( - atOffset: currentPosition.range.max + 1 + atIndex: currentPosition.indexRange.upperBound + 1 ), nextPosition.yPos < maxY else { return nil } self.currentPosition = layoutManager?.determineVisiblePosition(for: nextPosition) - return self.currentPosition + return self.currentPosition?.position } else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) { currentPosition = layoutManager?.determineVisiblePosition(for: position) - return currentPosition + return currentPosition?.position } return nil } } - // TODO: Docs - + /// 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? - ) -> TextLineStorage.TextLinePosition? { - guard let originalPosition else { return nil} + ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { + guard let originalPosition else { return nil } let attachments = attachments.attachments(overlapping: originalPosition.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 + return (originalPosition, originalPosition.index...originalPosition.index) } + var minIndex = originalPosition.index + var maxIndex = originalPosition.index var newPosition = originalPosition if firstAttachment.range.location < originalPosition.range.location, @@ -104,6 +122,7 @@ public extension TextLayoutManager { height: extendedLinePosition.height, index: extendedLinePosition.index ) + minIndex = min(minIndex, newPosition.index) } if lastAttachment.range.max > originalPosition.range.max, @@ -115,10 +134,11 @@ public extension TextLayoutManager { height: newPosition.height, index: newPosition.index ) + maxIndex = max(maxIndex, newPosition.index) } if newPosition == originalPosition { - return newPosition + return (newPosition, minIndex...maxIndex) } else { // Recurse, to make sure we combine all necessary lines. return determineVisiblePosition(for: newPosition) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index b838ff00d..fd4eda8e5 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? { - determineVisiblePosition(for: 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 determineVisiblePosition(for: 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 determineVisiblePosition(for: lineStorage.getLine(atIndex: index)) + return determineVisiblePosition(for: lineStorage.getLine(atIndex: index))?.position } /// Calculates the text position at the given point in the view. @@ -69,7 +69,7 @@ extension TextLayoutManager { guard point.y <= estimatedHeight() else { // End position is a special case. return textStorage?.length } - guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y)), + guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y))?.position, let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atPosition: point.y - linePosition.yPos ) else { @@ -91,8 +91,8 @@ extension TextLayoutManager { /// 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. + /// 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. /// @@ -161,7 +161,7 @@ extension TextLayoutManager { guard offset != lineStorage.length else { return rectForEndOffset() } - guard let linePosition = determineVisiblePosition(for: 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( diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 535a8850c..c39f07152 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -228,7 +228,7 @@ final public class Typesetter { ) lineFragments.build( from: [.init(data: fragment, length: 0, height: fragment.scaledHeight)], - estimatedLineHeight: displayData.estimatedLineHeight + estimatedLineHeight: 0 ) } diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift similarity index 100% rename from Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift rename to Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift new file mode 100644 index 000000000..725a06ea8 --- /dev/null +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -0,0 +1,54 @@ +// +// 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: "A\nB\nC\nD") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textStorage = textView.textStorage + layoutManager = try #require(textView.layoutManager) + } + + // MARK: - Determine Visible Line Tests + + @Test + func determineVisibleLinesMovesForwards() { + layoutManager.attachments.attachments(overlapping: <#T##NSRange#>) + } + + @Test + func determineVisibleLinesMovesBackwards() { + + } + + @Test + func determineVisibleLinesMergesMultipleAttachments() { + + } + + // MARK: - Iterator Tests + + @Test + func iterateWithAttachments() { + + } + + @Test + func iterateWithMultilineAttachments() { + + } +} diff --git a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift similarity index 81% rename from Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift rename to Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 1dcc9a7dd..df54f8094 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -130,4 +130,32 @@ struct TextLayoutManagerTests { #expect(lineFragmentIDs == afterLineFragmentIDs, "Line fragments were invalidated by `rectsFor(range:)` call.") layoutManager.lineStorage.validateInternalState() } + + /// # 05/05/25 + /// 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 iteratorDoesNotSkipEmptyLines() { + // 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 + } + } } From 4f2ca592dd5e31be5733a7bb576c83758b5de698 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:02:12 -0500 Subject: [PATCH 06/34] Rename `attachments(` to `get(`, Clarify Internal Methods --- .../TextAttachmentManager.swift | 53 +++++++++++-------- .../TextLayoutManager+Edits.swift | 2 +- .../TextLayoutManager+Iterator.swift | 2 +- .../TextLayoutManager+Layout.swift | 4 +- .../TextView/TextView+Menu.swift | 2 +- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index eeca93734..f1a5cb9e7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -44,7 +44,7 @@ public final class TextAttachmentManager { /// 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 attachments(startingIn range: NSRange) -> [TextAttachmentBox] { + public func get(startingIn range: NSRange) -> [TextAttachmentBox] { var results: [TextAttachmentBox] = [] var idx = findInsertionIndex(for: range.location) while idx < orderedAttachments.count { @@ -69,7 +69,7 @@ public final class TextAttachmentManager { /// /// - Parameter query: The `NSRange` to test for overlap. /// - Returns: An array of `TextAttachmentBox` instances whose ranges intersect `query`. - func attachments(overlapping query: NSRange) -> [TextAttachmentBox] { + public func get(overlapping query: NSRange) -> [TextAttachmentBox] { // Find the first attachment whose end is beyond the start of the query. guard let startIdx = firstIndex(where: { $0.range.upperBound > query.location }) else { return [] @@ -96,37 +96,44 @@ public final class TextAttachmentManager { } private extension TextAttachmentManager { - /// Returns the index in `orderedAttachments` at which an attachment with - /// `range.location == location` should be inserted to keep the array sorted. - /// (Lower‐bound search.) - func findInsertionIndex(for location: Int) -> Int { + /// 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: (TextAttachmentBox) -> Bool + ) -> Int { var low = 0 var high = orderedAttachments.count while low < high { let mid = (low + high) / 2 - if orderedAttachments[mid].range.location < location { - low = mid + 1 - } else { + if predicate(orderedAttachments[mid]) { high = mid + } else { + low = mid + 1 } } return low } - /// Finds the first index that matches a callback. - /// - Parameter predicate: The query predicate. - /// - Returns: The first index that matches the given predicate. + /// 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: (TextAttachmentBox) -> 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 < orderedAttachments.count ? low : nil + 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 7f9fafffb..ebcf7899f 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -46,7 +46,7 @@ extension TextLayoutManager: NSTextStorageDelegate { removeLayoutLinesIn(range: insertedStringRange) insertNewLines(for: editedRange) - attachments.attachments(overlapping: insertedStringRange).forEach { attachment in + attachments.get(overlapping: insertedStringRange).forEach { attachment in attachments.remove(atOffset: attachment.range.location) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index c2b3864b8..a9efa4a56 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -103,7 +103,7 @@ public extension TextLayoutManager { ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { guard let originalPosition else { return nil } - let attachments = attachments.attachments(overlapping: originalPosition.range) + let attachments = attachments.get(overlapping: originalPosition.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, originalPosition.index...originalPosition.index) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index f71f0c9a3..b3ebf8e6b 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -179,7 +179,7 @@ extension TextLayoutManager { stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), breakStrategy: lineBreakStrategy, - attachments: attachments.attachments(startingIn: position.range) + attachments: attachments.get(startingIn: position.range) ) } else { line.prepareForDisplay( @@ -188,7 +188,7 @@ extension TextLayoutManager { stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), breakStrategy: lineBreakStrategy, - attachments: attachments.attachments(startingIn: position.range) + attachments: attachments.get(startingIn: position.range) ) } diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 37e48c80f..a9be675a7 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -33,7 +33,7 @@ extension TextView { } @objc func buh() { - if layoutManager.attachments.attachments( + if layoutManager.attachments.get( startingIn: selectedRange() ).first?.range.location == selectedRange().location { layoutManager.attachments.remove(atOffset: selectedRange().location) From 2622e51cf05bda79b521895f6cd5d19feeef047b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:31:22 -0500 Subject: [PATCH 07/34] Whole Bunch of Fixes and Tests --- .../TextAttachmentManager.swift | 4 +- .../TextLayoutManager+Iterator.swift | 28 +++++--- .../TextLayoutManagerAttachmentsTests.swift | 67 +++++++++++++++++-- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index f1a5cb9e7..05275308a 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -24,7 +24,9 @@ public final class TextAttachmentManager { let insertIndex = findInsertionIndex(for: range.location) orderedAttachments.insert(box, at: insertIndex) layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { - layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) + if $0.height != 0 { + layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) + } } layoutManager?.invalidateLayoutForRange(range) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index a9efa4a56..e312153cd 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -102,18 +102,23 @@ public extension TextLayoutManager { for originalPosition: TextLineStorage.TextLinePosition? ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { guard let originalPosition else { return nil } + return determineVisiblePosition(for: (originalPosition, originalPosition.index...originalPosition.index)) + } - let attachments = attachments.get(overlapping: originalPosition.range) + func determineVisiblePosition( + for originalPosition: (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange) + ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { + let attachments = attachments.get(overlapping: 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, originalPosition.index...originalPosition.index) + return originalPosition } - var minIndex = originalPosition.index - var maxIndex = originalPosition.index - var newPosition = originalPosition + var minIndex = originalPosition.indexRange.lowerBound + var maxIndex = originalPosition.indexRange.upperBound + var newPosition = originalPosition.position - if firstAttachment.range.location < originalPosition.range.location, + if firstAttachment.range.location < originalPosition.position.range.location, let extendedLinePosition = lineStorage.getLine(atOffset: firstAttachment.range.location) { newPosition = TextLineStorage.TextLinePosition( data: extendedLinePosition.data, @@ -125,23 +130,24 @@ public extension TextLayoutManager { minIndex = min(minIndex, newPosition.index) } - if lastAttachment.range.max > originalPosition.range.max, + 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 + index: newPosition.index // We want to keep the minimum index. ) - maxIndex = max(maxIndex, newPosition.index) + maxIndex = max(maxIndex, extendedLinePosition.index) } - if newPosition == originalPosition { + // 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 determineVisiblePosition(for: newPosition) + return determineVisiblePosition(for: (newPosition, minIndex...maxIndex)) } } } diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index 725a06ea8..f47e52567 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -17,38 +17,95 @@ struct TextLayoutManagerAttachmentsTests { let layoutManager: TextLayoutManager init() throws { - textView = TextView(string: "A\nB\nC\nD") + 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.get(overlapping: textView.documentRange).count == 1) + #expect(layoutManager.attachments.get(overlapping: NSRange(start: 0, end: 3)).count == 1) + #expect(layoutManager.attachments.get(startingIn: NSRange(start: 0, end: 3)).count == 1) + } + // MARK: - Determine Visible Line Tests @Test - func determineVisibleLinesMovesForwards() { - layoutManager.attachments.attachments(overlapping: <#T##NSRange#>) + 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 determineVisibleLinesMovesBackwards() { + 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 determineVisibleLinesMergesMultipleAttachments() { + 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]) } } From 4427899b02ad8a1951ce9c2214bed7a762b3a976 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:31:40 -0500 Subject: [PATCH 08/34] Trailing Spaces --- .../CodeEditTextViewExample/Views/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index e5f5ac256..0323c8541 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -18,7 +18,7 @@ struct ContentView: View { Toggle("Wrap Lines", isOn: $wrapLines) Toggle("Inset Edges", isOn: $enableEdgeInsets) Button { - + } label: { Text("Insert Attachment") } From 2ef1f12f402813e7fd57a32df144fe500ccd9281 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:34:52 -0500 Subject: [PATCH 09/34] Rearrange Break Strategy into `DisplayData` --- .../TextLayoutManager+Layout.swift | 4 ++-- .../TextLayoutManagerRenderDelegate.swift | 1 - Sources/CodeEditTextView/TextLine/TextLine.swift | 13 +++++++++---- .../TextLine/Typesetter/Typesetter.swift | 11 +++-------- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index b3ebf8e6b..fa05fc130 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -167,7 +167,8 @@ extension TextLayoutManager { let lineDisplayData = TextLine.DisplayData( maxWidth: layoutData.maxWidth, lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() + estimatedLineHeight: estimateLineHeight(), + breakStrategy: lineBreakStrategy ) let line = position.data @@ -187,7 +188,6 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy, attachments: attachments.get(startingIn: position.range) ) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index cf8590f70..ab35370e0 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -44,7 +44,6 @@ public extension TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy, attachments: attachments ) } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 710727844..ce3d50832 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -48,13 +48,12 @@ 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: [TextAttachmentBox] ) { let string = stringRef.attributedSubstring(from: range) @@ -63,7 +62,6 @@ public final class TextLine: Identifiable, Equatable { string, documentRange: range, displayData: displayData, - breakStrategy: breakStrategy, markedRanges: markedRanges, attachments: attachments ) @@ -79,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/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index c39f07152..0170facf9 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -31,7 +31,6 @@ final public class Typesetter { _ string: NSAttributedString, documentRange: NSRange, displayData: TextLine.DisplayData, - breakStrategy: LineBreakStrategy, markedRanges: MarkedRanges?, attachments: [TextAttachmentBox] = [] ) { @@ -46,7 +45,6 @@ final public class Typesetter { let (lines, maxHeight) = typesetLineFragments( documentRange: documentRange, displayData: displayData, - breakStrategy: breakStrategy, attachments: attachments ) lineFragments.build(from: lines, estimatedLineHeight: maxHeight) @@ -121,7 +119,6 @@ final public class Typesetter { func typesetLineFragments( documentRange: NSRange, displayData: TextLine.DisplayData, - breakStrategy: LineBreakStrategy, attachments: [TextAttachmentBox] ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { let contentRuns = createContentRuns(documentRange: documentRange, attachments: attachments) @@ -136,8 +133,7 @@ final public class Typesetter { context: &context, range: run.range, typesetter: typesetter, - displayData: displayData, - breakStrategy: breakStrategy + displayData: displayData ) } } @@ -155,8 +151,7 @@ final public class Typesetter { context: inout TypesetContext, range: NSRange, typesetter: CTTypesetter, - displayData: TextLine.DisplayData, - breakStrategy: LineBreakStrategy + displayData: TextLine.DisplayData ) { // Layout as many fragments as possible in this content run while context.currentPosition < range.max { @@ -164,7 +159,7 @@ final public class Typesetter { // It's relative to the range being typeset, not the line let lineBreak = typesetter.suggestLineBreak( using: string, - strategy: breakStrategy, + strategy: displayData.breakStrategy, startingOffset: context.currentPosition - range.location, constrainingWidth: displayData.maxWidth - context.fragmentContext.width ) From d692ca67276581b1523821da45f8abfa6e32173d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:39:12 -0500 Subject: [PATCH 10/34] Tests Compile, Still Need To Fix Overridden Heights --- .../TextLayoutManager+Layout.swift | 1 - .../TextLayoutManagerRenderDelegate.swift | 2 - ...verridingLayoutManagerRenderingTests.swift | 13 +-- .../TypesetterTests.swift | 99 ++++++++++++------- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index fa05fc130..21e5a0bfd 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -179,7 +179,6 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy, attachments: attachments.get(startingIn: position.range) ) } else { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index ab35370e0..9c52d194c 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -18,7 +18,6 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy, attachments: [TextAttachmentBox] ) @@ -36,7 +35,6 @@ public extension TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy, attachments: [TextAttachmentBox] ) { textLine.prepareForDisplay( diff --git a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift index 1de30493c..3c2e9da53 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/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)? @@ -19,22 +18,19 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { displayData: TextLine.DisplayData, range: NSRange, stringRef: NSTextStorage, - markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + markedRanges: MarkedRanges? ) { prepareForDisplay?( textLine, displayData, range, stringRef, - markedRanges, - breakStrategy + markedRanges ) ?? textLine.prepareForDisplay( displayData: displayData, range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy, attachments: [] ) } @@ -63,13 +59,12 @@ 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 diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index bb5b20373..d2ce52557 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -19,17 +19,6 @@ final class DemoTextAttachment: TextAttachment { class TypesetterTests: XCTestCase { // NOTE: makes chars that are ~6.18 pts wide let attributes: [NSAttributedString.Key: Any] = [.font: NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)] - let limitedLineWidthDisplayData = TextLine.DisplayData( - maxWidth: 150, - lineHeightMultiplier: 1.0, - estimatedLineHeight: 20.0 - ) - let unlimitedLineWidthDisplayData = TextLine.DisplayData( - maxWidth: .infinity, - lineHeightMultiplier: 1.0, - estimatedLineHeight: 20.0 - ) - var typesetter: Typesetter! override func setUp() { @@ -41,8 +30,12 @@ class TypesetterTests: XCTestCase { 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 ) @@ -51,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 ) @@ -63,8 +60,12 @@ class TypesetterTests: XCTestCase { 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 ) @@ -73,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 ) @@ -85,8 +90,12 @@ class TypesetterTests: XCTestCase { 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 ) @@ -95,8 +104,12 @@ 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 ) @@ -108,8 +121,12 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: String(repeating: "A", count: 1000), attributes: attributes), documentRange: NSRange(location: 0, length: 1000), - displayData: limitedLineWidthDisplayData, // 150 px - breakStrategy: .character, + displayData: TextLine.DisplayData( + maxWidth: 150, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil, attachments: [] ) @@ -132,8 +149,12 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "ABC"), documentRange: NSRange(location: 0, length: 3), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .character, + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil, attachments: [TextAttachmentBox(range: NSRange(location: 1, length: 1), attachment: attachment)] ) @@ -158,8 +179,12 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "ABC"), documentRange: NSRange(location: 0, length: 3), - displayData: unlimitedLineWidthDisplayData, - breakStrategy: .character, + displayData: TextLine.DisplayData( + maxWidth: .infinity, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), markedRanges: nil, attachments: [TextAttachmentBox(range: NSRange(location: 0, length: 3), attachment: attachment)] ) @@ -184,8 +209,12 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "ABC123", attributes: attributes), documentRange: NSRange(location: 0, length: 6), - displayData: limitedLineWidthDisplayData, // 150 px - breakStrategy: .character, + 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)] ) @@ -211,8 +240,12 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "ABC123", attributes: attributes), documentRange: NSRange(location: 0, length: 6), - displayData: limitedLineWidthDisplayData, // 150 px - breakStrategy: .character, + 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)] ) From 1607f04ca96e05a2c01a8f33e98289740bf08846 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:41:52 -0500 Subject: [PATCH 11/34] Fix Overridding Delegate --- .../OverridingLayoutManagerRenderingTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift index 3c2e9da53..6c6857fbf 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift @@ -13,12 +13,13 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { var estimatedLineHeightOverride: (() -> CGFloat)? - func prepareForDisplay( // swiftlint:disable:this function_parameter_count + func prepareForDisplay( textLine: TextLine, displayData: TextLine.DisplayData, range: NSRange, stringRef: NSTextStorage, - markedRanges: MarkedRanges? + markedRanges: MarkedRanges?, + attachments: [TextAttachmentBox] ) { prepareForDisplay?( textLine, From 639104aee62dff2e351e115472c4c1f605890177 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 10:42:49 -0500 Subject: [PATCH 12/34] Linter --- .../LayoutManager/OverridingLayoutManagerRenderingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift index 6c6857fbf..23df7d776 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift @@ -13,7 +13,7 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { var estimatedLineHeightOverride: (() -> CGFloat)? - func prepareForDisplay( + func prepareForDisplay( // swiftlint:disable:this function_parameter_count textLine: TextLine, displayData: TextLine.DisplayData, range: NSRange, From 1d09ede5cd686c24ec4e07ea005071e1dd62400a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 13:54:45 -0500 Subject: [PATCH 13/34] Fix Some Typesetting Bugs, Add `RangeIterator` --- .../CTTypesetter+SuggestLineBreak.swift | 19 ++++---- .../TextAttachments/TextAttachment.swift | 2 +- .../TextAttachmentManager.swift | 10 ++++ .../TextLayoutManager+Edits.swift | 4 +- .../TextLayoutManager+Iterator.swift | 47 +++++++++++++++++-- .../TextLayoutManager+Layout.swift | 4 +- .../TextLayoutManager/TextLayoutManager.swift | 2 +- .../TextLine/Typesetter/Typesetter.swift | 7 +-- .../TextSelectionManager+Draw.swift | 4 +- .../TextSelectionManager+FillRects.swift | 2 +- .../TypesetterTests.swift | 19 ++++++++ 11 files changed, 92 insertions(+), 28 deletions(-) diff --git a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift index 72ae8dfa1..fefe98530 100644 --- a/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift +++ b/Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift @@ -18,20 +18,20 @@ extension CTTypesetter { func suggestLineBreak( using string: NSAttributedString, strategy: LineBreakStrategy, - startingOffset: Int, + subrange: NSRange, constrainingWidth: CGFloat ) -> Int { switch strategy { case .character: return suggestLineBreakForCharacter( string: string, - startingOffset: startingOffset, + startingOffset: subrange.location, constrainingWidth: constrainingWidth ) case .word: return suggestLineBreakForWord( string: string, - startingOffset: startingOffset, + subrange: subrange, constrainingWidth: constrainingWidth ) } @@ -72,11 +72,11 @@ extension CTTypesetter { /// - Returns: An offset relative to the entire string indicating where to break. private func suggestLineBreakForWord( string: NSAttributedString, - startingOffset: Int, + subrange: NSRange, constrainingWidth: CGFloat ) -> Int { - var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth) - let isBreakAtEndOfString = breakIndex >= string.length + var breakIndex = subrange.location + CTTypesetterSuggestClusterBreak(self, subrange.location, constrainingWidth) + let isBreakAtEndOfString = breakIndex >= subrange.max let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string) if isNextCharacterCarriageReturn { @@ -92,7 +92,7 @@ extension CTTypesetter { // Try to walk backwards until we hit a whitespace or punctuation var index = breakIndex - 1 - while breakIndex - index < 100 && index > startingOffset { + while breakIndex - index < 100 && index > subrange.location { if ensureCharacterCanBreakLine(at: index, for: string) { return index + 1 } @@ -107,9 +107,8 @@ extension CTTypesetter { /// - 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 set = CharacterSet( - charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string - ) + 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) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 948d44b03..d18e6d0a2 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -8,7 +8,7 @@ import AppKit public struct TextAttachmentBox: Equatable { - let range: NSRange + var range: NSRange let attachment: any TextAttachment var width: CGFloat { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 05275308a..d6df2d324 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -95,6 +95,16 @@ public final class TextAttachmentManager { return results } + + package func textUpdated(atOffset: Int, delta: Int) { + for (idx, box) in orderedAttachments.enumerated().reversed() { + if box.range.contains(atOffset) { + orderedAttachments.remove(at: idx) + } else if box.range.location > atOffset { + orderedAttachments[idx].range.location += delta + } + } + } } private extension TextAttachmentManager { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index ebcf7899f..b3d7d11bc 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -46,9 +46,7 @@ extension TextLayoutManager: NSTextStorageDelegate { removeLayoutLinesIn(range: insertedStringRange) insertNewLines(for: editedRange) - attachments.get(overlapping: insertedStringRange).forEach { attachment in - attachments.remove(atOffset: attachment.range.location) - } + attachments.textUpdated(atOffset: editedRange.location, delta: delta) invalidateLayoutForRange(insertedStringRange) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index e312153cd..24ab168f6 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), layoutManager: self) + return YPositionIterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), layoutManager: self) } /// Iterate over all lines in the y position range. @@ -29,11 +29,15 @@ 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) -> Iterator { - Iterator(minY: minY, maxY: maxY, layoutManager: self) + func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> YPositionIterator { + YPositionIterator(minY: minY, maxY: maxY, layoutManager: self) } - struct Iterator: LazySequenceProtocol, IteratorProtocol { + func linesInRange(_ range: NSRange) -> RangeIterator { + RangeIterator(range: range, layoutManager: self) + } + + struct YPositionIterator: LazySequenceProtocol, IteratorProtocol { typealias TextLinePosition = TextLineStorage.TextLinePosition private weak var layoutManager: TextLayoutManager? @@ -68,6 +72,39 @@ public extension TextLayoutManager { } } + 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(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? { + 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 diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 21e5a0bfd..9d1e3dd21 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -77,7 +77,7 @@ 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 @@ -162,7 +162,7 @@ extension TextLayoutManager { _ position: TextLineStorage.TextLinePosition, textStorage: NSTextStorage, layoutData: LineLayoutData, - laidOutFragmentIDs: inout Set + laidOutFragmentIDs: inout Set ) -> CGSize { let lineDisplayData = TextLine.DisplayData( maxWidth: layoutData.maxWidth, diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 9b5847852..b99d076d9 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -71,7 +71,7 @@ public class TextLayoutManager: NSObject { 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 diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 0170facf9..fbd515326 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -48,7 +48,6 @@ final public class Typesetter { attachments: attachments ) lineFragments.build(from: lines, estimatedLineHeight: maxHeight) - } private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) { @@ -153,14 +152,16 @@ final public class Typesetter { 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: string, + using: substring, strategy: displayData.breakStrategy, - startingOffset: context.currentPosition - range.location, + subrange: NSRange(start: context.currentPosition - range.location, end: range.length), constrainingWidth: displayData.maxWidth - context.fragmentContext.width ) 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/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index d2ce52557..1b4d97b38 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -267,4 +267,23 @@ class TypesetterTests: XCTestCase { 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) + } } From d86b59d76092a484742015060a20be2b6e238a00 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 13:56:13 -0500 Subject: [PATCH 14/34] Add Range Iterator Tests --- .../TextLayoutManagerTests.swift | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index df54f8094..3074c79cc 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -138,7 +138,7 @@ struct TextLayoutManagerTests { /// /// Related implementation: ``TextLayoutManager/Iterator`` @Test - func iteratorDoesNotSkipEmptyLines() { + 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)) @@ -158,4 +158,27 @@ struct TextLayoutManagerTests { 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 + } + } } From 7d2c81c0dd0e56ec37b2229b82216d3c8a9920e2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:27:45 -0500 Subject: [PATCH 15/34] Update Selections, Remove Demo Menu Item --- .../Views/ContentView.swift | 6 ----- .../TextAttachmentManager.swift | 2 +- .../TextLayoutManager+Layout.swift | 18 +++++++++++-- ...lectionManager+SelectionManipulation.swift | 18 ++++++++++--- .../TextView/TextView+Menu.swift | 13 +--------- .../TextLayoutManagerTests.swift | 26 +++++++++++++++++++ 6 files changed, 58 insertions(+), 25 deletions(-) diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index 0323c8541..1a64d8b54 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -17,12 +17,6 @@ struct ContentView: View { HStack { Toggle("Wrap Lines", isOn: $wrapLines) Toggle("Inset Edges", isOn: $enableEdgeInsets) - Button { - - } label: { - Text("Insert Attachment") - } - } Divider() SwiftUITextView( diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index d6df2d324..f504eedff 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -28,7 +28,7 @@ public final class TextAttachmentManager { layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) } } - layoutManager?.invalidateLayoutForRange(range) + layoutManager?.setNeedsLayout() } public func remove(atOffset offset: Int) { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 9d1e3dd21..eb84195b4 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 @@ -83,6 +84,10 @@ extension TextLayoutManager { 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 linesStartingAt(minY, until: maxY).lazy { guard linePosition.yPos < maxY else { continue } @@ -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 diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift index d94563a7b..5f203c8c7 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.get(overlapping: range) { + attachments.forEach { textAttachment in + range.formUnion(textAttachment.range) + } + } + + return range } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index a9be675a7..6227e8f65 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -25,20 +25,9 @@ extension TextView { menu.items = [ NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "c"), - NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v"), - NSMenuItem(title: "Attach", action: #selector(buh), keyEquivalent: "b") + NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v") ] return menu } - - @objc func buh() { - if layoutManager.attachments.get( - startingIn: selectedRange() - ).first?.range.location == selectedRange().location { - layoutManager.attachments.remove(atOffset: selectedRange().location) - } else { - layoutManager.attachments.add(Buh(), for: selectedRange()) - } - } } diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 3074c79cc..07f144f3b 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/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) } @@ -181,4 +182,29 @@ struct TextLayoutManagerTests { 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.") + } } From 61bb469a2f20fb53bfd2d23713e43fd1b67f3bed Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:31:33 -0500 Subject: [PATCH 16/34] Delete CodeEditTextViewExample.xcscheme --- .../CodeEditTextViewExample.xcscheme | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme deleted file mode 100644 index ceaa1d0a1..000000000 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From b43306c943c7441ab4efca0f52f3b71129c1861a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:40:35 -0500 Subject: [PATCH 17/34] Rename `Box` to `Any` --- .../TextAttachments/TextAttachment.swift | 9 +++- .../TextAttachmentManager.swift | 48 +++++++++---------- .../TextLayoutManagerRenderDelegate.swift | 4 +- .../TextLine/LineFragment.swift | 2 +- .../CodeEditTextView/TextLine/TextLine.swift | 2 +- .../TextLine/Typesetter/TypesetContext.swift | 2 +- .../TextLine/Typesetter/Typesetter.swift | 8 ++-- ...verridingLayoutManagerRenderingTests.swift | 2 +- .../TypesetterTests.swift | 4 +- 9 files changed, 43 insertions(+), 38 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index d18e6d0a2..f9d20e96b 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -7,7 +7,11 @@ import AppKit -public struct TextAttachmentBox: Equatable { +/// 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 @@ -15,11 +19,12 @@ public struct TextAttachmentBox: Equatable { attachment.width } - public static func == (_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> Bool { + public static func == (_ lhs: AnyTextAttachment, _ rhs: AnyTextAttachment) -> Bool { lhs.range == rhs.range && lhs.attachment === rhs.attachment } } +/// 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) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index f504eedff..895e6c875 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -13,16 +13,16 @@ import Foundation /// 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: [TextAttachmentBox] = [] + private var orderedAttachments: [AnyTextAttachment] = [] weak var layoutManager: TextLayoutManager? - /// Adds a new attachment box, keeping `orderedAttachments` sorted by range.location. + /// 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 box = TextAttachmentBox(range: range, attachment: attachment) + let attachment = AnyTextAttachment(range: range, attachment: attachment) let insertIndex = findInsertionIndex(for: range.location) - orderedAttachments.insert(box, at: insertIndex) + 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) @@ -46,20 +46,20 @@ public final class TextAttachmentManager { /// 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 get(startingIn range: NSRange) -> [TextAttachmentBox] { - var results: [TextAttachmentBox] = [] + public func get(startingIn range: NSRange) -> [AnyTextAttachment] { + var results: [AnyTextAttachment] = [] var idx = findInsertionIndex(for: range.location) while idx < orderedAttachments.count { - let box = orderedAttachments[idx] - let loc = box.range.location + 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(box.range.location) { - results.append(box) + if let lastResult = results.last, !lastResult.range.contains(attachment.range.location) { + results.append(attachment) } else if results.isEmpty { - results.append(box) + results.append(attachment) } } idx += 1 @@ -70,25 +70,25 @@ public final class TextAttachmentManager { /// Returns all attachments whose ranges overlap the given query range. /// /// - Parameter query: The `NSRange` to test for overlap. - /// - Returns: An array of `TextAttachmentBox` instances whose ranges intersect `query`. - public func get(overlapping query: NSRange) -> [TextAttachmentBox] { + /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. + public func get(overlapping query: NSRange) -> [AnyTextAttachment] { // Find the first attachment whose end is beyond the start of the query. guard let startIdx = firstIndex(where: { $0.range.upperBound > query.location }) else { return [] } - var results: [TextAttachmentBox] = [] + var results: [AnyTextAttachment] = [] var idx = startIdx // Collect every subsequent attachment that truly overlaps the query. while idx < orderedAttachments.count { - let box = orderedAttachments[idx] - if box.range.location >= query.upperBound { + let attachment = orderedAttachments[idx] + if attachment.range.location >= query.upperBound { break } - if NSIntersectionRange(box.range, query).length > 0, - results.last?.range != box.range { - results.append(box) + if NSIntersectionRange(attachment.range, query).length > 0, + results.last?.range != attachment.range { + results.append(attachment) } idx += 1 } @@ -97,10 +97,10 @@ public final class TextAttachmentManager { } package func textUpdated(atOffset: Int, delta: Int) { - for (idx, box) in orderedAttachments.enumerated().reversed() { - if box.range.contains(atOffset) { + for (idx, attachment) in orderedAttachments.enumerated().reversed() { + if attachment.range.contains(atOffset) { orderedAttachments.remove(at: idx) - } else if box.range.location > atOffset { + } else if attachment.range.location > atOffset { orderedAttachments[idx].range.location += delta } } @@ -115,7 +115,7 @@ private extension TextAttachmentManager { /// If it returns `orderedAttachments.count`, no element satisfied /// the predicate, but that’s still a valid insertion point. func lowerBoundIndex( - where predicate: (TextAttachmentBox) -> Bool + where predicate: (AnyTextAttachment) -> Bool ) -> Int { var low = 0 var high = orderedAttachments.count @@ -144,7 +144,7 @@ private extension TextAttachmentManager { /// - Parameter predicate: the query predicate. /// - Returns: the first matching index, or `nil` if none of the /// attachments satisfy the predicate. - func firstIndex(where predicate: (TextAttachmentBox) -> Bool) -> Int? { + func firstIndex(where predicate: (AnyTextAttachment) -> Bool) -> Int? { let idx = lowerBoundIndex { predicate($0) } return idx < orderedAttachments.count ? idx : nil } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index 9c52d194c..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?, - attachments: [TextAttachmentBox] + attachments: [AnyTextAttachment] ) func estimatedLineHeight() -> CGFloat? @@ -35,7 +35,7 @@ public extension TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - attachments: [TextAttachmentBox] + attachments: [AnyTextAttachment] ) { textLine.prepareForDisplay( displayData: displayData, diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 38fd432c8..6ace0b051 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -14,7 +14,7 @@ public final class LineFragment: Identifiable, Equatable { public struct FragmentContent: Equatable { public enum Content: Equatable { case text(line: CTLine) - case attachment(attachment: TextAttachmentBox) + case attachment(attachment: AnyTextAttachment) } let data: Content diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index ce3d50832..2eee6f375 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -54,7 +54,7 @@ public final class TextLine: Identifiable, Equatable { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - attachments: [TextAttachmentBox] + attachments: [AnyTextAttachment] ) { let string = stringRef.attributedSubstring(from: range) self.maxWidth = displayData.maxWidth diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 8254d338e..29087b0d9 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -19,7 +19,7 @@ struct TypesetContext { /// Tracks the current position when laying out runs var currentPosition: Int = 0 - mutating func appendAttachment(_ attachment: TextAttachmentBox) { + mutating func appendAttachment(_ attachment: AnyTextAttachment) { // Check if we can append this attachment to the current line if fragmentContext.width + attachment.width > displayData.maxWidth { popCurrentData() diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index fbd515326..e62adb639 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -14,7 +14,7 @@ final public class Typesetter { let type: RunType enum RunType { - case attachment(TextAttachmentBox) + case attachment(AnyTextAttachment) case string(CTTypesetter) } } @@ -32,7 +32,7 @@ final public class Typesetter { documentRange: NSRange, displayData: TextLine.DisplayData, markedRanges: MarkedRanges?, - attachments: [TextAttachmentBox] = [] + attachments: [AnyTextAttachment] = [] ) { makeString(string: string, markedRanges: markedRanges) lineFragments.removeAll() @@ -69,7 +69,7 @@ final public class Typesetter { /// - 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(documentRange: NSRange, attachments: [TextAttachmentBox]) -> [ContentRun] { + func createContentRuns(documentRange: NSRange, attachments: [AnyTextAttachment]) -> [ContentRun] { var attachments = attachments var currentPosition = 0 let maxPosition = documentRange.length @@ -118,7 +118,7 @@ final public class Typesetter { func typesetLineFragments( documentRange: NSRange, displayData: TextLine.DisplayData, - attachments: [TextAttachmentBox] + attachments: [AnyTextAttachment] ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { let contentRuns = createContentRuns(documentRange: documentRange, attachments: attachments) var context = TypesetContext(documentRange: documentRange, displayData: displayData) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift index 23df7d776..07f222fb6 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/OverridingLayoutManagerRenderingTests.swift @@ -19,7 +19,7 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - attachments: [TextAttachmentBox] + attachments: [AnyTextAttachment] ) { prepareForDisplay?( textLine, diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index 1b4d97b38..e065cb69c 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -156,7 +156,7 @@ class TypesetterTests: XCTestCase { breakStrategy: .character ), markedRanges: nil, - attachments: [TextAttachmentBox(range: NSRange(location: 1, length: 1), attachment: attachment)] + attachments: [AnyTextAttachment(range: NSRange(location: 1, length: 1), attachment: attachment)] ) XCTAssertEqual(typesetter.lineFragments.count, 1) @@ -186,7 +186,7 @@ class TypesetterTests: XCTestCase { breakStrategy: .character ), markedRanges: nil, - attachments: [TextAttachmentBox(range: NSRange(location: 0, length: 3), attachment: attachment)] + attachments: [AnyTextAttachment(range: NSRange(location: 0, length: 3), attachment: attachment)] ) XCTAssertEqual(typesetter.lineFragments.count, 1) From d0f16b807291311c745f5167d576ddde9059482d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:40:52 -0500 Subject: [PATCH 18/34] Reorder --- .../TextAttachments/TextAttachment.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index f9d20e96b..61ca777f2 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -7,6 +7,12 @@ 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 @@ -23,9 +29,3 @@ public struct AnyTextAttachment: Equatable { lhs.range == rhs.range && lhs.attachment === rhs.attachment } } - -/// 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) -} From f8e3fa5854d281bedb723f5d7361ca76f288852d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:57:00 -0500 Subject: [PATCH 19/34] Docs --- .../Typesetter/CTLineTypesetData.swift | 1 + .../LineFragmentTypesetContext.swift | 1 + .../TextLine/Typesetter/TypesetContext.swift | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift index 5be2eae53..7466a9e5d 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/CTLineTypesetData.swift @@ -7,6 +7,7 @@ import AppKit +/// Represents layout information received from a `CTTypesetter` for a `CTLine`. struct CTLineTypesetData { let ctLine: CTLine let descent: CGFloat diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift index 1260a7f0f..b9decb519 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift @@ -7,6 +7,7 @@ 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 diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 29087b0d9..842fc212b 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -7,6 +7,8 @@ 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 @@ -14,11 +16,16 @@ struct TypesetContext { /// Accumulated generated line fragments. var lines: [TextLineStorage.BuildItem] = [] var maxHeight: CGFloat = 0 - var fragmentContext: LineFragmentTypesetContext = .init(start: 0, width: 0.0, height: 0.0, descent: 0.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 { @@ -33,7 +40,12 @@ struct TypesetContext { 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) @@ -44,6 +56,9 @@ struct TypesetContext { 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( From ccf2d7b06e1242df9148c9ca2c47bc6669ea67ee Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 14:57:51 -0500 Subject: [PATCH 20/34] Docs, Make `attachments` a constant --- .../CodeEditTextView/TextLayoutManager/TextLayoutManager.swift | 2 +- .../CodeEditTextView/TextLine/Typesetter/TypesetContext.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index b99d076d9..84195b00c 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -64,7 +64,7 @@ public class TextLayoutManager: NSObject { } } - public var attachments: TextAttachmentManager = TextAttachmentManager() + public let attachments: TextAttachmentManager = TextAttachmentManager() // MARK: - Internal diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 842fc212b..3d8031ea5 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -40,7 +40,7 @@ struct TypesetContext { 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. From 579cd0ae1bb2f4588afcd219afb0cbf52d32fd4a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 15:09:35 -0500 Subject: [PATCH 21/34] Remove String Reference on `Typesetter`. --- .../TextLine/Typesetter/Typesetter.swift | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index e62adb639..f0ca9b555 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -8,6 +8,13 @@ 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 final public class Typesetter { struct ContentRun { let range: NSRange @@ -19,7 +26,6 @@ final public class Typesetter { } } - public var string: NSAttributedString = NSAttributedString(string: "") public var documentRange: NSRange? public var lineFragments = TextLineStorage() @@ -34,15 +40,16 @@ final public class Typesetter { markedRanges: MarkedRanges?, attachments: [AnyTextAttachment] = [] ) { - makeString(string: string, markedRanges: markedRanges) + let string = makeString(string: string, markedRanges: markedRanges) lineFragments.removeAll() // Fast path if string.length == 0 { - typesetEmptyLine(displayData: displayData) + typesetEmptyLine(displayData: displayData, string: string) return } let (lines, maxHeight) = typesetLineFragments( + string: string, documentRange: documentRange, displayData: displayData, attachments: attachments @@ -50,26 +57,31 @@ final public class Typesetter { lineFragments.build(from: lines, estimatedLineHeight: maxHeight) } - private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) { + 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) } - self.string = mutableString - } else { - self.string = string + 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(documentRange: NSRange, attachments: [AnyTextAttachment]) -> [ContentRun] { + func createContentRuns( + string: NSAttributedString, + documentRange: NSRange, + attachments: [AnyTextAttachment] + ) -> [ContentRun] { var attachments = attachments var currentPosition = 0 let maxPosition = documentRange.length @@ -116,11 +128,12 @@ final public class Typesetter { // MARK: - Typeset Content Runs func typesetLineFragments( + string: NSAttributedString, documentRange: NSRange, displayData: TextLine.DisplayData, attachments: [AnyTextAttachment] ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { - let contentRuns = createContentRuns(documentRange: documentRange, attachments: attachments) + let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments) var context = TypesetContext(documentRange: documentRange, displayData: displayData) for run in contentRuns { @@ -130,6 +143,7 @@ final public class Typesetter { case .string(let typesetter): layoutTextUntilLineBreak( context: &context, + string: string, range: run.range, typesetter: typesetter, displayData: displayData @@ -148,6 +162,7 @@ final public class Typesetter { func layoutTextUntilLineBreak( context: inout TypesetContext, + string: NSAttributedString, range: NSRange, typesetter: CTTypesetter, displayData: TextLine.DisplayData @@ -210,8 +225,8 @@ final public class Typesetter { /// Typesets a single, 0-length line fragment. /// - Parameter displayData: Relevant information for layout estimation. - private func typesetEmptyLine(displayData: TextLine.DisplayData) { - let typesetter = CTTypesetterCreateWithAttributedString(self.string) + 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( From fdf2df13bda2266a9c75b0e5e79083d1d74465dc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 15:11:17 -0500 Subject: [PATCH 22/34] Remove Bad `Equatable` Conformance --- .../TextLineStorage/TextLineStorage+Structs.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift index 1a744d376..b022a9b1c 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift @@ -8,7 +8,7 @@ import Foundation extension TextLineStorage where Data: Identifiable { - public struct TextLinePosition: Equatable { + public struct TextLinePosition { init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) { self.data = data self.range = range @@ -35,14 +35,6 @@ extension TextLineStorage where Data: Identifiable { public let height: CGFloat /// The index of the position. public let index: Int - - public static func == (_ lhs: TextLinePosition, _ rhs: TextLinePosition) -> Bool { - lhs.data.id == rhs.data.id && - lhs.range == rhs.range && - lhs.yPos == rhs.yPos && - lhs.height == rhs.height && - lhs.index == rhs.index - } } struct NodePosition { From 40e2a0f58e13bf6b5ed00854027b3c30a6a670c6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 5 May 2025 15:12:21 -0500 Subject: [PATCH 23/34] Remove `Buh` --- Sources/CodeEditTextView/TextView/TextView+Menu.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 6227e8f65..508f0caf6 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -7,15 +7,6 @@ import AppKit -class Buh: TextAttachment { - var width: CGFloat = 100 - - func draw(in context: CGContext, rect: NSRect) { - context.setFillColor(NSColor.red.cgColor) - context.fill(rect) - } -} - extension TextView { override public func menu(for event: NSEvent) -> NSMenu? { guard event.type == .rightMouseDown else { return nil } From a23d19694a3386a532da07888d758059367f81f2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 09:27:54 -0500 Subject: [PATCH 24/34] Update LineFragmentTypesetContext.swift Co-authored-by: Tom Ludwig --- .../TextLine/Typesetter/LineFragmentTypesetContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift index b9decb519..f6bb487b2 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/LineFragmentTypesetContext.swift @@ -7,7 +7,7 @@ import CoreGraphics -/// Represents partial parsing state for typesetting a line fragment.Used once during typesetting and then discarded. +/// 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 From 1c811fd8d9292b49ed0fcd92da44e794304ff424 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 09:28:00 -0500 Subject: [PATCH 25/34] Update TypesetContext.swift Co-authored-by: Tom Ludwig --- .../CodeEditTextView/TextLine/Typesetter/TypesetContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 3d8031ea5..9f0f713d9 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -7,7 +7,7 @@ import Foundation -/// Represents partial parsing state for typesetting a line.Used once during typesetting and then discarded. +/// 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 From 85cf92db2dd49b457807c5df5a11087c8586f1d7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 10:39:34 -0500 Subject: [PATCH 26/34] FIx Infinite Loop When Zero-Width --- .../CodeEditTextViewExample.xcscheme | 78 +++++++++++++++++++ .../TextLayoutManager+Public.swift | 2 +- .../TextLine/LineFragment.swift | 6 +- .../TextLine/Typesetter/Typesetter.swift | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/xcshareddata/xcschemes/CodeEditTextViewExample.xcscheme 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/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index fd4eda8e5..22327aeca 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -134,7 +134,7 @@ extension TextLayoutManager { point: CGPoint, inLine linePosition: TextLineStorage.TextLinePosition ) -> Int? { - guard let (content, contentPosition) = fragment.findContent(atX: point.x) else { + guard let (content, contentPosition) = fragment.findContent(atX: point.x - edgeInsets.left) else { return nil } switch content.data { diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 6ace0b051..1c777bcf5 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -17,10 +17,10 @@ public final class LineFragment: Identifiable, Equatable { case attachment(attachment: AnyTextAttachment) } - let data: Content - let width: CGFloat + public let data: Content + public let width: CGFloat - var length: Int { + public var length: Int { switch data { case .text(let line): CTLineGetStringRange(line).length diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index f0ca9b555..952563c4e 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -44,7 +44,7 @@ final public class Typesetter { lineFragments.removeAll() // Fast path - if string.length == 0 { + if string.length == 0 || displayData.maxWidth <= 0 { typesetEmptyLine(displayData: displayData, string: string) return } From 2486baa0ce7b48a34dacabf451c08157d6653e28 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 10:50:34 -0500 Subject: [PATCH 27/34] Document Iterator Structs, Recursion Depth Limit --- .../TextLayoutManager+Iterator.swift | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 24ab168f6..be0e4025f 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -32,11 +32,20 @@ public extension TextLayoutManager { 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 @@ -72,6 +81,11 @@ public extension TextLayoutManager { } } + /// 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 @@ -139,12 +153,25 @@ public extension TextLayoutManager { for originalPosition: TextLineStorage.TextLinePosition? ) -> (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange)? { guard let originalPosition else { return nil } - return determineVisiblePosition(for: (originalPosition, originalPosition.index...originalPosition.index)) + return determineVisiblePositionRecursively( + for: (originalPosition, originalPosition.index...originalPosition.index), + recursionDepth: 0 + ) } - func determineVisiblePosition( - for originalPosition: (position: TextLineStorage.TextLinePosition, indexRange: ClosedRange) + /// 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.get(overlapping: 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. @@ -184,7 +211,10 @@ public extension TextLayoutManager { return (newPosition, minIndex...maxIndex) } else { // Recurse, to make sure we combine all necessary lines. - return determineVisiblePosition(for: (newPosition, minIndex...maxIndex)) + return determineVisiblePositionRecursively( + for: (newPosition, minIndex...maxIndex), + recursionDepth: recursionDepth + 1 + ) } } } From c5c7e46ee856daf2ac5b545ea23033309a5f81d7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 10:51:56 -0500 Subject: [PATCH 28/34] Remove Date Doc Comments --- .../LayoutManager/TextLayoutManagerTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 07f144f3b..dc34b6e25 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -102,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 @@ -132,7 +131,6 @@ struct TextLayoutManagerTests { layoutManager.lineStorage.validateInternalState() } - /// # 05/05/25 /// 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. From ac763fb7eca2e61bd8f19542dec77b47e68a7f34 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 10:52:38 -0500 Subject: [PATCH 29/34] Comment Too Long --- .../TextLayoutManager/TextLayoutManager+Iterator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index be0e4025f..90e946528 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -161,7 +161,8 @@ public extension TextLayoutManager { /// Private implementation of ``TextLayoutManager/determineVisiblePosition(for:)``. /// - /// Separated for readability. This method does not have an optional parameter, and keeps track of a recursion depth. + /// 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 From 1090c23d18729ab77c21c8ca499c1e6987270443 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 10:53:49 -0500 Subject: [PATCH 30/34] Finish Cutoff Doc Comment --- Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 952563c4e..10c6d244e 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -14,7 +14,7 @@ import CoreText /// 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 +/// To retrieve the line fragments generated by this class, access the ``lineFragments`` property. final public class Typesetter { struct ContentRun { let range: NSRange From ef4e68ff32fc70705833ca5b09ad2ca109198f90 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 11:45:08 -0500 Subject: [PATCH 31/34] Move Unnecessary NSAttributedString Extension --- .../Documents/CodeEditTextViewExampleDocument.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift index 88efa7329..47a86c96c 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift @@ -33,19 +33,13 @@ struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable { } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let data = try text.data(for: NSRange(location: 0, length: text.length)) - return .init(regularFileWithContents: data) - } -} - -extension NSAttributedString { - func data(for range: NSRange) throws -> Data { - try data( - from: range, + let data = try text.data( + from: NSRange(location: 0, length: text.length), documentAttributes: [ .documentType: NSAttributedString.DocumentType.plain, .characterEncoding: NSUTF8StringEncoding ] ) + return .init(regularFileWithContents: data) } } From 2163fae1f9de596262a17b743e6ac816508f04dc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 11:57:38 -0500 Subject: [PATCH 32/34] Rename Similar Method Names For Clarity --- .../TextAttachmentManager.swift | 27 ++++++++++++------- .../TextLayoutManager+Iterator.swift | 2 +- .../TextLayoutManager+Layout.swift | 4 +-- ...lectionManager+SelectionManipulation.swift | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 895e6c875..5bad2de1e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -31,22 +31,26 @@ public final class TextAttachmentManager { layoutManager?.setNeedsLayout() } - public func remove(atOffset offset: Int) { + /// 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 { - assertionFailure("No attachment found at offset \(offset)") - return + 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 get(startingIn range: NSRange) -> [AnyTextAttachment] { + public func getAttachmentsStartingIn(_ range: NSRange) -> [AnyTextAttachment] { var results: [AnyTextAttachment] = [] var idx = findInsertionIndex(for: range.location) while idx < orderedAttachments.count { @@ -69,11 +73,11 @@ public final class TextAttachmentManager { /// Returns all attachments whose ranges overlap the given query range. /// - /// - Parameter query: The `NSRange` to test for overlap. + /// - Parameter range: The `NSRange` to test for overlap. /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. - public func get(overlapping query: NSRange) -> [AnyTextAttachment] { + 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 > query.location }) else { + guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else { return [] } @@ -83,10 +87,10 @@ public final class TextAttachmentManager { // Collect every subsequent attachment that truly overlaps the query. while idx < orderedAttachments.count { let attachment = orderedAttachments[idx] - if attachment.range.location >= query.upperBound { + if attachment.range.location >= range.upperBound { break } - if NSIntersectionRange(attachment.range, query).length > 0, + if attachment.range.intersection(range)?.length ?? 0 > 0, results.last?.range != attachment.range { results.append(attachment) } @@ -96,6 +100,11 @@ public final class TextAttachmentManager { 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) { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 90e946528..4e5efede5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -173,7 +173,7 @@ public extension TextLayoutManager { return originalPosition } - let attachments = attachments.get(overlapping: originalPosition.position.range) + 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 diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index eb84195b4..cdbc2704d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -193,7 +193,7 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - attachments: attachments.get(startingIn: position.range) + attachments: attachments.getAttachmentsStartingIn(position.range) ) } else { line.prepareForDisplay( @@ -201,7 +201,7 @@ extension TextLayoutManager { range: position.range, stringRef: textStorage, markedRanges: markedTextManager.markedRanges(in: position.range), - attachments: attachments.get(startingIn: position.range) + attachments: attachments.getAttachmentsStartingIn(position.range) ) } diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift index 5f203c8c7..eb7e8a349 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift @@ -59,7 +59,7 @@ public extension TextSelectionManager { } // Extend ranges to include attachments. - if let attachments = layoutManager?.attachments.get(overlapping: range) { + if let attachments = layoutManager?.attachments.getAttachmentsOverlapping(range) { attachments.forEach { textAttachment in range.formUnion(textAttachment.range) } From b335af726c3844642fe13e887270f633209143a4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 12:00:12 -0500 Subject: [PATCH 33/34] Update Tests Target After Rename --- .../LayoutManager/TextLayoutManagerAttachmentsTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index f47e52567..a3510c608 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -26,9 +26,9 @@ struct TextLayoutManagerAttachmentsTests { @Test func addAndGetAttachments() throws { layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 2, end: 8)) - #expect(layoutManager.attachments.get(overlapping: textView.documentRange).count == 1) - #expect(layoutManager.attachments.get(overlapping: NSRange(start: 0, end: 3)).count == 1) - #expect(layoutManager.attachments.get(startingIn: NSRange(start: 0, end: 3)).count == 1) + #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 From 3645aab6e7dcc696c2e4d588d7bb1112d478dab4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 13:50:06 -0500 Subject: [PATCH 34/34] Fix Small Positioning Bug --- .../TextLayoutManager/TextLayoutManager+Public.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 22327aeca..6a1a4df61 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -79,7 +79,7 @@ extension TextLayoutManager { if fragment.width == 0 { return linePosition.range.location + fragmentPosition.range.location - } else if fragment.width < point.x - edgeInsets.left { + } else if fragment.width <= point.x - edgeInsets.left { return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition) } else { return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition)