Skip to content

Draw Invisible Characters From Configuration #103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// 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<UInt16> { get }
func invisibleStyleShouldClearCache() -> Bool
func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextLine> = TextLineStorage()
var markedTextManager: MarkedTextManager = MarkedTextManager()
let viewReuseQueue: ViewReuseQueue<LineFragmentView, LineFragment.ID> = ViewReuseQueue()
let lineFragmentRenderer: LineFragmentRenderer

package var visibleLineIds: Set<TextLine.ID> = []
/// Used to force a complete re-layout using `setNeedsLayout`
package var needsLayout: Bool = false
Expand Down Expand Up @@ -122,14 +131,20 @@ 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
self.wrapLines = wrapLines
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
Expand Down Expand Up @@ -166,6 +181,7 @@ public class TextLayoutManager: NSObject {
viewReuseQueue.usedViews.removeAll()
maxLineWidth = 0
markedTextManager.removeAll()
lineFragmentRenderer.textStorage = textStorage
prepareTextLines()
setNeedsLayout()
}
Expand Down
49 changes: 3 additions & 46 deletions Sources/CodeEditTextView/TextLine/LineFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,13 +61,15 @@ public final class LineFragment: Identifiable, Equatable {
}

init(
lineRange: NSRange,
documentRange: NSRange,
contents: [FragmentContent],
width: CGFloat,
height: CGFloat,
descent: CGFloat,
lineHeightMultiplier: CGFloat
) {
self.lineRange = lineRange
self.documentRange = documentRange
self.contents = contents
self.width = width
Expand Down Expand Up @@ -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)

Expand Down
Loading