Skip to content

Commit 66fb2ed

Browse files
committed
Renamed EmphasisAPI to EmphasisManager. Separated concerns by moving match cycle logic from EmphasisManager to FindViewController. Using EmphasisManager in bracket pair matching instead of custom implementation reducing duplicated code. Implemented flash find matches when clicking the next and previous buttons when the editor is in focus. bracketPairHighlight becomes bracketPairEmphasis. Fixed various find issues and cleaned up implementation.
1 parent 30a4699 commit 66fb2ed

File tree

5 files changed

+375
-306
lines changed

5 files changed

+375
-306
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
//
2+
// EmphasisManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Tom Ludwig on 05.11.24.
6+
//
7+
8+
import AppKit
9+
10+
/// Defines the style of emphasis to apply to text ranges
11+
public enum EmphasisStyle: Equatable {
12+
/// Standard emphasis with background color
13+
case standard
14+
/// Underline emphasis with a line color
15+
case underline(color: NSColor)
16+
/// Outline emphasis with a border color
17+
case outline(color: NSColor)
18+
19+
public static func == (lhs: EmphasisStyle, rhs: EmphasisStyle) -> Bool {
20+
switch (lhs, rhs) {
21+
case (.standard, .standard):
22+
return true
23+
case (.underline(let lhsColor), .underline(let rhsColor)):
24+
return lhsColor == rhsColor
25+
case (.outline(let lhsColor), .outline(let rhsColor)):
26+
return lhsColor == rhsColor
27+
default:
28+
return false
29+
}
30+
}
31+
}
32+
33+
/// Represents a single emphasis with its properties
34+
public struct Emphasis {
35+
public let range: NSRange
36+
public let style: EmphasisStyle
37+
public let flash: Bool
38+
public let inactive: Bool
39+
public let select: Bool
40+
41+
public init(
42+
range: NSRange,
43+
style: EmphasisStyle = .standard,
44+
flash: Bool = false,
45+
inactive: Bool = false,
46+
select: Bool = false
47+
) {
48+
self.range = range
49+
self.style = style
50+
self.flash = flash
51+
self.inactive = inactive
52+
self.select = select
53+
}
54+
}
55+
56+
/// Manages text emphases within a text view, supporting multiple styles and groups.
57+
public final class EmphasisManager {
58+
/// Internal representation of a emphasis layer with its associated text layer
59+
private struct EmphasisLayer {
60+
let emphasis: Emphasis
61+
let layer: CAShapeLayer
62+
let textLayer: CATextLayer?
63+
}
64+
65+
private var emphasisGroups: [String: [EmphasisLayer]] = [:]
66+
private let activeColor: NSColor = .findHighlightColor
67+
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4)
68+
private var originalSelectionColor: NSColor?
69+
70+
weak var textView: TextView?
71+
72+
init(textView: TextView) {
73+
self.textView = textView
74+
}
75+
76+
/// Adds a single emphasis to the specified group.
77+
/// - Parameters:
78+
/// - emphasis: The emphasis to add
79+
/// - id: The group identifier
80+
public func addEmphasis(_ emphasis: Emphasis, for id: String) {
81+
addEmphases([emphasis], for: id)
82+
}
83+
84+
/// Adds multiple emphases to the specified group.
85+
/// - Parameters:
86+
/// - emphases: The emphases to add
87+
/// - id: The group identifier
88+
public func addEmphases(_ emphases: [Emphasis], for id: String) {
89+
// Store the current selection background color if not already stored
90+
if originalSelectionColor == nil {
91+
originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor
92+
}
93+
94+
let layers = emphases.map { createEmphasisLayer(for: $0) }
95+
emphasisGroups[id] = layers
96+
97+
// Handle selections
98+
handleSelections(for: emphases)
99+
100+
// Handle flash animations
101+
for (index, emphasis) in emphases.enumerated() where emphasis.flash {
102+
let layer = layers[index]
103+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
104+
self.applyFadeOutAnimation(to: layer.layer, textLayer: layer.textLayer)
105+
// Remove the emphasis from the group
106+
if var emphases = self.emphasisGroups[id] {
107+
emphases.remove(at: index)
108+
if emphases.isEmpty {
109+
self.emphasisGroups.removeValue(forKey: id)
110+
} else {
111+
self.emphasisGroups[id] = emphases
112+
}
113+
}
114+
}
115+
}
116+
}
117+
118+
/// Replaces all emphases in the specified group.
119+
/// - Parameters:
120+
/// - emphases: The new emphases
121+
/// - id: The group identifier
122+
public func replaceEmphases(_ emphases: [Emphasis], for id: String) {
123+
removeEmphases(for: id)
124+
addEmphases(emphases, for: id)
125+
}
126+
127+
/// Updates the emphases for a group by transforming the existing array.
128+
/// - Parameters:
129+
/// - id: The group identifier
130+
/// - transform: The transformation to apply to the existing emphases
131+
public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) {
132+
guard let existingLayers = emphasisGroups[id] else { return }
133+
let existingEmphases = existingLayers.map { $0.emphasis }
134+
let newEmphases = transform(existingEmphases)
135+
replaceEmphases(newEmphases, for: id)
136+
}
137+
138+
/// Removes all emphases for the given group.
139+
/// - Parameter id: The group identifier
140+
public func removeEmphases(for id: String) {
141+
emphasisGroups[id]?.forEach { layer in
142+
layer.layer.removeAllAnimations()
143+
layer.layer.removeFromSuperlayer()
144+
layer.textLayer?.removeAllAnimations()
145+
layer.textLayer?.removeFromSuperlayer()
146+
}
147+
emphasisGroups[id] = nil
148+
}
149+
150+
/// Removes all emphases for all groups.
151+
public func removeAllEmphases() {
152+
emphasisGroups.keys.forEach { removeEmphases(for: $0) }
153+
emphasisGroups.removeAll()
154+
155+
// Restore original selection emphasising
156+
if let originalColor = originalSelectionColor {
157+
textView?.selectionManager.selectionBackgroundColor = originalColor
158+
}
159+
originalSelectionColor = nil
160+
}
161+
162+
/// Gets all emphases for a given group.
163+
/// - Parameter id: The group identifier
164+
/// - Returns: Array of emphases in the group
165+
public func getEmphases(for id: String) -> [Emphasis] {
166+
emphasisGroups[id]?.map { $0.emphasis } ?? []
167+
}
168+
169+
/// Updates the positions and bounds of all emphasis layers to match the current text layout.
170+
public func updateLayerBackgrounds() {
171+
for (_, layers) in emphasisGroups {
172+
for layer in layers {
173+
if let shapePath = textView?.layoutManager?.roundedPathForRange(layer.emphasis.range) {
174+
if #available(macOS 14.0, *) {
175+
layer.layer.path = shapePath.cgPath
176+
} else {
177+
layer.layer.path = shapePath.cgPathFallback
178+
}
179+
180+
// Update bounds and position
181+
if let cgPath = layer.layer.path {
182+
let boundingBox = cgPath.boundingBox
183+
layer.layer.bounds = boundingBox
184+
layer.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
185+
}
186+
187+
// Update text layer if it exists
188+
if let textLayer = layer.textLayer {
189+
var bounds = shapePath.bounds
190+
bounds.origin.y += 1 // Move down by 1 pixel
191+
textLayer.frame = bounds
192+
}
193+
}
194+
}
195+
}
196+
}
197+
198+
private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer {
199+
guard let shapePath = textView?.layoutManager?.roundedPathForRange(emphasis.range) else {
200+
return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil)
201+
}
202+
203+
let layer = createShapeLayer(shapePath: shapePath, emphasis: emphasis)
204+
textView?.layer?.insertSublayer(layer, at: 1)
205+
206+
let textLayer = createTextLayer(for: emphasis)
207+
if let textLayer = textLayer {
208+
textView?.layer?.addSublayer(textLayer)
209+
}
210+
211+
if emphasis.inactive == false && emphasis.style == .standard {
212+
applyPopAnimation(to: layer)
213+
}
214+
215+
return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer)
216+
}
217+
218+
private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer {
219+
let layer = CAShapeLayer()
220+
221+
switch emphasis.style {
222+
case .standard:
223+
layer.cornerRadius = 4.0
224+
layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).cgColor
225+
layer.shadowColor = .black
226+
layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5
227+
layer.shadowOffset = CGSize(width: 0, height: 1.5)
228+
layer.shadowRadius = 1.5
229+
layer.opacity = 1.0
230+
layer.zPosition = emphasis.inactive ? 0 : 1
231+
case .underline(let color):
232+
layer.lineWidth = 1.0
233+
layer.lineCap = .round
234+
layer.strokeColor = color.cgColor
235+
layer.fillColor = nil
236+
layer.opacity = emphasis.flash ? 0.0 : 1.0
237+
layer.zPosition = 1
238+
case .outline(let color):
239+
layer.cornerRadius = 2.5
240+
layer.borderColor = color.cgColor
241+
layer.borderWidth = 0.5
242+
layer.fillColor = nil
243+
layer.opacity = emphasis.flash ? 0.0 : 1.0
244+
layer.zPosition = 1
245+
}
246+
247+
if #available(macOS 14.0, *) {
248+
layer.path = shapePath.cgPath
249+
} else {
250+
layer.path = shapePath.cgPathFallback
251+
}
252+
253+
// Set bounds of the layer; needed for the scale animation
254+
if let cgPath = layer.path {
255+
let boundingBox = cgPath.boundingBox
256+
layer.bounds = boundingBox
257+
layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
258+
}
259+
260+
return layer
261+
}
262+
263+
private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? {
264+
guard let textView = textView,
265+
let layoutManager = textView.layoutManager,
266+
let shapePath = layoutManager.roundedPathForRange(emphasis.range),
267+
let originalString = textView.textStorage?.attributedSubstring(from: emphasis.range) else {
268+
return nil
269+
}
270+
271+
var bounds = shapePath.bounds
272+
bounds.origin.y += 1 // Move down by 1 pixel
273+
274+
// Create text layer
275+
let textLayer = CATextLayer()
276+
textLayer.frame = bounds
277+
textLayer.backgroundColor = NSColor.clear.cgColor
278+
textLayer.contentsScale = textView.window?.screen?.backingScaleFactor ?? 2.0
279+
textLayer.allowsFontSubpixelQuantization = true
280+
textLayer.zPosition = 2
281+
282+
// Get the font from the attributed string
283+
if let font = originalString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont {
284+
textLayer.font = font
285+
} else {
286+
textLayer.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
287+
}
288+
289+
updateTextLayer(textLayer, with: originalString, emphasis: emphasis)
290+
return textLayer
291+
}
292+
293+
private func updateTextLayer(
294+
_ textLayer: CATextLayer,
295+
with originalString: NSAttributedString,
296+
emphasis: Emphasis
297+
) {
298+
let text = NSMutableAttributedString(attributedString: originalString)
299+
text.addAttribute(
300+
.foregroundColor,
301+
value: emphasis.inactive ? getInactiveTextColor() : NSColor.black,
302+
range: NSRange(location: 0, length: text.length)
303+
)
304+
textLayer.string = text
305+
}
306+
307+
private func getInactiveTextColor() -> NSColor {
308+
if textView?.effectiveAppearance.name == .darkAqua {
309+
return .white
310+
}
311+
return .black
312+
}
313+
314+
private func applyPopAnimation(to layer: CALayer) {
315+
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
316+
scaleAnimation.values = [1.0, 1.25, 1.0]
317+
scaleAnimation.keyTimes = [0, 0.3, 1]
318+
scaleAnimation.duration = 0.1
319+
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)]
320+
321+
layer.add(scaleAnimation, forKey: "popAnimation")
322+
}
323+
324+
private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) {
325+
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
326+
fadeAnimation.fromValue = 1.0
327+
fadeAnimation.toValue = 0.0
328+
fadeAnimation.duration = 0.1
329+
fadeAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
330+
fadeAnimation.fillMode = .forwards
331+
fadeAnimation.isRemovedOnCompletion = false
332+
333+
layer.add(fadeAnimation, forKey: "fadeOutAnimation")
334+
335+
if let textLayer = textLayer {
336+
if let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation {
337+
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation")
338+
}
339+
}
340+
341+
// Remove both layers after animation completes
342+
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) {
343+
layer.removeFromSuperlayer()
344+
textLayer?.removeFromSuperlayer()
345+
}
346+
}
347+
348+
/// Handles selection of text ranges for emphases where select is true
349+
private func handleSelections(for emphases: [Emphasis]) {
350+
let selectableRanges = emphases.filter(\.select).map(\.range)
351+
guard let textView, !selectableRanges.isEmpty else { return }
352+
353+
textView.selectionManager.setSelectedRanges(selectableRanges)
354+
355+
// Scroll to the first selected range
356+
if let firstRange = selectableRanges.first {
357+
textView.scrollToRange(firstRange)
358+
}
359+
360+
textView.needsDisplay = true
361+
}
362+
}

0 commit comments

Comments
 (0)