Skip to content

Commit c216bea

Browse files
committed
Merge branch 'main' into fix/drag-image
2 parents bde058d + 47faec9 commit c216bea

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)