Skip to content

Commit 47faec9

Browse files
Fix EmphasisAPI Leaking Layers, Emphasis Drawing (#79)
### Description - Fixes an emphasis manager bug where it could leak layers without removing their `CALayer`s when replacing flash emphases with other emphases. - Updates the emphasis drawing code to correctly draw underlines. - Updates the `roundedPathForRange` to correctly handle emphases that span multiple line fragments. While fixing the last point, I discovered that the emphasis manager doesn't correctly draw the text on emphases that span multiple line fragments. This makes sense, as right now it's just using a CATextLayer that has no knowledge of line breaks. In the future, that text layer should be replaced by a custom layer that draws the text using knowledge of existing line fragments positions. However, I think the issues this PR fixes are too important to wait until that can be done. Also note that that change will be critical to drag and drop, so it will absolutely be done and I have a branch somewhere with some of that work already completed. ### Related Issues * CodeEditApp/CodeEditSourceEditor#295 ### 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 Screen recording showing the text layer bug mentioned above, as well as the fixed multi-fragment drawing, and the fixed leak. Prior to this, when switching from flash to any other emphasis type, there was a good chance one of the new emphases would be kept. This was due to the emphasis manager assuming that the emphasis being flashed was kept around after the animation delay. This has been fixed in this PR. https://github.com/user-attachments/assets/a29048e4-ab81-4376-abe0-e0499e448ea8
1 parent 02202a8 commit 47faec9

File tree

8 files changed

+240
-132
lines changed

8 files changed

+240
-132
lines changed

Sources/CodeEditTextView/EmphasisManager/Emphasis.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import AppKit
99

1010
/// Represents a single emphasis with its properties
11-
public struct Emphasis {
11+
public struct Emphasis: Equatable {
1212
/// The range the emphasis applies it's style to, relative to the entire text document.
1313
public let range: NSRange
1414

Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift

Lines changed: 82 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ import AppKit
2020
/// each outside object without knowledge of the other's state.
2121
public final class EmphasisManager {
2222
/// Internal representation of a emphasis layer with its associated text layer
23-
private struct EmphasisLayer {
23+
private struct EmphasisLayer: Equatable {
2424
let emphasis: Emphasis
2525
let layer: CAShapeLayer
2626
let textLayer: CATextLayer?
27+
28+
func removeLayers() {
29+
layer.removeAllAnimations()
30+
layer.removeFromSuperlayer()
31+
textLayer?.removeAllAnimations()
32+
textLayer?.removeFromSuperlayer()
33+
}
2734
}
2835

2936
private var emphasisGroups: [String: [EmphasisLayer]] = [:]
@@ -37,6 +44,8 @@ public final class EmphasisManager {
3744
self.textView = textView
3845
}
3946

47+
// MARK: - Add, Update, Remove
48+
4049
/// Adds a single emphasis to the specified group.
4150
/// - Parameters:
4251
/// - emphasis: The emphasis to add
@@ -56,24 +65,27 @@ public final class EmphasisManager {
5665
}
5766

5867
let layers = emphases.map { createEmphasisLayer(for: $0) }
59-
emphasisGroups[id] = layers
60-
68+
emphasisGroups[id, default: []].append(contentsOf: layers)
6169
// Handle selections
6270
handleSelections(for: emphases)
6371

6472
// Handle flash animations
65-
for (index, emphasis) in emphases.enumerated() where emphasis.flash {
66-
let layer = layers[index]
73+
for flashingLayer in emphasisGroups[id, default: []].filter({ $0.emphasis.flash }) {
6774
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
6875
guard let self = self else { return }
69-
self.applyFadeOutAnimation(to: layer.layer, textLayer: layer.textLayer)
70-
// Remove the emphasis from the group
71-
if var emphases = self.emphasisGroups[id] {
72-
emphases.remove(at: index)
73-
if emphases.isEmpty {
76+
self.applyFadeOutAnimation(to: flashingLayer.layer, textLayer: flashingLayer.textLayer) {
77+
// Remove the emphasis from the group if it still exists
78+
guard let emphasisIdx = self.emphasisGroups[id, default: []].firstIndex(
79+
where: { $0 == flashingLayer }
80+
) else {
81+
return
82+
}
83+
84+
self.emphasisGroups[id, default: []][emphasisIdx].removeLayers()
85+
self.emphasisGroups[id, default: []].remove(at: emphasisIdx)
86+
87+
if self.emphasisGroups[id, default: []].isEmpty {
7488
self.emphasisGroups.removeValue(forKey: id)
75-
} else {
76-
self.emphasisGroups[id] = emphases
7789
}
7890
}
7991
}
@@ -94,30 +106,28 @@ public final class EmphasisManager {
94106
/// - id: The group identifier
95107
/// - transform: The transformation to apply to the existing emphases
96108
public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) {
97-
guard let existingLayers = emphasisGroups[id] else { return }
98-
let existingEmphases = existingLayers.map { $0.emphasis }
109+
let existingEmphases = emphasisGroups[id, default: []].map { $0.emphasis }
99110
let newEmphases = transform(existingEmphases)
100111
replaceEmphases(newEmphases, for: id)
101112
}
102113

103114
/// Removes all emphases for the given group.
104115
/// - Parameter id: The group identifier
105116
public func removeEmphases(for id: String) {
106-
emphasisGroups[id]?.forEach { layer in
107-
layer.layer.removeAllAnimations()
108-
layer.layer.removeFromSuperlayer()
109-
layer.textLayer?.removeAllAnimations()
110-
layer.textLayer?.removeFromSuperlayer()
117+
emphasisGroups[id]?.forEach { emphasis in
118+
emphasis.removeLayers()
111119
}
112120
emphasisGroups[id] = nil
121+
122+
textView?.layer?.layoutIfNeeded()
113123
}
114124

115125
/// Removes all emphases for all groups.
116126
public func removeAllEmphases() {
117127
emphasisGroups.keys.forEach { removeEmphases(for: $0) }
118128
emphasisGroups.removeAll()
119129

120-
// Restore original selection emphasising
130+
// Restore original selection emphasizing
121131
if let originalColor = originalSelectionColor {
122132
textView?.selectionManager.selectionBackgroundColor = originalColor
123133
}
@@ -128,38 +138,44 @@ public final class EmphasisManager {
128138
/// - Parameter id: The group identifier
129139
/// - Returns: Array of emphases in the group
130140
public func getEmphases(for id: String) -> [Emphasis] {
131-
emphasisGroups[id]?.map { $0.emphasis } ?? []
141+
emphasisGroups[id, default: []].map(\.emphasis)
132142
}
133143

144+
// MARK: - Drawing Layers
145+
134146
/// Updates the positions and bounds of all emphasis layers to match the current text layout.
135147
public func updateLayerBackgrounds() {
136-
for layer in emphasisGroups.flatMap(\.value) {
137-
if let shapePath = textView?.layoutManager?.roundedPathForRange(layer.emphasis.range) {
138-
if #available(macOS 14.0, *) {
139-
layer.layer.path = shapePath.cgPath
140-
} else {
141-
layer.layer.path = shapePath.cgPathFallback
142-
}
148+
for emphasis in emphasisGroups.flatMap(\.value) {
149+
guard let shapePath = makeShapePath(
150+
forStyle: emphasis.emphasis.style,
151+
range: emphasis.emphasis.range
152+
) else {
153+
continue
154+
}
155+
if #available(macOS 14.0, *) {
156+
emphasis.layer.path = shapePath.cgPath
157+
} else {
158+
emphasis.layer.path = shapePath.cgPathFallback
159+
}
143160

144-
// Update bounds and position
145-
if let cgPath = layer.layer.path {
146-
let boundingBox = cgPath.boundingBox
147-
layer.layer.bounds = boundingBox
148-
layer.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
149-
}
161+
// Update bounds and position
162+
if let cgPath = emphasis.layer.path {
163+
let boundingBox = cgPath.boundingBox
164+
emphasis.layer.bounds = boundingBox
165+
emphasis.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
166+
}
150167

151-
// Update text layer if it exists
152-
if let textLayer = layer.textLayer {
153-
var bounds = shapePath.bounds
154-
bounds.origin.y += 1 // Move down by 1 pixel
155-
textLayer.frame = bounds
156-
}
168+
// Update text layer if it exists
169+
if let textLayer = emphasis.textLayer {
170+
var bounds = shapePath.bounds
171+
bounds.origin.y += 1 // Move down by 1 pixel
172+
textLayer.frame = bounds
157173
}
158174
}
159175
}
160176

161177
private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer {
162-
guard let shapePath = textView?.layoutManager?.roundedPathForRange(emphasis.range) else {
178+
guard let shapePath = makeShapePath(forStyle: emphasis.style, range: emphasis.range) else {
163179
return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil)
164180
}
165181

@@ -178,6 +194,25 @@ public final class EmphasisManager {
178194
return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer)
179195
}
180196

197+
private func makeShapePath(forStyle emphasisStyle: EmphasisStyle, range: NSRange) -> NSBezierPath? {
198+
switch emphasisStyle {
199+
case .standard, .outline:
200+
return textView?.layoutManager.roundedPathForRange(range, cornerRadius: emphasisStyle.shapeRadius)
201+
case .underline:
202+
guard let layoutManager = textView?.layoutManager else {
203+
return nil
204+
}
205+
let lineHeight = layoutManager.estimateLineHeight()
206+
let lineBottomPadding = (lineHeight - (lineHeight / layoutManager.lineHeightMultiplier)) / 4
207+
let path = NSBezierPath()
208+
for rect in layoutManager.rectsFor(range: range) {
209+
path.move(to: NSPoint(x: rect.minX, y: rect.maxY - lineBottomPadding))
210+
path.line(to: NSPoint(x: rect.maxX, y: rect.maxY - lineBottomPadding))
211+
}
212+
return path
213+
}
214+
}
215+
181216
private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer {
182217
let layer = CAShapeLayer()
183218

@@ -274,6 +309,8 @@ public final class EmphasisManager {
274309
return .black
275310
}
276311

312+
// MARK: - Animations
313+
277314
private func applyPopAnimation(to layer: CALayer) {
278315
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
279316
scaleAnimation.values = [1.0, 1.25, 1.0]
@@ -284,7 +321,7 @@ public final class EmphasisManager {
284321
layer.add(scaleAnimation, forKey: "popAnimation")
285322
}
286323

287-
private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) {
324+
private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?, completion: @escaping () -> Void) {
288325
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
289326
fadeAnimation.fromValue = 1.0
290327
fadeAnimation.toValue = 0.0
@@ -301,9 +338,10 @@ public final class EmphasisManager {
301338
}
302339

303340
// Remove both layers after animation completes
304-
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in
305-
layer?.removeFromSuperlayer()
341+
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) {
342+
layer.removeFromSuperlayer()
306343
textLayer?.removeFromSuperlayer()
344+
completion()
307345
}
308346
}
309347

Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,15 @@ public enum EmphasisStyle: Equatable {
2828
return false
2929
}
3030
}
31+
32+
var shapeRadius: CGFloat {
33+
switch self {
34+
case .standard:
35+
4
36+
case .underline:
37+
0
38+
case .outline:
39+
2.5
40+
}
41+
}
3142
}

0 commit comments

Comments
 (0)