From 9b38030f7af6dd5b996b0c957a8bbe801cefa4d4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:55:16 -0500 Subject: [PATCH 1/2] Draw InvisibleCharacters From Configuration --- .../InvisibleCharactersDelegate.swift | 19 ++ .../TextLayoutManager+Layout.swift | 4 +- .../TextLayoutManager/TextLayoutManager.swift | 18 +- .../TextLine/LineFragment.swift | 49 +-- .../TextLine/LineFragmentRenderer.swift | 286 ++++++++++++++++++ .../TextLine/LineFragmentView.swift | 6 +- .../TextLine/Typesetter/TypesetContext.swift | 1 + .../TextLine/Typesetter/Typesetter.swift | 1 + .../TextSelectionManager.swift | 2 +- .../TextView/DraggingTextRenderer.swift | 6 +- 10 files changed, 339 insertions(+), 53 deletions(-) create mode 100644 Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift create mode 100644 Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift diff --git a/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift new file mode 100644 index 000000000..c32b97237 --- /dev/null +++ b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift @@ -0,0 +1,19 @@ +// +// InvisibleCharactersConfig.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/9/25. +// + +import Foundation +import AppKit + +public enum InvisibleCharacterStyle: Hashable { + case replace(replacementCharacter: String, color: NSColor, font: NSFont) + case emphasize(color: NSColor) +} + +public protocol InvisibleCharactersDelegate: AnyObject { + var triggerCharacters: Set { get } + func invisibleStyle(for character: Character, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 042622830..d7732e37c 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -251,9 +251,9 @@ extension TextLayoutManager { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } view.translatesAutoresizingMaskIntoConstraints = false - view.setLineFragment(lineFragment.data) + view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) - layoutView?.addSubview(view) + layoutView?.addSubview(view, positioned: .below, relativeTo: nil) view.needsDisplay = true } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 84195b00c..0985f53d7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -66,12 +66,21 @@ public class TextLayoutManager: NSObject { public let attachments: TextAttachmentManager = TextAttachmentManager() + public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? { + didSet { + lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate + layoutView?.needsDisplay = true + } + } + // MARK: - Internal weak var textStorage: NSTextStorage? var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() + let lineFragmentRenderer: LineFragmentRenderer + package var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` package var needsLayout: Bool = false @@ -122,7 +131,8 @@ public class TextLayoutManager: NSObject { wrapLines: Bool, textView: NSView, delegate: TextLayoutManagerDelegate?, - renderDelegate: TextLayoutManagerRenderDelegate? = nil + renderDelegate: TextLayoutManagerRenderDelegate? = nil, + invisibleCharacterDelegate: InvisibleCharactersDelegate? = nil ) { self.textStorage = textStorage self.lineHeightMultiplier = lineHeightMultiplier @@ -130,6 +140,11 @@ public class TextLayoutManager: NSObject { self.layoutView = textView self.delegate = delegate self.renderDelegate = renderDelegate + self.lineFragmentRenderer = LineFragmentRenderer( + textStorage: textStorage, + invisibleCharacterDelegate: invisibleCharacterDelegate + ) + self.invisibleCharacterDelegate = invisibleCharacterDelegate super.init() prepareTextLines() attachments.layoutManager = self @@ -166,6 +181,7 @@ public class TextLayoutManager: NSObject { viewReuseQueue.usedViews.removeAll() maxLineWidth = 0 markedTextManager.removeAll() + lineFragmentRenderer.textStorage = textStorage prepareTextLines() setNeedsLayout() } diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 1c777bcf5..6671fb8ef 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -47,6 +47,7 @@ public final class LineFragment: Identifiable, Equatable { } public let id = UUID() + public let lineRange: NSRange public let documentRange: NSRange public var contents: [FragmentContent] public var width: CGFloat @@ -60,6 +61,7 @@ public final class LineFragment: Identifiable, Equatable { } init( + lineRange: NSRange, documentRange: NSRange, contents: [FragmentContent], width: CGFloat, @@ -67,6 +69,7 @@ public final class LineFragment: Identifiable, Equatable { descent: CGFloat, lineHeightMultiplier: CGFloat ) { + self.lineRange = lineRange self.documentRange = documentRange self.contents = contents self.width = width @@ -102,52 +105,6 @@ public final class LineFragment: Identifiable, Equatable { } } - public func draw(in context: CGContext, yPos: CGFloat) { - context.saveGState() - - // Removes jagged edges - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - - // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than - // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness - // in low-contrast settings. - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - - // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher - // quality bitmaps and performance. - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - - ContextSetHiddenSmoothingStyle(context, 16) - - context.textMatrix = .init(scaleX: 1, y: -1) - - 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() - } - package func findContent(at location: Int) -> (content: FragmentContent, position: ContentPosition)? { var position = ContentPosition(xPos: 0, offset: 0) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift new file mode 100644 index 000000000..71710919d --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -0,0 +1,286 @@ +// +// LineFragmentRenderer.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/10/25. +// + +import AppKit +import CodeEditTextViewObjC + +/// Manages drawing line fragments into a drawing context. +public final class LineFragmentRenderer { + private struct CacheKey: Hashable { + let string: String + let font: NSFont + let color: NSColor + } + + private struct InvisibleDrawingContext { + let lineFragment: LineFragment + let ctLine: CTLine + let contentOffset: Int + let position: CGPoint + let context: CGContext + } + + weak var textStorage: NSTextStorage? + weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? + private var attributedStringCache: [CacheKey: CTLine] = [:] + + /// Create a fragment renderer. + /// - Parameters: + /// - textStorage: The text storage backing the fragments being drawn. + /// - invisibleCharacterDelegate: A delegate object to interrogate for invisible character drawing. + public init(textStorage: NSTextStorage?, invisibleCharacterDelegate: InvisibleCharactersDelegate?) { + self.textStorage = textStorage + self.invisibleCharacterDelegate = invisibleCharacterDelegate + } + + /// Draw the given line fragment into a drawing context, using the invisible character configuration determined + /// from the ``invisibleCharacterDelegate``, and line fragment information from the passed ``LineFragment`` object. + /// - Parameters: + /// - lineFragment: The line fragment to drawn + /// - context: The drawing context to draw into. + /// - yPos: In the drawing context, what `y` position to start drawing at. + public func draw(lineFragment: LineFragment, in context: CGContext, yPos: CGFloat) { + context.saveGState() + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + ContextSetHiddenSmoothingStyle(context, 16) + + context.textMatrix = .init(scaleX: 1, y: -1) + + var currentPosition: CGFloat = 0.0 + var currentLocation = 0 + for content in lineFragment.contents { + context.saveGState() + switch content.data { + case .text(let ctLine): + context.textPosition = CGPoint( + x: currentPosition, + y: yPos + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ).pixelAligned + CTLineDraw(ctLine, context) + + drawInvisibles( + lineFragment: lineFragment, + for: ctLine, + contentOffset: currentLocation, + position: CGPoint(x: currentPosition, y: yPos), + in: context + ) + case .attachment(let attachment): + attachment.attachment.draw( + in: context, + rect: NSRect( + x: currentPosition, + y: yPos, + width: attachment.width, + height: lineFragment.scaledHeight + ) + ) + } + context.restoreGState() + currentPosition += content.width + currentLocation += content.length + } + context.restoreGState() + } + + private func drawInvisibles( + lineFragment: LineFragment, + for ctLine: CTLine, + contentOffset: Int, + position: CGPoint, + in context: CGContext + ) { + guard let textStorage, let invisibleCharacterDelegate else { return } + + let drawingContext = InvisibleDrawingContext( + lineFragment: lineFragment, + ctLine: ctLine, + contentOffset: contentOffset, + position: position, + context: context + ) + + let range = createTextRange(for: drawingContext) + let string = (textStorage.string as NSString).substring(with: range) + + processInvisibleCharacters( + in: string, + range: range, + delegate: invisibleCharacterDelegate, + drawingContext: drawingContext + ) + } + + private func createTextRange(for drawingContext: InvisibleDrawingContext) -> NSRange { + return NSRange( + start: drawingContext.lineFragment.documentRange.location + drawingContext.contentOffset, + end: drawingContext.lineFragment.documentRange.max + ) + } + + private func processInvisibleCharacters( + in string: String, + range: NSRange, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + drawingContext.context.saveGState() + defer { drawingContext.context.restoreGState() } + + lazy var offset = CTLineGetStringRange(drawingContext.ctLine).location + + for (idx, character) in string.enumerated() + where delegate.triggerCharacters.contains(character) { + processInvisibleCharacter( + character: character, + at: idx, + in: range, + offset: offset, + delegate: delegate, + drawingContext: drawingContext + ) + } + } + + // Disabling the next lint warning because I *cannot* figure out how to split this up further. + + private func processInvisibleCharacter( // swiftlint:disable:this function_parameter_count + character: Character, + at index: Int, + in range: NSRange, + offset: Int, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + guard let style = delegate.invisibleStyle( + for: character, + at: NSRange(start: range.location + index, end: range.max), + lineRange: drawingContext.lineFragment.lineRange + ) else { + return + } + + let xOffset = CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + index, nil) + + switch style { + case let .replace(replacementCharacter, color, font): + drawReplacementCharacter( + replacementCharacter, + color: color, + font: font, + at: calculateReplacementPosition( + basePosition: drawingContext.position, + xOffset: xOffset, + lineFragment: drawingContext.lineFragment + ), + in: drawingContext.context + ) + case let .emphasize(color): + let emphasizeRect = calculateEmphasisRect( + basePosition: drawingContext.position, + xOffset: xOffset, + characterIndex: index, + offset: offset, + drawingContext: drawingContext + ) + + drawEmphasis( + color: color, + forRect: emphasizeRect, + in: drawingContext.context + ) + } + } + + private func calculateReplacementPosition( + basePosition: CGPoint, + xOffset: CGFloat, + lineFragment: LineFragment + ) -> CGPoint { + return CGPoint( + x: basePosition.x + xOffset, + y: basePosition.y + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ) + } + + private func calculateEmphasisRect( + basePosition: CGPoint, + xOffset: CGFloat, + characterIndex: Int, + offset: Int, + drawingContext: InvisibleDrawingContext + ) -> NSRect { + let xEndOffset = if offset + characterIndex + 1 == drawingContext.lineFragment.documentRange.length { + drawingContext.lineFragment.width + } else { + CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + characterIndex + 1, nil) + } + + return NSRect( + x: basePosition.x + xOffset, + y: basePosition.y, + width: xEndOffset - xOffset, + height: drawingContext.lineFragment.scaledHeight + ) + } + + private func drawReplacementCharacter( + _ replacementCharacter: String, + color: NSColor, + font: NSFont, + at position: CGPoint, + in context: CGContext + ) { + let cacheKey = CacheKey(string: replacementCharacter, font: font, color: color) + let ctLine: CTLine + if let cachedValue = attributedStringCache[cacheKey] { + ctLine = cachedValue + } else { + let attrString = NSAttributedString(string: replacementCharacter, attributes: [ + .font: font, + .foregroundColor: color + ]) + ctLine = CTLineCreateWithAttributedString(attrString) + attributedStringCache[cacheKey] = ctLine + } + context.textPosition = position + CTLineDraw(ctLine, context) + } + + private func drawEmphasis( + color: NSColor, + forRect: NSRect, + in context: CGContext + ) { + context.setFillColor(color.cgColor) + + let rect: CGRect + + if forRect.width == 0 { + // Zero-width character, add padding + rect = CGRect(x: forRect.origin.x - 2, y: forRect.origin.y, width: 4, height: forRect.height) + } else { + rect = forRect + } + + context.fill(rect) + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 89b9141dc..58a793306 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -10,6 +10,7 @@ import AppKit /// Displays a line fragment. open class LineFragmentView: NSView { public weak var lineFragment: LineFragment? + public weak var renderer: LineFragmentRenderer? open override var isFlipped: Bool { true @@ -29,8 +30,9 @@ open class LineFragmentView: NSView { /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. - open func setLineFragment(_ newFragment: LineFragment) { + open func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) { self.lineFragment = newFragment + self.renderer = renderer self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) } @@ -40,6 +42,6 @@ open class LineFragmentView: NSView { return } - lineFragment.draw(in: context, yPos: 0.0) + renderer?.draw(lineFragment: lineFragment, in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 9f0f713d9..e74af78a9 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -61,6 +61,7 @@ struct TypesetContext { /// Pop the current fragment state into a new line fragment, and reset the fragment state. mutating func popCurrentData() { let fragment = LineFragment( + lineRange: documentRange, documentRange: NSRange( location: fragmentContext.start + documentRange.location, length: currentPosition - fragmentContext.start diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 10c6d244e..2bebfb69d 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -230,6 +230,7 @@ final public class Typesetter { // Insert an empty fragment let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) let fragment = LineFragment( + lineRange: documentRange ?? .zero, documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0), contents: [.init(data: .text(line: ctLine), width: 0.0)], width: 0, diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 28e8801a2..deff69375 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -179,7 +179,7 @@ public class TextSelectionManager: NSObject { cursorTimer.register(internalCursorView) } - textView?.addSubview(cursorView) + textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) } cursorView.frame.origin = cursorRect.origin diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift index b8680f443..966b83b8d 100644 --- a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -70,13 +70,17 @@ class DraggingTextRenderer: NSView { yOffset: CGFloat, context: CGContext ) { + let renderer = LineFragmentRenderer( + textStorage: layoutManager.textStorage, + invisibleCharacterDelegate: layoutManager.invisibleCharacterDelegate + ) for fragment in line.data.lineFragments { guard let fragmentRange = fragment.range.shifted(by: line.range.location), fragmentRange.intersection(selectedRange) != nil else { continue } let fragmentYPos = line.yPos + fragment.yPos - yOffset - fragment.data.draw(in: context, yPos: fragmentYPos) + renderer.draw(lineFragment: fragment.data, in: context, yPos: fragmentYPos) // Clear text that's not selected if fragmentRange.contains(selectedRange.lowerBound) { From 79d71b5cf5ac0464a2ae24f66b5be35d7b494d2d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:16:51 -0500 Subject: [PATCH 2/2] Use Unichars --- .../InvisibleCharacters/InvisibleCharactersDelegate.swift | 5 +++-- .../CodeEditTextView/TextLine/LineFragmentRenderer.swift | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift index c32b97237..9b7fa9d75 100644 --- a/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift +++ b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift @@ -14,6 +14,7 @@ public enum InvisibleCharacterStyle: Hashable { } public protocol InvisibleCharactersDelegate: AnyObject { - var triggerCharacters: Set { get } - func invisibleStyle(for character: Character, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? + var triggerCharacters: Set { get } + func invisibleStyleShouldClearCache() -> Bool + func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift index 71710919d..5225123d8 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -44,6 +44,10 @@ public final class LineFragmentRenderer { /// - context: The drawing context to draw into. /// - yPos: In the drawing context, what `y` position to start drawing at. public func draw(lineFragment: LineFragment, in context: CGContext, yPos: CGFloat) { + if invisibleCharacterDelegate?.invisibleStyleShouldClearCache() == true { + attributedStringCache.removeAll(keepingCapacity: true) + } + context.saveGState() // Removes jagged edges context.setAllowsAntialiasing(true) @@ -147,7 +151,7 @@ public final class LineFragmentRenderer { lazy var offset = CTLineGetStringRange(drawingContext.ctLine).location - for (idx, character) in string.enumerated() + for (idx, character) in string.utf16.enumerated() where delegate.triggerCharacters.contains(character) { processInvisibleCharacter( character: character, @@ -163,7 +167,7 @@ public final class LineFragmentRenderer { // Disabling the next lint warning because I *cannot* figure out how to split this up further. private func processInvisibleCharacter( // swiftlint:disable:this function_parameter_count - character: Character, + character: UInt16, at index: Int, in range: NSRange, offset: Int,