Skip to content

Commit c045ffc

Browse files
Draw Invisible Characters From Configuration (#103)
### Description Adds the ability for developers to pass in an object to determine invisible character drawing. This is intentionally not doing the character matching and replacement styling in this package. To achieve the flexibility we want, I've raised that to the level of the source editor which can for instance determine if we want to draw a dot with larger emphasis because it's on a tab stop. #### Detailed changes: - Moved all line fragment drawing to a new object `LineFragmentRenderer`. Line fragments were increasingly requiring more and more objects to be passed to them. This object centralizes those dependencies into one object. - Adds a new `InvisibleCharactersDelegate` protocol for API consumers to conform to. Consumers can provide a set of characters to match on, and a method to provide a 'style' to draw them with. - Makes a slight adjustment to how cursors and line fragment views are added to the view hierarchy. - Cursors are now placed at the top of the subview stack, ensuring they're always on top of the content. - Line fragments are now placed on the bottom of the subview stack, ensuring they're always drawn under cursors. ### Related Issues * #22 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Fleshed out example with CESE replacing tabs with arrows, spaces with dots, emphasizing a zero-width space (​), and replacing newlines with the (¬) character. ![Screenshot 2025-06-10 at 10 55 04 AM](https://github.com/user-attachments/assets/409e0cd0-5eb5-4f1d-8755-5a1a222d0169)
1 parent 9eb70fc commit c045ffc

File tree

10 files changed

+344
-53
lines changed

10 files changed

+344
-53
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// InvisibleCharactersConfig.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 6/9/25.
6+
//
7+
8+
import Foundation
9+
import AppKit
10+
11+
public enum InvisibleCharacterStyle: Hashable {
12+
case replace(replacementCharacter: String, color: NSColor, font: NSFont)
13+
case emphasize(color: NSColor)
14+
}
15+
16+
public protocol InvisibleCharactersDelegate: AnyObject {
17+
var triggerCharacters: Set<UInt16> { get }
18+
func invisibleStyleShouldClearCache() -> Bool
19+
func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle?
20+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,9 @@ extension TextLayoutManager {
251251
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
252252
}
253253
view.translatesAutoresizingMaskIntoConstraints = false
254-
view.setLineFragment(lineFragment.data)
254+
view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer)
255255
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
256-
layoutView?.addSubview(view)
256+
layoutView?.addSubview(view, positioned: .below, relativeTo: nil)
257257
view.needsDisplay = true
258258
}
259259
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,21 @@ public class TextLayoutManager: NSObject {
6666

6767
public let attachments: TextAttachmentManager = TextAttachmentManager()
6868

69+
public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? {
70+
didSet {
71+
lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate
72+
layoutView?.needsDisplay = true
73+
}
74+
}
75+
6976
// MARK: - Internal
7077

7178
weak var textStorage: NSTextStorage?
7279
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
7380
var markedTextManager: MarkedTextManager = MarkedTextManager()
7481
let viewReuseQueue: ViewReuseQueue<LineFragmentView, LineFragment.ID> = ViewReuseQueue()
82+
let lineFragmentRenderer: LineFragmentRenderer
83+
7584
package var visibleLineIds: Set<TextLine.ID> = []
7685
/// Used to force a complete re-layout using `setNeedsLayout`
7786
package var needsLayout: Bool = false
@@ -122,14 +131,20 @@ public class TextLayoutManager: NSObject {
122131
wrapLines: Bool,
123132
textView: NSView,
124133
delegate: TextLayoutManagerDelegate?,
125-
renderDelegate: TextLayoutManagerRenderDelegate? = nil
134+
renderDelegate: TextLayoutManagerRenderDelegate? = nil,
135+
invisibleCharacterDelegate: InvisibleCharactersDelegate? = nil
126136
) {
127137
self.textStorage = textStorage
128138
self.lineHeightMultiplier = lineHeightMultiplier
129139
self.wrapLines = wrapLines
130140
self.layoutView = textView
131141
self.delegate = delegate
132142
self.renderDelegate = renderDelegate
143+
self.lineFragmentRenderer = LineFragmentRenderer(
144+
textStorage: textStorage,
145+
invisibleCharacterDelegate: invisibleCharacterDelegate
146+
)
147+
self.invisibleCharacterDelegate = invisibleCharacterDelegate
133148
super.init()
134149
prepareTextLines()
135150
attachments.layoutManager = self
@@ -166,6 +181,7 @@ public class TextLayoutManager: NSObject {
166181
viewReuseQueue.usedViews.removeAll()
167182
maxLineWidth = 0
168183
markedTextManager.removeAll()
184+
lineFragmentRenderer.textStorage = textStorage
169185
prepareTextLines()
170186
setNeedsLayout()
171187
}

Sources/CodeEditTextView/TextLine/LineFragment.swift

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public final class LineFragment: Identifiable, Equatable {
4747
}
4848

4949
public let id = UUID()
50+
public let lineRange: NSRange
5051
public let documentRange: NSRange
5152
public var contents: [FragmentContent]
5253
public var width: CGFloat
@@ -60,13 +61,15 @@ public final class LineFragment: Identifiable, Equatable {
6061
}
6162

6263
init(
64+
lineRange: NSRange,
6365
documentRange: NSRange,
6466
contents: [FragmentContent],
6567
width: CGFloat,
6668
height: CGFloat,
6769
descent: CGFloat,
6870
lineHeightMultiplier: CGFloat
6971
) {
72+
self.lineRange = lineRange
7073
self.documentRange = documentRange
7174
self.contents = contents
7275
self.width = width
@@ -102,52 +105,6 @@ public final class LineFragment: Identifiable, Equatable {
102105
}
103106
}
104107

105-
public func draw(in context: CGContext, yPos: CGFloat) {
106-
context.saveGState()
107-
108-
// Removes jagged edges
109-
context.setAllowsAntialiasing(true)
110-
context.setShouldAntialias(true)
111-
112-
// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
113-
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
114-
// in low-contrast settings.
115-
context.setAllowsFontSubpixelPositioning(true)
116-
context.setShouldSubpixelPositionFonts(true)
117-
118-
// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
119-
// quality bitmaps and performance.
120-
context.setAllowsFontSubpixelQuantization(true)
121-
context.setShouldSubpixelQuantizeFonts(true)
122-
123-
ContextSetHiddenSmoothingStyle(context, 16)
124-
125-
context.textMatrix = .init(scaleX: 1, y: -1)
126-
127-
var currentPosition: CGFloat = 0.0
128-
var currentLocation = 0
129-
for content in contents {
130-
context.saveGState()
131-
switch content.data {
132-
case .text(let ctLine):
133-
context.textPosition = CGPoint(
134-
x: currentPosition,
135-
y: yPos + height - descent + (heightDifference/2)
136-
).pixelAligned
137-
CTLineDraw(ctLine, context)
138-
case .attachment(let attachment):
139-
attachment.attachment.draw(
140-
in: context,
141-
rect: NSRect(x: currentPosition, y: yPos, width: attachment.width, height: scaledHeight)
142-
)
143-
}
144-
context.restoreGState()
145-
currentPosition += content.width
146-
currentLocation += content.length
147-
}
148-
context.restoreGState()
149-
}
150-
151108
package func findContent(at location: Int) -> (content: FragmentContent, position: ContentPosition)? {
152109
var position = ContentPosition(xPos: 0, offset: 0)
153110

0 commit comments

Comments
 (0)