@@ -20,10 +20,17 @@ import AppKit
2020/// each outside object without knowledge of the other's state.
2121public 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+ var 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
0 commit comments